Введение
Статья 521 показала простое наследование Rectangle : Shape концептуально через struct — в этой статье разберём наследование C++ систематически: одиночное (классический случай одного базового класса), множественное (несколько базовых классов одновременно) и виртуальное наследование, решающее специфичную проблему «ромбовидного» наследования.
Концепция
Наследование позволяет производному классу (derived class) переиспользовать и расширять функциональность базового класса (base class), автоматически получая все его публичные и защищённые члены — это выражает отношение «является» (is-a), и доступ к членам базового класса в производном классе зависит от уровня доступа, указанного при наследовании (public, protected, private, наиболее распространённый — public, сохраняющий исходный уровень доступа членов базового класса). Множественное наследование позволяет производному классу наследовать одновременно от нескольких базовых классов — мощная, но потенциально источник сложности возможность, особенно в случае «ромбовидного» наследования (diamond problem), когда два базовых класса сами наследуют от общего, единого предка, и производный класс, наследующий от обоих, без специальной обработки получил бы ДВЕ отдельные копии данных этого общего предка. Виртуальное наследование (class B : public virtual A) решает именно эту проблему ромбовидного наследования, гарантируя единственную, общую копию данных самого верхнего, общего предка независимо от количества промежуточных путей наследования к нему.
Пример кода: Наследование
#include <iostream>
// Одиночное наследование — классический, наиболее распространённый случай
class Animal {
public:
Animal(const std::string &name) : m_name(name) {}
void eat() { std::cout << m_name << " ест" << std::endl; }
protected:
std::string m_name; // protected — доступно в производных классах, но не снаружи иерархии
};
class Dog : public Animal {
public:
Dog(const std::string &name) : Animal(name) {} // вызов конструктора БАЗОВОГО класса
void bark() { std::cout << m_name << " лает" << std::endl; } // доступ к protected-полю базового класса
};
int main()
{
Dog myDog("Рекс");
myDog.eat(); // унаследованный метод базового класса Animal
myDog.bark(); // собственный метод производного класса Dog
return 0;
}
// Ромбовидная проблема множественного наследования и её решение через virtual
class Vehicle { public: int wheelCount = 4; };
class FlyingVehicle : public virtual Vehicle {}; // virtual — общая, единственная копия Vehicle
class WaterVehicle : public virtual Vehicle {}; // virtual — та же ОБЩАЯ копия Vehicle
class FlyingBoat : public FlyingVehicle, public WaterVehicle {
// БЕЗ virtual в базовых классах выше — было бы ДВЕ отдельные копии wheelCount,
// и доступ к wheelCount напрямую был бы НЕОДНОЗНАЧЕН (compile error без явного уточнения)
};
int main()
{
FlyingBoat boat;
std::cout << boat.wheelCount << std::endl; // благодаря virtual — однозначный доступ к ЕДИНОЙ копии
return 0;
}
Пояснения к коду
Dog : public Animal с вызовом Animal(name) в списке инициализации показывает обязательный, явный вызов конструктора базового класса — поскольку у Animal нет конструктора без параметров (только с параметром name), производный класс Dog обязан явно вызвать подходящий конструктор базового класса, передав ему нужные аргументы, иначе компиляция завершится ошибкой об отсутствии подходящего конструктора для инициализации базовой части объекта. Пример с FlyingVehicle/WaterVehicle, оба наследующие от Vehicle через virtual, показывает решение ромбовидной проблемы — без virtual в этих промежуточных классах, FlyingBoat, наследующий от обоих, физически содержал бы ДВЕ отдельные, не связанные копии данных Vehicle (одну через путь FlyingVehicle, другую через WaterVehicle), и обращение boat.wheelCount было бы неоднозначным без явного указания, через какой именно путь наследования к нему обращаться (boat.FlyingVehicle::wheelCount), тогда как с virtual гарантируется единственная, общая копия, делающая такое обращение однозначным.
Подводные камни
- Использование множественного наследования без действительно веской необходимости, когда композиция (хранение объекта другого класса как поля, вместо наследования от него) решила бы ту же задачу более просто и понятно — множественное наследование существенно усложняет иерархию классов и может привести к неочевидным проблемам (ромбовидное наследование, конфликты имён методов с одинаковой сигнатурой из разных базовых классов), и многие опытные разработчики C++ рекомендуют использовать множественное наследование осторожно, отдавая предпочтение композиции там, где отношение между классами не является действительно строгим «является» (is-a) для каждого из нескольких потенциальных базовых классов.
- Забытое использование
virtualдля базовых классов в ситуации потенциальной ромбовидной иерархии, что приводит к дублированию данных общего предка и неоднозначности доступа к ним, как явно показано в примере без virtual — эта проблема часто обнаруживается не сразу, а только когда иерархия классов реально развивается до образования ромбовидной структуры, что делает важным заблаговременное, осознанное решение о необходимости virtual-наследования при проектировании классов, потенциально способных стать частью такой иерархии в будущем. - Наследование от класса, не предназначенного для использования как базовый (например, не имеющего virtual-деструктора при необходимости полиморфного удаления, статья 532/534, или со скрытой, недокументированной логикой, не рассчитанной на расширение через наследование) — наследование от классов стандартной библиотеки (как
std::vector) или сторонних библиотек без проверки, действительно ли они спроектированы как открытые для наследования базовые классы (а не просто как «конечные», не предназначенные для расширения реализации), может привести к неожиданным, трудно диагностируемым проблемам. - Путаница между уровнем доступа наследования (
public/protected/privateперед именем базового класса) и уровнем доступа отдельных членов класса — это две разные, хотя и взаимодействующие настройки доступа, иprivate-наследование (значительно реже встречающееся на практике, чемpublic) кардинально меняет видимость унаследованных членов для кода, использующего производный класс, даже если эти члены былиpublicв самом базовом классе, что является источником путаницы для разработчиков, не до конца понимающих это взаимодействие двух разных уровней контроля доступа.