Синтаксис си/с++

Наследование: одиночное, множественное, виртуальное

Введение

Статья 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 в самом базовом классе, что является источником путаницы для разработчиков, не до конца понимающих это взаимодействие двух разных уровней контроля доступа.