Философия Java3
Шрифт:
Для замещения метода нужно просто создать новое определение этого метода в производном классе. Вы как бы говорите: «Я использую тот же метод интерфейса, но хочу, чтобы он выполнял другие действия для моего нового типа».
Отношение «является» в сравнении с «похоже»
При использовании наследования встает очевидный вопрос: следует ли при наследовании переопределять только методы базового класса (и не добавлять новые методы, не существующие в базовом классе)? Это означало бы, что производный тип будет точно такого же типа, как и базовый класс, так как они имеют одинаковый интерфейс. В результате вы можете свободно заменять
В иных случаях интерфейс производного класса дополняется новыми элементами, что приводит к его расширению. Новый тип все еще может применяться вместо базового, но теперь эта замена не идеальна, потому что она не позволяет использовать новые методы из базового типа. Подобная связь описывается выражением «похоже на» (это мой термин); новый тип содержит интерфейс старого типа, но также включает в себя и новые методы, и нельзя сказать, что эти типы абсолютно одинаковы. Для примера возьмем кондиционер.
Предположим, что ваш дом снабжен всем необходимым оборудованием для контроля процесса охлаждения. Представим теперь, что кондиционер сломался и вы заменили его обогревателем, способным как нагревать, так и охлаждать. Обогреватель «похож на» кондиционер, но он способен и на большее. Так как система управления вашего дома способна контролировать только охлаждение, она ограничена в коммуникациях с охлаждающей частью нового объекта. Интерфейс нового объекта был расширен, а существующая система ничего не признает, кроме оригинального интерфейса.
Конечно, при виде этой иерархии становится ясно, что базовый класс «охлаждающая система» недостаточно гибок; его следует переименовать в «систему контроля температуры» так, чтобы он включал и нагрев, — и после этого заработает принцип замены. Тем не менее эта диаграмма представляет пример того, что может произойти в реальности.
После знакомства с принципом замены может возникнуть впечатление, что этот подход (полная замена) — единственный способ разработки. Вообще говоря, если ваши иерархии типов так работают, это действительно хорошо. Но в некоторых ситуациях совершенно необходимо добавлять новые методы к интерфейсу производного класса. При внимательном анализе оба случая представляются достаточно очевидными.
Взаимозаменяемые объекты и полиморфизм
При использовании иерархий типов часто приходится обращаться с объектом определенного типа как с базовым типом. Это позволяет писать код, не зависящий от конкретных типов. Так, в примере с фигурами методы манипулируют просто фигурами, не обращая внимания на то, являются ли они окружностями, прямоугольниками, треугольниками или некоторыми еще даже не определенными фигурами. Все фигуры могут быть нарисованы, стерты и перемещены, а методы просто посылают сообщения объекту «фигура»; им безразлично, как объект обойдется с этим сообщением.
Подобный код не зависит от добавления новых типов, а добавление новых типов является наиболее распространенным
Однако при попытке обращения к объектам производных типов как к базовым типам (окружности как фигуре, велосипеду как средству передвижения, баклану как птице и т. п.) возникает одна проблема. Если метод собирается приказать обобщенной фигуре нарисовать себя, или средству передвижения следовать по определенному курсу, или птице полететь, компилятор не может точно знать, какая именно часть кода выполнится. В этом все дело — когда посылается сообщение, программист и не хочет знать, какой код выполняется; метод прорисовки с одинаковым успехом может применяться и к окружности, и к прямоугольнику, и к треугольнику, а объект выполнит верный код, зависящий от его характерного типа.
Если вам не нужно знать, какой именно фрагмент кода выполняется, то, когда вы добавляете новый подтип, код его реализации может измениться, но без изменений в том методе, из которого он был вызван. Если компилятор не обладает информацией, какой именно код следует выполнить, что же он делает? В следующем примере объект BirdController (управление птицей) может работать только с обобщенными объектами Bird (птица), не зная типа конкретного объекта. С точки зрения BirdController это удобно, поскольку для него не придется писать специальный код проверки типа используемого объекта Bird для обработки какого-то особого поведения. Как же все-таки происходит, что при вызове метода move без указания точного типа Bird исполняется верное действие — объект Goose (гусь) бежит, летит или плывет, а объект Penguin (пингвин) бежит или плывет?
Ответ объясняется главной особенностью объектно-ориентированного программирования: компилятор не может вызывать такие функции традиционным способом. При вызовах функций, созданных не ООП-компилятором, используется раннее связывание — многие не знают этого термина просто потому, что не представляют себе другого варианта. При раннем связывании компилятор генерирует вызов функции с указанным именем, а компоновщик привязывает этот вызов к абсолютному адресу кода, который необходимо выполнить. В ООП программа не в состоянии определить адрес кода до времени исполнения, поэтому при отправке сообщения объекту должен срабатывать иной механизм.
Для решения этой задачи языки объектно-ориентированного программирования используют концепцию позднего связывания. Когда вы посылаете сообщение объекту, вызываемый код неизвестен вплоть до времени исполнения. Компилятор лишь убеждается в том, что метод существует, проверяет типы для его параметров и возвращаемого значения, но не имеет представления, какой именно код будет исполняться.
Для осуществления позднего связывания Java вместо абсолютного вызова использует специальные фрагменты кода. Этот код вычисляет адрес тела метода на основе информации, хранящейся в объекте (процесс очень подробно описан в главе 7). Таким образом, каждый объект может вести себя различно, в зависимости от содержимого этого кода. Когда вы посылаете сообщение, объект фактически сам решает, что же с ним делать.