Глава     7

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


Разделы

Содержание

Первым шагом при изучении объектно-ориентированного программирования было осознание задачи как взаимодействия программных компонент. Этот организационный подход был главным при разборе примеров в главе═5 и═главе 6.

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

7.1. Интуитивное описание наследования

Разделы

Давайте вернемся к Фло═≈ хозяйке цветочного магазина из первой главы. Мы вправе ожидать от нее вполне определенного поведения не потому, что она хозяйка именно цветочного магазина, а потому, что она хозяйка магазина. Например, Фло попросит вас оплатить заказ, а затем даст вам квитанцию. Эти действия не являются уникальными для владельца цветочного магазина; они общие для булочников, бакалейщиков, продавцов канцелярских товаров и автомобилей и т.═д. Таким образом, мы как бы связали определенное поведение с общей категорией ╚хозяева магазинов╩ Shopkeeper, и поскольку хозяева (и хозяйки) цветочных магазинов (Florist) являются частным случаем категории Shopkeeper, поведение для данного подкласса определяется автоматически.

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

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

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

7.2. Подкласс, подтип и принцип подстановки

Разделы

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

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

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

Как мы увидим в главе═10, термин подтип часто применяется к такой связи ╚класс≈подкласс╩, для которой выполнен принцип подстановки, в отличие от общего случая, в котором этот принцип не всегда удовлетворяется.

Мы видели применение принципа подстановки в главе═6. В разделе═6.4 описывалась следующая процедура:

procedure drawBoard;
var
   gptr : GraphicalObject;

begin
   SetPort (theWindow);

   (* нарисовать все графические объекты *)
   gptr := listOfObjects;
   while gptr <> nil do
   begin
      gprt.draw;
      gptr := gptr.link;
   end;
end;

Глобальная переменная listOfObjects относится к списку графических объектов одного из трех типов. Переменная gptr объявлена просто как графический объект, хотя во время выполнения тела цикла она принимает значения, которые фактически представляют собой объекты порожденных подклассов. В одном случае gptr содержит шар, в другом═≈ лузу, а в третьем═≈ стенку. При обращении к функции draw всегда вызывается подходящий метод для текущего значения gptr, отличный от метода объявленного класса GraphicalObject. Для того чтобы эта процедура работала верно, необходимо, чтобы функциональные возможности каждого из подклассов соответствовали ожидаемым функциональным обязанностям, определенным для родительского класса; то есть подклассы должны быть также и подтипами.

7.2.1. Подтипы и строгий контроль типов данных

Подкласс, подтип и принцип подстановки

Языки программирования со статическими типами данных (такие, как C++ и Object Pascal) делают более сильный упор на принцип подстановки, чем это имеет место в языках с динамическими типами данных (Smalltalk и Objective-C). Причина этого в том, что языки со статическими типами данных склонны характеризовать объекты через приписанные им классы, тогда как языки с динамическими типами данных═≈ через их поведение. Например, полиморфная функция (функция, которая может принимать в качестве аргументов объекты различных классов) в языке со статическими типами данных может обеспечить должный уровень функциональных возможностей, только потребовав, чтобы все аргументы были подклассами нужного класса. Поскольку в языке с динамическими типами данных аргументы вовсе не имеют типа, подобное требование просто означало бы, что аргумент должен уметь отвечать на определенный набор сообщений. Дальнейшее обсуждение статических и динамических типов данных мы проведем в главе═10, а детальное изучение полиморфизма═≈ в главе═14.

7.3. Формы наследования

Разделы

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

В следующих разделах 7 обратите особое внимание на то, когда наследование обеспечивает порождение подтипов, а когда═≈ нет.

Комментарий
7 Описанные здесь категории взяты из [Halbert 1987], хотя я добавил несколько собственных. Пример ╚редактируемого окна╩ взят из [Meyer 1988a].

7.3.1. Порождение подклассов для специализации (порождение подтипов)

Формы наследования

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

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

7.3.2. Порождение подкласса для спецификации

Формы наследования

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

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

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

В примере, имитирующем игру бильярд, в главе═6 класс графических объектов GraphicalObject является абстрактным классом, поскольку он не реализует методы отображения объектов и их реакцию на соприкосновение с шаром. Последующие классы Ball, Wall и Hole используют порождение подклассов для спецификации, обеспечивая реальное воплощение этих методов.

В общем случае порождение подклассов для спецификации распознается по тому, что фактическое поведение не определено в родительском классе═≈ оно только описано и будет реализовано в дочернем классе.

7.3.3. Порождение подкласса с целью конструирования

Формы наследования

Часто класс наследует почти все функциональное поведение родительского класса, изменяя только имена методов или определенным образом модифицируя аргументы. Это может происходить даже в том случае, когда новому и родительскому классам не удается сохранить между собой отношение ╚быть экземпляром╩ (is-a relationship).

Например, иерархическая структура классов в языке Smalltalk реализует обобщение массива, называемое Dictionary (словарь). Словарь представляет собой набор пар ╚ключ√значение╩; ключи могут быть произвольными. Таблицу символов, которую можно было бы использовать в компиляторе, разумно представить словарем, в котором индексами служат символические имена, а значения имеют фиксированный формат, определенный для отдельных записей этой таблицы. Следовательно, класс SymbolTable (таблица символов) должен быть порожден из класса Dictionary введением новых методов, специфичных для таблицы символов. Другим примером может служить построение совокупности абстрактных данных на основе базового класса, обеспечивающего методы работы со списками. В обоих случаях дочерний класс не является более специализированной формой родительского класса, так как у нас и в мыслях не будет подставлять представителей дочернего класса туда, где используются представители родительского класса.

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

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

class Storable
{
   void writeByte(unsigned char);
}

class StoreMyStruct : public Storable
{
   void writeStruct (MyStruct &aStruct);
}

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

Мы изучим конкретный вариант порождения с целью конструирования в главе═8. Там же мы узнаем, что язык программирования C++ предоставляет интересный механизм: закрытое наследование, который позволяет порождать подклассы для конструирования без нарушения принципа подстановки.

7.3.4. Порождение подкласса для обобщения

Формы наследования

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

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

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

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

7.3.5. Порождение подкласса для расширения

Формы наследования

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

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

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

7.3.6. Порождение подкласса для ограничения

Формы наследования

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

Допустим, существующая библиотека классов предоставляет структуру данных Deque (double-ended-queue, очередь с двумя концами). Элементы могут добавляться или удаляться с любого конца структуры типа Deque, но программист желает создать класс Stack, вводя требование добавления или удаления элементов только с одного конца стека.

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

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

7.3.7. Порождение подкласса для варьирования

Формы наследования

Порождение подкласса для варьирования применяется, когда два класса имеют сходную реализацию, но не имеют никакой видимой иерархической связи между абстрактными понятиями, ими представляемыми. Например, программный код для управления мышкой может быть почти идентичным тому, что требуется для управления графическим планшетом. Теоретически, однако, нет никаких причин, для того чтобы класс Mouse, управляющий манипулятором ╚мышь╩, был подклассом класса Tablet, контролирующего графический планшет, или наоборот. В таком случае в качестве родителя произвольно выбирается один из них, при этом другой наследует общую программную часть кода и переопределяет код, зависящий от устройства.

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

7.3.8. Порождение подкласса для комбинирования

Формы наследования

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

7.3.9. Краткое перечисление форм наследования

Формы наследования

Мы можем подвести итог изучению различных форм наследования в виде следующей таблицы:

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

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

Конструирование. Дочерний класс использует методы, предоставляемые родительским классом, но не является подтипом родительского класса (реализация методов нарушает принцип подстановки).

Обобщение. Дочерний класс модифицирует или переопределяет некоторые методы родительского класса с целью получения объекта более общей категории.

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

Ограничение. Дочерний класс ограничивает использование некоторых методов родительского класса.

Варьирование. Дочерний и родительский классы являются вариациями на одну тему, и связь ╚класс≈подкласс╩ произвольна.

Комбинирование. Дочерний класс наследует черты более чем одного родительского класса. Это═≈ множественное наследование; оно будет рассмотрено в одной из следующих глав.

7.4. Наследование в различных языках программирования

Разделы

В следующих разделах мы подробно опишем синтаксис, используемый для описания наследования в каждом из рассматриваемых языков программирования. Обратите внимание на разницу между теми языками, которые требуют, чтобы все классы были порождениями общего родительского класса (обычно называемого Object, как в языках Smalltalk и Objective-C), и теми, которые допускают различные независимые иерархии классов.

Преимущество наследования с единым предком в том, что функциональные возможности последнего (класса Object) наследуются всеми объектами. Таким образом гарантируется, что каждый объект обладает общим минимальным уровнем функциональности. Минус в том, что единая иерархия ╚зацепляет╩ все классы друг с другом.

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

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

7.4.1. Наследование в языке Object Pascal

Наследование в различных языках программирования

В языке программирования Object Pascal фирмы Apple наследование от родительского класса указывается помещением его имени в круглые скобки после ключевого слова object. Предположим, например, что в нашей имитации бильярда мы решили породить классы Ball, Wall и Hole от общего класса GraphicalObject. Мы сделаем это так, как показано в листинге═7.1.

Листинг═7.1.
Пример наследования в языке Object Pascal фирмы Apple
type

  GraphicalObject = object

    (* поля данных *)
    region : Rect;
    link  : GraphicalObject;

    (* операции *)
    procedure draw;
    procedure update;
    procedure hitBy(aBall : Ball);

  end;

  Ball = object(GraphicalObject)

    (* поля данных *)
    direction : real;
    energy  : real;

    (* инициализация *)
    procedure initialize (x, y : integer);

    (* переопределяемые методы *)
    procedure draw;  override;
    procedure update; override;
    procedure hitBy(aBall : Ball); override;

    (* методы, специфичные для класса *)
    procedure erase;
    procedure setCenter(newx, newy : integer);
    function x : integer;
    function y : integer;

end;

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

Версия языка Object Pascal фирмы Borland (система Delphi) отличается в двух важных отношениях. Во-первых, как мы видели в предыдущих главах, вместо ключевого слова object используется слово class, и классы всегда должны выводиться один из другого. Класс TObject═≈ общий предок всех объектов. Во-вторых, в дополнение к ключевому слову override директива virtual помещается после описания тех методов родительского класса, которые могут быть переопределены ≈ почти как в языке C++. Пример, иллюстрирующий эти изменения, приведен в листинге═7.2. Пропуск ключевого слова override является частым источником ошибок, поскольку описание остается синтаксически законным, а его интерпретация неверной. Это мы обсудим подробнее в главе 10.

Листинг═7.2.
Пример наследования в языке Delphi Pascal
type

  GraphicalObject = class(TObject)

    (* поля данных *)
    region : Rect;
    link  : GraphicalObject;

    (* операции *)
    procedure draw; virtual;
    procedure update; virtual;
    procedure hitBy(aBall : Ball); virtual;

  end;

Ball = class(GraphicalObject)

    (* поля данных *)
    direction : real;
    energy  : real;

    (* инициализация *)
    procedure initialize (x, y : integer);

    (* переопределяемые методы *)
    procedure draw;  override;
    procedure update; override;
    procedure hitBy(aBall : Ball); override;

    (* методы, специфичные для класса *)
    procedure erase;
    procedure setCenter(newx, newy : integer);
    function x : integer;
    function y : integer;

end;

Более существенным различием между языками программирования Delphi Pascal и Apple Object Pascal является введение динамических методов. Они используют другие механизмы поиска во время выполнения программы (больше напоминающие Objective-C, чем C++; см. главу═21). Это делает динамические методы более медленными, чем виртуальные, но они требуют меньше памяти. Ключевое слово dynamic вместо virtual показывает, что объявляется динамический метод. Многие методы, связанные с действиями операционной системы по управлению окнами, реализованы как динамические. Значение термина сообщение часто ограничивается только действиями, связанными с управлением окнами.

7.4.2. Наследование в языке Smalltalk

Наследование в различных языках программирования

Как отмечено нами в главе═3, наследование как средство создания новых классов обязательно в языке Smalltalk. Новый класс не может быть определен без предварительного описания существующего класса, которому он наследует. Фактически новый класс создается с помощью сообщения родительскому классу.

List subclass: #Set
   instanceVariables: #()
   classVariables: #()

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

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

7.4.3. Наследование в языке Objective-C

Наследование в различных языках программирования

Как и в языке Smalltalk, для Objective-C наследование является неотъемлемой частью формирования нового класса. Описание интерфейса каждого нового класса должно определить предка, от которого происходит наследование. Следующий пример показывает, что класс игральных карт Card происходит от универсального класса Object:

@interface Card : Object
{
 . . .
}
 . . .
@end

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

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

7.4.4. Наследование в языке C++

Наследование в различных языках программирования

В отличие от Smalltalk и Objective-C новый класс в языке программирования C++ не обязан происходить от уже существующего класса. Наследование указывается в заголовке описания класса с помощью ключевого слова public, за которым следует имя родительского класса. Новый класс TablePile происходит от более общего класса CardPile, представляющего собой колоду карт:

class TablePile : public CardPile
{
 . . .
};

Ключевое слово public может быть заменено на слово private, указывающее на порождение класса для конструирования, то есть на форму наследования, не создающую подтипа. Такой пример будет рассмотрен в главе═11.

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

TablePile::TablePile (int x, int y, int c)
 : CardPile(x,y) // инициализация родителя
{
   // теперь инициализируем дочерний класс
   . . .
}

Язык программирования C++ поддерживает множественное наследование, то есть новый класс может быть определен как потомок двух или более родителей. Мы исследуем смысл этого более подробно в следующей главе.

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

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

7.4.5. Наследование в языке Java

Наследование в различных языках программирования

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

class window 
{
  // . . .
}

class textEditWindow extends window
{
  // . . .
}

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

Все классы происходят от единого предка Object. Если родительский класс не указан явно, то предполагается класс Object. Таким образом, определение класса window, приведенное выше, эквивалентно следующей записи:

class window extends Object
{
  // . . .
}

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

public interface Storing
{
   void writeOut(Stream s);
   void readFrom(Stream s);
}

Интерфейс определяет новый тип. Это означает, что переменные могут быть объявлены просто с именем интерфейса. А класс может указать, что он реализует протокол, определенный интерфейсом. Представители класса могут присваиваться переменным, объявленным с типом интерфейса, точно так же, как представители дочернего класса могут присваиваться переменным, объявленным с типом родительского класса:

public class BitImage implements Storing
{
  void writeOut (Stream s)
   {
      // . . .
   };

   void readFrom (Stream s)
   {
      // . . .
   };
};

Хотя язык Java поддерживает только одиночное наследование (наследование исключительно от одного родительского класса), класс может указывать, что он поддерживает несколько интерфейсов (реализует множественный интерфейc). Многие проблемы, для которых в языке C++ пришлось бы использовать множественное наследование, в языке программирования Java разрешаются через множественные интерфейсы. Интерфейсам позволено расширять другие интерфейсы, в том числе и множественные, через указание ключевого слова extend.

В языке Java идея порождения подкласса для спецификации формализована через модификатор abstract. Если класс объявлен как abstract, то из него должны порождаться подклассы. Не разрешается создавать представителей абстрактного класса, можно только порождать подклассы. Методы тоже могут быть объявлены как abstract, и в таком случае они не обязаны иметь реализацию. Таким образом, объявление класса как abstract обеспечивает, что он будет использоваться только как спецификация поведения, а не в виде конкретных объектов:

abstract class storable
{
   public abstract writeOut();
}

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

final class newClass extends oldClass
{
   . . .
}

7.5. Преимущества наследования

Разделы

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

7.5.1. Повторное использование программ

Преимущества наследования

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

Другое преимущество повторно используемого кода═≈ в его повышенной надежности (чем в большем числе ситуаций используется код, тем больше возможностей обнаружения ошибок) и низкой стоимости, так как она делится на всех пользователей кода.

7.5.2. Использование общего кода

Преимущества наследования

При применении объектно-ориентированной техники использование общего кода происходит на нескольких уровнях. Во-первых, клиенты могут пользоваться одними и теми же классами (Бред Кокс [Cox═1986] называет их software-IC, то есть программными интегральными схемами, по аналогии с аппаратными интегральными схемами). Иная форма использования общего кода возникает в случае, когда два или более класса, разработанных тем же самым программистом для некоторого проекта, наследуют от единого родительского класса. Например, множество Set и массив Array могут рассматриваться как разновидности совокупности данных Collection. В этом случае два или более типов объектов совместно используют наследуемый код. Он пишется единожды и входит в программу только в одном месте.

7.5.3. Согласование интерфейса

Преимущества наследования

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

7.5.4. Программные компоненты

Преимущества наследования

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

7.5.5. Быстрое макетирование

Преимущества наследования

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

7.5.6. Полиморфизм и структура

Преимущества наследования

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

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

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

7.5.7. Маскировка информации

Преимущества наследования

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

7.6. Издержки наследования

Разделы

Хотя преимущества наследования в объектно-ориентированном программировании несомненны, ничего не дается даром. По этой причине мы должны рассмотреть издержки технических средств объектно-ориентированного программирования═≈ в частности, наследования.

7.6.1. Скорость выполнения

Издержки наследования

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

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

Комментарий
9 Следующая цитата из статьи Билла Вульфа предлагает удачное замечание по поводу важности эффективности: ╚Во имя эффективности (как правило, эфемерной) совершается больше программных ошибок, чем по какой-либо другой причине, включая полную тупость╩ [Wulf 1972].

7.6.2. Размер программ

Издержки наследования

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

Комментарий
10 Стоит, однако, привести и другую точку зрения. Следующие цитаты принадлежат Алану Голубу ≈ программисту, консультанту и преподавателю, специализирующемуся в области ООП: ╚Разбухание программ является огромной проблемой. Жесткий диск в 350═Мб на моей машине может вместить операционную систему, усеченную версию компилятора и редактор, и больше ничего. В стародавние времена я мог разместить версии CP/M для тех же программ на одной-единственной дискете в 1,2 Мб... Я убежден, что большая часть этого разбухания памяти является результатом небрежного программирования╩; ╚Если только вы не проникнетесь сознанием необходимости дисциплинировать себя, то можете закончить гигантским модулем из неподдающейся сопровождению тарабарщины, только притворяющейся компьютерной программой╩. ≈ Примеч. перев.

7.6.3. Накладные расходы на посылку сообщений

Издержки наследования

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

часто минимально═≈ два или три дополнительных ассемблерных оператора и общее увеличение времени на═10 процентов. Результаты замеров скорости различны для разных языков. Накладные расходы на посылку сообщений больше в языках программирования с динамическими типами данных (например, Smalltalk) и гораздо меньше в языках со статическими типами (C++, в частности). Эти затраты, как и другие, должны рассматриваться на фоне многих преимуществ объектно-ориентированной техники.

Некоторые языки программирования, и особенно C++, предоставляют программистам некоторое количество опций, дающих возможность уменьшить накладные расходы на посылку сообщений. Они включают в себя исключение полиморфизма из сообщений (при указании имени класса в вызовах функций) и поддержку встраиваемых (inline) процедур. Подобным образом программист на языке Delphi Pascal может выбрать методы, описанные с помощью ключевого слова dynamic, которые будут использовать механизм поиска во время выполнения или использовать методы с ключевым словом virtual, которые применяют несколько более быструю технику. Динамические методы более медленны при наследовании, но требуют меньше памяти.

7.6.4. Сложность программ

Издержки наследования

Хотя объектно-ориентированное программирование часто выдвигается как способ разрешения проблемы сложности программного обеспечения, необдуманное использование наследования может часто просто заменить одну форму сложности на другую. Для понимания программы, использующей наследование, может потребоваться несколько сложных переходов вверх и вниз в иерархическом дереве. Эта проблема известна под именем ╚вверх-вниз╩, или ╚йо-йо╩. Мы обсудим ее в следующей главе.

Упражнения

Разделы

  1. Предположим, вам требуется написать программный проект на языке программирования, который не является объектно-ориентированным (например, Pascal или C). Как бы вы имитировали классы и методы? Как бы вы имитировали наследование? Сможете ли вы обеспечить множественное наследование? Обоснуйте свой ответ.
  2. Мы указывали, что накладные расходы, связанные с посылкой сообщений, обычно больше, чем при традиционном вызове процедур. Как вы могли бы их измерить? Для языка программирования, поддерживающего и классы, и процедуры (C++ или Object Pascal), придумайте эксперимент для определения фактических затрат на посылку сообщений.
  3. Рассмотрите три геометрических понятия: линия (бесконечна в обоих направлениях), луч (начало в фиксированной точке, бесконечен в одном направлении), сегмент (отрезок прямой с фиксированными концами). Как бы вы построили классы, представляющие эти три понятия, в виде иерархии наследования? Будет ли ваше решение другим, если вы обратите особое внимание на представление данных (на поведение)? Охарактеризуйте тип наследования, который вы использовали. Объясните ваше решение.
  4. Почему использованный в следующем рассуждении пример не является верной иллюстрацией наследования?

Видимо, наиболее важным понятием в объектно-ориентированном программировании является наследование. Объекты могут наследовать свойства других объектов, тем самым ликвидируется необходимость написания какого-либо кода! Предположим, например, что программа должна обрабатывать комплексные числа, состоящие из вещественной и мнимой частей. Для комплексных чисел вещественная и мнимая части ведут себя как вещественные величины, поэтому все операции (+, √, /, *, sqrt, sin, cos и т.═д.) могут быть наследованы от класса Real вместо того, чтобы писать новый код. Это, несомненно, окажет большое влияние на продуктивность работы программиста.


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