Опубликован: 04.12.2009 | Доступ: свободный | Студентов: 8231 / 585 | Оценка: 4.30 / 3.87 | Длительность: 27:27:00
Лекция 6:

Начальные сведения об объектном программировании

Аннотация: Наследование и полиморфизм. UML-диаграммы. Функции. Модификаторы. Передача примитивных типов в функции. Локальные и глобальные переменные. Модификаторы доступа и правила видимости. Ссылка this. Передача ссылочных типов в функции. Проблема изменения ссылки внутри подпрограммы. Наследование. Суперклассы и подклассы. Переопределение методов. Наследование и правила видимости. Зарезервированное слово super. Статическое и динамическое связывание методов. Полиморфизм. Базовый класс Object. Конструкторы. Зарезервированные слова super и this. Блоки инициализации. Удаление неиспользуемых объектов и метод finalize. Проблема деструкторов для сложно устроенных объектов. Перегрузка методов. Правила совместимости ссылочных типов как основа использования полиморфного кода. Приведение и проверка типов. Рефакторинг. Reverse engineering - построение UML-диаграмм по разработанным классам.
Ключевые слова: класс, ancestor, descendant, очередь, отношение наследования, Java, наследование, опыт, слово, полиморфизм, ПО, figures, объект, переменная, иерархия классов, процедурное программирование, иерархия, triangle, square, Окружность, Эллипс, прямоугольник, generalization, обобщение, superclass, subclass, суперкласс, подкласс, general, базовый класс, координаты, поле, длина, значение, aspect ratio, enterprise, потомок, отношение, доступ, модификатор видимости, класс-потомок, объектный тип, предметной области, логические ошибки, конфликт, абстрактный класс, иерархия наследования, object pascal, описание поля, SUN, UML, modeling language, язык моделирования, объектно-ориентированное проектирование, OOA, object orientation, architecture, reverse engineering, объявление функции, переопределение метода, выражение, прерывание, return statement, return, список, тело функции, последовательность операторов, алгоритм, операторы, файл, глобальные переменные, формальный параметр, фактический параметр, вычисление, примитивный тип, автоматическое преобразование, подпрограмма, передача параметров, параметр, сообщение об ошибке, перечисление, окончание блока, переменная класса, память, связь, garbage collector, ссылка, секция инициализации, тело цикла, объектная переменная, адрес, класс приложений, присваивание, OBJ, строковый, входной параметр, среда разработки, count, усложним пример, wrapper, строковый тип, произвольное, конструктор, автор, inheritance, зарезервированное слово, имя класса, абстракция, AWT, background color, цвет фона, абстрактный метод, override, информация, сигнатура метода, overload, перегрузка метода, параграф, компилятор, экранная форма, расширяющий класс, resizing, size, радиус, circle, пароль, ограничение доступа, прямой, целостность, SETI, высота, площадь, программа, public, super, программирование, статическое связывание, связывание, динамический метод, место, виртуальный метод, таблица, DMT, dynamic, table, VMT, virtual, equalizer, хэш-код, клонирование, возбуждение исключительной ситуации, ссылочный тип, глубокое клонирование, поле класса, инициализация, вывод, видимость имен, дочерний класс, родительский класс, знание, синтаксис, инициализация объекта, деструктор, язык программирования, destroy, деструкторы класса, закрытие файла, имя функции, перегрузка, перегруженный метод, функция, Произведение, методы класса, сигнатура, диапазон, диагностика, опасная ситуация, совместимость типов, пользователь, пункт, байт, тип объекта, время выполнения, исключительная ситуация, равенство, extract, меню, дерево, текстовый процессор, откат, getter, setter, relationship, diagram, dot, forward, обратное проектирование, Reverse

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

6.1. Наследование и полиморфизм. 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 - "специализация". Стрелки символизируют направление в сторону упрощения.

Иерархия фигур, отрисовываемых на экране

Рис. 6.1. Иерархия фигур, отрисовываемых на экране

Часто класс-прародитель называют суперклассом (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, естественно и однозначно. И однозначно то, что все классы нашей иерархии должны быть его наследниками. А вот остальные элементы иерархии можно было бы устроить совсем по-другому. Например, так, как показано на следующем рисунке.

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

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

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

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

Рис. 6.3. Еще один вариант иерархии фигур

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

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

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

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

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

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

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

Рис. 6.4. Итоговый вариант иерархии фигур

Основное ее преимущество по сравнению с предыдущими – возможность писать полиморфный код для наиболее общих разновидностей фигур. Введение промежуточных уровней наследования, отвечающих соответствующим абстракциям, является характерной чертой объектного программирования. При этом классы 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 – Универсальный Язык Моделирования. Он предназначен для моделирования на уровне абстракций классов и связей их друг с другом – то есть для задач Объектно-Ориентированного Проектирования (OOAObject- Oriented Architecture). Приведенные выше рисунки иерархий классов – это UML-диаграммы, сделанные с помощью NetBeans Enterprise Pack.

Пока в этой среде нет возможности по UML-диаграммам создавать заготовки классов Java, как это делается в некоторых других средах UML-проектирования. Но если создать пустые заготовки классов, то далее можно разрабатывать соответствующие им UML-диаграммы, и внесенные изменения на диаграммах будут сразу отображаться в исходном коде. Как это делается будет подробно описано в последнем параграфе данной лекции, где будет обсуждаться технология Reverse Engineering.

Максим Старостин
Максим Старостин

Код с перемещением фигур не стирает старую фигуру, а просто рисует новую в новом месте. Точку, круг.

Наталья Алмаева
Наталья Алмаева
Россия
Александр Санчиров
Александр Санчиров
Россия, Москва