Thinking in Java, 2nd edition, Revision 11

©2000 by Bruce Eckel

[ Предыдущая глава ] [ Короткое оглавление ] [ Оглавление ] [ Список ] [ Следующая глава ]

7: Полиморфизм

Полиморфизм - третья неотъемлемая часть объектно-ориентированного программирования, после абстракции и наследования соответственно.

Полиморфизм предоставляет другое измерение разделения интерфейса и реализации, т.е. отделяет что от как. Полиморфизм позволяет повысить возможности по организации кода и читабельность исходных текстов, так же, как создание расширяемых программ, которые могут расти не только во время их создания, но и после, когда к ним нужно добавить новую требуемую возможность.

Инкапсуляция создает новые инкапсулированные типы данных, комбинируя их характеристики и типы поведения. Имплементация скрывает интерфейс от дальнейшей имплементации делая некоторые элементы private. Этот вид механической организации может быть нов для кого-то, кто имеет большие познания в процедурном программировании. Но полиморфизм работает с разделением типов. В предыдущей главе Вы увидели, что наследование обращается с объектом с его собственным типом или с базовым типом. Эта особенность критична, поскольку при этом могут поддерживаться многие типы (дочерних от одного и того же базового типа), которые обрабатываются, как если бы они были одного типа и один и тот же код работает одинаково с этими различными типами. Вызов полиморфного метода поддерживает один тип для выражения его отличия от другого, такого же типа, и это из-за того, что они произошли от одного базового типа. Данное различие выражено через различие в поведении методов, которые Вы можете вызвать через базовый класс.

В этой главе, Вы узнаете о полиморфизме (так же называемом динамически связыванием или поздним связыванием или связыванием во время выполнения), начиная от основ с простыми примерами с последующим раскрытием всего поведения полиморфизма в программах.

Повторение приведения к базовому типу

В главе 6, Вы могли видеть, как можно использовать объект как своего собственного типа или в качестве базового типа. Получение ссылки на объект и привидение ее к типу базового класса называется "приведение к базовому типу", поскольку путь деревьев наследования растет сверху от базового класса.

Вы так же видели возникшую проблему истекающую из следующего:

//: c07:music:Music.java 
// Наследование и приведение  к базовому типу.

class Note {
  private int value;
  private Note(int val) { value = val; }
  public static final Note
    MIDDLE_C = new Note(0), 
    C_SHARP  = new Note(1),
    B_FLAT   = new Note(2);
} // И т.д.

class Instrument {
  public void play(Note n) {
    System.out.println("Instrument.play()");
  }
}

// Объект Wind так же и  instruments
// поскольку у них общий интерфейс:
class Wind extends Instrument {
  // Переопределение метода:
  public void play(Note n) {
    System.out.println("Wind.play()");
  }
}

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); // Приведение к базовому типу
  }
} ///:~

Метод Music.tune( ) принимает ссылки на Instrument, а так же на все, что произошло от Instrument. В main( ), Вы можете увидеть как это происходит, ссылка на Wind передается tune( ), без нужного преобразования типов. Интерфейс Instrument при этом должен существовать в Wind, поскольку Wind произошел от Instrument. Преобразование типа из Wind к Instrument может уменьшить интерфейс, но при этом он не будет меньше, чем весь интерфейс Instrument.

Забывание типа объекта

Это выражение может показаться странным для Вас. Почему кто-то должен намеренно забыть тип объекта? А это происходит, когда, Вы производите приведение к базовому типу, и выглядит это более прямо если бы tune( ) просто брала ссылку на Wind в качестве аргумента. Тем самым приносится еще одна неотъемлемая часть полиморфизма: Если бы Вы сделали так, как написано выше, то Вам было бы необходимо писать новый метод tune( ) для каждого типа Instrument в вашей системе. Допустим, мы последовали этой технике и добавили инструменты Stringed и Brass:

//: c07:music2:Music2.java 
// Перегрузка, вместо приведедния к базовому типу.

class Note {
  private int value;
  private Note(int val) { value = val; }
  public static final Note
    MIDDLE_C = new Note(0), 
    C_SHARP = new Note(1),
    B_FLAT = new Note(2);
} // И т.д.

class Instrument {
  public void play(Note n) {
    System.out.println("Instrument.play()");
  }
}

class Wind extends Instrument {
  public void play(Note n) {
    System.out.println("Wind.play()");
  }
}

class Stringed extends Instrument {
  public void play(Note n) {
    System.out.println("Stringed.play()");
  }
}

class Brass extends Instrument {
  public void play(Note n) {
    System.out.println("Brass.play()");
  }
}

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);
  }
} ///:~

Ура, работает, но при этом возникает большая работа по переписки кода: Вы должны писать типо-зависимые методы, для каждого нового класса Instrument, которые Вы добавите. А это означает, что во-первых нужно больше программировать, во-вторых, если Вы захотите добавить новый метод по типу tune( ) или просто новый тип инструмента, то придется проделать много работы. К этому следует добавить, что компилятор не сообщит о том, что Вы забыли перегрузить некоторые методы или о том, что некоторые методы работают с неуправляемыми типами.

А не было бы намного лучше, если бы Вы написали один метод, который получает в качестве аргумента базовый класс, а не каждый по отдельности дочерний класс? Было бы, но не было бы хорошо, если бы Вы смогли забыть, что есть какие-то дочерние классы и написали бы ваш код только для базового класса?

Именно это полиморфизм и позволяет делать. Но все равно, многие программисты пришедшие из процедурного программирования имеют небольшие проблемы при работе с полиморфизмом.

Скручивание

Сложности с Music.java можно видеть при запуске этой программы. Вывод в Wind.play( ). Причем это почти желаемый вывод, но здесь не должно играть роли, как это будет проигрываться. Посмотрите на метод tune( ):

  public static void tune(Instrument i) {
    // ...
    i.play(Note.MIDDLE_C);
  }

Метод воспринимает ссылку на Instrument. А как компилятору узнать, что в действительности эта ссылка на Instrument указывает на Wind в этом случае и не указывает на Brass или Stringed? Компилятор не может. Для того, что бы поглубже разобраться в этом затруднении неплохо было бы разобраться и в самой сущности связывания.

Связывание метод-вызов

Соединение вызова метода с телом метода называется связывание Когда свзяывание осуществляется до запуска программы (компилятором и компоновщиком, если такой используется), то оно (связывание) называется ранним связыванием. Вы могли даже и не слышать о таком термине, поскольку такая технология не применялась в процедурных языках. C компиляторы имеют только одну разновидность вызова, и она как раз является ранним связыванием.

В замешательство предыдущей программы находится вокруг раннего связывания, поскольку компилятор не знает правильный метод для вызова, если есть только ссылка на Instrument.

Решение называется позднее связывание, что означает, что связывание происходит во время работы программы и основывается на типе объекта. Позднее связывание так же иногда называют динамическим связыванием или связыванием во время выполнения. Когда язык поддерживает позднее связывание, то у него должен быть механизм определения типа объекта в время работы программы и вызова соответствующего метода. Все так и есть, компилятор все еще не знает какого типа этот объект, но механизм вызова методов находит его и вызывает соответствующее тело метода. Механизм позднего связывания меняется от языка к языку, но Вы можете представить себе, что в объект должна быть встроена некоторая информация о типе объекта.

В Java все методы за исключением final используют позднее связывание. И это означает, что Вам нет необходимости принимать решения, о необходимости применения позднего связывания в том или ином месте программы, поскольку это происходит автоматически.

Зачем нужно определять метод как final? Об этом написано в предыдущей главе, при помощи final осуществляется защита метода от переопределения. Возможно более важно или эффективно выключить динамическое связывание или сказать компилятору, что динамическое связывание не нужно. При этом компилятор может компилировать более эффективный код для элементов final. Однако в большинстве случаев не будет разницы в производительности вашей программы, так что лучше использовать final только как решение, принятое в угоду дизайну программы, а не для того, что бы повысить производительность.

Выработка правильного поведения

Как Вы уже знаете, все методы в Java имеют особенности полиморфизма, поскольку используется позднее связывание, Вы можете писать свой код для доступа к базовому классу и знаете, что с этим же кодом будут правильно работать и все классы наследники. Или, если идти другим путем, Вы посылаете сообщение объекту и последует правильная на его реакция.

Классический пример ООП - шейпы. Он используется наиболее часто, поскольку его легко нарисовать, но он так же и смущает начинающих программистов, которые думают, что ООП это только рисование таких вот схем.

В примере шейпе имеется класс Shape и множество дочерних типов: Circle, Square, Triangle и т.д. Причина этого примера проста, так же, как просто сказать "круг это всего лишь разновидность шейпа (геометрической фигуры)" и такое заявление легко понять. Диаграмма наследования показывает связи объектов:


Приведение к базовому типу происходит в выражении:

Shape s = new Circle();

Здесь, объект Circle создается и результирующая ссылка немедленно присваивается к Shape, здесь мы бы наверное получили бы ошибку (присвоение одного типа другому); но нет, все чудно прошло, поскольку Circle есть Shape через наследование. Так что компилятор согласился с выражением и не выдал никакой ошибки.

Предположим, что Вы вызываете метод базового класса (который был переопределен в дочернем классе):

s.draw();

И снова, Вы можете ожидать, что вызовется метод из Shape draw( ), поскольку это он и есть и как компилятору узнать, что это не он? А в самом деле вызовется Circle.draw( ), поскольку используется позднее связывание(полиморфизм).

Следующий пример поместит его несколько другим путем:

//: c07:Shapes.java
// Полиморфизм в Java.

class Shape { 
  void draw() {}
  void erase() {} 
}

class Circle extends Shape {
  void draw() { 
    System.out.println("Circle.draw()"); 
  }
  void erase() { 
    System.out.println("Circle.erase()"); 
  }
}

class Square extends Shape {
  void draw() { 
    System.out.println("Square.draw()"); 
  }
  void erase() { 
    System.out.println("Square.erase()"); 
  }
}

class Triangle extends Shape {
  void draw() { 
    System.out.println("Triangle.draw()"); 
  }
  void erase() { 
    System.out.println("Triangle.erase()");
  }
}

public class Shapes {
  public static Shape randShape() {
    switch((int)(Math.random() * 3)) {
      default:
      case 0: return new Circle();
      case 1: return new Square();
      case 2: return new Triangle();
    }
  }
  public static void main(String[] args) {
    Shape[] s = new Shape[9];
    // Заполним массив шейпами:
    for(int i = 0; i < s.length; i++)
      s[i] = randShape();
    // Сделаем вызов полиморфного метода:
    for(int i = 0; i < s.length; i++)
      s[i].draw();
  }
} ///:~

Базовый класс Shape предоставляет общий интерфейс для всех наследников от Shape, это означает, что все шейпы могут быть нарисованы и стерты. Дочерние классы перекрывают эти определения для обеспечения уникального поведения в зависимости от типа шейпа.

Главный класс Shapes содержит static метод - randShape( ), который возвращает ссылку на случайно выбранный объект Shape каждый раз, когда Вы вызываете его. Заметьте, что приведение к базовому типу происходит каждый раз при return-е, который ссылается на Circle, Square или Triangle и посылает их из метода, как возвращаемый параметр. Так что, когда Вы вызываете этот метод Вы не можете узнать, какого типа возвращается параметр, поскольку всегда возвращается базовый тип Shape.

main( ) содержит массив из ссылок Shape заполненный вызовами randShape( ). На этом этапе Вы знаете, что Вы имеете некоторое множество ссылок на объекты типа Shape, но Вы не знаете ничего о них больше (и не больше, чем знает компилятор). В любом случае, когда Вы перемещаетесь по этому массиву и вызываете draw( ) для каждого элемента, то автоматически проставляется правильный тип, как Вы можете посмотреть это на примере:

Circle.draw()
Triangle.draw()
Circle.draw()
Circle.draw()
Circle.draw()
Square.draw()
Triangle.draw()
Square.draw()
Square.draw()

Естественно, поскольку шейпы вызываются случайным образом, то и результаты вывода могут быть различны. Причина вызова шейпов случайным образом обусловлена тем, что бы была возможность показать, что компилятор не имеет специальных знаний, для создания правильных вариантов при компиляции. Все вызовы draw( ) сделаны посредством динамической связи.

Расширяемость

Теперь давайте вернемся к нашему примеру с музыкальными инструментами. В полиморфизме, Вы можете добавить столько новых типов, сколько захотите, без изменения метода tune( ). В хорошо спроектированной ООП программе, большинство или все ваши методы будут следовать модели tune( ) и будут соединятся только с интерфейсом базового класса. Такая программа расширяема, поскольку Вы можете добавлять новые возможности через наследование новых типов данных от общего базового класса. Методы манипулирующие интерфейсом базового класса не нуждаются в изменении в новых классах.

Рассмотрим, что произойдет, если Вы возьмете пример с инструментами и добавите больше методов в базовый класс и несколько новых классов. Вот диаграмма:


Все эти новые классы работают нормально со старым, неизмененным методом tune( ). Даже если tune( ) в другом файле и новые методы добавлены в интерфейс Instrument, tune( ) работает без ошибок даже без перекомпиляции. Ниже приведена реализация вышерасположенной диаграммы:

//: c07:music3:Music3.java
// Расширяемая программа.
import java.util.*;

class Instrument {
  public void play() {
    System.out.println("Instrument.play()");
  }
  public String what() {
    return "Instrument";
  }
  public void adjust() {}
}

class Wind extends Instrument {
  public void play() {
    System.out.println("Wind.play()");
  }
  public String what() { return "Wind"; }
  public void adjust() {}
}

class Percussion extends Instrument {
  public void play() {
    System.out.println("Percussion.play()");
  }
  public String what() { return "Percussion"; }
  public void adjust() {}
}

class Stringed extends Instrument {
  public void play() {
    System.out.println("Stringed.play()");
  }
  public String what() { return "Stringed"; }
  public void adjust() {}
}

class Brass extends Wind {
  public void play() {
    System.out.println("Brass.play()");
  }
  public void adjust() {
    System.out.println("Brass.adjust()");
  }
}

class Woodwind extends Wind {
  public void play() {
    System.out.println("Woodwind.play()");
  }
  public String what() { return "Woodwind"; }
}

public class Music3 {
  // Не беспокойтесь о новых типах,
  // поскольку добавленные продолжают работать правильно:
  static void tune(Instrument i) {
    // ...
    i.play();
  }
  static void tuneAll(Instrument[] e) {
    for(int i = 0; i < e.length; i++)
      tune(e[i]);
  }
  public static void main(String[] args) {
    Instrument[] orchestra = new Instrument[5];
    int i = 0;
    // Приведение к базовому типу во время добавления в массив:
    orchestra[i++] = new Wind();
    orchestra[i++] = new Percussion();
    orchestra[i++] = new Stringed();
    orchestra[i++] = new Brass();
    orchestra[i++] = new Woodwind();
    tuneAll(orchestra);
  }
} ///:~

Новые методы what( ), который возвращает String ссылку с описанием класса, и adjust( ), который предоставляет некоторый путь для настройки каждого инструмента.

В main( ), когда Вы помещаете что-то внутрь массива Instrument Вы автоматически производите операцию приведения к базовому типу к Instrument.

Вы можете видеть, что метод tune( ) удачно игнорирует все изменения кода, которые случились вокруг него и он все еще работает при этом корректно. Такое поведение так же допускается полиморфизмом. Изменения вашего кола не окажут разрушающего влияния на другие части программы. Другими словами полиморфизм предоставляет программисту возможность отделить те вещи, которые нужно изменить от тех вещей, который должны оставаться неизменными.

Переопределение против перегрузки

Давайте теперь взглянем на первый пример этой главы, но под другим углом зрения. В следующей программе интерфейс метода play( ) изменен в процессе переопределения, что означает, что Вы не переопределили этот метод, а вместо этого перегрузили его. Компилятор позволяет вам перегрузить метод, поэтому и не было никаких жалоб с его стороны. Но поведение метода вероятно отличается от того, что бы Вы хотели. Пример:

//: c07:WindError.java 
// Случайное изменение интерфейса.

class NoteX {
  public static final int
    MIDDLE_C = 0, C_SHARP = 1, C_FLAT = 2;
}

class InstrumentX {
  public void play(int NoteX) {
    System.out.println("InstrumentX.play()");
  }
}

class WindX extends InstrumentX {
  // Упс! Изменился интерфейс метода:
  public void play(NoteX n) {
    System.out.println("WindX.play(NoteX n)");
  }
}

public class WindError {
  public static void tune(InstrumentX i) {
    // ...
    i.play(NoteX.MIDDLE_C);
  }
  public static void main(String[] args) {
    WindX flute = new WindX();
    tune(flute); // Не желаемое поведедение!
  }
} ///:~

Здесь есть еще одна запутывающая сторона применения полиморфизма. В InstrumentX метод play( ) принимает int, который имеет идентификатор NoteX. Так что, даже если NoteX это имя класса, то оно так же может быть использовано и в качестве переменной, без возражений со стороны компилятора. Но в WindX, play( ) берет ссылку NoteX, которая имеет идентификатор n. (Хотя Вы никогда не сможете осуществить play(NoteX NoteX) без сообщения об ошибке.) Поэтому кажется, что программист собирался переопределить play( ), но немного опечатался. Компилятор же в свою очередь понял, что это перегрузка (overload), а не переопределение (override). Заметьте, что если Вы следуете соглашению об именах в Java, то тогда идентификатор был бы noteX (в нижнем регистре "n"), что отделило бы его от имени класса.

В tune, InstrumentX i посылает сообщение методу play( ), с одним из членов NoteX (MIDDLE_C) в качестве аргумента. Поскольку NoteX содержит определение int, то это означает, что будет вызвана int версия перегруженного метода play( ) и в силу того, что он не был переопределен, то будет использована версия базового класса.

Вывод программы:

InstrumentX.play()

Несомненно, это не повлияет на вызов полиморфного метода. Как только Вы поймете что случилось, Вы сможете с легкостью исправить возникшую проблему, но понять причину ошибки тем сложнее, чем больше размер программы.

Абстрактные методы и классы

Во всех примерах с инструментами, методы базового класса Instrument всегда поддельны, фиктивны. В этих методах всегда при вызове происходило что-то неправильное. Это происходит из-за того, что цель Instrument - создание общего интерфейса для всех дочерних классов.

Единственная причина для создания этого общего интерфейса, то, что он должен быть реализован по разному для каждого отдельного подтипа. Он создает основную форму, так что Вы можете сказать, что общего во всех дочерних классах. Другой путь сказать то же самое, это вызов Instrument абстрактного базового класса (или просто абстрактного класса). Вы создаете абстрактный класс, когда Вы хотите управлять набором классов, через этот общий интерфейс. Все методы дочерних классов, совпадающие с объявлением сигнатуры базового класса, используют динамическое связывание. (Однако как было написано в предыдущей секции, если имя метода совпадает с именем метода базового класса, а аргументы различны, то это означает перегрузку, что обычно не то, что требуется.)

Если у Вас есть абстрактный класс типа Instrument, объекты этого класса всегда ничего не значат. Это означает, что Instrument является только интерфейсом, а не частным случаем реализации, так что создание объектов Instrument бессмысленно, и Вы вероятно хотели бы оградить пользователей от этой возможности. Это можно осуществить путем внедрения во все методы Instrument вывода сообщения об ошибке, но при этом осуществляется задержка вывода информации при работе программы и требует изнуряющего тестирования пользовательской части. Но всегда все таки лучше ловить проблемы на стадии компиляции.

Java предоставляет механизм для этого, называемый вызов абстрактного метода[37]. Такой метод является не законченным; он имеет только объявление и не имеет тела метода. Ниже приведен синтаксис объявления абстрактного метода:

abstract void f();

Класс, содержащий абстрактные методы, называется абстрактным классом. Если класс содержит один или больше абстрактных методов, этот класс должен быть определен как abstract. (В противном случае компилятор выдаст сообщение об ошибке.)

Если объявлен абстрактный класс, то что компилятор сделает, если кто-то попытается создать объект от этого класса? Поскольку компилятор не может безопасно создать объект абстрактного класса, то Вы получите сообщение об ошибке. Таким образом компилятор заботится о чистоте абстрактного класса и вам нет необходимости беспокоиться об этом.

Если Вы наследуете от абстрактного класса и Вы хотите создать объект нового типа, то Вы должны предоставить определения всех абстрактных методов базового класса. Если же Вы этого не сделаете (а Вы можете решить не делать этого), то дочерний класса будет так же абстрактным и компилятор насильно установит модификатор abstract для этого класса.

Вообще возможно создать abstract класс без включения в него каких либо abstract методов. Такой способ удобно употреблять когда у вас есть класс в котором все равно есть ли в нем какие либо abstract методы, и тем самым Вы предотвратите наследование от этого класса.

Класс Instrument может быть с легкостью превращен в abstract класс. Только некоторые из методов будут abstract, поскольку создание абстрактного метода не требует от вас определение всех методов abstract. Здесь показано, на что это похоже:


Ниже пример с оркестром, модифицированный для использования abstract классов и методов:

//: c07:music4:Music4.java
// Абстрактные методы и классы.
import java.util.*;

abstract class Instrument {
  int i; // хранилище зарезервировано  для всех
  public abstract void play();
  public String what() {
    return "Instrument";
  }
  public abstract void adjust();
}

class Wind extends Instrument {
  public void play() {
    System.out.println("Wind.play()");
  }
  public String what() { return "Wind"; }
  public void adjust() {}
}

class Percussion extends Instrument {
  public void play() {
    System.out.println("Percussion.play()");
  }
  public String what() { return "Percussion"; }
  public void adjust() {}
}

class Stringed extends Instrument {
  public void play() {
    System.out.println("Stringed.play()");
  }
  public String what() { return "Stringed"; }
  public void adjust() {}
}

class Brass extends Wind {
  public void play() {
    System.out.println("Brass.play()");
  }
  public void adjust() { 
    System.out.println("Brass.adjust()");
  }
}

class Woodwind extends Wind {
  public void play() {
    System.out.println("Woodwind.play()");
  }
  public String what() { return "Woodwind"; }
}

public class Music4 {
  // Не беспокойтесь от типах, поскольку новые типы добавляемые
  // в систему, не мешают ей работать правильно:
  static void tune(Instrument i) {
    // ...
    i.play();
  }
  static void tuneAll(Instrument[] e) {
    for(int i = 0; i < e.length; i++)
      tune(e[i]);
  }
  public static void main(String[] args) {
    Instrument[] orchestra = new Instrument[5];
    int i = 0;
    // Приведение к базовому типу во время добавления в массив:
    orchestra[i++] = new Wind();
    orchestra[i++] = new Percussion();
    orchestra[i++] = new Stringed();
    orchestra[i++] = new Brass();
    orchestra[i++] = new Woodwind();
    tuneAll(orchestra);
  }
} ///:~

Вы можете видеть, что здесь не произошло реального изменения базового класса.

Так поступать очень удобно, создавая abstract классы и методы, потому что создается понятное и для пользователя и для компилятора намеренье об его использования.

Конструкторы и полиморфизм

Обычно конструкторы отличаются для разных методов. Это так же верно когда используется полиморфизм. Поскольку конструкторы не полиморфичны (но Вы можете получить разновидность виртуального конструктора, об этом Вы узнаете в главе 12), то важно понять способы работы конструкторов составной иерархии и с полиморфизмом. Понятие этого позволит вам устранить неприятную запутанность, связанную с конструкторами.

Порядок вызова конструкторов

Порядок вызова конструкторов был кратко рассмотрен в главе 4 и снова в главе 6, но это было до того, как мы узнали о полиморфизме.

Конструктор для базового класса всегда вызывается в конструкторе дочернего класса, и так по всей цепочке наследования, пока не будут вызваны конструкторы всех базовых классов. Такой порядок имеет значение, поскольку конструктор выполняет специальную работу: что бы убедится, что объект был создан правильно. Дочерний класс имеет доступ только к его собственным членам и ни к одному из базового класса (чьи элементы обычно private). Только конструктор базового класса имеет необходимую информацию и доступ к элементам базового класса. Следовательно, естественно, что вызываются все конструкторы, с другой стороны объект целиком не создается. Вот поэтому компилятор и вызывает конструкторы в конструкторах дочерних классов. Он просто тихо вызывает конструктор по умолчанию, если Вы этого сами явно не сделали в теле конструктора. Если же у базового класса нет конструктора по умолчанию, то компилятор по этому поводу возразит. (В случае, если класс не имеет конструкторов компилятор автоматически создает конструктор по умолчанию.)

Давайте посмотрим на пример, который показывает эффект композиции, наследование и полиморфизма на стадии создания:

//: c07:Sandwich.java
// Порядок вызова конструкторов.

class Meal {
  Meal() { System.out.println("Meal()"); }
}

class Bread {
  Bread() { System.out.println("Bread()"); }
}

class Cheese {
  Cheese() { System.out.println("Cheese()"); }
}

class Lettuce {
  Lettuce() { System.out.println("Lettuce()"); }
}

class Lunch extends Meal {
  Lunch() { System.out.println("Lunch()");}
}

class PortableLunch extends Lunch {
  PortableLunch() {
    System.out.println("PortableLunch()");
  }
}

class Sandwich extends PortableLunch {
  Bread b = new Bread();
  Cheese c = new Cheese();
  Lettuce l = new Lettuce();
  Sandwich() { 
    System.out.println("Sandwich()");
  }
  public static void main(String[] args) {
    new Sandwich();
  }
} ///:~

Этот пример создает составной класс из других классов и каждый из классов имеет конструктор, который извещает о себе. Важный класс Sandwich отражает три уровня наследования (четыре, если считать наследование от Object) и три объекта элемента. Когда объект Sandwich уже создан, вывод программы таков:

Meal()
Lunch()
PortableLunch()
Bread()
Cheese()
Lettuce()
Sandwich()

Это означает, что существует следующий вызов конструкторов для сложного объекта:

  1. Вызван конструктор базового объекта. Этот шаг был повторен пока вызов не добрался до корня иерархии, следуя вниз, до того, как будут обработаны все дочерние классы.
  2. Участники инициализации вызваны по порядку их декларации.
  3. Вызвано тело дочернего класса.

Порядок вызова конструкторов чрезвычайно важен. Когда Вы наследуете, Вы знаете все о базовом классе и можете получить доступ к любому public и protected его участнику. Это означает, что вам необходимо быть уверенным в том, что все члены класса приемлемы и допустимы на момент наследования. В нормальном методе, создание объекта уже завершено, поэтому все члены этого класса соответственно созданы. Внутри конструктора, однако, Вы должны быть уверены в том, что все участники класса созданы нормально. Существует только один путь, гарантирующий это - вызов конструктора базового класса в самую первую очередь. Затем, когда управление уже передается в конструктор дочернего класса, все участники базового класса будут проинициализированы и созданы должным образом. Знание того, что все члены класса приемлемы уже в конструкторе хорошая причина для того, что бы где только возможно инициализировать объекты на стадии их определения. Если Вы будете следовать этой практике, то Вы будете уверены, что все члены классов и члены объектов были правильно проинициализированы. Но, к сожалению, часто это не играет никакой роли, но об этом читайте в следующей секции.

Наследование и finalize( )

Когда Вы используете композицию для создания нового класса, Вы никогда не беспокоитесь о завершении объекта этого класса. Каждый участник - независимый объект и поэтому сборщик мусора не обращают внимание на то, что это член вашего класса. С наследованием Вы должны переопределять finalize( ) в дочерних классах, если у вас есть какие либо специальные очистки, которые должны произойти как часть сбора мусора. Когда Вы переопределяете finalize( ) в наследуемом классе, то важно не забыть вызвать finalize( ) базового класса, поскольку в противном случае финализация базового класса не произойдет. Следующий пример доказывает это утверждение:

//: c07:Frog.java
// Проверка завершения с наследованием.

class DoBaseFinalization {
  public static boolean flag = false;
}

class Characteristic {
  String s;
  Characteristic(String c) {
    s = c;
    System.out.println(
      "Creating Characteristic " + s);
  }
  protected void finalize() {
    System.out.println(
      "finalizing Characteristic " + s);
  }
}

class LivingCreature {
  Characteristic p = 
    new Characteristic("is alive");
  LivingCreature() {
    System.out.println("LivingCreature()");
  }
  protected void finalize() throws Throwable {
    System.out.println(
      "LivingCreature finalize");
    // Вызов версии базового класса!
    if(DoBaseFinalization.flag)
      super.finalize();
  }
}

class Animal extends LivingCreature {
  Characteristic p = 
    new Characteristic("has heart");
  Animal() {
    System.out.println("Animal()");
  }
  protected void finalize() throws Throwable {
    System.out.println("Animal finalize");
    if(DoBaseFinalization.flag)
      super.finalize();
  }
}

class Amphibian extends Animal {
  Characteristic p = 
    new Characteristic("can live in water");
  Amphibian() {
    System.out.println("Amphibian()");
  }
  protected void finalize() throws Throwable {
    System.out.println("Amphibian finalize");
    if(DoBaseFinalization.flag)
      super.finalize();
  }
}

public class Frog extends Amphibian {
  Frog() {
    System.out.println("Frog()");
  }
  protected void finalize() throws Throwable {
    System.out.println("Frog finalize");
    if(DoBaseFinalization.flag)
      super.finalize();
  }
  public static void main(String[] args) {
    if(args.length != 0 && 
       args[0].equals("finalize"))
       DoBaseFinalization.flag = true;
    else
      System.out.println("Not finalizing bases");
    new Frog(); // Тотчас становится мусором
    System.out.println("Bye!");
    // Принудительный вызов завершения и очистки:
    System.gc();
  }
} ///:~

Класс DoBaseFinalization просто содержит флаг, который показывает для каждого класса в иерархии вызывать ли super.finalize( ). Этот флаг устанавливается как аргумент командной строки, так что Вы можете посмотреть поведение с и без вызовов завершения базового класса.

Каждый класс в иерархии так же содержит объект класса Characteristic. Вы увидите, что не обращая внимание на вызов завершителя базового класса объект Characteristic всегда завершается.

Каждое переопределение finalize( ) должно иметь доступ к protected членам класса, поскольку метод finalize( ) в классе Object является protected и компилятор не позволит вам уменьшить права доступа во время наследования. ("Friendly" менее "достижимы" чем protected.)

В Frog.main( ), флаг DoBaseFinalization настраивается и создается единственный объект Frog. Помните, что сборщик мусора и индивидуальное завершение, могут не произойти для отдельного объекта, поэтому, что бы вызвать их насильно вызывается System.gc( ) и оттуда уже завершение. Без завершения базовых классов вывод такой:

Not finalizing bases
Creating Characteristic is alive
LivingCreature()
Creating Characteristic has heart
Animal()
Creating Characteristic can live in water
Amphibian()
Frog()
Bye!
Frog finalize
finalizing Characteristic is alive
finalizing Characteristic has heart
finalizing Characteristic can live in water

Вы можете видеть, что не были вызваны завершители для базовых классов Frog (объекты класса были завершены, как Вы и ожидали). Но если Вы добавите аргумент "finalize" в командную строку, Вы получите:

Creating Characteristic is alive
LivingCreature()
Creating Characteristic has heart
Animal()
Creating Characteristic can live in water
Amphibian()
Frog()
bye!
Frog finalize
Amphibian finalize
Animal finalize
LivingCreature finalize
finalizing Characteristic is alive
finalizing Characteristic has heart
finalizing Characteristic can live in water

Поскольку элементы объектов завершены в том же порядке, в каком они были созданы, то технически порядок финализации объектов не определен. С базовыми же классами, Вы можете осуществлять контроль над порядком завершения. Следуя форме, которая используется в C++ для деструкторов, Вы должны осуществить завершение дочернего класса раньше, чем завершение базового класса. Потому что финализация дочернего класса может вызвать некоторые методы в базовом классе, которые требуют, что бы компоненты этого базового классы были все еще активны, поэтому Вы не должны уничтожать их принудительно.

Поведение полиморфных методов внутри конструкторов

Иерархия вызовов конструкторов принесла нам интересную дилемму. Что происходит, если Вы внутри конструктора вызовите динамически компонуемый метод существующего объекта? Внутри обычного метода Вы можете представить, что случится - динамически компонуемый метод разрешится во время работы программы, поскольку объект не знает какого типа данный объект или от какого типа он произошел. В силу последовательности, Вы можете думать, что тоже самое случится и внутри конструктора.

А это уже не точно такой же случай. Если Вы вызываете динамически связываемый метод внутри конструктора, то используется переопределенное определение этого метода. И все равно, такого эффекта лучше избегать, поскольку в данном случае возможно возникновение трудно находимых ошибок.

Понятно, что работа конструктора заключается в оживлении объектов (что на самом деле сродни подвигу). Внутри любого конструктора, целый объект может быть сформирован только по частям, Вы можете знать только то, что базовый объект был проинициализирован, но Вы не можете знать, какие классы наследованы от вашего класса. Динамически связываемые методы в произошедших от них классах. Если Вы сделаете такой фокус внутри конструктора, Вы вызовете метод, который может обрабатывать объекты, которые еще не были инициализированы. Хороший способ для создания катастрофы!

Вы можете разглядеть эту проблему в следующем примере:

//: c07:PolyConstructors.java
// Конструткоры и полиморфизм
// не производите то, что вы не можете ожидать.

abstract class Glyph {
  abstract void draw();
  Glyph() {
    System.out.println("Glyph() before draw()");
    draw(); 
    System.out.println("Glyph() after draw()");
  }
}

class RoundGlyph extends Glyph {
  int radius = 1;
  RoundGlyph(int r) {
    radius = r;
    System.out.println(
      "RoundGlyph.RoundGlyph(), radius = "
      + radius);
  }
  void draw() { 
    System.out.println(
      "RoundGlyph.draw(), radius = " + radius);
  }
}

public class PolyConstructors {
  public static void main(String[] args) {
    new RoundGlyph(5);
  }
} ///:~

В Glyph, метод draw( ) - abstract, так что он спроектирован для переопределения. В замен этого Вы принудительного переопределяете его в RoundGlyph. Но конструктор Glyph вызывает этот метод и этот вызов заканчивается в RoundGlyph.draw( ), что в общем-то выглядит как то, что было нужно. Но посмотрите на вывод:

Glyph() before draw()
RoundGlyph.draw(), radius = 0
Glyph() after draw()
RoundGlyph.RoundGlyph(), radius = 5

Когда конструктор Glyph-а вызывает draw( ), значение radius еще не приняло значение по умолчанию 1. Оно еще равно 0. Это означает, что не будет нарисована точка на экране, Вы будете пытаться нарисовать эту фигуру на экране и пытаться сообразить, почему программа не работает.

Порядок инициализации, описанный в предыдущей секции, не совсем полон и вот Вам ключ для разрешения этой загадки. Настоящий процесс инициализации:

  1. Место отведенное под объекты инициализировано в ноль, до того, как что-то произойдет.
  2. Вызывается конструктор базового класса (как и было описано ранее). В этот момент вызывается переопределенный метод draw( )(да, до того, как будет вызван конструткор RoundGlyph), который открывает, что значение radius равно нулю, как и было описано в шаге 1.
  3. Инициализация элементов вызывается в порядке их определения.
  4. Вызывается тело конструткора базового класса.

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

С другой стороны, Вы должны быть устрашены результатами работы этой программы. Вы совершили совершенно логичную штуку и сейчас поведение программы непостижимо неправильно, и при этом без возражений со стороны компилятора. (C++ проявляет более рациональное поведение в таких ситуациях.) Ошибки на подобии этой могут быть с легкостью совершены, но в последствии потребуют много времени на их обнаружение.

В качестве результата, хорошие руководящие принципы для конструктора "Делайте в конструкторе настолько меньше, насколько можете и если это возможно, то не вызывайте никаких методов". Существует только один тип методов, которые безопасно вызывать из конструктора, это final методы из базового класса. (Это так же применимо и к private методам, которые так же являются final.) Они не могут быть переопределены и поэтому не могут преподнести своего рода сюрприз.

Проектировка с наследованием

Поскольку Вы изучаете полиморфизм, Вы можете видеть, что все следовало бы делать на его основе, поскольку полиморфизм на редкость умная штука. Но чрезмерное использование полиморфизма может значительно утяжелить ваш проект; в частности, если Вы выбираете наследование до того, как Вы используете существующий класс для создания нового класса, то программа будет излишне усложнена.

Лучший подход заключается в выборе для начала композиции, если не очевидно, что Вы должны использовать что-то другое. Композиция не превращает проектировку в иерархию наследования. Но композиция так же и более гибкая, поскольку она способна динамически выбирать типы (и линии поведения соответственно), тогда как наследование требует четко определенного типа известного на стадии компиляции. Следующий пример иллюстрирует это высказывание:

//: c07:Transmogrify.java
// Динамическое изменение поведения
// при композиции объекта.

abstract class Actor {
  abstract void act();
}

class HappyActor extends Actor {
  public void act() { 
    System.out.println("HappyActor"); 
  }
}

class SadActor extends Actor {
  public void act() { 
    System.out.println("SadActor");
  }
}

class Stage {
  Actor a = new HappyActor();
  void change() { a = new SadActor(); }
  void go() { a.act(); }
}

public class Transmogrify {
  public static void main(String[] args) {
    Stage s = new Stage();
    s.go(); // Выводит "HappyActor"
    s.change();
    s.go(); // Выводит "SadActor"
  }
} ///:~

Объект Stage содержит ссылку на Actor, которая проинициализирована на объект HappyActor. Это означает, что go( ) предоставляет специфическое поведение. Но поскольку ссылка может быть перенаправлена на другой объект во время выполнения, то ссылка на объект SadActor может быть подставлена в a а затем посредством go( ) может быть изменена линия поведения. Так Вы наживаетесь на динамическом изменении во время работы программы. (Это так же называется статический шаблон (State Pattern). Смотрите для подробностей " Thinking in Patterns with Java", доступный с www.BruceEckel.com.) В противоположность, Вы не можете решить использовать наследование с различными типами в режиме выполнения, типы должны быть полностью определены на стадии компиляции.

Основная линия поведения при этом может быть выражена фразой "Используй наследование для выражения различия в поведении и поля для выражения различий в значениях". В предыдущем примере использовались оба принципа из высказывания: два различных класса были наследованы для получения различий в методе act( ), а Stage использует композицию для изменения своего значения. В этом случае, такое изменение означает и изменение в поведении метода.

Чистое наследование против расширения

Во время изучения наследования, всегда преподается наиболее понятный, чистый путь для создания диаграммы наследования, т.н. "чистое" наследование. При этом существуют только методы в интерфейсе базового класса, которые переопределяются в дочерних классах, как и показано на диаграмме:


Это так называемая чистая "is-a" связь, поскольку интерфейс класса определяет, что же это есть на самом деле. Наследование гарантирует, что любой дочерний класс будет иметь тот же интерфейс (т.е. не меньше его) как и у базового класса и ничего более. Если Вы последуете представленной диаграмме, то можете увидеть, что дочерние классы так же имеют интерфейс не больший, чем у базового.

Это разновидность так называемой чистой замены, поскольку объекты дочернего класса могут быть чудным образом заменены для базового класса и вам не нужно знать никакой дополнительной информации о подклассах, когда Вы их будете использовать:


Так и получается, базовый класс может принимать любые сообщения, которые Вы можете посылать дочернему, поскольку они оба имеют тот же самый интерфейс. Все, что вам нужно сделать это привести к базовому типу от дочернего класса и больше не смотреть, с каким именно типом объекта Вы имеете дело. Все обрабатывается средствами полиморфизма.

Когда Вы видите такой путь, то это означает, что используются чистые связи "is-a", при этом такой подход является единственным и любой другой дизайн сигнализирует о запутанном обдумывании и по определению кривому восприятию кода. Вот и попались Вы в ловушку. Как только Вы начали думать в этом направлении, развернитесь и откройте для себя расширение интерфейса (которое к несчастью подстрекается ключевым словом extends) являющегося лучшим решением частной проблемы. Такой подход называется "is-like-a" (это похоже на то) связью, поскольку дочерний класс похож на базовый класс, из-за того, что они имеют один и тот же фундаментальный интерфейс, но они имеют различные особенности, которые требуют дополнительных методов для своей реализации:


У такого удобного и разумного подхода (в зависимости от ситуации) имеется и небольшой недостаток. Расширенная часть интерфейса дочернего класса не доступна из базового класса, так что при приведении к базовому типу Вы не можете получить доступ к этим новым методам:


Если же Вы не приводите к базовому классу, об этом не стоит беспокоится, но зачастую вам необходимо получить точный тип объекта, так что Вы в последствии можете расширить методы этого типа. Следующая секция показывает, как это можно провернуть.

Приведение к дочернему типу и идентификация типов во время работы

Из-за того, что Вы теряете информацию о типе при приведении к базовому типу (движение вверх по диаграмме наследования), то важно получить тип полученной информации, для этого нужно двигаться назад, вниз по иерархии, используя тем самым приведение к дочернему типу. Однако как Вы знаете приведение к базовому типу безопасно всегда, базовый класс не может иметь больший, интерфейс чем дочерний, поэтому каждое сообщение, которое Вы посылаете через базовый класс гарантированно будет получено. Но при приведении к дочернему типу, Вы в действительности не знаете, что шейп к примеру в действительности круг. А он может быть и треугольником и квадратом или чем еще угодно.


Для разрешения этой проблемы должен существовать некоторый путь, гарантирующий, что приведение к дочернему типу корректно, поскольку Вы наверняка не хотите привести тип к неверному значению и послать сообщение объекту, который не сможет его принять. Это может быть несколько не безопасно.

В некоторых языках (типа C++) Вы должны осуществлять специальную операцию в получении типо-безопасного приведения к дочернему типу, но в Java любое приведение к типу проверяется! И как бы это не выглядело странно, Вы просто выполняете ординарное родительское приведение, во время работы, это приведение проверяется, для того, что бы убедиться, что это на самом деле то, что нужно. Если что-то не так, то Вы получите ClassCastException. Этот акт проверки типов во время работы называется идентификация типов во время работы (run-time type identification (RTTI)). Следующий пример демонстрирует поведение RTTI:

//: c07:RTTI.java
// Приведение к дочернему типу и RTTI.
import java.util.*;

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. Как Вы можете видеть это происходит в момент инициализации массива x в main( ). Поскольку оба объекта в массиве есть типы от класса Useful, то Вы можете послать методы f( ) и g( ) обоим, а если Вы попытаетесь вызвать u( ) (который существует только в MoreUseful), то Вы получите ошибку времени компиляции.

Если Вы хотите получить доступ к расширенному интерфейсу объекта MoreUseful, Вы можете попытаться привести его к дочернему типу. Если он правильного типа, то все пройдет нормально. В противном случае, Вы получите ClassCastException. Вам не нужно писать какой либо специальный код для этого исключения, поскольку оно сигнализирует об ошибке программиста, которая произошла где-то в программе.

У RTTI есть больше применений, чем простое приведение. К примеру, существует возможность увидеть с каким типом Вы работаете, до того, как Вы попытаетесь привести к дочернему типу. Вся глава 12 посвящена изучению различных аспектов применения идентификации типов во время работы в Java

Резюме

Полиморфизм означает "различные формы". В ООП у вас есть одно и то же лицо (общий интерфейс в базовом классе) и различные формы использующие это лицо: различные версии динамически компонуемых методов.

В этой главе Вы увидели, что невозможно понять, а часто и создать пример полиморфизма без использования абстракций данных и наследования. Полиморфизм это такое свойство, которое не может быть рассмотрено в изоляции (а оператор switch, например можно), и при этом полиморфизм работает только в сочетании, как часть большой картины связей классов. Люди часто находятся в затруднении относительно других, не объектно-ориентированных свойств Java, таких как перезагрузка, которые иногда преподносились как объектно-ориентированные. Не сглупите: если оно не с поздним связыванием, то оно и не с полиморфизмом.

Для использования полиморфизма и таких же объектно-риентированных технологий в вашей программе ,Вы должны расширить ваш взгляд на программирование, включая не только участников классов и сообщений, но так же общие классы и их взаимосвязи. Естественно это потребует значительных усилий, потребуется приложить много сил, но результатом будет более быстрая разработка программ, лучшая организация кода, расширяемость программ и более легкая манипуляция кодом.

Упражнения

Решения к выбранным упражнениям могут быть найдены в электронном документе The Thinking in Java Annotated Solution Guide, доступном с www.BruceEckel.com.

  1. Добавьте новый метод в базовый класс Shapes.java, который печатает сообщение, но не переопределяйте его в дочерних классах. Объясните, что происходит. Теперь переопределите его в одном из дочерних классов, но не в остальных, и посмотрите, что произошло. В конце переопределите его во всех классах.
  2. Добавьте новый тип Shape в Shapes.java и проверьте в main( ), что полиморфизм работает для ваших новых типов, как если бы он были старых типов.
  3. Измените Music3.java, так что бы what( ) стал корневым методом объекта Object метода toString( ). Попробуйте напечатать объект Instrument используя System.out.println( ) (без любых приведений).
  4. Добавьте новый тип Instrument к Music3.java и проверьте, что полиморфизм работает для вашего нового типа.
  5. Измените Music3.java, так, что бы он случайным образом создавал объекты Instrument так же, как это делает Shapes.java.
  6. Создайте иерархию наследования Rodent: Mouse, Gerbil, Hamster, и т.д. В базовом классе, создайте метод общий для всех Rodent и переопределите их в дочерних классах для осуществления различного поведения в зависимости от типа Rodent. Создайте массив из Rodent, заполните его различными типами Rodent и вызовите ваш метод базового класса, что бы посмотреть, что случилось.
  7. Измените упражнение 6, так, что бы Rodent стал abstract классом. Сделайте методы Rodent абстрактными, где только возможно.
  8. Создайте класс как abstract без включения любых abstract методов и проверьте, что Вы не можете создать ни одного экземпляра этого класса.
  9. Добавьте класс Pickle к Sandwich.java.
  10. Измените упражнение 6, так что бы оно демонстрировало порядок инициализации базовых и дочерних классов. Теперь добавьте участников объектов в оба, в базовый и в дочерний классы и покажите порядок в каком происходит инициализация при создании объекта.
  11. Создайте трех уровневую иерархию наследования. Каждый из классов должен иметь метод finalize( ) и он должен правильно вызывать версию finalize( ) из базового класса. Покажите, что ваша иерархия работает правильно.
  12. Создайте базовый класс с двумя методами. В первом методе, вызовите второй метод. Наследуйте класс и переопределите второй метод. Создайте объект дочернего класса и приведите его к базовому типу, затем вызовите первый метод. Объясните, что произошло.
  13. Создайте базовый класс с методом abstract print( ), который переопределяется в дочернем классе. Переопределенная версия метода печатает значение переменной int, определенной в дочернем классе. В точке определения этой переменной, присвойте ей не нулевое значение. В конструкторе базового класса вызовите этот метод. В main( ), создайте объект дочернего типа и затем вызовите его print( ). Объясните результат.
  14. Следуйте примеру в Transmogrify.java, создайте класс Starship содержащий ссылку AlertStatus, которая может отображать три различных состояния. Включите в класс методы изменяющие это состояние.
  15. Создайте abstract класс без методов. Наследуйте класс и добавьте метод. Создайте static метод, который получает ссылку на базовый класс, приведите ее к дочернему типу и вызовите этот метод. В main( ), покажите, что это работает. Теперь поместите abstract объявление для метода в базовый класс, это уничтожит потребность в приведении к дочернему типу.

[37] Для программистов C++, это аналог C++ pure virtual function.

[ Предыдущая глава ] [ Короткое оглавление ] [ Содержание ] [ Индекс ] [ Следующая глава ]