На этом шаге рассмотрим полиморфизм.
Функция transmit, определенная в классе TelemetryData, может передавать идентификатор потока телеметрической информации и его временную метку. Та же самая функция в классе ElectricalData может вызывать функцию transmit из класса TelemetryData, передавая дополнительно величину напряжения и значения силы тока.
Такое поведение обеспечивается полиморфизмом, а операции называются полиморфными (polymorphic). Полиморфизм — это концепция теории типов, согласно которому одно и то же имя может обозначать экземпляры разных классов, связанных с общим суперклассом. Таким образом, любой объект с этим именем может по-разному выполнять общий набор операций. Используя полиморфизм, одну и ту же операцию можно по-разному реализовывать в классах, образующих иерархию. В результате подкласс может расширять возможности суперкласса или замещать базовые операции, как это делал класс ElectricalData.
Впервые идею полиморфизма описал Стрейчи (Strachey) [As quoted in Harland, D., Szyplewski, M., and Wainwright, J. October 1985. An Alternative View of Polymorphism. SIGPLAN Notices, vol. 20(10).], имея в виду возможность переопределять смысл символов, таких как "+". Эта концепция называется перегрузкой (overloading). В языке C++ можно объявить несколько функций с одним и тем же именем, которые при вызове различаются по своим сигнатурам, состоящим из различного количества и типов аргументов (в языке C++, в отличие от языка Ada, тип возвращаемого значения частью сигнатуры не считается). В противоположность этому язык Java не допускает перегрузки операторов. Кроме того, Стрейчи писал о параметрическом полиморфизме, который в настоящее время называется просто полиморфизмом.
Код программы, не использующей полиморфизм, содержит множество операторов многовариантного выбора. Без полиморфизма невозможно создать иерархию классов для разных типов телеметрических данных. В этом случае проектировщику пришлось бы создавать единую монолитную вариантную запись, содержащую все свойства, связанные со всеми типами данных. Для того чтобы отличать один вариант от другого, пришлось бы проверять дескриптор, связанный с записью.
Для того чтобы создать новый тип телеметрических данных, необходимо модифицировать эту вариантную запись, добавляя новый тип в каждый оператор case в каждом экземпляре записи. Это увеличивает вероятность ошибок и делает проект неустойчивым.
Наследование позволяет разделять разные виды абстракций, поэтому необходимость в монолитных типах исчезает. Каплан (Kaplan) и Джонсон (Johnson) отмечают, что полиморфизм наиболее полезен в тех случаях, когда несколько классов имеют один и тот же протокол [Kaplan, S., and Johnson, R. July 21, 1986. Designing and Implementing for Reuse. Urbana, IL: University of Illinois, Department of Computer Science, p. 8.]. Полиморфизм позволяет обойтись без операторов многовариантного выбора, поскольку каждый объект неявно сам знает свой тип.
Наследование без полиморфизма возможно, но не очень полезно. Полиморфизм тесно связан с механизмом позднего связывания. При полиморфизме связь метода и имени определяется только в процессе выполнения программы. В языке C++ программист имеет возможность выбирать между ранним и поздним связыванием имени с членом-функцией. В частности, если метод объявлен как виртуальный, то реализуется позднее связывание, а функция считается полиморфной. Если объявление виртуальной функции отсутствует, то используется раннее связывание и метод уточняется на этапе компиляции. В языке Java позднее связывание не требует использования ключевого слова virtual.
В традиционных языках программирования вызов подпрограмм носит исключительно статический характер. Например, в языке Pascal для оператора, вызывающего подпрограмму Р, обычно генерируется код, создающий новый стек, размещающий в нем аргументы, а затем изменяющий поток управления для выполнения кода, связанного с процедурой Р. Однако в языках, поддерживающих полиморфизм, таких как Smalltalk и C++, активизация операции может осуществляться динамически, поскольку класс объекта, из которого будет вызван метод, может быть неизвестным до выполнения программы. Ситуация становится еще интереснее, если используется механизм наследования. Семантика активизации операции при использовании механизма наследования без полиморфизма совпадает с простым статическим вызовом подпрограмм, но наличие полиморфизма вынуждает программиста прибегнуть к более сложным приемам.
Рассмотрим иерархию классов (рис. 1), в которую входят базовый класс Displayltem и три подкласса с именами Circle, Triangle и Rectangle. В свою очередь класс Rectangle имеет подкласс SolidRectangle.
Рис.1. Диаграмма классов
Предположим, что в классе Displayltem определена переменная экземпляра theCenter (задающая координаты центра изображаемого элемента), а также следующие операции:
Операция location является общей для всех подклассов и не требует переопределения, но операции draw и move должны быть переопределены, поскольку только подклассы могут знать, как изображать и передвигать элементы.
Класс Circle должен иметь переменную theRadius и соответствующие операции для установки и чтения значения этой переменной set и retrieve соответственно. Операция draw, переопределенная в подклассе, рисует окружность заданного радиуса с центром в точке theCenter. Аналогично, в классе Rectangle следует предусмотреть переменные theHeight и theWidth и соответствующие операции set и retrieve для установки и чтения их значений. Операция draw, переопределенная в этом подклассе, рисует прямоугольник заданной высоты и ширины с центром в заданной точке theCenter. Подкласс SolidRectangle наследует все свойства класса Rectangle, но операция draw в этом подклассе также переопределена. В частности, реализация функции draw в классе SolidRectangle сначала вызывает функцию draw, определенную в суперклассе Rectangle, а затем полученный контур заполняется цветом.
Предположим, что некий клиент желает нарисовать все фигуры, реализованные в подклассах. В данном случае компилятор не может статически сгенерировать код, чтобы вызвать подходящую функцию draw, поскольку класс объектов, из которых вызывается эта функция, определяется лишь во время выполнения программы. Посмотрим, как эту задачу можно решить с помощью разных объектно-ориентированных языков программирования.
Поскольку в языке Smalltalk нет типов, диспетчеризация методов является строго динамической. Когда клиент посылает сообщение draw объекту, указанному в списке, происходит следующее:
Этот процесс распространяется вверх по иерархии, пока сообщение не будет найдено или не будет достигнут базовый класс Object. В последнем варианте язык Smalltalk предусматривает передачу сообщения doesNotUnderstand, служащего признаком ошибки.
Главной особенностью этого алгоритма является словарь сообщений (message dictionary), являющийся частью представления класса и потому скрытый от клиента.
Этот словарь создается одновременно с классом и содержит все методы, связанные с экземплярами данного класса. Поиск сообщения в словаре требует довольно много времени. Вызов метода в языке Smalltalk требует примерно в 1,5 раза больше времени, чем простой вызов подпрограммы. В коммерческих версиях языка Smalltalk предусмотрена оптимизация диспетчеризации методов за счет кеширования доступа к словарю, поэтому часто передаваемые сообщения находятся быстро.
Как правило, кеширование ускоряет быстродействие программ на 20-30% [Deutsch, P. 1983. Efficient Implementation of the Smalltalk-80 System. In: Proceedings of the 11th Annual ACM Symposium on the Principles of Programming Languages, p. 300.].
Операция draw, определенная в подклассе SolidRectangle, представляет собой особый случай. Как отмечено выше, ее реализация сначала вызывает одноименный метод, определенный в суперклассе Rectangle. В языке Smalltalk методы суперкласса выделяются с помощью ключевого слова super.
Диспетчеризация методов в языке Smalltalk осуществляется так, как уже описывалось, за исключением того, что поиск начинается с суперкласса, а не с класса.
Исследования Дейча (Deutsch) дают основание утверждать, что в 85% случаев полиморфизм не нужен, поэтому вызов метода часто можно свести к обычному вызову процедуры. Дафф (Duff) отмечает, что, разрабатывая такие классы, программисты часто неявно предполагают существование механизма раннего связывания [Duff, С August 1986. Designing an Efficient Language. Byte vol. 11(8), p. 216.]. К сожалению, в языках без контроля типов, таких как Smalltalk, не существует общепринятых средств для передачи этих предположений компилятору.
В языках программирования с более строгим контролем типов, таких как C++, разработчик имеет такую возможность. Поскольку диспетчеризацию методов следует применять как можно реже, сохраняя тем не менее возможность полиморфной диспетчеризации, вызов метода в этих языках немного отличается от описанного выше.
Программист, работающий на языке C++, может выбрать операции для позднего связывания, объявив их виртуальными. Все вызовы остальных методов обрабатываются компилятором с помощью раннего связывания, т.е. как простой вызов процедуры.
Для управления виртуальными функциями в языке C++ используется концепция виртуальной таблицы vtable, определяемой для каждого объекта, требующего полиморфной диспетчеризации, в ходе его создания (т.е. когда класс объекта фиксирован). Виртуальная таблица, как правило, содержит список указателей на виртуальные функции. Например, при создании объекта класса Rectangle виртуальная таблица будет включать запись для виртуальной функции draw, содержащую указатель на ближайшую реализацию функции draw. Если же в класс Displayltem включена виртуальная функция Rotate, которая в классе Rectangle не переопределяется, то соответствующий указатель будет ссылаться на ее реализацию в классе Displayltem. Это позволяет исключить поиск на этапе выполнения программы. Ссылка на виртуальную функцию-член объекта представляет собой лишь косвенное обращение через соответствующий указатель, а требуемый код выполняется немедленно без какого-либо поиска [Stroustrup, B. 1988. Private communication.].
На следующем шаге рассмотрим множественное наследование.