Философия Java

Взаимозаменяемые объекты с полиморфизмом


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

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

Однако, существует проблема, когда пробуют трактовать унаследованный тип как объект базового типа (окружность - как форма, велосипед - как транспортное средство, баклана - как птицу и т.п.). Если функция предназначена для сообщения родительской форме о необходимости нарисовать себя или родительскому транспортному средству - управлять, или родительской птице - лететь, компилятор не может знать точно во время компиляции, какой кусок кода будет исполнен. Это то место когда сообщение послано, а программист не хочет знать, какой кусок кода будет исполнен. Функция рисования может быть одинаково применена к окружности, к квадрату или к треугольнику, и объект должен выполнять правильный код в зависимости определенного для него типа. Если вы не знаете какая часть кода будет выполнена, то когда вы добавляете новый подтип, то выполняемый код может отличаться, и это не потребует изменений при вызове функции. Поэтому, компилятор не может знать точно какая часть кода выполнилась, но что делает это? Например, в приведенной диаграмме КонтроллерПтицы объект работает только с родительским объектом Птица и не знает какой точно тип имеется. Это удобно с точки зрения КонроллераПтицы, так как нет необходимости писать специальный код для определения точного типа Птицы, с которой идет работа, или поведения Птицы. Если это так, то когда вызывается move( ) при игнорировании определенного типа Птицы, как воспроизведется правильное поведение (бег, полет или плаванье Гуся и бег или плаванье Пингвина)?


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

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

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

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

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



Если вы пишете метод в Java ( скоро вы выучите как это делать):

void doStuff(Shape s) { s.erase(); // ...

s.draw(); }

Эта функция говорит любой Форме, так что это не зависит от специфического типа объекта, который рисуется и стирается. Если в некоторой части программы мы используем функцию doStuff( )

:

Circle c = new Circle(); Triangle t = new Triangle(); Line l = new Line(); doStuff(c); doStuff(t); doStuff(l);

Вызов doStuff( ) автоматически работает правильно, не зависимо от точного типа объекта.

Это, фактически, красивый и удивительный фокус. Рассмотри строку:

doStuff(c);

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

Мы называем этот процесс, когда наследуемый тип трактуется как базовый, обратное преобразование (upcasting). Название преобразование (cast) использовалось в смысле преобразования к шаблону, а Обратное (up) исходит от способа построения диаграммы наследования, которая обычно упорядочена так: базовый тип вверху, а наследуемые классы развертываются вниз. Таким образом, преобразование к базовому типу - это перемещение вверх по диаграмме наследования: “обратное преобразование”.



Объектно-ориентированное программирование содержит кое-где обратное преобразование, поскольку, так как вы отделяете себя от знания точного типа, вы работаете с этим. Посмотрите на код doStuff( ):

s.erase(); // ...

s.draw();

Заметьте, что это не значит сказать: “Если ты Окружность, сделай это, если ты Квадрат, сделай то и т.д.” Если вы пишете код такого рода, который проверяет все возможные типы, которыми может быть Форма, это грязный способ и вы должны менять его всякий раз, когда добавляете новый сорт Формы. Здесь вы просто говорите: “Ты - Форма, я знаю, что ты можешь стирать erase( ) и рисовать draw( ) сама. Сделай это и правильно позаботься о деталях.”



Что впечатляющего в коде doStuff( ), так это то, что все почему-то правильно происходит. Вызов draw( ) для Окружности становится причиной вызова другого кода, чем при вызове draw( ) для Квадрата или для Линии, но когда сообщение draw( ) посылается к анонимной Форме, происходит правильное поведение, основанное на действительном типе Формы. Это удивительно, поскольку, как упомянуто ранее, когда компилятор Java компилирует код для doStuff( ), он не может знать точный тип, с которым идет работа. Так что обычно вы не ожидаете этого до конца вызова версии erase( ) и draw( ) для базового класса Формы, и для определенного класса Окружности, Квадрата или Линии. Тем не менее, правильная работа происходит по причине полиморфизма. Компилятор и система времени выполнения управляет деталями. Все что вам нужно - это знать что случится, и что более важно, как с этим работать. Когда вы посылаете объекту сообщение, объект будет делать правильные вещи, даже если используется обратное преобразование.


Содержание раздела