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

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

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


    В начале поговорим, как размещаются в памяти классы, если нет виртуального наследования. Рассмотрим код:
    class Base { ... };
    class X : public Base { ... };
    class Y : public Base { ... };
    class XY : public X, public Y { ... };

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

    Объект виртуального базового класса входит в объект производного класса только один раз. Устройство объекта 'XY' для приведенного ниже кода отображена на рисунке 2.
    class Base { ... };
    class X : public virtual Base { ... };
    class Y : public virtual Base { ... };
    class XY : public X, public Y { ... };

    Рисунок 2. Виртуальное множественное наследование.
    Рисунок 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. Приведение типов.
    Рисунок 3. Приведение типов.

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


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

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

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

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

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


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

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

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

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

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


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

    Похожие публикации

    Комментарии 9

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


      Прошу прощения, можно пояснить эту мысль (желательно, с иллюстрирующим кодом)?
        +2
        class Base {
        public:
        Base &operator =(const Base &) { cout << «A» << endl; return *this; }
        };
        class X: public virtual Base { };
        class Y: public virtual Base { };
        class XY: public X, public Y { };

        XY a, b;
        a = b;

        Этот код, собранный в Visual Studio 2010, печатает две буквы 'A'. Но это не значит, что он всегда и везде будет печатать 'A' два раза. Вызов оператора = может произойти и 1 раз.
          0
          X::operator =(*this);
          Y::operator =(*this);
          

          не?
          0
          Да, я могу назвать как минимум одно: подмешивание интерфейсов.

          История из жизни :)
          Я тут недавно столкнулся с проблемой, когда пытался так «подмешивать» интерфейсы. Командой составили пару интерфейсов, в которых присутствовали методы с одинаковыми именами и сигнатурами.
          По задумке предполагалось, что эти методы могут вести себя по-разному в объекте, реализующем оба интерфейса одновременно, в зависимости от того, через какой интерфейс к нему обращаются.
          Т.е., грубо говоря, хотели реализовать вызовы вида:
          Object::Interface1.Method() и Object::Interface2.Method() с различным поведением.
          Выяснилось, что по стандарту так делать нельзя.
          Есть несколько вариантов обойти это ограничение, но наследование перестает быть чисто абстрактным («интерфейсным»).
          Или становится непереносимым, если использовать ключевое слово __interface в MSVC.
            0
            А зачем такое нужно? Правда, интересно.
              0
              Да не нужно особо :)
              Было два интерфейса Reader/Writer, у обоих методы типа Seek и т.п.
              Назывались одинаково.
              Если делать Storage с обоими интерфейсами и независимыми курсорами на чтение/запись, на стандартном C++ без бубна не обойтись.
              Мы тупо переименовали методы с совпадающими сигнатурами.
              А так я сам смотрел и на RSDN спрашивал — делаются redirect-прослойки или в случае MSVC можно использовать ключевое слово __interface вместо абстрактного класса.
            0
            Хм, не совсем понял: dynamic_cast — плохо пахнет? Согласно «чистому» подходу reinterpret_cast и static_cast тоже ось зла :)
            А вообще нужно ли упоминать об reinterpret_cast для родственных классов? По определению — его тут использовать вообще не надо.
            static_cast — конечно можно использовать для приведения от базового к дочернему, но не приветствуется. Разве не так?
            А dynamic_cast как раз и должен использоваться разных игр с иерархиями, в частности преобразования указателя/ссылки на базовый класс к указателю/ссылке на дочерний. При этом производит все необходимые проверки. (но есть нюансы).

            Поправьте, если я не прав. Если эта статья обучающая, то считаю, что учесть эти моменты было бы важно.
              0
              Да, dynamic_cast плохо пахнет. Если такая возможность есть в языке, это не значит, что ей надо пользоваться на полную. Как правило, наличие dynamic_cast, свидетельствует о плохой иерархии классов и неверных методах работы с ними. dynamic_cast это костыль. Он медленный и часто приводит к лесу if (dynamic_cast<>())… else if (dynamic_cast<>()) else if (dynamic_cast<>()). А именно от этого и должна избавлять грамотная иерархия классов с виртуальными функциями.
              См. например на эту тему: Стефан К. Дьюхерст. Скользкие места C++. Советы:
              1. Совет 98. Задание «интимных» вопросов объекту.
              2. Совет 99. Опрос возможностей.


              P.S. О том что reinterpret_cast и static_cast это хорошо, я не говорил. Я просто привел пример, что с дуру и reinterpret_cast, можно заставить компилятор сделать бессмысленное приведение типа.
                0
                Я не хотел задеть, поспорить или оскорбить. Мне сильно бросилось в глаза пункт с «кастами» (с дуру наделать можно и не такого, согласитесь), по этому и оставил заметку.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое