Философия Java3
Шрифт:
Однако при попытке обращения к объектам производных типов как к базовым типам (окружности как фигуре, велосипеду как средству передвижения, баклану как птице и т. п.) возникает одна проблема. Если метод собирается приказать обобщенной фигуре нарисовать себя, или средству передвижения следовать по определенному курсу, или птице полететь, компилятор не может точно знать, какая именно часть кода выполнится. В этом все дело — когда посылается сообщение, программист и не хочет знать, какой код выполняется; метод прорисовки с одинаковым успехом может применяться и к окружности, и к прямоугольнику, и к треугольнику, а объект выполнит верный код, зависящий от его характерного типа.
Если вам не нужно знать, какой именно фрагмент кода выполняется, то, когда вы добавляете новый подтип, код его реализации может измениться,
Ответ объясняется главной особенностью объектно-ориентированного программирования: компилятор не может вызывать такие функции традиционным способом. При вызовах функций, созданных не ООП-компилятором, используется раннее связывание — многие не знают этого термина просто потому, что не представляют себе другого варианта. При раннем связывании компилятор генерирует вызов функции с указанным именем, а компоновщик привязывает этот вызов к абсолютному адресу кода, который необходимо выполнить. В ООП программа не в состоянии определить адрес кода до времени исполнения, поэтому при отправке сообщения объекту должен срабатывать иной механизм.
Для решения этой задачи языки объектно-ориентированного программирования используют концепцию позднего связывания. Когда вы посылаете сообщение объекту, вызываемый код неизвестен вплоть до времени исполнения. Компилятор лишь убеждается в том, что метод существует, проверяет типы для его параметров и возвращаемого значения, но не имеет представления, какой именно код будет исполняться.
Для осуществления позднего связывания 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. И тем не менее все работает правильно благодаря полиморфизму. Компилятор и система исполнения берут на себя все подробности; все, что вам нужно знать, — как это происходит... и, что еще важнее, как создавать программы, используя такой подход. Когда вы посылаете сообщение объекту, объект выберет правильный вариант поведения даже при восходящем преобразовании.
Однокорневая иерархия
Вскоре после появления С++ стал активно обсуждаться вопрос — должны ли все классы обязательно наследовать от единого базового класса? В Java (как практически во всех других ООП-языках, кроме С++) на этот вопрос был дан положительный ответ. В основе всей иерархии типов лежит единый базовый класс Object. Оказалось, что однокорневая иерархия имеет множество преимуществ.
Все объекты в однокорневой иерархии имеют некий общий интерфейс, так что по большому счету все они могут рассматриваться как один основополагающий тип. В С++ был выбран другой вариант — общего предка в этом языке не существует. С точки зрения совместимости со старым кодом эта модель лучше соответствует традициям С, и можно подумать, что она менее ограничена. Но как только возникнет необходимость в полноценном объектно-ориентированном программировании, вам придется создавать собственную иерархию классов, чтобы получить те же преимущества, что встроены в другие ООП-языки. Да и в любой новой библиотеке классов вам может встретиться какой-нибудь несовместимый интерфейс. Включение этих новых интерфейсов в архитектуру вашей программы потребует лишних усилий (и возможно, множественного наследования). Стоит ли дополнительная «гибкость» С++ подобных издержек? Если вам это нужно (например, при больших вложениях в разработку кода С), то в проигрыше вы не останетесь. Если же разработка начинается «с нуля», подход Java выглядит более продуктивным.