Глава     12


Разделы

Содержание

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

Мы уже описали отношение "быть экземпляром" как фундаментальное свойство наследования. Одна из точек зрения на отношение "быть экземпляром" — это рассматривать его как средство, связывающее тип данных (в смысле типа переменной) и набор значений (а именно значения, которые могут законным образом содержаться в переменной). Если переменная win описана как экземпляр конкретного класса, скажем Window, то конечно же она может содержать значения типа Window. Если мы имеем подкласс класса Window, например TextWindow, то, поскольку TextWindow «является экземпляром» Window, имеет смысл присвоить переменной win значение типа TextWindow. Это называется принципом подстановки, который мы встречали в предыдущих главах.

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

12.1. Выделение памяти

Разделы

Начнем с рассмотрения внешне простого вопроса, ответы на который весьма разнятся, а именно: сколько памяти надо выделить переменной, которая описана как принадлежащая конкретному классу? Сколько памяти должно быть выделено переменной win, которая описана как экземпляр класса Window?

Всеми признано, что размещение переменных в стеке при вызове процедуры более эффективно, чем использование "кучи" (см., однако, [Appel 1987]).

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

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

class Window
{
 	int height;
 	int width;
 	...

public:
    virtual void oops();
};

class TextWindow : public Window
{
 	char *contents;
 	int cursorLocation;
 	...

public:
    virtual void oops();
};

// объявлена переменная класса Window
Window win; 

Следует ли принимать во внимание дополнительные значения данных (поля contents и cursorLocation) при размещении переменной win? Имеется по крайней мере три правдоподобных способа действий:

  1. Выделить память, достаточную только для базового класса. То есть разместить для переменной win исключительно данные, описанные как часть класса Window, игнорируя требования памяти для подкласса.
  2. Разместить максимум памяти, достаточной для любого законного значения, независимо от того, принадлежит ли оно базовому классу или одному из подклассов.
  3. Разместить память под указатель. Выделять память, необходимую для реального значения, из «кучи» во время выполнения программы (при этом указатель устанавливается надлежащим образом).

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

12.1.1. Размещение минимальной статической памяти

Выделение памяти

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

В языке C++ отслеживается, как именно описывается переменная и соответственно используются ли указатели для доступа к ее полям данных. Ниже, например, переменная win размещается через стек. Пространство для нее будет выделено в стеке при входе в процедуру, где описывается переменная. Память выделяется под размер переменной базового класса. Переменная tWinPtr, с другой стороны, содержит только указатель. Память под значение, на которое указывает tWinPtr, будет выделяться динамически при выполнении оператора new. Поскольку к этому времени размер объектов типа TextWindow уже известен, при выделении из «кучи» памяти, нужной для объекта TextWindow, проблем не возникнет.

Window win;
Window *tWinPtr;
...
tWinPtr = new TextWindow;

Что происходит, когда значение, на которое указывает переменная tWinPtr, присваивается переменной win? Другими словами, как выполняется оператор

win = *tWinPtr;

Память, выделенная под переменную win, вмещает только объекты типа Window, в то время как значение, на которое указывает переменная tWinPtr, больше по размеру. Очевидно, что не все значения, на которые указывает tWinPtr, могут быть скопированы. Поведение по умолчанию состоит в том, что копируются только совпадающие поля (рис. 12.1). (В языке C++ пользователь может переопределить смысл оператора присваивания и обеспечить любое желаемое функционирование. Так что здесь мы имеем в виду только стандартное поведение, которое имеет место при отсутствии определяемых пользователем альтернатив.)

Очевидно, что некоторая информация (содержащаяся в дополнительных полях tWinPtr) теряется. Некоторые авторы используют термин срезка (slicing) для этого процесса, поскольку поля данных объекта справа «срезаются» перед присваиванием левому объекту.

Насколько опасна потеря информации? Только в том случае, если пользователь что-то заподозрит. Вопрос: как пользователь сможет заметить отсутствие «лишних» полей?

Семантика языка гарантирует, что для переменной win вызываются только методы, определенные для класса Window, но не методы класса TextWindow.

Методы, определенные и реализованные в классе Window, не могут иметь доступа к полям данных подклассов. Но как насчет методов, определенных в классе Window и переопределяемых в подклассах?

Рассмотрим, к примеру, две процедуры oops, показанные выше. Если пользователь выполняет команду win.oops() и при этом выбирается метод класса TextWindow, то может произойти попытка вывести данные из поля win.cursorLocation, которого не существует в блоке памяти переменной win. Это вызовет либо нарушение доступа к памяти, либо (что более вероятно) приведет к выводу мусора.

void Window::oops()
{
  printf("Window oops");
}

void TextWindow::oops()
{
  printf("TextWindow oops %d", cursorLocation);
}

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

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

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

Window win;
TextWindow *tWinPtr, *tWin;
...

tWinPtr = new TextWindow;
win = * tWinPtr;
tWin = tWinPtr;

...

win.oops();
(*tWin).oops();

Хотя пользователь, вероятно, думает, что переменная win и значение, на которое указывает указатель tWin, — это одно и то же, важно помнить, что присваивание переменной win изменило тип значения. Из-за этого первое обращение к процедуре oops() будет вызывать метод класса Window, в то время как второе — метод класса TextWindow.

12.1.2. Размещение максимальной статической памяти

Выделение памяти

Другое решение проблемы: при описании объекта надо выделить максимальный объем памяти, достаточный для любого значения, которое может содержаться в объекте (независимо от того, относится оно к объявленному классу или к его подклассам). Этот подход аналогичен тому, что используется при размещении перекрывающихся типов данных в традиционных языках программирования (записи с вариантами в языке Паскаль, объединения (union) в C). При присваивании будет невозможно присвоить значение, не умещающееся в памяти, отведенной под переменную в правой части оператора присвоения. Поэтому рис. 12.1 более не актуален и проблемы, вытекающие из него, отныне не возникают.

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

Не просто модуль (unit в языке Object Pascal, файл в C++), но вся программа должна быть отсканирована прежде, чем мы сможем определить максимальный размер подкласса данного класса. Это требование является столь ограничивающим, что ни один из основных объектно-ориентированных языков не использует данный подход.

12.1.3. Динамическое выделение памяти

Выделение памяти

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

Этот подход используется в языках Object Pascal, Smalltalk, Java и Objective-C, о чем пользователь уже мог догадаться по сходству объектов и указателей в Object Pascal. Как для указателей, так и для объектов необходимо вызывать стандартную процедуру new для размещения памяти перед обращением к объектам. Аналогично пользователь явно вызывает процедуру free для освобождения памяти, выделенной объекту.

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

type
  intBuffer = object
    value : integer;
  end;

var
  x, y : intBuffer;

begin
 	new(x);  {создать буфер}
 	x.value:=5; 
    writeln(x.value);

 	y:=x;   {y — тот же буфер, что и x}
 	y.value:=7; 
    writeln(x.value);
end;

Заметим, что экземплярами этого класса объявлены две переменные. При выполнении программы пользователь, вероятно, удивится, когда последний оператор напечатает значение 7, а не 5. Причина: при присваивании x и y не просто получили одно значение, они стали указывать на одно значение. Эта ситуация показана на рис. 12.2. Семантика указателей для объектов в языке Object Pascal отчасти смущает, поскольку альтернативный подход — семантика копирования — используется для всех других типов данных. Если бы x и y были структурами, то присваивание y:=x привело бы к копированию информации из переменной x в переменную y. Поскольку при этом создаются две различные копии, то дальнейшие изменения в переменной y не влияют на x.


12.2. Присваивание

12.2 Присваивание

Разделы

Как в C++, так и в Object Pascal используемые механизмы выделения памяти влияют на смысл операции присваивания. Поэтому здесь мы определим точные значения этого оператора в рассматриваемых языках. Как было отмечено в предыдущем разделе, имеются две интерпретации операции присваивания.

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

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

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

В общем случае, если используется семантика указателей, то языки программирования предусматривают некоторые средства создания истинной копии. Опять же, семантика указателей, вообще говоря, используется чаще при размещении объектов через «кучу» (то есть динамически), а не через стек (автоматически). Когда применяется семантика указателей, значения довольно часто «переживают» свой контекст, внутри которого они были созданы.

Отличие объектно-ориентированных языков состоит в использовании разных семантик (первой, второй или их комбинации).

12.2.1. Присваивание в C++

Присваивание

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

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

String & String::operator = (String& right)
{
    // копировать длину строки
 	len = right.len;
    // копировать указатель на строку
 	buffer = right.buffer;  
 	return (*this);
}

Типичный источник ошибок начинающего программиста на языке C++ — это использование одного и того же символа равенства (=) для операции присваивания и для операции инициализации. В стандартном C присваивание при объявлении — это просто удобное синтаксическое сокращение. Так что эффект от

int limit = 300;
такой же, что и от
int limit;
limit = 300;

В языке C++ присваивание при объявлении может вызывать произвольные конструкторы и не использовать присваивание вообще. Тем самым оператор типа

Complex x = 4;
интерпретируется по смыслу как
Complex x(4);

При инициализации часто используются ссылки; тем самым ситуация напоминает семантику указателей. Например, если идентификатор s — это объект типа String, то следующая команда делает идентификатор t синонимом идентификатора s (так что изменение в одной переменной приводит к изменению в другой).

... // использование переменной s
String &t = s;
... // переменные t и s теперь ссылаются на одно значение

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

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

class Base
{
public:
    virtual void see();
};

class Derived
{
public:
    virtual void see();
};

void f(Base);
void g(Base &);

Derived z;

f(z); 
g(z);

Обе функции f() и g() используют в качестве аргумента значение класса Base, но функция g() описывает аргумент как переменную-ссылку. Если вызывается функция f() с аргументом, принадлежащим к классу Derived, то как часть вызова процедуры аргумент преобразуется (с использованием срезки), чтобы создать значение, принадлежащее к классу Base. Тем самым если внутри функции f() вызывается метод see, то будет использована виртуальная функция из класса Base. С другой стороны, это преобразование не происходит при передаче параметров в функцию g(). Поэтому если метод see вызывается из функции g(), то будет использована виртуальная функция из класса Derived. Эта разница в интерпретации, которая зависит только от одного символа в заголовке функции, иногда называется проблемой срезки.

Язык C++ позволяет переопределять символ присваивания и выбирать механизм пересылки параметров (по ссылке или по значению). Это мощные средства, но они могут привести к неожиданным последствиям. Например, символ присваивания, используемый при инициализации (хотя это точно такой же знак =), не подвергается изменению при перегрузке оператора присваивания. Хорошее объяснение правильного использования мощного потенциала операции присваивания, заложенного в языке C++, дано в работах [Koenig 1989a, Koenig 1989b].

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

class Complex
{
  ...
  Complex (const Complex &source)
   {
    // просто дублирует поля источника
    rl = source.rl;
    im = source.im;
   }
  ...

private:

    double rl;
    double im;
}

12.2.2. Присваивание в Object Pascal и Java

Присваивание

Как Object Pascal, так и Java используют семантику указателей для присваивания объектов. В языке Object Pascal нет предусмотренных системой механизмов для создания копии объекта, поэтому принято определять безаргументный метод copy, который создает копию получателя, если такое функционирование является желательным. В языке Java класс всех объектов Object определяет метод clone, который создает побитную копию получателя сообщения clone. Подклассы могут переопределять этот метод. Тип возвращаемого результата в этом методе определен как Object, так что должно использоваться приведение типа для получения значения нужного типа:

newBall = (Ball) aBall.clone();

Заметьте, что в языке Object Pascal семантика указателей используется только для объектов. Все другие типы данных (массивы, записи) осуществляют семантику копирования при присваивании. Это часто приводит в смущение начинающего программиста.

12.2.3. Присваивание в Smalltalk

Присваивание

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

x := y copy

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

x := y deepCopy

создает новый объект, аналогичный y, в котором поля (переменные экземпляра) инициализированы копиями полей y.

Другими словами, метод copy (называемый также shallowCopy) делает переменные экземпляра общими с переменными оригинала, в то время как метод deepCopy копирует другие переменные экземпляра. Например, если y — это объект с тремя переменными a, b и c, то копия copy (или shallowCopy) экземпляра y выглядит следующим образом:

С другой стороны, метод deepCopy создает новые копии переменных экземпляра:

Собственно переменные экземпляра создаются с использованием метода copy. Классы имеют право переопределять любой из методов копирования copy, shallowCopy и deepCopy, так что для экземпляров некоторых классов можно получить нестандартное поведение.

12.2.4. Присваивание в Objective-C

Присваивание

Язык Objective-C использует семантику указателей для присваивания объектов. Копия объекта может быть получена одним из трех методов: copy, shallowCopy и deepCopy, которые аналогичны одноименным методам, используемым в языке Smalltalk.

id x, y, z;
// ... определение объекта у
x = [ y copy ];
z = [ y deepCopy ];

12.3. Проверка на равенство

Разделы

Подобно операции присваивания, вопрос о том, является ли один объект эквивалентным другому объекту, является сложнее, чем это может показаться на первый взгляд. Отчасти трудность в понимании того, что в точности значит эквивалентность (идентичность), аналогична проблеме разговорного языка. Если кто-то спрашивает: «Является ли утренняя звезда вечерней звездой?» (Is the morning star the evening star?), ответ с полным основанием может быть как «да», так и «нет». Если вопрос стоит о сравнении физических объектов, обозначаемых этими двумя терминами (а в обоих случаях речь идет о планете Венера), то ответом, безусловно, будет «да». С другой стороны, если спрашивающий хочет узнать, обозначает ли в данном языке термин «утренняя звезда» объекты, появляющиеся на вечернем небе, то ответом столь же безусловно будет «нет».

Изучение ссылок, значений и эквивалентности в естественном языке — дело трудное, и мы не будем углубляться в этот вопрос дальше. Заинтересованный читатель может обратиться к эпизоду с Белым Рыцарем в книге «Алиса в Зазеркалье» или к избранным работам, цитируемым в [Rosenberg 1971] и [Whorf 1956]. К счастью, равенство в языках программирования обычно хорошо формализовано, хотя сделано это по-разному в разных языках.

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

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

Для составных объектов вроде записей в языках Pascal и C побитное сравнение может оказаться недостаточным. Часто блок памяти для таких типов данных может включать пустые участки, которые не имеют отношения к значениям, хранимым в объекте. Поскольку эти пропуски не должны учитываться при определении равенства, используется второй механизм, а именно поэлементное равенство. При поэлементном сравнении мы проверяем сопоставляемые элементы на совпадение, применяя это правило рекурсивно, пока не встретится элемент, отличный от записи. В последнем случае применяется побитное сравнение. Если все элементы удовлетворяют проверке, две записи рассматриваются как равные друг другу. Если какие-либо два элемента не совпадают, то записи не равны друг другу. Такое отношение равенства иногда называется структурной эквивалентностью.

Техника объектно-ориентированного программирования привносит свои особенности в проверку на равенство. Например, если при сравнении двух значений как статических типов они оказываются равными, то при сравнении их в качестве динамических типов это не обязательно так. Следует ли при проверке на равенство принимать это во внимание? Что если один из типов определяет поля, которые отсутствуют в другом? Проблема состоит также в том, что выбор интерпретации для вызываемого сообщения определяется получателем. Нет гарантии, что такое фундаментальное свойство, как коммутативность, будет сохраняться. Если идентификаторы x и y принадлежат к различным типам данных, то вполне может быть, что отношение x=y справедливо, а отношение y=x — нет!

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

Техника объектно-ориентированного программирования привносит свои особенности в проверку на равенство. Например, если при сравнении двух значений как статических типов они оказываются равными, то при сравнении их в качестве динамических типов это не обязательно так. Следует ли при проверке на равенство принимать это во внимание? Что если один из типов определяет поля, которые отсутствуют в другом? Проблема состоит также в том, что выбор интерпретации для вызываемого сообщения определяется получателем. Нет гарантии, что такое фундаментальное свойство, как коммутативность, будет сохраняться. Если идентификаторы x и y принадлежат к различным типам данных, то вполне может быть, что отношение x=y справедливо, а отношение y=x — нет!

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

12.3.1. Ковариация и контрвариация

Проверка на равенство

При проверке на равенство часто полезно изменить сигнатуру типа в методах подкласса. Рассмотрим класс Shape (фигура) и два его подкласса Triangle (треугольник) и Square (квадрат). Кажется разумным, чтобы треугольники могли сравниваться только с треугольниками, а квадраты — исключительно с квадратами. Соответственно программист, возможно, напишет определение класса следующим образом:

class Shape
{
public:

    boolean equals (Shape)
     {
      return false;
     }
    ...
}

class Triangle : public Shape
{
public:

    boolean equals (Triangle);
    ...
}

class Square : public Shape
{
public:

    boolean equals (Square);
    ...
}

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

В этом примере аргумент функции проверки на равенство является контрвариантом.

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

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

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

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

Triangle aTriangle;
Shape  aShape;
aShape := aTriangle;

if aTriangle.equals(aTriangle)
 { ... } // возвращает true

if aTriangle.equals(aShape)
 { ... } // возвращает false!

Первый вызов функции equals в данном примере связывается с методом класса Triangle и, как и ожидается, возвращает значение true. Второй оператор вызовет метод класса Shape, поскольку аргумент — это не треугольник в явном виде. Возвращаемый результат равен false, несмотря на то что фактическое значение аргумента равно (в действительности, идентично) тому же треугольнику.

Аналогичная неконгруэнтность возникает, если полиморфная переменная используется как получатель. Поскольку единственный разумный метод, который здесь можно вызвать, — это метод родителя, то следующий тест неожиданно дает нам значение false:

if aShape.equals(aTriangle) ... // возвращает false

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

boolean Triangle.equals(Shape & aShape)
{
  Triangle & right = dynamic_cast(aShape);

  if (right)
   {
    ... // сравнить два треугольника
   }
  else 
   return false;
}

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

К языкам программирования, которые допускают ковариацию и контрвариацию, относятся Eiffel [Rist 1995] и Sather.

12.3.2. Равенство в Objective-C, Java и Object Pascal

Проверка на равенство

В языках Objective-C, Java и Object Pascal объекты всегда (для Objective-C — почти всегда) представлены внутренним образом как указатели. Неудивительно, что смысл по умолчанию оператора равенства (= в языке Object Pascal, == в языках Objective-C и Java) — это идентичность, то есть равенство указателей. Две объектные переменные при тестировании равны, только если они указывают на один и тот же объект.

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

function Card.equal(aCard : Card) : boolean;
begin
   if (suitValue = aCard.suit) and
    (rankValue = aCard.rank)
   then equal := true
   else equal := false;
end;

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

class Triangle extends Shape
{
  boolean equals (Shape aShape)
   {
    if (aShape instanceOf Triangle)
     {
      ... // сравнение треугольников
     }
    else return false;
   }
}

12.3.3. Равенство в Smalltalk

Проверка на равенство

Язык Smalltalk различает идентичность объектов и их равенство. Идентичность объектов проверяется с помощью удвоенного символа равенства (==). Равенство объектов анализируется с помощью однократного символа равенства (=) и рассматривается как сообщение, пересылаемое левому объекту. По умолчанию смысл этого сообщения тот же самый, что и для проверки на идентичность. Однако каждый класс может переопределить этот символ произвольным образом. Например, класс Array определяет, что равенство имеет место тогда, когда объект справа является массивом той же длины, а соответствующие элементы массивов равны.

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

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

12.3.4. Равенство в C++

Проверка на равенство

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

class A
{
public:

    int i;
    A(int x) {i = x;}

    int operator== (A& x)
     {
      return i == x.i;
     }
};

class B : public A
{
public:

    int j;
    B (int x, int y) : A(x) { j = y;}

    int operator== (B& x)
     {
      return (i == x.i;) && (j == x.j);
     }
};

Если переменные a и b — это экземпляры классов A и B, то сравнения a==a и a==b используют метод класса A, а сравнение b==b — метод класса B. Выражение b==a вызовет сообщение об ошибке компиляции, поскольку аргумент (a) не соответствует определению оператора равенства для класса B. (Это было первым и наименее интуитивным вариантом выбора в предыдущем обсуждении, когда мы пытались приписать смысл контрвариантному методу.)

Более важно то, что если полиморфная переменная (которая в C++ должна быть либо указателем, либо ссылкой) типа A на самом деле содержит значение типа B, то оператор сравнения все равно связывается с классом A. Это происходит потому, что два определения, показанные выше, — совершенно разные функции, так что никакого переопределения на самом деле не происходит. Это утверждение справедливо, даже если в первом определении используется ключевое слово virtual.

12.4. Преобразование типов

Разделы

Для языков программирования со статическими типами данных (вроде C++ и Object Pascal) нельзя присваивать значение типа надкласса переменной, объявленной как экземпляр подкласса. Значение, о котором компилятору известно, что оно класса Window, не может быть присвоено переменной, описанной с классом TextWindow. Причины такого ограничения почти очевидны. Если это не так, то они рассматриваются в упражнениях 1 и 2 этой главы.

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

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

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

class Link
{
protected:

    Link *link;

public:

    Link * next();
    void setLink(Link * elem);
};

void Link::setLink(Link *elem)
{
  link = elem;
}

Link * Link::next()
{
  return link;
}

Класс Ball сделаем подклассом класса Link. Поскольку метод setLink наследуется от надкласса, его нет необходимости повторять. Однако имеется проблема с наследуемым методом next. Он утверждает, что возвращает экземпляр класса Link, а не Ball. Тем не менее мы знаем, что объект на самом деле принадлежит классу Ball, поскольку это единственные объекты, которые мы помещаем в список. Поэтому мы переписываем класс Ball с тем, чтобы переопределить метод next, и используем приведение типа для того, чтобы изменить тип возвращаемого значения:

class Ball : public Link
{
  ...

public:
    ...
    Ball * next();
    ...
};

Ball * Ball::next()
{
  return dynamic_cast <Ball *>(Link::next());
}

Заметьте, что метод next не описан как виртуальный: нельзя изменить тип возвращаемого значения виртуальной функции. Важно помнить, что в языке C++ это приведение типа будет законным только для указателей, а не для собственно объектов (см. упражнение 2 этой главы). Функция dynamic_cast является частью системы RTTI (Run-Time Typing Information — идентификация типа во время выполнения), которая была описана в главе 10. Приведение типов через RTTI должно использоваться вместо более ранних синтаксических форм, поскольку неправильное приведение типов данных является стандартным источником ошибок в программах.

В языке Object Pascal задействована аналогичная идея. Поскольку объекты рассматриваются внутренним образом как указатели, то это преобразование может применяться к объектам любого типа, а не только к указателям. Язык Object Pascal обеспечивает проверку класса объекта во время выполнения, так что все «сомнительные» приведения типа должны быть выявлены с помощью явной проверки до присваивания.

var
  x : TextWindow;
  y : Window;

begin
  ...
  if Member(y, TextWindow)
    then x := TextWindow(y)
    else writeln('illegal window assignment');
  ...
end;

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

Ball aBall;
WhiteBall wBall;

if (aBall instanceOf WhiteBall)
  wBall = (WhiteBall) aBall;
else
  ...

Упражнения

Разделы

  1. Объясните, почему в языках со статическими типами данных (вроде C++ и Object Pascal) нельзя присваивать значение типа надкласса переменной, описанной как экземпляр подкласса. То есть команды вроде нижеследующих приводят к сообщению компилятора об ошибке:

    	TextWindow X;
    	Window Y;
    	...
    	X := Y;

  2. Предположим, что в C++ метод распределения памяти работает так, как описано в разделе 12.1. Объясните, какие могут возникнуть проблемы, если пользователь пытается обойти ограничение, описанное в упражнении 1, путем приведения типов данных, используя команду присваивания вроде

    x = (TextWindow) Y;
  3. Приведите пример на Object Pascal или C++, который иллюстрирует, почему для определения размера объекта с помощью метода из раздела 12.1 должна просматриваться вся программа целиком, а не только отдельный файл.
  4. Объясните, почему при условии соблюдения принципа подстановки возвращаемое значение для метода дочернего класса не может быть более общим, чем у родительского класса.
  5. Покажите, что можно определить язык, аналогичный Object Pascal, который не использует семантику указателей при присваивании. Другими словами, опишите алгоритм для операции присваивания в языке программирования, который реализует управление памятью, описанное в разделе 12.1, но не приводит к тому, что две переменные указывают на одно место в памяти после операции присваивания. Как вы думаете, почему разработчики языка Object Pascal не использовали ваш алгоритм присваивания?

Следствия наследования

Сайт создан в системе uCoz