Ответ объясняется главной особенностью объектно-ориентированного про¬граммирования: компилятор не может вызывать такие функции традиционным способом. При вызовах функций, созданных не ООП-компилятором, использу¬ется раннее связывание — многие не знают этого термина просто потому, что не представляют себе другого варианта. При раннем связывании компилятор генерирует вызов функции с указанным именем, а компоновщик привязывает этот вызов к абсолютному адресу кода, который необходимо выполнить. В ООП программа не в состоянии определить адрес кода до времени исполнения, по¬этому при отправке сообщения объекту должен срабатывать иной механизм.
Для решения этой задачи языки объектно-ориентированного программиро¬вания используют концепцию позднего связывания. Когда вы посылаете сооб¬щение объекту, вызываемый код неизвестен вплоть до времени исполнения. Компилятор лишь убеждается в том, что метод существует, проверяет типы для его параметров и возвращаемого значения, но не имеет представления, какой именно код будет исполняться.
Для осуществления позднего связывания 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 выглядит более продуктивным.
Все объекты из однокорневой иерархии гарантированно обладают некото¬рой общей функциональностью. Вы знаете, что с любым объектом в системе можно провести определенные основные операции. Все объекты легко создают¬ся в динамической «куче», а передача аргументов сильно упрощается.
Однокорневая иерархия позволяет гораздо проще реализовать уборку мусо¬ра — одно из важнейших усовершенствований Java по сравнению с С++. Так как информация о типе во время исполнения гарантированно присутствует в любом из объектов, в системе никогда не появится объект, тип которого не удастся определить. Это особенно важно при выполнении системных опера¬ций, таких как обработка исключений, и для обеспечения большей гибкости программирования.