C++ для начинающих

18. Множественное и виртуальное наследование

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

18.1. Готовим сцену

Прежде чем детально описывать множественное и виртуальное наследование, покажем, зачем оно нужно. Наш первый пример взят из области трехмерной компьютерной графики. Но сначала познакомимся с предметной областью.

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

Процесс применения источников освещения и камеры к геометрической модели для получения двумерного изображения, отображаемого на дисплее, называется рендерингом. В алгоритме рендеринга учитываются два основных аспекта: природа источника освещения сцены и свойства материалов поверхностей объектов, такие, как цвет, шероховатость и прозрачность. Ясно, что перышки на белоснежных крыльях феи выглядят совершенно не так, как капающие из ее глаз слезы, хотя те и другие освещены одним и тем же серебристым светом.

Добавление объектов к сцене, их перемещение, игра с источниками освещения и геометрией – работа компьютерного художника. Наша задача – предоставить интерактивную поддержку для манипуляций с графом сцены на экране. Предположим, что в текущей версии своего инструмента мы решили воспользоваться каркасом приложений Open Inventor для C++ (см. [WERNECKE94]), но с помощью подтипизации расширили его, создав собственные абстракции нужных нам классов. Например, Open Inventor располагает тремя встроенными источниками освещения, производными от абстрактного базового класса SoLight:

class SoSpotLight : public SoLight { ... }
class SoPointLight : public SoLight { ... }
class SoDirectionalLight : public SoLight { ... }

Префикс So служит для того, чтобы дать уникальные имена сущностям, которые в области компьютерной графики весьма распространены (данный каркас приложений проектировался еще до появления пространств имен). Точечный источник (point light) – это источник света, излучающий, как солнце, во всех направлениях. Направленный источник (directional light) – источник света, излучающий в одном направлении. Прожектор (spotlight) – источник, испускающий узконаправленный конический пучок, как обычный театральный прожектор.

По умолчанию Open Inventor осуществляет рендеринг графа сцены на экране с помощью библиотеки OpenGL (см. [NEIDER93]). Для интерактивного отображения этого достаточно, но почти все изображения, сгенерированные для киноиндустрии, сделаны с помощью средства RenderMan (см. [UPSTILL90]). Чтобы добавить поддержку такого алгоритма рендеринга мы, в частности, должны реализовать собственные специальные подтипы источников освещения:

class RiSpotLight : public SoSpotLight { ... }
class RiPointLight : public SoPointLight { ... }
class RiDirectionalLight : public SoDirectionalLight { ... }

Новые подтипы содержат дополнительную информацию, необходимую для рендеринга с помощью RenderMan. При этом базовые классы Open Inventor по-прежнему позволяют выполнять рендеринг с помощью OpenGL. Неприятности начинаются, когда возникает необходимость расширить поддержку теней.

В RenderMan направленный источник и прожектор поддерживают отбрасывание тени (поэтому мы называем их источниками освещения, дающими тень, – SCLS), а точечный – нет. Общий алгоритм требует, чтобы мы обошли все источники освещения на сцене и составили карту теней для каждого включенного SCLS. Проблема в том, что источники освещения хранятся в графе сцены как полиморфные объекты класса SoLight. Хотя мы можем инкапсулировать общие данные и необходимые операции в класс SCLS, непонятно, как включить его в существующую иерархию классов Open Inventor.

В поддереве с корнем SoLight в иерархии Open Inventor нет такого класса, из которого можно было бы произвести с помощью одиночного наследования класс SCLS так, чтобы в дальнейшем уже от него произвести SdRiSpotLight и SdRiDirectionalLight. Если не пользоваться множественным наследованием, лучшее, что можно сделать, – это сравнить член класса SCLS с каждым возможным типом SCLS-источника и вызвать соответствующую операцию:

SoLight *plight = next_scene_light();

if ( RiDirectionalLight *pdilite =
     dynamic_cast<RiDirectionalLight*>( plight ))
         pdilite->scls.cast_shadow_map();
else
if ( RiSpotLight *pslite =
     dynamic_cast<RiSpotLight*>( plight ))
         pslite->scls.cast_shadow_map();
// и так далее

(Оператор dynamic_cast – это часть механизма идентификации типов во время выполнения (RTTI). Он позволяет опросить тип объекта, адресованного полиморфным указателем или ссылкой. Подробно RTTI будет обсуждаться в главе 19.)

Пользуясь множественным наследованием, мы можем инкапсулировать подтипы SCLS, защитив наш код от изменений при добавлении или удалении источника освещения (см. рис. 18.1).

RiDirectionalLight :
      public SoDirectionalLight, public SCLS { ... };

class RiSpotLight :
      public SoSpotLight, public SCLS { ... };

// ...
SoLight *plight = next_scene_light();
if ( SCLS *pscls = dynamic_cast<SCLS*>(plight))
   pscls->cast_shadow_map();

Это решение несовершенно. Если бы у нас был доступ к исходным текстам Open Inventor, то можно было бы избежать множественного наследования, добавив к SoLight член-указатель на SCLS и поддержку операции cast_shadow_map():

class SoLight : public SoNode {
public:
   void cast_shadow_map()
        { if ( _scls ) _scls->cast_shadow_map(); }
   // ...
protected:
   SCLS *_scls;
};

// ...

SdSoLight *plight = next_scene_light();
plight-> cast_shadow_map();

Самое распространенное приложение, где используется множественное (и виртуальное) наследование, – это потоковая библиотека ввода/вывода в стандартном C++. Два основных видимых пользователю класса этой библиотеки – istream (для ввода) и ostream (для вывода). В число их общих атрибутов входят:

  • информация о форматировании (представляется ли целое число в десятичной, восьмеричной или шестнадцатеричной системе счисления, число с плавающей точкой – в нотации с фиксированной точкой или в научной нотации и т.д.);
  • информация о состоянии (находится ли потоковый объект в нормальном или ошибочном состоянии и т.д.);
  • информация о параметрах локализации (отображается ли в начале даты день или месяц и т.д.);
  • буфер, где хранятся данные, которые нужно прочитать или записать.

Эти общие атрибуты вынесены в абстрактный базовый класс ios, для которого istream и ostream являются производными.

Класс iostream – наш второй пример множественного наследования. Он предоставляет поддержку для чтения и записи в один и тот же файл; его предками являются классы istream и ostream. К сожалению, по умолчанию он также унаследует два различных экземпляра базового класса ios, а нам это не нужно.

Виртуальное наследование решает проблему наследования нескольких экземпляров базового класса, когда нужен только один разделяемый экземпляр. Упрощенная иерархия iostream изображена на рис. 18.2.

Рис. 18.2. Иерархия виртуального наследования iostream (упрощенная)

Еще один реальный пример виртуального и множественного наследования дают распределенные объектные вычисления. Подробное рассмотрение этой темы см. в серии статей Дугласа Шмидта (Douglas Schmidt) и Стива Виноски (Steve Vinoski) в [LIPPMAN96b].

В данной главе мы рассмотрим использование и поведение механизмов виртуального и множественного наследования. В другой нашей книге, "Inside the C++ Object Model", описаны более сложные вопросы производительности и дизайна этого аспекта языка.

Для последующего обсуждения мы выбрали иерархию животных в зоопарке. Наши животные существуют на разных уровнях абстракции. Есть, конечно, особи, имеющие свои имена: Линь-Линь, Маугли или Балу. Каждое животное принадлежит к какому-то виду; скажем, Линь-Линь – это гигантская панда. Виды в свою очередь входят в семейства. Так, гигантская панда – член семейства медведей, хотя, как мы увидим в разделе 18.5, по этому поводу в зоологии долго велись бурные дискуссии. Каждое семейство – член животного мира, в нашем случае ограниченного территорией зоопарка.

На каждом уровне абстракции имеются данные и операции, необходимые для поддержки все более и более широкого круга пользователей. Например, абстрактный класс ZooAnimal хранит информацию, общую для всех животных в зоопарке, и предоставляет открытый интерфейс для всех возможных запросов.

Помимо классов, описывающих животных, есть и вспомогательные классы, инкапсулирующие различные абстракции иного рода, например "животные, находящиеся под угрозой вымирания". Наша реализация класса Panda множественно наследует от Bear (медведь) и Endangered (вымирающие).

18.2. Множественное наследование

Для поддержки множественного наследования синтаксис списка базовых классов

class Bear : public ZooAnimal { ... };

расширяется: допускается наличие нескольких базовых классов, разделенных запятыми:

class Panda : public Bear, public Endangered { ... };

Для каждого из перечисленных базовых классов должен быть указан уровень доступа: public, protected или private. Как и при одиночном наследовании, множественно наследовать можно только классу, определение которого уже встречалось ранее.

Язык не накладывает никаких ограничений на число базовых классов, которым может наследовать производный. На практике чаще всего встречается два класса, один из которых представляет открытый абстрактный интерфейс, а второй – закрытую реализацию (хотя ни один из рассмотренных выше примеров этой модели не следует). Производные классы, наследующие от трех или более базовых, – это пример такого стиля проектирования, когда каждый базовый класс представляет одну грань полного интерфейса производного.

В случае множественного наследования объект производного класса содержит по одному подобъекту каждого из своих базовых (см. раздел 17.3). Например, когда мы пишем

Panda ying_yang;

то объект ying_yang будет состоять из подобъекта класса Bear (который в свою очередь содержит подобъект ZooAnimal), подобъекта Endangered и нестатических членов, объявленных в самом классе Panda, если таковые есть (см. рис. 18.3).

Рис. 18.3. Иерархия множественного наследования класса Panda

Конструкторы базовых классов вызываются в порядке объявления в списке базовых классов. Например, для ying_yang эта последовательность такова: конструктор Bear (но поскольку класс Bear – производный от ZooAnimal, то сначала вызывается конструктор ZooAnimal), затем конструктор Endangered и в самом конце конструктор Panda.

Как отмечалось в разделе 17.4, на порядок вызова не влияет ни наличие базовых классов в списке инициализации членов, ни порядок их перечисления. Иными словами, если бы конструктор Bear вызывался неявно и потому не был бы упомянут в списке инициализации членов, как в следующем примере:

// конструктор по умолчанию класса Bear вызывается до
// конструктора класса Endangered с двумя аргументами ...
Panda::Panda()
     : Endangered( Endangered::environment,
                   Endangered::critical )
{ ... }

то все равно конструктор по умолчанию Bear был бы вызван раньше, чем явно заданный в списке конструктор класса Endangered с двумя аргументами.

Порядок вызова деструкторов всегда противоположен порядку вызова конструкторов. В нашем примере деструкторы вызываются в такой последовательности: ~Panda(), ~Endangered(), ~Bear(), ~ZooAnimal().

В разделе 17.3 уже говорилось, что в случае одиночного наследования к открытым и защищенным членам базового класса можно обращаться напрямую (не квалифицируя имя члена именем его класса), как если бы они были членами производного класса. То же самое справедливо и для множественного наследования. Однако при этом можно унаследовать одноименные члены из двух или более базовых классов. В таком случае прямое обращение оказывается неоднозначным и приводит к ошибке компиляции.

Однако такую ошибку вызывает не потенциальная неоднозначность неквалифицированного доступа к одному из двух одноименных членов, а лишь попытка фактического обращения к нему (см. раздел 17.4). Например, если в обоих классах Bear и Endangered определена функция-член print(), то инструкция

ying_yang.print( cout );

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

Error: ying_yang.print( cout ) -- ambiguous, one of
          Bear::print( ostream& )
          Endangered::print( ostream&, int )
Ошибка: ying_yang.print( cout ) -- неоднозначно, одна из
          Bear::print( ostream& )
          Endangered::print( ostream&, int )

Причина в том, что унаследованные функции-члены не образуют множество перегруженных функций внутри производного класса (см. раздел 17.3). Поэтому print() разрешается только по имени, а не по типам фактических аргументов. (О том, как производится разрешение, мы поговорим в разделе 18.4.)

В случае одиночного наследования указатель, ссылка или объект производного класса при необходимости автоматически преобразуются в указатель, ссылку или объект базового класса, которому открыто наследует производный. Это остается верным и для множественного наследования. Так, указатель, ссылку или сам объект класса Panda можно преобразовать в указатель, ссылку или объект ZooAnimal, Bear или Endangered:

extern void display( const Bear& );
extern void highlight( const Endangered& );
Panda ying_yang;
display( ying_yang );   // i?aaeeuii
highlight( ying_yang ); // i?aaeeuii
extern ostream&
       operator<<( ostream&, const ZooAnimal& );

cout << ying_yang << endl;   // правильно

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

extern void display( const Bear& );
extern void display( const Endangered& );

Неквалифицированный вызов display() для объекта класса Panda

Panda ying_yang;
display( ying_yang );   // ошибка: неоднозначность
приводит к ошибке компиляции:
Error: display( ying_yang ) -- ambiguous, one of
          display( const Bear& );
          display( const Endangered& );
Ошибка: display( ying_yang ) -- неоднозначно, одна из
          display( const Bear& );
          display( const Endangered& );

Компилятор не может различить два непосредственных базовых класса с точки зрения преобразования производного. Равным образом применимы обе трансформации. (Мы покажем способ разрешения этого конфликта в разделе 18.4.)

Чтобы понять, какое влияние оказывает множественное наследование на механизм виртуальных функций, определим их набор в каждом из непосредственных базовых классов Panda. (Виртуальные функции введены в разделе 17.2 и подробно обсуждались в разделе 17.5.)

class Bear : public ZooAnimal {
public:
   virtual ~Bear();
   virtual ostream& print( ostream& ) const;
   virtual string isA() const;
   // ...
};

class Endangered {
public:
   virtual ~Endangered();
   virtual ostream& print( ostream& ) const;
   virtual void highlight() const;
   // ...
};

Теперь определим в классе Panda собственный экземпляр print(), собственный деструктор и еще одну виртуальную функцию cuddle():

class Panda : public Bear, public Endangered
{
public:
   virtual ~Panda();
   virtual ostream& print( ostream& ) const;
   virtual void cuddle();
   // ...
};

Множество виртуальных функций, которые можно напрямую вызывать для объекта Panda, представлено в табл. 18.1.

Таблица 18.1. Виртуальные функции для класса Panda
Имя виртуальной функцииАктивный экземпляр
деструкторPanda::~Panda()
print(ostream&) constPanda::print(ostream&)
isA() constBear::isA()
highlight() constEndangered::highlight()
cuddle()Panda::cuddle()

Когда ссылка или указатель на объект Bear или ZooAnimal инициализируется адресом объекта Panda или ему присваивается такой адрес, то части интерфейса, связанные с классами Panda и Endangered, становятся недоступны:

Bear *pb = new Panda;

pb->print( cout );      // i?aaeeuii: Panda::print(ostream&)
pb->isA();              // i?aaeeuii: Bear::isA()
pb->cuddle();           // ioeaea: yoi ia ?anou eioa?oaena Bear
pb->highlight();        // ioeaea: yoi ia ?anou eioa?oaena Bear
delete pb;              // правильно: Panda::~Panda()

(Обратите внимание, что если бы объекту класса Panda был присвоен указатель на ZooAnimal, то все показанные выше вызовы разрешались бы так же.)

Аналогично, если ссылка или указатель на объект Endangered инициализируется адресом объекта Panda или ему присваивается такой адрес, то части интерфейса, связанные с классами Panda и Bear, становятся недоступными:

Endangered *pe = new Panda;

pe->print( cout );  // правильно: Panda::print(ostream&)

// ioeaea: yoi ia ?anou eioa?oaena Endangered
pe->cuddle();

pe->highlight();        // правильно: Endangered::highlight()
delete pe;              // правильно: Panda::~Panda()

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

// ZooAnimal *pz = new Panda;
delete pz;
// Bear *pb = new Panda;
delete pb;
// Panda *pp = new Panda;
delete pp;
// Endangered *pe = new Panda;
delete pe;

Деструктор класса Panda вызывается с помощью механизма виртуализации. После его выполнения по очереди статически вызываются деструкторы Endangered и Bear, а в самом конце – ZooAnimal.

Почленная инициализация и присваивание объекту производного класса, наследующего нескольким базовым, ведут себя точно так же, как и при одиночном наследовании (см. раздел 17.6). Например, для нашего объявления класса Panda

class Panda : public Bear, public Endangered
{ ... };

в результате почленной инициализации объекта ling_ling

Panda yin_yang;
Panda ling_ling = yin_yang;

вызывается копирующий конструктор класса Bear (но, так как Bear производный от ZooAnimal, сначала выполняется копирующий конструктор класса ZooAnimal), затем – класса Endangered и только потом – класса Panda. Почленное присваивание ведет себя аналогично.

Упражнение 18.1

Какие из следующих объявлений ошибочны? Почему?

(a) class CADVehicle : public CAD, Vehicle { ... };
(b) class DoublyLinkedList:
          public List, public List { ... };
(c) class iostream:
          private istream, private ostream { ... };

Упражнение 18.2

Дана иерархия, в каждом классе которой определен конструктор по умолчанию:

class A { ... };
class B : public A { ... };
class C : public B { ... };
class X { ... };
class Y { ... };
class Z : public X, public Y { ... };
class MI : public C, public Z { ... };

Каков порядок вызова конструкторов в таком определении:

MI mi;

Упражнение 18.3

Дана иерархия, в каждом классе которой определен конструктор по умолчанию:

class X { ... };
class A { ... };
class B : public A { ... };
class C : private B { ... };
class D : public X, public C { ... };

Какие из следующих преобразований недопустимы:

D *pd = new D;
(a) X *px = pd;   (c) B *pb = pd;
(b) A *pa = pd;   (d) C *pc = pd;

Упражнение 18.4

Дана иерархия классов, обладающая приведенным ниже набором виртуальных функций:

class Base {
public:
   virtual ~Base();
   virtual ostream& print();
   virtual void debug();
   virtual void readOn();
   virtual void writeOn();
   // ...
};

class Derived1 : virtual public Base {
public:
   virtual ~Derived1();
   virtual void writeOn();
   // ...
};

class Derived2 : virtual public Base {
public:
   virtual ~Derived2();
   virtual void readOn();
   // ...
};

class MI : public Derived1, public Derived2 {
public:
   virtual ~MI();
   virtual ostream& print();
   virtual void debug();
   // ...
};

Какой экземпляр виртуальной функции вызывается в каждом из следующих случаев:

Base *pb = new MI;

(a) pb->print();   (c) pb->readOn();   (e) pb->log();
(b) pb->debug();   (d) pb->writeOn();  (f) delete pb;

Упражнение 18.5

На примере иерархии классов из упражнения 18.4 определите, какие виртуальные функции активны при вызове через pd1 и pd2:

(a) Derived1 *pd1 new MI;
(b) MI obj;
    Derived2 d2 = obj;

18.3. Открытое, закрытое и защищенное наследование

Открытое наследование называется еще наследованием типа. Производный класс в этом случае является подтипом базового; он замещает реализации всех функций-членов, специфичных для типа базового класса, и наследует общие для типа и подтипа функции. Можно сказать, что производный класс служит примером отношения "ЯВЛЯЕТСЯ", т.е. предоставляет специализацию более общего базового класса. Медведь (Bear) является животным из зоопарка (ZooAnimal); аудиокнига (AudioBook) является предметом, выдаваемым читателям (LibraryLendingMaterial). Мы говорим, что Bear – это подтип ZooAnimal, равно как и Panda. Аналогично AudioBook – подтип LibBook (библиотечная книга), а оба они – подтипы LibraryLendingMaterial. В любом месте программы, где ожидается базовый тип, можно вместо него подставить открыто унаследованный от него подтип, и программа будет продолжать работать правильно (при условии, конечно, что подтип реализован корректно). Во всех приведенных выше примерах демонстрировалось именно наследование типа.

Закрытое наследование называют также наследованием реализации. Производный класс напрямую не поддерживает открытый интерфейс базового, но пользуется его реализацией, предоставляя свой собственный открытый интерфейс.

Чтобы показать, какие здесь возникают вопросы, реализуем класс PeekbackStack, который поддерживает выборку из стека с помощью метода peekback():

bool
PeekbackStack::
peekback( int index, type &value ) { ... }

где value содержит элемент в позиции index, если peekback() вернула true. Если же peekback() возвращает false, то заданная аргументом index позиция некорректна и в value помещается элемент из вершины стека.

В реализации PeekbackStack возможны два типа ошибок:

  • реализация абстракции PeekbackStack: некорректная реализация поведения класса;
  • реализация представления данных: неправильное управление выделением и освобождением памяти, копированием объектов из стека и т.п.

Обычно стек реализуется либо как массив, либо как связанный список элементов (в стандартной библиотеке по умолчанию это делается на базе двусторонней очереди, хотя вместо нее можно использовать вектор, см. главу 6). Хотелось бы иметь гарантированно правильную (или, по крайней мере, хорошо протестированную и поддерживаемую) реализацию массива или списка, чтобы использовать ее в нашем классе PeekbackStack. Если она есть, то можно сосредоточиться на правильности поведения стека.

У нас есть класс IntArray, представленный в разделе 2.3 (мы временно откажемся от применения класса deque из стандартной библиотеки и от поддержки элементов, имеющих отличный от int тип). Вопрос, таким образом, заключается в том, как лучше всего воспользоваться классом IntArray в нашей реализации PeekbackStack. Можно задействовать механизм наследования. (Отметим, что для этого нам придется модифицировать IntArray, сделав его члены защищенными, а не закрытыми.) Реализация выглядела бы так:

#include "IntArray.h"
class PeekbackStack : public IntArray {
private:
	const int static bos = -1;
public:
	explicit PeekbackStack( int size )
	       : IntArray( size ), _top( bos ) {}
	bool empty() const { return _top == bos; }
	bool full()  const { return _top == size()-1; }
	int  top()   const { return _top; }
	int pop() {
 		if ( empty() )
              /* ia?aaioaou ioeaeo */ ;
 		return _ia[ _top-- ];
	}
	void push( int value ) {
 		if ( full() )
              /* ia?aaioaou ioeaeo */ ;
 		_ia[ ++_top ] = value;
	}
	bool peekback( int index, int &value ) const;

private:
	 int _top;
};

inline bool
PeekbackStack::
peekback( int index, int &value ) const
{
	if ( empty() )
        /* ia?aaioaou ioeaeo */ ;

 	if ( index < 0 || index > _top )
     {
  		value = _ia[ _top ];
  		return false;
	}

	value = _ia[ index ];
 	return true;
}

К сожалению, программа, которая работает с нашим новым классом PeekbackStack, может неправильно использовать открытый интерфейс базового IntArray:

extern void swap( IntArray&, int, int );
PeekbackStack is( 1024 );
// iai?aaaeaaiiia ioeai?iia eniieuciaaiea PeekbackStack
swap(is, i, j);
is.sort();
is[0] = is[512];

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

Проблема в том, что открытое наследование описывается как отношение "ЯВЛЯЕТСЯ". Но PeekbackStack не является разновидностью массива IntArray, а лишь включает его как часть своей реализации. Открытый интерфейс IntArray не должен входить в открытый интерфейс PeekbackStack.

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

В приведенном ранее определении PeekbackStack достаточно заменить слово public в списке базовых классов на private. Внутри же самого определения класса public и private следует оставить на своих местах:

class PeekbackStack : private IntArray { ... };

18.3.1. Наследование и композиция

Реализация класса PeekbackStack с помощью закрытого наследования от IntArray работает, но необходимо ли это? Помогло ли нам наследование в данном случае? Нет.

Открытое наследование – это мощный механизм для поддержки отношения "ЯВЛЯЕТСЯ". Однако реализация PeekbackStack по отношению к IntArray – пример отношения "СОДЕРЖИТ". Класс PeekbackStack содержит класс IntArray как часть своей реализации. Отношение "СОДЕРЖИТ", как правило, лучше поддерживается с помощью композиции, а не наследования. Для ее реализации надо один класс сделать членом другого. В нашем случае объект IntArray делается членом PeekbackStack. Вот реализация PeekbackStack на основе композиции:

class PeekbackStack {
private:
	const int static bos = -1;

public:
	explicit PeekbackStack( int size ) :
	stack( size ), _top( bos ) {}

	bool empty() const { return _top == bos; }
	bool full()  const { return _top == size()-1; }
	int  top()   const { return _top; }

	int pop() {
 		if ( empty() )
              /* обработать ошибку */ ;
 		return stack[ _top-- ];
	}

	void push( int value ) {
 		if ( full() )
              /* обработать ошибку */ ;
 		stack[ ++_top ] = value;
	}
	bool peekback( int index, int &value ) const;

private:
	 int _top;
      IntArray stack;
};
inline bool
PeekbackStack::
peekback( int index, int &value ) const
{
	if ( empty() )
        /* обработать ошибку */ ;

 	if ( index < 0 || index > _top )
     {
  		value = stack[ _top ];
  		return false;
	}

	value = stack[ index ];
 	return true;
}

Решая, следует ли использовать при проектировании класса с отношением "СОДЕРЖИТ" композицию или закрытое наследование, можно руководствоваться такими соображениями:

  • если мы хотим заместить какие-либо виртуальные функции базового класса, то должны закрыто наследовать ему;
  • если мы хотим разрешить нашему классу ссылаться на класс из иерархии типов, то должны использовать композицию по ссылке (мы подробно расскажем о ней в разделе 18.3.4);
  • если, как в случае с классом PeekbackStack, мы хотим воспользоваться готовой реализацией, то композиция по значению предпочтительнее наследования. Если требуется отложенное выделение памяти для объекта, то следует выбрать композицию по ссылке (с помощью указателя).

18.3.2. Открытие отдельных членов

Когда мы применили закрытое наследование класса PeekbackStack от IntArray, то все защищенные и открытые члены IntArray стали закрытыми членами PeekbackStack. Было бы полезно, если бы пользователи PeekbackStack могли узнать размер стека с помощью такой инструкции:

is.size();

Разработчик способен оградить некоторые члены базового класса от эффектов неоткрытого наследования. Вот как, к примеру, открывается функция-член size() класса IntArray:

class PeekbackStack : private IntArray {
public:
   // сохранить открытый уровень доступа
   using IntArray::size;
   // ...
};

Еще одна причина для открытия отдельных членов заключается в том, что иногда необходимо разрешить доступ к защищенным членам закрыто унаследованного базового класса при последующем наследовании. Предположим, что пользователям нужен подтип стека PeekbackStack, который может динамически расти. Для этого классу, производному от PeekbackStack, понадобится доступ к защищенным элементам ia и _size класса IntArray:

template <class Type>
class PeekbackStack : private IntArray {
public:
   using intArray::size;
   // ...

protected:
   using intArray::size;
   using intArray::ia;
   // ...
};

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

На практике множественное наследование очень часто применяется для того, чтобы унаследовать открытый интерфейс одного класса и закрытую реализацию другого. Например, в библиотеку классов Booch Components включена следующая реализация растущей очереди Queue (см. также статью Майкла Вило (Michaeel Vilot) и Грейди Буча (Grady Booch) в [LIPPMAN96b]):

template < class item, class container >
class Unbounded_Queue:
      private Simple_List<item >,   // ?aaeecaoey
      public  Queue< item >         // eioa?oaen
{ ... }

18.3.3. Защищенное наследование

Третья форма наследования – это защищенное наследование. В таком случае все открытые члены базового класса становятся в производном классе защищенными, т.е. доступными из его дальнейших наследников, но не из любого места программы вне иерархии классов. Например, если бы нужно было унаследовать PeekbackStack от Stack, то закрытое наследование

// увы: при этом не поддерживается дальнейшее наследование
// PeekbackStack: все члены IntArray теперь закрыты
class Stack : private IntArray { ... }

было бы чересчур ограничительным, поскольку закрытие членов IntArray в классе Stack делает невозможным их последующее наследование. Для того чтобы поддержать наследование вида:

class PeekbackStack : public Stack { ... };
класс Stack должен наследовать IntArray защищенно:
class Stack : protected IntArray { ... };

18.3.4. Композиция объектов

Есть две формы композиции объектов:

  • композиция по значению, когда членом одного класса объявляется сам объект другого класса. Мы показывали это в исправленной реализации PeekbackStack;
  • композиция по ссылке, когда членом одного класса является указатель или ссылка на объект другого класса.

Композиция по значению обеспечивает автоматическое управление временем жизни объекта и семантику копирования. Кроме того, прямой доступ к объекту оказывается более эффективным. А в каких случаях следует предпочесть композицию по ссылке?

Предположим, что мы решили с помощью композиции представить класс Endangered. Надо ли определить его объект непосредственно внутри ZooAnimal или сослаться на него с помощью указателя или ссылки? Сначала выясним, все ли объекты ZooAnimal обладают этой характеристикой, а если нет, то может ли она изменяться с течением времени (допустимо ли добавлять или удалять эту характеристику).

Если ответ на первый вопрос положительный, то, как правило, лучше применить композицию по значению. (Как правило, но не всегда, поскольку с точки зрения эффективности включение больших объектов не оптимально, особенно когда они часто копируются. В таких случаях композиция по ссылке позволит обойтись без ненужных копирований, если применять при этом подсчет ссылок и технику, называемую копированием при записи. Увеличение эффективности, правда, достигается за счет усложнения управления объектом. Обсуждение этой техники не вошло в наш вводный курс; тем, кому это интересно, рекомендуем прочитать книгу [KOENIG97], главы 6 и 7.)

Если же оказывается, что только некоторые объекты класса ZooAnimal обладают указанной характеристикой, то лучшим вариантом будет композиция по ссылке (скажем, в примере с зоопарком не имеет смысла включать в процветающие виды большой объект, описывающий виды вымирающие).

Поскольку объекта Endangered может и не существовать, то представлять его надо указателем, а не ссылкой. (Предполагается, что нулевой указатель не адресует объект. Ссылка же всегда должна именовать определенный объект. В разделе 3.6 это различие объяснялось более подробно.)

Если ответ на второй вопрос положительный, то необходимо задать функции, позволяющие вставить и удалить объект Endangered во время выполнения.

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

class ZooAnimal {
public:
   // ...
   const Endangered* Endangered() const;
   void addEndangered( Endangered* );
   void removeEndangered();
   // ...
protected:
   Endangered *_endangered;
   // ...
};

Если предполагается, что наше приложение будет работать на разных платформах, то полезно инкапсулировать всю платформенно-зависимую информацию в иерархию абстрактных классов, чтобы запрограммировать платформенно-независимый интерфейс. Например, для вывода объекта ZooAnimal на дисплей UNIX-машины и ПК, можно определить иерархию классов DisplayManager:

class DisplayManager { ... };
class DisplayUNIX : public DisplayManager { ... };
class DisplayPC : public DisplayManager { ... };

Наш класс ZooAnimal не является разновидностью класса DisplayManager, но содержит экземпляр последнего посредством композиции, а не наследования. Возникает вопрос: использовать композицию по значению или по ссылке?

Композиция по значению не может представить объект DisplayManager, с помощью которого можно будет адресовать либо объект DisplayUNIX, либо объект DisplayPC. Только ссылка или указатель на объект DisplayManager позволят нам полиморфно манипулировать его подтипами. Иначе говоря, объектно-ориентированное программирование поддерживается только композицией по ссылке (подробнее см. [LIPPMAN96a].)

Теперь нужно решить, должен ли член класса ZooAnimal быть ссылкой или указателем на DisplayManager:

  • член может быть объявлен ссылкой лишь в том случае, если при создании объекта ZooAnimal имеется реальный объект DisplayManager, который не будет изменяться по ходу выполнения программы;
  • если применяется стратегия отложенного выделения памяти, когда память для объекта DisplayManager выделяется только при попытке вывести объект на дисплей, то объект следует представить указателем, инициализировав его значением 0;
  • если мы хотим переключать режим вывода во время выполнения, то тоже должны представить объект указателем, который инициализирован нулем. Под переключением мы понимаем предоставление пользователю возможности выбрать один из подтипов DisplayManager в начале или в середине работы программы.

Конечно, маловероятно, что для каждого подобъекта ZooAnimal в нашем приложении будет нужен собственный подтип DisplayManager для отображения. Скорее всего мы ограничимся статическим членом в классе ZooAnimal, указывающим на объект DisplayManager.

Упражнение 18.6

Объясните, в каких случаях имеет место наследование типа, а в каких – наследование реализации:

(a) Queue : List              // очередь : список
(b) EncryptedString : String  // зашифрованная строка : строка
(c) Gif : FileFormat
(d) Circle : Point            // окружность : точка
(e) Dqueue : Queue, List
(f) DrawableGeom : Geom, Canvas // рисуемая фигура : фигура, холст

Упражнение 18.7

Замените член IntArray в реализации PeekbackStack (см. раздел 18.3.1) на класс deque из стандартной библиотеки. Напишите небольшую программу для тестирования.

Упражнение 18.8

Сравните композицию по ссылке с композицией по значению, приведите примеры их использования.

18.4. Область видимости класса и наследование

У каждого класса есть собственная область видимости, в которой определены имена членов и вложенные типы (см. разделы 13.9 и 13.10). При наследовании область видимости производного класса вкладывается в область видимости непосредственного базового. Если имя не удается разрешить в области видимости производного класса, то поиск определения продолжается в области видимости базового.

Именно эта иерархическая вложенность областей видимости классов при наследовании и делает возможным обращение к именам членов базового класса так, как если бы они были членами производного. Рассмотрим сначала несколько примеров одиночного наследования, а затем перейдем к множественному. Предположим, есть упрощенное определение класса ZooAnimal:

class ZooAnimal {
public:
   ostream &print( ostream& ) const;

   // сделаны открытыми только ради демонстрации разных случаев
   string is_a;
   int    ival;
private:
   double dval;
};
и упрощенное определение производного класса Bear:
class Bear : public ZooAnimal {
public:
   ostream &print( ostream& ) const;

   // сделаны открытыми только ради демонстрации разных случаев
   string name;
   int    ival;
};

Когда мы пишем:

Bear bear;
bear.is_a;

то имя разрешается следующим образом:

  • bear – это объект класса Bear. Сначала поиск имени is_a ведется в области видимости Bear. Там его нет.
  • Поскольку класс Bear производный от ZooAnimal, то далее поиск is_a ведется в области видимости последнего. Обнаруживается, что имя принадлежит его члену. Разрешение закончилось успешно.

Хотя к членам базового класса можно обращаться напрямую, как к членам производного, они сохраняют свою принадлежность к базовому классу. Как правило, не имеет значения, в каком именно классе определено имя. Но это становится важным, если в базовом и производном классах есть одноименные члены. Например, когда мы пишем:

bear.ival;

ival – это член класса Bear, найденный на первом шаге описанного выше процесса разрешения имени.

Иными словами, член производного класса, имеющий то же имя, что и член базового, маскирует последний. Чтобы обратиться к члену базового класса, необходимо квалифицировать его имя с помощью оператора разрешения области видимости:

bear.ZooAnimal::ival;

Тем самым мы говорим компилятору, что объявление ival следует искать в области видимости класса ZooAnimal.

Проиллюстрируем использование оператора разрешения области видимости на несколько абсурдном примере (надеемся, вы никогда не напишете чего-либо подобного в реальном коде):

int ival;

int Bear::mumble( int ival )
{
   return ival +        // обращение к параметру
        ::ival +        // обращение к глобальному объекту
        ZooAnimal::ival +
        Bear::ival;
}

Неквалифицированное обращение к ival разрешается в пользу формального параметра. (Если бы переменная ival не была определена внутри mumble(), то имел бы место доступ к члену класса Bear. Если бы ival не была определена и в Bear, то подразумевался бы член ZooAnimal. А если бы ival не было и там, то речь шла бы о глобальном объекте.)

Разрешение имени члена класса всегда предшествует выяснению того, является ли обращение к нему корректным. На первый взгляд, это противоречит интуиции. Например, изменим реализацию mumble():

int dval;
int Bear::mumble( int ival )
{
   // ошибка: разрешается в пользу закрытого члена ZooAnimal::dval
   return ival + dval;
}

Можно возразить, что алгоритм разрешения должен остановиться на первом допустимом в данном контексте имени, а не на первом найденном. Однако в приведенном примере алгоритм разрешения выполняется следующим образом:

(a)	Определено ли dval в локальной области видимости функции-члена класса Bear? Нет.
     (b)	Определено ли dval в области видимости Bear? Нет.
     (c)	Определено ли dval в области видимости ZooAnimal? Да. Обращение разрешается в пользу этого имени.

После того как имя разрешено, компилятор проверяет, возможен ли доступ к нему. В данном случае нет: dval является закрытым членом, и прямое обращение к нему из mumble() запрещено. Правильное (и, возможно, имевшееся в виду) разрешение требует явного употребления оператора разрешения области видимости:

return ival + ::dval;  // правильно

Почему же имя члена разрешается перед проверкой уровня доступа? Чтобы предотвратить тонкие изменения семантики программы в связи с совершенно независимым, казалось бы, изменением уровня доступа к члену. Рассмотрим, например, такой вызов:

int dval;
int Bear::mumble( int ival )
{
   foo( dval );
   // ...
}

Если бы функция foo() была перегруженной, то перемещение члена ZooAnimal::dval из закрытой секции в защищенную вполне могло бы изменить всю последовательность вызовов внутри mumble(), а разработчик об этом даже и не подозревал бы.

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

ostream& Bear::print( ostream &os) const
{
   // вызывается ZooAnimal::print(os)
   ZooAnimal::print( os );

   os << name;
   return os;
}

18.4.1. Область видимости класса при множественном наследовании

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

class Endangered {
public:
   ostream& print( ostream& ) const;
   void highlight();
   // ...
};

class ZooAnimal {
public:
   bool onExhibit() const;
   // ...
private:
   bool highlight( int zoo_location );
   // ...
};

class Bear : public ZooAnimal {
public:
   ostream& print( ostream& ) const;
   void dance( dance_type ) const;
   // ...
};

Panda объявляется производным от двух классов:

class Panda : public Bear, public Endangered {
public:
   void cuddle() const;
   // ...
};

Хотя при наследовании функций print() и highlight() из обоих базовых классов Bear и Endangered имеется потенциальная неоднозначность, сообщение об ошибке не выдается до момента явно неоднозначного обращения к любой из этих функций.

В то время как неоднозначность двух унаследованных функций print() очевидна с первого взгляда, наличие конфликта между членами highlight() удивляет (ради этого пример и составлялся): ведь у них разные уровни доступа и разные прототипы. Более того, экземпляр из Endangered – это член непосредственного базового класса, а из ZooAnimal – член класса, стоящего на две ступеньки выше в иерархии.

Однако все это не имеет значения (впрочем, как мы скоро увидим, может иметь, но в случае виртуального наследования). Bear наследует закрытую функцию-член highlight() из ZooAnimal; лексически она видна, хотя вызывать ее из Bear или Panda запрещено. Значит, Panda наследует два лексически видимых члена с именем highlight, поэтому любое неквалифицированное обращение к этому имени приводит к ошибке компиляции.

Поиск имени начинается в ближайшей области видимости, объемлющей его вхождение. Например, в коде

int main()
{
   Panda yin_yang;
   yin_yang.dance( Bear::macarena );
}

ближайшей будет область видимости класса Panda, к которому принадлежит yin_yang. Если же мы напишем:

void Panda::mumble()
{
   dance( Bear::macarena );
   // ...
}

то ближайшей будет локальная область видимости функции-члена mumble(). Если объявление dance в ней имеется, то разрешение имени на этом благополучно завершится. В противном случае поиск будет продолжен в объемлющих областях видимости.

В случае множественного наследования имитируется одновременный просмотр всех поддеревьев наследования – в нашем случае это класс Endangered и поддерево Bear/ZooAnimal. Если объявление обнаружено только в поддереве одного из базовых классов, то разрешение имени заканчивается успешно, как, например, при таком вызове dance():

// правильно: Bear::dance()
yin_yang.dance( Bear::macarena );

Если же объявление найдено в двух или более поддеревьях, то обращение считается неоднозначным и компилятор выдает сообщение об ошибке. Так будет при неквалифицированном обращении к print():

int main()
{
   // ошибка: неоднозначность: одна из
   //         Bear::print( ostream& ) const
   //         Endangered::print( ostream& ) const
   Panda yin_yang;
   yin_yang.print( cout );
}

На уровне программы в целом для разрешения неоднозначности достаточно явно квалифицировать имя нужной функции-члена с помощью оператора разрешения области видимости:

int main()
{
   // правильно, но не лучшее решение
   Panda yin_yang;
   yin_yang.Bear::print( cout );
}

Предложенный способ неэффективен: теперь пользователь вынужден решать, каково правильное поведение класса Panda; однако лучше, если такого рода ответственность примет на себя проектировщик и класс Panda сам устранит все неоднозначности, свойственные его иерархии наследования. Простейший способ добиться этого – задать квалификацию уже в определении экземпляра в производном классе, указав тем самым требуемое поведение:

inline void Panda::highlight() {
   Endangered::highlight();
}

inline ostream&
Panda::print( ostream &os ) const
{
   Bear::print( os );
   Endangered::print( os );
   return os;
}

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

Упражнение 18.9

Дана следующая иерархия классов:

class Base1 {
public:
   // ...
protected:
   int    ival;
   double dval;
   char   cval;
   // ...
private:
   int    *id;
   // ...
};

class Base2 {
public:
   // ...
protected:
   float fval;
   // ...
private:
   double dval;
   // ...
};

class Derived : public Base1 {
public:
   // ...
protected:
   string sval;
   double dval;
   // ...
};

class MI : public Derived, public Base2 {
public:
   // ...
protected:
   int             *ival;
   complex<double> cval;
   // ...
};
и структура функции-члена MI::foo():
int ival;
double dval;

void MI::
foo( double dval )
{
   int id;
   // ...
}
(a)	Какие члены видны в классе MI? Есть ли среди них такие, которые видны в нескольких базовых?
     (b)	Какие члены видны в MI::foo()?

Упражнение 18.10

Пользуясь иерархией классов из упражнения 18.9, укажите, какие из следующих присваиваний недопустимы внутри функции-члена MI::bar():

void MI::
bar()
{
   int sval;
   // вопрос упражнения относится к коду, начинающемуся с этого места ...
}

(a) dval = 3.14159; (d) fval = 0;
(b) cval = 'a';     (e) sval = *ival;
(c) id = 1;

Упражнение 18.11

Даны иерархия классов из упражнения 18.9 и скелет функции-члена MI::foobar():

int id;
void MI::
foobar( float cval )
{
   int dval;
   // вопросы упражнения относятся к коду, начинающемуся с этого места ...
}
(a)	Присвойте локальной переменной dval сумму значений члена dval класса Base1 и члена dval класса Derived.
(b)	Присвойте вещественную часть члена cval класса MI члену fval класса Base2.
(c)	Присвойте значение члена cval класса Base1 первому символу члена sval класса Derived.

Упражнение 18.12

Дана следующая иерархия классов, в которых имеются функции-члены print():

class Base {
public:
   void print( string ) const;
   // ...
};

class Derived1 : public Base {
public:
   void print( int ) const;
   // ...
};

class Derived2 : public Base {
public:
   void print( double ) const;
   // ...
};

class MI : public Derived1, public Derived2 {
public:
   void print( complex ) const;
   // ...
};
(a)	Почему приведенный фрагмент дает ошибку компиляции?
	MI mi;
	string dancer( "Nejinsky" );
	mi.print( dancer );
(b)	Как изменить определение MI, чтобы этот фрагмент компилировался и выполнялся правильно?

18.5. Виртуальное наследование A

По умолчанию наследование в C++ является специальной формой композиции по значению. Когда мы пишем:

class Bear : public ZooAnimal { ... };

каждый объект Bear содержит все нестатические данные-члены подобъекта своего базового класса ZooAnimal, а также нестатические члены, объявленные в самом Bear. Аналогично, если производный класс является базовым для какого-то другого:

class PolarBear : public Bear { ... };

то каждый объект PolarBear содержит все нестатические члены, объявленные в PolarBear, Bear и ZooAnimal.

В случае одиночного наследования эта форма композиции по значению, поддерживаемая механизмом наследования, обеспечивает компактное и эффективное представление объекта. Проблемы возникают только при множественном наследовании, когда некоторый базовый класс неоднократно встречается в иерархии наследования. Самый известный реальный пример такого рода – это иерархия классов iostream. Взгляните еще раз на рис. 18.2: istream и ostream наследуют одному и тому абстрактному базовому классу ios, а iostream является производным как от istream, так и от ostream.

class iostream :
   public istream, public ostream { ... };

По умолчанию каждый объект iostream содержит два подобъекта ios: из istream и из ostream. Почему это плохо? С точки зрения эффективности хранение двух копий подобъекта ios – пустая трата памяти, поскольку объекту iostream нужен только один экземпляр. Кроме того, конструктор вызывается для каждого подобъекта. Более серьезной проблемой является неоднозначность, к которой приводит наличие двух экземпляров. Например, любое неквалифицированное обращение к члену класса ios дает ошибку компиляции. Какой экземпляр имеется в виду? Что будет, если классы istream и ostream инициализируют свои подобъекты ios по-разному? Можно ли гарантировать, что в классе iostream используется согласованная пара членов ios? Применяемый по умолчанию механизм композиции по значению не дает таких гарантий.

Для решения данной проблемы язык предоставляет альтернативный механизм композиции по ссылке: виртуальное наследование. В этом случае наследуется только один разделяемый подобъект базового класса, независимо от того, сколько раз базовый класс встречается в иерархии наследования. Этот разделяемый подобъект называется виртуальным базовым классом. С помощью виртуального наследования снимаются проблемы дублирования подобъектов базового класса и неоднозначностей, к которым такое дублирование приводит.

Для изучения синтаксиса и семантики виртуального наследования мы выбрали класс Panda. В зоологических кругах уже на протяжении ста лет периодически вспыхивают ожесточенные споры по поводу того, к какому семейству относить панду: к медведям или к енотам. Поскольку проектирование программного обеспечения призвано обслуживать, в основном, интересы прикладных областей, то самое правильное – произвести класс Panda от обоих классов:

class Panda : public Bear,
              public Raccoon, public Endangered { ... };

Наша виртуальная иерархия наследования Panda показана на рис. 18.4: две пунктирные стрелки обозначают виртуальное наследование классов Bear и Raccoon от ZooAnimal, а три сплошные – невиртуальное наследование Panda от Bear, Raccoon и, на всякий случай, от класса Endangered из раздела 18.2.

Рис. 18.4. Иерархия виртуального наследования класса Panda

На данном рисунке показан интуитивно неочевидный аспект виртуального наследования: оно (в нашем случае наследование классов Bear и Raccoon) должно появиться в иерархии раньше, чем в нем возникнет реальная необходимость. Необходимым виртуальное наследование становится только при объявлении класса Panda, но если перед этим базовые классы Bear и Raccoon не наследуют своему базовому виртуально, то проектировщику класса Panda не повезло.

Должны ли мы производить свои базовые классы виртуально просто потому, что где-то ниже в иерархии может потребоваться виртуальное наследование? Нет, это не рекомендуется: снижение производительности и усложнение дальнейшего наследования может оказаться существенным (см. [LIPPMAN96a], где приведены и обсуждаются результаты измерения производительности).

Когда же использовать виртуальное наследование? Чтобы его применение было успешным, иерархия, например библиотека iostream или наше дерево классов Panda, должна проектироваться целиком либо одним человеком, либо коллективом разработчиков.

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

18.5.1. Объявление виртуального базового класса

Для указания виртуального наследования в объявление базового класса вставляется модификатор virtual. Так, в данном примере ZooAnimal становится виртуальным базовым для Bear и Raccoon:

// взаимное расположение ключевых слов public и virtual
// несущественно
class Bear : public virtual ZooAnimal { ... };
class Raccoon : virtual public ZooAnimal { ... };

Виртуальное наследование не является явной характеристикой самого базового класса, а лишь описывает его отношение к производному. Как мы уже отмечали, виртуальное наследование – это разновидность композиции по ссылке. Иначе говоря, доступ к подобъекту и его нестатическим членам косвенный, что обеспечивает гибкость, необходимую для объединения нескольких виртуально унаследованных подобъектов базовых классов в один разделяемый экземпляр внутри производного. В то же время объектом производного класса можно манипулировать через указатель или ссылку на тип базового, хотя последний является виртуальным. Например, все показанные ниже преобразования базовых классов Panda выполняются корректно, хотя Panda использует виртуальное наследование:

extern void dance( const Bear* );
extern void rummage( const Raccoon* );

extern ostream&
      operator<<( ostream&, const ZooAnimal& );

int main()
{
   Panda yin_yang;

   dance( &yin_yang );   // правильно
   rummage( &yin_yang ); // правильно
   cout << yin_yang;     // правильно
   // ...
}

Любой класс, который можно задать в качестве базового, разрешается сделать виртуальным, причем он способен содержать все те же элементы, что обычные базовые классы. Так выглядит объявление ZooAnimal:

#include <iostream>
#include <string>

class ZooAnimal;
extern ostream&
      operator<<( ostream&, const ZooAnimal& );
class ZooAnimal {
public:
   ZooAnimal( string name,
              bool onExhibit, string fam_name )
            : _name( name ),
              _onExhibit( onExhibit ), _fam_name( fam_name )
   {}

   virtual ~ZooAnimal();
   virtual ostream& print( ostream& ) const;
   string name() const { return _name; }
   string family_name() const { return _fam_name; }
   // ...

protected:
   bool   _onExhibit;
   string _name;
   string _fam_name;
   // ...
};

К объявлению и реализации непосредственного базового класса при использовании виртуального наследования добавляется ключевое слово virtual. Вот, например, объявление нашего класса Bear:

class Bear : public virtual ZooAnimal {
public:
   enum DanceType {
        two_left_feet, macarena, fandango, waltz };

   Bear( string name, bool onExhibit=true )
       : ZooAnimal( name, onExhibit, "Bear" ),
         _dance( two_left_feet )
   {}

   virtual ostream& print( ostream& ) const;
   void dance( DanceType );
   // ...

protected:
   DanceType _dance;
   // ...
};

А вот объявление класса Raccoon:

class Raccoon : public virtual ZooAnimal {
public:
   Raccoon( string name, bool onExhibit=true )
       : ZooAnimal( name, onExhibit, "Raccoon" ),
         _pettable( false )
   {}

   virtual ostream& print( ostream& ) const;

   bool pettable() const { return _pettable; }
   void pettable( bool petval ) { _pettable = petval; }
   // ...

protected:
   bool _pettable;
   // ...
};

18.5.2. Специальная семантика инициализации

Наследование, в котором присутствует один или несколько виртуальных базовых классов, требует специальной семантики инициализации. Взгляните еще раз на реализации Bear и Raccoon в предыдущем разделе. Видите ли вы, какая проблема связана с порождением класса Panda?

class Panda : public Bear,
              public Raccoon, public Endangered {
public:
      Panda( string name, bool onExhibit=true );
      virtual ostream& print( ostream& ) const;

      bool sleeping() const { return _sleeping; }
      void sleeping( bool newval ) { _sleeping = newval; }
      // ...

protected:
      bool _sleeping;
      // ...
};

Проблема в том, что конструкторы базовых классов Bear и Raccoon вызывают конструктор ZooAnimal с неявным набором аргументов. Хуже того, в нашем примере значения по умолчанию для аргумента fam_name (название семейства) не только отличаются, они еще и неверны для Panda.

В случае невиртуального наследования производный класс способен явно инициализировать только свои непосредственные базовые классы (см. раздел 17.4). Так, классу Panda, наследующему от ZooAnimal, не разрешается напрямую вызвать конструктор ZooAnimal в своем списке инициализации членов. Однако при виртуальном наследовании только Panda может напрямую вызывать конструктор своего виртуального базового класса ZooAnimal.

Ответственность за инициализацию виртуального базового возлагается на ближайший производный класс. Например, когда объявляется объект класса Bear:

Bear winnie( "pooh" );

то Bear является ближайшим производным классом для объекта winnie, поэтому выполняется вызов конструктора ZooAnimal, определенный в классе Bear. Когда мы пишем:

cout << winnie.family_name();

будет выведена строка:

The family name for pooh is Bear

(Название семейства для pooh – это Bear)

Аналогично для объявления

Raccoon meeko( "meeko");

Raccoon – это ближайший производный класс для объекта meeko, поэтому выполняется вызов конструктора ZooAnimal, определенный в классе Raccoon. Когда мы пишем:

cout << meeko.family_name();

печатается строка:

The family name for meeko is Raccoon

(Название семейства для meeko - это Raccoon)

Если же объявить объект типа Panda:

Panda yolo( "yolo" );

то ближайшим производным классом для объекта yolo будет Panda, поэтому он и отвечает за инициализацию ZooAnimal.

Когда инициализируется объект Panda, то явные вызовы конструктора ZooAnimal в конструкторах классов Raccoon и Bear не выполняются, а вызывается он с теми аргументами, которые указаны в списке инициализации членов объекта Panda. Вот так выглядит реализация:

Panda::Panda( string name, bool onExhibit=true )
          : ZooAnimal( name, onExhibit, "Panda" ),
            Bear( name, onExhibit ),
            Raccoon( name, onExhibit ),
            Endangered( Endangered::environment,
                        Endangered::critical ),
            sleeping( false )
{}

Если в конструкторе Panda аргументы для конструктора ZooAnimal не указаны явно, то вызывается конструктор ZooAnimal по умолчанию либо, если такового нет, выдается ошибка при компиляции определения конструктора Panda.

Когда мы пишем:

cout << yolo.family_name();

печатается строка:

The family name for yolo is Panda

(Название семейства для yolo - это Panda)

Внутри определения Panda классы Raccoon и Bear являются промежуточными, а не ближайшими производными. В промежуточном производном классе все прямые вызовы конструкторов виртуальных базовых классов автоматически подавляются. Если бы от Panda был в дальнейшем произведен еще один класс, то сам класс Panda стал бы промежуточным и вызов из него конструктора ZooAnimal также был бы подавлен.

Обратите внимание, что оба аргумента, передаваемые конструкторам Bear и Raccoon, излишни в том случае, когда они выступают в роли промежуточных производных классов. Чтобы избежать передачи ненужных аргументов, мы можем предоставить явный конструктор, вызываемый, когда класс оказывается промежуточным производным. Изменим наш конструктор Bear:

class Bear : public virtual ZooAnimal {
public:
   // если выступает в роли ближайшего производного класса
   Bear( string name, bool onExhibit=true )
       : ZooAnimal( name, onExhibit, "Bear" ),
         _dance( two_left_feet )
   {}

   // ... остальное без изменения

protected:
   // если выступает в роли промежуточного производного класса
   Bear() : _dance( two_left_feet ) {}

   // ... остальное без изменения
};

Мы сделали этот конструктор защищенным, поскольку он вызывается только из производных классов. Если аналогичный конструктор по умолчанию обеспечен и для класса Raccoon, можно следующим образом модифицировать конструктор Panda:

Panda::Panda( string name, bool onExhibit=true )
          : ZooAnimal( name, onExhibit, "Panda"),
            Endangered( Endangered::environment,
                        Endangered::critical ),
            sleeping( false )
{}

18.5.3. Порядок вызова конструкторов и деструкторов

Виртуальные базовые классы всегда конструируются перед невиртуальными, вне зависимости от их расположения в иерархии наследования. Например, в приведенной иерархии у класса TeddyBear (плюшевый мишка) есть два виртуальных базовых: непосредственный – ToyAnimal (игрушечное животное) и экземпляр ZooAnimal, от которого унаследован класс Bear:

class Character { ... };         // персонаж
class BookCharacter : public Character { ... };
                                 // литературный персонаж
class ToyAnimal { ... };         // игрушка

class TeddyBear : public BookCharacter,
                  public Bear, public virtual ToyAnimal
                  { ... };

Эта иерархия изображена на рис. 18.5, где виртуальное наследование показано пунктирной стрелкой, а невиртуальное – сплошной.

Рис. 18.5. Иерархия виртуального наследования класса TeddyBear

Непосредственные базовые классы просматриваются в порядке их объявления при поиске среди них виртуальных. В нашем примере сначала анализируется поддерево наследования BookCharacter, затем Bear и наконец ToyAnimal. Каждое поддерево обходится в глубину, т.е. поиск начинается с корневого класса и продвигается вниз. Так, для поддерева BookCharacter сначала просматривается Character, а затем BookCharacter. Для поддерева Bear – ZooAnimal, а потом Bear.

При описанном алгоритме поиска порядок вызова конструкторов виртуальных базовых классов для TeddyBear таков: ZooAnimal, потом ToyAnimal.

После того как вызваны конструкторы виртуальных базовых классов , настает черед конструкторов невиртуальных, которые вызываются в порядке объявления: BookCharacter, затем Bear. Перед выполнением конструктора BookCharacter вызывается конструктор его базового класса Character.

Если имеется объявление:

TeddyBear Paddington;

то последовательность вызова конструкторов базовых классов будет такой:

ZooAnimal();          // виртуальный базовый класс Bear
      ToyAnimal();          // непосредственный виртуальный базовый класс
      Character();          // невиртуальный базовый класс BookCharacter
      BookCharacter();      // непосредственный невиртуальный базовый класс
      Bear();               // непосредственный невиртуальный базовый класс
      TeddyBear();          // ближайший производный класс

причем за инициализацию ZooAnimal и ToyAnimal отвечает TeddyBear – ближайший производный класс объекта Paddington.

Порядок вызова копирующих конструкторов при почленной инициализации (и копирующих операторов присваивания при почленном присваивании) такой же. Гарантируется, что деструкторы вызываются в последовательности, обратной вызову конструкторов.

18.5.4. Видимость членов виртуального базового класса

Изменим наш класс Bear так, чтобы он имел собственную реализацию функции-члена onExhibit(), предоставляемой также ZooAnimal: bool Bear::onExhibit() { ... }

Теперь обращение к onExhibit() через объект Bear разрешается в пользу экземпляра, определенного в этом классе:

Bear winnie( "любитель меда" );
winnie.onExhibit();       // Bear::onExhibit()

Обращение же к onExhibit() через объект Raccoon разрешается в пользу функции-члена, унаследованной из ZooAnimal:

Raccoon meeko( "любитель всякой еды" );
meeko.onExhibit();       // ZooAnimal::onExhibit()

Производный класс Panda наследует члены своих базовых классов. Их можно отнести к одной из трех категорий:

  • члены виртуального базового класса ZooAnimal, такие, как name() и family(), не замещенные ни в Bear, ни в Raccoon;
  • член onExhibit() виртуального базового класса ZooAnimal, наследуемый при обращении через Raccoon и замещенный в классе Bear;
  • специализированные в классах Bear и Raccoon экземпляры функции print() из ZooAnimal.

Можно ли, не опасаясь неоднозначности, напрямую обращаться к унаследованным членам из области видимости класса Panda? В случае невиртуального наследования – нет: все неквалифицированные ссылки на имя неоднозначны. Что касается виртуального наследования, то прямое обращение допустимо к любым членам из первой и второй категорий. Например, дан объект класса Panda:

Panda spot( "Spottie" );

Тогда инструкция

spot.name();

вызывает разделяемую функцию-член name() виртуального базового ZooAnimal, а инструкция

spot.onExhibit();

вызывает функцию-член onExhibit() производного класса Bear.

Когда два или более экземпляров члена наследуются разными путями (это относится не только к функциям-членам, но и к данным-членам, а также к вложенным типам) и все они представляют один и тот же член виртуального базового класса, неоднозначности не возникает, поскольку существует единственный разделяемый экземпляр (первая категория). Если один экземпляр представляет член виртуального базового, а другой – член унаследованного от него класса, то неоднозначности также не возникает: специализированному экземпляру из производного класса отдается предпочтение по сравнению с разделяемым экземпляром из виртуального базового (вторая категория). Но если оба экземпляра представляют члены производных классов, то прямое обращение неоднозначно. Лучше всего разрешить эту ситуацию, предоставив замещающий экземпляр в производном классе (третья категория).

Например, при невиртуальном наследовании неквалифицированное обращение к onExhibit() через объект Panda неоднозначно:

// ошибка: неоднозначно при невиртуальном наследовании
Panda yolo( "любитель бамбука" );
yolo.onExhibit();

В данном случае все унаследованные экземпляры имеют равные приоритеты при разрешении имени, поэтому неквалифицированное обращение приводит к ошибке компиляции из-за неоднозначности (см. раздел 18.4.1).

При виртуальном наследовании члену, унаследованному из виртуального базового класса, приписывается меньший приоритет, чем члену с тем же именем, замещенному в производном. Так, унаследованному от Bear экземпляру onExhibit() отдается предпочтение перед экземпляром из ZooAnimal, унаследованному через Raccoon:

// правильно: при виртуальном наследовании неоднозначности нет
// вызывается Bear::onExhibit()
yolo.onExhibit();

Если два или более классов на одном и том же уровне наследования замещают некоторый член виртуального базового, то в производном они будут иметь одинаковый вес. Например, если в Raccoon также определен член onExhibit(), то при обращении к нему из Panda придется квалифицировать имя с помощью оператора разрешения области видимости:

bool Panda::onExhibit()
{
   return Bear::onExhibit() &&
          Raccoon::onExhibit() &&
          ! _sleeping;
}

Упражнение 18.13

Дана иерархия классов:

class Class { ... };
class Base : public Class { ... };
class Derived1 : virtual public Base { ... };
class Derived2 : virtual public Base { ... };
class MI : public Derived1,
           public Derived2 { ... };
class Final : public MI, public Class { ... };
(a)	В каком порядке вызываются конструкторы и деструкторы при определении объекта Final?
(b)	Сколько подобъектов класса Base содержит объект Final? А сколько подобъектов Class?
(c)	Какие из следующих присваиваний вызывают ошибку компиляции?
Base     *pb;
MI       *pmi;
Class    *pc;
Derived2 *pd2;

(i)  pb = new Class;   (iii) pmi = pb;
(ii) pc = new Final;   (iv)  pd2 = pmi;

Упражнение 18.14

Дана иерархия классов:

class Base {
public:
   bar( int );
   // ...
protected:
   int ival;
   // ...
};

class Derived1 : virtual public Base {
public:
   bar( char );
   foo( char );
   // ...
protected:
   char cval;
   // ...
};

class Derived2 : virtual public Base {
public:
   foo( int );
   // ...
protected:
   int ival;
   char cval;
   // ...
};

class VMI : public Derived1, public Derived2 {};

К каким из унаследованных членов можно обращаться из класса VMI, не квалифицируя имя? А какие требуют квалификации?

Упражнение 18.15

Дан класс Base с тремя конструкторами:

class Base {
public:
   Base();
   Base( string );
   Base( const Base& );
   // ...
protected:
   string _name;
};

Определите соответствующие конструкторы для каждого из следующих классов:

(a) любой из
          class Derived1 : virtual public Vase { ... };
          class Derived2 : virtual public Vase { ... };
      (b) class VMI : public Derived1, public Derived2 { ... };
      (c) class Final : public VMI { ... };

18.6. Пример множественного виртуального наследования A

Мы продемонстрируем определение и использование множественного виртуального наследования, реализовав иерархию шаблонов классов Array (см. раздел 2.4) на основе шаблона Array (см. главу 16), модифицированного так, чтобы он стал конкретным базовым классом. Перед тем как приступать к реализации, поговорим о взаимосвязях между шаблонами классов и наследованием.

Конкретизированный экземпляр такого шаблона может выступать в роли явного базового класса:

class IntStack : private Array<int> {};

Разрешается также произвести его от не шаблонного базового класса:

class Base {};
template <class Type> 
    class Derived : public Base {};

Шаблон может выступать одновременно в роли базового и производного классов:

template <class Type> 
    class Array_RC : public virtual Array<Type>  {};

В первом примере конкретизированный типом int шаблон Array служит закрытым базовым классом для не шаблонного IntStack. Во втором примере не шаблонный Base служит базовым для любого класса, конкретизированного из шаблона Derived. В третьем примере любой конкретизированный из шаблона Array_RC класс является производным от класса, конкретизированного из шаблона Array. Так, инструкция

Array_RC<int> ia;

конкретизирует экземпляры шаблонов Array и Array_RC.

Кроме того, сам параметр-шаблон может служить базовым классом [MURRAY93]:

template < typename Type > 
     class Persistent : public Type { ... };

в данном примере определяется производный устойчивый (persistent) подтип для любого конкретизированного типа. Как отмечает Мюррей (Murray), на Type налагается неявное ограничение: он должен быть типом класса. Например, инструкция

Persistent< int>   pi;    // ошибка

приводит к ошибке компиляции, поскольку встроенный тип не может быть объектом наследования.

Шаблон, выступающий в роли базового класса, должен квалифицироваться полным списком параметров. Если имеется определение:

template <class T>  class Base {};

то необходимо писать:

template < class Type > 
    class Derived : public Base<Type>  {};

Такая запись неправильна:

// ошибка: Base - это шаблон,
// так что должны быть заданы его аргументы
template < class Type > 
    class Derived : public Base {};

В следующем разделе шаблон Array, определенный в главе 16, выступает в роли виртуального базового класса для подтипа Array, контролирующего выход за границы массива; для отсортированного подтипа Array; для подтипа Array, который обладает обоими указанными свойствами. Однако первоначальное определение шаблона класса Array для наследования не подходит:

  • все его члены и вспомогательные функции объявлены закрытыми, а не защищенными;
  • ни одна из зависящих от типа функций-членов, скажем оператор взятия индекса, не объявлена виртуальной.

Означает ли это, что наша первоначальная реализация была неправильной? Нет. Она была верной на том уровне понимания, которым мы тогда обладали. При реализации шаблона класса Array мы еще не осознали необходимость специализированных подтипов. Теперь, однако, определение шаблона придется изменить так (реализации функций-членов при этом останутся теми же):

#ifndef ARRAY_H
#define ARRAY_H

#include <iostream> 

// необходимо для опережающего объявления operator<<
template <class Type>  class Array;

template <class Type>  ostream&
          operator<<( ostream &, Array<Type>  & );

template <class Type> 
class Array {
   static const int ArraySize = 12;
public:
    explicit Array( int sz = ArraySize ) { init( 0, sz ); }
    Array( const Type *ar, int sz )      { init( ar, sz ); }
    Array( const Array &iA )     { init( iA.ia, iA.size()); }
    virtual ~Array()             { delete[] ia; }

    Array& operator=( const Array & );
    int size() const { return _size; }
    virtual void grow();

    virtual void print( ostream& = cout );

    Type at( int ix ) const { return ia[ ix ]; }
    virtual Type& operator[]( int ix ) { return ia[ix]; }

    virtual void sort( int,int );
    virtual int find( Type );
    virtual Type min();
    virtual Type max();

protected:
    void swap( int, int );
    void init( const Type*, int );
    int  _size;
    Type *ia;
};

#endif

Одна из проблем, связанных с таким переходом к полиморфизму, заключается в том, что реализация оператора взятия индекса перестала быть встроенной и сводится теперь к значительно более дорогому вызову виртуальной функции. Так, в следующей функции, на какой бы тип она ни ссылалась, было бы достаточно встроенного чтения элемента:

int find( const Array< int > &ia, int value )
{
    for ( int ix = 0; ix <ia.size(); ++ix )
          // а теперь вызов виртуальной функции
          if ( ia[ ix ] == value )
	        return ix;
    return -1;
}

Для повышения производительности мы включили встроенную функцию-член at(),обеспечивающую прямое чтение элемента.

18.6.1. Порождение класса, контролирующего выход за границы массива

В функции try_array() из раздела 16.13, предназначенной для тестирования нашей предыдущей реализации шаблона класса Array, есть две инструкции:

int index = iA.find( find_val );
Type value = iA[ index ];

find() возвращает индекс первого вхождения значения find_val или -1, если значение в массиве не найдено. Этот код некорректен, поскольку в нем не проверяется, что не была возвращена -1. Поскольку -1 находится за границей массива, то каждая инициализация value может привести к ошибке. Поэтому мы создадим подтип Array, который будет контролировать выход за границы массива, – Array_RC и поместим его определение в заголовочный файл Array_RC.h:

#ifndef ARRAY_RC_H
#define ARRAY_RC_H

#include "Array.h"

template <class Type>
class Array_RC : public virtual Array<Type> {
public:
    Array_RC( int sz = ArraySize )
            : Array <Type>( sz ) {}
    Array_RC( const Array_RC& r );
    Array_RC( const Type *ar, int sz );
    Type& operator[]( int ix );
};
#endif

Внутри определения производного класса каждая ссылка на спецификатор типа шаблона базового должна быть квалифицирована списком формальных параметров:

Array_RC( int sz = ArraySize )
        : Array<Type>( sz ) {}
Такая запись неправильна:
// ошибка: Array - это не спецификатор типа
Array_RC( int sz = ArraySize ) : Array( sz ) {}

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

Вот полная реализация функций-членов Array_RC, находящаяся в файле Array_RC.C (определения функций класса Array помещены в заголовочный файл Array.C, поскольку мы пользуемся моделью конкретизации шаблонов с включением, описанной в разделе 16.18):

#include "Array_RC.h"
#include "Array.C"
#include <assert.h>

template  <class Type>
Array_RC <Type >::Array_RC( const Array_RC<Type> &r )
       :  Array <Type>( r ) {}

template  <class Type>
Array_RC <Type >::Array_RC( const Type *ar, int sz )
       :  Array <Type>( ar, sz ) {}

template  <class Type>
Type &Array_RC <Type>::operator[]( int ix ) {
        assert( ix >= 0 && ix  < Array <Type>::_size );
        return ia[ ix ];
}

Мы квалифицировали обращения к членам базового класса Array, например к _size, чтобы предотвратить просмотр Array до момента конкретизации шаблона:

Array <Type>::_size;

Мы достигаем этого, включая в обращение параметр шаблона. Таким образом, имена в определении Array_RC разрешаются тогда, когда определяется шаблон (за исключением имен, явно зависящих от его параметра). Если встречается неквалифицированное имя _size, то компилятор должен найти его определение, если только это имя не зависит явно от параметра шаблона. Мы сделали имя _size зависящим от параметра шаблона, предварив его именем базового класса Array. Теперь компилятор не будет пытаться разрешить имя _size до момента конкретизации шаблона. (В определении класса Array_Sort мы приведем другие примеры использования подобных приемов.)

Каждая конкретизация Array_RC порождает экземпляр класса Array. Например:

Array_RC<string> sa;

конкретизирует параметром string как шаблон Array_RC, так и шаблон Array. Приведенная ниже программа вызывает try_array() (реализацию см. в разделе 16.13), передавая ей объекты подтипа Array_RC. Если все сделано правильно, то выходы за границы массивы будут замечены:

#include "Array_RC.C"
#include "try_array.C"

int main()
{
    static int ia[] = { 12,7,14,9,128,17,6,3,27,5 };

    cout  < < "конкретизация шаблона класса Array_RC <int>\n";
    try_array( iA );

    return 0;
}

После компиляции и запуска программа печатает следующее:

конкретизация шаблона класса Array_RC<int>

try_array: начальные значения массива
( 10 ) < 12, 7, 14, 9, 128, 17
   6, 3, 27, 5 >

try_array: после присваиваний
( 10 ) < 128, 7, 14, 9, 128, 128
   6, 3, 27, 3 >

try_array: почленная инициализация
( 10 ) < 12, 7, 14, 9, 128, 128
   6, 3, 27, 3 >

try_array: после почленного копирования
( 10 ) < 12, 7, 128, 9, 128, 128
   6, 3, 27, 3 >

try_array: после вызова grow
( 10 ) < 12, 7, 128, 9, 128, 128
   6, 3, 27, 3, 0, 0
   0, 0, 0, 0 >

искомое значение: 5       возвращенный индекс: -1
Assertion failed: ix >= 0 && ix  < _size

18.6.2. Порождение класса отсортированного массива

Вторая наша специализация класса Array – отсортированный подтип Array_Sort. Мы поместим его определение в заголовочный файл Array_S.h:

#ifndef ARRAY_S_H_
#define ARRAY_S_H_

#include "Array.h"

template  <class Type>
class Array_Sort : public virtual Array <Type> {
protected:
    void set_bit()   { dirty_bit = true; }
    void clear_bit() { dirty_bit = false; }

    void check_bit() {
         if ( dirty_bit ) {
              sort( 0, Array <Type>::_size-1 );
              clear_bit();
         }
    }

public:
    Array_Sort( const Array_Sort& );
    Array_Sort( int sz = Array <Type>::ArraySize )
              : Array <Type>( sz )
                { clear_bit();  }

    Array_Sort( const Type* arr, int sz )
              : Array <Type>( arr, sz )
              { sort( 0,Array <Type>::_size-1 ); clear_bit(); }

    Type& operator[]( int ix )
	      { set_bit(); return ia[ ix ]; }

    void print( ostream& os = cout ) const
           { check_bit(); Array <Type>::print( os ); }
    Type min() { check_bit(); return ia[ 0 ]; }
    Type max() { check_bit(); return ia[ Array <Type>::_size-1 ]; }

    bool is_dirty() const { return dirty_bit; }
    int  find( Type );
    void grow();

protected:
    bool dirty_bit;
};

#endif

Array_Sort включает дополнительный член – dirty_bit. Если он установлен в true, то не гарантируется, что массив по-прежнему отсортирован. Предоставляется также ряд вспомогательных функций доступа: is_dirty() возвращает значение dirty_bit; set_bit() устанавливает dirty_bit в true; clear_bit() сбрасывает dirty_bit в false; check_bit() пересортировывает массив, если dirty_bit равно true, после чего сбрасывает его в false. Все операции, которые потенциально могут перевести массив в неотсортированное состояние, вызывают set_bit().

При каждом обращении к шаблону Array необходимо указывать полный список параметров.

Array<Type>::print( os );

вызывает функцию-член print() базового класса Array, конкретизированного одновременно с Array_Sort. Например:

Array_Sort<string> sas;

конкретизирует типом string оба шаблона: Array_Sort и Array.

cout << sas;

конкретизирует оператор вывода из класса Array, конкретизированного типом string, затем этому оператору передается строка sas. Внутри оператора вывода инструкция

ar.print( os );

приводит к вызову виртуального экземпляра print() класса Array_Sort, конкретизированного типом string. Сначала вызывается check_bit(), а затем статически вызывается функция-член print() класса Array, конкретизированного тем же типом. (Напомним, что под статическим вызовом понимается разрешение функции на этапе компиляции и – при необходимости – ее подстановка в место вызова.) Виртуальная функция обычно вызывается динамически в зависимости от фактического типа объекта, адресуемого ar. Механизм виртуализации подавляется, если она вызывается явно с помощью оператора разрешения области видимости, как в Array::print(). Это повышает эффективность в случае, когда мы явно вызываем экземпляр виртуальной функции базового класса из экземпляра той же функции в производном, например в print() из класса Array_Sort (см. раздел 17.5).

Функции-члены, определенные вне тела класса, помещены в файл Array_S.C. Объявление может показаться слишком сложным из-за синтаксиса шаблона. Но, если не считать списков параметров, оно такое же, как и для обычных классов:

template <class Type>
Array_Sort<Type>::
Array_Sort( const Array_Sort<Type> &as )
          : Array<Type>( as )
{
    // замечание: as.check_bit() не работает!
    // ---- объяснение см. ниже ...
    if ( as.is_dirty() )
         sort( 0, Array<Type>::_size-1 );
    clear_bit();
}

Каждое использование имени шаблона в качестве спецификатора типа должно быть квалифицировано полным списком параметров. Следует писать:

template <class Type>
Array_Sort<Type>::
Array_Sort( const Array_Sort<Type> &as )
а не
template <class Type>
Array_Sort<Type>::
Array_Sort<Type>(    // ошибка: это не спецификатор типа

поскольку второе вхождение Array_Sort синтаксически является именем функции, а не спецификатором типа.

Есть две причины, по которым правильна такая запись:

if ( as.is_dirty() )
   sort( 0, _size );

а не просто

as.check_bit();

Первая причина связана с типизацией: check_bit() – это неконстантная функция-член, которая модифицирует объект класса. В качестве аргумента передается ссылка на константный объект. Применение check_bit() к аргументу as нарушает его константность и потому воспринимается компилятором как ошибка.

Вторая причина: копирующий конструктор рассматривает массив, ассоциированный с as, только для того, чтобы выяснить, нуждается ли вновь созданный объект класса Array_Sort в сортировке. Напомним, однако, что член dirty_bit нового объекта еще не инициализирован. К началу выполнения тела конструктора Array_Sort инициализированы только члены ia и _size, унаследованные от класса Array. Этот конструктор должен с помощью clear_bit() задать начальные значения дополнительных членов и, вызвав sort(), обеспечить специальное поведение подтипа. Конструктор Array_Sort можно было бы инициализировать и по-другому:

// альтернативная реализация
template <class Type>
Array_Sort<Type>::
Array_Sort( const Array_Sort<Type> &as )
          : Array<Type>( as )
{
    dirty_bit = as.dirty_bit;
    clear_bit();
}

Ниже приведена реализация функции-члена grow().1 Наша стратегия состоит в том, чтобы воспользоваться имеющейся в базовом классе Array реализацией для выделения дополнительной памяти, а затем пересортировать элементы и сбросить dirty_bit:

template <class Type>
void Array_Sort<Type>::grow()
{
    Array<Type>::grow();
    sort( 0, Array<Type>::_size-1 );
    clear_bit();
}

Так выглядит реализация двоичного поиска в функции-члене find() класса Array_Sort:

template <class Type>
int Array_Sort<Type>::find( const Type &val )
{
     int low = 0;
     int high = Array<Type>::_size-1;
     check_bit();
     while ( low <= high ) {
          int mid = ( low + high )/2;

          if ( val == ia[ mid ] )
               return mid;

          if ( val < ia[ mid ] )
               high = mid-1;
          else low = mid+1;
     }
     return -1;
}

Протестируем нашу реализацию класса Array_Sort с помощью функции try_array(). Показанная ниже программа тестирует шаблон этого класса для конкретизаций типами int и string:

#include "Array_S.C"
#include "try_array.C"
#include <string>

main()
{
    static int ia[ 10 ] = { 12,7,14,9,128,17,6,3,27,5 };
    static string sa[ 7 ] = {
  	      "Eeyore", "Pooh", "Tigger",
           "Piglet", "Owl", "Gopher", "Heffalump"
    };

    Array_Sort<int> iA( ia,10 );
    Array_Sort<string> SA( sa,7 );

    cout << "eiie?aoecaoey eeanna Array_Sort<int>"
         <<  endl;
    try_array( iA );

    cout <<  "eiie?aoecaoey eeanna Array_Sort<string>"
         <<  endl;
    try_array( SA );

    return 0;
}

При конкретизации типом string после компиляции и запуска программа печатает следующий текст (обратите внимание, что попытка вывести элемент с индексом -1 заканчивается крахом):

конкретизация класса Array_Sort<string>

try_array: начальные значения массива
( 7 )< Eeyore, Gopher, Heffalump, Owl, Piglet, Pooh
       Tigger >

try_array: после присваиваний
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
       Pooh >

try_array: почленная инициализация
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
       Pooh >

try_array: после почленного копирования
( 7 )< Eeyore, Piglet, Owl, Piglet, Pooh, Pooh
       Pooh >
try_array: после вызова grow
( 7 )< <empty>, <empty>, <empty>, <empty>, Eeyore, Owl
       Piglet, Piglet, Pooh, Pooh, Pooh >

искомое значение: Tigger           возвращенный индекс: -1
Memory fault (coredump)

После почленного копирования массив не отсортирован, поскольку виртуальная функция вызывалась через объект, а не через указатель или ссылку. Как было сказано в разделе 17.5, в таком случае вызывается экземпляр функции из класса именно этого объекта, а не того подтипа, который может находиться в переменной. Поэтому функция sort() никогда не будет вызвана через объект Array. (Разумеется, мы реализовали такое поведение только в целях демонстрации.)

18.6.3. Класс массива с множественным наследованием

Определим отсортированный массив с контролем выхода за границы. Для этого можно применить множественное наследование от Array_RC и Array_Sort. Вот как выглядит наша реализация (напомним еще раз, что мы ограничились тремя конструкторами и оператором взятия индекса). Определение находится в заголовочном файле Array_RC_S.h:

#ifndef ARRAY_RC_S_H
#define ARRAY_RC_S_H

#include "Array_S.C"
#include "Array_RC.C"

template <class Type>
class Array_RC_S : public Array_RC<Type>,
                   public Array_Sort<Type>
{
public:
    Array_RC_S( int sz = Array<Type>::ArraySize )
              : Array<Type>( sz )
              { clear_bit(); }

    Array_RC_S( const Array_RC_S &rca )
	      : Array<Type>( rca )
           { sort( 0,Array<Type>::_size-1 ); clear_bit(); }

    Array_RC_S( const Type* arr, int sz )
 	      : Array<Type>( arr, sz )
 	      { sort( 0,Array<Type>::_size-1 ); clear_bit(); }

    Type& operator[]( int index )
	{
		set_bit();
 	      return Array_RC<Type>::operator[]( index );
     }
};

#endif

Этот класс наследует две реализации каждой интерфейсной функции Array: из Array_Sort и из виртуального базового класса Array через Array_RC (за исключением оператора взятия индекса, для которого из обоих базовых классов наследуется замещенный экземпляр). При невиртуальном наследовании вызов find() был бы помечен компилятором как неоднозначный, поскольку он не знает, какой из унаследованных экземпляров мы имели в виду. В нашем случае замещенным в Array_Sort экземплярам отдается предпочтение по сравнению с экземплярами, унаследованными из виртуального базового класса через Array_RC (см. раздел 18.5.4). Таким образом, при виртуальном наследовании неквалифицированный вызов find() разрешается в пользу экземпляра, унаследованного из класса Array_Sort.

Оператор взятия индекса переопределен в классах Array_RC и Array_Sort, и обе реализации имеют равный приоритет. Поэтому внутри Array_RC_S неквалифицированное обращение к оператору взятия индекса неоднозначно. Класс Array_RC_S должен предоставить собственную реализацию, иначе пользователи не смогут напрямую применять такой оператор к объектам этого класса. Но какова семантика его вызова в Array_RC_S? При учете отсортированности массива он должен установить в true унаследованный член dirty_bit. А чтобы учесть наследование от класса с контролем выхода за границы массива – проверить указанный индекс. После этого можно возвращать элемент массива с данным индексом. Последние два шага выполняет унаследованный из Array_RC оператор взятия индекса. При обращении

return Array_RC<Type>::operator[]( index );

он вызывается явно, и механизм виртуализации не применяется. Поскольку это встроенная функция, то при статическом вызове компилятор подставляет ее код в место вызова.

Теперь протестируем нашу реализацию с помощью функции try_array(), передавая ей по очереди классы, конкретизированные из шаблона Array_RC_S типами int и string:

#include "Array_RC_S.h"
#include "try_array.C"
#include <string>
int main()
{
    static int ia[ 10 ] = { 12,7,14,9,128,17,6,3,27,5 };
    static string sa[ 7 ] = {
   	    "Eeyore", "Pooh", "Tigger",
   	    "Piglet", "Owl", "Gopher", "Heffalump"
    };
    Array_RC_S<int> iA( ia,10 );
    Array_RC_S<string> SA( sa,7 );

    cout << "eiie?aoecaoey eeanna Array_RC_S<int>"
         <<  endl;
    try_array( iA );

    cout <<  "eiie?aoecaoey eeanna Array_RC_S"string>"
         <<  endl;
    try_array( SA );

    return 0;
}

Вот что печатает программа для класса, конкретизированного типом string (теперь ошибка выхода за границы массива перехватывается):

конкретизация класса Array_Sort<string>

try_array: начальные значения массива
( 7 )< Eeyore, Gopher, Heffalump, Owl, Piglet, Pooh
       Tigger >

try_array: после присваиваний
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
       Pooh >

try_array: почленная инициализация
( 7 )< Eeyore, Gopher, Owl, Piglet, Pooh, Pooh
       Pooh >

try_array: после почленного копирования
( 7 )< Eeyore, Piglet, Owl, Piglet, Pooh, Pooh
       Pooh >

try_array: после вызова grow
( 7 )< <empty>, <empty>, <empty>, <empty>, Eeyore, Owl
       Piglet, Piglet, Pooh, Pooh, Pooh >

искомое значение: Tigger           возвращенный индекс: -1
Assertion failed: ix >= 0 && ix < size

Представленная в этой главе реализация иерархии класса Array иллюстрирует применение множественного и виртуального наследования. Детально проектирование класса массива описано в [NACKMAN94]. Однако, как правило, достаточно класса vector из стандартной библиотеки.

Упражнение 18.16

Добавьте в Array функцию-член spy(). Она запоминает операции, примененные к объекту класса: число доступов по индексу; количество вызовов каждого члена; какой элемент искали с помощью find() и сколько было успешных поисков. Поясните свои проектные решения. Модифицируйте все подтипы Array так, чтобы spy() можно было использовать и для них тоже.

Упражнение 18.17

Стандартный библиотечный класс map (отображение) называют еще ассоциативным массивом, поскольку он поддерживает индексирование значением ключа. Как вы думаете, является ли ассоциативный массив кандидатом на роль подтипа нашего класса Array? Почему?

Упражнение 18.18

Перепишите иерархию Array, пользуясь контейнерными классами из стандартной библиотеки и применяя обобщенные алгоритмы.

Назад   Вперед
Содержание