Смекни!
smekni.com

Общие представления о языке Java 5 (стр. 34 из 68)

Очень часто встречающийся вариант ошибочных рассуждений, основанный на нём, и приводящий к неправильному построению иерархии, выглядит так: “поскольку Circle является частным случаем Ellipse (при равных длинах полуосей), а Dot является частным случаем Circle (при нулевом радиусе), то класс Ellipse более общий, чем Circle, а Circle – более общий, чем Dot. Поэтому Ellipse должен являться прародителем для Circle, а Circle должен являться прародителем для Dot ”. Ошибка заключается в неправильном понимании идей “общности” и “специализации”, а также характерной путанице, когда объекты не отличают от классов.

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

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

По своему поведению любой объект-эллипс вполне может рассматриваться как экземпляр типа “Окружность” и даже вести себя в точности как окружность. Но не наоборот - объекты типа Окружность не обладает поведением Эллипса. Мы намеренно используем заглавные буквы для того, чтобы не путать классы с объектами. Если для эллипса можно изменить значение aspectRatio ( вызвать метод setAspectRatio (новое значение) ), то для окружности такая операция не имеет смысла или запрещена. Аналогично, и для эллипса, и для окружности имеет смысл операция установки нового размера setSize(новое значение), а для точки она не имеет смысла или запрещена. И даже если построить неправильную иерархию Ellipse-Circle-Dot и унаследовать от Ellipse эти методы в Circle и Dot, возникнет проблема с их переопределением. Если setAspectRatio будет менять отношение полуосей нашей “окружности” – она перестанет быть окружностью. Аналогично, если setSize изменит размер точки – та перестанет быть точкой. Если же сделать эти методы ничего не делающими “заглушками” – экземпляры таких потомков не смогут обладать поведением прародителя. Например, мы не сможем вписать окружность в прямоугольник, установив нужное значение aspectRatio – найдутся только две точки, общие для окружности и сторон прямоугольника, а не четыре, как для объекта типа Ellipse. То есть объект типа Circle на уровне абстракции поведения во многих случаях не сможет обладать всеми особенностями поведения объекта типа Ellipse. А значит, Circle не может быть потомком Ellipse.

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

Сформулируем критерий того, когда следует использовать наследование, более корректно: “если имеются классы A1 и A2, и можно считать, что A2 является модифицированным (усложнённым или изменённым) вариантом A1 с сохранением всех особенностей поведения A1 , то A2 должен описываться как потомок A1. - На уровне абстракции, описывающей поведение, объект типа A2 должен вести себя, как объект типа A1 при любых значениях полей данных”.

Специализированный класс, вообще говоря, должен быть устроен более сложно (“расширенно” - extended) по сравнению с прародительским. У него должны иметься дополнительные поля данных и/или дополнительные методы. С этой точки зрения очевидно, что Окружность более специализирована, чем Точка, а Эллипс более специализирован, чем Окружность. Иногда встречаются ситуации, когда потомок отличается от прародителя только своим поведением. У него не добавляется новых полей или методов, а только переопределяется часть методов (возможно, только один). Отметим, что поля или методы, имеющиеся в прародителе, не могут отсутствовать в наследнике – они наследуются из прародителя. Даже если доступ к ним в классе-наследнике закрыт (так бывает в случае, когда поле или метод объявлены с модификатором видимости private – “закрытый”, “частный”).

Когда про класс-потомок можно сказать, что он является специализированной разновидностью класса-прародителя (“B есть A”), всё очевидно. Но в объектном программировании иногда приходится использовать отношение “Класс B похож на A - имеет те же поля данных, плюс, возможно, дополнительные, но обладает несколько иным поведением”.

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

Альтернативный вариант иерархии фигур

Возможно и такое решение: все указанные классы сделать наследниками Figure и расположить на одном уровне наследования.

Ещё один вариант иерархии фигур

Возможны и другие варианты, ничуть не менее логичные. Какой вариант выбрать?

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

Один из важных принципов при построении таких иерархий – соответствие представлений из предметной области строящейся иерархии. В примере, приведённом на первом рисунке, мы имеем вполне логичную с точки зрения идеологии наследования иерархию, показанную на первом рисунке. С точки зрения общности/специализации такая иерархия безупречна. По этой причине она удобна для написания учебных программ, иллюстрирующих работу с классами-наследниками, совместимостью объектных типов, а также написанием полиморфного кода. Но в геометрии, из которой мы знаем о свойствах этих фигур, считается, что окружность является частным случаем эллипса, а точка – частным случаем окружности (а значит, и эллипса). Так как значения полей данных объекта задают его состояние, в некоторых случаях объекты, являющиеся Эллипсами по типу (внутреннему устройству), окажутся в состоянии, когда с точки предметной области они будут являться окружностями. Хотя по внутреннему устройству и будут отличаться от объектов-Окружностей.

Поэтому данная иерархия может вызывать внутренний протест у многих людей. Особенно учитывая сложность различения классов и объектов в обычной речи и при не очень строгих рассуждениях (а можно ли всегда рассуждать абсолютно строго?). Поэтому такое решение может приводить к логическим ошибкам в рассуждениях. Вот почему последний из предложенных вариантов иерархий, когда все классы наследуются непосредственно от Figure, во многих случаях предпочтителен. Тем более, что никакого выигрыша при написании программного кода увеличение числа поколений наследования не даёт: код, написанный для класса Dot, вряд ли будет использоваться для объектов классов Circle и Ellipse. А ведь наследование само по себе не нужно – это инструмент для написания более экономного полиморфного кода. Более того, увеличение числа поколений приводит к снижению надёжности кода. Так что им не следует злоупотреблять. (Об этом подробнее говорится в одном из параграфов главы 8).

На выбор варианта иерархии оказывают заметное влияние соображения повторного использования кода – если бы класс Ellipse активно использовал часть кода, написанного для класса Circle, а тот, в свою очередь, активно пользовался кодом класса Dot, выбор первого варианта мог бы стать предпочтительным по сравнению с третьим. Даже несмотря на некоторый конфликт с “обыденными” (не принципиальными!) представлениями предметной области.

Но имеется одна возможность, которую можно реализовать, попытавшись совместить идеи, возникшие при попытках построить предыдущие варианты нашей иерархии. Мы пришли к выводу, что фигуры могут быть масштабируемы (без изменения формы, оставаясь подобными), а также растягиваемы. Поэтому можно ввести классы ScalableFigure (“масштабируемая фигура”) и StretchableFigure (“растягиваемая фигура”). Точка Dot не является ни масштабируемой, ни растягиваемой. Очевидно, что любая растягиваемая фигура должна быть масштабируемая. Окружность Circle и квадрат Square масштабируемы, но не растягиваемы. А прямоугольник Rectangle, эллипс Ellipse и треугольник Triangle как масштабируемы, так и растягиваемы. Поэтому наша иерархия будет выглядеть так:

Итоговый вариант иерархии фигур

Основное её преимущество по сравнению с предыдущими – возможность писать полиморфный код для наиболее общих разновидностей фигур. Введение промежуточных уровней наследования, отвечающих соответствующим абстракциям, является характерной чертой объектного программирования. При этом классы Figure, ScalableFigure и StretchableFigure будут абстрактными – экземпляров такого типа создавать не предполагается. Так как не бывает “фигуры”, “масштабируемой фигуры” или “растягиваемой фигуры” в общем виде, без указания её конкретной формы. Точно так же методы show и hide для этих классов также будут абстрактными.