Наследование и полиморфизм. UML-диаграммы
Наследование опирается на инкапсуляцию. Оно позволяет строить на основе первоначального класса новые, добавляя в классы новые поля данных и методы. Первоначальный класс называется прародителем (ancestor), новые классы – его потомками (descendants). От потомков, в свою очередь, можно наследовать, получая очередных потомков. И так далее. Набор классов, связанных отношением наследования, называется иерархией классов. А класс, стоящий во главе иерархии, от которого унаследованы все остальные (прямо или опосредованно), называется базовым классом иерархии. В Java все классы являются потомками класса Object. То есть он является базовым для всех классов. Тем не менее, если рассматривается поведение, характерное для объектов какого-то класса и всех потомков этого класса, говорят об иерархии, начинающейся с этого класса, В этом случае именно он является базовым классом иерархии.
Полиморфизм опирается как на инкапсуляцию, так и на наследование. Как показывает опыт преподавания, это наиболее сложный для понимания принцип. Слово “полиморфизм” в переводе с греческого означает “имеющий много форм”. В объектном программировании под полиморфизмом подразумевается наличие кода, написанного для объектов, имеющих тип базового класса иерархии. При этом такой код должен правильно работать для любого объекта, являющегося экземпляром класса из данной иерархии. Независимо от того, где этот класс расположен в иерархии. Такой код и называется полиморфным. При написании полиморфного кода заранее неизвестно, для объектов какого типа он будет работать – один и тот же метод будет исполняться по-разному в зависимости от типа объекта. Пусть, например, у нас имеется класс Figure-“фигура”, и в нём заданы методы show()– показать фигуру на экране, и и hide() - скрыть её. Тогда для переменной figure типа Figure вызовы figure.show() и figure.hide() будут показывать или скрывать объект, на который ссылается эта переменная. Причём сам объект “знает”, как себя показывать или скрывать, а код пишется на уровне абстракций этих действий.
Основное преимущество объектного программирования по сравнению с процедурным как раз и заключается в возможности написания полиморфного кода. Именно для этого пишется иерархия классов. Полиморфизм позволяет резко увеличить коэффициент повторного использования программного кода и его модифицируемость по сравнению с процедурным программированием.
В качестве примера того, как строится иерархия, рассмотрим иерархию фигур, отрисовываемых на экране – она показана на рисунке. В ней базовым классом является Figure, от которого наследуются Dot – “точка”, Triangle – “треугольник” и Square – “квадрат”. От Dot наследуется класс Circle – “окружность”, а от Circle унаследуем Ellipse – “эллипс”. И, наконец, от Square унаследуем Rectangle – “прямоугольник”.
Отметим, что в иерархии принято рисовать стрелки в направлении от наследника к прародителю. Такое направление называется Generalization – “обобщение”, “генерализация”. Оно противоположно направлению наследования, которое принято называть Specialization – “специализация”. Стрелки символизируют направление в сторону упрощения.
Иерархия фигур, отрисовываемых на экране
Часто класс-прародитель называют суперклассом (superclass), а класс-наследник – субклассом (subclass). Но такая терминология подталкивает начинающих программистов к неверной логике: суперкласс пытаются сделать “суперсложным”. Так, чтобы его подклассы (это неверно воспринимается синонимом выражению “упрощённые разновидности”) обладали упрощённым по сравнению с ним поведением. На деле же потомки должны обладать более сложным устройством и поведением по сравнению прародителем. Поэтому в данном учебном пособии предпочтение отдаётся терминам “прародитель” и “наследник”.
Чем ближе к основанию иерархии лежит класс, тем более общим и универсальным (general) он является. И одновременно – более простым. Класс, который лежит в основе иерархии, называется базовым классом этой иерархии. Базовый класс всегда называют именем, которое характеризует все объекты - экземпляры классов-наследников, и которое выражает наиболее общую абстракцию, применимую к таким объектам. В нашем случае это класс Figure. Любая фигура будет иметь поля данных x и y – координаты фигуры на экране.
Класс Dot (“точка”) является наследником Figure, поэтому он будет иметь поля данных x и y, наследуемые от Figure. То есть в самом классе Dot задавать эти поля не надо. От Dot мы наследуем класс Circle (“окружность”), поэтому в нём также имеется поля x и y, наследуемые от Figure. Но появляется дополнительное поля данных. У Circle это поле, соответствующее радиусу. Мы назовём его r. Кроме того, для окружности возможна операция изменения радиуса, поэтому в ней может появиться новый метод, обеспечивающий это действие – назовём его setSize (“установить размер”). Класс Ellipse имеет те же поля данных и обеспечивает то же поведение, что и Circle, но в этом классе появляется дополнительное поле данных r2 – длина второй полуоси эллипса, и возможность регулировать значение этого поля. Возможен и другой подход, в некотором роде более логичный: считать эллипс сплюснутой или растянутой окружностью. В этом случае необходимо ввести коэффициент растяжения (aspect ratio). Назовём его k. Тогда эллипс будет характеризоваться радиусом r и коэффициентом растяжения k. Метод, обеспечивающий изменение k, назовём stretch (“растянуть”). Обратим внимание, что исходя из выбранной логики действий метод scale должен приводить к изменению поля r и не затрагивать поле k – поэтому эллипс будет масштабироваться без изменения формы.
Каждый из классов этой ветви иерархии фигур можно считать описанием “усложнённой точки”. При этом важно, что любой объект такого типа можно считать “точкой, которую усложнили”. Грубо говоря, считать, что круг или эллипс – это такая “жирная точка”. Аналогичным образом Ellipse является “усложнённой окружностью”
Аналогично, класс Square наследует поля x и y, но в нём добавляется поле, соответствующее стороне квадрата. Мы назовём его a. У Triangle в качестве новых, не унаследованных полей данных могут выступать координаты вершин треугольника; либо координаты одной из вершин, длины прилегающих к ней сторон и угол между ними, и так далее.
Как располагать классы иерархии, базовый класс внизу а наследники вверху, образуя ветви дерева наследования, или наоборот, базовый класс вверху а наследники внизу, образуя “корни” дерева наследования – принципиального значения не имеет. По-видимому, на начальном этапе развития объектного программирования применялся первый вариант, почему базовый класс, лежащий в основе иерархии, и получил такое название. Такой вариант выбран в данном учебном пособии, поскольку именно он используется в NetBeans Enterprise Pack. Хотя настоящее время чаще используют второй вариант, когда базовый класс располагают сверху.
В литературе по объектному программированию часто встречается следующий критерий: “если имеются классы A1 и A2, и можно сказать, что A2 является частным случаем A1, то A2 должен описываться как потомок A1”. Данный критерий не совсем корректен.
Очень часто встречающийся вариант ошибочных рассуждений, основанный на нём, и приводящий к неправильному построению иерархии, выглядит так: “поскольку 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 для этих классов также будут абстрактными.
Ещё один важный принцип при построении иерархий на первый взгляд может показаться достаточно странным и противоречащим требованию повторного использования кода. Его можно сформулировать так: не использовать код неабстрактных классов для наследования.
Можно заметить, что в приведённой иерархии несколько этапов наследования приходятся именно на абстрактные классы, и ни один из классов, имеющих экземпляры (объекты), не имеет наследников. Причина такого требования проста: изменение реализации одного класса, проводимое не на уровне абстракции, а относящееся только к одному конкретному классу, не должна влиять на поведение другого класса. Иначе возможны неотслеживаемые труднопонимаемые ошибки в работе иерархии классов. Например, если мы попробуем унаследовать класс Ellipse от Circle, после исправлений в реализации Circle, обеспечивающих правильную работу объектов этого типа, могут возникнуть проблемы при работе объектов типа Ellipse, которые до того работали правильно. Причём речь идёт об особенностях реализации конкретного класса, не относящихся к абстракциям поведения.
Продумывание того, как устроены классы, то есть какие в них должны быть поля и методы (без уточнения об конкретной реализации этих методов), и описание того, какая должна быть иерархия наследования, называется проектированием. Это сложный процесс, и он обычно гораздо важнее написания конкретных операторов в реализации (кодирования).
В языке Java, к сожалению, отсутствуют адекватные средства для проектирования классов. Более того, в этом отношении он заметно уступает таким языкам как C++ или Object PASCAL, поскольку в Java отсутствует разделение декларации класса (описание полей и заголовков методов) и реализации методов. Но в Sun Java Studio и NetBeans Enterprise Pack имеется средство решения этой проблемы – создание UML-диаграмм. UML расшифровывается как Universal Modeling Language – Универсальный Язык Моделирования. Он предназначен для моделирования на уровне абстракций классов и связей их друг с другом – то есть для задач Объектно-Ориентированного Проектирования (OOA – Object-Oriented Architecture). Приведённые выше рисунки иерархий классов – это UML-диаграммы, сделанные с помощью NetBeans Enterprise Pack.
Пока в этой среде пока нет возможности по UML-диаграммам создавать заготовки классов Java, как это делается в некоторых других средах UML-проектирования. Но если создать пустые заготовки классов, то далее можно разрабатывать соответствующие им UML-диаграммы, и внесённые изменения на диаграммах будут сразу отображаться в исходном коде. Как это делается будет подробно описано в последнем параграфе данной главы, где будет обсуждаться технология Reverse Engineering.