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

Добавлено 20 августа 2021 в 01:25

В последней главе, в уроке «17.9 – Множественное наследование», мы закончили разговор на «проблеме ромба» (или англоязычный термин – diamond problem). В этом разделе мы продолжим это обсуждение.

Примечание. Этот раздел представляет сложную тему, и при желании его можно пропустить или отложить на позже.

Проблема ромба

Вот наш пример из предыдущего урока (с конструкторами), иллюстрирующий проблему ромба:

class PoweredDevice
{
public:
    PoweredDevice(int power)
    {
		std::cout << "PoweredDevice: " << power << '\n';
    }
};
 
class Scanner: public PoweredDevice
{
public:
    Scanner(int scanner, int power)
        : PoweredDevice{ power }
    {
		std::cout << "Scanner: " << scanner << '\n';
    }
};
 
class Printer: public PoweredDevice
{
public:
    Printer(int printer, int power)
        : PoweredDevice{ power }
    {
		std::cout << "Printer: " << printer << '\n';
    }
};
 
class Copier: public Scanner, public Printer
{
public:
    Copier(int scanner, int printer, int power)
        : Scanner{ scanner, power }, Printer{ printer, power }
    {
    }
};

Хотя вы можете ожидать получить диаграмму наследования, которая выглядит так:

Рисунок 1 Ромбовидная структура наследования
Рисунок 1 – Ромбовидная структура наследования

Если бы вам нужно было создать объект класса Copier, по умолчанию вы получите две копии класса PoweredDevice – одну из Printer, а одну из Scanner. Диаграмма наследования будет иметь следующую структуру:

Рисунок 2 Получаемая по умолчанию структура наследования
Рисунок 2 – Получаемая по умолчанию структура наследования

Мы можем создать небольшой пример, который продемонстрирует это в действии:

int main()
{
    Copier copier{ 1, 2, 3 };
 
    return 0;
}

Этот код дает следующий результат:

PoweredDevice: 3
Scanner: 1
PoweredDevice: 3
Printer: 2

Как видите, PoweredDevice создался дважды.

Хотя это часто желательно, в других случаях может потребоваться, чтобы и частью Scanner, и частью Printer использовалась только одна копия PoweredDevice.

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

Чтобы использовать базовый класс совместно, просто вставьте ключевое слово virtual в список наследования производного класса. Это создает так называемый виртуальный базовый класс, что означает, что существует только один базовый объект. Этот базовый объект используется всеми объектами в дереве наследования и создается только один раз. Вот пример (для простоты без конструкторов), показывающий, как использовать ключевое слово virtual для создания общего базового класса:

class PoweredDevice
{
};
 
class Scanner: virtual public PoweredDevice
{
};
 
class Printer: virtual public PoweredDevice
{
};
 
class Copier: public Scanner, public Printer
{
};

Теперь, когда вы создаете объект класса Copier, вы получите только одну копию PoweredDevice в объекте Copier, которая будет использоваться как частью Scanner, так и частью Printer.

Однако это приводит к еще одной проблеме: если Scanner и Printer используют общий базовый класс PoweredDevice, кто несет ответственность за его создание? Как оказалось, ответ – Copier. За создание PoweredDevice отвечает конструктор Copier. Следовательно, это единственный раз, когда классу Copier разрешено напрямую вызывать конструктор, не являющийся непосредственно родительским:

#include <iostream>
 
class PoweredDevice
{
public:
    PoweredDevice(int power)
    {
		std::cout << "PoweredDevice: " << power << '\n';
    }
};
 
// обратите внимание: PoweredDevice теперь является виртуальным базовым классом
class Scanner: virtual public PoweredDevice 
{
public:
    Scanner(int scanner, int power)
        // эта строка требуется для создания объектов Scanner,
        // но в данном случае игнорируется
        : PoweredDevice{ power } 
    {
		std::cout << "Scanner: " << scanner << '\n';
    }
};
 
// обратите внимание: PoweredDevice теперь является виртуальным базовым классом
class Printer: virtual public PoweredDevice 
{
public:
    Printer(int printer, int power)
        // эта строка требуется для создания объектов Printer,
        // но в данном случае игнорируется
        : PoweredDevice{ power } 
    {
		std::cout << "Printer: " << printer << '\n';
    }
};
 
class Copier: public Scanner, public Printer
{
public:
    Copier(int scanner, int printer, int power)
        : PoweredDevice{ power }, // PoweredDevice создается здесь
        Scanner{ scanner, power }, Printer{ printer, power }
    {
    }
};

На этот раз наш предыдущий пример:

int main()
{
    Copier copier{ 1, 2, 3 };
 
    return 0;
}

дает результат:

PoweredDevice: 3
Scanner: 1
Printer: 2

Как видите, PoweredDevice создается только один раз.

Есть несколько деталей, которые стоит упомянуть.

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

Во-вторых, обратите внимание, что у конструкторов Scanner и Printer всё еще есть вызовы конструктора PoweredDevice. При создании экземпляра Copier эти вызовы конструктора просто игнорируются, потому что за создание PoweredDevice отвечает Copier, а не Scanner или Printer. Однако если бы мы создавали экземпляр Scanner или Printer, эти вызовы конструктора были бы использованы, и были бы применены обычные правила наследования.

В-третьих, если класс наследует один или несколько классов, имеющих виртуальных родителей, за создание виртуального базового класса отвечает наиболее производный класс. В этом случае Copier наследует Printer и Scanner, оба из которых имеют виртуальный базовый класс PoweredDevice. Copier, самый производный класс, отвечает за создание PoweredDevice. Обратите внимание, что это верно даже в случае одиночного наследования: если Copier унаследован только от Printer, а Printer был унаследован от PoweredDevice, Copier по-прежнему отвечает за создание PoweredDevice.

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

Поскольку Scanner и Printer фактически являются производными от PoweredDevice, Copier будет только с одним подобъектом PoweredDevice. И Scanner, и Printer должны знать, как найти этот единственный подобъект PoweredDevice, чтобы иметь доступ к его членам (потому что, в конце концов, они являются производными от него). Обычно это делается с помощью магии виртуальной таблицы (которая, по сути, сохраняет смещение от каждого подкласса к подобъекту PoweredDevice).

Теги

C++ / CppLearnCppvtableВиртуальная таблицаВиртуальное наследованиеВиртуальный базовый классДля начинающихМножественное наследованиеНаследованиеОбучениеПроблема ромба / Diamond problemПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.