"Гради Буч. Объектно-ориентированный анализ и проектирование с примерами приложений на С++ (fb2) " - читать интересную книгу автора (Буч Гради)

3.4. Отношения между классами

Типы отношений

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

• Маргаритка - цветок.

• Роза - (другой) цветок.

• Красная и желтая розы - розы.

• Лепесток является частью обоих видов цветов.

• Божьи коровки питаются вредителями, поражающими некоторые цветы.

Из этого простого примера следует, что классы, как и объекты, не существуют изолированно. В каждой проблемной области ключевые абстракции взаимодействуют многими интересными способами, что мы и должны отразить в проекте [21].

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

Известны три основных типа отношений между классами [22]. Во-первых, это отношение "обобщение/специализация" (общее и частное), известное как "is-a". Розы суть цветы, что значит: розы являются специализированным частным случаем, подклассом более общего класса "цветы". Во вторых, это отношение "целое/ часть", известное как "part of". Так, лепестки являются частью цветов. В-третьих, это семантические, смысловые отношения, ассоциации. Например, божьи коровки ассоциируются с цветами - хотя, казалось бы, что у них общего. Или вот: розы и свечи - и то, и другое можно использовать для украшения стола.

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

• ассоциация

• наследование

• агрегация

• использование

• инстанцирование

• метакласс.

Альтернативой наследованию является делегирование, при этом объекты рассматриваются как прототипы, которые делегируют свое поведение родственным им объектам. Таким образом, классы становятся не нужны [23].

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

Наследование, вероятно, следует считать самым интересным семантически. Оно выражает отношение общего и частного. Однако, по нашему опыту, одного наследования недостаточно, чтобы выразить все многообразие явлений и отношений жизни. Полезна также агрегация, отражающая отношения целого и части между экземплярами классов. Нелишне добавить отношение использования, означающее наличие связи между экземплярами классов. Имея дело с языками Ada, Eiffel и C++, нам не обойтись без инстанцирования, которое, подобно наследованию, является специфической разновидностью обобщения. "Метаклассовые" отношения - это нечто совершенно иное, в явном виде встречающееся только в языках Smalltalk и CLOS. Метакласс - это класс классов, что позволяет нам трактовать классы как объекты.  

Рис. 3-4. Ассоциация.

Ассоциация

Пример. Желая автоматизировать розничную торговую точку, мы обнаруживаем две абстракции - товары и продажи. На рис. 3-4 показана ассоциация, которую мы при этом усматриваем. Класс Product - это то, что мы продали в некоторой сделке, а класс Sale - сама сделка, в которой продано несколько товаров. Надо полагать, ассоциация работает в обе стороны: задавшись товаром, можно выйти на сделку, в которой он был продан, а пойдя от сделки, найти, что было продано.

В C++ это можно выразить с помощью того, что Румбах называет погребенными указателями [24]. Вот две выдержки из объявления соответствующих классов:

class Product;

class Sale;

class Product { public: ... protected:

Sale* lastSale;

};

class Sale { public: ... protected:

Product** productSold;

};

Это ассоциация вида "один-ко-многим": каждый экземпляр товара относится только к одной последней продаже, в то время как каждый экземпляр Sale может указывать на совокупность проданных товаров.

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

Мощность. В предыдущем примере мы имели ассоциацию "один ко многим". Тем самым мы обозначили ее мощность (то есть, грубо говоря, количество участников). На практике важно различать три случая мощности ассоциации:

• "один-к-одному"

• "один-ко-многим"

• "многие-ко-многим".

Отношение "один-к-одному" обозначает очень узкую ассоциацию. Например, в розничной системе продаж примером могла бы быть связь между классом Sale и классом CreditCardTransaction: каждая продажа соответствует ровно одному снятию денег с данной кредитной карточки. Отношение "многие-ко-многим" тоже нередки. Например, каждый объект класса Customer (покупатель) может инициировать транзакцию с несколькими объектами класса Saleperson (торговый агент), и каждый торговый агент может взаимодействовать с несколькими покупателями. Как мы увидим в главе 5, все три вида мощности имеют разного рода вариации.

Наследование

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

class Time...

struct ElectricalData {

Time timeStamp; int id; float fuelCell1Voltage, fuelCell2Voltage; float fuelCell1Amperes, fuelCell2Amperes; float currentPower;

};

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

Дочерний класс может унаследовать структуру и поведение родительских классов.

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

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

class TelemetryData { public:

TelemetryData(); virtual ~TelemetryData(); virtual void transmit(); Time currentTime() const;

protected:

int id; Time timeStamp;

};

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

Теперь разберемся с ElectricalData:

class ElectricalData : public TelemetryData { public:

ElectricalData(float v1, float v2, float a1, float a2); virtual ~ElectricalData(); virtual void.transmit(); float currentPower() const;

protected:

float fuelCell1Voltage, fuelCell2Voltage; float fuelCell1Amperes, fuelCell2Amperes;

};

Этот класс - наследник класса TelemetryData, но исходная структура дополнена (четырьмя новыми элементами), а поведение - переопределено (изменена функция transmit). Кроме того, добавлена функция currentPower.

Одиночное наследование. Попросту говоря, наследование - это такое отношение между классами, когда один класс повторяет структуру и поведение другого класса (одиночное наследование) или других (множественное наследование) классов. Класс, структура и поведение которого наследуются, называется суперклассом. Так, TelemetryData. является суперклассом для ElectricalData. Производный от суперкласса класс называется подклассом. Это означает, что наследование устанавливает между классами иерархию общего и частного. В этом смысле ElectricalData является более специализированным классом более общего TelemetryData. Мы уже видели, что в подклассе структура и поведение исходного суперкласса дополняются и переопределяются. Наличие механизма наследования отличает объектно-ориентированные языки от объектных.

Подкласс обычно расширяет или ограничивает существующую структуру и поведение своего суперкласса. Например, подкласс GuardedQueue может добавлять к поведению суперкласса Queue операции, которые защищают состояние очереди от одновременного изменения несколькими независимыми потоками. Обратный пример: подкласс UnselectableDisplayItem может ограничить поведение своего суперкласса DisplayItem, запретив выделение объекта на экране. Часто подклассы делают и то, и другое.

Отношения одиночного наследования от суперкласса TelemetryData показаны на рис. 3-5. Стрелки обозначают отношения общего к частному. В частности, Cameradata - это разновидность класса SensorData, который в свою очередь является разновидностью класса TelemetryData. Такой же тип иерархии характерен для семантических сетей, которые часто используются специалистами по распознаванию образов и искусственному интеллекту для организации баз знаний [25]. В главе 4 мы покажем, что правильная организация иерархии абстракций - это вопрос логической классификации.  

Рис. 3-5. Одиночное наследование.

Можно ожидать, что для некоторых классов на рис. 3-5 будут созданы экземпляры, а для других - нет. Наиболее вероятно образование объектов самых специализированных классов ElectricalData и SpectrometerData (такие классы называют конкретными классами, или листьями иерархического дерева). Образование объектов из классов, занимающих промежуточное положение (SensorData или тем более TelemetryData), менее вероятно. Классы, экземпляры которых не создаются, называются абстрактными. Ожидается, что подклассы абстрактных классов доопределят их до жизнеспособной абстракции, наполняя класс содержанием. В языке Smalltalk разработчик может заставить подкласс переопределить метод, помещая в реализацию метода суперкласса вызов метода SubclassResponsibility. Если метод не переопределен, то при попытке выполнить его генерируется ошибка. Аналогично, в C++ существует возможность объявлять функции чисто виртуальными. Если они не переопределены, экземпляр такого класса невозможно создать.

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

У класса обычно бывает два вида клиентов [26]:

• экземпляры;

• подклассы.

Часто полезно иметь для них разные интерфейсы [27]. В частности, мы хотим показать только внешне видимое поведение для клиентов-экземпляров, но нам нужно открыть служебные функции и представления клиентам-подклассам. Этим объясняется наличие открытой, защищенной и закрытой частей описания класса в языке C++: разработчик может четко разделить, какие элементы класса доступны Для экземпляров, а какие для подклассов. В языке Smalltalk степень такого разделения меньше: данные видимы для подклассов, но не для экземпляров, а методы общедоступны (можно ввести закрытые методы, но язык не обеспечивает их защиту).

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

Наследование подразумевает, что подклассы повторяют структуры их суперклассов. В предыдущем примере экземпляры класса ElectricalData содержат элементы структуры суперкласса (id и timeStamp) и более специализированные элементы (fuelCell1Voltage, fuelCell2Voltage, fuelCell1Amperes, fuelCell2Amperes) [Некоторые языки объектно-ориентированного программирования, главным образом экспериментальные, позволяют подклассу сокращать структуру его суперкласса].

Поведение суперклассов также наследуется. Применительно к объектам класса ElectricalData можно использовать операции currentTime (унаследована от суперкласса), currentPower (определена в классе) и transmit (переопределена в подклассе). В большинстве языков допускается не только наследование методов суперкласса, но также добавление новых и переопределение существующих методов. В Smalltalk любой метод суперкласса можно переопределить в подклассе.

В C++ степень контроля за этим несколько выше. Функция, объявленная виртуальной (функция transmit в предыдущем примере), может быть в подклассе переопределена, а остальные (currentTime) - нет.

Одиночный полиморфизм. Пусть функция transmit класса TelemetryData реализована следующим образом:

void TelemetryData::transmit() {

// передать id // передать timeStamp

};

В классе ElectricalData та же функция переопределена:

void ElectricalData::transmit() {

TelemetryData::transmit(); // передать напряжение // передать силу тока

};

Эта функция сначала вызывает одноименную функцию суперкласса с помощью ее явно квалифицированного имени TelemetryData::transmit(). Та передаст заголовок пакета (id и timeStamp), после чего в подклассе передаются его собственные данные.

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

TelemetryData telemetry; ElectricalData electrical(5.0, -5.0, 3.0, 7.0);

Теперь определим свободную процедуру:

void transmitFreshData (TelemetryDataamp; d, const Timeamp; t) {

if (d.currentTime() gt;= t)

d.transmit();

);

Что произойдет, если выполнить следующие два оператора?

transmitFreshData(telemetry, Time(60)); transmitFreshData(electrical, Time(120));

В первом операторе будет передан уже известный нам заголовок. Во втором будет передан он же, плюс четыре числа в формате с плавающей точкой, содержащие результаты измерений электрических параметров. Почему это так? Ведь функция transmitFreshData ничего не знает о классе объекта, она просто выполняет d.transmit()! Это был пример полиморфизма. Переменная d может обозначать объекты разных классов. У этих классов есть общий суперкласс и они, хотя и по разному, могут реагировать на одно и то же сообщение, одинаково понимая его смысл.

Карделли и Вегнер заметили, что "традиционные типизированные языки типа Pascal основаны на той идее, что функции и процедуры, а следовательно, и операнды должны иметь определенный тип. Это свойство называется мономорфизмом, то есть каждая переменная и каждое значение относятся к одному определенному типу. В противоположность мономорфизму полиморфизм допускает отнесение значений и переменных к нескольким типам" [28]. Впервые идею полиморфизма ad hoc описал Страчи [29], имея в виду возможность переопределять смысл символов, таких, как "+", сообразно потребности. В современном программировании мы называем это перегрузкой. Например, в C++ можно определить несколько функций с одним и тем же именем, и они будут автоматически различаться по количеству и типам своих аргументов. Совокупность этих признаков называется сигнатурой функции; в языке Ada к этому списку добавляется тип возвращаемого значения. Страчи говорил также о параметрическом полиморфизме, который мы сейчас называем просто полиморфизмом.

При отсутствии полиморфизма код программы вынуждено содержит множество операторов выбора case или switch. Например, на языке Pascal невозможно образовать иерархию классов телеметрических данных; вместо этого придется определить одну большую запись с вариантами, включающую все разновидности данных. Для выбора варианта нужно проверить метку, определяющую тип записи. На языке Pascal процедура TransmitFreshData может быть написана следующим образом:

const

Electrical = 1; Propulsion = 2; Spectrometer = 3;

Procedure Transmit_Presh_Data(TheData: Data; The_Time: Time); begin

if (The_Data.Current_Time gt;= The_Time) then

case TheData.Kind of

Electrical: Transmit_Electrical_Data(The_Data); Propulsion: Transmit_Propulsion_Data(The_Data);

end;

end;

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

Наследование позволяет различать разновидности абстракций, и монолитные типы становятся не нужны. Каплан и Джонсон отметили, что "полиморфизм наиболее целесообразен в тех случаях, когда несколько классов имеют одинаковые протоколы" [30]. Полиморфизм позволяет обойтись без операторов выбора, поскольку объекты сами знают свой тип.

Наследование без полиморфизма возможно, но не очень полезно. Это видно на примере Ada, где можно объявлять производные типы, но из-за мономорфизма языка операции жестко задаются на стадии компиляции.

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

Наследование и типизация. Рассмотрим еще раз переопределение функции transmit:

void ElectricalData::transmit() {

TelemetryData::transmit(); // передать напряжение // передать силу тока

};

В большинстве объектно-ориентированных языков программирования при реализации метода подкласса разрешается вызывать напрямую метод какого-либо суперкласса. Как видно из примера, это допускается и в том случае, если метод подкласса имеет такое же имя и фактически переопределяет метод суперкласса. В Smalltalk метод вышестоящего класса вызывают с помощью ключевого слова super, при этом вызывающий может указывать на самого себя с помощью ключевого слова self. В C++ метод любого достижимого вышестоящего класса можно вызывать, добавляя имя класса в качестве префикса, формируя квалифицированное имя метода (как TelemetryData::transmit() в нашем примере). Вызывающий объект может ссылаться на себя с помощью предопределенного указателя this.

На практике метод суперкласса вызывается до или после дополнительных действий. Метод подкласса уточняет или дополняет поведение суперкласса [В CLOS эти различные роли метода выражаются явно с помощью дополнительных квалификаторов :before, :after или :around. Метод без дополнительного квалификатора считается первичным и выполняет основную работу, обеспечивающую требуемое поведение. Before-метод вызывается до первичного, after-метод - после первичного, around-метод действует как оболочка вокруг первичного метода, которая вызывается изнутри этого метода функцией call-next-method].

Все подклассы на рис. 3-5 являются также подтипами вышестоящего класса. В частности, ElectricalData является подтипом TelemetryData. Система типов, развивающаяся параллельно наследованию, обычна для объектно-ориентированных языков с сильной типизацией, включая C++. Для Smalltalk, который едва ли вообще можно считать типизированным, типы не имеют значения.  

 Поиск метода

Рассмотрим иерархию (рис. 3-6), в которой имеется базовый класс и три подкласса с именами circle, Triangle и Rectangle. Для класса Rectangle определен в свою очередь подкласс SolidRectangle. Предположим, что в классе DisplayItem определена переменная экземпляра theCenter (задающая координаты центра изображения), а также следующие операции:

• draw - нарисовать изображение;

• move - передвинуть изображение;

• location - вернуть координаты изображения.

Операция location является общей для всех подклассов и не требует обязательного переопределения. Однако, поскольку только подклассы могут знать, как их изображать и передвигать, операции draw и move должны быть переопределены.  

Рис. 3-6. Диаграмма класса DisplayItem.

Класс Circle имеет переменную theRadius и соответственно операции для установки (set) и чтения значения этой переменной. Для этого класса операция draw формирует изображение окружности заданного радиуса с центром в точке theCenter. В классе Rectangle есть переменные theHeight и theWidth и соответствующие операции установки и чтения их значений. Операция draw в данном случае формирует изображение прямоугольника заданной высоты и ширины с центром в заданной точке theCenter. Подкласс SolidRectangle наследует все особенности класса Rectangle, но операция draw в этом подклассе переопределена. Сначала вызывается draw вышестоящего класса, а затем полученный контур заполняется цветом.

Теперь рассмотрим следующий фрагмент программы:

DisplayItem* items[10];  for (unsigned index = 0; index lt; 10; index++) items[index]-gt;draw();

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

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

• получатель ищет селектор сообщения в словаре методов своего класса;

• если метод найден, то запускается его код;

• если нет, поиск производится в словаре методов суперкласса.

Таким образом, поиск распространяется вверх по иерархии и заканчивается на классе object, который является "предком" всех классов. Если метод не найден и там, посылается сообщение doesNotUnderstand, то есть, генерируется ошибка.

Главным действующим лицом в этом алгоритме является словарь методов. Он формируется при создании класса и, являясь частью его реализации, скрыт от клиентов. Вызов метода в Smalltalk требует примерно в 1.5 раза больше времени, чем вызов простой подпрограммы. В коммерческих версиях Smalltalk вызов методов ускорен на 20-30% за счет кеширования доступа к словарю [31].

Операция draw в подклассе solidRectangle представляет собой особый случай. Мы уже отмечали, что вначале вызывается одноименный метод суперкласса Rectangle. В Smalltalk для вызова метода суперкласса используется ключевое слово super. Поиск метода super draw начинается сразу с суперкласса.

Исследования Дейча дают основание полагать, что полиморфизм в 85% случаев не нужен, так что вызов метода часто можно свести к обычному вызову процедуры [32]. Дафф замечает, что в таких ситуациях программист часто делает неявные предположения, которые бы позволили раннее связывание [33]. К сожалению, в нетипизированных языках у него нет способа сообщить об этом компилятору.

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

В C++ операции для позднего связывания объявляются виртуальными (virtual), а все остальные обрабатываются компилятором как обычные вызовы подпрограмм. В нашем примере draw - виртуальная функция, a location - обычная. Есть еще одно средство, используя которое можно выиграть в скорости. Невиртуальные методы могут быть объявлены подставляемыми (inline), при этом соответствующая подпрограмма не вызывается, а явно включается в код на манер макроподстановки.

Для управления виртуальными функциями в C++ используется концепция vtable (виртуальных таблиц), которые формируются для каждого объекта при его создании (то есть когда класс объекта уже известен). Такая таблица содержит список указателей на виртуальные функции. Например, при создании объекта класса Rectangle виртуальная таблица будет содержать запись для виртуальной функции draw, содержащую указатель на ближайшую в иерархии реализацию функции draw. Если в классе DisplayItem есть виртуальная функция rotate, которая в классе Rectangle не переопределена, то соответствующий указатель для rotate останется связан с классом DisplayItem. Во время исполнения программы происходит косвенное обращение через соответствующий указатель и сразу выполняется нужный код без всякого поиска [34].

Операция draw в классе SolidRectangle представляет собой особый случай и в языке C++. Чтобы вызвать метод draw из суперкласса, применяется специальный префикс, указывающий на место определения функции. Это выглядит следующим образом:

Rectangle::Draw(); 

Исследование Страуструпа показало, что вызов виртуальной функции по эффективности мало уступает вызову обычной функции [35]. Для одиночного наследования вызов виртуальной функции требует дополнительно выполнения трех-четырех операций доступа к памяти по отношению к обычному вызову; при множественном наследовании число таких дополнительных операций составляет пять или шесть.

Существенно сложнее выполняется поиск нужных функций в языке CLOS, здесь используются дополнительные квалификаторы: : before, : after, : around. Наличие множественного полиморфизма еще более усложняет проблему. При вызове метода в языке CLOS, как правило, реализуется следующий алгоритм:

• Определяется тип аргументов. 

• Вычисляется множество допустимых методов. 

• Методы сортируются в направлении от наиболее специализированных к более общим. 

• Выполняются вызовы всех методов с квалификатором : before. 

• Выполняется вызов наиболее специализированного первичного метода. 

• Выполняются вызовы всех методов с квалификаторами : after. 

• Возвращается значение первичного метода [36]. 

В CLOS есть протокол для метаобъектов, который позволяет переопределять в том числе и этот алгоритм. На практике, однако, мало кто заходит так далеко. Как справедливо отметили Винстон и Хорн: "Алгоритмы, используемые в языке CLOS, сложны, и даже кудесники программирования стараются не вникать в их детали, так же как физики предпочитают иметь дело с механикой Ньютона, а не с квантовой механикой" [37].  

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

TelenetryData telemetry; ElectrycalData electrical(5.0, -5.0, 3.0, 7.0);

Следующий оператор присваивания правомочен:

telemetry = electrical; //electrical - это подтип telemetry

Хотя он формально правилен, он опасен: любые дополнения в состоянии подкласса по сравнению с состоянием суперкласса просто срезаются. Таким образом, дополнительные четыре параметра, определенные в подклассе electrical, будут потеряны при копировании, поскольку их просто некуда записать в объекте telemetry клacca TelemetryData.

Следующий оператор неправилен:

electrical = telemetry; //неправильно: telemetry - это не подтип electrical

Можно сделать заключение, что присвоение объекту y значения объекта x допустимо, если тип объекта x совпадает с типом объекта y или является его подтипом.

В большинстве строго типизированных языков программирования допускается преобразование значений из одного типа в другой, но только в тех случаях, когда между двумя типами существует отношение класс/подкласс. В языке C++ есть оператор явного преобразования, называемый приведением типов. Как правило, такие преобразования используются по отношению к объекту специализированного класса, чтобы присвоить его значение объекту более общего класса. Такое приведение типов считается безопасным, поскольку во время компиляции осуществляется семантический контроль. Иногда необходимы операции приведения объектов более общего класса к специализированным классам. Эти операции не являются надежными с точки зрения строгой типизации, так как во время выполнения программы может возникнуть несоответствие (несовместимость) приводимого объекта с новым типом [Новейшие усовершенствования C++, направленные на динамическое определение типа, смягчили эту проблему]. Однако такие преобразования достаточно часто используются в тех случаях, когда программист хорошо представляет себе все типы объектов. Например, если нет параметризованных типов, часто создаются классы set или bag, представляющие собой наборы произвольных объектов. Их определяют для некоторого базового класса (это гораздо безопаснее, чем использовать идиому void*, как мы делали, определяя класс Queue). Итерационные операции, определенные для такого класса, умеют возвращать только объекты этого базового класса. Внутри конкретного приложения разработчик может использовать этот класс, создавая объекты только какого-то специализированного подкласса, и, зная, что именно он собирается помещать в этот класс, может написать соответствующий преобразователь. Но вся эта стройная конструкция рухнет во время выполнения, если в наборе встретится какой-либо объект неожиданного типа.

Большинство сильно типизированных языков позволяют приложениям оптимизировать технику вызова методов, зачастую сводя пересылку сообщения к простому вызову процедуры. Если, как в C++, иерархия типов совпадает с иерархией классов, такая оптимизация очевидна. Но у нее есть недостатки. Изменение структуры или поведения какого-нибудь суперкласса может поставить вне закона его подклассы. Вот что об этом пишет Микаллеф: "Если правила образования типов основаны на наследовании и мы переделываем какой-нибудь класс так, что меняется его положение в иерархии наследования, клиенты этого класса могут оказаться вне закона с точки зрения типов, несмотря на то, что внешний интерфейс класса остается прежним" [38].

Тем самым мы подходим к фундаментальным вопросам наследования. Как было сказано выше, наследование используется в связи с тем, что у объектов есть что-то общее или между ними есть смысловая ассоциация. Выражая ту же мысль иными словами, Снайдерс пишет: "наследование можно рассматривать, как способ управления повторным использованием программ, то есть, как простое решение разработчика о заимствовании полезного кода. В этом случае механика наследования должна быть гибкой и легко перестраиваемой. Другая точка зрения: наследование отражает принципиальную родственность абстракций, которую невозможно отменить" [39]. В Smalltalk и CLOS эти два аспекта неразделимы. C++ более гибок. В частности, при определении класса его суперкласс можно объявить public (как ElectricalData в нашем примере). В этом случае подкласс считается также и подтипом, то есть обязуется выполнять все обязательства суперкласса, в частности обеспечивая совместимое с суперклассом подмножество интерфейса и обладая неразличимым с точки зрения клиентов суперкласса поведением. Но если при определении класса объявить его суперкласс как private, это будет означать, что, наследуя структуру и поведение суперкласса, подкласс уже не будет его подтипом [Мы можем также объявить суперкласс защищенным, что даст ту же семантику, что и в случае закрытого суперкласса, но открытые и защищенные элементы такого суперкласса будут доступны подклассам]. Это означает, что открытые и защищенные члены суперкласса станут закрытыми членами подкласса, и следовательно они будут недоступны подклассам более низкого уровня. Кроме того, тот факт, что подкласс не будет подтипом, означает, что класс и суперкласс обладают несовместимыми (вообще говоря) интерфейсами с точки зрения клиента. Определим новый класс:

class InternalElectricalData: private ElectricalData { public:

InternalElectricalData(float v1, float v2, float a1, float a2); virtual ~InternalElectricalData(); ElectricalData::currentPower;

};

Здесь суперкласс ElectricalData объявлен закрытым. Следовательно, его методы, такие, например, как transmit, недоступны клиентам. Поскольку класс InternalElectricalData не является подтипом ElectricalData, мы уже не сможем присваивать экземпляры подкласса объектам суперкласса, как в случае объявления суперкласса в качестве открытого. Отметим, что функция currentPower сделана видимой путем ее явной квалификации. Иначе она осталась бы закрытой. Как можно было ожидать, правила C++ запрещают делать унаследованный элемент в подклассе "более открытым", чем в суперклассе. Так, член timeStamp, объявленный в классе TelemetryData защищенным, не может быть сделан в подклассе открытым путем явного упоминания (как это было сделано для функции currentpower).

В языке Ada для достижения аналогичного эффекта вместо подтипов используется механизм производных типов. Определение подтипа означает не появление нового типа, а лишь ограничение существующего. А вот определение производного типа создает самостоятельный новый тип, который имеет структуру, заимствованную у исходного типа.

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

Множественное наследование. Мы рассмотрели вопросы, связанные с одиночным наследованием, то есть, когда подкласс имеет ровно один суперкласс. Однако, как указали Влиссидес и Линтон: "одиночное наследование при всей своей полезности часто заставляет программиста выбирать между двумя равно привлекательными классами. Это ограничивает возможность повторного использования предопределенных классов и заставляет дублировать уже имеющиеся коды. Например, нельзя унаследовать графический элемент, который был бы одновременно окружностью и картинкой; приходится наследовать что-то одно и добавлять необходимое от второго" [40].

Множественное наследование прямо поддерживается в языках C++ и CLOS, а также, до некоторой степени, в Smalltalk. Необходимость множественного наследования в OOP остается предметом горячих споров. По нашему опыту, множественное наследование - как парашют: как правило, он не нужен, но, когда вдруг он понадобится, будет жаль, если его не окажется под рукой.

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

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

Очевидно, одиночное наследование в данном случае не отражает реальности, так что придется прибегнуть к множественному [В действительности, это - "лакмусовая бумажка" для множественного наследования. Если мы составим структуру классов, в которой конечные классы (листья) могут быть сгруппированы в множества по разным ортогональным признакам (как в нашем примере, где такими признаками были способность приносить дивиденды и возможность страховки) и эти множества перекрываются, то это служит признаком невозможности обойтись одной структурой наследования, в которой бы существовали какие-то промежуточные классы с нужным поведением. Мы можем исправить ситуацию, используя множественное наследование, чтобы соединить два нужных поведения там, где это необходимо]. Получившаяся структура классов показана на рис. 3-7. На нем класс Security (ценные бумаги) наследует одновременно от классов InterestBearingItem (источник дивидендов) и Asset (имущество). Сходным образом, BankAccount (банковский счет) наследует сразу от трех классов: InsurableItem (страхуемое) и уже известным Asset и InterestBearingItem.

Вот как это выражается на C++. Сначала базовые классы:

class Asset ... class InsurableItem ... class InterestBearingItem ...

Теперь промежуточные классы; каждый наследует от нескольких суперклассов:

class BankAccount: public Asset, public InsurableItem, public InterestBearingItem ... class RealEstate: public Asset, public InsurableItem ... class Security: public Asset, public InterestBearingItem ...

Наконец, листья:

class SavingsAccount: public BankAccount ... class CheckingAccount: public BankAccount ... class Stock: public Security ... class Bond: public Security ...

Рис. 3-7. Множественное наследование.

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

Конфликт имен происходит, когда в двух или более суперклассах случайно оказывается элемент (переменная или метод) с одинаковым именем. Представьте себе, что как Asset, так и InsurableItem содержат атрибут presentValue, обозначающий текущую стоимость. Так как класс RealEstate наследует обоим этим классам, как понимать наследование двух операций с одним и тем же именем? Это, на самом деле, главная беда множественного наследования: конфликт имен может ввести двусмысленность в поведение класса с несколькими предками.

Борются с этим конфликтом тремя способами. Во-первых, можно считать конфликт имен ошибкой и отвергать его при компиляции (так делают Smalltalk и Eiffel, хотя в Eiffel конфликт можно разрешить, исправив имя). Во-вторых, можно считать, что одинаковые имена означают одинаковый атрибут (так делает CLOS). В третьих, для устранения конфликта разрешается добавить к именам префиксы, указывающие имена классов, откуда они пришли. Такой подход принят в C++ [В C++ конфликт имен элементов подкласса может быть разрешен полной квалификацией имени члена класса. Функции-члены с одинаковыми именами и сигнатурами семантическими считаются идентичными].

О второй проблеме, повторном наследовании, Мейер пишет следующее: "Одно тонкое затруднение при использовании множественного наследования встречается, когда один класс является наследником другого по нескольким линиям. Если в языке разрешено множественное наследование, рано или поздно кто-нибудь напишет класс D, который наследует от B и C, которые, в свою очередь, наследуют от A. Эта ситуация называется повторным наследованием, и с ней нужно корректно обращаться" [41]. Рассмотрим следующий класс:

class MutualFund: public Stock, public Bond ...

который дважды наследует от класса security.

Проблема повторного наследования решается тремя способами. Во-первых, можно его запретить, отслеживая при компиляции. Так сделано в языках Smalltalk и Eiffel (но в Eiffel, опять-таки допускается переименование для устранения неопределенности). Во-вторых, можно явно развести две копии унаследованного элемента, добавляя к именам префиксы в виде имени класса-источника (это один из подходов, принятых в C++). В-третьих, можно рассматривать множественные ссылки на один и тот же класс, как обозначающие один и тот же класс. Так поступают в C++, где повторяющийся суперкласс определяется как виртуальный базовый класс. Виртуальный базовый класс появляется, когда какой-либо подкласс именует другой класс своим суперклассом и отмечает этот суперкласс как виртуальный, чтобы показать, что это - общий (shared) класс. Аналогично, в языке CLOS повторно наследуемые классы "обобществляются" с использованием механизма, называемого список следования классов. Этот список заводят для каждого нового класса, помещая в него сам этот класс и все его суперклассы без повторений на основе следующих правил:

• класс всегда предшествует своему суперклассу;

• каждый класс сам определяет порядок следования своих непосредственных родителей.

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

При множественном наследовании часто используется прием создания примесей (mixin). Идея примесей происходит из языка Flavors: можно комбинировать (смешивать) небольшие классы, чтобы строить классы с более сложным поведением. Хендлер пишет об этом так: "примесь синтаксически ничем не отличается от класса, но назначение их разное. Примесь не предназначена для порождения самостоятельно используемых экземпляров - она смешивается с другими классами" [44]. На рис. 3-7 классы InsurableItem и interestBearingItem - это примеси. Ни один из них не может существовать сам по себе, они используются для придания смысла другим классам [Для языка CLOS при обогащении поведения существующих первичных методов обычной практикой является строить примесь, используя только :before- и :after-методы]. Таким образом, примесь - это класс, выражающий не поведение, а одну какую-то хорошо определенную повадку, которую можно привить другим классам через наследование. При этом повадка эта обычно ортогональна собственному поведению наследующего ее класса. Классы, сконструированные целиком из примесей, называют агрегатными.

Множественный полиморфизм. Вернемся к одной из функций-членов класса DisplayItem:

virtual void draw();

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

На самом деле операция draw должна бы зависеть от характеристик используемой системы отображения, в частности от графического режима. Например, в одном случае мы хотим получить изображение с высоким разрешением, а в другом - быстро получить черновое изображение. Можно ввести две различных операции, скажем, drawGraphic и drawText, но это не совсем то, что хотелось бы. Дело в том, что каждый раз, когда требуется учесть новый вид устройства, его надо проводить по всей иерархии надклассов для класса DisplayItem.

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

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

virtual void draw(DisplayDeviceamp;);

При реализации этого метода мы вызываем графические операции, которые полиморфны относительно переданного параметра типа DisplayItem, таким образом происходит двойная диспетчеризация: draw сначала демонстрирует полиморфное поведение в зависимости от того, к какому подклассу класса DisplayItem принадлежит объект, а затем полиморфизм проявляется в зависимости от того, к какому подклассу класса DisplayDevice принадлежит аргумент. Эту идиому можно продолжить до множественной диспетчеризации.

Агрегация

Пример. Отношение агрегации между классами имеет непосредственное отношение к агрегации между их экземплярами. Рассмотрим вновь класс TemperatureController:

class TemperatureController { public:

TemperatureController(Location); ~TemratureController(); void process(const TemperatureRampamp;); Minute schedule(const TemperatureRampamp;) const;

private:

Heater h;

};  

Рис. 3-8. Агрегация.

Как явствует из рис. 3-8, класс TemperatureController это, несомненно, целое, а экземпляр класса Heater - одна из его частей. Совершенно такое же отношение агрегации между экземплярами этих классов показано на рис. 3-3.

Физическое включение. В случае класса TemperatureController мы имеем агрегацию по значению; эта разновидность физического включения означает, что объект класса Heater не существует отдельно от объемлющего экземпляра класса TemperatureController.

Менее обязывающим является включение по ссылке. Мы могли бы изменить закрытую часть TemperatureController так [В качестве альтернативы мы могли бы описать h как ссылку на нагреватель (Heateramp; в C++), в этом случае семантика инициализации и модификации этого объекта будет совершенно отличной от семантики указателей]:

Heater* h;

В этом случае класс TemperatureController по-прежнему означает целое, но его часть, экземпляр класса Heater, содержится в целом косвенно. Теперь эти объекты живут отдельно друг от друга: мы можем создавать и уничтожать экземпляры классов независимо. Чтобы избежать структурной зависимости через ссылки важно придерживаться какой-то договоренности относительно создания и уничтожения объектов, ссылки на которые могут содержаться в разных местах. Нужно, чтобы это делал кто-то один.

Агрегация является направленной, как и всякое отношение "целое/часть". Объект Heater входит в объект TemperatureController, и не наоборот. Физическое вхождение одного в другое нельзя "зациклить", а вот указатели - можно (каждый из двух объектов может содержать указатель на другой).

Конечно, как уже говорилось, агрегация не требует обязательного физического включения, ни по значению, ни по ссылке. Например, акционер владеет акциями, но они не являются его физической частью. Более того, время жизни этих объектов может быть совершенно различным, хотя концептуально отношение целого и части сохраняется и каждая акция входит в имущество своего акционера. Поэтому агрегация может быть очень косвенной. Например, объект класса Shareholder (акционер) может содержать ключ записи об этом акционере в базе данных акций. Это тоже агрегация без физического включения. "Лакмусовая бумажка" для выявления агрегации такова: если (и только если) налицо отношение "целое/часть" между объектами, их классы должны находиться в отношении агрегации друг с другом.  

Рис. 3-9. Отношение использования.

Часто агрегацию путают с множественным наследованием. Действительно, в C++ скрытое (защищенное или закрытое) наследование почти всегда можно заменить скрытой агрегацией экземпляра суперкласса. Решая, с чем вы имеете дело - с наследованием или агрегацией - будьте осторожны. Если вы не уверены, что налицо отношение общего и частного (is а), вместо наследования лучше применить агрегацию или что-нибудь еще.

Использование

Пример. В недавнем примере объекты rampController и growingRamp иллюстрировали связь между объектами, которую мы представляли в виде отношения использования между их классами TemperatureController и TemperatureRamp.

class TemperatureController { public:

TemperatureController(Location); ~TemperatureController(); void process(const TemperatureRampamp;); Minute schedule(const TemperatureRampamp;) const;

private:

Heater h;

};

Класс TemperatureRamp упомянут как часть сигнатуры функции-члена process; это дает нам основания сказать, что класс TemperatureController пользуется услугами класса TemperatureRamp.

Клиенты и серверы. Отношение использования между классами соответствует равноправной связи между их экземплярами. Это то, во что превращается ассоциация, если оказывается, что одна из ее сторон (клиент) пользуется услугами другой (сервера). Пример клиент-серверных отношений показан на рис. 3-9.

На самом деле, один класс может использовать другой по-разному. В нашем примере это происходит в сигнатуре интерфейсной функции. Можно представить, что TemperatureController внутри реализации функции schedule использует, например, экземпляр класса Predictor (предсказатель). Отношения целого и части тут ни при чем, поскольку этот объект не входит в объект TemperatureController, а только используется. В типичном случае такое отношение использования проявляет себя, если в реализации какой-либо операции происходит объявление локального объекта используемого класса.

Строгое отношение использования иногда несколько ограничительно, поскольку клиент имеет доступ только к открытой части интерфейса сервера. Иногда по тактическим соображениям мы должны нарушить инкапсуляцию, для чего, собственно, и служат "дружеские" отношения классов в C++.

Инстанцирование

Примеры. Наша первая попытка сконструировать класс Queue (очередь) была не особенно успешной, поскольку нам не удалось сделать его безопасным в отношении типов. Мы можем значительно усовершенствовать нашу абстракцию, если прибегнем к конструкции параметризованных классов, которая поддерживается языками C++ и Eiffel.

Templatelt;class Itemgt; class Queue { public:

Queue(); Queue(const Queuelt;Itemgt;amp;); virtual ~Queue(); virtual Queuelt;Itemgt;amp; operator=(const Queuelt;Itemgt;amp;); virtual int operator==(const Queuelt;Itemgt;amp;) const; int operator!=(const Queuelt;Itemgt;amp;) const; virtual void clear(); virtual void append(const Itemamp;); virtual void pop(); virtual void remove(int at); virtual int length() const; virtual int isEmpty() const; virtual const Itemamp; front() const; virtual int location(const void*);

protected: ... };

В этом новом варианте не используется идиома void*, вместо этого объекты помещаются в очередь и достаются из нее через класс item, объявленный как аргумент шаблона.

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

Queuelt;intgt; intQueue; Queuelt;DisplayItem*gt; itemQueue;

Объекты intQueue и itemQueue - это экземпляры совершенно различных классов, которые даже не имеют общего суперкласса. Тем не менее, они получены из одного параметризованного класса Queue. По причинам, которые мы объясним позже в главе 9, во втором случае мы поместили в очередь указатели. Благодаря этому, любые объекты подклассов DisplayItem, помещенные в очередь, не будут "срезаться", но сохранят свое полиморфное поведение.  

Рис. 3-10. Инстанцирование.

Это инстанцирование безопасно с точки зрения типов. По правилам C++ будет отвергнута любая попытка поместить в очередь или извлечь из нее что-либо кроме, соответственно, целых чисел и разновидностей DisplayItem.

Отношения между параметризованным классом Queue, его инстанцированием для класса DisplayItem и экземпляром itemQueue показаны на рис. 3-10.

Обобщенные классы. Существует четыре основных способа создавать такие классы, как параметризованный класс Queue. Во-первых, мы можем использовать макроопределения. Именно так это было в раннем C++, но, как пишет Страуструп, "данный подход годился только для небольших проектов" [45], так как макросы неуклюжи и находятся вне семантики языка, более того, при каждом инстанцировании создается новая копия программного кода. Во-вторых, можно положиться на позднее связывание и наследование, как это делается в Smalltalk [46]. При таком подходе мы можем строить только неоднородные контейнерные классы, так как в языке нет средства ввести нужный класс элементов контейнера; каждый элемент в контейнере трактуется как экземпляр некоторого удаленного базового класса. Третий способ реализован в языках семейства Object Pascal, которые имеют и сильные типы, и наследование, но не поддерживают никакой разновидности параметризованных классов. В этом случае приходится создавать обобщенные контейнеры, как в Smalltalk, но использовать явную проверку типа объекта, прежде чем помещать его в контейнер. Наконец, есть собственно параметризованные классы, впервые появившиеся в CLU. Параметризованный класс представляет собой что-то вроде шаблона для построения других классов; шаблон может быть параметризован другими классами, объектами или операциями. Параметризованный класс должен быть инстанцирован перед созданием экземпляров. Механизм обобщенных классов есть в C++ и Eiffel.

Как можно заметить из рис. 3-10, чтобы инстанцировать параметризованный класс Queue мы должны использовать другой класс, например, DisplayItem. Благодаря этому отношение инстанцирования почти всегда подразумевает отношение использования.

Мейер указывает, что наследование - более мощный механизм, чем обобщенные классы и что через наследование можно получить большинство преимуществ обобщенных классов, но не наоборот [47]. Нам кажется, что лучше, когда языки поддерживают и то, и другое.

Параметризованные классы полезны далеко не только для создания контейнеров. Например, Страуструп отмечает их значение для обобщенной арифметики [48].

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

Метаклассы

Как было сказано, любой объект является экземпляром какого-либо класса. Что будет, если мы попробуем и с самими классами обращаться как с объектами? Для этого нам надо ответить на вопрос, что же такое класс класса? Ответ - это метакласс. Иными словами, метакласс - это класс, экземпляры которого суть классы. Метаклассы венчают объектную модель в чисто объектно-ориентированных языках. Соответственно, они есть в Smalltalk и CLOS, но не в C++.

Вот как Робсон мотивирует потребность в метаклассах: "классы доставляют программисту интерфейс для определения объектов. Если так, то желательно, чтобы и сами классы были объектами, так, чтобы ими можно было манипулировать, как всеми остальными описаниями" [49].

В языках типа Smalltalk первичное назначение метакласса - поддержка переменных класса (которые являются общими для всех экземпляров этого класса), операции инициализации переменных класса и создания единичного экземпляра метакласса [50]. По соглашению, метакласс Smalltalk обычно содержит примеры использования его классов. Например, как показано на рис. 3-11, мы могли бы задать переменную класса nextId для метакласса TelemetryData, чтобы вырабатывать идентифицирующие метки при создании каждого экземпляра TelemetryData. Аналогично, мы могли бы определить оператор порождения новых экземпляров класса, который изготавливал бы их, скажем, в некотором предварительно выделенном пуле памяти.

Хотя в C++ метаклассов нет, семантика его конструкторов и деструкторов служит целям, аналогичным тем, что вызвали к жизни метаклассы. C++ имеет средства поддержки и переменных класса, и операций метакласса. Конкретно, в C++ можно описать члены данных или функции класса как статические (static), что будет означать: этот элемент является общим для всех экземпляров класса. Статические члены класса в C++ эквивалентны переменным класса в Smalltalk. Статическая функция-член класса играет роль операций метакласса в Smalltalk.

Как мы уже отмечали, в CLOS аппарат метаклассов еще сильнее чем в Smalltalk. Через него можно изменять саму семантику элементов: следование классов, обобщенные функции и методы. Главное преимущество - возможность экспериментировать с другими объектно-ориентированными парадигмами и создавать такие инструменты для разработчика, как броузеры классов и объектов.  

Рис. 3-11. Метаклассы.

В CLOS есть предопределенный класс с именем standard-class, который является метаклассом для всех нетипизированных классов, определенных с помощью defclass. В этом метаклассе есть метод make-instance, который создает экземпляры. Кроме того, в нем определена вся техника работы со списком следования классов. Все это можно изменить.

Методы и обобщенные функции в CLOS тоже можно рассматривать как объекты. Так как они несколько отличаются от обычных объектов, то в совокупности объекты, соответствующие классам, методам и обобщенным функциям, называются метаобьектами. Каждый метод является экземпляром предопределенного класса standard-method, а каждая функция является экземпляром предопределенного класса standard-generic-function. Поскольку поведение этих предопределенных классов можно изменить, удается влиять на трактовку методов и обобщенных функций.