Основы объектно-ориентированного программирования

Мейер Бертран

Лекция 14. Введение в наследование

 

 

Многоугольники и прямоугольники

 

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

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

При обеспечении расширяемости (extendibility) преимущество описанной выше системы типов состоит в гарантированной совместности во время компиляции, но она запрещает многие вполне законные комбинации элементов. Например, нельзя объявить массив, содержащий геометрические объекты различных совместных типов, таких как POINT (ТОЧКА) и SEGMENT(ОТРЕЗОК).

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

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

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

 

Многоугольники

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

Рассмотрим вначале класс, описывающий многоугольники. Операции будут включать вычисление периметра, параллельный перенос и вращение. Этот класс может выглядеть так:

indexing

description: "Многоугольники с произвольным числом вершин"

class POLYGON creation

...

feature -- Доступ

count: INTEGER

-- Число вершин

perimeter: REAL is

-- Длина периметра

do ... end

feature -- Преобразование

display is

-- Вывод многоугольника на экран.

do ... end

rotate (center: POINT; angle: REAL) is

-- Поворот на угол angle вокруг точки center.

do

... См. далее ...

end

translate (a, b: REAL) is

-- Сдвиг на a по горизонтали, на b по вертикали.

do ... end

... Объявления других компонентов ...

feature {NONE} -- Реализация

vertices: LINKED_LIST [POINT]

-- Список вершин многоугольника

invariant

same_count_as_implementation: count = vertices.count

at_least_three: count >= 3

-- У многоугольника не менее трех вершин (см. упражнение У14.2)

end

Атрибут vertices задает список вершин, выбор линейного списка - это лишь одно из возможных представлений (массив мог бы оказаться лучше).

Приведем реализацию типичной процедуры rotate. Эта процедура осуществляет поворот на заданный угол вокруг заданного центра поворота. Для поворота многоугольника достаточно повернуть по очереди каждую его вершину.

rotate (center: POINT; angle: REAL) is

-- Поворот вокруг точки center на угол angle.

do

from

vertices.start

until

vertices.after

loop

vertices.item.rotate (center, angle)

vertices.forth

end

end

Чтобы понять эту процедуру заметим, что компонент item из LINKED_LIST возвращает значение текущего элемента списка. Поскольку vertices имеют тип LINKED_LIST [POINT], то vertices.item обозначает точку, к которой можно применить процедуру поворота rotate, определенную для класса POINT в предыдущей лекции. Это вполне корректно и достаточно общепринято - давать одно и то же имя (в данном случае rotate), компонентам разных классов, поскольку результирующее множество каждого из них имеет свой явно определенный тип. (Это ОО-форма перегрузки.)

Более важна для наших целей процедура вычисления периметра многоугольника. Единственный способ вычислить периметр многоугольника - это в цикле пройти по всем его вершинам и просуммировать длины всех ребер. Вот возможная реализация процедуры perimeter:

perimeter: REAL is

-- Сумма длин ребер

local

this, previous: POINT

do

from

vertices.start; this := vertices.item

check not vertices.after end -- Следствие условия at_least_three

until

vertices.is_last

loop

previous := this

vertices.forth

this := vertices.item

Result := Result + this.distance (previous)

end

Result := Result + this.distance (vertices.first)

end

В этом цикле просто последовательно складываются расстояния между соседними вершинами. Функция distance была определена в классе POINT. Значение Result, возвращаемое этой функцией, при инициализации получает значение 0. Из класса LINKED_LIST используются следующие компоненты: first дает первый элемент списка, start сдвигает курсор, на этот первый элемент, forth передвигает его на следующий, item выдает значение элемента под курсором, is_last определяет, является ли текущий элемент последним, after узнает, что курсор оказался за последним элементом. Как указано в команде check инвариант at_least_three обеспечивает правильное начало и завершение цикла. Он стартует в состоянии not after, в котором элемент vertices.item определен. Допустимо применение forth один или более раз, что, в конце концов, приведет в состояние, удовлетворяющее условию выхода из цикла is_last.

 

Прямоугольники

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

Преимущества такой смеси общих и специфических компонентов можно использовать, определив класс RECTANGLE как наследника (heir) класса POLYGON. При этом все компоненты класса POLYGON, называемого родителем (parent) класса RECTANGLE, по умолчанию будут применимы и к классу-наследнику. Для этого достаточно включить в RECTANGLE предложение наследования (inheritance clause):

class RECTANGLE inherit

POLYGON

feature

... Компоненты, специфичные для прямоугольников ...

end

В предложении feature класса-наследника компоненты родителя не повторяются: они автоматически доступны благодаря предложению о наследовании. В нем будут указаны лишь компоненты, специфичные для наследника. Это могут быть новые компоненты, такие как diagonal, а также переопределяемые наследуемые компоненты.

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

class RECTANGLE inherit

POLYGON

redefine perimeter end

feature

...

end

Это позволяет включить в предложение feature класса RECTANGLE новую версию компонента perimeter, которая заменит его версию из класса POLYGON. Если не включить объявление redefine, то новое объявление компонента perimeter среди других компонентов класса RECTANGLE приведет к ошибке, поскольку у RECTANGLE уже есть компонент perimeter, унаследованный от POLYGON, т.е. у некоторого компонента окажется два определения.

Класс RECTANGLE выглядит следующим образом:

indexing

description: "Прямоугольники, - специальный случай многоугольников"

class RECTANGLE inherit

POLYGON

redefine perimeter end

creation

make

feature -- Инициализация

make (center: POINT; s1, s2, angle: REAL) is

-- Установить центр прямоугольника в center, длины сторон

-- s1 и s2 и ориентацию angle.

do ... end

feature -- Access

side1, side2: REAL

-- Длины двух сторон

diagonal: REAL

-- Длина диагонали

perimeter: REAL is

-- Сумма длин сторон

-- (Переопределение версии из POLYGON)

do

Result := 2 S (side1 + side2)

end

invariant

four_sides: count = 4

first_side: (vertices.i_th (1)).distance (vertices.i_th (2)) = side1

second_side: (vertices.i_th (2)).distance (vertices.i_th (3)) = side2

third_side: (vertices.i_th (3)).distance (vertices.i_th (4)) = side1

fourth_side: (vertices.i_th (4)).distance (vertices.i_th (1)) = side2

end

Для списка i_th(i) дает элемент в позиции i ( i -й элемент, следовательно это имя запроса).

Так как RECTANGLE является наследником класса POLYGON, то все компоненты родительского класса применимы и к новому классу: vertices, rotate, translate, perimeter (в переопределенном виде) и все остальные. Их не нужно повторять в определении нового класса.

Этот процесс транзитивен: всякий класс, будучи наследником RECTANGLE, например, SQUARE, также обладает всеми компонентами класса POLYGON.

 

Основные соглашения и терминология

Кроме терминов "наследник" и "родитель" будут полезны следующие термины:

Терминология наследования

Потомок класса C - это любой класс, который наследует C явно или неявно, включая и сам класс C. (Формально, это либо C, либо, по рекурсии, потомок некоторого наследника C).

Собственный потомок класса C - это потомок, отличный от самого C.

Предок C - это такой класс A, для которого C является потомком. Собственный предок C - это такой класс A, для которого C является собственным потомком.

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

Имеется также терминология для компонентов класса: компонент либо является наследуемым (перешедшим от некоторого собственного предка), либо непосредственным (введенным в данном классе).

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

Рис. 14.1.  Связь по наследованию

Переопределяемый компонент отмечается ++ (это соглашение принято в Business Object Notation (B.O.N.)).

Стрелка указывает вверх от наследника к родителю. Это соглашение легко запомнить - оно представляет отношение "наследовать от". В литературе встречается и обратное направление таких стрелок. Хотя обычно выбор графического представления является делом вкуса, в данном случае, одно из них явно лучше другого, поскольку одно наводит на мысль о правильном отношении, а другое может привести к путанице. Стрелка - это не просто произвольная пиктограмма, она указывает на одностороннюю связь между своими двумя концами. В данном случае:

[x]. Всякий экземпляр наследника можно рассматривать как экземпляр родителя, а обратное неверно.

[x]. В тексте наследника всегда упоминается его родитель, но не наоборот. Это, на самом деле, является важным свойством ОО-метода, вытекающим из принципа Открыт-Закрыт, согласно которому класс не "знает" списка своих наследников и других собственных потомков.

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

 

Наследование инварианта

Хотелось бы указать инвариант класса RECTANGLE, который говорил бы, что число сторон прямоугольника равно четырем и что длины сторон последовательно равны side1, side2, side1 и side2.

У класса POLYGON также имеется инвариант, который применим и к его наследнику:

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

Инвариант класса является конъюнкцией утверждений из его раздела invariant и свойств инвариантов его родителей (если таковые имеются).

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

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

В нашем примере второе предложение (at_least_three) инварианта POLYGON утверждает, что число сторон должно быть не менее трех, оно является следствием предложения four_sides из инварианта класса RECTANGLE, которое требует, чтобы сторон было ровно четыре.

 

Наследование и конструкторы

Ранее не показанная процедура создания (конструктор) для класса POLYGON может иметь вид

make_polygon (vl: LINKED_LIST [POINT]) is

-- Создание по вершинам из vl.

require

vl.count >= 3

do

...Инициализация представления многоугольника по элементам из vl ...

ensure

-- vertices и vl состоят из одинаковых элементов (это можно выразить

формально)

end

Эта процедура берет список точек, содержащий по крайней мере три элемента, и использует его для создания многоугольника.

Ей дано собственное имя make_polygon , чтобы избежать конфликта имен при ее наследовании классом RECTANGLE , у которого имеется собственная процедура создания make . Мы не рекомендуем так делать в общем случае, в следующей лекции будет показано, как давать процедуре создания класса POLYGON стандартное имя make , а затем использовать переименование в предложении о наследовании класса RECTANGLE , чтобы предотвратить коллизию имен.

Приведенная выше процедура создания класса RECTANGLE имеет четыре аргумента: точку, служащую центром, длины двух сторон и ориентацию. Отметим, что компонент vertices применим к прямоугольникам, поэтому процедура создания для RECTANGLE создает список вершин vertices (четыре угла вычисляются по центру, длинам сторон и ориентации).

Общая процедура создания для многоугольников не удобна прямоугольникам, так как приемлемы только списки из четырех элементов, удовлетворяющих инварианту класса RECTANGLE. Процедура создания для прямоугольников, в свою очередь, не годится для произвольных многоугольников. Это обычное дело: процедура создания родителя не подходит для наследника. Нельзя гарантировать, что она будет удовлетворять его новому инварианту.

Например, если у наследника имеются новые атрибуты, то процедуре создания нужно будет их инициализировать, для чего потребуются дополнительные аргументы. Отсюда общее правило:

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

При наследовании свойство процедуры быть конструктором не сохраняется.

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

В некоторых случаях родительский конструктор подходит и для наследника. Тогда его просто нужно указать в предложении creation:

class B inherit

A

creation

make

feature

...

где процедура make наследуется без изменений от класса A, у которого она также указана в предложении creation.

 

Пример иерархии

В конце обсуждения полезно рассмотреть пример POLYGON-RECTANGLE в контексте более общей иерархии типов геометрических фигур.

Рис. 14.2.  Иерархия типов фигур

Фигуры разбиты на замкнутые и незамкнутые. Примером замкнутой фигуры кроме многоугольника является также эллипс, а частным случаем эллипса является круг.

Рядом с классами указаны их разные компоненты. Символ "++" означает "переопределено", а символы "+" и "*" будут объяснены далее.

Ранее для простоты RECTANGLE был наследником класса POLYGON. Поскольку указанная классификация основана на числе вершин, то представляется разумным ввести промежуточный класс QUADRANGLE для четырехугольников на том же уровне, что и классы TRIANGLE, PENTAGON и т. п. Тогда компонент diagonal (диагональ) можно переместить на уровень класса QUADRANGLE.

Отметим, что класс SQUARE, наследник класса RECTANGLE, характеризуется инвариантом side1 = side2. Аналогично, у эллипса имеются два фокуса, а у круга они сливаются в один, что определяет инвариант класса CIRCLE: equal (focus1 = focus2).

 

Полиморфизм

 

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

 

Полиморфное присоединение

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

Предположим, что для структуры наследования на рисунке вверху объявлены следующие сущности:

p: POLYGON; r: RECTANGLE; t: TRIANGLE

Тогда допустимы следующие присваивания:

p := r

p := t

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

Такие присваивания, в которых тип источника (правой части) отличен от типа цели (левой части), называются полиморфными присваиваниями. Сущность, входящая в полиморфное присваивание слева (в примере это p), является полиморфной сущностью.

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

Приведенные в примере полиморфные присваивания легитимны, поскольку структура наследования позволяет рассматривать экземпляр класса RECTANGLE или TRIANGLE как экземпляр класса POLYGON. Мы говорим, что в таком случае тип источника согласован с типом цели. В обратном направлении присваивание недопустимо, т.е. некорректно писать r := p. Вскоре это важное правило будет рассмотрено более подробно.

Кроме присваивания, полиморфизм имеет место и при передаче аргументов, например в вызовах вида f (r) или f (t) при условии объявлении компонента f в виде:

f (p: POLYGON) is do ... end

Напомним, что присваивание и передача аргументов имеют одинаковую семантику, и оба называются присоединением (attachment). Когда источник и цель имеют разные типы, можно говорить о полиморфном (polymorphic) присоединении.

 

Что на самом деле происходит при полиморфном присоединении?

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

Рис. 14.3.  Полиморфное присоединение ссылки

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

Полиморфные присоединения допускаются только для целей типа ссылки, но, ни в коем случае, для расширенных типов. Поскольку у класса-потомка могут быть новые атрибуты, то соответствующие ему экземпляры могут иметь больше полей. На видно, что объект класса RECTANGLE больше, чем объект класса POLYGON. Такая разница в размерах объектов не приводит к проблемам, если все, что заново присоединяется, имеет тип ссылки. Но если p - не ссылка, а имеет развернутый тип (например, объявлена как expanded POLYGON), то значением p является непосредственно некоторый объект, и всякое присваивание p будет менять содержимое этого объекта. В этом случае никакой полиморфизм невозможен.

 

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

Рассмотрим массив многоугольников:

poly_arr: ARRAY [POLYGON]

Когда некоторое значение x присваивается элементу этого массива, как в вызове

poly_arr.put (x, some_index)

(для некоторого допустимого значения индекса some_index), то спецификация класса ARRAY указывает, что тип присваиваемого значения должен быть согласован с типом фактического родового параметра:

class ARRAY [G] creation

...

feature - Изменение элемента

put (v: G; i: INTEGER) is

-- Присвоить v элементу с индексом i

...

end

Так как тип формального аргумента v, соответствующего x, в классе определен как G, а фактический родовой параметр, соответствующий G в вызове poly_arr, - это POLYGON, то тип x должен быть согласован с ним. Как мы видели, для этого x не обязан иметь тип POLYGON, подойдет любой потомок типа POLYGON.

Поэтому, если границы массива равны 1 и 4, то можно объявить некоторые сущности:

p: POLYGON; r: RECTANGLE; s: SQUARE; t: TRIANGLE

и, создав соответствующие объекты, можно выполнить операции

poly_arr.put (p, 1)

poly_arr.put (r, 2)

poly_arr.put (s, 3)

poly_arr.put (t, 4)

которые присвоят элементам массива ссылки на объекты различных типов.

Рис. 14.4.  Полиморфный массив

На этом рисунке графические объекты представлены соответствующими геометрическими фигурами, а не обычными диаграммами объектов с набором их полей.

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

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

Рис. 14.5.  Измерения обобщения

Типы, которые на неформально назывались SET_OF_BOOKS и т. п., заменены типами, выведенными из родового универсального типа, - SET [BOOK].

Такая комбинация универсальности и наследования является весьма сильным средством. Оно позволяет описывать структуру объектов с нужной степенью общности. Например,

LIST [RECTANGLE]: может содержать квадраты, но не треугольники.

LIST [POLYGON]: может содержать квадраты, прямоугольники, треугольники, но не круги.

LIST [FIGURE]: может содержать экземпляры любого типа из иерархии FIGURE, но не книги или банковские счета.

LIST [ANY]: может содержать объекты любого типа.

В последнем случае использован класс ANY, который условимся считать предком любого класса (он будет подробнее рассмотрен далее).

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

 

Типизация при наследовании

 

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

 

Согласованность типов

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

p: POLYGON

r: RECTANGLE

Выделим в приведенной выше иерархии нужный фрагмент ().

Тогда законны следующие выражения:

[x]. p.perimeter: никаких проблем, поскольку perimeter определен для многоугольников;

[x]. p.vertices, p.translate (...), p.rotate (...) с корректными аргументами;

[x]. r.diagonal, r.side1, r.side2: эти три компонента объявлены на уровне RECTANGLE или QUADRANGLE;

[x]. r.vertices, r.translate (...), r.rotate (...): эти компоненты объявлены на уровне POLYGON или еще выше и поэтому применимы к прямоугольникам, наследующим все компоненты многоугольников;

[x]. r.perimeter: то же, что и в предыдущем случае. Но у вызываемой здесь функции имеется новое определение в классе RECTANGLE, так что она отличается от функции с тем же именем из класса POLYGON.

Рис. 14.6.  Фрагмент иерархии геометрических фигур

А следующие вызовы компонентов незаконны, так как эти компоненты недоступны на уровне многоугольника:

p.side1

p.side2

p.diagonal

Это рассмотрение основано на первом фундаментальном правиле типизации:

Правило Вызова Компонентов

Если тип сущности x основан на классе С, то в вызове компонента x.f сам компонент f должен быть определен в одном из предков С.

Напомним, что класс С является собственным предком. Фраза "тип сущности x основан на классе С" напоминает, что для классов, порожденных из родовых, тип может включать не только имя класса: LINKED_LIST [INTEGER]. Но базовый класс для типа - это LINKED_LIST, так что родовой параметр никак не участвует в нашем правиле.

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

Статическая типизация - это один из главных ресурсов ОО-технологии для достижения объявленной в 1-ой лекции цели - надежности ПО.

Уже отмечалось, что не все подходы к построению ОО-ПО имеют статическую типизацию. Наиболее известным представителем языков с динамической типизацией является Smalltalk, в котором не действует статическое правило вызова, но допускается, чтобы вычисление аварийно завершалось в случае возникновения ошибки: "сообщение не понятно". В лекции, посвященной типизации, будет приведено сравнение разных подходов.

 

Пределы полиморфизма

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

Все примеры полиморфных присваиваний, такие, как p := r и p := t, в качестве типа источника используют потомков класса-цели. Скажем, что в таком случае тип источника согласован с классом цели. Например, SQUARE согласован с RECTANGLE и с POLYGON, но не с TRIANGLE. Чтобы уточнить это понятие, дадим формальное определение:

Определение: согласованность

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

Почему недостаточно понятия потомка в этом определении? Причина снова в том, что допускается порождение из родовых классов, поэтому приходится различать типы и классы. Для каждого типа имеется базовый класс, который при отсутствии порождения совпадает с самим типом (например, POLYGON является базовым для себя). При этом для универсально порожденного класса базовым является универсальный класс с опущенными родовыми параметрами. Например, для класса LIST [POLYGON] базовым будет класс LIST. Вторая часть определения говорит о том, что B [Y] будет согласован с A [X], если B является потомком A, а Y - потомком X.

Заметим, что поскольку каждый класс является собственным потомком, то каждый тип согласован сам с собой.

При таком обобщении понятия потомка получаем второе важное правило типизации:

Правило согласования типов

Присоединение к источнику y цели x (т. е. присваивание x:=y или использование y в качестве фактического параметра в вызове процедуры с соответствующим формальным параметром x) допустимо только тогда, когда тип y согласован с типом x.

Правило согласования типов выражает тот факт, что специальное можно присваивать общему, но не наоборот. Поэтому присваивание p := r допустимо, а r := p нет.

Это правило можно проиллюстрировать следующим образом. Предположим, что я настолько ненормален, что послал в компанию Любимцы-По-Почте заказ на "Animal" ("Животное"). В этом случае, что бы я ни получил: собаку, божью коровку или дельфина-касатку, у меня не будет права пожаловаться. (Предполагается, что DOG и все прочие являются потомками класса ANIMAL ). Но если я заказал собаку, а почтальон принес мне утром коробку с надписью ANIMAL, или, например, MAMMAL (млекопитающее), то я имею право вернуть ее отправителю, даже если из нее доносится недвусмысленный лай и тявканье. Поскольку мой заказ не был исполнен в соответствии со спецификацией, я ничего не должен фирме Любимцы-По-Почте.

 

Экземпляры

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

Определение: прямой экземпляр, экземпляр

Прямой экземпляр класса C - это объект, созданный в соответствии с точным определением C с помощью команды создания create x ..., в которой цель x имеет тип C (или, рекурсивно, путем клонирования прямого экземпляра C).

Экземпляр C - это прямой экземпляр потомка C.

Из последней части этого определения следует, что прямой экземпляр класса C является также экземпляром C, так как класс входит во множество своих потомков.

Таким образом, выполнение фрагмента:

p1, p2: POLYGON; r: RECTANGLE

...

create p1 ...; create r ...; p2 := r

создаст два экземпляра класса POLYGON, но лишь один прямой экземпляр (тот, который присоединен к p1). Другой объект, на который указывают p2 и r, является прямым экземпляром класса RECTANGLE, а следовательно, экземпляром обоих классов POLYGON и RECTANGLE.

Хотя понятия прямого экземпляра и экземпляра определены выше для классов, они естественно распространяются на любые типы (с базовым классом и возможными родовыми параметрами).

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

Статико-динамическая согласованность типов

Сущность типа T может во время исполнения прикрепляться только к экземплярам класса T.

 

Статический тип, динамический тип

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

Таким образом, при объявлении p: POLYGON статический тип ссылки, обозначенной p, есть POLYGON, после выполнения create p динамическим типом этой ссылки также является POLYGON, а после присваивания p := r, где r имеет тип RECTANGLE и не пусто, динамическим типом становится RECTANGLE.

Правило согласования типов утверждает, что динамический тип всегда должен соответствовать статическому типу.

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

У объекта имеется только динамический тип, который он получил в момент создания. Этот тип во время жизни объекта не изменяется.

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

Только у сущности имеются и статический, и динамический типы. Ее статический тип - это тип, с которым она была объявлена: если объявление имеет вид x: T, то этим типом будет T. Ее динамический тип в каждый момент выполнения - это тип значения этой ссылки, т.е. того объекта, к которому она присоединена.

В развернутом случае нет ссылки, значением x является объект типа T , и T является и статическим типом и единственно возможным динамическим типом для x .

 

Обоснованы ли ограничения?

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

1

p:= r; r := p

2

p := r; x := p.diagonal

В (1) запрещается присваивать многоугольник сущности-прямоугольнику, хотя во время выполнения так получилось, что этот многоугольник является прямоугольником (аналогично тому, как можно отказаться принять собаку из-за того, что на клетке написано "животное"). В (2) компонент diagonal оказался не применим к p несмотря на то, что во время выполнения он, фактически, присутствует.

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

На практике, случаи вида (1) и (2) маловероятны. Присваивания типа p:= r обычно встречаются внутри некоторых управляющих структур, которые зависят от условий, определяемых во время выполнения, например, от ввода данных пользователем. Более реалистичная полиморфная схема может выглядеть так:

create r.make (...); ...

screen.display_icons -- Вывод значков для разных многоугольников

screen.wait_for_mouse_click -- Ожидание щелчка кнопкой мыши

x := screen.mouse_position -- Определение места нажатия кнопки

chosen_icon := screen.icon_where_is (x) -- Определение значка,

-- на котором находится указатель мыши

if chosen_icon = rectangle_icon then

p := r

elseif ...

p := "Многоугольник другого типа" ...

end

... Использование p, например, p.display, p.rotate, ...

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

В другом типичном случае p просто является формальным параметром процедуры:

some_routine (p: POLYGON) is ...

и можно выполнять вызов some_routine (r), корректный в соответствии с правилом согласования типов. Но при написании процедуры об этом вызове еще ничего не известно. На самом деле, вызов some_routine (t) для t типа TRIANGLE или любого другого потомка класса POLYGON будет также корректен, таким образом, можно считать, что p представляет некоторый вид многоугольников - любой из их видов. Тогда вполне разумно, что к p применимы только компоненты класса POLYGON.

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

 

Может ли быть польза от неведения?

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

Если вы все еще испытываете неудобство от невозможности написать p.diagonal после присваивания p :=r (в случае (2)), то вы не одиноки. Это шокирует многих людей, когда они впервые сталкиваются с этими понятиями. Мы знаем, что p - это прямоугольник, почему же у нас нет доступа к его диагонали? По той причине, что это было бы бесполезно. После полиморфного присваивания, как показано на следующем фрагменте из предыдущего рисунка, один и тот же объект типа RECTANGLE имеет два имени: имя многоугольника p и прямоугольника r.

Рис. 14.7.  После полиморфного присваивания

В таком случае, поскольку известно, что объект O2 является прямоугольником и доступен через имя прямоугольника r, зачем пытаться использовать доступ к его диагонали посредством операции p.diagonal? Это не имеет смысла, так как можно просто написать r.diagonal, использовав официальное имя прямоугольника и сняв все сомнения в правомерности применения его операций. Использование имени многоугольника p, которое может с тем же успехом обозначать треугольник, ничего не дает и приводит к неопределенности.

Действительно, полиморфизм теряет информацию: когда в результате присваивания p :=r появляется возможность ссылаться на прямоугольник O2 через имя многоугольника p, то теряется нечто важное - возможность использовать специфические компоненты прямоугольника. В чем тогда польза? В данном случае - ни в чем. Как уже отмечалось, интерес возникает, когда заранее неизвестно, каков будет вид многоугольника p после выполнения команды if some_condition then p:= r else p := something_else ... или когда p является формальным аргументом процедуры и неизвестно, каков будет тип фактического аргумента. Но в этих случаях было бы некорректно и опасно применять к p что-либо кроме компонентов класса POLYGON.

Продолжая тему животных, представим, что некто спрашивает: "У вас есть домашний любимец?" и вы отвечаете: "Да, кот!". Это похоже на полиморфное присваивание - один объект известен под двумя именами разных типов: " мой_домашний_любимец " и " мой_кот " обозначают сейчас одно животное. Но они не служат одной цели, первое имя является менее информативным, чем второе. Можно одинаково успешно использовать оба имени при звонке в отдел отсутствующих хозяев компании Любимцы-По-Почте ("Я собираюсь в отпуск, сколько будет стоить наблюдение за моим_домашним_любимцем (или: моим_котом) в течение двух недель?") Но при звонке в другой отдел с вопросом: "Могу ли я привезти во вторник моего домашнего любимца, чтобы отстричь когти?", вы не запишетесь на прием, пока не уточните, что имели в виду своего кота.

 

Когда хочется задать тип принудительно

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

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

В таких случаях требуется новый механизм - попытка присваивания, который позволит писать команду вида r ?= p (где ?= обозначает символ попытки присваивания, в отличие от := для обычного присваивания), означающую "выполнить присваивание, если тип объекта соответствует r, а иначе сделать r пустым". Но мы пока не готовы понять, как такая команда сочетается с ОО-методом, поэтому вернемся к этому вопросу в следующих лекциях. (А до того, считайте, что вы ничего об этом не читали).

 

Полиморфное создание

Введение наследования и полиморфизма приводит к небольшому расширению механизма создания объектов, который позволит непосредственно создавать объекты типов-потомков.

Напомним, что команды создания (процедуры-конструкторы) имеют один из следующих видов:

create x

create x.make (...)

где вторая форма подразумевает и требует, чтобы базовый класс для типа T, приписанного x, содержал предложение creation, в котором make указана как одна из процедур-конструкторов. (Разумеется, процедура создания может иметь любое имя, - make рекомендуется по умолчанию). Результатом выполнения первой команды является создание нового объекта типа T, его инициализация значениями, заданными по умолчанию, и его присоединение к x. А при выполнении второй инструкции для создания и инициализации объекта будет вызываться make с заданными аргументами.

Предположим, что у T имеется собственный потомок U. Мы можем захотеть использовать x полиморфно и присоединить сразу к прямому экземпляру U, а не к экземпляру T. Возможное решение использует локальную сущность типа U.

some_routine (...) is

local

u_temp: U

do

...; create u_temp.make (...); x := u_temp; ...

end

Это работает, но чересчур громоздко, особенно в контексте многозначного выбора, когда захочется присоединить x к экземпляру одного из нескольких возможных типов наследников. Локальные сущности (u_temp в нашем примере) играют только временную роль, их объявления и присваивания загромождают текст программы. Поэтому нужны специальные варианты конструкторов:

create {U} x

create {U} x.make (...)

Результат должен быть тот же, что и у конструкторов create, приведенных выше, но создаваемый объект должен являться прямым экземпляром U, а не T. Этот вариант должен удовлетворять очевидному ограничению: тип U должен быть согласован с типом T, а во второй форме make должна быть определена как процедура создания в классе, базовом для U, и если этот класс имеет одну или несколько процедур создания, то применима лишь вторая форма. Заметим, что здесь не важно, имеет ли сам класс T процедуры создания, - все зависит только от U.

Типичное применение связано с созданием экземпляра одного из нескольких возможных типов:

f: FIGURE

...

"Вывести значки фигур"

if chosen_icon = rectangle_icon then

create {RECTANGLE} f

elseif chosen_icon = circle_icon then

create {CIRCLE} f

else

...

end

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

Для формы с неявным типом create x ... тип при создании есть тип x.

Для формы с явным типом create {U} x ... тип при создании есть U.

 

Динамическое связывание

 

Динамическое связывание дополнит переопределение, полиморфизм и статическую типизацию, создавая базисную тетралогию наследования.

 

Использование правильного варианта

Операции, определенные для всех вариантов многоугольников, могут реализовываться по-разному. Например, perimeter (периметр) имеет разные версии для общих многоугольников и для прямоугольников, назовем эти версии perimeterPOL и perimeterRECT. У класса SQUARE также будет свой вариант (умноженная на 4 длина стороны). При этом естественно возникает важный вопрос: что случится, если программа, имеющая разные версии, будет применена к полиморфной сущности?

Во фрагменте

create p.make (...); x := p.perimeter

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

create r.make (...); x := r.perimeter

будет использована версия perimeterRECT. Но что, если полиморфная сущность p статически объявлена как многоугольник, а динамически ссылается на прямоугольник? Предположим, что нужно выполнить фрагмент:

create r.make (...)

p := r

x := p.perimeter

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

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

-- Вычислить периметр фигуры выбранной пользователем

p: POLYGON

...

if chosen_icon = rectangle_icon then

create {RECTANGLE} p.make (...)

elseif chosen_icon = triangle_icon then

create {TRIANGLE} p.make (...)

elseif

...

end

...

x := p.perimeter

или после условного полиморфного присваивания if ... then p := r elseif ... then p := t ..., ; или если p является элементом полиморфного массива многоугольников, или если p является формальным аргументом с объявленным типом POLYGON некоторой процедуры, которой вызвавшая ее процедура передала фактический аргумент согласованного типа?

Тогда в зависимости от хода вычисления динамическим типом p будет RECTANGLE, или TRIANGLE, или т.п. У нас нет никакого способа узнать, какой из этих случаев будет иметь место. Но, благодаря динамическому связыванию, этого и не нужно знать: что бы ни случилось с p, при вызове будет выполнен правильный вариант компонента perimeter.

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

Динамическое связывание позволяет завершить начатое выше обсуждение аспектов, связанных с потерей информации при полиморфизме. Сейчас стало понятно, почему не страшно потерять информацию об объекте: после присваивания p := q или вызова some_routine (q), в котором p являлся формальным аргументом, теряется специфическая информация о типе q, но если применяется операция p.polygon_feature, для которой polygon_feature имеет специальную версию, применимую к q, то будет выполняться именно эта версия.

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

 

Переопределение и утверждения

Если клиент класса POLYGON вызывает p.perimeter, то он ожидает получить значение периметра p, определенное спецификацией функции perimeter в определении этого класса. Но теперь, благодаря динамическому связыванию, клиент может вызвать другую программу, переопределенную в некотором классе-потомке. В классе RECTANGLE переопределение улучшает эффективность и не изменяет результат, но что помешало бы переопределить периметр так, чтобы новая версия вычисляла бы, скажем, площадь?

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

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

 

О реализации динамического связывания

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

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

 

Отложенные компоненты и классы

 

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

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

Отложенные компоненты и классы обеспечивают необходимый механизм абстракции.

 

Движения произвольных фигур

Чтобы понять необходимость в отложенных процедурах и классах, снова рассмотрим иерархию фигур FIGURE.

Рис. 14.8.  Снова иерархия FIGURE

Наиболее общим понятием здесь является FIGURE. Основываясь на механизмах полиморфизма и динамического связывания, можно попытаться применить описанную ранее общую схему:

transform (f: FIGURE) is

-- Применить специфическое преобразование к f.

do

f.rotate (...)

f.translate (...)

end

с соответствующими значениями опущенных аргументов. Тогда все следующие вызовы корректны:

transform (r) -- для r: RECTANGLE

transform (c) -- для c: CIRCLE

transform (figarray.item (i)) -- для массива фигур: ARRAY [POLYGON]

Иными словами, требуется применить преобразования rotate и translate к фигуре f и предоставить механизму динамического связывания выбор подходящей версии (различной для классов RECTANGLE и CIRCLE), зависящей от текущего вида фигуры f, который выяснится во время выполнения.

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

Но переопределять-то нечего! Класс FIGURE - это очень общее понятие, покрывающее все виды двумерных фигур. Ясно, что невозможно написать версию процедур rotate и translate, подходящую для всех фигур "вообще", не уточнив информацию об их виде.

Таким образом, мы имеем ситуацию, в которой процедура transform будет выполняться корректно, благодаря динамическому связыванию, но статически она незаконна, поскольку rotate и translate не являются компонентами класса FIGURE. Проверка типов выявит в вызовах f.rotate и f.translate ошибки.

Можно, конечно, ввести на уровне класса FIGURE процедуру rotate, которая ничего не будет делать. Но это опасный путь, компоненты rotate (center, angle) имеют интуитивно хорошо понятную семантику и "ничего не делать" не является их разумной реализацией.

 

Отложенный компонент

Таким образом, нужен способ спецификации компонентов rotate и translate на уровне класса FIGURE, который возлагал бы обязанность по их фактической реализации на потомков этого класса. Это достигается объявлением этих компонентов как "отложенных". При этом вся часть тела процедуры с командами заменяется ключевым словом deferred. В классе FIGURE будет объявление:

rotate (center: POINT; angle: REAL) is

-- Повернуть на угол angle вокруг точки center.

deferred

end

и аналогично будет объявлен компонент translate. Это означает, что этот компонент известен в том классе, где появилось такое объявление, но его реализации находятся в классах - собственных потомках. В таком случае вызов вида f.rotate в процедуре transform становится законным.

Объявленный таким образом компонент называется отложенным компонентом. Компонент, не являющийся отложенным, - имеющий реализацию (например, любой из ранее встретившихся нам компонентов), называется эффективным.

 

Эффективизация компонента

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

class POLYGON inherit

CLOSED_FIGURE

feature

rotate (center: POINT; angle: REAL) is

-- Повернуть на угол angle вокруг точки center.

do

... Команды для поворота всех вершин ...

end

...

end

Заметим, что POLYGON наследует компоненты класса FIGURE не непосредственно, а через класс CLOSED_FIGURE, в котором процедура rotate остается отложенной.

Этот процесс обеспечения реализацией отложенного компонента называется эффективизацией (effecting). (Эффективный компонент - это компонент, снабженный реализацией.)

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

Задание реализации компонента, конечно, близко к его переопределению и, за исключением включения в предложении redefine, подчиняется тем же правилам. Поэтому нужен общий термин.

Определение: повторное объявление

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

Разница между этими двумя формами повторного объявления хорошо иллюстрируется примерами, приведенными при их определении:

[x]. При переходе от POLYGON к RECTANGLE компонент perimeter уже реализован у родителя, и мы хотим предложить новую его реализацию в классе RECTANGLE. Это переопределение. Заметим, что этот компонент еще раз переопределяется в классе SQUARE.

[x]. При переходе от FIGURE к POLYGON у родителя нет реализации компонента rotate, и мы хотим реализовать его в классе POLYGON. Это эффективизация. Собственные потомки POLYGON могут, конечно, переопределить эту эффективную версию.

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

Повторное объявление компонента к Повторное объявление компонента от
Отложенный Эффективный
Отложенный Переопределение Отмена определения
Эффективный Эффективизация Переопределение

Таблица 14.1.Эффекты повторного объявления

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

 

Отложенные классы

Как мы видели, компонент может быть отложенным или эффективным. То же относится и к классам.

Определение: отложенный класс, эффективный класс

Класс является отложенным, если у него имеется отложенный компонент.

В противном случае, класс является эффективным.

Таким образом, чтобы класс был эффективным, должны быть эффективными все его компоненты. Один или несколько отложенных компонентов делают класс отложенным. В этом случае класс должен содержать специальную метку:

Правило объявления отложенного класса

Объявление отложенного класса должно включать подряд идущие ключевые слова deferred class (в отличие от одного слова class для эффективных классов).

Поэтому класс FIGURE будет объявлен следующим образом:

deferred class FIGURE feature

rotate (...) is

... Объявления отложенных компонентов ...

... Объявления других компонентов ...

end

Обратно, если класс отмечен как отложенный, то у него должен быть хотя бы один отложенный компонент. При этом класс может быть отложенным, даже если в нем самом не объявлен ни один отложенный компонент, так как у него может быть отложенный родитель, от которого он унаследовал отложенный компонент, не ставший у него эффективным. В нашем примере в классе OPEN_FIGURE, скорее всего, останутся отложенными компоненты display, rotate и многие другие, унаследованные от класса FIGURE, поскольку понятие незамкнутой фигуры не настолько конкретизировано, чтобы поддерживать стандартные реализации этих операций. Поэтому этот класс является отложенным и будет объявлен как

deferred class OPEN_FIGURE inherit

FIGURE

...

даже если в нем самом не вводится ни один отложенный компонент.

Потомок отложенного класса является эффективным классом, если все отложенные компоненты его родителей имеют в нем эффективные определения и в нем не вводятся никакие собственные отложенные компоненты. Эффективные классы, такие как POLYGON и ELLIPSE, должны обеспечить реализацию отложенных компонентов display, rotate.

Для удобства мы будем называть тип отложенным, если его базовый класс является отложенным. Таким образом, класс FIGURE, рассматриваемый как тип, является отложенным. Если родовой класс LIST является отложенным (как это и должно быть, если он представляет понятие списка, не зависящее от реализации), то тип LIST [INTEGER] является отложенным. Учитывается только базовый класс: C [X] будет эффективным, если класс C эффективный, и отложенным, если C является отложенным, независимо от статуса X.

 

Соглашения о графических обозначениях

Сейчас можно полностью объяснить графические символы, использованные на . Звездочкой отмечаются отложенные компоненты или классы:

FIGURE*

display*

perimeter* -- На уровне класса OPEN_FIGURE на рис. 14.8

Знак плюс означает "эффективный" и им отмечается эффективизация компонента:

perimeter+ -- На уровне POLYGON на рис. 14.8

Чтобы указать, что класс эффективный, можно отметить его знаком +. По умолчанию, неотмеченный класс считается эффективным, так же как в текстовом виде объявление class C без ключевого слова deferred означает, что класс эффективный.

Можно присоединять одиночный плюс к компоненту для указания того, что он стал эффективным. Например, компонент perimeter появляется как отложенный и, следовательно, имеет вид perimeter * в классе CLOSED_FIGURE. Затем на уровне POLYGON для этого компонента дается реализация и он отмечается в этом классе как perimeter + .

Наконец, два знака плюс отмечают переопределение:

perimeter++ -- На уровне RECTANGLE и SQUARE на рис.14.8

 

Что делать с отложенными классами?

Присутствие отложенных элементов в системе вызывает вопрос: "что случится, если компонент rotate применить к объекту типа FIGURE?" или в общем виде - "можно ли применить отложенный компонент к прямому экземпляру отложенного класса?" Ответ может обескуражить: такой вещи как объект типа FIGURE не существует - прямых экземпляров отложенных классов не бывает.

Правило отсутствия экземпляров отложенных классов

Тип создания в процедуре создания не может быть отложенным.

Напомним, что тип создания - это тип x, для формы create x, и U для формы create {U} x. Тип считается отложенным, если таков его базовый класс.

Поэтому вызов конструктора create f некорректен и будет отвергнут компилятором, если типом f будет один из отложенных классов: FIGURE, OPEN_FIGURE, CLOSED_FIGURE. Это правило устраняет опасность ошибочных вызовов компонентов.

Отметим однако, что даже, если тип сущности f отложенный, то допустима явная форма процедуры создания - create{RECTANGLE} f , поскольку здесь типом создания является эффективный потомок FIGURE - класс RECTANGLE . Мы уже видели, как этот прием используется в многовариантной процедуре создания для объектов класса FIGURE , которые, в зависимости от контекста, будут экземплярами эффективных классов RECTANGLE , CIRCLE и др.

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

f: FIGURE

...

f := "Некоторое выражение эффективного типа, такого как CIRCLE или POLYGON"

...

f.rotate (some_point, some_angle)

f.display

...

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

 

Задание семантики отложенных компонентов и классов

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

Как и другие классы, отложенный класс может иметь инвариант, а у отложенного компонента может быть предусловие, постусловие или оба эти утверждения.

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

Рис. 14.9.  Список с курсором

Этот класс является отложенным:

indexing

description: "Линейные списки"

deferred class

LIST [G]

feature -- Access

count: INTEGER is

-- Число элементов

deferred

end

index: INTEGER is

-- Положение курсора

deferred

end

item: G is

-- Элемент в позиции курсора

deferred

end

feature - Отчет о статусе

after: BOOLEAN is

-- Курсор за последним элементом?

deferred

end

before: BOOLEAN is

-- Курсор перед первым элементом?

deferred

end

feature - Сдвиг курсора

forth is

-- Передвинуть курсор на одну позицию вперед.

require

not after

deferred

ensure

index = old index + 1

end

... Другие компоненты ...

invariant

non_negative_count: count >= 0

offleft_by_at_most_one: index >= 0

offright_by_at_most_one: index <= count + 1

after_definition: after = (index = count + 1)

before_definition: before = (index = 0)

end

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

Рис. 14.10.  Позиции курсора

Два последних предложения инварианта можно также представить в виде постусловий: ensure Result = (index = count + 1) для after и ensure Result = (index = 0) для before . Такой выбор всегда возникает при выражении свойств, включающих только запросы без аргументов. Я предпочитаю использовать предложения инварианта, рассматривая такие свойства как глобальные свойства класса, а не прикреплять их к конкретному компоненту.

Утверждения о forth точно выражают то, что должна делать эта процедура: передвигать курсор на одну позицию. Поскольку курсор должен оставаться в пределах списка элементов плюс две позиции "меток" слева и справа, то применение forth требует выполнения условия not after, а результатом будет, как сказано в постусловии, увеличение index на один.

Вот другой пример - наш старый друг стек. Нашей библиотеке потребуется общий класс STACK [G], который будет отложенным, так как он должен покрывать всевозможные реализации. Его собственные потомки, такие как FIXED_STACK и LINKED_STACK, будут описывать конкретные реализации. Одной из отложенных процедур класса STACK является put:

put (x: G) is

-- Поместить x на вершину.

require

not full

deferred

ensure

not_empty: not empty

pushed_is_top: item = x

one_more: count = old count + 1

end

Булевские функции empty и full (также отложенные на уровне STACK) выражают свойство стека быть пустым и заполненным.

Только с помощью утверждений отложенные классы достигают своей полной силы. Как уже отмечалось (хотя детали появятся через две лекции), предусловия и постусловия применимы ко всем переопределениям процедуры. Это особенно важно в отложенном случае: в нем такие утверждения будут ограничивать все допустимые реализации. Таким образом, приведенная спецификация ограничивает все варианты put в потомках класса STACK.

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

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

 

Способы изменения объявлений

 

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

[x]. Возможность изменить объявление функции на атрибут.

[x]. Простой способ сослаться на первоначальную версию в теле нового определения.

 

Повторное объявление функции как атрибута

Повторные объявления позволяют активно применять один из центральных принципов модульности - принцип Унифицированного Доступа (Uniform Access).

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

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

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

Наш прежний пример хорошо подходит для иллюстрации. Пусть имеется класс ACCOUNT1:

class ACCOUNT1 feature

balance: INTEGER is

-- Текущий баланс

do

Result := list_of_deposits.total - list_of_withdrawals.total

end

...

End

Тогда в потомке может быть выбрана вторая реализация из нашего первоначального примера, переопределяющая balance как атрибут:

class ACCOUNT2 inherit

ACCOUNT1

redefine balance end

feature

balance: INTEGER

-- Текущий баланс

...

end

По-видимому, в классе ACCOUNT2 нужно будет переопределить некоторые процедуры, такие как withdraw и deposit, чтобы, кроме других своих обязанностей они еще модифицировали нужным образом balance, сохраняя в качестве инварианта свойство: balance = list_of_deposits.total - list_of_withdrawals.total.

В этом примере новое объявление является переопределением. Его результатом может также оказаться превращение отложенного компонента в атрибут. Например, пусть в отложенном классе LIST имеется компонент

count: INTEGER is

-- Число вставленных элементов

deferred

end

Тогда в реализации списка этот компонент может быть реализован как атрибут:

count: INTEGER

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

Переобъявление функции как атрибута, объединенное с полиморфизмом и динамическим связыванием, приводят к полной реализации принципа Унифицированного Доступа. Сейчас можно не только реализовать запрос клиента вида a.service либо через память, либо посредством вычисления, но один и тот же запрос в процессе одного вычисления может в одних случаях запустить доступ к некоторому полю, а в других - вызвать некоторую функцию. Это может, в частности, случиться при выполнении одного и того же вызова a.balance, если по ходу вычисления a будет полиморфно присоединяться к объектам разных классов.

 

Обратного пути нет

Можно было бы ожидать, что допустимо и обратное переопределение атрибута в функцию без аргументов. Но нет. Присваивание - операция применимая к атрибутам, - становится бессмысленной для функций. Предположим, что a - это атрибут класса C, и некоторая подпрограмма содержит команду

a := some_expression

Если потомок C переопределит a как функцию, то эта функция будет не применима, поскольку нельзя использовать функцию в левой части присваивания.

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

 

Использование исходной версии при переопределении

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

Например, класс BUTTON, наследник класса WINDOW, может переопределить компонент display, рисующий кнопку, так чтобы вначале рисовалось окно, а затем появлялась рамка:

class BUTTON inherit

WINDOW

redefine display end

feature -- Вывод

display is

-- Изобразить как кнопку.

do

"Изобразить как нормальное окно"; -- См. ниже

draw_border

end

... Другие компоненты ...

end

где draw_border - это процедура нового класса. Для того чтобы "Изобразить как нормальное окно", нужно вызвать исходную версию display, технически известную как precursor (предшественник) процедуры draw_border.

Это достаточно общий случай, и желательно ввести для него специальное обозначение. Конструкцию

Precursor

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

Поэтому в последнем примере часть "Изобразить как нормальное окно" можно записать просто как

Precursor

Это будет означать вызов исходной версии этой процедуры из класса WINDOW, допустимый при переопределении процедуры классом-наследником WINDOW. Precursor - это зарезервированное имя сущности такое же, как Result или Current, и оно так же пишется курсивом с заглавной первой буквой.

В данном примере переопределяемый компонент является процедурой и поэтому вызов конструкции Precursor - это команда. Этот же вызов может участвовать при переопределении функции в выражении:

some_query (n: INTEGER): INTEGER is

-- Значение, возвращаемое версией родителя, если оно

-- положительно, иначе ноль

do

Result := (Precursor (n)).max (0)

end

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

 

Смысл наследования

 

Мы уже рассмотрели основные способы наследования. Многое еще предстоит изучить, в частности, множественное наследование и детали того, что происходит с утверждениями в контексте наследования (понятие субконтрактов).

Но вначале следует поразмышлять над этими фундаментальными понятиями и выяснить их значение для вопроса о качестве ПО и для процесса разработки ПО.

 

Двойственная перспектива

По-видимому, нигде двойственная роль классов как модулей, с одной стороны, и типов - с другой, не проявляется так отчетливо, как при изучении наследования. При взгляде на класс, как на модуль, наследник описывает расширение модуля-родителя, а при взгляде на него, как на тип, он описывает подтип типа родителя.

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

Рис. 14.11.  Механизмы наследования и их роль

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

 

Взгляд на класс как на модуль

С этой точки зрения наследование особенно эффективно в качестве метода повторного использования.

Модуль это множество служб, предлагаемых внешнему миру. Без наследования каждому новому модулю пришлось бы самому определять все предоставляемые им службы. Конечно, реализации этих служб могут основываться на службах, предоставляемых другими модулями: это и есть цель отношения "быть клиентом". Но единственным способом определить новый модуль является добавление новых служб к ранее определенным модулям.

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

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

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

[x]. Закрытой, поскольку клиентам для выполнения их собственной разработки нужны службы модуля и, будучи один раз зафиксированы в некоторой его версии, они не должны изменяться при введении новых служб, в которых клиент не нуждается.

[x]. Открытой, так как нет никакой гарантии, что с самого начала в модуль были включены все службы, потенциально необходимые некоторому клиенту.

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

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