3

3


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

Для замещения метода нужно просто создать новое определение этого мето¬да в производном классе. Вы как бы говорите: «Я использую тот же метод ин¬терфейса, но хочу, чтобы он выполнял другие действия для моего нового типа».
Отношение «является» в сравнении с «похоже»
При использовании наследования встает очевидный вопрос: следует ли при на¬следовании переопределять только методы базового класса (и не добавлять но¬вые методы, не существующие в базовом классе)? Это означало бы, что произ¬водный тип будет точно такого же типа, как и базовый класс, так как они имеют одинаковый интерфейс. В результате вы можете свободно заменять объ¬екты базового класса объектами производных классов. Можно говорить о пол¬ной замене, и это часто называется принципом замены. В определенном смысле это способ наследования идеален. Подобный способ взаимосвязи базового и производного классов часто называют связью «является тем-то», поскольку можно сказать «круг есть фигура». Чтобы определить, насколько уместным бу¬дет наследование, достаточно проверить, существует ли отношение «является» между классами и насколько оно оправданно.
В иных случаях интерфейс производного класса дополняется новыми эле¬ментами, что приводит к его расширению. Новый тип все еще может приме¬няться вместо базового, но теперь эта замена не идеальна, потому что она не по¬зволяет использовать новые методы из базового типа. Подобная связь описывается выражением «похоже на» (это мой термин); новый тип содержит интерфейс старого типа, но также включает в себя и новые методы, и нельзя сказать, что эти типы абсолютно одинаковы. Для примера возьмем кондиционер.
Предположим, что ваш дом снабжен всем необходимым оборудованием для контроля процесса охлаждения. Представим теперь, что кондиционер сломался и вы заменили его обогревателем, способным как нагревать, так и охлаждать. Обогреватель «похож на» кондиционер, но он способен и на большее. Так как система управления вашего дома способна контролировать только охлаждение, она ограничена в коммуникациях с охлаждающей частью нового объекта. Ин¬терфейс нового объекта был расширен, а существующая система ничего не при¬знает, кроме оригинального интерфейса.

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

Ответ объясняется главной особенностью объектно-ориентированного про¬граммирования: компилятор не может вызывать такие функции традиционным способом. При вызовах функций, созданных не ООП-компилятором, использу¬ется раннее связывание — многие не знают этого термина просто потому, что не представляют себе другого варианта. При раннем связывании компилятор генерирует вызов функции с указанным именем, а компоновщик привязывает этот вызов к абсолютному адресу кода, который необходимо выполнить. В ООП программа не в состоянии определить адрес кода до времени исполнения, по¬этому при отправке сообщения объекту должен срабатывать иной механизм.
Для решения этой задачи языки объектно-ориентированного программиро¬вания используют концепцию позднего связывания. Когда вы посылаете сооб¬щение объекту, вызываемый код неизвестен вплоть до времени исполнения. Компилятор лишь убеждается в том, что метод существует, проверяет типы для его параметров и возвращаемого значения, но не имеет представления, какой именно код будет исполняться.
Для осуществления позднего связывания Java вместо абсолютного вызова использует специальные фрагменты кода. Этот код вычисляет адрес тела мето¬да на основе информации, хранящейся в объекте (процесс очень подробно опи¬сан в главе 7). Таким образом, каждый объект может вести себя различно, в за¬висимости от содержимого этого кода. Когда вы посылаете сообщение, объект фактически сам решает, что же с ним делать.
В некоторых языках необходимо явно указать, что для метода должен ис¬пользоваться гибкий механизм позднего связывания (в С++ для этого преду¬смотрено ключевое слово virtual). В этих языках методы по умолчанию компо¬нуются не динамически. В Java позднее связывание производится по умолча¬нию, и вам не нужно помнить о необходимости добавления каких-либо ключе¬вых слов для обеспечения полиморфизма.
Вспомним о примере с фигурами. Семейство классов (основанных на одина¬ковом интерфейсе) было показано на диаграмме чуть раньше в этой главе. Для демонстрации полиморфизма мы напишем фрагмент кода, который игнорирует характерные особенности типов и работает только с базовым классом. Этот код отделен от специфики типов, поэтому его проще писать и понимать. И если но¬вый тип (например, шестиугольник) будет добавлен посредством наследова¬ния, то написанный вами код будет работать для нового типа фигуры так же хо¬рошо, как прежде. Таким образом, программа становится расширяемой.
Допустим, вы написали на Java следующий метод (вскоре вы узнаете, как это делать):
void doSomething(Shape shape) { shape.eraseO: II стереть II...
shape.drawO, II нарисовать }
Метод работает с обобщенной фигурой (Shape), то есть не зависит от кон¬кретного типа объекта, который рисуется или стирается. Теперь мы используем вызов метода doSomething() в другой части программы:
Circle circle = new CircleO. // окружность Triangle triangle = new TriangleO; II треугольник Line line = new LineO; // линия doSomething(circle). doSomething(triangle). doSomething( line);
Вызовы метода doStuff() автоматически работают правильно, вне зависимо¬сти от фактического типа объекта. На самом деле это довольно важный факт. Рассмотрим строку:
doSomething(c);
Здесь происходит следующее: методу, ожидающему объект Shape, передается объект «окружность» (Circle). Так как окружность (Circle) одновременно являет¬ся фигурой (Shape), то метод doSomething() и обращается с ней, как с фигурой. Другими словами, любое сообщение, которое метод может послать Shape, также принимается и Circle. Это действие совершенно безопасно и настолько же ло¬гично.
Мы называем этот процесс обращения с производным типом как с базовым восходящим преобразованием типов. Слово преобразование означает, что объект трактуется как принадлежащий к другому типу, а восходящее оно потому, что на диаграммах наследования базовые классы обычно располагаются вверху, а производные классы располагаются внизу «веером». Значит, преобразование к базовому типу — это движение по диаграмме вверх, и поэтому оно «восходя¬щее».

Объектно-ориентированная программа почти всегда содержит восходящее преобразование, потому что именно так вы избавляетесь от необходимости знать точный тип объекта, с которым работаете. Посмотрите на тело метода doSomething():
shape erase().
// .
shape drawO,
Заметьте, что здесь не сказано «если ты объект Circle, делай это, а если ты объект Square, делай то-то и то-то». Такой код с отдельными действиями для ка¬ждого возможного типа Shape будет путаным, и его придется менять каждый раз при добавлении нового подтипа Shape. А так, вы просто говорите: «Ты фигура, и я знаю, что ты способна нарисовать и стереть себя, ну так и делай это, а о де¬талях позаботься сама».
В коде метода doSomething() интересно то, что все само собой получается правильно. При вызове draw() для объекта Circle исполняется другой код, а не тот, что отрабатывает при вызове draw() для объектов Square или Line, а когда draw() применяется для неизвестной фигуры Shape, правильное поведение обеспечива¬ется использованием реального типа Shape. Это в высшей степени интересно, потому что, как было замечено чуть ранее, когда компилятор генерирует код doSomething(), он не знает точно, с какими типами он работает. Соответственно, можно было бы ожидать вызова версий методов draw() и erase() из базового класса Shape, а не их вариантов из конкретных классов Circle, Square или Line. И тем не менее все работает правильно благодаря полиморфизму. Компилятор и система исполнения берут на себя все подробности; все, что вам нужно знать, — как это происходит... и, что еще важнее, как создавать программы, используя такой подход. Когда вы посылаете сообщение объекту, объект выбе¬рет правильный вариант поведения даже при восходящем преобразовании.

Report Page