Глава     11

Замещение и уточнение


Разделы

Содержание

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

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

11.1. Добавление, замещение и уточнение

Разделы

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

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

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

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

11.1.1. Американская и скандинавская семантики

Добавление, замещение и уточнение

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

Первый тип переопределения часто называют американской семантикой, поскольку он обычно ассоциируется с языками программирования американского происхождения (Smalltatk или C++). Второй известен как скандинавская семантика, так как он чаще всего ассоциируется с языком Simula [Dahl═1966, Birtwistle═1979, Kirkerud═1989], первым объектно-ориентированным языком программирования, и с более поздним языком Beta [Madsen═1993]. Оба языка имеют скандинавское происхождение.

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

11.2. Замещение методов

Разделы

В языке Smalltalk целые числа и числа с плавающей точкой являются объектами═≈ экземплярами классов Integer и Float соответственно. В свою очередь оба эти класса═≈ подклассы более общего класса Number. Теперь предположим, что у нас есть переменная aNumber, которая содержит в текущий момент значение, относящееся к классу целых чисел, и мы посылаем этой переменной сообщение sqrt, вызывающее вычисление квадратного корня. Метода, соответствующего этому имени, в классе Integer нет, поэтому исследуется класс Number и находится следующий метод:

sqrt
    " преобразовать к числам с плавающей точкой "
    " затем  вычислить  квадратный  корень  "
    ^ self asFloat sqrt

Этот метод посылает сообщение asFloat переменной self, которая получила сообщение sqrt. Сообщение asFloat возвращает число с плавающей точкой той же величины, что и исходное целое. Затем этому значению посылается сообщение sqrt. Поиск метода начинается с класса Float. Обнаруживается, что класс Float содержит метод с тем же именем sqrt, который для чисел с плавающей точкой переопределяет собой метод в классе Number. Метод класса Float (который здесь не приведен) вычисляет и возвращает квадратный корень.

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

11.2.1. Замещение методов и принцип подстановки

Замещение методов

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

В только что упомянутом примере методы в обоих классах═≈ Float и Number═≈ были связаны только их концептуальными отношениями к абстрактному понятию ╚квадратный корень╩. Может возникнуть большой беспорядок, если подкласс поменяет поведение унаследованного метода слишком радикально═≈ например, изменяя sqrt так, чтобы он вычислял логарифм.

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

  1. Просто игнорировать эту проблему и предоставить программисту заботиться о том, чтобы подклассы делали правильные вещи во всех важных ситуациях. Этот подход принят на вооружение во всех рассматриваемых нами языках программирования: Object Pascal, C++, Objective-C, Java и Smalltalk.
  2. В языке Eiffel [Meyer═1988a, Rist═1995], еще одном хорошо известном объектно-ориентированном языке, программист может присоединить к методу утверждения. Они являются логическими выражениями, которые проверяют состояние объекта во время исполнения, обеспечивая тем самым выполнение определенных условий. Утверждения автоматически наследуются подклассами в прежней форме, даже когда текущие методы переопределяются методами подкласса. Таким образом, утверждения могут использоваться для того, чтобы удостовериться, что дочерний класс ведет себя допустимым образом.
  3. Разделить понятия подкласса и подтипа, как это частично сделано в Java. Подклассы затем могут использовать семантику замещения в качестве техники реализации, при этом не обязательно подразумевая, что результирующий класс будет подтипом первоначального класса.
  4. Совершенно отбросить семантику замещения, а использовать уточнение. Эта возможность рассматривается в следующем разделе.

11.2.2. Уведомление о замещении

Замещение методов

Языки программирования используют различные подходы к указанию на то, что некоторый метод переопределяется (неважно как: с замещением или уточнением). Smalltalk, Java и Objective-C вообще не требуют указания на переопределение. В C++ базовый (родительский) класс должен иметь специальные указания о возможности переопределения. В языке Object Pascal версии Apple это указание происходит не в родительском, а в дочернем классе. В языке Delphi Pascal ключевое слово должно быть помещено в оба класса.

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

Например, один программист создает класс (скажем, List) для конкретного приложения. Позднее другой программист захочет построить специализированную форму этого класса (например, OrderedList), переопределяя многие его методы. В таких языках, как C++, это потребует текстовых изменений в исходном классе, чтобы объявить методы как виртуальные (virtual). Напротив, в Java или Objective-C не потребуется никаких изменений в описании родительского класса.

11.3. Замещение в разных языках

Разделы

Ниже кратко характеризуется замещение в различных языках программирования.

11.3.1. Замещение в C++

Замещение в разных языках

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

Листинг═11.1.
Описания класса и подкласса в C++
class CardPile
{
public:

    CardPile(int, int);
    card&   top();
    void    pop();
    bool    empty();

    virtual bool includes(int, int);
    virtual void select(int, int);
    virtual void addCard(card &);
    virtual void display(window &);
    virtual void canTake(card &);

protected:

    Card* firstCard;

    int x;
    int y;
};

class SuitPile : public CardPile
{
public:

    SuitPile(int, int);
    virtual bool canTake(card &);
};

Простое замещение встречается в том случае, когда═1) аргументы метода дочернего класса идентичны по типу и числу аргументов методу родительского класса,═2) для описания метода в родительском классе используется модификатор virtual 11 . Пример: класс CardPile, используемый в пасьянсе из главы═8. Если мы должны перевести пасьянс на C++, то объявления могут выглядеть примерно так, как в листинге═11.1, где представлено описание класса CardPile и его подкласса (в данном случае SuitPile).

Комментарий
11 За одним исключением: когда тип возвращаемого значения для функции, описанной в дочернем классе, не совпадает с типом возвращаемого значения метода родительского класса. См. раздел 12.3.1 следующей главы.

Метод canTake описан таким образом, что он переопределяет метод родительского класса. Последний "говорит только нет"═≈ он всегда возвращает значение "ложь" в ответ на запрос, можно ли положить в стопку новую карту:

bool CardPile::canTake (card & c) {
  // всегда ╚нет╩
  return false;
}

Напротив, этот метод в классе SuitPile дает значение ╚истина╩, если колода пуста и карта является тузом или если карта подходит по масти верхней карте колоды и на единицу больше ее по рангу:

bool SuitPile::canTake (card & c)
{
 	// можно добавить к стопке туза
  	if (empty ())
  	  return c.rank() ==═0;
  	card & topcard = top()

  	// иначе должно быть совпадение по масти
  	// и карта должна быть следующей по старшинству
    if ((c.suit() == topcard.suit()) &&
        (c.rank() ==═1 + topcard.rank()))
      return true;

    return false;
}

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

Для компилятора языка C++ есть тонкий смысловой нюанс в том, объявлен ли метод canTake виртуальным и в классе CardPile тоже. Оба варианта имеют право на существование. Чтобы метод работал в соответствии с тем, что мы называем объектно-ориентированным стилем, он должен быть объявлен как виртуальный. Модификатор virtual необязателен при описании в дочернем классе. Как только метод объявляется виртуальным, он остается виртуальным и во всех подклассах. Однако для целей документирования этот модификатор обычно повторяется во всех производных классах.

Если модификатор virtual не задан, метод по-прежнему будет замещать одноименный метод родительского класса. Однако процесс связывания метода и сообщения будет происходить по-другому. Невиртуальные методы являются статическими в смысле, описанном в главе═10. То есть связывание вызова невиртуального метода будет выполняться во время компиляции, исходя из объявленного (статического) типа получателя, а не во время выполнения программы, исходя из динамического типа получателя. Если ключевое слово virtual удалено из описания метода canTake, то переменные, объявленные как SuitPile, будут выполнять метод из класса SuitPile, а переменные, объявленные как CardPile, будут выполнять метод по умолчанию независимо от действительного значения переменной.

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

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

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

11.3.2. Замещение методов в Object Pascal

Замещение в разных языках

В языке Object Pascal версии фирмы Apple метод может замещать метод надкласса, только если:

Листинг═11.2 иллюстрирует замещение метода. Здесь класс Employee═≈ общее описание служащих фирмы, а классы SalaryEmployee и HourlyEmployee═≈ два его подкласса. Функция computePay в классе Employee вычисляет зарплату за данный период. Этот метод переопределяется для подклассов, поскольку вычисления, используемые для двух типов служащих, различны.

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

  Employee = objet

    name : alpha;

    function computePay : integer;
    function hourlyWorker : boolean;
    procedure create;
  end;

  SalaryEmployee = object (Employee)

    salary : integer;

    function computePay : integer; override;
    function hourlyWorker : boolean; override;
    procedure create; override;
  end;

  HourlyEmployee = object (Employee)

    wage : integer;
    hourworked : integer;

    function computePay : integer; override;
    function hourlyWorker : boolean; override;
    procedure create; override;
  end;

function Employee.computePay : integer;
begin
  return═0; (* будет переопределяться подклассами *)
end;

function HourlyEmployee.computePay : integer;
begin
  return hoursworked * wage;
      (* зарплата равна числу часов, умноженному на ставку *)
end;

function SalaryEmployee.computePay : integer;
begin
  return salary div═12;
    (* делим установленную зарплату в год на число месяцев *)
end;

Рассмотрим переменную emp, объявленную как экземпляр класса Employee. Как мы уже отмечали, она может иметь значение либо класса SalaryEmployee, либо класса HourlyEmployee (или любого другого типа служащих). Независимо от ее значения обращение к процедуре computePay приведет к вызову нужного метода для соответствующего типа служащих.

Синтаксис, используемый языком Delphi Pascal фирмы Borland, гораздо ближе к C++. В языке фирмы Borland метод, переопределяемый в подклассах, должен следовать за ключевым словом virtual в родительском классе, как это показано в листинге═11.3.

Листинг═11.3.
Уведомление о замещении в Delphi Pascal
type
  Employee = class (TObject)

    name : string;

    function computePay : integer; virtual;
    function hourlyWorker : boolean; virtual;

    constructor create; virtual;
  end;

  SalaryEmployee = class (Employee)

    salary : integer;

    function computePay : integer; override;
    function hourlyWorker : boolean; override;

    constructor create; override;
  end;

11.3.3. Замещение в Smalltalk и Objective-C

Замещение в разных языках

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

11.3.4. Замещение в Java

Замещение в разных языках

В языке программирования Java, как и в Smalltalk и Objective-C, нет необходимости уведомлять о переопределении метода. Достаточно того, что новый метод имеет то же самое имя, список аргументов и тип возвращаемого значения, что и метод родительского класса.

Как отмечалось в главе═10, в языке Java возможно даже замещение полей данных (чего нет ни в одном другом из рассматриваемых нами языков). Однако такие замещения не являются динамическими, и при использовании поля данных его тип будет определяться объявленным (статическим) классом переменной, а не ее динамическим значением. Эта ситуация иногда описывается как маскировка в дочернем классе переменной родителя. Пример был приведен в главе═10.

Интересное свойство Java═≈ ключевое слово final. Если оно применяется к имени метода, то определяет, что метод является ╚листом╩ (╚терминатором╩) в иерархическом дереве класса и не может быть в дальнейшем переопределен каким бы то ни было способом. Если это ключевое слово встречается в определении класса, то это означает, что из класса не могут порождаться подклассы. Компилятору языка Java позволено оптимизировать методы, описанные с помощью ключевого слова final, превращая их в inline-функцию и подставляя ее явный код в точку вызова (что напоминает язык Beta, см. раздел═11.4.1).

11.4. Уточнение методов

Разделы

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

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

По этой причине полезно иметь некоторый механизм внутри переопределяемого метода, который бы вызывал метод-предшественник родительского класса и таким образом ╚повторно╩ использовал бы код переопределяемого метода. Когда метод вызывает таким способом переопределяемый метод родительского класса, мы будем говорить, что новый метод уточняет поведение родительского класса.

11.4.1. Уточнение в языках Simula и Beta

Уточнение методов

Семантика уточнения появилась в самом первом объектно-ориентированном языке═≈ Simula, который был разработан в начале═1960 года [Dahl═1966]. В языке Simula инициализация вновь создаваемого объекта определялась блоком команд, присоединенным к определению класса, как показано ниже:

class Employee
begin
  integer identificationNumber;
   		comment описание класса опущено;
    		comment операторы, представленные здесь,
    		comment выполняют инициализацию
    		comment каждого вновь создаваемого объекта;
  identificationNumber :=
    prompt_for_integer("Enter idNumber: ");
  inner;
end;

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

Employee class HourlyEmployee
begin
  integer hourlyWage;
    ... comment описание класса опущено;
  hourlyWage :=
    prompt_for_integer("Enter wage: ");
  inner;
end;

Язык программирования Simula использует уточнение и ключевое слово inner только во время инициализации новых объектов. Переопределение обычных методов производится с помощью семантики замещения. Языку программирования Beta [Madsen═1993] осталось только систематически применить семантику уточнения ко всем методам посредством унификации описаний классов, функций и методов в единую конструкцию, называемую схемой (pattern) (не путайте со схемами разработки классов (design patterns), которые мы будем обсуждать в главе═18).

Чтобы проиллюстрировать стиль языка Beta в отношении схем и уточнения методов, рассмотрим сначала пример, который использует простые функции, а не классы и методы. Предположим, мы хотим задать действия для печати HTML-тэгов для WWW-адресов. Метка-тэг состоит из некоторого начального текста, за которым следует поле URL (адрес машины и полное имя файла), а за ним идет некоторый заключительный текст. Полный HTML-тэг может выглядеть так:

<A HREF="http://www.cs.orst.edu/~budd/oop.html">

В языке Beta мы можем достигнуть этого, используя сначала функцию, которая печатает только начальный и конечный текст, а не реальный WWW-адрес:

printAnchor:
  (#
    do
       '<A HREF="http:'->puttext; INNER
       '">'->puttext
  #);

Команда puttext осуществляет текстовый вывод. Три оператора приведенной выше функции создают начальный текст, за которым следует уточнение (если оно есть), а потом═≈ конечный текст.

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

printOSUAnchor : printAnchor
  (#
    do
      '//www.cs.orst.edu/'->puttext;
      INNER
  #);

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

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

printBuddAnchor : printOSUAnchor
  (#
    do
      '~budd/'->puttext;
      INNER
  #);

В результате выполнения функции printBuddAnchor получаем текст:

<A HREF="http://www.cs.orst.edu/~budd/">

В языке Beta уточнения могут быть даже встраиваемыми. В результате выполнения оператора

printBuddAnchor(# do 'oop.html/'->puttext #)

получаем на выходе текст:

<A HREF="http://www.cs.orst.edu/~budd/oop.html/">

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

Employee :
  (#
    identificationNumber : @integer;
    display:<
      (#
        do 'Employee Number: '->puttext;
        identificationNumber->printInteger;
        INNER
      #);
  #);

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

HourlyEmployee : Employee
  (#
    wage : @integer;
    display::<
      (#
        do
          ' wage: '->puttext;
          wage->printInteger;
          INNER
      #)
  #);

Подчиняясь принципу подстановки, экземпляр подкласса HourlyEmployee можно подставить вместо экземпляра класса Employee. Если над первым выполнить действие display, то сперва вызовется метод родительского класса. При достижении команды INNER будут вставлены команды из функции display дочернего класса.

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

11.4.2. Методы-оболочки в языке CLOS

Уточнение методов

Еще одна интересная вариация на тему семантики уточнения встречается в языке программирования CLOS [Keen═1989]═≈ диалекте Lisp. В языке CLOS подкласс может переопределять метод в родительском классе и вводить метод-оболочку (wrapping method). Метод-оболочка может быть методом-до, методом-после или методом-внутри. В соответствии со своим типом, до метода, после метода или внутри метода, выполняется вызов метода родительского класса. В последнем случае специальный оператор дочернего метода call-next-method вызывает метод родительского класса. Это напоминает способ, которым моделируется уточнение в таких языках программирования, как C++ и Object Pascal.

11.5. Уточнение в разных языках

Разделы

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

11.5.1. Уточнение в Object Pascal

Уточнение в разных языках

Уточнение в языке программирования Object Pascal осуществляется методом дочернего класса, который явным образом вызывает переопределяемый метод родительского класса. Это подход более или менее противоположен принятому в языках Simula или Beta, где метод родительского класса сам вызывает метод из дочернего класса. Однако в большинстве случаев эффект одинаков ≈ оба метода будут выполнены.

Ключевое слово inherited используется внутри дочернего класса, чтобы отметить точку внутри дочернего метода, в которой должен быть вызван метод родительского класса. Предположим, например, что мы хотим написать метод с именем initialize, который будет запрашивать у пользователя значения, инициализирующие поля данных объекта. Этот метод для родительского класса Employee мог бы выглядеть следующим образом:

procedure Employee.initialize;
begin
  writeln("enter employee name: ");
  readln(name);
end;

Подкласс SalaryEmployee может присоединять инициализацию полей данных родительского класса к своему коду инициализации следующим образом:

procedure SalaryEmployee.initialize;
begin
  inherited initialize;
  writeln("enter salary: ");
  readln(salary);
end;

В языке Delphi Pascal ключевое слово inherited используется даже в конструкторах. Иногда это полезно, поскольку точка внутри конструктора дочернего класса, где вызывается конструктор родителя, может задаваться программистом.

11.5.2. Уточнение в C++

Уточнение в разных языках

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

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

Мы еще раз задействуем пример из программы пасьянса, переписанной на C++. Метод addCard в классе CardPile выполняет основные действия по помещению новой карты наверх стопки. Класс DiscardPile должен дополнительно обеспечивать, чтобы добавляемая карта была открыта. Это делается с помощью следующего метода:

void  DiscardPile::addCard (card & newCard)
{
  // убедиться, что новая карта лежит картинкой вверх
  if (! newCard.faceUp())
    newCard.flip();

  // затем добавить ее к колоде
  CardPile::addCard (newCard);
}

Ранее мы отмечали, что один из аспектов, в которых конструктор отличается от других методов языка C++, состоит в том, что в дочернем классе он всегда использует уточнение, а не замещение. То есть конструктор в дочернем классе всегда вызывает конструктор родительского класса.

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

TablePile::TablePile (int c, int x, int y)
  : CardPile(x, y)
{
  column = c;
}

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

11.5.3. Уточнение в Smalltalk, Java и Objective-C

Уточнение в разных языках

В главе═6 мы столкнулись с псевдопеременной super. Единственная (практически) роль этой переменной в языках Smalltalk, Java и Objective-C═≈ это разрешить уточнение при переопределении метода. Передача сообщения переменной super указывает на то, что поиск соответствующего метода должен начинаться с родителя текущего класса:

class A
{
  private int a;

  public initialize()
   {
    a =═3;
   }
}

class B extends A
{
  private int b;

  public initialize()
   {
     b =═7;
     // выполнить метод родительского класса
     super.initialize();
   }
}

Конструктор в языке Java вызывает конструктор родительского класса с помощью ключевого слова super:

class newClass extends oldClass
{
  newClass (int a, int b, int c) 
   {
    // вызвать конструктор родительского класса
    super (a, b);
    // . . .
   }
}

С той же самой целью подобные конструкции используются в языках программирования Smalltalk и Objective-C.

Упражнения

Разделы

  1. Обоснуйте утверждение: если не используется ни замещение, ни уточнение, то подкласс всегда должен быть подтипом.
  2. Приведите пример, иллюстрирующий, что если имеет место семантика замещения, то подкласс не обязан быть подтипом.
  3. Хотя систематическое использование семантики уточнения делает более сложным создание подклассов, не являющихся подтипами, такое все же возможно. Проиллюстрируйте это, приведя пример уточняющего подкласса, не являющегося тем не менее подтипом базового класса.
  4. Часто во время инициализации экземпляра подкласса должны быть выполнены по очереди: некоторый код родительского класса, код дочернего класса, затем снова код родительского класса. Например, для оконных систем родительский класс выделяет память для важных структур данных, затем дочерний класс модифицирует некоторые поля этих структур (такие, как имя и размер окна), и наконец родительский класс отображает окно на экране дисплея. Как данная последовательность вызовов может быть выполнена в объектно-ориентированном языке программирования? Подсказка: вероятно, вам придется разбить процесс инициализации на два сообщения.
  5. Не всегда семантика уточнения легко моделируется семантикой замещения. Чтобы продемонстрировать это, напишите набор классов, обеспечивающих выполнение действий, напоминающих подпрограммы создания тэгов WWW-адресов, описанные в разделе═11.4.1. Как и в случае упражнения═4, вам, возможно, понадобится ввести ряд ╚скрытых╩ методов.

Замещение и уточнение