Глава     14

Полиморфизм


Разделы

Содержание

Слово полиморфизм греческого происхождения и означает приблизительно «много форм» (poly — много, morphos — форма). Слово morphos имеет отношение к греческому богу сна Морфею (Morphus), который мог являться спящим людям в любой форме, в какой только пожелает, и, следовательно, был воистину полиморфным. В биологии полиморфные виды — это те (наподобие Homo Sapiens), которые характеризуются наличием различных форм или расцветок. В химии полиморфные соединения могут кристаллизоваться по крайней мере в двух различных формах (например, углерод имеет две кристаллические формы — графита и алмаза).

14.1. Полиморфизм в языках программирования

Разделы

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

14.1.1. Полиморфные функции в динамических языках

Полиморфизм в языках программирования

Полиморфные функции относительно легко составлять в языках программирования с динамическими типами данных (Lisp, Scheme в функциональной парадигме, Smalltalk в объектно-ориентированной парадигме). Следующий пример иллюстрирует метод языка Smalltalk с именем silly, который в зависимости от аргумента x возвращает: (x+1), если x — целое число, обратную величину, если x — дробь, текст в обратном порядке, если x — текстовая строка, и специальное значение nil во всех остальных случаях:

silly: x 
    " глупейший полиморфный метод "
    (x isKindOf: Integer)  ifTrue:  [ ^ x + 1 ].
    (x isKindOf: Fraction) ifTrue:  [ ^ x reciprocal ].
    (x isKindOf: String)   ifTrue:  [ ^ x reversed ].
     ^ nil

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

Новые функциональные языки программирования (например, ML [Milner 1990]) разрешают использовать разновидность полиморфизма, называемую параметрическим полиморфизмом. При этом подходе параметр может быть описан только частично — например, «список из T», где тип данных T остается неопределенным. Это позволяет определять функции, оперирующие со списками. Такие функции могут вызываться для списков произвольного типа. Аналогичные свойства доступны в некоторых объектно-ориентированных языках через обобщенные функции или шаблоны.

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

14.1.2. Абстракции низкого и высокого уровней

Полиморфизм в языках программирования

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

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

function length (list) -> integer
begin
  if list.link is nil
  then
    return 1
  else
    return 1 + length(list.link)
end

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

type
  intlist = record
     value : integer;
     link : ^ intlist;
end;

function length (x : ^ intlist) : integer;
begin
  if x^.link = nil then
    length := 1
  else
    lehgth := 1 + length(x^.link);
end;

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

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

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

14.2. Разновидности полиморфизма

Разделы

В объектно-ориентированных языках программирования полиморфизм — естественное следствие:

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

Чистый полиморфизм имеет место, когда одна и та же функция применяется к аргументам различных типов. В случае чистого полиморфизма есть одна функция (тело кода) и несколько ее интерпретаций. Другая крайность наблюдается, когда имеется множество различных функций (то есть тел кода) с одним именем. Такая ситуация называется перегрузкой или полиморфизмом ad hoc. Между этими двумя полюсами лежат переопределяемые и отложенные методы 14 .

Комментарий
14 Опять-таки отметим, что согласие в среде сообщества программистов относительно используемой терминологии весьма невелико. Например, в работах [Horovitz 1984], [Marcotty 1987], [MacLennan 1987] и [Pinson 1988] термин полиморфизм определяется так, что он приблизительно соответствует понятию, называемому в данной книге перегрузкой. В работах [Sethi 1989] и [Meyer 1988a], а также в среде людей, занимающихся функциональным программированием [Wikstrom 1987], [Milner 1990], этот термин резервируется для обозначения того, что здесь называется чистым полиморфизмом. Другие же авторы используют этот термин для обозначения одного-двух или всех механизмов полиморфизма, рассматриваемых в данной главе. Два законченных, но запугивающих избытком технических подробностей анализа могут быть найдены в работах [Cardelli 1985] и [Danforth 1988].

14.3. Полиморфные переменные

Разделы

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

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

В языках со статическими типами данных (таких, как C++, Java, Object Pascal и Objective-C при использовании статических описаний) ситуация немного сложнее. Мы отмечали, что эти языки рассматривают создание подклассов как порождение подтипов данных. Полиморфизм существует в этих языках благодаря различию между декларированным (статическим) классом переменной и фактическим (динамическим) классом значения, которое содержится в переменной. Как мы отметили в главе 10, это достигается через отношение «быть подклассом». Переменная может содержать значение объявленного типа или любого его подтипа.

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

Хорошим примером полиморфной переменной является массив allPiles в карточном пасьянсе из главы 8. Массив был описан как содержащий значения типа CardPile, но на самом деле он хранит значения, принадлежащие подклассам родительского класса. Сообщение (например, показанное ниже сообщение display), передаваемое к элементу этого массива, выполняет метод, связанный с динамическим типом переменной, а не со статическим классом.

public class Solitare extends Applet
{
  ...
  static CardPile allPiles[];
  ...
  public void paint(Graphics g)
   {
    for (int i = 0; i < 13; i++)
     {
      allPiles[i].display(g);
     };
   }
  ...
}

14.4. Перегрузка

Разделы

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

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

14.4.1. Перегрузка в реальной жизни

Перегрузка

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

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

14.4.2. Перегрузка и приведение типа

Перегрузка

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

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

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

14.4.3. Перегрузка не подразумевает сходство

Перегрузка

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

Заметьте, что данная перегрузка одного и того же имени независимыми и не имеющими отношение друг к другу значениями не обязательно является плохим стилем программирования. Как правило, это не вносит путаницы. На самом деле выбор короткого, ясного и значимого имени (вроде add, draw и т. д.) значительно улучшает и облегчает использование объектно-ориентированных компонент. Проще запомнить, что вы добавляете элемент через метод add, а не вспоминать что-нибудь вроде addNewElement или вызывать процедуру Set_Module_Addition_Method.

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

14.4.4. Параметрическая перегрузка

Перегрузка

Другой стиль перегрузки, при котором процедурам (функциям, методам) в одном и том же контексте разрешается использовать совместно одно имя, а двусмысленность снимается за счет анализа числа и типов аргументов, называется параметрической перегрузкой. Она присутствует в C++ и Java, а также в некоторых директивных языках (например, Ada) и во многих языках, основанных на функциональной парадигме. Мы уже видели примеры такой перегрузки для функций-конструкторов. C++ позволяет любому методу, функции, процедуре или оператору быть параметрически перегруженными, коль скоро аргументы таковы, что выбор может быть произведен однозначно на этапе компиляции. (При автоматическом приведении типа — например, от символов character к целым числам integer или от целых integer к числам с плавающей точкой float — алгоритм, используемый для разрешения неоднозначности в имени перегруженной функции, становится очень сложным. Более подробная информация может быть найдена в работах [Ellis 1990] и [Stroustrup 1986].)

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

14.5. Переопределение

Разделы

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

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

14.5.1. Переопределение в классе Magnitude

Переопределение

Интересным примером переопределения методов является класс Magnitude в системе Little Smalltalk. Magnitude — это абстрактный надкласс, который имеет дело с величинами, обладающими по крайней мере частичным (если не полным) упорядочиванием. Числа — это наиболее характерный пример объектов, обладающих «величиной», хотя время и дата также могут быть упорядочены, равно как и символы, точки на двухмерной координатной плоскости, слова в словаре и т. д.

В классе Magnitude шесть отношений сравнения определяются следующим образом:

<= arg
  ^ self < arg or: [ self = arg ]

>= arg
  ^ arg <= self

< arg
  ^ self <= arg and: [ self ~= arg ]

> arg
  ^ arg < self

= arg
  ^ self == arg

~= arg
  ^ (self = arg) not

Заметьте, что определения цикличны: каждое из них зависит от некоторых других. Как можно избежать бесконечного цикла при вызове какого-либо конкретного сравнения? Ответ состоит в том, что подклассы класса Magnitude должны переопределять по крайней мере одно из шести сообщений сравнения. Мы оставляем в качестве упражнения для читателя проверку того, что если переопределены сообщения = и либо <:, либо >=, то оставшиеся операторы не приведут к бесконечному циклу.

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

Пользователи языка C++ должны быть осведомлены о тонком семантическом различии между переопределениями виртуального и невиртуального методов. Мы будем обсуждать это более подробно в разделе 14.9.

14.6. Отложенные методы

Разделы

Отложенный метод (иногда называемый абстрактным методом, а в C++ — чисто виртуальным методом) может рассматриваться как обобщение переопределения. В обоих случаях поведение родительского класса изменяется для потомка. Для отложенного метода, однако, поведение просто не определено. Любая полезная деятельность задается в дочернем классе.

Одно из преимуществ отложенных методов является чисто концептуальным: программист может мысленно наделить нужным действием абстракцию сколь угодно высокого уровня. Например, для геометрических фигур мы можем определить метод draw, который их рисует: треугольник Triangle, окружность Circle и квадрат Square. Мы определим аналогичный метод и для родительского класса Shape. Однако такой метод на самом деле не может выполнять полезную работу, поскольку в классе Shape просто нет достаточной информации для рисования чего бы то ни было. Тем не менее присутствие метода draw позволяет связать функциональность (рисование) только один раз с классом Shape, а не вводить три независимые концепции для подклассов Square, Triangle и Circle.

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

14.7. Чистый полиморфизм

Разделы

Многие авторы резервируют понятие полиморфизм (или чистый полиморфизм) для ситуаций, когда одна функция используется с разными наборами аргументов, и термин перегрузка — для случая, когда определено несколько функций с одним именем1. Эти термины не ограничены исключительно объектно-ориентированным подходом. Например, в языках Lisp и ML легко написать функции, которые обрабатывают списки с различными элементами. Такие функции являются полиморфными, поскольку тип аргумента неизвестен при определении функции. Полиморфные функции — это одна из наиболее мощных объектно-ориентированных техник программирования. Они позволяют единожды писать код на высоком уровне абстрагирования и затем применять его в конкретной ситуации. Обычно программист выполняет подгонку кода с помощью посылки дополнительных сообщений получателю, использующему метод. Эти дополнительные сообщения часто не связаны с классом на уровне абстракции полиморфного метода. Они являются виртуальными методами, которые определяются для классов более низкого уровня.

Следующий пример поможет проиллюстрировать эту концепцию. Как мы отметили в разделе 14.5, посвященному переопределению методов, класс Magnitude в языке Smalltalk — это абстрактный надкласс, который имеет дело с упорядоченными величинами. Рассмотрим метод с именем between:and:, приведенный ниже:

between: low and: high
 	" проверить, находится ли получатель "
 	" между двумя крайними точками "
    ^ ( low <= self ) and: ( self <= high )

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

После того как объекту послано сообщение between:and: с двумя аргументами, фактическое выполнение зависит от конкретного смысла сообщения "меньше или равно". Данное сообщение, хотя оно и определено для класса Magnitude, переопределяется в большинстве подклассов. Для целочисленных значений смысл сообщения — сравнить целые числа. Тем самым сообщение between:and: может использоваться для проверки попадания целого числа в заданный интервал. Для значения с плавающей точкой все происходит аналогично:

anInteger between: 7 and: 11
aFloat between: 2.7 and: 3.5

Для символов соотношение «меньше или равно» сравнивает ASCII коды. Соответственно сообщение between:and: проверяет, лежит ли символ в интервале между двумя другими символами. Например, чтобы узнать, является ли символ aChar строчной буквой, мы можем использовать следующее выражение (лексема $a обозначает в языке Smalltalk символ a):

aChar between: $a and: $z

Для точек Points сравнение "меньше или равно" возвращает true, если получатель расположен выше и левее аргумента (то есть и первая и вторая координаты получателя удовлетворяют соотношению "меньше или равно" при сравнении с соответствующими координатами точки-аргумента). Points — базовые объекты языка Smalltalk. Они конструируются из целых чисел с помощью оператора @. Число-получатель становится первой координатой, а число-аргумент — второй. Заметьте, что определение соотношения "<" (меньше) для точек дает лишь частичное упорядочивание. Не все точки являются соизмеримыми. Тем не менее выражение

aPoint between: 2@4 and: 12@14

дает true, если точка aPoint лежит в прямоугольнике с координатами (2,4) для левого верхнего угла и (12,14) для правого нижнего угла.

Важный момент здесь — это то, что во всех случаях используется только один метод between:and:. Он является полиморфным и работает с аргументами многих типов. В каждом случае переопределение сообщений, вызываемых полиморфной подпрограммой (сообщения «меньше или равно»), приспосабливает код к конкретным обстоятельствам.

В главе 18 мы встретим много новых примеров полиморфных подпрограмм, когда будем обсуждать шаблоны.

14.8. Обобщенные функции и шаблоны

Разделы

Совершенно другой тип полиморфизма обеспечивается за счет так называемых обобщенных функций, которые в языке C++ называются шаблонами. Аргументом обобщенной функции (класса) является тип, который используется при ее (его) параметризации. Аналогия с обычными функциями очевидна: последние реализуют необходимый алгоритм без задания конкретных числовых значений. Чтобы проиллюстрировать понятие обобщенной функции, вернемся к началу этой главы. Там мы отметили, что проблема с языками со строгим контролем типов данных состоит в том, что они не разрешают создавать тип вроде Linked List of X (связанный список из объектов X), где идентификатор X — это неизвестный тип данных. Обобщенные функции обеспечивают такую возможность.

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

template <class T> class List
{
public:

      void add(T);
      T firstElement();

      // поля данных
      T value;
      List<T> * nextElement;
};

В этом примере идентификатор T используется как обозначение типа. Каждый экземпляр класса List содержит значение типа T и указатель на следующий элемент списка. Функция-член add добавляет новый элемент в список. Первый элемент в списке возвращается функцией firstElement.

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

List<int> aList;
List<double> bList;

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

template <class T> int length (List<T> & aList)
{
    if (aList == 0) return 0;
    return 1 + length(aList.nextElement);
}

В С++ функции-шаблоны интенсивно используются в стандартной библиотеке шаблонов, которая описывается в главе 16.

14.9. Полиморфизм в различных языках

Разделы

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

14.9.1. Полиморфизм в C++

Разделы

Полиморфизм часто является источником затруднений для изучающих C++. Поэтому остановимся на этом вопросе подробнее.

Полиморфные переменные

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

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

class One
{
public:

    virtual int value()
     {
      return 1;
     }
};

class Two : public One
{
public:

    virtual int value()
     {
      return 2;
     }
};

Класс One описывает виртуальный метод, который возвращает значение 1. Этот метод переопределяется в классе Two на метод, возвращающий значение 2.

Определяются следующие функции:

void directAssign (One x)
{
  printf("by assignment value is %d\n", x.value());
}

void byPointer (One * x)
{
  printf("by pointer value is %d\n", x->value());
}

void byReference (One & x)
{
  printf("by reference value is %d\n", x.value());
}

Эти функции используют в качестве аргумента значение класса One, которое передается соответственно по значению, через указатель и через ссылку. При выполнении этих функций с аргументом класса Two для первой функции параметр преобразуется к классу One, и в результате будет напечатано значение 1. Две другие функции допускают полиморфный аргумент. В обоих случаях переданное значение сохранит свой динамический тип данных, и напечатано будет значение 2.

Виртуальное и невиртуальное переопределение

Приводящий в замешательство аспект переопределения методов в языке C++ — это разница между переопределением виртуального и невиртуального методов. Как мы отмечали в главе 11, ключевое слово virtual не является необходимым для того, чтобы происходило переопределение. Однако семантический смысл сильно меняется в зависимости от того, используется это слово или нет. Если удалить ключевое слово virtual из описания метода в классе One в предыдущем примере (даже если его сохранить в классе Two), то результат «1» будет напечатан для всех трех функций.

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

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

virtual void display (char *, int);

Подкласс пытается переопределить метод:

virtual void display (char *, short);

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

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

Параметрическая перегрузка

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

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

Отложенные методы в C++

В языке C++ отложенный метод (который здесь называется чисто виртуальным методом) должен быть описан в явном виде с ключевым словом virtual. Тело отложенного метода не определяется, вместо этого функции «присваивается» значение 0:

class Shape
{
public:
    ...
    virtual void draw() = 0;
    ...
};

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

Обобщенные функции и шаблоны

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

14.9.2. Полиморфизм в Java

Разделы

Язык Java поддерживает как иерархию подклассов (с ключевым словом extends), так и иерархию подтипов (с ключевым словом interfaces). Переменные могут быть объявлены или через класс, или через интерфейс. Все переменные являются полиморфными. Переменная, описанная как класс, может содержать значения, относящиеся к любому подклассу. Переменная, объявленная как интерфейс, может хранить значения любого класса, который реализует этот интерфейс.

Отложенные методы реализуются в языке Java через ключевое слово abstract. Методы, описанные как abstract, не имеют тела; они оканчиваются точкой с запятой. Абстрактные методы должны переопределяться в подклассах. Класс, который включает в себя абстрактный метод, должен в свою очередь быть описан как абстрактный. Не разрешается создавать экземпляры абстрактных классов.

abstract class shape
{
    // ниже должно переопределяться
    public abstract draw();
    // ...
}

class triangle extends shape
{
    public draw()
     {
      // нарисовать треугольник
     }

    // ...
}

Интересным свойством языка Java является модификатор final, который в некотором смысле противоположен ключевому слову abstract. Класс или метод, описанный как final, не может порождать подклассы или переопределяться.

14.9.3. Полиморфизм в Object Pascal

Разделы

Полиморфные переменные

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

Отложенные методы в Object Pascal

Как мы отметили в главе 7, версии Apple и Borland языка Object Pascal отличаются тем, как они указывают на переопределение метода. В Object Pascal версии Apple ключевое слово override помещается в описание метода дочернего класса. Версия Delphi требует ключевых слов override и virtual в описании метода в родительском классе.

Язык Object Pascal версии Apple не поддерживает отложенные методы. Они реализуются в виде процедуры, генерирующей сообщение об ошибке:

type

  Shape = object
    corner : Point;
    procedure draw();
    ...
  end;

  Circle = object (Shape)
    radius : integer;
    procedure draw(); override;
    ...
  end;

procedure Shape.draw();
begin
     writeln('descendant should define draw');
     halt();
end;

В языке Delphi Pascal метод может быть объявлен как отложенный c ключевым словом abstract, следующим за ключевым словом virtual (или dynamic) при описании в родительском классе. Для абстрактного метода не задается тело. В отличие от C++ можно создать объект, класс которого имеет все еще не переопределенные абстрактные методы. То есть язык Delphi поддерживает абстрактные методы, но не абстрактные классы.

type

  class TShape
    procedure draw; virtual; abstract;
    ...
  end;

  class TTriangle (TShape)
    procedure draw; override;
    ...
  end;

14.9.4. Полиморфизм в Objective-C

Разделы

Полиморфные переменные

При описании с ключевым словом id все переменные в Objective-C полиморфны и поэтому могут содержать любое значение. При описании с конкретным классом переменные имеют все свойства (хорошие и не очень) переменных языка C++.

Отложенные методы в Objective-C

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

Отложенный метод draw для класса Shape может быть записан, к примеру, следующим образом:

@implementation Shape : Object
...
- draw { return [ self subclassResponsibility ]; }
...
@end

14.9.5. Полиморфизм в Smalltalk

Разделы

Полиморфные переменные

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

Отложенные методы в Smalltalk

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

Отложенный метод draw для класса Shape может быть записан следующим образом:

draw
  " дочерние классы должны переопределять этот метод "
  ^ self subclassResponsibility

14.10. Эффективность и полиморфизм

Разделы

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

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

Упражнения

Разделы

  1. Как вы думаете, следует ли рассматривать значение nil в языке Pascal, или аналогичную величину NULL в C как полиморфный объект? Обоснуйте свой ответ.
  2. Какие еще операции (за исключением арифметических) обычно являются перегруженными в традиционных языках программирования (Pascal и C)?
  3. Проведите трассировку методов и классов при вычислении выражения:

    anInteger between: 7 and: 11

  4. Предположим, что в языке Smalltalk имеются два класса: яблоки Apple и апельсины Orange, которые являются подклассами фруктов Fruit. Какой минимальный объем кода потребуется для сравнения яблок и апельсинов?

Полиморфизм

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