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

В языке C++ родительский класс указывается при объявлении дочернего класса. После имени дочернего класса ставится двоеточие, далее указывается спецификатор доступа, и после него указывается имя родительского класса.

class Parent
{
public:
    int val = 100;
};

class Child : public Parent
{
};

Child child;
std::cout << child.val;     // 100

 

Спецификаторы доступа

Спецификатор доступа, указываемый при наследовании, позволяет переопределить тип доступа к членам родительского класса при обращении к ним через объект дочернего класса. Таким образом, например, можно сделать открытые члены родительского класса закрытыми, для доступа через объект дочернего класса.

Возможные значения спецификаторов:

  • public - используется в большинстве случаев. При его использовании не происходит никаких изменений в доступе к членам родительского класса.
  • protected - не используется практически никогда. При его использовании открытые (public) члены родительского класса становятся защищенными (protected).
  • private - используется, когда нужно закрыть все члены родительского класса. При его использовании открытые и защищенные члены родительского класса становятся закрытыми.
class Parent
{
public:
    int val = 100;
};

class Child : private Parent
{
public:
    int getVal() { return val; }
};

Child child;

std::cout << child.val;             // Error
std::cout << child.getVal();        // OK

Спецификаторы доступа при наследовании влияют на доступ к членам родительского класса только при обращении к ним через объект дочернего класса. Доступ к членам родительского класса из методов дочернего класса не меняется и остается таким, каким он был задан в родительском классе.

Если спецификатор доступа при наследовании не указан, то считается, что он private.

 

Конструкторы и деструкторы

При наследовании дочернего класса от родительского при создании объекта дочернего класса сначала выполняется конструктор родительского класса, а потом конструктор дочернего класса.

При уничтожении объекта выполнение деструкторов происходит наоборот - сначала выполняется деструктор дочернего класса, а потом выполняется деструктор родительского класса.

class Parent
{
public:
    Parent() { std::cout << "Parent constructor" << std::endl; };
    ~Parent() { std::cout << "Parent destructor" << std::endl; };
};

class Child : public Parent
{
public:
    Child() { std::cout << "Child constructor" << std::endl; };
    ~Child() { std::cout << "Child destructor" << std::endl; };
};

Child child;

/*
Output:
  Parent constructor
  Child constructor
  Child destructor
  Parent destructor
*/

 

Инициализация родительского класса

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

class Parent
{
public:
    int val;
    Parent(int _v) : val(_v) {}
};

class Child : public Parent
{
public:
    Child(int v) : Parent(v) {}  // Конструктор родительского класса
};

Child child(10);
std::cout << child.val;   // 10

 

Переопределение методов родительского класса

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

class ParentClass
{
private:
    double val = 12.34f;
public:
    double getVal() { return val; }
    double getMultVal(double mult) { return val * mult; }
};

class ChildClass : public ParentClass
{
public:
    int val = 100;
    ChildClass(int v) : val(v) {}
    int getMultVal() { return val * 2; }
};

ChildClass child(200);
std::cout << child.val << std::endl;             // 200
std::cout << child.getVal() << std::endl;        // 12.34
std::cout << child.getMultVal() << std::endl;    // 400

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

class ChildClass : public ParentClass
{
public:
    auto getVal()
    {
        return ParentClass::getVal();
    }
};

 

Переопределение видимости

В случае, когда в дочернем классе требуется переопределить лишь спецификатор видимости метода родительского класса, используется using-объявление.

class ParentClass
{
protected:
    int val = 100;
    int getVal() { return val; }
};

class ChildClass : public ParentClass
{
public:
    using ParentClass::val;
    using ParentClass::getVal;
};

ChildClass child;
std::cout << child.val << std::endl;       // 100
std::cout << child.getVal() << std::endl;  // 100

Однако такое переопределение можно сделать только для тех членов родительского класса, которые доступны дочернему классу. Соответственно не получится сделать закрытые члены родительского класса открытыми. Тем не менее, при помощи этого метода можно закрыть открытые члены родительского класса. Также можно просто удалить ненужные члены класса, просто присвоив им значение delete.

class ParentClass
{
public:
    int val = 100;
    int getVal() { return val; }
    int getDoubleVal() { return val * 2; }
};

class ChildClass : public ParentClass
{
private:
    using ParentClass::val;       // Закрываем
    using ParentClass::getVal;    // Закрываем
public:
    int getDoubleVal() = delete;  // Удаляем
};

 

Множественное наследование

Язык C++ поддерживает возможность наследоваться от нескольких классов. Для этого они вместе со спецификаторами доступа указываются через запятую.

class ParentOne
{
public:
    ParentOne() {}
};

class ParentTwo
{
public:
    ParentTwo() {}
};

class ChildClass : public ParentOne, public ParentTwo
{
public:
    ChildClass() {}
};

Конфликты

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

class ParentOne
{
public:
    int val;
    ParentOne() {}
};

class ParentTwo
{
public:
    int val;
    ParentTwo() {}
};

class ChildClass : public ParentOne, public ParentTwo
{
public:
    ChildClass() {}
};

ChildClass child;
std::cout << child.ParentOne::val << std::endl;

 

Виртуальные функции

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

class ParentClass
{
public:
    const char* getFirstMsg() { return "First message from ParentClass"; }
    virtual const char* getSecondMsg() { return "Second message from ParentClass"; }
};

class ChildClass : public ParentClass
{
public:
    const char* getFirstMsg() { return "First message from ChildClass"; }
    virtual const char* getSecondMsg() { return "Second message from ChildClass"; }
};

ChildClass child;
ParentClass& p = child;

std::cout << child.getFirstMsg() << std::endl;   // First message from ChildClass
std::cout << p.getFirstMsg() << std::endl;       // First message from ParentClass

std::cout << child.getSecondMsg() << std::endl;  // Second message from ChildClass
std::cout << p.getSecondMsg() << std::endl;      // Second message from ChildClass

Это полезно, когда, например, требуется собрать массив объектов различных дочерних классов, имeющих общего родителя. В этом случае в массиве нельзя размещать указатели разных типов, но можно разместить указатели одного типа - указателя на родительский класс. В дальнейшем для каждого указателя в этом массиве можно вызывать один виртуальный метод, а выполняться будут различные методы соответствующих дочерних классов.

class ParentClass
{
public:
    virtual const char* getMsg() { return "Hello from ParentClass"; }
};

class ChildClass1 : public ParentClass
{
public:
    virtual const char* getMsg() { return "Hello from ChildClass1"; }
};

class ChildClass2 : public ParentClass
{
public:
    virtual const char* getMsg() { return "Hello from ChildClass2"; }
};

ChildClass1 child1;
ChildClass2 child2;
ParentClass* arr[] = { &child1, &child2 };

for (auto p : arr)
    std::cout << p->getMsg() << std::endl;

//> Hello from ChildClass1
//> Hello from ChildClass2

Важные замечания

  • Сигнатура виртуального метода дочернего класса должна полностью соответствовать сигнатуре виртуального метода родительского класса. Если у дочернего метода будет другой тип параметров, нежели у родительского, то этот метод вызываться не будет.
  • Нельзя вызывать виртуальные функции в конструкторах и деструкторах классов. Если это сделать, будут вызываться родительские методы.
  • Обработка и выполнение виртуальных функций происходит медленнее, чем обработка и выполнение обычных методов

Исключение

При использовании виртуальных функций есть одно исключение, когда сигнатура метода дочернего класса может не совпадать с сигнатурой родительского класса, но переопределение все же выполнится. Это тот случай, когда возвращаемый тип метода - это указатель на сам класс (так называемый "ковариантный тип возврата"). В этом случае родительский класс может возвращать указатель на свой класс, а дочерний класс может возвращать указатель на свой. При этом переопределение все равно произойдет.

class ParentClass
{
public:
    virtual ParentClass* getThis() { std::cout << "Parent"; return this; }
};

class ChildClass : public ParentClass
{
public:
    virtual ChildClass* getThis() { std::cout << "Child"; return this; }
};

ChildClass child;
ParentClass& p = child;
p.getThis();                // Child

Игнорирование виртуальных функций

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

class ParentClass
{
public:
    virtual const char* getMsg() { return "parent msg"; }
};

class ChildClass : public ParentClass
{
public:
    const char* getMsg() override { return "child msg"; }
};

ChildClass child;
ParentClass& p = child;
std::cout << p.ParentClass::getMsg() << std::endl;  // parent msg

 

Модификатор override

При использовании виртуальных функций часто возникают случаи ошибок из-за несовпадения сигнатур переопределяемых методов у дочернего и родительского класса. В этом случае сложно отловить ошибку, потому что она возникает только при выполнении программы. Именно для выявления таких случаев в версии 11 языка C++ добавили модификатор override.

Этот модификатор ставится после указания сигнатуры метода и контролирует возможность переопределения метода при компиляции программы. То есть, если у метода указан данный модификатор и этот метод не переопределяет никакой родительский метод, то при компиляции возникает ошибка.

class ParentClass
{
public:
    virtual const char* getMsg() const { return "Hello from ParentClass"; }
};

class ChildClass : public ParentClass
{
public:
    virtual const char* getMsg() const override { return "Hello from ChildClass"; }
};

Использование модификатора override никак не влияет на эффективность или производительность программы, но помогает избежать непреднамеренных ошибок. Следовательно, настоятельно рекомендуется использовать модификатор override для каждого из своих переопределений.

 

Модификатор final

Модификатор final используется тогда, когда требуется запретить переопределять метод родительского класса в его наследниках, либо даже запретить наследовать весь класс целиком. В первом случае модификатор ставится там же, где и модификатор override - после сигнатуры метода.

class ParentClass
{
public:
    virtual const char* getMsg() const final { return "Hello from ParentClass"; }
};

В случае, когда модификатор запрещает наследовать весь класс целиком, его ставят сразу после названия класса.

class ParentClass final
{
public:
    virtual const char* getMsg() const { return "Hello from ParentClass"; }
};

 

Виртуальные деструкторы

При использовании наследования класов, деструктор у родительского класса еобходимо указывать, как виртуальный. Иначе может получиться такая ситуация, что при уничтожении объекта через ссылку или указатель на родительский класс, деструктор дочернего класса не будет вызван, что может привести к утечке памяти и прочим ошибкам.

class ParentClass
{
public:
    virtual ~ParentClass() { std::cout << "parent destructor\n"; }
};

class ChildClass : public ParentClass
{
    int* arr;
public:
    ChildClass() { arr = new int[100]; }
    ~ChildClass() { std::cout << "child destructor\n"; delete[] arr; }
};

ParentClass* p = new ChildClass();
delete p;
//> child destructor
//> parent destructor

 

Абстрактные функции и классы

Есть случаи, когда метод не имеет смысла определять в родительском классе, а имеет смысл только в дочерних классах. В этом случае в родительском классе этот метод можно сделать абстрактным или "чистой виртуальной функцией". Для этого необходимо вместо определения метода присвоить ему значение 0.

class ParentClass
{
public:
    virtual const char* getMsg() = 0;
};

Класс, который содержит хоть одну абстрактную функцию, также называется абстрактным. Создать объект такого класса невозможно, потому что у него есть методы без определения. Возможно только создавать объекты наследников абстрактного класса. У наследников абстрактного класса все методы абстрактного класса должны быть определены, иначе эти классы также считаются абстрактными.

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

class ParentClass
{
public:
    virtual const char* getMsg() = 0;
};

const char* ParentClass::getMsg() { return "default"; }

class ChildClass : public ParentClass
{
public:
    const char* getMsg() override { return ParentClass::getMsg(); }
};

ChildClass child;
std::cout << child.getMsg() << std::endl;  // default

Если у класса нет переменных-членов и все методы класса являются абстрактными, то такой класс называется интерфейсным классом. Принято название такого класса начинать с буквы "I" (Inteface).

 

Виртуальный базовый класс

В случае множественного наследования дочернего класса D от нескольких родителей B и C, эти родительские классы в свою очередь также могут быть унаследованы от какого-то базового класса A. В этом случае при создании объекта дочернего класса D происходит конструирование нескольких копий базового класса A - по одной на каждого родителя B и C

class A { public: A() { std::cout << "A "; } };
class B : public A { public: B() { std::cout << "B "; } };
class C : public A { public: C() { std::cout << "C "; } };
class D : public B, public C { public: D() { std::cout << "D "; } };

D d;    // A B A C D

Если такое поведение нежелательно, и требуется, чтобы базовый класс создавался только один раз, то при объявлении дочерних классов при наследовании следует указать ключевое слово virtual.

class A { public: A() { std::cout << "A "; } };
class B : virtual public A { public: B() { std::cout << "B "; } };
class C : virtual public A { public: C() { std::cout << "C "; } };
class D : public B, public C { public: D() { std::cout << "D "; } };

D d;    // A B C D

В этом случае объект базового класса A создается уже не классами B и C, а классом D. То есть, если создавать классы B и C по-отдельности, то объект класса A будет создан два раза.

class A { public: A() { std::cout << "A "; } };
class B : virtual public A { public: B() { std::cout << "B "; } };
class C : virtual public A { public: C() { std::cout << "C "; } };
class D : public B, public C { public: D() { std::cout << "D "; } };

B b;    // A B
C c;    // A C

 

Приведение к родительскому типу

При присваивании объекта дочернего класса объекту родительского класса происходит приведение типа объекта дочернего класса к типу родительского класса. то есть все, что было в объекте дочернего класса убирается, и остается только та часть объекта, которая соответствует родительскому классу.

class ParentClass
{
public:
    virtual const char* getMsg() { return "Parent"; }
};

class ChildClass : public ParentClass
{
public:
    const char* getMsg() override { return "Child"; }
};

ChildClass child;
ParentClass& p = child;
ParentClass parent = child;
std::cout << p.getMsg() << std::endl;         // Child
std::cout << parent.getMsg() << std::endl;    // Parent

 

Динамическое приведение типов

Если существует объект дочернего класса и на него есть ссылка или указатель типа родительского класса, то есть возможность привести эту ссылку или указатель к типу этого дочернего класса. Для этого используется оператор динамического приведения типов dynamic_cast.

class ParentClass
{
public:
    virtual const char* getMsg() { return "ParentClass"; }
};

class ChildClass : public ParentClass
{
public:
    const char* str = "ChildClass";
};

ChildClass child;
ParentClass* ptr = &child;
ChildClass* cp = dynamic_cast<ChildClass*>(ptr);  // Динамическое приведение типа
std::cout << cp->str << std::endl;                // ChildClass

Есть случаи, когда оператор dynamic_cast не может выполнить конвертацию. Это может произойти, например, если переданный ему указатель не соответствует тому типу, в который производится конвертация. В таких случаях оператор dynamic_cast возвращает нулевой указатель в случае работы с указателями, или выбрасывает исключение std::bad_cast, в случае работы со ссылками.

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