>
>
>
Не зная брода, не лезь в воду. Часть N4

Андрей Карпов
Статей: 674

Не зная брода, не лезь в воду. Часть N4

В этот раз я хочу поговорить о виртуальном наследовании в языке Си++, и почему его следует использовать очень осторожно. Предыдущие статьи: часть N1, N2, N3.

О инициализации виртуальных базовых классов

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

class Base { ... };
class X : public Base { ... };
class Y : public Base { ... };
class XY : public X, public Y { ... };

Здесь всё просто. Члены невиртуального базового класса 'Base' размещаются как простые данные-члены производного класса. В результате внутри объекта 'XY' мы имеем два независимых подобъекта 'Base'. Схематически это можно изобразить так:

Рисунок 1. Невиртуальное множественное наследование.

Объект виртуального базового класса входит в объект производного класса только один раз. Устройство объекта 'XY' для приведенного ниже кода отображена на рисунке 2.

class Base { ... };
class X : public virtual Base { ... };
class Y : public virtual Base { ... };
class XY : public X, public Y { ... };

Рисунок 2. Виртуальное множественное наследование.

Память для разделяемого подобъекта 'Base', скорее всего, будет выделена в конце объекта 'XY'. Как именно будет устроен класс, зависит от компилятора. Например, в классах 'X' и 'Y' могут храниться указатели на общий объект 'Base'. Но как я понимаю, такой метод вышел из обихода. Чаще ссылка на разделяемый подобъект реализуется в виде смещения или информации, которая хранится в таблице виртуальных функций.

Только "самый производный" класс 'XY' точно знает, где должна находиться память для подобъекта виртуального базового класса 'Base'. Поэтому инициализировать все подобъекты виртуальных базовых классов поручается самому производному классу.

Конструкторы 'XY' инициализируют подобъект 'Base' и указатели на этот объект в 'X' и 'Y'. Затем инициализируются остальные члены классов 'X', 'Y', 'XY'.

После того как подобъект 'Base' инициализируется в конструкторе 'XY', он не будет ещё раз инициализироваться конструктором 'X' или 'Y'. Как это будет сделано, зависит от компилятора. Например, компилятор может передавать специальный дополнительный аргумент в конструкторы 'X' и 'Y', который будет указывать не инициализировать класс 'Base'.

А теперь самое интересное, приводящее ко многим непониманиям и ошибкам. Рассмотрим вот такие конструкторы:

X::X(int A) : Base(A) {}
Y::Y(int A) : Base(A) {}
XY::XY() : X(3), Y(6) {}

Какое число примет конструктор базового класса в качестве аргумента? Число 3 или 6? Ни одно из них.

Конструктор 'XY' инициализирует виртуальный подобъект 'Base', но делает это неявно. Вызывается конструктор 'Base' по умолчанию.

Когда конструктор 'XY' вызывает конструктор 'X' или 'Y', он не инициализирует 'Base' заново. Поэтому явного обращения к 'Base' с каким-то аргументом не происходит.

На этом приключения с виртуальными базовыми классами не заканчиваются. Помимо конструкторов существуют операторы присваивания. Если я не ошибаюсь, стандарт говорит, что генерируемый компилятором оператор присваивания может многократно выполнять присваивание подобъекту виртуального базового класса. А может только один раз. Так что не известно, сколько раз будет происходить копирование объекта 'Base'.

Если вы реализуете свой оператор присваивания, то вы должны самостоятельно позаботься об однократном копировании объекта 'Base'. Рассмотрим неправильный код:

XY &XY::operator =(const XY &src)
{
  if (this != &src)
  {
    X::operator =(*this);
    Y::operator =(*this);
    ....
  }
  return *this;
}

Это код приведёт к двойному копированию объекта 'Base'. Чтобы этого избежать, в классах 'X' и 'Y' необходимо реализовать функции, которые не будут копировать члены класса 'Base'. Содержимое класса 'Base' копируется однократно здесь же. Исправленный код:

XY &XY::operator =(const XY &src)
{
  if (this != &src)
  {
    Base::operator =(*this);
    X::PartialAssign(*this);
    Y::PartialAssign(*this);
    ....
  }
  return *this;
}

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

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

Из-за особенностей размещения виртуальных базовых классов в памяти, нельзя выполнить вот такие приведения типов:

Base *b = Get();
XY *q = static_cast<XY *>(b); // Ошибка компиляции
XY *w = (XY *)(b); // Ошибка компиляции

Однако, настойчивый программист может всё-таки привести тип, воспользовавшись оператором 'reinterpret_cast':

XY *e = reinterpret_cast<XY *>(b);

Однако скорее всего это даст непригодный для использования результат. Адрес начала объекта 'Base' будет интерпретирован, как начало объект 'XY'. А это совсем не то, что надо. Смотри поясняющий рисунок 3.

Единственный способ выполнить приведение типа, воспользоваться оператором dynamic_cast. Однако код, где регулярно используется dynamic_cast, плохо пахнет.

Рисунок 3. Приведение типов.

Отказываться ли от виртуального наследования?

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

Виртуальное наследование порождает проблемы при инициализации и копировании объектов. Инициализацией и копирование должен заниматься "самый производный" класс. А это значит, он должен знать интимные подробности об устройстве базовых классов. Образуется лишняя связанность между классами, которая усложняет структуру проекта и заставляет делать дополнительные правки в разных классах при рефакторинге. Всё это способствует ошибкам и усложняет понимание проекта новыми разработчиками.

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

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

Польза от множественного наследования

Хорошо, критика множественного виртуального наследования и просто множественного наследования, понятна. А есть ли места, где она безопасна и удобна.

Да, я могу назвать как минимум одно: подмешивание интерфейсов. Если вам не знакома это методология, предлагаю обратиться к книге "Верёвка достаточной длины чтобы... выстрелить себе в ногу" [3].

В интерфейсном классе нет никаких данных. Все функции, как правило, чисто виртуальные. Конструктора в нем нет, или он ничего не делает. Это значит, что нет проблем с созданием или копированием таких классов.

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

Дополнительная литература

  • Стефан К. Дьюхерст. Скользкие места C++. Как избежать проблем при проектировании и компиляции ваших программ. - М.: ДМК Пресс. - 264 с.: ил. ББК 32.973.26-018.2, ISBN 978-5-94074-837-3. (См. совет под номером 45 и 53).
  • Wikipedia. Агрегирование (программирование).
  • Ален И. Голуб. "Верёвка достаточной длины чтобы... выстрелить себе в ногу". (Легко ищется в интернете. Следует смотреть раздел 101 и далее).