logo search
Языки программирования

15.5. Проектные соображения

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

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

with Airplane_Package;

package SS"f.Package is

type SST_Data is private;

private

type SST_Data is

record

A: Airplane. Data;

Mach: Float;

end record;

end SST_Package;

и в языке C++ класс может включать экземпляр другого класса как элемент:

class SST_Data {

private:

Airplane_Data a;

float mach;

};

Композиция — более простая операция, чем наследование, потому что для ее поддержки не требуется никаких новых конструкций языка; любая поддерж­ка инкапсуляции модуля автоматически дает вам возможности для композиции абстракций. Родовые единицы, которые в любом случае необхо­димы в языке с проверкой соответствия типов, также могут использоваться для формирования абстракций. Наследование, однако, требует сложной под­держки языка (теговых записей в языке Ada и виртуальных функций в языке C++) и дополнительных затрат при выполнении на динамическую диспетче­ризацию.

Если вам нужна динамическая диспетчеризация, то вы должны, конеч­но, выбрать наследование, а не композицию. Однако, если динамической диспетчеризации нет, выбор зависит только от решения вопроса, какой ме­тод дает «лучший» проект. Вспомните, что язык C++ требует, чтобы при со­здании базового класса вы решили, должна ли выполняться динамическая диспетчеризация, объявляя одну или несколько подпрограмм как виртуаль­ные; эти и только эти подпрограммы будут участвовать в диспетчеризации. В языке Ada 95 динамическая диспетчеризация потенциально произойдет в любой подпрограмме, объявленной с управляющим параметром тегового типа:

type T is tagged ...;

procedure Proc(Parm: T);

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

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

С другой стороны, при каждом доступе к закрытой абстракции должна вы­полняться подпрограмма интерфейса, в то время как наследование разрешает эффективный прямой доступ производным классам. Кроме того, вы можете изменить реализацию в производном классе, в то время как в композиции ог­раничены использованием существующей реализации. Говоря кратко: легко «купить» и «продать» модули для композиции, в то время как наследование делает вас «партнером» разработчика модуля.

Нет никакой опасности при аккуратном и продуманном использовании любого метода; проблемы могут возникнуть, когда наследование использует­ся беспорядочно, поскольку при этом может возникнуть слишком много за­висимостей между компонентами программной системы. Мы оставляем под­робное обсуждение относительных достоинств этих двух понятий специализированным работам по ООП. О преимуществах наследования см. книгу Мейера по конструированию объектно-ориентированного программного обеспечения (Meyer, Object-oriented Software Construction, Prentice-Hall International, 1988), особенно гл. 14 и 19. Сравните ее с точкой зрения предпочтения композиции, выраженной в статье J.P. Rosen, «What orien­tation should Ada objects take?» Communications of the ACM, 35(11), 1992, стр. 71—76.

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

Удобно разделить случаи применения наследования на несколько катего­рий:

Подобие поведения. SST ведет себя как Airplane. Это простое применение наследования для совместного использования кода: операции, подходя­щие для Airplane, подходят для SST. Операции при необходимости могут быть замещены.

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

Родовая совместимость. Общие свойства наследуются несколькими клас­сами. Эта методика применяется в больших библиотеках, таких как в языках Smalltalk или Eiffel, где общие свойства выносятся в классы-пре­дки, иногда называемые аспект-классами (aspect classes). Например, класс Comparable (сравнимый) мог бы использоваться для объявления таких операций отношения, как «<», и любой такой класс, как Integer или Float, обладающий такими операциями, наследуется из Compara­ble.

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

Эти категории не являются ни взаимоисключающими, ни исчерпывающи­ми; они представлены как руководство к использованию этой мощной конст­рукции в ваших программных проектах.

Перегрузка и полиморфизм

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

C++

void proc put(int);

void proc put(float);

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

C++

virtual void set_speed(int):

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

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

C++

class SST_Data : public Airplane_Data {

public:

void set_speed(float); //float, а не int

};

Правила языка C++ определяют, что эта подпрограмма и не перегружает, и не замещает подпрограмму в базовом классе; вместо этого она скрывает определение в базовом классе точно так же, как внутренняя область дей­ствия!

Язык Ada 95 допускает сосуществование перегрузки и замещения :

with Airplane_Package; use Airplane_Package;

package SST_Package is

Ada

type SSTJData is new Airplane_Data with ...

procedure Set_Speed(A: in out SST_Data; I: in Integer);

-- Замещает примитивную подпрограмму из Airplane_Package procedure Set_Speed(A: in out SST_Data; I: in Float);

-- Перегрузка, не подпрограмма-примитив

end SST_Package;

Поскольку нет примитивной подпрограммы Set_Speed с параметром Float для родительского типа, второе объявление — это просто самостоятельная подпрограмма, которая перегружает то же самое имя. Хотя это допустимо, этого следует избегать, потому что пользователь типа, скорее всего, запута­ется. Посмотрев только на SST_Package (и без комментариев!), вы не смо­жете сказать, какая именно подпрограмма замещается, а какая перегру­жается:

Ada

procedure Proc(A: Airplane_Data'Class) is

begin

Set_Speed(A, 500); -- Правильно, диспетчеризуется

Set_Speed(A, 500.0); -- Ошибка, не может диспетчеризоваться!

end Proc;