Множественное наследование. Порядок вызова конструкторов и деструкторов

Практический курс по ООП C++: https://stepik.org/a/205781

Смотреть материал на YouTube | RuTube

До сих пор мы с вами наследовали дочерний класс от одного базового. И это относительно частая ситуация. Однако иногда требуется выполнять наследование сразу от нескольких базовых классов. Такой подход называется множественным наследованием.

Надо сказать, что не все языки программирования предоставляют такой функционал. Например, в Java множественное наследование запрещено. Правда, там есть классы специальных видов – интерфейсы, которые несколько сглаживают это ограничение. С чем это связано? При наличии нескольких базовых классов можно легко нарушить логику работы всей этой конструкции, так как классы не редко разрабатываются разными программистами и учесть их согласованную работу становится не так то просто. Или же, один и тот же программист спустя определенное время может легко внести в один из классов такие правки, которые приведут к краху всей программы. Поэтому применять множественное наследование следует с крайней осторожностью и лучше здесь придерживаться устоявшихся подходов к построению таких конструкций.

Тем не менее, язык C++, наряду с другими ЯП, позволяет нам наследоваться сразу от нескольких классов. И в ряде случаев это бывает весьма полезной возможностью. Поэтому давайте внимательно рассмотрим этот механизм.

На предыдущих занятиях у нас с вами использовался базовый класс GeomBase и дочерний Line. Запишем их в следующем виде:

class GeomBase {
protected:
    int x0{0}, y0{0}, x1{0}, y1{0};
public:
    GeomBase(int a = 0, int b = 0, int c = 0, int d = 0)
        : x0(a), y0(b), x1(c), y1(d)
        { }
 
    void set_coords(int x0, int y0, int x1, int y1)
    {
        this->x0 = x0; this->y0 = y0;
        this->x1 = x1; this->y1 = y1;
    }
 
    virtual void draw() const = 0;
};
 
class Line : public GeomBase {
private:
    double length{0.0};
public:
    Line(int a = 0, int b = 0, int c = 0, int d = 0) : GeomBase(a, b, c, d)
        { }
 
    virtual void draw() const
        { printf("Line: %d, %d, %d, %d\n", x0, y0, x1, y1); }
};

Здесь все должно быть вам знакомо и понятно. Но допустим, что затем, мы решили разработать функционал для сохранения геометрических фигур в файл. Так как это самостоятельная задача, то ее целесообразно выделить в отдельный класс. Для простоты опишем его следующим образом (в самом начале):

#include <fstream>
 
class GeomSerialize {
protected:
    bool fl_saved {false};
public:
    virtual void save(std::ostream& os) const = 0;
    virtual void load(std::istream& is) = 0;
};

И, затем, унаследует класс Line сразу от двух классов:

class Line : public GeomBase, public GeomSerialize {
...
};

Обратите внимание, как прописано множественное наследование. Перед каждым классом указывается его режим наследования (public), а сами классы записаны через запятую.

После этого в классе Line можно переопределить виртуальные методы save и load класса GeomSerialize. В самом простом варианте это можно сделать так:

class Line : public GeomBase, public GeomSerialize {
...
public:
...
    virtual void save(std::ostream& os) const override
        { os.write((char *)this, sizeof(*this)); }
 
    virtual void load(std::istream& is) override
        { is.read((char *)this, sizeof(*this)); }
};

Если эти методы не переопределять в дочернем классе Line, то при компиляции возникла бы ошибка из-за отсутствия определения чисто виртуальных методов базового класса.

В результате мы получили следующую схему наследования классов:

Порядок вызовов конструкторов и деструкторов

Первый вопрос, который здесь возникает, в каком порядке вызываются конструкторы и деструкторы этих классов. Давайте посмотрим. Добавим в каждый класс публичный конструктор и деструктор с выводом сообщений:

class GeomSerialize {
protected:
    bool fl_saved {false};
public:
    GeomSerialize()
        { std::cout << "GeomSerialize: constructor" << std::endl; }
    virtual ~GeomSerialize()
        { std::cout << "GeomSerialize: destructor" << std::endl; }
...
};
 
class GeomBase {
...
public:
    GeomBase(int a = 0, int b = 0, int c = 0, int d = 0)
        : x0(a), y0(b), x1(c), y1(d)
        { std::cout << "GeomBase: constructor" << std::endl; }
    virtual ~GeomBase()
        { std::cout << "GeomBase: destructor" << std::endl; }
...
};
 
class Line : public GeomBase, public GeomSerialize {
...
public:
    Line(int a = 0, int b = 0, int c = 0, int d = 0) : GeomBase(a, b, c, d)
        { std::cout << "Line: constructor" << std::endl; }
    ~Line()
        { std::cout << "Line: destructor" << std::endl; }
...
};

Если теперь создать в функции main объект класса Line:

int main()
{
    Line ln(1, 2, 10, 20);
    
    return 0;
}

То в консоли увидим строчки:

GeomBase: constructor
GeomSerialize: constructor
Line: constructor
Line: destructor
GeomSerialize: destructor
GeomBase: destructor

То есть, сначала вызываются конструкторы базовых классов в порядке их указания при наследовании, затем, конструктор дочернего класса Line. А деструкторы отрабатывают в обратном порядке: сначала деструктор класса Line, затем, базовых классов GeomSerialize и GeomBase. Все вполне логично и ожидаемо.

Однако здесь есть один нюанс. Конструктор дочернего класса Line вызывает конструктор базового класса GeomBase. При этом порядок вызовов конструкторов при делегировании следует записывать в том же порядке, что и классы при наследовании. Например, если поменять порядок следования классов, то правильно было бы вызывать их конструкторы так:

class Line : public GeomSerialize, public GeomBase {
...
public:
    Line(int a = 0, int b = 0, int c = 0, int d = 0) : GeomSerialize(), GeomBase(a, b, c, d)
        { std::cout << "Line: constructor" << std::endl; }
...
};

Правда, современные компиляторы расположат вызовы конструкторов базовых классов в правильном порядке, вне зависимости от их записи при делегировании. Поэтому, можно специально не вызывать конструктор по умолчанию класса GeomSerialize:

    Line(int a = 0, int b = 0, int c = 0, int d = 0) : GeomBase(a, b, c, d)
        { std::cout << "Line: constructor" << std::endl; }

Все по-прежнему будет работать. Но, по возможности, порядок все же следует соблюдать.

Объект дочернего класса при множественном наследовании

С порядком конструирования объектов дочерних классов при множественном наследовании мы разобрались. Следующий вопрос, что из себя представляет объект класса Line?

Пусть базовые классы при наследовании записаны в порядке:

class Line : public GeomBase, public GeomSerialize { ... };

Тогда сначала создается объект класса GeomBase, а затем класса GeomSerialize. В результате объект дочернего класса будет содержать данные в следующем порядке:

Благодаря строгому порядку расположения переменных, мы можем преобразовать объект дочернего класса к указателю или ссылки любого базового. Например:

int main()
{
    Line ln(1, 2, 10, 20);
 
    GeomBase* ptr_b = &ln;
    GeomSerialize* ptr_sz = &ln;
 
    GeomBase& lnk_b = ln;
    GeomSerialize& lnk_sz = ln;
 
    return 0;
}

При этом операции приведения типов в таких случаях прописывать не обязательно, компилятор языка C++ это сделает автоматически. Соответственно, через указатель или ссылку мы можем работать только с фрагментом соответствующего базового класса. Например:

int main()
{
...
    ptr_b->draw();
    
    std::ofstream ofs("line.dat");
    lnk_sz.save(ofs);
    ofs.close();
 
    return 0;
}

На следующем занятии мы продолжим эту тему и рассмотрим некоторые проблемы и способы их решения при множественном наследовании.

Практический курс по ООП C++: https://stepik.org/a/205781

Видео по теме