Правила программирования на С и С++. Главы 7-8 - Возможности, определенные в базовом классе, должны использоваться <em>всеми</em> производными классами

ОГЛАВЛЕНИЕ

 

100. Возможности, определенные в базовом классе, должны использоваться всеми производными классами.

101. С++ - это не Smalltalk: избегайте общего класса object.

Процесс разработки иерархии снизу вверх обычно дает вам лес из маленьких деревьев, скорее широких, чем высоких. Построение иерархии снизу вверх поможет вам избежать общей проблемы для иерархий классов С++: класса object, от которого наследуется все в системе, как в Smalltalk. Такой проект хорош для Smalltalk, но, как правило, не работает в С++. Какое свойство мог бы реализовывать этот общий object? То есть какое свойство должен иметь каждый объект каждого класса в вашей программе? Единственное, что приходит на ум, это - управление памятью, способность объекта себя создать. Это делается в С++ посредством оператора new, который в действительности является функцией глобального уровня. Фактически вы можете смотреть на глобальный уровень С++, как на функциональный эквивалент object в Smalltalk. Хорошая иерархия классов С++ представляет собой обычно коллекцию иерархий меньшего размера. Процитируем такого авторитета, как самого Бьярна Страуструпа - создателя С++ - по этому поводу6 :

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

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

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

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

class employee

{

// содержит всю информацию, общую для всех служащих:

// фамилия, адрес и т.д.

};

class manager : public employee

{

// добавляет информацию, специфичную для управляющего, такую, как

// список подчиненных служащих. Управляющий тоже является служащим,

// поэтому применимо наследование

database list_of_managed_emploees;

}

class peon : public employee

{

// добавляет информацию, специфичную для поденщика

manager *this_boss;

}

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

class storable; // сохраняемый

class employee : public storable { /* ... */ };

class manager : public employee { /* ... */ };

class peon : public employee { /* ... */ };

Например, метод add() класса database мог бы получать указатель на объект storable в качестве своего аргумента. Таким способом любой объект storable (или объект, производный от storable) может быть добавлен в database без необходимости модифицировать что-либо в программе, в состав которой входит класс database.

Все кажется правильным до тех пор, пока мы реально не взглянем на то, как используются классы. Давайте скажем, что это средняя фирма, где число управляющих относится к числу поденщиков как 100 к 1. Однако списка управляющих нет, есть лишь список поденщиков. Тем не менее, каждый manager несет на себе излишнюю возможность сохраняемости, хотя она никогда не используется. Решим эту проблему при помощи множественного наследования.

class storable;

class employee { /* ... */ };

class manager : public employee { /* ... */ };

class peon : public employee, public storable { /* ... */ };

Проблема здесь в том, что эта "сохраняемость" является атрибутом объекта. Это не является базовым классом в стандартном смысле типа "круг является фигурой", а скорее - "поденщик является сохраняемым". Здесь важна замена существительного на прилагательное. Базовый класс, который реализует "свойство" типа сохраняемости, называется классом-смешением, потому что вы можете примешивать это свойство к тем классам, которым оно нужно, и только к этим классам. Хороший метод распознавания этих двух употреблений наследования состоит в том, что имя класса-смешения обычно выражено прилагательным (сохраняемый, сортируемый, устойчивый, динамический и т.д.). Именем настоящего базового класса обычно является существительное.

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

class parent {}; // родитель

class mother : public parent {}; // мать

class father : public parent {}; // отец

class child : public mother, public father {} // потомок

Здесь имеется две трудности. Если у parent есть метод для укладывания спать с названием go_to_sleep(), то вы получите ошибку, попытавшись послать такое сообщение: child philip; // Филипп - потомок

philip.go_to_sleep(); // Филипп, иди спать!

Проблема состоит в том, что в объекте child на самом деле два объекта parent. Запомните, что наследование просто добавляет поля (данные-члены) и обработчики сообщений (функции-члены). Объект mother имеет компонент parent: он содержит дополнительно к своим собственным все поля parent.7 То же самое относится и к father. Затем, у child есть mother и father, у каждого из которых есть parent. Проблема с philip.go_to_sleep() состоит в том, что компилятор не знает, какой из объектов parent должен получить это сообщение: тот, который в mother, или тот, который в father.8

Одним из путей решения этой проблемы является введение уточняющей функции, которая направляет сообщение нужному классу (или обоим):

class parent { public: go_to_sleep(); };

class mother : public parent {};

class father : public parent {};

class child : public mother, public father

{

public:

go_to_sleep()

{

mother::go_to_sleep();

father::go_to_sleep();

}}Другим решением является виртуальный базовый класс: class parent {};

class mother : virtual public parent {};

class father : virtual public parent {};

class child : public mother, public father {}

который заставляет компилятор помещать в объект child лишь один объект parent, совместно используемый объектами mother и father. Двусмысленность исчезает, но появляются другие проблемы. Во-первых, нет возможность показать на уровне потомка, хотите вы или нет виртуальный базовый класс. Например, в следующем коде tree_list_node может быть членом как дерева, так и списка одновременно: class node;

class list_node : public node {};

class tree_node : public node {};

class tree_list_node : public list_node, public tree_node {};

В следующем варианте tree_list_node может быть членом или дерева, или списка, но не обоих одновременно: class node;

class list_node : virtual public node {};

class tree_node : virtual public node {};

class tree_list_node : public list_node, public tree_node {};

Вам бы хотелось делать этот выбор при создании tree_list_node, но такой возможности нет. 

Второй проблемой является инициализация. Конструкторы в list_node и tree_node, вероятно, инициализируют базовый класс node, но разными значениями. Если имеется всего один node, то какой из конструкторов выполнит эту инициализацию? Ответ неприятный. Инициализировать node должен наследуемый последним производный класс (tree_list_node). Хотя это действительно плохая мысль - требовать, чтобы класс знал о чем-либо в иерархии, кроме своих непосредственных родителей - иначе было бы слишком сильное внутреннее связывание. 

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

class persistent

{

public:

virtual flush() = 0;};

class doc1: virtual public persistent

{

public:

virtual flush() { /* сохранить данные doc1 на диске */ }};

class doc2: virtual public persistent

{

public:

virtual flush() { /* сохранить данные doc2 на диске */ }};

class superdoc : public doc1, public doc2 {};

persistent *p = new superdoc();

p->flush(); // ОШИБКА: какая из функций flush() вызвана?