Глава 8 Thinking in Java 4th edition |
ПОЛИМОРФИЗМ
Полиморфизм является третьей неотъемлемой чертой объектно-ориентированного языка, вместе с абстракцией данных и наследованием.Он предоставляет еще одну степень отделения интерфейса от реализации, разъединения что от как. Полиморфизм улучшает организацию кода и его читаемость, а также способствует созданию расширяемых программ, которые могут «расти» не только в процессе начальной разработки проекта, но и при добавлении новых возможностей.
ПОЛИМОРФИЗМ
Полиморфизм является третьей неотъемлемой чертой объектно-ориентированного языка, вместе с абстракцией данных и наследованием.
Он предоставляет еще одну степень отделения интерфейса от реализации, разъединения что от как. Полиморфизм улучшает организацию кода и его читаемость, а также способствует созданию расширяемых программ, которые могут «расти» не только в процессе начальной разработки проекта, но и при добавлении новых возможностей.
Инкапсуляция создает новые типы данных за счет объединения характеристик и поведения. Сокрытие реализации отделяет интерфейс от реализации за счет изоляции технических подробностей в private-частях класса. Подобное механическое разделение понятно любому, кто имел опыт работы с процедурными языками.
Но полиморфизм имеет дело с логическим разделением в контексте типов. В предыдущей главе вы увидели, что наследование позволяет работать с объектом, используя как его собственный тип, так и его базовый тип.
Этот факт очень важен, потому что он позволяет работать со многими типами (производными от одного базового типа) как с единым типом, что дает возможность единому коду работать с множеством разных типов единообразно. Вызов полиморфного метода позволяет одному типу выразить свое отличие от другого, сходного типа, хотя они и происходят от одного базового типа. Это отличие выражается различным действием методов, вызываемых через базовый класс.
В этой главе рассматривается полиморфизм (также называемый динамическим связыванием, или поздним связыванием, или связыванием во время выполнения). Мы начнем с азов, а изложение материала будет поясняться простыми примерами, полностью акцентированными на полиморфном поведении программы.
Снова о восходящем преобразовании
Как было показано в главе 7, с объектом можно работать с использованием как его собственного типа, так и его базового типа. Интерпретация ссылки на объект как ссылки на базовый тип называется восходящим преобразованием.
Также были представлены проблемы, возникающие при восходящем преобразовании и наглядно воплощенные в следующей программе с музыкальными инструментами. Поскольку мы будем проигрывать с их помощью объекты Note (нота), логично создать эти объекты в отдельном пакете:
//: polymorphism/music/Note.java
// Ноты для игры на музыкальных инструментах.
package polymorphism.music;
public enum Note {
MIDDLE_C, C_SHARP, B_FLAT; // Etc.
}
Перечисления были представлены в главе 5. В следующем примере Wind является частным случаем инструмента (Instrument), поэтому класс Wind наследует от Instrument:
//: polymorphism/music/Instrument.java
package polymorphism.music;
import static net.mindview.util.Print.*;
class Instrument {
public void play(Note n) {
print("Instrument.play()");
}
}
//: polymorphism/music/Wind.java
package polymorphism.music;
// Объекты Wind также являются объектами Instrument
// поскольку имеют тот же интерфейс:
public class Wind extends Instrument {
// Redefine interface method:
public void play(Note n) {
System.out.println("Wind.play() " + n);
}
}
//: polymorphism/music/Music.java
// Наследование и восходящее преобразование
package polymorphism.music;
public class Music {
public static void tune(Instrument i) {
// ...
i.play(Note.MIDDLE_C);
}
public static void main(String[] args) {
Wind flute = new Wind();
tune(flute); // Восходящее преобразование
}
}
<spoiler text="Output:">
Wind.play() MIDDLE_C
</spoiler>
Метод Music.tune() получает ссылку на Instrument, но последняя также может указывать на объект любого класса, производного от Instrument. В методе main() ссылка на объект Wind передается методу tune() без явных преобразований. Это нормально; интерфейс класса Instrument должен существовать и в классе Wind, поскольку последний был унаследован от Instrument. Восходящее преобразование от Wind к Instrument способно «сузить» этот интерфейс, но не сделает его «меньше», чем полный интерфейс класса Instrument.
Потеря типа объекта
Программа Music.java выглядит немного странно. Зачем умышленно игнорировать фактический тип объекта? Именно это мы наблюдаем при восходящем преобразовании, и казалось бы, программа стала яснее, если бы методу tune() передавалась ссылка на объект Wind. Но при этом мы сталкиваемся с очень важным обстоятельством: если поступить подобным образом, то потом придется писать новый метод tune() для каждого типа Instrument, присутствующего в системе. Предположим, что в систему были добавлены новые классы Stringed и Brass:
//: polymorphism/music/Music2.java
// Перегрузка вместо восходящего преобразования
package polymorphism.music;
import static net.mindview.util.Print.*;
class Stringed extends Instrument {
public void play(Note n) {
print("Stringed.play() " + n);
}
}
class Brass extends Instrument {
public void play(Note n) {
print("Brass.play() " + n);
}
}
public class Music2 {
public static void tune(Wind i) {
i.play(Note.MIDDLE_C);
}
public static void tune(Stringed i) {
i.play(Note.MIDDLE_C);
}
public static void tune(Brass i) {
i.play(Note.MIDDLE_C);
}
public static void main(String[] args) {
Wind flute = new Wind();
Stringed violin = new Stringed();
Brass frenchHorn = new Brass();
tune(flute); // Без восходящего преобразования
tune(violin);
tune(frenchHorn);
}
}
<spoiler text="Output:">
Wind.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
</spoiler>
Программа работает, но у нее есть огромный недостаток: для каждого нового Instrument приходится писать новый, зависящий от конкретного типа метод tune(). Объем программного кода увеличивается, а при добавлении нового метода (такого, как tune()) или нового типа инструмента придется выполнить немало дополнительной работы. А если учесть, что компилятор не выводит сообщений об ошибках, если вы забудете перегрузить один из ваших методов, весь процесс работы с типами станет совершенно неуправляемым.
Разве не лучше было бы написать единственный метод, в аргументе которого передается базовый класс, а не один из производных классов? Разве не удобнее было бы забыть о производных классах и написать обобщенный код для базового класса?
Именно это и позволяет делать полиморфизм. Однако большинство программистов с опытом работы на процедурных языках при работе с полиморфизмом испытывают некоторые затруднения.
Особенности
Сложности с программой Music.java обнаруживаются после ее запуска. Она выводит строку Wind.play(). Именно это и требуется, но не понятно, откуда берется такой результат. Взгляните на метод tune():
public static void tune(Instrument i) {
//...
і.play(Note.MIDDLE_C),
}
Метод получает ссылку на объект Instrument. Как компилятор узнает, что ссылка на Instrument в данном случае указывает на объект Wind, а не на Brass или Stringed? Компилятор и не знает. Чтобы в полной мере разобраться в сути происходящего, необходимо рассмотреть понятие связывания (binding).
Связывание «метод-вызов»
Присоединение вызова метода к телу метода называется связыванием. Если связывание проводится перед запуском программы (компилятором и компоновщиком, если он есть), оно называется ранним связыванием (early binding). Возможно, ранее вам не приходилось слышать этот термин, потому что в процедурных языках никакого выбора связывания не было. Компиляторы C поддерживают только один тип вызова — раннее связывание.
Неоднозначность предыдущей программы кроется именно в раннем связывании: компилятор не может знать, какой метод нужно вызывать, когда у него есть только ссылка на объект Instrument.
Проблема решается благодаря позднему связыванию (late binding), то есть связыванию, проводимому во время выполнения программы, в зависимости от типа объекта. Позднее связывание также называют динамическим (dynamic) или связыванием на стадии выполнения (runtime binding).
В языках, реализующих позднее связывание, должен существовать механизм определения фактического типа объекта во время работы программы, для вызова подходящего метода. Иначе говоря, компилятор не знает тип объекта, но механизм вызова методов определяет его и вызывает соответствующее тело метода. Механизм позднего связывания зависит от конкретного языка, но нетрудно предположить, что для его реализации в объекты должна включаться какая-то дополнительная информация.
Для всех методов Java используется механизм позднего связывания, если только метод не был объявлен как final (приватные методы являются final по умолчанию). Следовательно, вам не придется принимать решений относительно использования позднего связывания — оно осуществляется автоматически.
Зачем объявлять метод как final? Как уже было замечено в предыдущей главе, это запрещает переопределение соответствующего метода.
Что еще важнее, это фактически «отключает» позднее связывание или, скорее, указывает компилятору на то, что позднее связывание не является необходимым. Поэтому для методов final компилятор генерирует чуть более эффективный код. Впрочем, в большинстве случаев влияние на производительность вашей программы незначительно, поэтому final лучше использовать в качестве продуманного элемента своего проекта, а не как средство улучшения производительности.
Получение нужного результата
Теперь, когда вы знаете, что связывание всех методов в Java осуществляется полиморфно, через позднее связывание, вы можете писать код для базового класса, не сомневаясь в том, что для всех производных классов он также будет работать верно. Другими словами, вы «посылаете сообщение объекту и позволяете ему решить, что следует делать дальше».
Классическим примером полиморфизма в ООП является пример с геометрическими фигурами. Он часто используется благодаря своей наглядности, но, к сожалению, некоторые новички начинают думать, что ООП подразумевает графическое программирование — а это, конечно же, неверно.
В примере с фигурами имеется базовый класс с именем Shape (фигура) и различные производные типы: Circle (окружность), Square (прямоугольник), Triangle (треугольник) и т. п. Выражения типа «окружность есть фигура» очевидны и не представляют трудностей для понимания. Взаимосвязи показаны на следующей диаграмме наследования:
Восходящее преобразование имеет место даже в такой простой команде:
Shape s = new Circle();
Здесь создается объект Circle, и полученная ссылка немедленно присваивается типу Shape. На первый взгляд это может показаться ошибкой (присвоение одного типа другому), но в действительности все правильно, потому что тип Circle (окружность) является типом Shape (фигура) посредством наследования. Компилятор принимает команду и не выдает сообщения об ошибке.
Предположим, вызывается один из методов базового класса (из тех, что были переопределены в производных классах):
s.draw();
Опять можно подумать, что вызывается метод draw() из класса Shape, раз имеется ссылка на объект Shape — как компилятор может сделать что-то другое? И все же будет вызван правильный метод Circle.draw(), так как в программе используется позднее связывание (полиморфизм).
Следующий пример показывает несколько другой подход:
//: polymorphism/shape/Shape.java
package polymorphism.shape;
public class Shape {
public void draw() {}
public void erase() {}
}
//: polymorphism/shape/Circle.java
package polymorphism.shape;
import static net.mindview.util.Print.*;
public class Circle extends Shape {
public void draw() { print("Circle.draw()"); }
public void erase() { print("Circle.erase()"); }
}
//: polymorphism/shape/Triangle.java
package polymorphism.shape;
import static net.mindview.util.Print.*;
public class Triangle extends Shape {
public void draw() { print("Triangle.draw()"); }
public void erase() { print("Triangle.erase()"); }
}
//: polymorphism/shape/Square.java
package polymorphism.shape;
import static net.mindview.util.Print.*;
public class Square extends Shape {
public void draw() { print("Square.draw()"); }
public void erase() { print("Square.erase()"); }
}
//: polymorphism/shape/RandomShapeGenerator.java
// "Фабрика" случайных фигур.
package polymorphism.shape;
import java.util.*;
public class RandomShapeGenerator {
private Random rand = new Random(47);
public Shape next() {
switch(rand.nextInt(3)) {
default:
case 0: return new Circle();
case 1: return new Square();
case 2: return new Triangle();
}
}
}
//: polymorphism/Shapes.java
// Polymorphism in Java.
import polymorphism.shape.*;
public class Shapes {
private static RandomShapeGenerator gen =
new RandomShapeGenerator();
public static void main(String[] args) {
Shape[] s = new Shape[9];
// Fill up the array with shapes:
for(int i = 0; i < s.length; i++)
s[i] = gen.next();
// Make polymorphic method calls:
for(Shape shp : s)
shp.draw();
}
}
<spoiler text="Output:">
Triangle.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Triangle.draw()
Circle.draw()
</spoiler>
Базовый класс Shape устанавливает общий интерфейс для всех классов, производных от Shape — то есть любую фигуру можно нарисовать (draw()) и стереть (erase()). Производные классы переопределяют этот интерфейс, чтобы реализовать уникальное поведение для каждой конкретной фигуры.
Класс RandomShapeGenerator — своего рода «фабрика», при каждом вызове метода next() производящая ссылку на случайно выбираемый объект Shape. Заметьте, что восходящее преобразование выполняется в командах return, каждая из которых получает ссылку на объект Circle, Square или Triangle, а выдает ее за пределы next() в виде возвращаемого типа Shape. Таким образом, при вызове этого метода вы не сможете определить конкретный тип объекта, поскольку всегда получаете просто Shape.
Метод main() содержит массив ссылок на Shape, который заполняется последовательными вызовами RandomShapeGenerator.next(). К этому моменту вам известно, что имеются объекты Shape, но вы не знаете об этих объектах ничего конкретного (так же, как и компилятор). Но если перебрать содержимое массива и вызвать draw() для каждого его элемента, то, как по волшебству, произойдет верное, свойственное для определенного типа действие — в этом нетрудно убедиться, взглянув на результат работы программы.
Случайный выбор фигур в нашем примере всего лишь помогает понять, что компилятор во время компиляции кода не располагает информацией о том, какую реализацию следует вызывать. Все вызовы метода draw() проводятся с применением позднего связывания.
Расширяемость
Теперь вернемся к программе Music.java. Благодаря полиморфизму вы можете добавить в нее сколько угодно новых типов, не изменяя метод tune(). В хорошо спланированной ООП-программе большая часть ваших методов (или даже все методы) следуют модели метода tune(), оперируя только с интерфейсом базового класса.
Такая программа является расширяемой, поскольку в нее можно добавить дополнительную функциональность, определяя новые типы данных от общего базового класса. Методы, работающие на уровне интерфейса базового класса, совсем не нужно изменять, чтобы приспособить их к новым классам.
Давайте возьмем пример с объектами Instrument и включим дополнительные методы в базовый класс, а также определим несколько новых классов. Рассмотрим диаграмму.
Все новые классы правильно работают со старым, неизмененным методом tune(). Даже если метод tune() находится в другом файле, а к классу Instrument присоединяются новые методы, он все равно будет работать верно без повторной компиляции. Ниже приведена реализация рассмотренной диаграммы:
//: polymorphism/music3/Music3.java
// Расширяемая программа
package polymorphism.music3;
import polymorphism.music.Note;
import static net.mindview.util.Print.*;
class Instrument {
void play(Note n) { print("Instrument.play() " + n); }
String what() { return "Instrument"; }
void adjust() { print("Adjusting Instrument"); }
}
class Wind extends Instrument {
void play(Note n) { print("Wind.play() " + n); }
String what() { return "Wind"; }
void adjust() { print("Adjusting Wind"); }
}
class Percussion extends Instrument {
void play(Note n) { print("Percussion.play() " + n); }
String what() { return "Percussion"; }
void adjust() { print("Adjusting Percussion"); }
}
class Stringed extends Instrument {
void play(Note n) { print("Stringed.play() " + n); }
String what() { return "Stringed"; }
void adjust() { print("Adjusting Stringed"); }
}
class Brass extends Wind {
void play(Note n) { print("Brass.play() " + n); }
void adjust() { print("Adjusting Brass"); }
}
class Woodwind extends Wind {
void play(Note n) { print("Woodwind.play() " + n); }
String what() { return "Woodwind"; }
}
public class Music3 {
// Работа метода не зависит от фактического типа объекта,
// поэтому типы, добавленные в систему, будут работать правильно:
public static void tune(Instrument i) {
// ...
i.play(Note.MIDDLE_C);
}
public static void tuneAll(Instrument[] e) {
for(Instrument i : e)
tune(i);
}
public static void main(String[] args) {
// Upcasting during addition to the array:
Instrument[] orchestra = {
new Wind(),
new Percussion(),
new Stringed(),
new Brass(),
new Woodwind()
};
tuneAll(orchestra);
}
}
<spoiler text="Output:">
Wind.play() MIDDLE_C
Percussion.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
Woodwind.play() MIDDLE_C
</spoiler>
Новый метод what() возвращает строку (String) с информацией о классе, а метод adjust() предназначен для настройки инструментов.
В методе main() сохранение любого объекта в массиве orchestra автоматически приводит к выполнению восходящего преобразования к типу Instrument.
Вы можете видеть, что метод tune() изолирован от окружающих изменений кода, но при этом все равно работает правильно. Для достижения такой функциональности и используется полиморфизм. Изменения в коде не затрагивают те части программы, которые не зависят от них. Другими словами, полиморфизм помогает отделить «изменяемое от неизменного».
Проблема: «переопределение» закрытых методов
Перед вами одна из ошибок, совершаемых по наивности:
//: polymorphism/PrivateOverride.java
// Попытка переопределения приватного метода
package polymorphism;
import static net.mindview.util.Print.*;
public class PrivateOverride {
private void f() { print("private f()"); }
public static void main(String[] args) {
PrivateOverride po = new Derived();
po.f();
}
}
class Derived extends PrivateOverride {
public void f() { print("public f()"); }
}
<spoiler text="Output:">
private f()
</spoiler>
Вполне естественно было бы ожидать, что программа выведет сообщение public f(), но закрытый (private) метод автоматически является неизменным (final), а заодно и скрытым от производного класса. Так что метод f() класса Derived в нашем случае является полностью новым — он даже не был перегружен, так как метод f() базового класса классу Derived недоступен.
Из этого можно сделать вывод, что переопределяются только методы, не являющиеся закрытыми. Будьте внимательны: компилятор в подобных ситуациях не выдает сообщений об ошибке, но и не делает того, что вы от него ожидаете. Иными словами, методам производного класса следует присваивать имена, отличные от имен закрытых методов базового класса.
Конструкторы и полиморфизм
Конструкторы отличаются от обычных методов, и эти отличия проявляются и при использовании полиморфизма. Хотя конструкторы сами по себе не полиморфны (фактически они представляют собой статические методы, только ключевое слово static опущено), вы должны хорошо понимать, как работают конструкторы в сложных полиморфных иерархиях. Такое понимание в дальнейшем поможет избежать некоторых затруднительных ситуаций.
Порядок вызова конструкторов
Порядок вызова конструкторов коротко обсуждался в главах 5 и 7, но в то время мы еще не рассматривали полиморфизм.
Конструктор базового класса всегда вызывается в процессе конструирования производного класса. Вызов автоматически проходит вверх по цепочке наследования, так что в конечном итоге вызываются конструкторы всех базовых классов по всей цепочке наследования. Это очень важно, поскольку конструктору отводится особая роль — обеспечивать правильное построение объектов.
Производный класс обычно имеет доступ только к своим членам, но не к членам базового класса (которые чаще всего объявляются со спецификатором private). Только конструктор базового класса обладает необходимыми знаниями и правами доступа, чтобы правильно инициализировать свои внутренние элементы.
Именно поэтому компилятор настаивает на вызове конструктора для любой части производного класса. Он незаметно подставит конструктор по умолчанию, если вы явно не вызовете конструктор базового класса в теле конструктора производного класса. Если конструктора по умолчанию не существует, компилятор сообщит об этом. (Если у класса вообще нет пользовательских конструкторов, компилятор автоматически генерирует конструктор по умолчанию.)
Следующий пример показывает, как композиция, наследование и полиморфизм влияют на порядок конструирования:
//: polymorphism/Sandwich.java
// Порядок вызова конструкторов.
package polymorphism;
import static net.mindview.util.Print.*;
class Meal {
Meal() { print("Meal()"); }
}
class Bread {
Bread() { print("Bread()"); }
}
class Cheese {
Cheese() { print("Cheese()"); }
}
class Lettuce {
Lettuce() { print("Lettuce()"); }
}
class Lunch extends Meal {
Lunch() { print("Lunch()"); }
}
class PortableLunch extends Lunch {
PortableLunch() { print("PortableLunch()");}
}
public class Sandwich extends PortableLunch {
private Bread b = new Bread();
private Cheese c = new Cheese();
private Lettuce l = new Lettuce();
public Sandwich() { print("Sandwich()"); }
public static void main(String[] args) {
new Sandwich();
}
}
<spoiler text="Output:">
Meal()
Lunch()
PortableLunch()
Bread()
Cheese()
Lettuce()
Sandwich()
</spoiler>
В этом примере создается сложный класс, собранный из других классов, и в каждом классе имеется конструктор, который сообщает о своем выполнении. Самый важный класс — Sandwich, с тремя уровнями наследования (четырьмя, если считать неявное наследование от класса Object) и тремя встроенными объектами. Результат виден при создании объекта Sandwich в методе main(). Это значит, что конструкторы для сложного объекта вызываются в следующей последовательности:
- Сначала вызывается конструктор базового класса. Этот шаг повторяется рекурсивно: сначала конструируется корень иерархии, затем следующий за ним класс, затем следующий за этим классом класс и т. д., пока не достигается «низший» производный класс.
- Проводится инициализация членов класса в порядке их объявления.
- Вызывается тело конструктора производного класса.
Порядок вызова конструкторов немаловажен. При наследовании вы располагаете полной информацией о базовом классе и можете получить доступ к любому из его открытых (public) или защищенных (protected) членов. Следовательно, при этом подразумевается, что все члены базового класса являются действительными в производном классе.
При вызове нормального метода известно, что конструирование уже было проведено, поэтому все части объекта инициализированы. Однако в конструкторе вы также должны быть уверены в том, что все используемые члены уже проинициализированы. Это можно гарантировать только одним способом — сначала вызывать конструктор базового класса.
В дальнейшем при выполнении конструктора производного класса можно быть уверенным в том, что все члены базового класса уже инициализированы. Гарантия действительности всех членов в конструкторе — важная причина, по которой все встроенные объекты (то есть объекты, помещенные в класс посредством композиции) инициализируются на месте их определения (как в рассмотренном примере сделано с объектами b, с и l). Если вы будете следовать этому правилу, это усилит уверенность в том, что все члены базового класса и объекты-члены были проинициализированы. К сожалению, это помогает не всегда, в чем вы убедитесь в следующем разделе.
Наследование и завершающие действия
Если при создании нового класса используется композиция и наследование, обычно вам не приходится беспокоиться о проведении завершающих действий — подобъекты уничтожаются сборщиком мусора. Но если вам необходимо провести завершающие действия, создайте в своем классе метод dispose() (в данном разделе я решил использовать такое имя; возможно, вы придумаете более удачное название). Переопределяя метод dispose() в производном классе, важно помнить о вызове версии этого метода из базового класса, поскольку иначе не будут выполнены завершающие действия базового класса. Следующий пример доказывает справедливость этого утверждения:
//: polymorphism/Frog.java
// Наследование и завершающие действия.
package polymorphism;
import static net.mindview.util.Print.*;
class Characteristic {
private String s;
Characteristic(String s) {
this.s = s;
print("Creating Characteristic " + s);
}
protected void dispose() {
print("disposing Characteristic " + s);
}
}
class Description {
private String s;
Description(String s) {
this.s = s;
print("Creating Description " + s);
}
protected void dispose() {
print("disposing Description " + s);
}
}
// живое существо
class LivingCreature {
private Characteristic p =
new Characteristic("is alive");
private Description t =
new Description("Basic Living Creature");
LivingCreature() {
print("LivingCreature()");
}
protected void dispose() {
print("LivingCreature dispose");
t.dispose();
p.dispose();
}
}
// животное
class Animal extends LivingCreature {
private Characteristic p =
new Characteristic("has heart");
private Description t =
new Description("Animal not Vegetable");
Animal() { print("Animal()"); }
protected void dispose() {
print("Animal dispose");
t.dispose();
p.dispose();
super.dispose();
}
}
// земноводное
class Amphibian extends Animal {
private Characteristic p =
new Characteristic("can live in water");
private Description t =
new Description("Both water and land");
Amphibian() {
print("Amphibian()");
}
protected void dispose() {
print("Amphibian dispose");
t.dispose();
p.dispose();
super.dispose();
}
}
// лягушка
public class Frog extends Amphibian {
private Characteristic p = new Characteristic("Croaks");
private Description t = new Description("Eats Bugs");
public Frog() { print("Frog()"); }
protected void dispose() {
print("Frog dispose");
t.dispose();
p.dispose();
super.dispose();
}
public static void main(String[] args) {
Frog frog = new Frog();
print("Bye!");
frog.dispose();
}
}
<spoiler text="Output:">
Creating Characteristic is alive
Creating Description Basic Living Creature
LivingCreature()
Creating Characteristic has heart
Creating Description Animal not Vegetable
Animal()
Creating Characteristic can live in water
Creating Description Both water and land
Amphibian()
Creating Characteristic Croaks
Creating Description Eats Bugs
Frog()
Bye!
Frog dispose
disposing Description Eats Bugs
disposing Characteristic Croaks
Amphibian dispose
disposing Description Both water and land
disposing Characteristic can live in water
Animal dispose
disposing Description Animal not Vegetable
disposing Characteristic has heart
LivingCreature dispose
disposing Description Basic Living Creature
disposing Characteristic is alive
</spoiler>
Каждый класс в иерархии содержит объекты классов Characteristic и Description, которые также необходимо «завершать». Очередность завершения должна быть обратной порядку инициализации в том случае, если объекты зависят друг от друга. Для полей это означает порядок, обратный последовательности объявления полей в классе (инициализация соответствует порядку объявления).
В базовых классах сначала следует выполнять финализацию для производного класса, а затем — для базового класса. Это объясняется тем, что завершающий метод производного класса может вызывать некоторые методы базового класса, для которых необходимы действительные компоненты базового класса. Из результатов работы программы видно, что все части объекта Frog будут финализованы в порядке, противоположном очередности их создания.
Также обратите внимание на то, что в описанном примере объект Frog является «владельцем» встроенных объектов. Он создает их, определяет продолжительность их существования (до тех пор, пока существует Frog) и знает, когда вызывать dispose() для встроенных объектов.
Но если встроенный объект используется совместно с другими объектами, ситуация усложняется и вы уже не можете просто вызвать dispose(). В таких случаях для отслеживания количества объектов, работающих со встроенным объектом, приходится использовать подсчет ссылок. Вот как это выглядит:
//: polymorphism/ReferenceCounting.java
// Уничтожение совместно используемых встроенных объектов
import static net.mindview.util.Print.*;
class Shared {
private int refcount = 0;
private static long counter = 0;
private final long id = counter++;
public Shared() {
print("Creating " + this);
}
public void addRef() { refcount++; }
protected void dispose() {
if(--refcount == 0)
print("Disposing " + this);
}
public String toString() { return "Shared " + id; }
}
class Composing {
private Shared shared;
private static long counter = 0;
private final long id = counter++;
public Composing(Shared shared) {
print("Creating " + this);
this.shared = shared;
this.shared.addRef();
}
protected void dispose() {
print("disposing " + this);
shared.dispose();
}
public String toString() { return "Composing " + id; }
}
public class ReferenceCounting {
public static void main(String[] args) {
Shared shared = new Shared();
Composing[] composing = { new Composing(shared),
new Composing(shared), new Composing(shared),
new Composing(shared), new Composing(shared) };
for(Composing c : composing)
c.dispose();
}
}
<spoiler text="Output:">
Creating Shared 0
Creating Composing 0
Creating Composing 1
Creating Composing 2
Creating Composing 3
Creating Composing 4
disposing Composing 0
disposing Composing 1
disposing Composing 2
disposing Composing 3
disposing Composing 4
Disposing Shared 0
</spoiler>
В переменной static long counter хранится количество созданных экземпляров Shared. Для счетчика выбран тип long вместо int для того, чтобы предотвратить переполнение (это всего лишь хороший стиль программирования; в рассматриваемых примерах переполнение вряд ли возможно). Поле id объявлено со спецификатором final, поскольку его значение остается постоянным на протяжении жизненного цикла объекта.
Присоединяя к классу общий объект, необходимо вызвать addRef(), но метод dispose() будет следить за состоянием счетчика ссылок и сам решит, когда нужно выполнить завершающие действия. Подсчет ссылок требует дополнительных усилий со стороны программиста, но при совместном использовании объектов, требующих завершения, у вас нет особого выбора.
Поведение полиморфных методов при вызове из конструкторов
В иерархиях конструкторов возникает интересный вопрос. Что происходит, если вызвать в конструкторе динамически связываемый метод конструируемого объекта?
В обычных методах представить происходящее нетрудно — динамически связываемый вызов обрабатывается во время выполнения, так как объект не знает, принадлежит ли этот вызов классу, в котором определен метод, или классу, производному от этого класса. Казалось бы, то же самое должно происходить и в конструкторах.
Но ничего подобного. При вызове динамически связываемого метода в конструкторе используется переопределенное описание этого метода. Однако последствия такого вызова могут быть весьма неожиданными, и здесь могут крыться некоторые коварные ошибки.
По определению, задача конструктора — дать объекту жизнь (и это отнюдь не простая задача). Внутри любого конструктора объект может быть сформирован лишь частично — известно только то, что объекты базового класса были проини- циализированы. Если конструктор является лишь очередным шагом на пути построения объекта класса, производного от класса данного конструктора, «производные» части еще не были инициализированы на момент вызова текущего конструктора.
Однако динамически связываемый вызов может перейти во «внешнюю» часть иерархии, то есть к производным классам. Если он вызовет метод производного класса в конструкторе, это может привести к манипуляциям с неинициализированными данными — а это наверняка приведет к катастрофе. Следующий пример поясняет суть проблемы:
//: polymorphism/PolyConstructors.java
// Конструкторы и полиморфизм дают не тот
// результат, который можно было бы ожидать
import static net.mindview.util.Print.*;
class Glyph {
void draw() { print("Glyph.draw()"); }
Glyph() {
print("Glyph() before draw()");
draw();
print("Glyph() after draw()");
}
}
class RoundGlyph extends Glyph {
private int radius = 1;
RoundGlyph(int r) {
radius = r;
print("RoundGlyph.RoundGlyph(), radius = " + radius);
}
void draw() {
print("RoundGlyph.draw(), radius = " + radius);
}
}
public class PolyConstructors {
public static void main(String[] args) {
new RoundGlyph(5);
}
}
<spoiler text="Output:">
Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5
</spoiler>
Метод Glyph.draw() изначально предназначен для переопределения в производных классах, что и происходит в RoundGlyph. Но конструктор Glyph вызывает этот метод, и в результате это приводит к вызову метода RoundGlyph.draw(), что вроде бы и предполагалось.
Однако из результатов работы программы видно — когда конструктор класса Glyph вызывает метод draw(), переменной radius еще не присвоено даже значение по умолчанию 1. Переменная равна 0. В итоге класс может не выполнить свою задачу, а вам придется долго всматриваться в код программы, чтобы определить причину неверного результата.
Порядок инициализации, описанный в предыдущем разделе, немного неполон, и именно здесь кроется ключ к этой загадке. На самом деле процесс инициализации проходит следующим образом:
- Память, выделенная под новый объект, заполняется двоичными нулями.
- Конструкторы базовых классов вызываются в описанном ранее порядке. В этот момент вызывается переопределенный метод draw() (да, перед вызовом конструктора класса RoundGlyph), где обнаруживается, что переменная radius равна нулю из-за первого этапа.
- Вызываются инициализаторы членов класса в порядке их определения.
- Исполняется тело конструктора производного класса.
У происходящего есть и положительная сторона — по крайней мере, данные инициализируются нулями (или тем, что понимается под нулевым значением для определенного типа данных), а не случайным «мусором» в памяти. Это относится и к ссылкам на объекты, внедренные в класс с помощью композиции. Они принимают особое значение null.
Если вы забудете инициализировать такую ссылку, то получите исключение во время выполнения программы. Остальные данные заполняются нулями, а это обычно легко заметить по выходным данным программы.
С другой стороны, результат программы выглядит довольно жутко. Вроде бы все логично, а программ ведет себя загадочно и некорректно без малейших объяснений со стороны компилятора. (В языке C++ такие ситуации обрабатываются более рациональным способом.) Поиск подобных ошибок занимает много времени.
При написании конструктора руководствуйтесь следующим правилом: не пытайтесь сделать больше для того, чтобы привести объект в нужное состояние, и по возможности избегайте вызова каких-либо методов. Единственные методы, которые можно вызывать в конструкторе без опаски — неизменные (final) методы базового класса. (Сказанное относится и к закрытым (private) методам, поскольку они автоматически являются неизменными.) Такие методы невозможно переопределить, и поэтому они застрахованы от «сюрпризов».
Ковариантность возвращаемых типов
В Java SE5 появилась концепция ковариантности возвращаемых типов; этот термин означает, что переопределенный метод производного класса может вернуть тип, производный от типа, возвращаемого методом базового класса:
//: polymorphism/CovariantReturn.java
class Grain {
public String toString() { return "Grain"; }
}
class Wheat extends Grain {
public String toString() { return "Wheat"; }
}
class Mill {
Grain process() { return new Grain(); }
}
class WheatMill extends Mill {
Wheat process() { return new Wheat(); }
}
public class CovariantReturn {
public static void main(String[] args) {
Mill m = new Mill();
Grain g = m.process();
System.out.println(g);
m = new WheatMill();
g = m.process();
System.out.println(g);
}
}
<spoiler text="Output:">
Grain
Wheat
</spoiler>
Главное отличие Java SE5 от предыдущих версий Java заключается в том, что старые версии заставляли переопределение process() возвращать Grain вместо Wheat, хотя тип Wheat, производный от Grain, является допустимым возвращаемым типом. Ковариантность возвращаемых типов позволяет вернуть более специализированный тип Wheat.
Разработка с наследованием
После знакомства с полиморфизмом может показаться, что его следует применять везде и всегда. Однако злоупотребление полиморфизмом ухудшит архитектуру ваших приложений.
Лучше для начала использовать композицию, пока вы точно не уверены в том, какой именно механизм следует выбрать. Композиция не стесняет разработку рамками иерархии наследования. К тому же механизм композиции более гибок, так как он позволяет динамически выбирать тип (а следовательно, и поведение), тогда как наследование требует, чтобы точный тип был известен уже во время компиляции. Следующий пример демонстрирует это:
//: polymorphism/Transmogrify.java
// Динамическое изменение поведения объекта
// с помощью композиции (шаблон проектирования «Состояние»)
import static net.mindview.util.Print.*;
class Actor {
public void act() {}
}
class HappyActor extends Actor {
public void act() { print("HappyActor"); }
}
class SadActor extends Actor {
public void act() { print("SadActor"); }
}
class Stage {
private Actor actor = new HappyActor();
public void change() { actor = new SadActor(); }
public void performPlay() { actor.act(); }
}
public class Transmogrify {
public static void main(String[] args) {
Stage stage = new Stage();
stage.performPlay();
stage.change();
stage.performPlay();
}
}
<spoiler text="Output:">
HappyActor
SadActor
</spoiler>
Объект Stage содержит ссылку на объект Actor, которая инициализируется объектом HappyActor. Это значит, что метод performPlay() имеет определенное поведение. Но так как ссылку на объект можно заново присоединить к другому объекту во время выполнения программы, ссылке actor назначается объект SadActor, и после этого поведение метода performPlay() изменяется.
Таким образом значительно улучшается динамика поведения на стадии выполнения программы. С другой стороны, переключиться на другой способ наследования во время работы программы невозможно; иерархия наследования раз и навсегда определяется в процессе компиляции программы.
Нисходящее преобразование и динамическое определение типов
Так как при проведении восходящего преобразования (передвижение вверх по иерархии наследования) теряется информация, характерная для определенного типа, возникает естественное желание восстановить ее с помощью нисходящего преобразования. Впрочем, мы знаем, что восходящее преобразование абсолютно безопасно; базовый класс не может иметь «больший» интерфейс, чем производный класс, и поэтому любое сообщение, посланное базовому классу, гарантированно дойдет до получателя.
Но при использовании нисходящего преобразования вы не знаете достоверно, что фигура (например) в действительности является окружностью. С такой же вероятностью она может оказаться треугольником, прямоугольником или другим типом. Должен существовать какой-то механизм, гарантирующий правильность нисходящего преобразования; в противном случае вы можете случайно использовать неверный тип, послав ему сообщение, которое он не в состоянии принять. Это было бы небезопасно.
В некоторых языках (подобных C++) для проведения безопасного нисходящего преобразования типов необходимо провести специальную операцию, но в Java каждое преобразование контролируется! Поэтому, хотя внешне все выглядит как обычное приведение типов в круглых скобках, во время выполнения программы это преобразование проходит проверку на фактическое соответствие типу. Если типы не совпадают, происходит исключение ClassCastException. Процесс проверки типов во время выполнения программы называется динамическим определением типов (run-time type identification, RTTI). Следующий пример демонстрирует действие RTTI:
//: polymorphism/RTTI.java
// Нисходящее преобразование и динамическое определение типов (RTTI).
// {ThrowsException}
class Useful {
public void f() {}
public void g() {}
}
class MoreUseful extends Useful {
public void f() {}
public void g() {}
public void u() {}
public void v() {}
public void w() {}
}
public class RTTI {
public static void main(String[] args) {
Useful[] x = {
new Useful(),
new MoreUseful()
};
x[0].f();
x[1].g();
// Стадия компиляции- метод не найден в классе Useful:
//! x[1].u();
((MoreUseful)x[1]).u(); // Нисх. преобразование /RTTI
((MoreUseful)x[0]).u(); //Происходит исключение
}
}
Класс MoreUseful расширяет интерфейс класса Useful. Но благодаря наследованию он также может быть преобразован к типу Useful. Вы видите, как это происходит, при инициализации массива х в методе main(). Так как оба объекта в массиве являются производными от Useful, вы можете послать сообщения (вызвать методы) f() и g() для обоих объектов, но при попытке вызова метода u() (который существует только в классе MoreUseful) вы получите сообщение об ошибке компиляции.
Чтобы получить доступ к расширенному интерфейсу объекта MoreUseful, используйте нисходящее преобразование. Если тип указан правильно, все пройдет успешно; иначе произойдет исключение ClassCastException. Вам не понадобится писать дополнительный код для этого исключения, поскольку оно указывает на общую ошибку, которая может произойти в любом месте программы.
Впрочем, RTTI не сводится к простой проверке преобразований. Например, можно узнать, с каким типом вы имеете дело, прежде чем проводить нисходящее преобразование. Глава 11 полностью посвящена изучению различных аспектов динамического определения типов Java.
Резюме
Полиморфизм означает «многообразие форм». В объектно-ориентированном программировании базовый класс предоставляет общий интерфейс, а различные версии динамически связываемых методов — разные формы использования интерфейса.
Как было показано в этой главе, невозможно понять или создать примеры с использованием полиморфизма, не прибегнув к абстракции данных и наследованию. Полиморфизм — это возможность языка, которая не может рассматриваться изолированно; она работает только согласованно, как часть «общей картины» взаимоотношений классов.
Чтобы эффективно использовать полиморфизм — а значит, все объектно- ориентированные приемы — в своих программах, необходимо расширить свои представления о программировании, чтобы они охватывали не только члены и сообщения отдельного класса, но и общие аспекты классов, их взаимоотношения. Хотя это потребует значительных усилий, результат стоит того. Наградой станет ускорение разработки программ, улучшение структуры кода, расширяемые программы и сокращение усилий по сопровождению кода.
------------------------
ТРИО теплый пол отзыв
Заработок на сокращении ссылок
Earnings on reducing links
Код PHP на HTML сайты
Категория: Книги по Java
Комментарии |