имеет тот же НОД, что и пара variant x.max (y) until x = y loop if x > y then x := x - y else y := y - x end end
Как отмечалось, предложения invariant и variant являются возможными. Когда они присутствуют, то помогают прояснить цель цикла и проверить его корректность.Для любого не тривиального цикла характерны интересные варианты и инварианты; многие из примеров в последующих лекциях включают варианты и инварианты, обеспечивая глубокое понимание корректности лежащих в основе алгоритмов.
Связывание с АТД
Класс, как неоднократно говорилось, является реализацией АТД, заданного формальной спецификацией или неявно подразумеваемого. В начале лекции отмечалось, что утверждения можно рассматривать, как способ введения в класс семантических свойств, лежащих в основе АТД. Давайте уточним наше понимание концепции утверждений, прояснив их связь с компонентами спецификации АТД.
Толерантные модули
(При первом чтении этот раздел можно опустить или ограничиться его беглым просмотром.)
Простые, но не защищенные модули могут быть не достаточно устойчивыми для использования их у произвольных клиентов. В таких случаях возникает необходимость создания нескольких классов, играющих роль фильтров. В отличие от ранее рассмотренных фильтров, устанавливаемых между внешним миром и обрабатывающими модулями, новые фильтры будут устанавливаться между "беспечными" клиентами с одной стороны и незащищенными классами с другой стороны.
Хотя было показано, что обычно это не лучший подход к проектированию, полезно рассмотреть, как выглядят классы, если использовать толерантный стиль в некоторых особых случаях. Класс STACK3, представленный ниже, иллюстрирует эту идею.
Поскольку классу понадобятся целочисленные коды ошибок, удобно для этой цели использовать ранее не введенную нотацию "unique" для целочисленных констант. Если объявить множество атрибутов следующим образом:
a, b, c, ...: INTEGER is unique
то в результате этого объявления a, b, c получат последовательно идущие целочисленные значения. Эти значения будут даваться компилятором с гарантией того, что все объявленные таким образом константы получат различные значения (будут уникальными). По принятому соглашению, всем объявляемым таким образом константам даются имена, начинающиеся с буквы в верхнем регистре и с остальными символами в нижнем регистре, например Underflow.
Вот написанная в этом стиле толерантная версия нашего класса стек. Заметьте, что этот текст, возможно пропущенный при первом чтении, включен только для понимания толерантного стиля. Он не является примером рекомендуемого стиля проектирования по причинам, обсуждаемым ниже, но которые достаточно ясны при просмотре этого текста.
indexing description: "Стеки: Структуры с политикой доступа Last-In, First-Out % %Первый пришел - Последний ушел, с фиксированной емкостью; % %толерантная версия, устанавливающая код ошибки в случае % %недопустимых операций." class STACK3 [G] creation make feature - Initialization (Инициализация) make (n: INTEGER) is -- Создать стек, содержащий максимум n элементов, если n > 0; -- в противном случае установить код ошибки равным Negative_size. -- Без всяких предусловий! do if capacity >= 0 then capacity := n create representation.make (capacity) else error := Negative_size end ensure error_code_if_impossible: (n < 0) = (error = Negative_size) no_error_if_possible: (n >= 0) = (error = 0) capacity_set_if_no_error: (error = 0) implies (capacity = n) allocated_if_no_error: (error = 0) implies (representation /= Void) end feature - Access (Доступ) item: G is -- Элемент вершины, если существует; в противном случае -- значение типа по умолчанию. -- с ошибкой категории Underflow. -- Без всяких предусловий! do if not empty then check representation /= Void end Result := representation.item error := 0 else error := Underflow -- В этом случае результатом является значение по умолчанию end ensure error_code_if_impossible: (old empty) = (error = Underflow) no_error_if_possible: (not (old empty)) = (error = 0) end feature -- Status report (Отчет о статусе) empty: BOOLEAN is -- Пуст ли стек? do Result := (capacity = 0) or else representation.empty end error: INTEGER -- Индикатор ошибки, устанавливаемый различными компонентами -- в ненулевое значение, если они не могут выполнить свою работу full: BOOLEAN is -- Заполнен ли стек? do Result := (capacity = 0) or else representation.full end Overflow, Underflow, Negative_size: INTEGER is unique -- Возможные коды ошибок feature -- Element change (Изменение элементов) put (x: G) is -- Добавить x на вершину, если возможно; иначе задать код ошибки. -- Без всяких предусловий! do if full then error := Overflow else check representation /= Void end representation.put (x); error := 0 end ensure error_code_if_impossible: (old full) = (error = Overflow) no_error_if_possible: (not old full) = (error = 0) not_empty_if_no_error: (error = 0) implies not empty added_to_top_if_no_error: (error = 0) implies item = x one_more_item_if_no_error: (error = 0) implies count = old count + 1 end remove is -- Удалить вершину, если возможно; иначе задать код ошибки. -- Без всяких предусловий! do if empty then error := Underflow else check representation /= Void end representation.remove error := 0 end ensure error_code_if_impossible: (old empty) = (error = Underflow) no_error_if_possible: (not old empty) = (error = 0) not_full_if_no_error: (error = 0) implies not full one_fewer_item_if_no_error: (error = 0) implies count = old count - 1 end feature {NONE} - Implementation (Реализация) representation: STACK2 [G] -- Незащищенный стек используется для реализации capacity: INTEGER -- Максимальное число элементов стека end - class STACK3
Операции этого класса не имеют предусловий (более точно, имеют True в качестве предусловия). Результат выполнения может характеризовать ненормальную ситуацию, постусловие переопределено так, чтобы позволить отличать корректную и ошибочную обработку. Например, при вызове s.remove, где s это экземпляр класса STACK3, в корректной ситуации значение s.error будет равно 0; в ошибочной - Underflow. В последнем случае никакая другая работа выполняться не будет. Клиент несет ответственность за проверку s.error после вызова. Как уже отмечалось, у общецелевого модуля, такого как STACK3 нет способа решить, что делать в ошибочной ситуации: выдать сообщение об ошибке, произвести корректировку ситуации...
Такие модули фильтры служат для отделения нормальных ситуаций от ситуаций, обрабатывающих ошибки. В этом отличие корректности от устойчивости, объясняемое в начале книги: написание модуля корректно выполняющего свою задачу в предусмотренных случаях - одна задача, сделать так, чтобы и в непредусмотренных ситуациях обработка выполнялась сносно - совсем другая задача. Обе они необходимы, но их нужно разделять и управлять ими по-разному. Одна из типичных ошибок, приводящая к безнадежной сложности программных систем, - в алгоритм, делающий действительно нечто полезное, добавляется куча проверок на безнадежные ситуации и из лучших побуждений делается попытка управлять ими. В таких системах путаница начинает расти как грибы после дождя.
Несколько технических замечаний к приведенному примеру класса.
Экземпляр STACK3 - содержит атрибут representation, представляющий ссылку на экземпляр STACK2, содержащий, в свою очередь, ссылку на массив. Эти обходные пути пагубно отражаются на эффективности, избежать этого можно введением наследования, изучаемого в последующих лекциях.Булева операция or else подобна or, но если первый операнд равен True, игнорирует второй операнд, возможно неопределенный в такой ситуации.Инструкция check, используемая в put и remove, служит для проверки выполнения некоторых утверждений.
Она будет изучаться позднее в этой лекции.
В заключение: вы, наверное, отметили тяжеловесность STACK3 в сравнении с простотой STACK2, достигнутой благодаря предусловиям. Это хороший пример, показывающий, что толерантный стиль может приводить к бесполезно усложненному ПО. Требовательный стиль, по контрасту, вытекает из общего духа Проектирования по контракту. Попытка управлять всем, - и возможными и невозможными случаями - совсем не лучший способ помочь вашим клиентам. Если вместо этого вы построите классы, влекущие возможно более строгие условия на их использование, точно опишите эти условия, включив их в документацию класса, вы реально облегчите жизнь вашим клиентам. Требовательная любовь (tough love) может быть лучше всепрощающей; лучше эффективная поддержка функциональности с проверяемыми ограничениями, чем страстная попытка предугадать желания клиентов, принятие возможно неадекватных решений, жертвой чего становятся простота и эффективность.
Для модулей, чьими клиентами являются другие программные модули, требовательный подход обычно является правильным выбором. Возможным исключением становятся модули, предназначенные для клиентов, чьи авторы используют не ОО-языки и могут не понимать основных концепций Проектирования по контракту.
| Толерантный подход остается полезным для модулей, принимающих данные от внешнего мира. Как отмечалось, в этом случае строятся фильтры, отделяющие внешний мир от обрабатывающих модулей. Класс STACK3 иллюстрирует идеи построения подобных фильтров. |
Трудности циклов
Возможность повторять некоторые вычисления произвольное число раз, не поддаваясь усталости, без случайных потерь чего-либо важного, - в этом принципиальное отличие компьютерных вычислений от возможностей человека. Вот почему циклы так важны. Трудно вообразить, что можно было бы делать в языках, в которых были бы только две управляющие структуры - последовательность и выбор, - но не было бы циклов и не было бы поддержки рекурсии, еще одного базисного механизма поддержки итеративных вычислений.
Но с мощностью приходят и риски. У циклов дурная слава, - их трудно заставить работать правильно. Типичными для циклов являются:
Ошибки "больше-меньше" (выполнение цикла слишком много или слишком мало раз).Ошибки управления пограничными ситуациями, например пустыми структурами. Цикл может правильно работать на больших массивах, но давать ошибки, когда у массива один элемент или он вообще пуст.Ошибки завершения ("зацикливание") в некоторых ситуациях.
Бинарный поиск - один из ключевых элементов базового курса "Введение в информатику" (Computer Science 101) - хорошая иллюстрация "коварства" циклов даже в относительно тривиальной ситуации. Рассмотрим целочисленный, упорядоченный по возрастанию массив t с индексами от 1 до n. Используем алгоритм бинарного поиска для ответа на вопрос: появляется ли целое x среди элементов массива. Если массив пуст, ответ должен быть "нет", если в массиве ровно один элемент, то ответ "да" тогда и только тогда, когда элемент массива совпадает с x. Суть бинарного поиска, использующего упорядоченность массива, проста: вначале x сравнивается со средним элементом массива, если есть совпадение, то задача решена, если x меньше среднего элемента, то поиск продолжается в верхней половине массива, в противном случае - в нижней половине. Каждое сравнение уменьшает размер массива вдвое. Ниже представлены четыре попытки реализации этой
простой идеи. К несчастью, все они содержат ошибки. Вам предоставляется случай поупражняться в поиске ошибок и установить, в какой ситуации каждый из алгоритмов не работает нужным образом.
| Напомню, t @ m означает элемент массива t с индексом m. Знак операции // означает деление нацело, так что 7 // 2 и 6 // 2 дают значение 3. Синтаксис цикла будет дан ниже, но он должен быть и так понятен. Предложение from вводит инициализацию цикла. |
Таблица 11.3. Четыре (ошибочных) попытки реализации бинарного поиска
BS1 from i := 1; j := n until i = j loop m := (i + j) // 2 if t @ m <= x then i := m else j := m end end Result := (x = t @ i)
|
BS2 from i := 1; j := n; found := false until i = j and not found loop m := (i + j) // 2 if t @ m < x then i := m + 1 elseif t @ m = x then found := true else j := m - 1 end end Result := found
|
BS3 from i := 0; j := n until i = j loop m := (i + j + 1) // 2 if t @ m <= x then i := m + 1 else j := m end end if i >= 1 and i <= n then Result := (x = t @ i) else Result := false end
|
BS4 from i := 0; j := n + 1 until i = j loop m := (i + j) // 2 if t @ m <= x then i := m + 1 else j := m end end if i >= 1 and i <= n then Result := (x = t @ i) else Result := false end
|
У11.1 Комплексные числа
Напишите спецификацию АТД для класса COMPLEX, описывающую понятие комплексных чисел с арифметическими операциями. Исходите из точной арифметики.
У11.10 Модуль "очередь"
Напишите класс, реализующий очередь (стратегию доступа "первый пришел - первый ушел", FIFO - "first in - first out"). Задайте подходящие утверждения в стиле класса STACK этой лекции.
У11.11 Модуль "множество"
Напишите класс, реализующий множество элементов произвольного типа со стандартными операциями: проверка принадлежности, добавление нового элемента, объединение, пересечение и другими. Не забудьте включить подходящие утверждения. Приемлема любая корректная реализация, основанная на массивах или связных списках.
У11.2 Класс и его АТД
Проверьте все предусловия и аксиомы АТД STACK, введенного в предыдущих лекциях, и покажите, отображаются ли они в классе STACK4, а если да, то как.
У11.3 Полные утверждения для стеков
Покажите, что введение закрытой функции body, возвращающей тело стека, сделает возможным утверждениям класса STACK полностью отражать спецификацию соответствующего АТД. Обсудите теоретическую и практическую значимость такого подхода.
У11.4 Экспортирование размера
Почему capacity экспортируется для реализации стеков ограниченных размеров, класс STACK2?
У11.6 Утверждения и экспорт
Обсудите использование функций в утверждениях, в частности, введение функции correct_index в предусловия программ put и item. Если добавить эту функцию в класс ARRAY, то какой статус экспорта следует ей дать?
У11.7 Поиск жучков (bugs)
Покажите, что каждая из четырех попыток бинарного поиска, объявленная как "ошибочная", действительно некорректна. (Подсказка: в отличие от доказательства корректности, для доказательства некорректности достаточно предъявить один пример, на котором алгоритм приводит к неверному результату: не завершается, выполняет запрещенную операцию, такую, как выход индекса за допустимые границы, любое другое нарушение предусловия).
У11.8 Нарушение инварианта
В этой лекции было показано, что нарушение предусловия указывает на ошибку клиента, а нарушение постусловия указывает на ошибку поставщика. Объясните, почему нарушение инварианта также указывает на ошибку поставщика.
У11.9 Генерация случайных чисел
Напишите класс, реализующий алгоритм получения псевдослучайных чисел, основанный на последовательности: ni = f(ni - 1), где функция f задана, а начальное значение n0 определяется клиентом класса. Функция не должна иметь побочных эффектов. Определение функции f можно найти в учебниках, таких как [Knuth 1981] и в библиотеках по численным методам.
Утверждения это не управляющие структуры
Еще одно типичное заблуждение - рассматривать утверждения как управляющую структуру, реализующую разбор случаев. К этому моменту должно быть ясно, что не в этом их роль. Если написать программу sqrt, в которой отрицательные значения будут обрабатываться одним способом, а положительные - другим, то писать предусловие - предложение require не следует. В этом случае используется обычный разбор случаев: оператор if - then - else, или оператор case языка Pascal, или оператор inspect, введенный в этой книге как раз для таких целей.
Утверждения выражают нечто иное. Они говорят о корректности условий. Если sqrt имеет предусловие, то вызов, в котором x<0, это "жучок" (bug).
Правило нарушения утверждения (1)
Нарушение утверждения в период выполнения является проявлением "жучка" в ПО.
Слово "жучок" не принадлежит к научному лексикону, но этот термин понятен всем программистам. Учитывая контракты, это правило можно уточнить:
Правило нарушения утверждения (2)
Нарушение предусловия является проявлением "жучка" у клиента.
Нарушение постусловия является проявлением "жучка" у поставщика.
Нарушение предусловия означает, что вызывающая программа нарушила контракт - "виноват заказчик". С позиций внешнего наблюдателя можно, конечно, критиковать сам контракт, но коль скоро контракт заключен, его следует выполнять. Если есть программа, осуществляющая мониторинг утверждений, то запускать на выполнение программу, чье предусловие не выполняется, не имеет смысла.
Нарушение постусловия означает, что программа, предположительно вызванная в корректных условиях, не выполнила свою часть работы, предусмотренную контрактом. Здесь тоже ясно, кто виноват, а кто нет: "жучок" в программе, клиент не виновен.
Утверждения как средство для написания корректного ПО
Первое использование является чисто методологическим и, вероятно, самым важным. В деталях оно рассматривалось в предыдущих разделах: точные требования к каждой программе, глобальные свойства классов и циклов - все это помогает разработчикам производить программный продукт, корректный с самого начала в противоположность подходу, пытающемуся добиться корректности в процессе отладки. Преимущества точной спецификации и систематического подхода к конструированию программ не могут быть преувеличены. Во всей этой книге всякий раз при встрече с программным элементом его формальные свойства выражались точно, насколько это было возможным.
Ключевая идея этой лекции - Проектирование по контракту. Использование компонент некоторого модуля является контрактом с его службами. Хорошие контракты точно специфицируют и ограничивают права и обязанности каждого участника. В проектировании ПО, где корректность и устойчивость так важны, необходимо раскрытие терминов контракта, как предварительное условие их следованию. Утверждения дают способ точно установить, что ожидается и что гарантируется каждой стороне в этом соглашении.
Утверждения не являются механизмом проверки вводимых данных
Полезно сосредоточиться на некоторых неявно обсуждавшихся свойствах контрактов. Заметьте, контракты описывают только взаимодействие двух программ (программа - программа). Контракты не задают другие виды взаимодействий: человек - программа, внешний мир - программа. Предусловие не заботится о корректировке ввода пользователя, например программа read_positive_integer, ожидающая в интерактивном режиме ввода пользователем положительного целого. Включение в такую программу предусловия:
require input > 0
хотя и желательно, но технически не реализуемо. Полагаться на пользователя в контрактах нельзя. В данной ситуации нет заменителя обычной конструкции проверки условия, включая почтенный if - then - else; полезен и механизм обработки исключений.
У утверждений своя роль в решении проблемы проверки ввода данных. При описании критерия Защищенности модуля отмечалось, что Метод поощряет проверку правильности любых объектов, получаемых из внешнего мира - от сенсоров, пользовательского ввода, из сети и т. д. Эта проверка должна быть максимально приближена к источникам объектов, используя при необходимости модули - "фильтры".

Рис. 11.1. Использование модулей - фильтров
При получении информации извне нельзя опираться на предусловия. Задача модулей ввода - гарантировать, что никакая информация не будет передана обрабатывающим модулям, пока она не будет удовлетворять условиям, требуемым для корректной обработки. При таком подходе утверждения будут широко использоваться в коммуникациях программа - программа. Постусловия модулей ввода должны соответствовать или превосходить предусловия, продиктованные обрабатывающими модулями. Фильтры играют охраняющую роль, обеспечивая корректность входных данных.
Включение функций в утверждения
Булевы выражения не ограничиваются использованием атрибутов и локальных сущностей. Мы уже использовали возможность вызова функций в утверждениях: предусловие для put класса стек было not full, где full - функция
full: BOOLEAN is -- Is stack full? (Заполнен ли стек?) do Result := (count = capacity) ensure full_definition: Result = (count = capacity) end
В этом наш маленький секрет, - мы вышли из рамок исчисления высказываний, в котором булевы выражения могут строиться только из переменных, констант и знаков логических операций. Благодаря введению функций, мы получили мощный механизм, позволяющий вычислять булевы значения любым, подходящим для нас способом. Не следует беспокоиться о присутствии постусловия самой функции full, это не создает никакого пагубного зацикливания. Детали вскоре.
Использование функций ведет к получению более абстрактных утверждений. Например, кто-то предпочтет заменить предусловие в операциях над массивом, ранее выраженное как
index_not_too_small: lower <= i index_not_too_large: i <= upper
одним предложением в форме
index_in_bounds: correct_index (i)
с определением функции
correct_index (i: INTEGER): BOOLEAN is -- Является ли i внутри границ массива? do Result := (i >= lower) and (i <= upper) ensure definition: Result = ((i >= lower) and (i <= upper)) end
Еще одно преимущество использования функций в выражениях в том, что они дают способ обойти ограничения выразительной силы, возникающие из-за отсутствия механизмов логики предикатов первого порядка. Неформальный инвариант нашего цикла для maxarray
-- Result является максимумом нарезки массива t в интервале [t.lower,i]
формально может быть выражен так
Result = (t.slice (lower, i)).max
в предположении, что slice вырабатывает нарезку - массив с индексами от lower до i, - а функция max дает максимальный элемент этого массива.
| Этот подход был исследован в [M 1995a] как способ расширения выразительной силы механизма утверждений, возможно ведущий к разработке полностью формального метода, - другими словами, к математическому доказательству корректности ПО. В этом исследовании есть две центральные идеи. Первая - использование библиотек в процессе доказательства, так что можно его проводить для реальных, широкомасштабных систем, строя многоярусную структуру, использующую условные доказательства. Вторая идея - определение ограниченного языка чисто аппликативной природы - IFL (Intermediate Functional Language), в котором выражаются функции, используемые в выражениях. Язык IFL является подмножеством нотации этой книги, включающий некоторые императивные конструкции, такие как любые присваивания. |
<
/p>
Ясно, чем мы рискуем: появление функций в выражениях означает введение потенциально императивных элементов (программ) в чисто аппликативный, до сего времени, мир утверждений. Без функций мы имели ясное и четкое разделение ролей, обсуждаемое ранее: инструкции предписывают, утверждения описывают. Теперь мы открыли ворота аппликативного города императивным полчищам.
Все же трудно сопротивляться мощи использования функций, поскольку все альтернативы имеют свои недостатки.
Включение полного языка спецификаций, как отмечалось, приводит к потере эффективности и простоты изучения.Вероятно, хуже то, что неясно, достаточны ли общепринятые языки утверждений. Возьмем, например, такого естественного кандидата, в которого многие верят, - язык логики предикатов первого порядка. Этот формализм не позволяет нам выразить некоторые свойства, представляющие непосредственный интерес для разработчиков и часто используемые в утверждениях, такие как, например, "граф не имеет циклов" (типичный инвариант цикла). Математически это может быть выражено как r+
r =
, где r - это отношение на графе, а+ его транзитивное замыкание. Хотя можно представить себе язык спецификации, поддерживающий эти понятия, большинство языков этого не делают.
Все это создает больше трудностей для программиста, которому проще написать булеву функцию cyclic, исследующую граф и возвращающую true, если и только если в графе есть цикл. Такие примеры являются серьезными аргументами в пользу базисного языка утверждений с использованием функций для повышения его выразительной силы.
Но остается необходимость разделять императивные и аппликативные элементы. Любая программно реализованная функция, используемая в утверждениях для специфицирования свойств, должна быть "безупречной", без обвинений ее в императивности, - она не должна быть причиной никаких изменений абстрактного состояния.
Это неформальное требование достаточно ясно на практике; формализм подъязыка IFL исключает все императивные элементы, которые либо изменяют глобальное состояние системы, либо не имеют тривиальных аппликативных эквивалентов, в частности исключаются:
присваивания атрибутам;присваивания в циклах;вызовы программ, не входящих в IFL.
Если особо тщательно дирижировать функциями, достаточно простыми с очевидной корректностью, то использование в утверждениях программно реализованных функций дает мощный метод абстракции.
Некоторые технические вопросы могут потребовать внимания. Функция f, используемая в утверждении программы r, может сама иметь утверждения, что демонстрируют примеры функций full и correct_index. Возникает потенциальная проблема при мониторинге утверждений в период выполнения: если при вызове r мы вычисляем утверждение, вызывающее f, то не придется ли нам вычислять утверждение для f? Нетрудно сконструировать пример зацикливания, если пойти по этому пути. Но даже и без этого риска было бы неправильно вычислять утверждение для f. Это бы означало, что мы рассматриваем "на равных" программы, являющиеся предметом наших вычислений, такие как r, и их функции утверждения, такие как f. В противовес этому сформулируем правило, согласно которому утверждения должны иметь более высокий приоритет, чем программы, которые они защищают, их корректность должна быть кристально ясной. Пр авило простое:
Правило вычисления утверждения
В процессе вычисления утверждений, входящие в них вызовы программ должны выполняться без вычисления ассоциированных утверждений.
Если вызов f встречается как часть проверки утверждения программы r, то слишком поздно спрашивать, удовлетворяет ли f своим утверждениям. Подходящим является время, когда решается вопрос использования f в утверждении, применимом к r.
Рассматривайте f как охранника ядерного предприятия, в обязанности которого входит проверка посетителей. Охранников тоже нужно проверять, но не тогда, когда они сопровождают посетителей.
Введение утверждений в программные тексты
Как только корректность ПО определена как согласованность реализации с ее спецификацией, следует предпринять шаги по включению спецификации в сам программный продукт. Для большинства в программистском сообществе это все еще новая идея. Привычно писать программы, устанавливая тем самым, - как делать (the how); менее привычно рассматривать описание целей - что делать (the what) - как часть программного продукта.
Спецификации будут основываться на утверждениях - выражениях, включающих сущности нашего ПО. Выражение задает свойство, которому эти сущности могут удовлетворять на некоторых этапах выполнения программы. Типичное утверждение может выражать тот факт, что определенное целое имеет положительное значение, или что некоторая ссылка не определена.
Ближайшим к утверждению математическим понятием является предикат, хотя используемый язык утверждений обладает лишь частью выразительной силы полного исчисления предикатов.
Синтаксически утверждения в нашей нотации будут обычными булевыми выражениями с небольшими расширениями. Одним из расширений является введение в нотацию термина "old", другим - введение символа ";" для обозначения конъюнкции (логического И). Вот пример:
n>0; x /= Void
Как между объявлениями и операторами, стоящими на разных строках, символ ";" является возможным, но не обязательным, так и в последовательности утверждений, записанных на разных строках, он может быть опущен, подразумеваясь по умолчанию. Эти соглашения облегчают идентификацию индивидуальных компонентов утверждения, которым обычно даются имена:
Positive: n > 0 Not_void: x /= Void
Метки, такие как Positive и Not_Void, в период выполнения играют роль утверждений, что будет еще обсуждаться в этой лекции. В данный момент они введены, главным образом, для ясности и документирования. В нескольких последующих разделах будет дан обзор принципиальных возможностей применения утверждений: как концептуального средства, позволяющего создавать корректные системы, и как документирование того, почему они корректны.
Выражение аксиом
Из соответствия между АТД функциями и компонентами класса можно вывести соответствие между утверждениями класса и семантическими свойствами АТД.
Предусловие для специфицированной в АТД функции появляется как предусловие программы, соответствующей данной функции.Аксиома, включающая команду, и, возможно, одну или более функций запросов, появится как постусловие соответствующей процедуры.Аксиомы, включающие только запросы, появятся как постусловия соответствующих функций или как инвариант. Последнее обычно имеет место, если более чем одна функция включена в аксиому, или, по меньшей мере, один из запросов реализован в виде атрибута.Аксиомы, включающие функцию создатель, появятся в постусловии соответствующей процедуры создания.
В этот момент следует вернуться назад и сравнить аксиомы АТД STACK с утверждениями класса STACK4 (включая и те, которые даны для класса STACK2).
Выражение спецификаций
От неформальных высказываний перейдем к простой математической нотации, принятой в теории формальной проверки правильности программ и имеющей ценность при доказательстве корректности программных элементов.
Выразительная сила утверждений
Как можно было заметить, применяемый язык утверждений является языком обычных булевых выражений, обогащенный несколькими понятиями, такими как old. Как результат, он ограничен и не позволяет включить в наши классы некоторые свойства, достаточно просто выражаемые в математической нотации, используемой при описании АТД.
Утверждения класса стек дают хороший пример того, что выразимо, и что не выразимо в нашем языке. Мы найдем, что многие аксиомы и предусловия из спецификации АТД, приведенной в лекции 6, прямым образом отображаются в утверждения класса. Например, аксиома
A4. not empty (put (s, x))
задает постусловие not empty процедуры put. Но в некоторых случаях в классе нет непосредственного двойника. Ни одно из постусловий для remove, приводимое до сих пор, не отражает аксиому
A2. remove (put (s, x)) = s
Мы, конечно, можем ввести эту аксиому неформально, добавив в постусловие комментарий, описывающий это свойство:
remove is -- Удалить элемент вершины require not_empty: not empty -- i.e. count > 0 do count := count - 1 ensure not_full: not full one_fewer: count = old count - 1 LIFO_policy: -- item является последним элементом, помещенным в стек -- и еще не удален, если таковое имело место. End
Подобные неформальные утверждения, синтаксически выраженные комментариями, появлялись в инвариантах цикла для maxarray и gcd.
В таких случаях, два из принципиальных использований утверждений, обсуждаемых ранее, остаются применимыми, по крайней мере, частично: помощь в создании корректного продукта и его документации (утверждения, заданные комментариями будут появляться в краткой форме класса). Другие использования, в частности, отладка и тестирование предполагают вычисление выражений, и становятся теперь неприменимыми.
Было бы предпочтительнее, выражать все утверждения формально. Лучший способ достичь этой цели - расширить язык выражений, так чтобы он позволял задавать любые свойства. Это требует возможности описания сложных математических объектов - множеств, последовательностей, функций, отношений.
Необходим и мощный по выразительности язык, например, язык логики предикатов первого порядка, допускающий выражения с кванторами всеобщности и существования. Существуют формальные языки спецификаций, обладающие, по крайней мере, частью такой выразительной силы. Наиболее известными являются языки Z, VDM, Larch, OBJ-2; как Z, так и VDM имеют ОО-расширения, например, Object-Z. Библиографические замечания к лекции 6 дают необходимые ссылки.
Включение полного языка спецификаций в язык этой книги полностью изменило бы ее природу. Смысл языка в том, чтобы он был простым, легким в обучении, применимым во всех программистских конструкциях. Он должен допускать быструю компиляцию и эффективную реализацию с производительностью, соизмеримой с C или Fortran.
Вместо этого, в механизме утверждений мы пошли на инженерный компромисс: он включает достаточно формальных элементов, оказывающих существенный эффект на качество ПО, но останавливается в точке убывания - границе, за которой выгоды от большей формализации, начинают оборачиваться потерями простоты и эффективности.
| Определение границы во многом определяется личным выбором. Я был удивлен, для программистского сообщества в целом эта граница не изменилась со времен первого издания этой книги. Наша деятельность требует большего формализма, но профессиональное сообщество еще не осознало этого. |
Так что пока и на ближайшее будущее утверждения остаются булевыми выражениями с некоторыми расширениями. Это не такое уж и строгое ограничение, поскольку булевы выражения допускают вызов функций.
Замечание о пустоте структур
Предусловие в процедуре создания (конструкторе) make класса STACK1 требует комментария. Оно устанавливает n>=0 и, следовательно, допускает пустые стеки. Если n=0, то make вызовет процедуру создания для массивов, также имеющую имя make, с аргументами 1 и 0 для нижней и верхней границ соответственно. Это не ошибка, это соответствует спецификации процедуры создания массивов, которая в случае, когда нижняя граница на единицу больше верхней, создает пустой массив.
Пустой стек не ошибка, это особый случай. Ошибка может возникнуть при попытке чтения из пустого стека, но этот случай охраняется предусловиями put и item.
При определении общих структур данных, подобных стеку или массиву, возникает вопрос о концептуальной целесообразности пустой структуры. В зависимости от ситуации ответ может быть разный, например, для деревьев полагается обычно, что дерево должно иметь хотя бы один узел - корень. Но в случае стеков или массивов, когда нет логической невозможности существования пустой структуры, ее следует допускать.
Базисные концепции обработки исключений
Базисные концепции обработки исключений
Литература по обработке исключений зачастую не очень точно определяет, что вызывает исключение. Как следствие, механизм исключений, представленный в таких языках программирования как PL/I и Ada, часто неправильно используется: вместо того, чтобы резервироваться только для истинно чрезвычайных ситуаций, они заканчивают службу как внутрипрограммные инструкции goto, нарушающие принцип Защищенности.
К счастью, теория Проектирования по Контракту, введенная в предыдущей лекции, обеспечивает хорошие рамки для точного определения включаемых концепций.
Цепочка вызовов
Обсуждая механизм обработки исключений, полезно иметь ясную картину последовательности вызовов, приведших в итоге к исключению. Это понятие уже появлялось при рассмотрении механизма языка Ada.

Рис. 12.1. Цепочка вызовов
Пусть r0 будет корневой процедурой некоторой системы (в Ada это программа main). В каждый момент выполнения есть текущая программа, вызванная последней и ставшая причиной исключения. Пройдем по цепочке в обратном порядке, начиная с текущей программы, от вызываемой к вызывающей программе. Реверсная цепочка (r0, последняя вызванная r0 программа r1, последняя вызванная r1 программа r2 и так далее до текущей программы) называется цепочкой вызовов.
Если возникает исключение, то для его обработки, возможно, придется подняться по цепочке, пока не будет достигнута программа, способная справиться с исправлением ситуации. Этот процесс заканчивается, когда достигнута программа r0 и не найден нужный обработчик исключения.
Четкое разделение ролей
Интересно сравнить формальные роли тела и предложения Rescuer:
{prer and INV} Bodyr {postr (xr) INV} {True} Rescuer {INV}
Входное утверждение сильнее для Bodyr - в то время, когда Rescuer не накладывает никаких требований, перед началом выполнение тела программы (предложения do) должно выполняться предусловие и инвариант. Это упрощает работу Bodyr.
Выходное утверждение также сильнее для Bodyr - в то время, когда Rescuer обязана восстановить только инвариант класса, Bodyr обязана сыграть свою роль и обеспечить истинность выполнения постусловия. Это делает ее работу более трудной.
Эти правила отражают разделение ролей между предложением do и предложением rescue. Задача тела обеспечить выполнение контракта программы, не управляя непосредственно исключениями. Задача rescue - управлять обработкой исключениями, возвращая управление либо телу программы, либо вызывающей программе. Но в обязанности rescue не входит обеспечение контракта.
Дисциплинированные исключения
Исключения, как они были введены, дают способ справиться с аномалиями, возникающими в процессе выполнения: нарушениями утверждений, сигналами аппаратуры, попытками получить доступ к void ссылкам.
Исследуемый нами подход основан на метафоре контракта, - ни при каких обстоятельствах программа не должна претендовать на успешность, когда фактически имеет место отказ в достижении цели. Программа может быть либо успешной (возможно, после исправления ситуации и нескольких попыток retry), либо приводить к отказу.
Исключения в языках Ada, CLU, PL/1 не следуют этой модели. В языке Ada ее инструкция
Raise exc
прервет выполнение программы и возвратит управление вызывающей программе, которая может обработать исключение в специальном обработчике, или вернет управление на уровень выше. Но здесь нет правила, ограничивающего действия обработчика. Следовательно, полностью возможно игнорировать исключение или вернуть альтернативный результат. Это объясняет, почему некоторые разработчики смотрят на механизм исключений просто как на средство обработки специальных случаев, не включенных в основной алгоритм. Такие приложения исключения рассматривают фактически raise как goto, что, очевидно, опасно, так как позволяет передавать управление за границы программы. По моему мнению, они злоупотребляют механизмом.
Традиционно есть две точки зрения на исключения. Первая признает исключения необходимым свойством. Она присуща большинству практикующих программистов, знающих как важно сохранить управление во время выполнения программы при возникновении ненормальных условий - аппаратных или программных ошибок. Вторая точка зрения присуща ученым, озабоченным корректностью и систематическим конструированием программ. Они с подозрением относятся к исключениям, рассматривая их как нечто нечистое, старающееся обойти стандартные правила управления программными структурами. Надеюсь, выше разработанный механизм способен примирить обе стороны.
Должны ли исключения быть объектами?
Фанатики объектной ориентации (многие ли из тех, кто открыл красоту этого подхода, не рискуют стать его фанатиками?) могут критиковать представленный механизм за то, что исключения не являются гражданами первого сорта в программном сообществе. Почему исключения не являются объектами?
В ОО-расширении Pascal в среде Delphi исключения действительно представлены объектами.
Не очень понятны преимущества такого решения. Некоторое обоснование можно будет найти в лекции 4 курса "Основы объектно-ориентированного проектирования", посвященной ответу на вопрос, каким должен быть класс. Объект является экземпляром абстрактно определенного типа данных, характеризуемого его компонентами. Исключение, конечно, как мы видели в классе EXCEPTIONS, имеет компоненты, заданные целочисленным кодом, текстовым сообщением. Но эти компоненты являются запросами, в то время, как истинные объекты имеют команды, изменяющие состояние объекта. Исключения не находятся под управлением программной системы; они результат событий, находящихся вне пределов ее достижимости.
Доступность их свойств через запросы и команды класса EXCEPTIONS достаточна для удовлетворения потребностей разработчиков, которые хотят обрабатывать исключения конкретного вида.
Исключения разработчика
Все исключения, изучаемые до сих пор, были результатом событий внешних по отношению к ПО (сигналы операционной системы) или принудительных следствий его работы (нарушение утверждений). В некоторых приложениях полезно, чтобы исключения возникали по воле разработчика в определенных ситуациях.
Такие исключения называются исключениями разработчика. Они характеризуются как целочисленным кодом, отличающимся от системных кодов, так и именем (строкой), которые могут быть использованы, например, в сообщениях об ошибке. Можно использовать следующие свойства для возбуждения исключения разработчика и для анализа его свойств в предложении rescue.
trigger (code: INTEGER; message: STRING) -- Прерывает выполнение текущей программы, выбрасывая исключение с кодом -- code и связанным текстовым сообщением. developer_exception_code: INTEGER -- Код последнего исключения разработчика developer_exception_name: STRING -- Имя, ассоциированное с последним исключением разработчика is_developer_exception: BOOLEAN -- Было ли последнее исключение исключением разработчика? is_developer_exception_of_name (name: STRING): BOOLEAN -- Имеет ли последнее исключение разработчика имя name? ensure Result := is_developer_exception and then equal (name, developer_exception_name)
Иногда полезно связать с исключением разработчика контекст - произвольный объект, структура которого может быть полезной при обработке исключения разработчика:
set_developer_exception_context (c: ANY) -- Определить c как контекст, связанный с последовательностью -- исключений разработчика (причина вызова компонента trigger). require context_exists: c /= Void developer_exception_context: ANY -- Контекст, установленный последним вызовом set_developer_exception_context -- void, если нет такого вызова.
Эти свойства позволяют использовать стиль программирования, в котором обработка исключений представляет часть общего процесса работы с программными элементами. Авторы одного из трансляторов при разборе текстов предпочитали выбрасывать исключения при появлении особых случаев, после чего вызывать для их анализа специальные программы. Это не мой стиль работы, но по сути, ничего ошибочного в нем нет, так что механизм исключений разработчика для тех, кому нравится так работать.
Исключения
Вооружившись понятием отказа, можно теперь определить понятие "исключение". Программа приводит к отказу из-за возникновения некоторых специфических событий (арифметического переполнения, нарушения спецификаций), прерывающих ее выполнение. Такие события и являются исключениями.
Определение: исключение
Исключение - событие периода выполнения, которое может стать причиной отказа программы.
Зачастую исключение будет причиной отказа. Но можно предотвратить отказ, написав программу так, что она будет захватывать возникшее исключение, пытаться восстановить состояние, допускающее нормальное продолжение вычислений. Вот почему отказ и исключение - это разные понятия: каждый отказ это следствие исключения, но не каждое исключение приводит к отказу.
Изучение программных аномалий в предыдущей лекции привело к появлению терминов неисправность (fault) - для событий, приводящих к пагубным последствиям при выполнении программы, дефект (defect) - неадекватность программной системы, способная привести к отказам, ошибка (error) - неверные решения разработчика или проектировщика, приводящие к дефектам. Отказ - это неисправность; исключение, зачастую, тоже неисправность, но таковым не является, если его возможное появление предвиделось, и программа может справиться с возникшей ситуацией.
Источники исключений
Исключения можно классифицировать, разделив их на категории.
Определение: исключительные ситуации
Исключения могут возникать при выполнении программы r в результате следующих ситуаций.
Попытка квалифицированного вызова a.f и обнаружение, что a = Void.Попытка присоединить значение Void к развернутой (expanded) цели.Выполнение невозможной или запрещенной операции, обнаруживаемое аппаратно или операционной системой.Вызов программы, приводящей к отказу.Предусловие r не выполняется на входе.Постусловие r не выполняется на выходе.Инвариант класса не выполняется на входе или выходе.Инвариант цикла не выполняется в результате инициализации в предложении from или после очередной итерации тела цикла.Итерация тела цикла не уменьшает вариант цикла.Не выполняется утверждение инструкции check.Выполнение инструкции, явно включающей исключение.
Случай (1) отражает одно из основных требований к использованию ссылок: вызов a.f имеет смысл, когда к a присоединен объект, другими словами, когда a не void. Это обсуждалось в лекции 8 при рассмотрении динамической модели.
Случай (2) также имеет дело с void значениями. Напомним, что "присоединение" (attachment) покрывает присваивание и передачу аргументов, имеющих одинаковую семантику. В разделе "Гибридное присоединение" лекции 8 отмечалась возможность присваивания ссылки развернутой цели, в результате чего происходит копирование объекта. Но это предполагает существование объекта, но если источник void, то присоединение вызовет исключение.
Случай (3) следствие сигналов, посылаемых приложению операционной системой.
Случай (4) возникает при отказе программы, как результат возникновения в ней исключения, с которым она не смогла справиться. Более подробно это будет рассмотрено ниже, но пока обратите внимание на правило, вытекающее из (4):
Отказы и исключения
Отказ программы - причина появления исключения в вызывающей программе.
Случаи (5)-(10) могут встретиться только при мониторинге утверждений, включенных на соответствующем уровне: assertion (require) для (5), assertion (loop) для (8) и (9) и так далее.
Случай (11) предполагает вызов процедуры raise, выбрасывающей (зажигающей) исключения. Такая процедура будет рассмотрена чуть позднее.
Как не следует делать это - Ada пример
Приведу пример программы, взятый из одного учебника1) по языку Ada.
sqrt (x: REAL) return REAL is begin if x < 0.0 then raise Negative else normal_square_root_computation end exception when Negative => put ("Negative argument") return when others => ... end -- sqrt
Этот пример, вероятно, предназначался для синтаксической иллюстрации механизма Ada, и был написан быстро (он, например, отказывается возвращать значение в случае возникновения исключения). Поэтому было бы непорядочно критиковать его, как если бы это был настоящий пример хорошего программирования. Вместе с тем, он ясно показывает нежелательный способ обработки исключений. Поскольку Ada ориентирована на военные и космические приложения, то остается надеяться, что ни одна из реальных программ не следует буквально этой модели.
Целью программы является получение вещественного квадратного корня из вещественного числа. Но что если число отрицательно? В языке Ada нет утверждений, так что в программе проводится проверка, возбуждающая исключение для отрицательных чисел.
Инструкция raise Exc прерывает выполнение текущей программы и включает исключение с кодом Exc. Это исключение может быть захвачено и обработано при наличии предложений exception, имеющих вид:
exception when code_a1, code_a2, ...=> Instructions_a; when code_b1, ... => Instructions_b; ...
Если код исключения совпадает с одним из кодов, указанных в части when, то выполняются соответствующие инструкции. Если, как в примере, есть предложение when others, то его инструкции выполняются, когда код исключения не совпадает ни с одним из кодов предыдущих частей when. Если нет универсального обработчика when others, и код исключения не совпадает ни с одним кодом, то поиск обработчика будет вестись у вызывающей программы, если вызывающей программы нет, то достигнута программа main и программа завершается отказом.
В примере нет необходимости переходить к вызывающей программе, поскольку выброшенное исключение с кодом Negative захватывается обработчиком с таким же кодом.
Но что делают соответствующие инструкции? Посмотрите еще раз:
put ("Negative argument") return
Напечатается сообщение - довольно глубокомысленное, а затем управление перейдет к вызывающей программе, которая, не будучи уведомлена о событии, продолжит свое выполнение, как если бы ничего не случилось. Вспоминая снова о типичных приложениях Ada, можно лишь надеяться, что этой схеме не следует артиллерийское приложение, в результате которой снаряды могут упасть на головы совсем не тех солдат, для которых вряд ли может служить утешением посланное сообщение об ошибке.
Эта техника, вероятно, хуже, чем C-Unix сигнальный механизм, позволяющий, по крайней мере, возобновить вычисление в точке, где оно остановилось. Обработчик исключения when, заканчивающийся инструкцией return, даже не продолжает текущую программу; он возвращает управление вызывающей программе, будто бы все прекрасно, в то время как все далеко не прекрасно.
Этот контрпример дает хороший урок Ada-программистам: почти ни при каких обстоятельствах обработчик when не должен заканчиваться return. Слово "почти" употреблено для полноты картины, поскольку есть особый допустимый случай ложной тревоги (false alarm), достаточно редкий, который мы обсудим чуть позже. Опасно и неприемлемо не уведомлять вызывающую программу о возникшей ошибке. Если невозможно исправить ситуацию и выполнить контракт, то программа должна выработать отказ. Язык Ada позволяет сделать это: предложение exception может заканчиваться инструкцией raise без параметров, повторно выбрасывая исходное исключение, передавая его вызывающей программе. Это и есть подходящий способ завершения выполнения, когда невозможно выполнить свой контракт.
Правило исключений языка Ada
Выполнение любого обработчика исключений должно заканчиваться либо выполнением инструкции raise, либо повторением объемлющего программного блока.
Как не следует делать это - C-Unix пример
Первым контрпримером механизма (наиболее полно представленным в Unix, но доступным и на других платформах, реализующих C) является процедура signal, вызываемая в следующей форме:
signal (signal_code, your_routine)
с эффектом вызова обработчика исключения - программы your_routine, когда выполнение текущей программы прерывается, выдавая соответствующий код сигнала (signal_code). Код сигнала - целочисленная константа, например, SIGILL (неверная инструкция - illegal instruction) или SIGFPE (переполнение с плавающей точкой - floating-point exception). В программу можно включить сколь угодно много вызовов процедуры signal, что позволяет обрабатывать различные, возможные ошибки.
Теперь предположим, что при выполнении некоторой инструкции произошло прерывание и выработан соответствующий код сигнала. Будет или нет вызвана процедура signal, но выполнение программы завершается в не нормальном состоянии. Предположим, что вызывается обработчик события - your_routine, пытающийся исправить ситуацию. Беда в том, что, завершив работу, он возвращает управление непосредственно в точку, где произошло прерывание (в не нормальное состояние). Это опасно, вероятнее всего, из этой точки невозможно нормально продолжить работу.
Что необходимо в большинстве подобных случаев - исправить ситуацию и продолжить выполнение, начиная с некоторой особой точки, но не точки прерывания. Мы увидим, что есть простой механизм, реализующий эту схему. Заметьте, он может быть реализован и на C, на большинстве платформ. Достаточно комбинировать процедуру signal с двумя другими библиотечными процедурами: setjmp, вставляющую маркер в точку, допускающую продолжение вычислений, и longjmp для возврата к маркеру. С механизмом setjmp-longjmp следует обращаться весьма аккуратно. Поэтому он не ориентирован на обычных программистов, но может использоваться разработчиками компиляторов для реализации высокоуровневого механизма ОО-исключений, который будет описан в этой лекции.
Как отказаться сразу
Последнее высказывание достойно возведения в ранг принципа.
Принцип отказа
Завершение выполнения предложения rescue, не включающее инструкции retry, приводит к тому, что вызов программы завершается отказом.
Так что, если и были вопросы, как на практике возникает отказ (ситуация (4) в классификации исключений), то это делается именно так, - при завершении предложения rescue.
В качестве специального случая рассмотрим программу, не имеющую предложения rescue. На практике именно этот случай характерен для огромного большинства программ. В разрабатываемом подходе к обработке исключений лишь избранные из поставляемых программ должны иметь такое предложение. Игнорируя объявления и другие части программы, можно полагать, что программа без предложения rescue имеет вид:
routine is do body end
Тогда, приняв, как временное соглашение, что отсутствие предложения rescue эквивалентно существованию пустого предложения rescue, наша программа эквивалента программе:
routine is do body rescue -- Здесь ничего (пустой список инструкций) end
Из принципа Отказа вытекают следующие следствия: если исключение встретилось в программе, не имеющей предложения rescue, то эта программа вырабатывает отказ, включая исключение у вызывающей программы.
| Рассмотрение отсутствующего предложения rescue, как присутствующего пустого предложения, является подходящей аппроксимацией на данном этапе рассмотрения. Но нам придется слегка подправить это правило, когда начнем рассматривать эффект исключений на инвариант класса. |
Какой должна быть степень контроля?
Могут возникнуть замечания по поводу уровня обработки специфических исключений, иллюстрируемых двумя последними примерами. В этой лекции проводилась та точка зрения, что исключение - нежелательное событие; когда оно возникает, то естественная реакция ПО и его разработчика - "я не хочу быть здесь! Выпустите меня отсюда, как можно скорее!". Это, кажется, несовместимым с проведением в предложении rescue глубокого анализа источника исключений.
По этой причине я пытался в моей собственной работе избегать детального разбора случаев причины исключений, стараясь показать, что обработка исключений лишь фиксирует ситуацию, если может, а затем либо fail, либо retry.
Этот стиль, вероятно, слишком строг, и некоторые разработчики предпочитают менее ограниченную схему, используя в полной мере механизм запросов класса EXCEPTIONS, позволяющий в тоже время оставаться дисциплинированным. Если вы хотите придерживаться такой схемы, то в классе EXCEPTIONS найдете все, что для этого нужно. Но всегда помните о следующем принципе:
Принцип Простоты Исключения
Вся обработка, выполняемая в предложении rescue, должна оставаться простой и фокусироваться на единственной цели - возвратить объект получателя в стабильное состояние, допуская повторение, если это возможно.
Ключевые концепции
Обработка исключений - это механизм, позволяющий справиться с неожиданными условиями, возникшими в период выполнения.Отказ - это невозможность во время выполнения программы выполнить свой контракт.Программа получает исключение в результате: отказа вызванной ею программы, нарушения утверждений, сигналов аппаратуры или операционной системы об аномалиях, возникших в ходе их работы.Программная система может включать также исключения, спроектированные разработчиком.Программа имеет два способа справиться с исключениями - Повторение вычислений (Retry) и Организованная Паника. При Повторении тело программы выполняется заново. Организованная Паника означает отказ и формирование исключения у вызывающей программы.Формальная роль обработчика исключений, не заканчивающегося retry, состоит в восстановлении инварианта, но не в обеспечении контракта программы. Последнее всегда является делом тела программы (предложения do). Формальная роль ветви, заканчивающейся retry, состоит в восстановлении инварианта и предусловия, так чтобы тело программы могло попытаться в новой попытке выполнить контракт.Базисный механизм обработки исключений, включаемый в язык, должен оставаться простым, если только поощрять прямую цель обработки исключений - Организованную Панику или Повторение. Для приложений, нуждающихся в более тонком контроле над исключениями, доступен класс EXCEPTIONS, позволяющий добраться до свойств каждого вида исключений и провести их обработку. Этот класс позволяет создавать и обрабатывать исключения разработчика.
Когда нет предложения rescue
Формализовав роль предложения rescue, вернемся к рассмотрению ситуации, когда это предложение отсутствует в программе. Правило для этого случая было введено ранее, но с обязательством его уточнения. Ранее полагалось, что отсутствующее предложение rescue эквивалентно присутствию пустого предложения (rescue end). В свете наших формальных правил это не всегда является приемлемым решением. Правило (3) требует:
{True} Rescuer {INV}
Если Rescuer является пустой инструкцией, а инвариант не тождественен True, то правило не выполняется.
Зададим точное правило. Класс Any является корневым классом - прародителем всех классов. В состав этого класса включена процедура default_rescue, наследуемая всеми классами - потомками Any:
default_rescue is -- Обрабатывает исключение, если нет предложения rescue. -- (По умолчанию: ничего не делает) do end
Программа, не имеющая предложения rescue, рассматривается теперь как эквивалентная программе с предложением rescue в следующей форме:
rescue default_rescue
Каждый класс может переопределить default_rescue, для выполнения специфических действий, гарантирующих восстановление инварианта класса, вместо эффекта пустого действия, заданного по умолчанию в GENERAL. Механизм переопределения компонент класса будет изучаться в последующих лекциях, посвященных наследованию.
Вы, конечно, помните, что одна из ролей процедуры создания состоит в производстве состояния, удовлетворяющего инварианту класса INV. Отсюда понятно, что во многих случаях переопределение default_rescue может основываться на использовании процедур создания.
Корректность предложения rescue
Формальное определение корректности класса выдвигает два требования к компонентам класса. Первое (1) требует, чтобы процедуры создания гарантировали корректную инициализацию - выполнение инварианта класса. Второе (2) напрямую относится к нашему обсуждению, требуя от каждой программы, запущенной при условии выполнения предусловия и инварианта класса, выполнения в завершающем состоянии постусловия и инварианта класса. Диаграмма, описывающая жизненный цикл объекта, отражает эти требования:

Рис. 12.3. Жизнь объекта
Формально правило (2) говорит:
Для каждой экспортируемой программы r и любого множества правильных аргументов xr
{prer (xr) and INV} Bodyr {postr (xr) and INV}
Для простоты позвольте в дальнейшем рассмотрении игнорировать аргументы xr.
Пусть Rescuer обозначает ту часть предложения rescue, в которой игнорируются все ветви, ведущие к retry, другими словами в этой части сохраняются все ветви, доходящие до конца предложения rescue. Правило (2) задает спецификацию для программ тела - Bodyr. Можно ли получить такую же спецификацию для Rescuer? Она должна иметь вид:
{ ? } Rescuer { ? }
с заменой знаков вопроса соответствующими утверждениями. (Полезно, перед дальнейшим чтением постараться самостоятельно задать эти утверждения.)
Рассмотрим, прежде всего, предусловие для Rescuer. Любая попытка написать нечто не тривиальное будет ошибкой! Напомним, чем сильнее предусловие, тем проще работа программы. Любое предусловие для Rescuer ограничит число случаев, которыми должна управлять эта программа. Но она должна работать во всех ситуациях! Когда возникает исключение, ничего нельзя предполагать, - такова природа исключения. Нам не дано предугадать, когда компьютер даст сбой, или пользователю вздумается нажать клавишу "break".
Поэтому остается единственная возможность - предусловие для Rescuer равно True. Это самое слабое предусловие, удовлетворяющее всем состояниям и означающее, что Rescuer должна работать во всех ситуациях.
Для ленивого создателя Rescuer это "плохая новость", - тот случай, когда "заказчик всегда прав"!
Что можно сказать о постусловии Rescuer? Напомню, эта часть предложения rescue ведет к отказу, но, прежде чем передать управление клиенту, необходимо восстановить стабильное состояние. Это означает необходимость восстановления инварианта класса.
Отсюда следует правило, в котором уже больше нет знаков вопросов:
Правило корректности для включающего отказ предложения rescue
{True} Rescuer {INV}
Похожие рассуждения дают правило для Retryr - части предложения rescue, включающей ветви, приводящие к инструкции retry:
Правило корректности для включающего повтор предложения rescue
{True} Retryr {INV and prer }
Механизм исключений
Из предшествующего анализа следует механизм исключений, наилучшим образом соответствующий ОО-подходу и идеям Проектирования по Контракту.
Для обеспечения основных свойств введем в язык два новых ключевых слова. Для случаев, в которых необходим точно отрегулированный механизм, будет доступен библиотечный класс EXCEPTIONS.
Методологическая перспектива
Финальное замечание и обзор. Обработка исключений, имеющая дело со специальными и нежелательными случаями, - не единственный ответ на общую проблему устойчивости. Мы уже приобрели некоторую методологическую интуицию, но более полный ответ появится в лекции, обсуждающей проектирование интерфейсов модулей, позволяя нам понять место обработки исключений в широком арсенале методов устойчивости и расширения.
N-версионное программирование
Другим примером повторения программы, толерантной к неисправностям, является реализация N-версионного программирования - подхода, улучшающего надежность ПО.
В основе N-версионного программирования лежит идея избыточности, доказавшая свою полезность в аппаратуре. В критически важных областях зачастую применяется дублирование аппаратуры, например, несколько компьютеров выполняют одни и те же вычисления, и есть компьютер-арбитр, сравнивающий результаты, и принимающий окончательное решение, если большинство компьютеров дало одинаковый результат. Этот подход хорошо защищает от случайных отказов в аппаратуре отдельного устройства. Он широко применяется в аэрокосмической области. (Известен случай, когда при запуске космического челнока сбой произошел в компьютере-арбитре). N-версионное программирование переносит этот подход на разработку ПО в критически важных областях. В этом случае создаются несколько программистских команд, каждая из которых независимо разрабатывает свою версию системы (программы). Предполагается, что ошибки, если они есть, будут у каждой команды свои.
Это спорная идея; возможно, лучше вложить средства в одну версию, добиваясь ее корректности, чем финансировать две или три несовершенных реализации. Проигнорируем, однако, эти возражения, пусть о полезности идеи судят другие. Нас будет интересовать возможность использования механизма retry в ситуации, где есть несколько реализаций, и используется первая из них, не заканчивающаяся отказом:
do_task is -- Решить проблему, применяя одну из нескольких возможных реализаций. require ... local attempts: INTEGER do if attempts = 0 then implementation_1 elseif attempts = 1 then implementation_2 end ensure ... rescue attempts := attempts + 1 if attempts < 2 then "Инструкции, восстанавливающие стабильное состояние" retry end end
Обобщение на большее, чем две, число реализаций очевидно.
Этот пример демонстрирует типичное использование retry. Предложение rescue никогда не пытается достигнуть исходной цели, запуская, например, очередную реализацию.
Достижение цели - привилегия нормального тела программы.
Заметьте, после двух попыток (в общем случае n попыток) предложение rescue достигает конца, не вызывая retry, следовательно, приводит к отказу.
Давайте рассмотрим более тщательно, что случается, когда включается исключение во время выполнения r. Нормальное выполнение (тела) останавливается; вместо этого начинает выполняться предложение rescue. После чего могут встретиться два случая:
Предложение rescue выполнит в конечном итоге retry. В этом случае начнется повторное выполнение тела программы. Эта новая попытка может быть успешной, тогда программа нормально завершится и управление вернется к клиенту. Вызов успешен, контракт выполнен. За исключением того, что вызов мог занять больше времени, никакого другого влияния появление исключения не оказывает. Если, однако, повторная попытка снова приводит к исключению, то вновь начнет работать предложение rescue.Если предложение rescue не выполняет retry, оно завершится естественным образом, достигнув end. (В последнем примере это происходит, когда attempts >=2.) В этом случае программа завершается отказом; она возвращает управление клиенту, сигнализируя о неудаче выбрасыванием исключения. Поскольку клиент должен обработать возникшее исключение, то снова возникают два рассмотренных случая, теперь уже на уровне клиента.
Этот механизм строго соответствует принципу Дисциплинированной Обработки Исключения. Программа завершается либо успехом, либо отказом. В случае успеха ее тело выполняется до конца и гарантирует выполнение постусловия и инварианта. Когда выполнение прерывается исключением, то можно либо уведомить об отказе, либо попытаться повторно выполнить нормальное тело. Но нет никакой возможности выхода из предложения rescue, уведомив клиента, что все завершилось нормально.
Обработка исключений
Теперь у нас есть определение того, что может случиться, - исключения - и того, с чем мы бы не хотели столкнуться в результате появления исключения, - отказа. Давайте разыскивать способы справляться с исключениями так, чтобы не возникли отказы. Что может сделать программа, когда ее выполнение прервано из-за нежелательного поведения?
Помощь в нахождении разумного ответа могут дать примеры того, как не следует поступать в подобных ситуациях. Ими мы обязаны механизму сигналов языка C, пришедшему из Unix, и одному учебнику по языку Ada.
Отказы
Неформально исключение это аномальное событие, прерывающее выполнение программы. Для получения содержательного определения полезно вначале рассмотреть понятие отказа, непосредственно следующее из идеи контракта.
Программа это не произвольная последовательность инструкций, а реализация некоторой спецификации - контракта программы. Всякий вызов программы должен завершаться в состоянии, удовлетворяющем постусловию и инварианту класса. Неявное следствие контракта - при вызове программы не должны появляться прерывания операционной системы, связанные, например, с обращением к недоступным областям памяти или переполнением при выполнении арифметических операций.
Так должно быть, но в жизни не все происходит так, как должно быть. И мы должны ожидать, что рано или поздно при очередном вызове программы она не сможет выполнить свой контракт. Произойдет системное прерывание, или будет вызвана программа в состоянии, не удовлетворяющем ее предусловию, или в заключительном состоянии будет нарушено постусловие либо инвариант (в двух последних случаях предполагается мониторинг утверждений в период выполнения).
Такие ситуации будем называть отказом (failure).
Определения: успех, отказ
Вызов программы успешен, если он завершается в состоянии, удовлетворяющем контракту. Вызов завершается отказом, если он не успешен.
Будем использовать термины "отказ программы" или просто "отказ", как сокращения более точного термина "вызов программы, завершающийся отказом". Понятно, что сама программа не может быть ни успешной, ни давать отказ. Эти понятия применимы только по отношению к конкретному вызову.
Поломки при вводе
Предположим, что в интерактивной системе необходимо выдать подсказку пользователю, от которого требуется ввести целое. Пусть только одна процедура занимается вводом целых - read_one_integer, которая результат ввода присваивает атрибуту last_integer_read. Эта процедура работает неустойчиво, - если на ее входе будет нечто, отличное от целого, она может привести к отказу, выбрасывая исключение. Конечно, вы не хотите, чтобы это событие приводило к отказу всей системы. Но поскольку вы не управляете программой ввода, то следует ее использовать и организовать восстановление ситуации, при возникновении исключений. Вот возможная схема:
get_integer is -- Получить целое от пользователя и сделать его доступным в -- last_integer_read. -- Если ввод некорректен, запросить повторения, столько раз, -- сколько необходимо. do print ("Пожалуйста, введите целое: ") read_one_integer rescue retry end
Эта версия программы иллюстрирует стратегию повторения.
Очевидный недостаток - пользователь упорно вводит ошибочное значение, программа упорно запрашивает значение. Это не очень хорошее решение. Можно ввести верхнюю границу, скажем 5, числа попыток. Вот пересмотренная версия:
Maximum_attempts: INTEGER is 5 -- Число попыток, допустимых при вводе целого. get_integer is -- Попытка чтения целого, делая максимум Maximum_attempts попыток. -- Установить значение integer_was_read в true или false -- в зависимости от успеха чтения. -- При успехе сделать целое доступным в last_integer_read. local attempts: INTEGER do if attempts < Maximum_attempts then print ("Пожалуйста, введите целое: ") read_one_integer integer_was_read := True else integer_was_read := False end rescue attempts := attempts + 1 retry end
Предполагается, что включающий класс имеет булев атрибут integer_was_read.
Вызывающая программа должна использовать эту программу следующим образом, пытаясь введенное целое присвоить сущности n:
get_integer if integer_was_read then n := last_integer_read else "Иметь дело со случаем, в котором невозможно получить целое" end
Повторение программы, толерантной к неисправностям
Предположим, вы написали текстовый редактор, и к вашему стыду нет уверенности, что он полностью свободен от жучков. Но вам хочется передать эту версию некоторым пользователям для получения обратной связи. Нашлись смельчаки, готовые принять систему с оставшимися ошибками, понимая, что могут возникать ситуации, когда их запросы не будут выполнены. Но они не будут тестировать ваш редактор на серьезных текстах, (а именно это вам и требуется), если будут бояться, что отказы могут привести к катастрофе, например грубый выход с потерей текста, над которым шла работа последние полчаса. Используя механизм повторения, можно обеспечить защиту от такого поведения.
Предположим, что редактор, как это обычно бывает для подобных систем, содержит основной цикл, выполняющий команды редактора:
from ... until exit loop execute_one_command end
где тело программы execute_one_command имеет вид:
"Декодировать запрос пользователя" "Выполнить команду, реализующую запрос"
Инструкция "Выполнить ..." выбирает нужную программу (например, удалить строку, заменить слово и так далее). Мы увидим в последующих лекциях, как техника наследования и динамическое связывание дает простые, элегантные структуры для подобных ветвящихся решений.
Будем исходить из того, что не все эти программы являются безопасными. Некоторые из них могут отказать в непредсказуемое время. Вы можете обеспечить примитивную, но эффективную защиту против таких событий, написав программу следующим образом:
execute_one_command is -- Получить запрос от пользователя и, если возможно, -- выполнить соответствующую команду. do "Декодировать запрос пользователя" "Выполнить подходящую команду в ответ на запрос" rescue message ("Извините, эта команда отказала") message ("Пожалуйста, попробуйте использовать другую команду") message ("Пожалуйста, сообщите об отказе автору") "Команды, латающие состояние редактора" retry end
Эта схема предполагает на практике, что поддерживаемые запросы пользователя включают: "сохранить текущее состояние работы", "завершить работу". Оба последних запроса должны работать корректно. Пользователь, получивший сообщение "Извините...", несомненно, захочет сохранить работу и выйти как можно скорее. Некоторые из программ, реализующих команды редактора, могут иметь собственные предложения rescue, хотя и приводящие к отказу, но предварительно выдающие более информативные сообщения.
Примеры обработки исключений
Теперь, когда у нас есть базисный механизм, давайте посмотрим, как он применяется в общих ситуациях.
Принципы обработки исключений
Контрпримеры помогли указать дорогу к дисциплинированному использованию исключений. Следующие принципы послужат основой обсуждения.
Принципы дисциплинированной обработки исключений
Есть только два легитимных отклика на исключение, возникшее при выполнении программы:
Повторение (Retrying) - попытка изменить условия, приведшие к исключению, и выполнить программу повторно, начиная все сначала.Отказ (Failure) - известный также как "организованная паника" (organized panic): чистка стека и других ресурсов, завершение вызова и отчет об отказе перед вызывающей программой.
В дополнение, некоторые сигналы операционной системы (случай (3) в классификации исключений) в редких случаях являются откликом на "ложную тревогу". Определив, что исключение безвредно, можно возобновить выполнение в точке прерывания.
Давайте начнем рассмотрение с третьего случая - ложной тревоги, обработка которого соответствует основному механизму C-Unix. Вот пример. Некоторые оконные системы будут вызывать исключения, если пользователь перестраивает размеры окна во время выполнения процесса в этом окне. Предположим, что процесс не выполняет никакого вывода в это окно, тогда исключение будет безвредным, и можно возобновить выполнение процесса в прерванной точке. Но даже в этом случае есть лучшие пути, такие как полная блокировка сигналов на время выполнения процесса, чтобы исключение вообще не встретилось. Именно так мы будем поступать с ложными тревогами в механизме, рассматриваемом в следующем разделе.
Ложные тревоги возможны лишь для одного вида сигналов операционной системы - благоприятных сигналов, но нельзя игнорировать арифметическое переполнение или невозможность выделения запрашиваемой память. Исключения всех других категорий также указывают на трудности, не допускающие игнорирования. Было бы абсурдно, например, запускать программу при ложном предусловии.
Повторение - более обнадеживающая стратегия: мы потерпели поражение в битве, но не проиграли войну. Хотя наш первоначальный план выполнения контракта потерпел неудачу, мы можем постараться удовлетворить клиента, применив другую тактику.
Если она будет успешной, то исключение не оказывает никакого влияния на клиента. После одной или нескольких попыток, приведших к неудаче, в очередной попытке нам, возможно, удастся полностью выполнить контракт ("Миссия завершена, сэр. Обычные, небольшие проблемы, сэр. Теперь все хорошо, сэр").
Что значит "другая тактика", испытываемая при следующей попытке? Это может быть другой алгоритм; или тот же алгоритм, выполняемый после некоторых произведенных изменений в начальном состоянии (атрибуты, локальные переменные). В некоторых случаях это может быть просто повторный запуск той же программы в надежде, что изменились внешние условия - освободились временно занятые устройства, линии связи и так далее.
При отказе приходится признавать не только поражение в битве, но и невозможность выиграть войну. Мы сдаемся, но прежде следует выполнить два условия, объясняющие использование термина "организованная паника", как более точного синонима понятия "отказ":
Обеспечить появление исключения у вызывающей программы. В этом и состоит аспект "паники" - программа отказывается жить в соответствии с ее контрактом.Восстановить согласованное состояние выполнения - "организованный" аспект.
Что является согласованным состоянием? Корректность класса позволяет дать ответ: состояние, удовлетворяющее инварианту. Мы уже говорили, что программа во время ее выполнения может нарушать инвариант, восстанавливая его в конце работы. Если возникло исключение, то инвариант может быть нарушен. Программа должна восстановить его до возвращения управления вызывающей программе.
Продвинутая обработка исключений
Чрезвычайно простой механизм, разработанный до сих пор, удовлетворяет большинству потребностей обработки исключений. Но некоторые приложения могут требовать более тонкой настройки:
Возможно, требуется определить природу последнего исключения, чтобы разными исключениями управлять по-разному.Возможно, требуется запретить включение исключений для некоторых сигналов.Возможно, вы захотите включать собственные исключения.
Можно было бы соответствующим образом расширить механизм, встроенный в язык, но это не кажется правильным подходом. Вот, по меньшей мере, три причины. Первая - свойства нужны только от случая к случаю, так что они будут загромождать язык. Вторая - все, что касается сигналов, может зависеть от платформы, а язык должен быть переносимым. Наконец, третья, - когда выбирается множество подобных свойств, никогда нет полной уверенности, что позже вам не захочется добавить новое свойство, что требовало бы модификации языка - не очень приятная перспектива.
В таких ситуациях следует обращаться не к языку, но к поддерживающим библиотекам. Мы введем библиотечный класс EXCEPTIONS, обеспечивающий необходимые возможности тонкой настройки. Классы, нуждающиеся в таких свойствах, будут наследниками EXCEPTIONS. Некоторые разработчики могут предпочесть отношение встраивания вместо наследования.
Ситуации отказа
Рассматривая список возможных исключений, полезно определить, когда может встретиться отказ (причина исключения у вызывающей программы):
Определение: случаи отказа
Вызов программы приводит к отказу, если и только если встретилось исключение в процессе выполнения, и программа не смогла с ним справиться.
Определения отказа и исключения взаимно рекурсивны: отказ возникает из-за появления исключений, а одна из причин исключения - отказ при вызове программы (случай (4)).
Спаси и Повтори (Rescue и Retry)
Прежде всего, в тексте программы должна быть возможность указания действий, выполняемых при возникновении исключения. Для этой цели и вводится новое ключевое слово rescue, задающее предложение с описанием действий, предпринимаемых для восстановления ситуации. Поскольку предложение rescue описывает действия, предпринимаемые при нарушении контракта, то разумно поместить его в конце программы после всех других предложений:
routine is require precondition local ... Объявление локальных сущностей ... do body ensure postcondition rescue rescue_clause end
Предложение rescue_clause является последовательностью инструкций. При возникновении исключения в теле программы вычисление прерывается, и управление передается предложению rescue. Хотя есть только одно такое предложение на программу, но в нем можно проанализировать причину исключения и нужным образом реагировать на различные события.
Другой новой конструкцией является инструкция retry, записываемая просто как retry. Эта инструкция может появляться только в предложении rescue. Ее выполнение состоит в том, что она повторно запускает тело программы с самого начала. Инициализация, конечно, не повторяется.
Эти конструкции являются прямой реализацией принципа Дисциплинированной Обработки Исключений. Инструкция retry обеспечивает механизм повторения; предложение rescue, не заканчивающееся retry приводит к отказу.
Таблица истории исключений
Если в программе произошел отказ, то ли из-за отсутствия предложения rescue, то ли потому, что это предложение закончилось без retry, она прервет выполнение вызывающей программы, вызвав в ней исключение типа (4) - отказ в вызываемой программе. Вызывающая программа столкнется с теми же самыми двумя возможностями: либо в ней есть предложение rescue, способное исправить ситуацию, либо она выработает отказ и передаст управление вверх по цепочке вызовов. Если на всем пути не найдется программы, способной справиться с исключением, то выполнение всей системы закончится отказом. В этом случае окружение должно сформировать и вывести ясную картину произошедшего - таблицу истории исключения. Вот пример такой таблицы:
Таблица 12.1. Пример таблицы истории исключенийОбъектКлассПрограммаПрирода исключенияЭффект
| O4 | Z_Function | split (from E_FUNCTION) | Feature interpolate: Вызывалаcь ссылкой void | Повторение |
O3
O2
O2
| |
INTERVAL
EQUATION
EQUATION
| |
integrate
solve (from GENERAL_EQUATION)
filter
| |
interval_big_enough: Нарушено предусловие
Отказ программы
Отказ программы
| |
Отказ
Отказ
Повторение
| |
O2
O1(root)
| |
MATH
INTERFACE
| |
new_matrix (from BASIC_MATH)
make
| |
enough_memory: Check Нарушение
Отказ программы
| |
Отказ
Отказ
| |
Эта таблица содержит историю не только тех исключений, которые привели, в конечном счете, к отказу системы, но и исключений, эффект которых был преодолен в результате выполнения rescue - retry. Число исключений в таблице может быть ограничено, например, числом 100 по умолчанию. Порядок в таблице сверху вниз является обратным порядку, в котором вызываются программы. Корневая процедура создания записана в последней строке таблицы.
Столбец Программа идентифицирует для каждого исключения программу, чей вызов был прерван исключением. Столбец Объект идентифицирует цель этого вызова; используемые здесь имена O1 и так далее, но в реальной трассировке они будут внутренними идентификаторами, позволяющие определить, являются ли объекты совпадающими. Столбец Класс указывает класс, генерирующий объект.
Столбец Природа Исключения указывает, что случилось. Здесь, как показано во второй сверху строке таблицы, могут использоваться метки утверждений, например, interval_big_enough, что позволяет точно идентифицировать нарушаемое предложение в программе.
Последний столбец указывает, как обрабатывалось исключение, то ли используя Повторение, то ли Отказ. Таблица состоит из последовательности секций, отделяемых толстой линией. Каждая секция, за исключением последней, приводила к Повторению, что указывает на восстановление ситуации. Понятно, что между двумя вызовами, отделенными толстыми линиями, может быть произвольное число вызовов.
Игнорируя такие промежуточные вызовы, - успешные и потому неинтересные для цели нашего обсуждения - здесь приведена цепочка вызовов и возвратов, соответствующая выше приведенной истории исключений. Для реконструкции действий следует следовать по стрелкам, обходя их против часовой стрелки, начиная от программы make, изображенной слева вверху.

Рис. 12.2. Выполнение, приведшее к отказу
У12.1 Наибольшее целое
Предположим, компьютер генерирует исключение, когда сложение целых дает переполнение. Используя обработку исключений, напишите приемлемую по эффективности функцию, возвращающую наибольшее положительное целое, представимое на этой машине.
У12.2 Объект Exception
Несмотря на скептицизм, высказанный в разделе "Обсуждение" этой лекции по поводу рассматривания исключений как объектов, займитесь развитием этой идеи и обсудите, как мог бы выглядеть класс EXCEPTION, полагая, что экземпляры этого класса обозначают исключения, появившиеся при выполнении. Не путайте его с классом EXCEPTIONS, который доступен благодаря наследованию и обеспечивает общие свойства исключений. Попытайтесь, в частности, наряду с запросами, включить команды в разрабатываемый вами класс.
1)
Sommerville, Morrison "Software Development with Ada", Addison-Wesley, 1987. Синтаксис и некоторые идентификаторы изменены для приведения в соответствие со стилем данной книги.
|  |
Восстановление при исключениях, сгенерированных операционной системой
Среди событий, включающих исключения, есть сигналы, посылаемые операционной системой, некоторые из которых являются следствием аппаратных прерываний. Примеры: арифметическое переполнение сверху и снизу, невозможные операции ввода-вывода, запрещенные команды, обращение к недоступной памяти, прерывания от пользователя (например, нажата клавиша break). Теоретически можно рассматривать такие условия, как нарушение утверждений. Если a+b приводит к переполнению, то это означает, что вызов не удовлетворяет неявному предусловию функции + для целых или вещественных чисел, устанавливающее, что сумма двух чисел должна быть представима в компьютере. Подобное неявное предусловие задается при создании новых объектов (создание копии) - памяти должно быть достаточно. Отказы встречаются из-за того, что окружение - файлы, устройства, пользователи - не отвечают условиям применимости. Но в таких случаях непрактично или невозможно задавать утверждения, допуская их независимую проверку. Единственное решение - пытаться выполнить операцию, и, если аппаратура или операционная система выдает сигнал о ненормальном состоянии, рассматривать его как исключение. Рассмотрим проблему написания функции quasi_inverse, возвращающей для каждого вещественного x обратную величину 1/x или 0, если x слишком мало. Подобные задачи по существу нельзя реализовать, не используя механизм исключений. Единственный практичный способ узнать, можно ли для данного x получить обратную величину, это выполнить деление. Но деление может спровоцировать переполнение, и если нет механизма управления исключениями, то программа завершится отказом, и будет слишком поздно возвращать 0 в качестве результата.
| На некоторых платформах можно написать функцию invertible, такую что invertible(x) равна true, если и только если обратная величина может быть вычислена. Тогда можно написать и quasi_inverse. Но это решение не будет переносимым, и может приводить к потере производительности при интенсивном использовании этой функции. |
Механизм rescue-retry позволяет просто решить эту проблему, по крайней мере, на платформе, включающей сигнал при арифметическом переполнении:
quasi_inverse (x: REAL): REAL is -- 1/x, если возможно, иначе 0 local division_tried: BOOLEAN do if not division_tried then Result := 1/x end rescue division_tried := True retry end
Правила инициализации устанавливают значение false для division_tried в начале каждого вызова. В теле не нужно предложение else, поскольку инициализация установит Result равным 0.
Задача предложения rescue
Последний комментарий позволяет нам продвинуться в лучшем понимании механизма исключений, обосновав теоретическую роль предложения rescue. Формальные рассуждения помогут получить полную картину.
Запросы при работе с классом EXCEPTIONS
Класс EXCEPTIONS обеспечивает несколько запросов для получения требуемой информации о последнем исключении. Прежде всего, можно получить целочисленный код этого исключения:
exception: INTEGER -- Код последнего встретившегося исключения original_exception: INTEGER -- Код последнего исключения - первопричины текущего исключения
Разница между exception и original_exception важна в случае "организованной паники". Если программа получила исключение с кодом oc, указывающим на арифметическое переполнение, но не имеет предложения rescue, то вызывающая программа получит исключение, код которого, заданный значением exception, будет указывать на "отказ в вызванной программе". Но на этом этапе или выше по цепи вызовов может понадобиться выяснить оригинальное исключение - первопричину появления исключений - код oc, который и будет значением original_exception. Коды исключений являются целыми. Значения для предопределенных исключений задаются целочисленными константами, обеспечиваемыми классом EXCEPTIONS (который наследует их от класса EXCEPTIONS_CONSTANTS). Вот несколько примеров:
Check_instruction: INTEGER is 7 -- Код исключения при нарушении утверждения check Class_invariant: INTEGER is ... -- Код исключения при нарушении инварианта класса Incorrect_inspect_value: INTEGER is ... -- Код исключения, когда проверяемое значение не является ни одной -- ожидаемых констант, если отсутствует часть Else Loop_invariant: INTEGER is ... -- Код исключения при нарушении инварианта цикла Loop_variant: INTEGER is ... -- Код исключения при нарушении убывания варианта цикла No_more_memory: INTEGER is ... -- Код исключения при отказе в распределении памяти Postcondition: INTEGER is ... -- Код исключения при нарушении постусловия Precondition: INTEGER is ... -- Код исключения при нарушении предусловия Routine_failure: INTEGER is ... -- Код исключения при отказе вызванной программы Void_assigned_to_expanded: INTEGER is ...
Так как значения констант не играют здесь роли, то показано только первое из них.
Приведу несколько других запросов, обеспечивающих при необходимости дополнительной информацией. Смысл запросов понятен из их описания:
meaning (except: INTEGER) -- Сообщение, описывающее природу исключения с кодом except is_assertion_violation: BOOLEAN -- Является ли последнее исключение нарушением утверждения -- или нарушением убывания варианта цикла ensure Result = (exception = Precondition) or (exception = Postcondition) or (exception = Class_invariant) or (exception = Loop_invariant) or (exception = Loop_variant) is_system_exception: BOOLEAN -- Является ли последнее исключение внешним событием -- (ошибкой операционной системы)? is_signal: BOOLEAN -- Является ли последнее исключение сигналом операционной системы? tag_name: STRING -- Метка утверждения, нарушение которого привело к исключению original_tag_name: STRING -- Метка последнего нарушенного утверждения оригинальным исключением. recipient_name: STRING -- Имя программы, чье выполнение было прервано последним исключением class_name: STRING -- Имя класса, включающего получателя последнего исключения original_recipient_name: STRING -- Имя программы, чье выполнение было прервано -- последним оригинальным исключением original_class_name: STRING -- Имя класса, включающего получателя последнего оригинального исключения
Имея эти свойства, предложение rescue может управлять каждым исключением особым способом. Например, в классе, наследуемом от EXCEPTIONS, предложение rescue можно написать так:
rescue if is_assertion_violation then "Случай, обрабатывающий нарушение утверждений" else if is_signal then "Случай, обрабатывающий сигналы операционной системы" else ... end
Используя класс EXCEPTIONS, можно модифицировать пример quasi_inverse, чтобы он выполнял retry только при переполнении. Другие исключения, например, нажатие пользователем клавиши "break" не должны приводить к retry. Инструкция в предложении rescue теперь может иметь вид:
if exception = Numerical_error then division_tried := True; retry end
Так как здесь нет else ветви, то исключения, отличные от Numerical_error, будут причиной отказа - корректное следствие, поскольку программа не имеет рецепта восстановления в подобных случаях. Иногда предложение rescue пишется специально для того, чтобы обработать определенный вид возможных исключений. Этот стиль позволяет избежать анализа других неожиданных видов исключений.
Взаимодействие с не объектным ПО
Циклы
Синтаксис циклов описан при обсуждении Проектирования по Контракту (лекция 11):
from initialization_instructions invariant invariant variant variant until exit_condition loop loop_instructions end
Предложения invariant и variant факультативны. Предложение from требуется, хотя и может быть пустым. Оно задает инициализацию параметров цикла. Не рассматривая сейчас факультативные предложения, выполнение цикла можно описать следующим образом. Вначале происходит инициализация, и выполняются initialization_instructions. Затем следует "циклический процесс", определяемый так: если exit_condition верно, то циклический процесс - пустая инструкция (null instruction); если условие неверно, то циклический процесс - это выполнение loop_instructions, затем следует (рекурсивно) повторение циклического процесса.
Инструкции
ОО-нотация, разработанная в этой книге, императивна: вычисления специфицируются через команды (commands), также называемые инструкциями (instructions). (Мы избегаем обычно применимого термина оператор (предложение) (statement), поскольку в слове есть оттенок выражения, описывающего факты, а хотелось подчеркнуть императивный характер команды.) Для имеющих опыт работы с современными языками инструкции выглядят как хорошие знакомые. Исключение составляют некоторые специальные свойства циклов, облегчающие их верификацию. Вот список инструкций: Вызов процедуры, Присваивание, Условие, Множественный выбор, Цикл, Проверка, Отладка, Повторное выполнение, Попытка присваивания.
Использование внешних программ
Внешние программы являются частью ОО-метода, помогая сочетать старое ПО с новым. Любой метод проектирования ПО, допускающий возможность повторного использования, должен допускать программный код, написанный на других языках. Трудно было бы убедить потенциального пользователя, что надо отказаться от всего существующего ПО, поскольку с этой минуты начинается повторное использование. Открытость остальному миру - требование большинства программных продуктов. Это можно назвать принципом скромности: авторы новых инструментов должны дать возможность пользователям иметь доступ к ранее имевшимся возможностям. Внешние программы также необходимы для обеспечения доступа к аппаратуре и возможностям операционной системы. Типичный пример - класс файлов. Другой пример - класс ARRAY, чей интерфейс рассматривался в предыдущих лекциях, и чья реализация основана на внешних программах: процедура создания make использует программу распределения памяти, функция доступа item использует внешний механизм для быстрого доступа к элементам массива, и т.д. Эта техника обеспечивает ясный интерфейс между ОО-миром и другими подходами. Для клиентов внешняя программа - это просто программа. В примере, программа на С _char_write обрела статус компонента (feature) класса, дополнена предусловием и постусловием и получила стандартное имя put. Возможности, внутренне опирающиеся на не ОО-механизмы, получают новую упаковку абстрактных данных, так что участники ОО-мира начинают рассматривать их как законных граждан сообщества, и их низкое происхождение никогда не упоминается в "изысканном обществе". ("Изысканное общество" не означает бесклассовое.)
Ключевые концепции
Внешние программы доступны через хорошо определенный интерфейс.Объектная технология может служить в качестве механизма упаковки наследуемого ПО.Подпрограммы не могут модифицировать свои аргументы, хотя они могут изменять объекты, связанные с этими аргументами.Нотация включает небольшой набор инструкций: присваивания, выбора, цикла, вызова, отладки и проверки.Выражения следуют общепринятому стилю. Current - выражение, обозначающее текущий экземпляр. Не будучи сущностью, Current не может быть целью присваивания.Нестрогие булевы операторы эквивалентны стандартным булевым оператором, когда определены оба операнда, но могут быть определенными в случаях, когда стандартные операторы не определены.Строки, ввод и вывод определяются простыми библиотечными классами.Регистр незначим в идентификаторах, хотя правила стиля включают рекомендуемые соглашения по записи имен.
Лексические соглашения
Идентификатор - это последовательность из символа подчеркивания, буквенных и цифровых символов, начинающаяся с буквы. Нет ограничений на длину идентификатора, что позволяет сделать ясными имена компонентов и классов. Регистр в идентификаторах не учитывается, так что Hi, hi, HI и hI - все означают один и тот же идентификатор. Было бы опасным позволять двум идентификаторам, различающимся только одним символом, скажем Structure и structure, обозначать различные элементы. Лучше попросить разработчиков включить воображение, чем рисковать возникновением ошибок. Нотация включает набор точных стандартных соглашений по стилю (см. лекцию 26 курса "Основы объектно-ориентированного проектирования"): имена классов (INTEGER, POINT ...) и формальные родовые параметры (G в LIST [G]) записываются в верхнем регистре; предопределенные сущности и выражения (Result, Current...) и константные атрибуты (Pi) начинаются с буквы верхнего регистра и продолжаются в нижнем регистре. Все другие идентификаторы (неконстантные атрибуты, формальные аргументы программ, локальные сущности) - в нижнем регистре. Хотя компиляторы не проверяют эти соглашения, не являющиеся частью спецификации, они важны для удобочитаемости текстов программных продуктов и последовательно применяются в библиотеках и текстах этой книги.
Манифестные константы
Неименованная или манифестная константа задается значением, синтаксис которого позволяет определить и тип этого значения, например, целое 0. Этим она отличается от символьной константы, чье имя не зависит от значения. Булевых констант две, - True и False. Целые константы имеют обычную форму, например:
453 -678 +66623
В записи вещественных (real) констант присутствует десятичная точка. Целая, либо дробная часть может отсутствовать. Может присутствовать знак и экспонента, например:
52.5 -54.44 +45.01 .983 -897. 999.e12
Символьные константы состоят из одного символа в одинарных кавычках, например, 'A'. Для цепочек из нескольких символов используется библиотечный класс STRING, описанный ниже.
Множественный выбор
Инструкция множественного выбора (также известная, как инструкция Case) производит разбор вариантов, имеющих форму: e = vi , где e - выражение, а vi - константы то же типа. Хотя условная инструкция (if e = v1 then ...elseif e = v2 then...) работает, есть две причины, оправдывающие применение специальной инструкции, что является исключением из обычного правила: "если нотация дает хороший способ сделать что-то, нет необходимости вводить другой способ". Вот эти причины: Разбор случаев настолько распространен, что заслуживает особого синтаксиса, увеличивающего ясность, позволяя избежать бесполезного повторения "e =".Компиляторы могут использовать особенно эффективную технику реализации, - таблицу переходов (jump table), - неприменимую к общим условным инструкциям и избегающую явных проверок.
Что касается типа анализируемых величин (тип e и vi), то инструкции множественного выбора достаточно поддерживать только целые и булевы значения. Согласно правилу, они фактически должны объявляться либо все как INTEGER, либо как CHARACTER. Общая форма инструкции такова:
inspect e when v1 then instruction; instruction; ... when v2 then instruction; instruction; ... ... else instruction; instruction; ... end
Все значения vi должны быть различными; часть else факультативна; каждая из ветвей может иметь произвольное число инструкций или не иметь их. Инструкция действует так: если значение e равно значению vi (это может быть только для одного из них), выполняются инструкции соответствующей ветви; иначе, выполняются инструкции в ветви else, если они есть. Если отсутствует else, и значение e не соответствует ни одному vi, то возникает исключительная ситуация ("Некорректно проверяемое значение"). Это решение может вызвать удивление, поскольку соответствующая условная инструкция в этом случае ничего не делает. Но оно характеризует специфику инструкции множественного выбора. Когда вы пишете inspect с набором значений vi, нужно включить ветвь else, даже пустую, если вы понимаете, что во время выполнения значения e могут не соответствовать никаким vi.
Если вы не включаете else, то это эквивалентно явному утверждению: "значение e всегда является одним из vi". Проверяя это утверждение и создавая исключительную ситуацию при его нарушении, реализация оказывает нам услугу. Бездействие в данной ситуации - означает ошибку - в любом случае, ее необходимо устранить как можно раньше.
Одно из частых приложений инструкции множественного выбора - анализ символа, введенного пользователем4):
inspect first_input_letter when 'D' then "Удалить строку" when 'I' then "Вставить строку" ... else message ("Неопознанная команда; введите H для получения справки") end
Когда значения vi целые, то они могут быть определены как уникальные (unique values), концепция которых рассмотрена в следующей лекции. Это делает возможным в объявлении определить несколько абстрактных констант, например, Do, Re, Mi, Fa, Sol, La, Si: INTEGER is unique, и затем анализировать их в инструкции: inspect note when Do then...when Re then...end.
Как и условные инструкции, инструкции множественного выбора не должны использоваться для замены неявного выбора, основанного на динамическом связывании.
Нестрогие булевы операторы
Операторы and then и or else (названия заимствованы из языка Ada), а также implies не коммутативны и называются нестрогими (non-strict) булевыми операторами. Их семантика следующая: Нестрогие булевы операторы a and then b ложно, если a ложно, иначе имеет значение b.a or else b истинно, если a истинно, иначе имеет значение b.a implies b имеет то же значение, что и: (not a) or else b.
Первые два определения, как может показаться, дают ту же семантику, что и and и or. Но разница выявляется, когда b не определено. В этом случае выражения, использующие стандартные булевы операторы, математически не определены, но данные выше определения дают результат: если a ложно, то a and then b ложно независимо от b; а если a истинно, то a and then b истинно независимо от b. Аналогично, a implies b истинно, если a ложно, даже если b не определено. Итак, нестрогие операторы могут давать результат, когда стандартные не дают его. Типичный пример:
(i /= 0) and then (j // i = k)
которое, согласно определению, ложно, если i равно 0. Если бы в выражении использовался and, а не and then, то из-за неопределенности второго операнда при i равном 0 статус выражения неясен. Эта неопределенность скажется во время выполнения: Если компилятор создает код, вычисляющий оба операнда, то во время выполнения произойдет деление на ноль, и возникнет исключительная ситуация.Если же генерируется код, вычисляющий второй операнд только тогда, когда первый истинен, то при i равном 0 возвратится значение ложь.
Для гарантии интерпретации (2), используйте and then. Аналогично,
(i = 0) or else (j // i /= k)
истинно, если i равно 0, а вариант or может дать ошибку во время выполнения. Можно недоумевать, почему необходимы два новых оператора - не проще и не надежнее ли просто поддерживать стандарт операторов and и or и принимать, что они означают and then и or else? Это не изменило бы значение булева выражения, когда оба оператора определены, но расширило бы круг случаев, где выражения могут получить непротиворечивое значение.
Именно так некоторые языки программирования, в частности, ALGOL, W и C, интерпретируют булевы операторы. Однако есть теоретические и практические причины сохранять два набора различных операторов.
С точки зрения теории, стандартные математические булевы операторы коммутативны: a and b всегда имеет значение такое же, как b and a, в то время как a and then b может быть определенным, когда b and then a не определено. Когда порядок операндов не имеет значения, предпочтительно использовать коммутативный оператор.С точки зрения практики, некоторые оптимизации компилятора становятся невозможными, если требуется, чтобы компилятор вычислял операнды в заданном выражением порядке, как в случае с некоммутативными операторами. Поэтому лучше использовать стандартные операторы, если известно, что оба операнда определены.
Отметим, что можно смоделировать нестрогие операторы посредством условных команд на языке, не включающем такие операторы. Например, вместо
b := ((i /= 0) and then (j // i = k))
можно написать
if i = 0 then b := false else b := (j // i = k) end
Нестрогая форма, конечно, проще. Это особенно ясно, когда она используется как условие выхода из цикла:
from i := a.lower invariant -- Для всех элементов из интервала [a.lower .. i - 1], (a @ i) /= x variant a.upper - i until i > a.upper or else (a @ i = x) loop i := i + 1 end; Result := (i <= a.upper)
Цель - сделать Result верным, если и только если значение x находится в массиве a. Использование or здесь будет неверным. В этом случае всегда могут вычисляться два операнда, так что при истинности первого операнда (i > a.upper) произойдет попытка доступа к несуществующему элементу массива a @(aupper+1), что приведет к ошибке во время выполнения (нарушение предусловия при включенной проверке утверждений).
Решение без нестрогих операторов будет неэлегантным.
Другой пример - утверждение, например, инварианта класса, выражающее, что первое значение списка l целых неотрицательно, при условии, что список непустой:
l.empty or else l.first >= 0
При использовании or инвариант был бы некорректен. Здесь нет способа написать условие без нестрогих операторов (кроме написания специальной функции и вызова ее в утверждении). Базовые библиотеки алгоритмов и структур данных содержат много таких случаев.
Оператор implies, описывающий включения, также нестрогий. Форма implies менее привычна, но часто более ясна, например, последний пример выглядит лучше в записи:
(not l.empty) implies (l.first >= 0)
ОО-изменение архитектуры (re-architecturing)
Понятие внешней программы хорошо соответствует остальной части подхода. Основной вклад метода - архитектурный: объектная технология говорит, как разработать структуру систем, чтобы обеспечить расширяемость, надежность и повторное использование. Она также говорит, как заполнить эту структуру. Но что по-настоящему определяет, является ли система объектной, - так это ее модульная организация. Для использования ОО-архитектуры часто разумно использовать прием, называемый обертыванием (wrap), одевая в одежды класса внутренние элементы. Крайний, но не совсем абсурдный, способ использования нотации - построить систему полностью на внешних программах. Объектная технология тогда служит просто инструментом упаковки, использующим мощные механизмы инкапсуляции: классы, утверждения, скрытие информации, клиент, наследственность. Но обычно нет причины заходить так далеко. ОО-нотация адекватна вычислениям любого рода и столь же эффективна, как и вычисления на языках Fortran или C. В каких случаях полезна ОО-инкапсуляция внешнего ПО? Один из них мы видели: обеспечение доступа к операциям, зависящим от платформы. Другой - проблема многих организаций - управление старым ПО, доставшимся в наследство и продолжающим широко использоваться. Объектная технология предлагает возможность обновления таких систем, изменяя их архитектуру, но не переписывая их полностью. Эта техника, которую можно назвать ОО-перестройкой (object-oriented re-architecturing) дает интересное решение сохранения ценных свойств существующего ПО, готовя его к будущему расширению и эволюции. Однако для этого необходимы определенные условия: Необходимо суметь подобрать хорошие абстракции для старого ПО, которое, не будучи объектным, как правило, имеет дело с абстракциями функций, а не данных. Но в этом и состоит задача - обернуть старые функции в новые классы. Если с выделением абстракций не удастся справиться, то никакая ОО-перестройка не поможет.Наследуемое ПО должно быть хорошего качества. Перестроенное старье остается старьем - возможно хуже первоначального, поскольку оно будет скрыто под слоями абстракции.
Эти два требования частично сходны, поскольку качество любого ПО в значительной степени определяется качеством его структуры.
Когда они выполнены, можно использовать внешний механизм для построения интересного ОО-программного продукта, основанного на прежних разработках. Приведем два примера, являющихся частью среды, описанной в последней лекции.
Библиотека Vision (библиотеки описываются в лекции 14 курса "Основы объектно-ориентированного проектирования") дает переносимую графику и механизмы пользовательского интерфейса, позволяющие разработчикам создавать графические приложения для многих различных платформ с ощущением обычной перекомпиляции. Внутренне, она основана на "родных" механизмах, используемых во внешних программах. Точнее, ее нижний уровень инкапсулирует механизмы соответствующих платформ.Другая библиотека, Math, обеспечивает широкий набор возможностей численных вычислений в таких областях как теория вероятностей, статистика, численное интегрирование, линейные и нелинейные уравнения, дифференциальные уравнения, оптимизация, быстрое преобразование Фурье, анализ временных рядов. Внутренне она основана на коммерческой библиотеке подпрограмм, библиотеке NAG от Nag Ltd., Oxford, но обеспечивает пользователям ОО-интерфейс. Библиотека скрывает используемые ею программы и предлагает абстрактные объекты, понятные математику, физику или экономисту, представленные классами: INTEGRATOR, BASIC_MATRIX, DISCRETE_FUNCTION, EXPONENTIAL_DISTRIBUTION. Прекрасные результаты достигаются благодаря качеству внешних программ - NAG аккумулирует сотни человеко-лет разработки и реализации численных алгоритмов. К нему добавлены ОО-преимущества: классы, скрытие информации, множественное наследование, утверждения, систематическая обработка ошибок через исключительные ситуации, согласованное именование.
Эти примеры типичны для сочетания лучших традиционных программных продуктов и объектной технологии.
Отладка
Инструкция отладки является средством условной компиляции. Она записывается так:
debug instruction; instruction; ... end
В файле управления (Ace-файле) для каждого класса можно включить или отключить параметр debug. При его включении все инструкция отладки данного класса выполняются, при отключении - они не влияют на выполнение. Эту инструкцию можно использовать для включения специальных действий, выполняющихся только в режиме отладки, например, печати некоторых величин.
Передача аргументов
Один из аспектов нотации требует разъяснений: что происходит со значениями, переданными в качестве аргументов подпрограмме? Рассмотрим вызов в форме
r (a1, a2, ..., an)
соответствующий программе
r (x1: T1, x2: T2, ..., xn: Tn) is ...
где r может быть как функцией, так и процедурой, и вызов может быть квалифицированным, как в b.r (...). Выражения a1, a2, ..., an называются фактическими аргументами, а xi - формальными. (Помните, что для родовых параметров типа остается термин "параметр".) Встают важные вопросы: каково соответствие между фактическими и формальными аргументами? Какие операции допустимы над формальными аргументами? Каково их влияние на соответствующие фактические аргументы? Ответ на первый вопрос: эффект связывания фактических - формальных аргументов таков же как соответствующего присваивания. Обе операции называются присоединением (attachment). В предыдущем вызове можно считать, что запуск программы начинается с выполнения команд, неформально эквивалентных присваиваниям:
x1 := a1; x2 := a2;... xn := an
Ответ на второй вопрос: внутри тела программы любой формальный аргумент x защищен. Программа не может применять к нему прямых модификаций, таких как: Присваивание x значения в форме x := ...Процедуры создания, где x является целью: create x.make (...)| Читатели, знакомые с механизмом передачи, известным как вызов по значению, поймут, что здесь ограничения более строгое: при вызове по значению формальные аргументы инициализируются значениями фактических, но затем могут быть целью любых операций. |
Ответ на третий вопрос - что может программа делать с фактическими аргументами? - вытекает из того, что присоединение используется для задания семантики связывания формальных и фактических аргументов. Присоединение (см. лекцию 8) означает копирование либо ссылки, либо объекта. Это зависит от того, являются ли соответствующие типы развернутыми: Для ссылок (обычный случай) при передаче аргументов копируется ссылка, - Void, либо присоединенная к объекту.Для развернутых типов (включающих основные типы INTEGER, REAL и т.п.), при передаче аргументов копируется объект.
В первом случае, запрет операций прямой модификации означает, что нельзя модифицировать ссылку (reference) через повторное присоединение или создание. Но если ссылка не пустая, то разрешается модифицировать присоединенный объект.
 Рис. 13.1. Допустимые операции на аргументе ссылки
Если xi - один из формальных аргументов r, то тело программы может содержать вызов:
xi.p (...)
где p - процедура, применимая к xi, (объявлена в базовом классе типа Ti аргумента xi). Процедура может модифицировать поля объекта, присоединенного к xi во время выполнения, то есть объекта, присоединенного к соответствующему фактическому аргументу ai.
Вызов q (a) никогда не может изменить значение a, если a развернутого типа и является объектом. Если же a является ссылкой, то ссылка не меняется, но объект, присоединенный к ней, может измениться в результате вызова.
Существует много причин, по которым не следует позволять программам прямую модификацию их аргументов. Одна из самых убедительных - Конфликтующие присваивания. Предположим, что язык допускает присваивания аргументам, и процедура1)
dont_I_look_innocuous (a, b: INTEGER) is -- я выгляжу -- безвредной, но не стоит мне доверять. do a := 0; b := 1 end
Теперь рассмотрим вызов dont_I_look_innocuous (x, x). Каково значение x после возвращения: 0 или 1? Ответ зависит от того, как компилятор реализует изменения формальных - фактических аргументов при выходе программы. Это ставит в тупик не только программистов, использующих язык Fortran.
Разрешение программе изменять аргументы приводит к ограничениям на фактические аргументы. В этом случае он должен быть элементом, способным изменять свое значение, что допустимо для переменных, но не постоянных атрибутов (см. лекцию 18). Недопустимым фактическим аргументом становится сущность Current, выражения, такие как a + b. Устранение модификации аргументов позволяет избежать подобных ограничений и использовать любые выражения в качестве фактических аргументов.
Следствием этих правил является признание того, что только три способа допускают модификацию значения ссылки x: процедура создания create x...; присваивание x := y; и попытка присваивания x ?= y, обсуждаемая ниже.Передача x как фактического аргумента никогда не модифицирует x.
Это также означает, что процедура не возвращает ни одного результата, функция - официальный результат, представленный сущностью Result. Для получения нескольких результатов необходимо одно из двух:
Использовать функцию, возвращающую объект с несколькими полями (обычно, возвращается ссылка на такой объект).Использовать процедуру, изменяющую поля объектов соответствующих атрибутов. Затем клиент может выполнять запросы к этим полям.
Первый прием уместен, когда речь идет о составном результате. Например, функция не может возвращать два значения, соответствующих заглавию и году публикации книги, но может возвращать одно значение типа BOOK, с атрибутами title и publication_year. В более общих ситуациях применяются процедуры. Эта техника будет обсуждаться вместе с вопросом побочных эффектов в разделе принципов модульного проектирования2).
Повторение вычислений
Инструкция повторного выполнения рассматривалась при обсуждении исключительных ситуаций (лекция 12). Она появляется только в предложении rescue, повторно запуская тело подпрограммы, работа которой была прервана.
Присваивание (Assignment)
Инструкция присваивания записывается в виде:
x := e
где x - сущность, допускающая запись (writable), а e - выражение совместимого типа. Такая сущность может быть: неконстантным атрибутом включающего класса;локальной сущностью включающей подпрограммы. Для функции допустима сущность Result.
Сущности, не допускающие запись, включают константные атрибуты и формальные аргументы программы - которым, как мы видели, подпрограмма не может присваивать новое значение.
Проверка
Инструкция проверки рассматривалась при обсуждении утверждений (лекция 11). Она говорит, что определенные утверждения должны удовлетворяться в определенных точках:
check assertion -- Одно или больше предложений end
Создание (Creation)
Инструкция создания изучалась в предыдущих лекциях3) в двух ее формах: без процедуры создания, как в create x, и с процедурой создания, как в create x.p (...). В обоих случаях x должна быть сущностью, допускающей запись.
Строки
Класс STRING описывает символьные строки. Он имеет специальный статус, поскольку нотация допускает манифестные строковые константы, обозначающие экземпляры STRING. Строковая константа записывается в двойных кавычках, например,
"ABcd Ef ~*_ 01"
Символ двойных кавычек должны предваряться знаком %, если он появляется как один из символов строки. Неконстантные строки также являются экземплярами класса STRING, чья процедура создания make принимает в качестве аргумента ожидаемую начальную длину строки, так что
text1, text2: STRING; n: INTEGER; ... create text1.make (n)
динамически размещает строку text1, резервируя пространство для n символов. Заметим, что n - только исходный размер, не максимальный. Любая строка может увеличиваться и сжиматься до произвольного размера. На экземплярах STRING доступны многочисленные операции: сцепление, выделение символов и подстрок, сравнение и т.д. (Они могут изменять размер строки, автоматически запуская повторное размещение, если размер строки становится больше текущего.) Присваивание строк означает разделение (sharing): после text2 := text1, любая модификация text1 модифицирует text2, и наоборот. Для получения копии строки, а не копии ссылки, используется клонирование text2 := clone (text1). Константную строку можно объявить как атрибут:
message: STRING is "Your message here"
Текущий объект
Зарезервированное слово Current означает текущий экземпляр класса и может использоваться в выражении. Само Current - тоже выражение, а не сущность, допускающая запись. Значит присваивание Current, например, Current := some_value будет синтаксически неверным. При ссылке на компонент (атрибут или программу) текущего экземпляра нет необходимости писать Current.f, достаточно написать f. Поэтому Current используется реже, чем в ОО-языках, где каждая ссылка на компонент должна быть явно квалифицированной. (Например, в Smalltalk компонент всегда квалифицирован, даже когда он применим к текущему экземпляру.) Случаи, когда надо явно называть Current включают: Передачу текущего экземпляра в качестве аргумента в программу, как в a.f (Current). Обычное применение - создание копии (duplicate) текущего экземпляра, как в x: = clone (Current).Проверку,- присоединена ли ссылка к текущему экземпляру, как в проверке x = Current.Использование Current в качестве опорного элемента в "закрепленном объявлении" в форме like Current (лекция 16).
У13.1 Внешние классы
При обсуждении интеграции внешнего не объектного ПО с объектной системой отмечалось, что компоненты являются тем уровнем, на котором нужно осуществлять интеграцию. Когда же речь идет об интеграции с ПО, созданным на другом объектном языке, уровнем интеграции могут быть классы. Рассмотрите понятие "внешнего класса" как дополнение к нотации книги.
У13.2 Избегая нестрогих операторов
Напишите цикл для поиска элемента x в массиве a, подобный алгоритму в этой лекции, но не использующий нестрогих операторов.
1)
ПРЕДУПРЕЖДЕНИЕ: некорректный текст программы. Только для целей иллюстрации.
2)
См. лекцию 5 курса "Основы объектно-ориентированного проектирования", особенно "Схема, основанная на опыте".
3)
См. "Инструкция создания" и "Процедуры создания", лекцию 8. Один из вариантов рассмотрен в "Полиморфное создание", лекция 14.
4)
Это элементарная схема. О более сложных технических приемах обработки пользовательских команд см. лекцию 3 курса "Основы объектно-ориентированного проектирования".
|
|  |
Улучшенные варианты
Описанный механизм включает большинство случаев и достаточен для целей описания нашей книги. На практике полезны некоторые уточнения: Некоторые внешние программные элементы могут быть макросами. Они имеют вид подпрограмм в ОО-мире, но любой их вызов предполагает вставку тела макроса в точке вызова. Этого можно достичь вариацией имени языка (как, например, "C:[macro]...").Необходимо также разрешить вызовы программ из "динамически присоединяемых библиотек" (DLL), доступных в Windows и других платформах. Программа DLL загружается динамически во время первого вызова. Имя программы и библиотеки разрешается также задавать динамически в период выполнения. Поддержка DLL должна включать как способ статической спецификации имени, так и полностью динамический подход с использованием библиотечных классов DYNAMIC_LIBRARY и DYNAMIC_ROUTINE. Эти классы можно инициализировать во время выполнения, создавая объекты, представляющие динамически определенные библиотеки и подпрограммы.Необходима и связь в обратном направлении, позволяющая не объектному ПО создавать объекты и вызывать компоненты. Например, графической системе может понадобиться механизм обратного вызова (callback mechanism), вызывающий определенные компоненты класса.
Все эти возможности присутствуют в ОО-среде, описанной в последней лекции. Однако их подробное обсуждение - это отдельный разговор.
Условная Инструкция (Conditional)
Эта инструкция задает различные формы обработки в зависимости от выполнения определенных условий. Основная форма:
if boolean_expression then instruction; instruction; ... else instruction; instruction; ... end
где каждая ветвь может иметь произвольное число инструкций (а возможно и не иметь их). Будут выполняться инструкции первой ветви, если boolean_expression верно, а иначе - второй ветви. Можно опустить часть else, если второй список инструкций пуст, что дает:
if boolean_expression then instruction; instruction; ... end
Когда есть более двух возможных случаев, можно избежать вложения (nesting) условных команд в частях else, используя одну или более ветвей elseif, как в:
if c1 then instruction; instruction; ... elseif c2 then instruction; instruction; ... elseif c3 then instruction; instruction; ... ... else instruction; instruction; ... end
где часть else остается факультативной. Это дает возможность избежать вложения
if c1 then instruction; instruction; ... else if c2 then instruction; instruction; ... else if c3 then instruction; instruction; ... ... else instruction; instruction; ... end end end
Когда необходим множественный разбор случаев, более удобна инструкция множественного выбора inspect, обсуждаемая ниже. ОО-метод, благодаря полиморфизму и динамическому связыванию, уменьшает необходимость явных условных инструкций и множественного выбора, поддерживая неявную форму выбора. Когда объект применяет некоторый компонент, имеющий несколько вариантов, то во время выполнения нужный вариант выбирается автоматически в соответствии с типом объекта. Этот неявный стиль выбора обычно предпочтительнее, но, конечно, инструкции явного выбора остаются необходимыми.
Внешние программы
ОО-системы состоят из классов, образованных компонентами (features), в частности, подпрограммами, содержащими инструкции. Что же является правильным уровнем модульности (granularity) для интегрирования внешнего программного продукта? Конструкция должна быть общей - это исключает классы, существующие только в ОО-языках. Инструкции - слишком низкий уровень. Последовательность, в которой две ОО-инструкции окаймляют инструкцию на языке С:
-- только в целях иллюстрации create x l make (clone (a)) (struct A) *x = &y; /* A piece of C */ x.display
трудно было бы понять, проверить, сопровождать. Остается уровень компонентов. Он разумен и допустим, поскольку инкапсуляция компонентов совместима с ОО-принципами. Класс является реализацией типа данных, защищенных скрытием информации. Компоненты - единицы взаимодействия класса с остальной частью ПО. Поскольку клиенты полагаются на официальную спецификацию компонентов (краткую форму) независящую от их реализации, внешнему миру неважно, как написан компонент - в ОО-нотации или нет. Отсюда вытекает понятие внешней программы. Внешняя программа имеет большинство признаков нормальной программы: имя, список аргументов, тип результата, если это функция, предусловие и постусловие, если они уместны. Вместо предложения do она имеет предложение external, определяющее язык реализации. Следующий пример взят из класса, описывающего символьные файлы:
put (c: CHARACTER) is -- Добавить c в конец файла. require write_open: open_for_write external "C" alias "_char_write"; ensure one_more: count = old count + 1 end
Предложение alias факультативно и используется, только если оригинальное имя внешней программы отличается от имени, данного в классе. Это случается, когда внешнее имя недопустимо в ОО-нотации, например, имя, начинающееся с символа подчеркивания (используемое в языке С).
Вопрос совместимости: гибридный программный продукт или гибридные языки?
Теоретически, мало кто не согласится с принципом скромности или будет отрицать необходимость механизма интеграции между ОО-разработками и старым ПО. Противоречия возникают, когда выбирается уровень интеграции. Многие языки - самыми известными являются Objective-C, C++, Java, Object Pascal и Ada 95 - пошли по пути добавления ОО-конструкций в существовавший не ОО-язык. Они известны как гибридные языки (hybrid languages) - см. лекцию 17 курса "Основы объектно-ориентированного проектирования". Техника интеграции, описанная выше, основывалась на внешних программах и ОО-перестройке. Это другой принцип: необходимость в совместимости ПО не означает перегрузку языка механизмами, могущими расходиться с принципами объектной технологии. Гибрид добавляет новый языковой уровень к существующему языку, например С. В результате сложность может ограничить привлекательность объектной технологии - простоту идей.Начинающие часто с трудом осваивают гибридный язык, поскольку для них неясно, что именно является ОО, а что досталось из прошлого.Старые механизмы могут быть несовместимыми, по крайней мере, с некоторыми аспектами ОО-идей. Есть много примеров несоответствий между системой типов языков С или Pascal и ОО-подходом.Не объектные механизмы часто конкурируют со своими аналогами. Например, C++ предлагает, наряду с динамическим связыванием, возможность динамического выбора, используя аппарат указателей функций. Это смущает неспециалиста, не понимающего, какой подход выбрать в данном случае. В результате, программный продукт, хотя и создан ОО-средой, по сути является реализацией на языке С, и не дает ожидаемого качества и производительности, дискредитируя объектную технологию.
Если целью является получение наилучших программных продуктов и процесса их разработки, то компромисс на уровне языка кажется неправильным подходом. Взаимодействие (Interfacing) ОО-инструментария и приемов с достижениями прошлого и смешивание (mixing) различных уровней технологии - не одно и то же.
| Можно привести пример из электроники. Конечно, полезно сочетать различные уровни технологии в одной системе, например, звуковой усилитель включает несколько диодов наряду с транзисторами и интегральными схемами. Но мало проку от компонента, который является полудиодом, полутранзистором. |
ОО-разработка должна обеспечивать совместимость с ПО, построенным на других подходах, но не за счет преимуществ и целостности метода. Этого и достигает внешний механизм: отдельные миры, каждый из которых состоятелен и имеет свои достоинства, и четкий интерфейс, обеспечивающий взаимодействие между ними.
Ввод и вывод
Два класса библиотеки KERNEL обеспечивают основные средства ввода и вывода: FILE и STD_FILES. Среди операций, определенных для объекта f типа FILE, есть следующие:
create f.make ("name") -- Связывает f с файлом по имени name.
f.open_write -- Открытие f для записи
f.open_read -- Открытие f для чтения
f.put_string ("A_STRING") -- Запись данной строки в файл f
Операции ввода-вывода стандартных файлов ввода, вывода и ошибок, можно наследовать из класса STD_FILES, определяющего компоненты input, output и error. В качестве альтернативы можно использовать предопределенное значение io, как в io.put_string ("ABC"), обходя наследование.
Выражения с операторами
Выражения могут включать знаки операций или операторы. Унарные операторы + и - применяются к целым и вещественным выражениям и не применяются к булевым выражениям. Бинарные операторы, имеющие точно два операнда, включают операторы отношения:
= /= < > <= >=
где /= означает "не равно". Значение отношения имеет булев тип. Выражения могут включать один или несколько операндов, соединенных операторами. Численные операнды могут соединяться следующими операторами:
+ - . / ^ // \\
где // целочисленное деление, \\ целый остаток, а ^ степень (возведение в степень). Булевы операнды могут соединяться операторами: and, or, xor, and then, or else, implies. Последние три объясняются в следующем разделе; xor - исключающее или. Предшествование операторов, основанное на соглашениях обычной математики, строится по "Принципу Наименьшей Неожиданности". Во избежание неопределенности и путаницы, в книге используются скобки, даже там, где они не очень нужны.
Выражения
Выражение задает вычисление, вырабатывающее значение, - объект или ссылку на объект. Выражениями являются: неименованные (манифестные) константы;сущности (атрибуты, локальные сущности, формальные аргументы, Result);вызовы функций;выражения с операторами (технически - это специальный случай вызова функций);Current.
Вызов процедуры
При вызове указывается имя подпрограммы, возможно, с фактическими аргументами. В инструкции вызова подпрограмма должна быть процедурой. Вызов функции является выражением. Хотя сейчас нас интересуют инструкции, следующие правила применимы в обоих случаях. Вызов может быть квалифицированным или неквалифицированным. Для неквалифицированного вызова подпрограммы из включающего класса в качестве цели используется текущий экземпляр класса. Этот вызов имеет вид: r (без аргументов), или r (x, y, ...) (с аргументами) Квалифицированный вызов явно называет свою цель, заданную некоторым выражением. Если a - выражение некоторого типа, C - базовый класс этого типа, а - q одна из программ C, то квалифицированный вызов имеет форму a.q. Опять же, за q может следовать список фактических аргументов; a может быть неквалифицированным вызовом функции с аргументами, как в p (m).q (n), где p(m) - это цель. В качестве цели можно также использовать более сложное выражение при условии заключения его в скобки, как в (vector1 + vector2).count. Также разрешаются квалифицированные вызовы с многоточием в форме: a.q1q2 ...qn, где a, так же, как и qi , может включать список фактических аргументов. Экспорт управляет применением квалифицированных вызовов. Напомним, что компонент f, объявленный в классе B, доступен в классе A (экспортирован классу), если предложение feature, объявляющее f, начинается с feature (без дальнейшего уточнения) или feature {X, Y,... }, где один из элементов списка {X, Y,...} является A или предком A. Имеет место: Правило Квалифицированного Вызова Квалифицированный вызов вида b.q1. q2.... qn, появляющийся в классе C корректен, только если он удовлетворяет следующим условиям: Компонент, стоящий после первой точки, q1, должно быть доступен в классе C.В вызове с многоточием, каждый компонент после второй точки, то есть, каждое qi для i > 1, должен быть доступен в классе C.
Чтобы понять причину существования второго правила, отметим, что a.q.r.s - краткая запись для
b:= a.q; c:=b.r; c.s
которая верна только, если q, r и s доступны классу C, в котором появляется этот фрагмент. Не имеет значения, доступно ли r базовому классу типа q, и доступно ли s базовому классу типа r.
| Вызовы могут иметь инфиксную или префиксную форму. Выражение a + b, записанное в инфиксной форме, может быть переписано в префиксной форме: a.plus (b). Для обеих форм действуют одинаковые правила применимости. |
Вызовы функций
Вызовы функций имеют такой же синтаксис, как и вызовы процедур. Они могут быть квалифицированные и неквалифицированные: в первом случае используется нотация с многоточием. При соответствующих объявлениях класса и функций, они, например, таковы:
b.f b.g(x, y, ...) b.h(u, v).i.j(x, y, ...)
Правило квалифицированного вызова, приведенное для процедур, применимо также к вызовам функций.
Взаимодействие с не объектным ПО
До сих пор, элементы ПО выражались полностью в ОО-нотации. Но программы появились задолго до распространения ОО-технологии. Часто возникает необходимость соединить объектное ПО с элементами, написанными, например, на языках С, Fortran или Pascal. Нотация должна поддерживать этот процесс. Сначала следует рассмотреть языковой механизм, а затем поразмышлять над его более широким значением как части процесса разработки ОО-продукта.
Многоугольники и прямоугольники
Что делать с отложенными классами?
Присутствие отложенных элементов в системе вызывает вопрос: "что случится, если компонент 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 ...
Такие примеры являются комбинацией и кульминацией уникальных средств абстракции ОО-метода таких, как классы, скрытие информации, единственный выбор, наследование, полиморфизм, динамическое связывание, отложенные классы (и, как будет видно дальше, утверждения). Вы манипулируете объектами, не зная точно их типов, задавая только минимум информации, необходимой для требуемых операций. Имея надежный штамп контролера типов, удостоверяющий согласованность вызовов этих операций с их объявлениями, можно рассчитывать на большую силу - динамическое связывание, которая позволяет применять корректную версию каждой операции, не зная точно, что это за версия.
Что на самом деле происходит при полиморфном присоединении?
Все сущности, встречающиеся в предыдущих примерах полиморфных присваиваний, имеют тип ссылок: возможными значениями p, r и t являются не объекты, а ссылки на объекты. Поэтому результатом присваивания p := r является просто новое присоединение ссылки.
 Рис. 14.3. Полиморфное присоединение ссылки Несмотря на название, не следует представлять полиморфизм как некоторую трансмутацию объектов во время выполнения программы. Будучи один раз создан, объект никогда не изменяет свой тип. Так могут поступать только ссылки, которые могут указывать на объекты разных типов. Отсюда также следует, что за полиморфизм не нужно платить потерей эффективности, перенаправление ссылки - очень быстрая операция, ее стоимость не зависит от включенных в эту операцию объектов. Полиморфные присоединения допускаются только для целей типа ссылки, но, ни в коем случае, для расширенных типов. Поскольку у класса-потомка могут быть новые атрибуты, то соответствующие ему экземпляры могут иметь больше полей. На рис. 14.3 видно, что объект класса RECTANGLE больше, чем объект класса POLYGON. Такая разница в размерах объектов не приводит к проблемам, если все, что заново присоединяется, имеет тип ссылки. Но если p - не ссылка, а имеет развернутый тип (например, объявлена как expanded POLYGON), то значением p является непосредственно некоторый объект, и всякое присваивание p будет менять содержимое этого объекта. В этом случае никакой полиморфизм невозможен.
Динамическое связывание
Динамическое связывание дополнит переопределение, полиморфизм и статическую типизацию, создавая базисную тетралогию наследования.
Динамическое связывание и эффективность
Можно подумать, что сила механизма динамического связывания приведет во время выполнения к недопустимым накладным расходам. Такая опасность существует, но аккуратное проектирование языка и хорошие методы его реализации могут ее предотвратить. Дело в том, что динамическое связывание требует несколько большего объема действий во время выполнения. Сравним вызов обычной процедуры в традиционном языке программирования (Pascal, Ada, C, ...)
f (x, a, b, c...)
с ОО-формой
x.f (a, b, c...)
Разница между этими двумя формами уже была разъяснена при введении понятия класса, для идентификации типа модуля. Но сейчас мы понимаем, что это связано не только со стилем, имеется также различие и в семантике. В форме (1), какой именно компонент обозначает имя f известно статически во время компиляции или, в худшем случае, во время компоновки, если для объединения раздельно откомпилированных модулей используется компоновщик. Однако при динамическом связывании такая информация недоступна статически: для f в форме (2) выбор компонента зависит от объекта, к которому присоединен x во время конкретного выполнения. Каким будет этот тип нельзя (в общем случае) определить по тексту программы, это служит источником гибкости этого ранее разрекламированного механизма. Предположим вначале, что динамическое связывание реализовано наивно. Во время выполнения хранится копия иерархии классов. Каждый объект содержит информацию о своем типе - вершине в этой иерархии. Чтобы интерпретировать во время выполнения x.f, окружение ищет соответствующую вершину и проверяет, содержит ли этот класс компонент f. Если да, то прекрасно, мы нашли то, что требовалось. Если нет, то переходим к вершине-родителю и повторяем всю операцию. Может потребоваться проделать путь до самого верхнего класса (или нескольких таких классов в случае множественного наследования).
| В типизированном языке нахождение подходящего компонента гарантировано, но в нетипизированном языке, таком как Smalltalk, поиск может быть неудачным, и придется завершить выполнение диагнозом "сообщение не понято". |
Такая схема все еще применяется с различными оптимизациями во многих реализациях не статически типизированных языков. Она приводит к существенным затратам, снижающим эффективность. Хуже того, эти затраты не прогнозируемы и растут с увеличением глубины структуры наследования, так как алгоритм может постоянно проходить путь до корня иерархии наследования. Это приводит к конфликту между повторным использованием и эффективностью, поскольку упорная работа над повторное использование м приводит к введению дополнительных уровней наследования. Представьте состояние бедного разработчика, который перед добавлением нового уровня наследования должен оценить, как это ударит по эффективности. Нельзя ставить разработчиков ПО перед таким выбором.
Такой подход является одним из главных источников неэффективности реализаций языка Smalltalk. Это также объясняет, почему он (по крайней мере, в коммерческих реализациях) не поддерживает множественного наследования. Причина - в том, что из-за необходимости обходить весь граф, а не одну ветвь, накладные расходы оказываются чрезмерными.
К счастью, использование статической типизации устраняет эти неприятности. При правильно построенной системе типов и алгоритмах компиляции нет никакой нужды перемещаться по структуре наследования во время выполнения. Для ОО-языка со статической типизацией возможные типы x не произвольны, а ограничены потомками исходного типа x, поэтому компилятор может упростить работу системы выполнения, построив массив структурных данных, содержащих всю необходимую информацию. При наличии этих структур данных накладные расходы на динамическое связывание сильно уменьшаются: они сводятся к вычислению индекса и доступу к массиву. Важно не только то, что такие затраты невелики, но и то, что они ограничены константой, и поэтому можно не беспокоиться о рассмотренной выше проблеме соотношения между переиспользуемостью и эффективностью. Будет ли структура наследования в вашей системе иметь глубину 2 или 20, будет ли в ней 100 классов или 10000, максимальные накладные р асходы всегда одни и те же.
Они не зависят и от того, является ли наследование единичным или множественным.
Открытие в 1985г. этого свойства, т.е. того, что даже при множественном наследовании можно реализовать вызов динамически связываемого компонента за константное время, было главным побудительным толчком к разработке проекта (среди прочего приведшего к появлению первого и настоящего изданий этой книги), направленного на построение современного окружения для разработки ПО, отталкивающегося от идей великолепно введенных языком Simula 67, распространенных затем на множественное наследование (длительный опыт применения Симулы показал, что непозволительно ограничиваться единичным наследованием), приведенных в соответствие с принципами современной программной инженерии и объединяющих эти идеи с самыми полезными результатами формальных подходов к спецификации, построению и верификации ПО. Создание эффективного механизма динамического связывания за константное время, которое на первый взгляд может показаться второстепенным среди указанного набора целей, на самом деле было настоятельно необходимым.
Эти наблюдения могут показаться странными тому, кто познакомился с ОО-технологией через линзу представлений об ОО-анализе и проектировании, в которых реализация и эффективность рассматриваются как приземленные предметы, которыми следует заниматься после того, как решено все остальное. Эффективность является одним из ключевых факторов, который должен рассматриваться на всех шагах, когда речь идет о реальной разработке промышленного ПО, о реальной увязке инженерных решений. Как отмечалось в одной из предыдущих лекций, если вы откажетесь от эффективности, то эффективность откажется от вас. Конечно, ОО-технология это нечто большее, чем динамическое связывание за константное время, но без этого не могло бы быть никакой успешной ОО-технологии. |
Доступ к предшественнику процедуры
Напомним правило использования конструкции Precursor (...): она может появляться только в переопределяемой версии процедуры. Этим обеспечивается цель введения этой конструкции: позволить новому определению использовать первоначальную реализацию. При этом возможность явного указания родителя устраняет всякую неопределенность (в частности, при множественном наследовании). Если бы допускался доступ любой процедуры к любому компоненту предков, то текст класса было бы трудно понять, читателю все время приходилось бы обращаться к текстам многих других классов.
Движения произвольных фигур
Чтобы понять необходимость в отложенных процедурах и классах, снова рассмотрим иерархию фигур 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) имеют интуитивно хорошо понятную семантику и "ничего не делать" не является их разумной реализацией.
Двойственная перспектива
По-видимому, нигде двойственная роль классов как модулей, с одной стороны, и типов - с другой, не проявляется так отчетливо, как при изучении наследования. При взгляде на класс, как на модуль, наследник описывает расширение модуля-родителя, а при взгляде на него, как на тип, он описывает подтип типа родителя. Хотя некоторые аспекты наследования больше относятся к взгляду на класс, как на тип, большая часть полезна для обоих подходов, о чем свидетельствует приведенная примерная классификация (на которой отражены также несколько еще не изученных аспектов: переименование, скрытие потомков, множественное и повторное наследование). Ни один из рассматриваемых аспектов не относится исключительно к взгляду на класс, как на модуль.
 Рис. 14.11. Механизмы наследования и их роль Эти два взгляда дополняют друг друга, придавая наследованию силу и гибкость. Эта сила может даже показаться пугающей, что побуждает предложить разделить механизм на два: на возможность расширять модули и на механизм выделения подтипов. Но когда мы вникнем в проблему глубже (в лекции о методологии наследования), то обнаружим, что у такого разделения имеется множество недостатков, и нет явных преимуществ. Наследование - это объединяющий принцип, как и многие другие объединяющие идеи в науке, он соединяет вместе явления, рассматриваемые ранее как различные.
Использование исходной версии при переопределении
Рассмотрим некоторый класс, который переопределяет подпрограмму, унаследованную от родителя. Обычная схема переопределения состоит в том, чтобы выполнить все, что делает исходная версия, предпослав ей или поместив за ней некоторые специальные действия. Например, класс 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
В случае множественного наследования, рассматриваемого в следующей лекции, у процедуры может быть несколько предшественников, что позволяет объединить несколько наследуемых процедур в одну. Тогда для устранения неоднозначности нужно будет указывать родителя, например, Precursor {WINDOW}.
Заметим, что использование конструкции Precursor не делает компонент-предшественник компонентом данного класса, компонентом является только его переопределенная версия. (В частности, предшествующая версия может не удовлетворять новому инварианту.) Целью конструкции является облегчение переопределения в случае, когда новая версия включает старую. | |
В более сложном случае, когда, в частности, требуется использовать и предшествующую и новую версии в качестве компонентов класса, можно воспользоваться дублируемым наследованием, при котором родительский компонент, фактически, дублируется, и у наследника создаются два законченных компонента. Это будет подробно обсуждаться при рассмотрении дублируемого наследования.
Использование правильного варианта
Операции, определенные для всех вариантов многоугольников, могут реализовываться по-разному. Например, 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, то будет выполняться именно эта версия.
| Вполне допустимо посылать ваших любимцев в отдел отсутствующих хозяев, который обслуживает все виды, если наверняка известно, что, когда придет время еды, ваш кот получит кошачью еду, а пес - собачью. |
Явное переопределение
Роль предложения redefine состоит в улучшении читаемости и надежности. Компиляторам, на самом деле, оно не нужно, так как в классе может быть лишь один компонент с данным именем, то объявленный в данном классе компонент, имеющий то же имя, что и компонент некоторого предка, может быть только переопределением этого компонента (или ошибкой). Не следует пренебрегать возможностью ошибки, так как программист может наследовать некоторый класс, не зная всех компонентов, объявленных в его предках. Для избежания этой опасности требуется явно указать каждое переопределение. В этом и состоит основная роль предложения redefine, которое также полезно при чтении класса.
Эффективизация компонента
В некоторых собственных потомках класса 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, подчиняется тем же правилам. Поэтому нужен общий термин. Определение: повторное объявление Повторное объявление компонента - означает определение или переопределение его реализации. Разница между этими двумя формами повторного объявления хорошо иллюстрируется примерами, приведенными при их определении: При переходе от POLYGON к RECTANGLE компонент perimeter уже реализован у родителя, и мы хотим предложить новую его реализацию в классе RECTANGLE. Это переопределение. Заметим, что этот компонент еще раз переопределяется в классе SQUARE.При переходе от FIGURE к POLYGON у родителя нет реализации компонента rotate, и мы хотим реализовать его в классе POLYGON. Это эффективизация. Собственные потомки POLYGON могут, конечно, переопределить эту эффективную версию.
Может появиться нужда в некотором изменении параметров наследуемого отложенного компонента, после которого оно все так же останется отложенным. Эти изменения могут затрагивать сигнатуру компонента - типы ее аргументов и результата - и его утверждения (точные ограничения будут указаны в следующей лекции). В отличие от перехода от отложенного компонента к эффективному, такой переход от отложенного к отложенному рассматривается как переопределение и требует предложения redefine. Приведем резюме четырех возможных случаев нового объявления:
Таблица 14.1. Эффекты повторного объявленияПовторное объявление компонента кПовторное объявление компонента от ОтложенныйЭффективный Отложенный| Переопределение | Отмена определения | Эффективный| Эффективизация | Переопределение |
В этой таблице имеется один еще не рассмотренный случай: отмена определения - переход от эффективного компонента к отложенному. При этом отменяется исходная реализация и начинается новая жизнь.
Экземпляры
С введением полиморфизма нам требуется уточнить терминологию, связанную с экземплярами. Содержательно, экземпляры класса - это объекты времени выполнения, построенные в соответствии с определением класса. Но сейчас в этом качестве нужно также рассматривать объекты, построенные для собственных потомков класса. Вот более точное определение: Определение: прямой экземпляр, экземпляр Прямой экземпляр класса 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.
Ключевые концепции
С помощью наследования можно определять новые классы как расширение, специализацию и комбинацию ранее определенных классов.Класс, наследующий другому классу, называется его наследником, а исходный класс - его родителем. Распространенные на произвольное число уровней (включая ноль) эти понятия становятся понятиями потомка и предка.Наследование является ключевым методом как для повторного использования, так и для расширяемости.Плодотворное применение наследования требует переопределения (предоставления классу возможности переписать реализацию некоторых компонентов его собственного предка), полиморфизма (возможности связывать ссылку во время выполнения с экземплярами разных классов), динамического связывания (динамического выбора подходящего варианта переопределенного компонента), совместности типов (требования, чтобы всякая сущность могла присоединяться только к экземплярам типов-наследников).С точки зрения модулей наследник расширяет набор служб, предоставляемых его родителями. В частности, это полезно для повторно использования.С точки зрения типов отношение между наследником и его родителем - это отношение "является". Оно полезно как для повторного использования, так и для расширяемости.Функцию без аргументов можно переопределить как атрибут, но не наоборот.Методы наследования, в особенности, динамическое связывание, позволяют разрабатывать децентрализованную архитектуру, в которой каждый вариант операции определяется в том же модуле, где описан соответствующий вариант структуры данных.Для типизированных языков динамическое связывание можно реализовать с малыми накладными расходами. Связанные с ним оптимизации, в частности, применяемое компилятором статическое связывание и подстановка кода, помогают ОО-программам достичь или превзойти эффективность выполнения традиционных программ.Отложенные классы содержат один или более отложенный (не реализованный) компонент. Они описывают частичные реализации абстрактных типов данных.Способность эффективных подпрограмм вызывать отложенные позволяет примирить с помощью "классов поведения" повторное использование с расширяемостью.Отложенные классы являются основным средством, используемым ОО-методами на стадиях анализа и проектирования.Утверждения, применяемые к отложенным компонентам, позволяют точно специфицировать отложенные классы.Если семантики динамического и статического связывания различны, то всегда нужно выбирать динамическое связывание. Если же они действуют одинаково, то статическое связывание следует рассматривать как оптимизацию, которую лучше возложить на компилятор. Компилятор может проверить и безопасно применить как эту оптимизацию, так и оптимизацию, связанную с подстановкой кода подпрограммы в точках вызова.
Кнопка под другим именем: когда статическое связывание ошибочно
К этому моменту должен стать понятным главный вывод из изложенных в этой лекции принципов наследования: Принцип динамического связывания Если результат статического связывания не совпадает с результатом динамического связывания, то такое статическое связывание семантически некорректно. Рассмотрим вызов x.r. Если x объявлена типа A, но в процессе вычисления была присоединена к объекту типа B, а в классе B компонент r переопределен, то использование в этом вызове исходной версии r из класса A - это не вопрос выбора, это просто ошибка! Безусловно, имелись причины для переопределения r. Одной из них могла быть оптимизация, как в случае с компонентом perimeter в классе RECTANGLE, но могло также оказаться, что исходная версия r просто некорректно работает для объектов из B. Рассмотрим, например, эскизно описанный класс BUTTON (КНОПКА), являющийся наследником класса WINDOW (ОКНО) в некоторой оконной системе (кнопки являются специальным видом окон). В этом классе переопределена процедура display, так как изображение кнопки немного отличается от изображения обычного окна (например, нужно показать ее рамку). В этом случае, если w имеет объявленный тип WINDOW, но динамически связана, благодаря полиморфизму, с объектом типа BUTTON, то вызов w.display должен исполняться для "кнопочной" версии! Использование display из класса WINDOW приведет к искажению изображения на экране. Мы не должны позволить, чтобы нас обманула гибкость системы типов, основанная на наследовании, особенно ее правило совместимости типов, позволяющее объявлять сущность на уровне абстракции более высоком, чем уровень типа присоединенного объекта во время конкретного выполнения. Во время выполнения программы единственное, что имеет значение, - это те объекты, к которым применяются компоненты, а сущности - имена в тексте программы - уже давно забыты. Кнопка под любым именем остается кнопкой, независимо от того, названа ли она в программе кнопкой или присоединена к сущности типа окно. Это рассуждение можно подкрепить некоторым математическим анализом.
Напомним условие корректности процедуры из лекции 11 об утверждениях:
{prer (xr) and INV} Bodyr {postr (xr) and INV}.
Для целей нашего обсуждения его можно немного упростить, оставив только часть, относящуюся к инвариантам классов, опустив аргументы и используя в качестве индекса имя класса A:
[A-CORRECT]
{INVA} rA {INVA}
Содержательно это означает, что всякое выполнение процедуры r из класса A сохраняет инвариант этого класса. Предположим теперь, что мы переопределили r в некотором собственном потомке B. Соответствующее свойство будет выполняться, если новый класс корректен:
[B-CORRECT]
{INVB} rB {INVB}
Напомним, что инварианты накапливаются при движении вниз по структуре наследования, так что INVB влечет INVA, но, как правило, не наоборот.
 Рис. 14.14. Версия родителя может не удовлетворять новому инварианту
Напомним, например, как RECTANGLE добавляет собственные условия к инварианту класса POLYGON. Другой пример, рассмотренный при изучении инвариантов в лекции 11, это класс ACCOUNT1 с компонентами withdrawals_list и deposits_list; его собственный потомок ACCOUNT2 добавляет к нему, возможно, по соображениям эффективности, новый атрибут balance для постоянного запоминания текущего баланса счета. К инварианту добавляется новое предложение:
consistent_balance: deposits_listltotal - withdrawals_listltotal = current_balance
Из-за этого, возможно, придется переопределить некоторые из процедур класса ACCOUNT1; например, процедура deposit, которая использовалась просто для добавления элемента в список deposits_list, сейчас должна будет модифицировать также balance. Иначе класс просто станет ошибочным. Это аналогично тому, что версия процедуры display из класса WINDOW не является корректной для экземпляра класса BUTTON.
Предположим теперь, что к объекту типа B, достижимому через сущность типа A, применяется статическое связывание. При этом из-за того, что соответствующая версия процедуры rA , как правило, не будет поддерживать необходимый инвариант (как, например, depositACCOUNT1 для объектов типа ACCOUNT2 или displayWINDOW для объектов типа BUTTON), будет получаться неверный объект (например, объект класса ACCOUNT2 с неправильным полем balance или объект класса BUTTON, неправильно показанный на экране).
Такой результат - объект, не удовлетворяющий инварианту своего класса, т.е. основным, универсальным ограничениям на все объекты такого вида - является одним из самых страшных событий, которые могут случиться во время выполнения программы. Если такая ситуация может возникнуть, то нечего надеяться на верный результат вычисления.
Суммируем: статическое связывание является либо оптимизацией, либо ошибкой. Если его семантика совпадает с семантикой динамического связывания (как в случаях (1) и (2)), то оно является оптимизацией, которую может выполнить компилятор. Если у него другая семантика, то это ошибка.
Когда хочется задать тип принудительно
В некоторых случаях нужно выполнить присваивание, не соответствующее структуре наследования, и допустить, что при этом в качестве результата не обязательно будет получен объект. Такого, обычно, не бывает, когда ОО-метод применяется к объектам, внутренним для некоторой программы. Но можно, например, поучить по сети объект с его объявленным типом, и поскольку нет возможности контролировать источник происхождения этого объекта, то объявления статических типов ничего не гарантируют и прежде, чем использовать объект, необходимо проверить его тип.
| При получении коробки с надписью "Животное" вместо ожидаемой надписи "Собака", можно соблазниться и все же ее открыть, зная, что, если внутри будет не собака, то потеряется право на возврат посылки и, в зависимости от того, что из нее появится, можно лишиться даже возможности рассказать эту историю. |
В таких случаях требуется новый механизм - попытка присваивания, который позволит писать команду вида r ?= p (где ?= обозначает символ попытки присваивания, в отличие от := для обычного присваивания), означающую "выполнить присваивание, если тип объекта соответствует r, а иначе сделать r пустым". Но мы пока не готовы понять, как такая команда сочетается с ОО-методом, поэтому вернемся к этому вопросу в следующих лекциях. (А до того, считайте, что вы ничего об этом не читали).
Многоугольники и прямоугольники
Для объяснения основных понятий рассмотрим простой пример. Здесь приведен скорее набросок этого примера, а не полный его вариант, но он хорошо показывает все существенные идеи.
Многоугольники
Предположим, что требуется построить графическую библиотеку. Ее классы будут описывать геометрические абстракции: точки, отрезки, векторы, круги, эллипсы, многоугольники, треугольники, прямоугольники, квадраты и т. п. Рассмотрим вначале класс, описывающий многоугольники. Операции будут включать вычисление периметра, параллельный перенос и вращение. Этот класс может выглядеть так:
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.
Может ли быть польза от неведения?
Поскольку введенные только что понятия играют важную роль в последующем, стоит еще раз повторить несколько последних положений. (На самом деле, в этом коротком пункте не будет ничего нового, но он поможет лучше понять основные концепции и подготовит к введению новых понятий). Если вы все еще испытываете неудобство от невозможности написать 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.
| Продолжая тему животных, представим, что некто спрашивает: "У вас есть домашний любимец?" и вы отвечаете: "Да, кот!". Это похоже на полиморфное присваивание - один объект известен под двумя именами разных типов: "мой_домашний_любимец" и "мой_кот" обозначают сейчас одно животное. Но они не служат одной цели, первое имя является менее информативным, чем второе. Можно одинаково успешно использовать оба имени при звонке в отдел отсутствующих хозяев компании Любимцы-По-Почте ("Я собираюсь в отпуск, сколько будет стоить наблюдение за моим_домашним_любимцем (или: моим_котом) в течение двух недель?") Но при звонке в другой отдел с вопросом: "Могу ли я привезти во вторник моего домашнего любимца, чтобы отстричь когти?", вы не запишетесь на прием, пока не уточните, что имели в виду своего кота. |
Наследование и децентрализация
Имея динамическое связывание, можно создавать децентрализованные архитектуры ПО, необходимые для достижения целей повторно использования и расширяемости. Сравним ОО-подход, при котором самодостаточные классы предоставляют свои множества вариантов операций, с классическими подходами. В Паскале или Аде можно использовать тип записи с вариантами
type FIGURE = record "Общие поля" case figtype: (polygon, rectangle, triangle, circle,...) of polygon: (vertices: LIST_OF_POINTS; count: INTEGER); rectangle: (side1, side2: REAL;...); ... end
чтобы определить различные виды фигур. Но это означает, что всякая программа, которая должна работать с фигурами (поворачивать и т.п.) должна проводить разбор возможных случаев:
case f.figure_type of polygon: ... circle: ... ... end
В случае таблиц процедура search должна была бы использовать ту же структуру. Неприятность состоит в том, что эти процедуры должны обладать чересчур большими знаниями о будущем всей системы: они должны точно знать, какие типы фигур в ней допускаются. Любое добавление нового типа или изменение существующего будет затрагивать каждую процедуру. Ne sutor ultra crepidam, (для сапожника ничего сверх сандалий) - это принцип разработки ПО: процедуре поворота не требуется знать полный список типов фигур. Ей должно хватать информации необходимой для выполнения своей работы: поворота некоторых видов фигур. Распределение информации среди чересчур большого количества процедур является главным источником негибкости классических подходов к разработке ПО. Основные трудности модификации ПО можно проследить, анализируя эту проблему. Она также частично объясняет, почему так трудно управлять программными проектами, когда совсем небольшие изменения имеют далеко идущие последствия, заставляя разработчиков переделывать модули, которые, казалось бы, были успешно завершены. ОО-методы также сталкиваются с этой проблемой. Изменение реализации операции затрагивает только тот класс, в котором применяется эта реализация. Добавление нового варианта некоторого типа в большинстве случаев не затронет другие классы. Причиной является децентрализация: классы заведуют своими собственными реализациями и не вмешиваются в дела друг друга. В применении к людям это звучало бы как Вольтеровское Cultivez votre jardin, - ухаживайте за своим собственным садом. В применении к модулям существенным является требование получения децентрализованных структур, которые изящно поддаются расширению, модификации, комбинированию и повторному использованию.
Наследование и конструкторы
Ранее не показанная процедура создания (конструктор) для класса 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.
Наследование инварианта
Хотелось бы указать инвариант класса RECTANGLE, который говорил бы, что число сторон прямоугольника равно четырем и что длины сторон последовательно равны side1, side2, side1 и side2. У класса POLYGON также имеется инвариант, который применим и к его наследнику: Правило наследования инварианта Инвариант класса является конъюнкцией утверждений из его раздела invariant и свойств инвариантов его родителей (если таковые имеются). Поскольку у родителей класса могут быть свои родители, то это правило рекурсивно: в результате полный инвариант класса получается как конъюнкция собственного инварианта и инвариантов классов всех его предков. Это правило отражает одну из важных характеристик наследования: сказать, что B наследует A - это утверждать, что каждый экземпляр B является также экземпляром A. Вследствие этого всякое выраженное инвариантом ограничение целостности, применимое к экземплярам A, будет также применимо и к экземплярам B. В нашем примере второе предложение (at_least_three) инварианта POLYGON утверждает, что число сторон должно быть не менее трех, оно является следствием предложения four_sides из инварианта класса RECTANGLE, которое требует, чтобы сторон было ровно четыре.
Назад к абстрактным типам данных
Насыщенные утверждениями отложенные классы хорошо подходят для представления АТД. Прекрасный пример - отложенный класс для стеков. Мы уже описывали процедуру put, сейчас приведем возможную версию полного описания этого класса.
indexing description: "Стеки (распределительные структуры с дисциплиной Last-in, First-Out), % %не зависящие от выбора представления" deferred class STACK [G] feature -- Доступ count: INTEGER is -- Число элементов. deferred end item: G is -- Последний вставленный элемент. require not_empty: not empty deferred end feature - Отчет о статусе empty: BOOLEAN is -- Стек пустой? do Result := (count = 0) end full: BOOLEAN is -- Стек заполнен? deferred end feature - Изменение элемента 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 remove is -- Вытолкнуть верхний элемент. require not empty deferred ensure not_full: not full one_less: count = old count - 1 end change_top (x: T) is -- Заменить верхний элемент на x require not_empty: not empty do remove; put (x) ensure not_empty: not empty new_top: item = x same_number_of_items: count = old count end wipe_out is -- Удалить все элементы. deferred ensure no_more_elements: empty end invariant non_negative_count: count >= 0 empty_count: empty = (count = 0) end
Этот класс показывает, как можно реализовать эффективную процедуру, используя отложенные: например, процедура change_top реализована в виде последовательных вызовов процедур remove и put. (Такая реализация для некоторых представлений, например, для массивов, может оказаться не самой лучшей, но эффективные потомки класса STACK могут ее переопределить.) Если сравнить класс STACK со спецификацией соответствующего АТД, приведенной в лекции 6, то обнаружится удивительное сходство. Подчеркнем, в частности, соответствие между функциями АТД и компонентами класса, и между пунктом PRECONDITIONS и предусловиями процедур. Аксиомы представлены в постусловиях процедур и в инварианте класса.
Добавление операций change_top, count и wipe_out в данном случае несущественно, так как они легко могут быть включены в спецификацию АТД (см. упражнение У6.8). Отсутствие явного эквивалента функции new из АТД также несущественно, так как созданием объектов будут заниматься процедуры-конструкторы в эффективных потомках этого класса. Остаются три существенных отличия.
Первое из них - это введение функции full, рассчитанной на реализации с ограниченным числом элементов стека, например, на реализацию массивами. Это типичный пример ограничения, которое несущественно на уровне спецификации, но необходимо для разработки практических систем. Отметим однако, что это отличие между АТД и отложенным классом можно легко устранить, включив в спецификацию АТД средства для охвата ограниченных стеков. При этом общность не будет потеряна, так как некоторые реализации (например, с помощью списков) могут реализовывать full тривиальными процедурами, всегда возвращающими ложь.
Второе отличие, отмеченное при обсуждении разработки по контракту, состоит в том, что спецификация АТД полностью аппликативна (функциональна), она включает функции без побочных эффектов. А отложенный класс, несмотря на его абстрактность, является императивным (процедурным), например put определена как процедура, изменяющая стек, а не как функция, которая берет в качестве аргумента один стек и возвращает другой.
Наконец, как тоже уже отмечалось, механизм утверждений недостаточно выразителен для некоторых аксиом АТД. Из четырех аксиом стеков
Для всех x: G, s: STACK [G],
item (put (s, x)) = x
remove (put (s, x)) = s
empty (new)
not empty (put (s, x))
все, кроме (2), имеют прямые эквиваленты среди утверждений. (Мы предполагаем, что для (3) процедуры-конструкторы у потомков обеспечат выполнение условия empty). Причины таких ограничений уже были объяснены и были намечены возможные пути их преодоления - языки формальных спецификаций IFL.
Не вызывайте нас, мы вызовем вас
Класс SEQUENTIAL_TABLE дает представление о том, как ОО-технология, используя понятие класса поведения, отвечает на последний оставшийся открытым в лекции 4 вопрос о "Факторизации общих поведений". Особенно интересна возможность определения такой эффективной процедуры в классе поведения, которая использует в своей реализации отложенные процедуры. Эта возможность проиллюстрирована выше процедурой has. Она показывает, как можно использовать частично отложенные классы для того, чтобы зафиксировать общее поведение нескольких вариантов. В отложенном классе описывается только то общее, что у всех них имеется, а описание вариаций остается потомкам. Ряд примеров в последующих лекциях будет базироваться на этом методе, который играет важную роль в применении ОО-методов к построению повторно используемого ПО. Он особенно полезен при создании библиотек для конкретных предметных областей и реально применяется во многих контекстах. Типичным примером, описанным в [M 1994a], является разработка библиотек Lex и Parse, предназначенных для анализа языков. В частности, Parse определяет общую схему разбора, по которой будет обрабатываться любой текст (формат данных для языка программирования и т.п.), структура которого соответствует некоторой грамматике. Классы поведения высокого уровня содержат небольшое число отложенных компонентов, таких как post_action, описывающих семантические действия, которые должны выполняться после разбора некоторой конструкции. Для определения собственной семантической обработки пользователю достаточно реализовать эти компоненты. Такая схема широко распространена. В частности, бизнес-приложения часто следуют стандартным образцам - обработать полученные за день счета, выполнить соответствующую проверку требований на платежи, ввести новых заказчиков и так далее, - индивидуальные компоненты которых могут варьироваться. В таких случаях можно предоставить набор классов поведения со смесью эффективных компонент, описывающих известную часть, и отложенных компонент, задающих изменяемые элементы.
Как правило, эффективные компоненты будут вызывать в своих телах отложенные. При таком подходе потомки могут создавать реализации, удовлетворяющие их потребностям.
| Не все изменяемые элементы следует откладывать. Если доступна реализация по умолчанию, то ее следует включить в качестве эффективного компонента, который при необходимости можно переопределить на уровне потомка. Это упростит разработку потомков, так как в них нужно будет реализовывать новые версии лишь тех компонент, которые отличаются от реализаций по умолчанию. Разумеется, такой метод следует применять лишь при наличии подходящей реализации по умолчанию, в противном случае соответствующий компонент следует объявить отложенным (как, например, display в классе FIGURE). |
Этот метод является частью более общего подхода, который можно окрестить "Не вызывайте нас, мы вызовем вас": не прикладная система вызывает повторно используемые примитивы, а универсальная схема позволяет разработчикам приложений размещать их собственные варианты в стратегических местах.
Эта идея не является абсолютно новой. Древняя и весьма почтенная СУБД IMS фирмы IBM уже использовала нечто в этом роде. Структура управления графических систем (таких как система X для Unix) включает "цикл по событиям", в котором на каждой итерации вызываются специфические функции, поставляемые разработчиками приложений. Этот подход известен как схема обратного вызова (callback scheme).
То, что предлагает ОО-метод, благодаря классам поведения, представляет систематическую, обеспечивающую безопасность поддержку этой техники разработки. Эта поддержка включает классы, наследование, проверку типов, отложенные классы и компоненты, а также утверждения, позволяющие разработчику сразу зафиксировать, каким условиям должны всегда удовлетворять изменяемые элементы.
Независимость от представления
Динамическое связывание связано с одним из принципиальных аспектов повторного использования: независимостью от представления, т.е. возможностью запрашивать исполнение некоторой операции, имеющей несколько вариантов, не уточняя, какой из них будет применен. В предыдущей лекции при обсуждении этого понятия использовался пример вызова
present := has (x, t)
который должен применить подходящий алгоритм поиска, зависящий от вида t во время выполнения. Если t объявлена как таблица, но может присоединяться к экземпляру бинарного дерева поиска, хеш-таблице и т. п. (в предположении, что все необходимые классы доступны), то при динамическом связывании вызов
present := t.has (x)
найдет во время выполнения подходящую версию процедуры has. С помощью динамического связывания достигается то, что было невозможно получить с помощью перегрузки и универсальности: клиент может запросить некоторую операцию, а поддерживающая язык система автоматически найдет ее соответствующую реализацию. Таким образом, объединение классов, наследования, переопределения, полиморфизма и динамического связывания дает прекрасные ответы на вопросы, поставленные в начале этой книги: требования повторного использования, критерии, принципы и правила модульности.
О реализации динамического связывания
Может возникнуть опасение, что динамическое связывание - это дорогой механизм, требующий во время выполнения поиска по графу наследования и поэтому накладных расходов, растущих с увеличением глубины этого графа. К счастью, это не так в случае хорошо спроектированного (и статически типизированного) ОО-языка. Более детально это будет обсуждаться в конце лекции, но мы можем уже сейчас успокоить себя тем, что последствия динамического связывания не будут существенными для эффективности при работе в подходящем окружении.
Обоснованы ли ограничения?
Приведенные выше правила типизации могут иногда показаться слишком строгими. Например, второй оператор в обоих случаях статически отвергается:
p:= r; r := p
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) весьма полезны.
Обратного пути нет
Можно было бы ожидать, что допустимо и обратное переопределение атрибута в функцию без аргументов. Но нет. Присваивание - операция применимая к атрибутам, - становится бессмысленной для функций. Предположим, что a - это атрибут класса C, и некоторая подпрограмма содержит команду
a := some_expression
Если потомок C переопределит a как функцию, то эта функция будет не применима, поскольку нельзя использовать функцию в левой части присваивания. Отсутствие симметрии (допустимо изменять объявление функции на объявление атрибута, но не наоборот) неприятно, но неизбежно и не является на практике серьезным препятствием. Оно означает, что объявление некоторого компонента атрибутом является окончательным и необратимым выбором, в то время как объявление его функцией все еще оставляет место для последующих реализаций через память, а не через вычисление.
Оценка накладных расходов
Оказывается, можно грубо оценить потери на накладные расходы для описанных выше методов динамического связывания. Следующие цифры взяты из опытов ISE по использованию динамического связывания (данные получены при отключении объясняемой ниже оптимизации статического связывания). Для процедуры, которая ничего не делает, т. е. описана как p1 is do end, превышение времени динамического связывания над временем статического связывания (например, над эквивалентной процедурой на C) составляет около 30%. Это, конечно, оценка сверху, поскольку реальные процедуры что-нибудь да делают. Цена динамического связывания одинакова для всех процедур независимо от времени их выполнения, поэтому, чем больший объем вычислений выполняет процедура, тем меньше относительная доля накладных расходов. Если вместо p1 использовать процедуру, которая выполняет некоторые типичные операции, такую как
p2 (a, b, c: INTEGER) is local x, y do x := a; y := b + c + 1; x := x * y; p2 if x > y then x := x + 1 else x := x - 1 end end
то накладные расходы падают до 15%. Для программы, выполняющей нечто более существенное (например, некоторый цикл) их доля совсем мала.
Основные соглашения и терминология
Кроме терминов "наследник" и "родитель" будут полезны следующие термины: Терминология наследования Потомок класса C - это любой класс, который наследует C явно или неявно, включая и сам класс C. (Формально, это либо C, либо, по рекурсии, потомок некоторого наследника C). Собственный потомок класса C - это потомок, отличный от самого C. Предок C - это такой класс A, для которого C является потомком. Собственный предок C - это такой класс A, для которого C является собственным потомком. В литературе также встречаются термины "подкласс" и "суперкласс", но мы не будем их использовать из-за неоднозначности. Имеется также терминология для компонентов класса: компонент либо является наследуемым (перешедшим от некоторого собственного предка), либо непосредственным (введенным в данном классе). При графическом представлении структур ОО-ПО, в котором классы изображаются эллипсами, связи по отношению наследования показываются в виде одинарных стрелок. Тем самым они отличаются от связей по отношению "быть клиентом", которые представляются двойными стрелками.
 Рис. 14.1. Связь по наследованию Переопределяемый компонент отмечается ++ (это соглашение принято в Business Object Notation (B.O.N.)). Стрелка указывает вверх от наследника к родителю. Это соглашение легко запомнить - оно представляет отношение "наследовать от". В литературе встречается и обратное направление таких стрелок. Хотя обычно выбор графического представления является делом вкуса, в данном случае, одно из них явно лучше другого, поскольку одно наводит на мысль о правильном отношении, а другое может привести к путанице. Стрелка - это не просто произвольная пиктограмма, она указывает на одностороннюю связь между своими двумя концами. В данном случае: Всякий экземпляр наследника можно рассматривать как экземпляр родителя, а обратное неверно.В тексте наследника всегда упоминается его родитель, но не наоборот. Это, на самом деле, является важным свойством ОО-метода, вытекающим из принципа Открыт-Закрыт, согласно которому класс не "знает" списка своих наследников и других собственных потомков.
Хотя у нас нет жесткого правила, определяющего для достаточно сложных систем размещение классов на диаграммах наследования, мы будем, по возможности, помещать класс выше его наследника.
Отложенные классы как частичные интерпретации: классы поведения
Не все отложенные классы так близки к АТД как STACK. В промежутке между полностью абстрактным классом, таким как STACK, в котором все существенные компоненты отложены, и эффективным классом, таким как FIXED_STACK, описывающим единственную реализацию АТД, имеется место для реализаций АТД с различной степенью завершенности. Типичным примером является иерархия реализаций таблиц, которая помогла нам понять роль частичной общности при изучении повторного использования. Первоначальный рисунок, показывающий отношения между вариантами, можно сейчас перерисовать в виде диаграммы наследования.
 Рис. 14.13. Варианты понятия "таблица" Наиболее общий класс TABLE является полностью или почти полностью отложенным, так как на этом уровне мы можем объявить несколько компонентов, но не можем предложить никакой существенной их реализации. Среди вариантов имеется класс SEQUENTIAL_TABLE, представляющий таблицы, в которые элементы вставляются последовательно. Примерами таких таблиц являются массивы, связанные списки и последовательные файлы. Соответствующие им классы в нижней части рисунка являются эффективными. Особый интерес представляют такие классы как SEQUENTIAL_TABLE. Этот класс все еще отложенный, но его статус находится посредине между полностью отложенным статусом как у класса TABLE и полностью эффективным как у ARRAY_TABLE. У него достаточно информации, чтобы позволить себе реализацию некоторых специфических алгоритмов, например, в нем можно полностью реализовать последовательный поиск:
has (x: G): BOOLEAN is -- x имеется в таблице? do from start until after or else equal (item, x) loop forth end Result := not after end
Эта функция эффективна, хотя ее алгоритм использует отложенные компоненты. Компоненты start (поместить курсор в первую позицию), forth (сдвинуть курсор на одну позицию), item (значение элемента в позиции курсора), after (находится ли курсор за последним элементом?) являются отложенными в классе SEQUENTIAL_TABLE и в каждом из показанных на рисунке потомков этого класса они реализуются по-разному.
Эти реализации были приведены при обсуждении повторного использования. Например класс ARRAY_TABLE может представлять курсор числом i, так что процедура start реализуется как i := 1, а item как t @ i и т.д.
Отметим важность включения предусловия и постусловия компонента forth, а также инварианта объемлющего класса для гарантирования того, что все будущие реализации будут удовлетворять одной и той же базовой спецификации. Эти утверждения приводились ранее в этой лекции (в несколько ином контексте для класса LIST, но непосредственно применимы и здесь).
Этого обсуждение в полной степени показывает соответствие между классами и АТД:
Полностью отложенный класс, такой как TABLE, соответствует АТД.Полностью эффективный класс, такой как ARRAY_TABLE, соответствует реализации АТД.Частично отложенный класс, такой как SEQUENTIAL_TABLE, соответствует семейству реализаций (или, что эквивалентно, частичной реализации) АТД.
Такой класс как SEQUENTIAL_TABLE, аккумулирующий черты, свойственные нескольким вариантам АТД, можно назвать классом поведения (behavior class). Классы поведения предоставляют важные образцы для конструирования ОО-ПО.
Отложенные классы
Как мы видели, компонент может быть отложенным или эффективным. То же относится и к классам. Определение: отложенный класс, эффективный класс Класс является отложенным, если у него имеется отложенный компонент. В противном случае, класс является эффективным. Таким образом, чтобы класс был эффективным, должны быть эффективными все его компоненты. Один или несколько отложенных компонентов делают класс отложенным. В этом случае класс должен содержать специальную метку: Правило объявления отложенного класса Объявление отложенного класса должно включать подряд идущие ключевые слова 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.
Отложенные компоненты и классы
Полиморфизм и динамическое связывание означают, что в процессе проектирования ПО можно рассчитывать на абстракции и быть уверенными в том, что при выполнении будет выбрана подходящая реализация. Но перед выполнением все должно быть полностью реализовано. Однако полная реализация не всегда нужна. Частично реализованные или не реализованные абстрактные элементы ПО помогают при решении многих задач: анализе проблемы и проектировании архитектуры системы (в этом случае можно их сохранить в заключительном продукте, чтобы запомнить ход анализа и проектирования), при фиксации соглашений между реализаторами, при описании промежуточных точек в классификации. Отложенные компоненты и классы обеспечивают необходимый механизм абстракции.
Отложенный компонент
Таким образом, нужен способ спецификации компонентов rotate и translate на уровне класса FIGURE, который возлагал бы обязанность по их фактической реализации на потомков этого класса. Это достигается объявлением этих компонентов как "отложенных". При этом вся часть тела процедуры с командами заменяется ключевым словом deferred. В классе FIGURE будет объявление:
rotate (center: POINT; angle: REAL) is -- Повернуть на угол angle вокруг точки center. deferred end
и аналогично будет объявлен компонент translate. Это означает, что этот компонент известен в том классе, где появилось такое объявление, но его реализации находятся в классах - собственных потомках. В таком случае вызов вида f.rotate в процедуре transform становится законным. Объявленный таким образом компонент называется отложенным компонентом. Компонент, не являющийся отложенным, - имеющий реализацию (например, любой из ранее встретившихся нам компонентов), называется эффективным.
Парадокс расширения-специализации
Наследование иногда рассматривается как расширение, а иногда как специализация. Хотя эти два толкования как будто противоречат друг другу, оба они истинны - но с разных точек зрения. Все снова зависит от того, смотрим ли мы на класс как на тип или как на модуль. В первом случае наследование, представляющее отношение "является", - это специализация: "собака" более специальное понятие, чем "животное", а "прямоугольник" - чем "многоугольник". Как уже отмечалось, это соответствует отношению включения подмножества во множество: если B наследник A, то множество объектов, представляющих во время выполнения B является подмножеством соответствующего множества для A. Но с точки зрения модуля, при которой класс рассматривается как поставщик служб, B реализует службы A и свои собственные. Малому числу объектов часто позволяют иметь больше компонентов, так как это приводит к увеличению информации. Переходя от произвольных животных к собакам, мы можем добавить специфическое для них свойство "лаять", а при переходе от многоугольников к прямоугольникам можно добавить компонент "диагональ". Поэтому по отношению к реализованным компонентам отношение включения направлено в другую сторону: компоненты, применимые к экземплярам A, являются подмножеством компонент, применимых к экземплярам B.
| >Здесь мы говорим о реализуемых компонентах, а не о предлагаемых (клиентам) службах, потому что при соединении скрытия информации с наследованием, как мы увидим, B может скрыть от клиентов некоторые из компонентов, в то время как A их экспортировал своим клиентам. |
Таким образом, наследование является специализацией с точки зрения типов и расширением с точки зрения модулей. Это и есть парадокс расширения-специализации: чем больше применяемых компонентов, тем меньше объектов, к которым они применяются. Парадокс расширения-специализации - это одна из причин для устранения термина "подкласс", предполагающего понятие "подмножество". Другой, уже отмеченной, является встречающееся в литературе сбивающее с толку использование термина "подкласс" для обозначения как прямого, так и непрямого наследования. Эти проблемы не возникают при использовании точно определенных терминов: наследник, потомок и собственный потомок и двойственных к ним терминов: родитель, предок и собственный предок.
Переопределение и утверждения
Если клиент класса POLYGON вызывает p.perimeter, то он ожидает получить значение периметра p, определенное спецификацией функции perimeter в определении этого класса. Но теперь, благодаря динамическому связыванию, клиент может вызвать другую программу, переопределенную в некотором классе-потомке. В классе RECTANGLE переопределение улучшает эффективность и не изменяет результат, но что помешало бы переопределить периметр так, чтобы новая версия вычисляла бы, скажем, площадь? Это противоречит духу переопределения. Переопределение должно изменять реализацию процедуры, а не ее семантику. К счастью, утверждения позволяют ограничить семантику процедур. Неформально, основное правило контроля за переопределением и динамическим связыванием можно сформулировать просто: предусловие и постусловие программы должны быть применимы к любому ее переопределению, и, как мы уже видели, инвариант класса автоматически должен распространяться на всех его потомков. Точные правила будут приведены ниже. Но уже сейчас можно заметить, что переопределение не является произвольным: допускаются только переопределения, сохраняющие семантику. Это дело автора программы - выразить ее семантику достаточно точно, но оставить при этом свободу для будущих реализаторов.
Подход языка С++ к связыванию
Учитывая широкое распространение и влияние языка С++ на другие языки, нужно разъяснить, как в нем решаются некоторые из обсуждаемых здесь вопросов. Соглашения, принятые в С++, кажутся странными. По умолчанию связывание является статическим. Чтобы процедура (в терминах С++ - функция или метод) связывалась динамически, она должна быть специально объявлена как виртуальная (virtual). Это означает, что приняты два решения: Сделать программиста ответственным за выбор статического или динамического связывания.Использовать статическое связывание в качестве предопределенного.
Оба нарушают ОО-разработку ПО, но в различной степени: (1) можно попробовать объяснить, а (2) защищать трудно. По сравнению с подходом этой книги (1) ведет к другому пониманию того, какие задачи должны выполняться людьми (разработчиками ПО), а какие - компьютерами (более точно, компиляторами). Это та же проблема, с которой мы столкнулись при обсуждении автоматического распределения памяти. Подход С++ продолжает традиции C и дает программисту полный контроль над тем, что случится во время выполнения, будь то размещение объекта или вызов процедуры. В отличие от этого, в духе ОО-технологии стремление переложить на плечи компилятора все утомительные задачи, выполнение которых вручную приводит к ошибкам, и для которых имеются подходящие алгоритмы. В крупном масштабе и на большом промежутке времени компиляторы всегда справятся с работой лучше. Конечно, разработчики отвечают за эффективность их программ, но они должны сосредотачивать свои усилия на том, что может действительно существенно повлиять на результат: на выборе подходящих структур данных и алгоритмов. За все остальное несут ответственность разработчики языков и компиляторов. Отсюда и несогласие с решением (1): С++ считает, что статическое связывание, как и подстановка кода, должно определяться разработчиками, а развиваемый в этой книге ОО-подход полагает, что за это отвечает компилятор, который будет сам оптимизировать вызовы. Статическое связывание - это оптимизация, а не выбор семантики.
Для ОО- метода имеется еще одно негативное последствие (1). Всегда при определении процедуры требуется указать политику связывания: является она виртуальной или нет, т.е. будет связываться динамически или статически. Такая политика противоречит принципу Открыт-Закрыт, так как заставляет разработчика с самого начала угадать, что будет переопределяться, а что - нет. Это не соответствует тому, как работает наследование: на практике может потребоваться переопределить некоторый компонент в далеком потомке класса, при проектировании которого нельзя было это предвидеть. При подходе С++, если разработчик исходного класса такого не предусмотрел, то придется снова вернуться к этому классу, чтобы изменить объявление компонента на virtual. При этом предполагается, что исходный текст доступен для модификации. А если его нет, или у разработчика нет права его менять, то вас ожидает горькая участь.
По этим причинам решение (1), требующее, чтобы программисты сами задавали политику связывания, мешает эффективному применению ОО-метода.
Решение (2) - использовать статическое связывание в качестве предопределенного - еще хуже. Очень трудно подобрать доводы в его пользу с точки зрения проектирования языка. Как мы видели, выбор статического связывания всегда приводит к ошибкам, если его семантика отличается от динамического. Поэтому не может быть никаких причин для его выбора в качестве предопределенного.
Одно дело - сделать программистов, а не компиляторы ответственными за оптимизацию в безопасных случаях (т.е. попросить их явно указывать статическое связывание, если они считают, что это корректно), но заставлять их писать нечто специальное, чтобы получить корректную семантику - это совсем другое. Если верно или неверно понятые соображения эффективности начинают брать верх над основополагающим требованием корректности ПО, то что-то не в порядке.
Даже в языке, заставляющем программиста отвечать за выбор политики связывания (такое решение принято в C), предопределенное значение должно быть противоположным.
Вместо того, чтобы требовать объявлять динамически связываемые функции виртуальными (virtual), язык должен был бы использовать динамическое связывание по умолчанию и разрешить программистам выделять словом static (или каким-нибудь другим) компоненты, для которых они хотели бы запросить оптимизацию, доверив им самим (в традиции C и С++) удостоверяться в том, что она допустима.
Это различие особенно важно для начинающих, которые, естественно, имеют тенденцию доверять значениям по умолчанию. Даже для языка, менее страшного, чем С++, нельзя предполагать, что кто-либо сразу справится со всеми деталями наследования. Ответственный подход к этому должен гарантировать корректную семантику для новичков (и вообще, для разработчиков, начинающих новый проект, которые "хотят чтобы прежде всего он был правильным, а уж затем быстрым"), а затем предоставить возможности оптимизации для тех, кому это требуется и кто хорошо разбирается в предмете.
Имея в виду широко распространенный интерес к "совместимости снизу - вверх", создание комитета для изменения политики связывания в С++, особенно пункта (2), будет тяжелым делом, но стоит попытаться пролить свет на опасность нынешних соглашений.
Прискорбно, но подход С++ влияет и на другие языки, например, политика динамического связывания в языке Borland Delphi, продолжающем прежние расширения Паскаля, по сути, та же, что и в С++. Отметим все же, что вышедший из недр С++ язык Java в качестве базового использует динамическое связывание. |
Эти наблюдения позволяют дать некоторый практический совет. Что разработчик может сделать при использовании С++ или иного языка с той же политикой связывания? Самым лучшим для разработчиков, не имеющих возможности переключиться на другие средства или ждать улучшений в этом языке, было бы объявлять все функции как виртуальные и тем самым разрешить их любые переопределения в духе ОО-разработки ПО. (К сожалению, некоторые компиляторы С++ ограничивают число виртуальных функций в системе, но можно надеяться, что эти ограничения будут сняты).
Парадокс этого совета в том, что он возвращает нас назад к ситуации, в которой все вызовы реализуются через динамическое связывание и требуют несколько большего времени выполнения. Иными словами, соглашения (1) и (2) языка С++, предназначенные для улучшения эффективности, в конце концов, если следовать правилу: "корректность прежде всего", срабатывают против этого!
Неудивительно, что эксперты по С++ не советуют использовать "чересчур много" объектной ориентированности. Уолтер Брайт (Walter Bright), автор одного из самых популярных компиляторов С++, пишет в [Bright 1995]:
| Хорошо известно, что чем больше С++ [механизмов] вы используете в некотором классе, тем медленнее его код. К счастью, есть несколько вещей, позволяющих склонить чашу весов в вашу пользу. Во-первых, не используйте без большой необходимости виртуальные функции [т. е. динамическое связывание], виртуальные базовые классы [отложенные классы], деструкторы и т.п. Другой источник разбухания - это множественное наследование [...]. Если у вас сложная иерархия классов с одной или двумя виртуальными функциями, то попробуйте устранить виртуальный аспект и, быть может, сделать то же самое, используя проверки и ветвления. |
Иными словами: не прибегайте к использованию ОО-методов. ( В том же тексте отстаивается и "группировка всех кодов инициализации" для локализации ссылки - приглашение нарушить элементарные принципы модульного проектирования, которые, как мы видели, предполагают, что каждый класс должен сам отвечать за все, связанное с его инициализацией.)
В этой лекции предложен другой подход: в первую очередь разработчик ОО-ПО должен быть уверен в том, что семантика вызова всегда будет правильной, а это гарантируется динамическим связыванием. Затем можно использовать достаточно изощренные методы компиляции, чтобы порождать статическое связывание или подстановку кода для тех вызовов, которые, как установлено на основе строгого алгоритмического анализа, не требуют динамического связывания.
Полиморфизм
Иерархии наследования позволяют достаточно гибко работать с объектами, сохраняя надежность статической типизации. Поддерживающие их методы: полиморфизм и динамическое связывание - одни из самых фундаментальных аспектов архитектуры ПО, обсуждаемой в этой книге. Начнем с полиморфизма.
Полиморфное присоединение
"Полиморфизм" означает способность обладать несколькими формами. В ОО-разработке несколькими формами обладают сущности (элементы структур данных), способные во время выполнения присоединяться к объектам разных типов, что контролируется статическими объявлениями. Предположим, что для структуры наследования на рисунке вверху объявлены следующие сущности:
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) присоединении.
Полиморфное создание
Введение наследования и полиморфизма приводит к небольшому расширению механизма создания объектов, который позволит непосредственно создавать объекты типов-потомков. Напомним, что команды создания (процедуры-конструкторы) имеют один из следующих видов:
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.
Полиморфные структуры данных
Рассмотрим массив многоугольников:
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. Полиморфный массив
| На этом рисунке графические объекты представлены соответствующими геометрическими фигурами, а не обычными диаграммами объектов с набором их полей. |
Такие структуры данных, содержащие объекты разных типов, имеющих общего предка, называются полиморфными структурами данных. Далее будут рассмотрены многочисленные примеры таких структур. Массивы - это только одна из возможностей, полиморфными могут быть любые структуры контейнеров: списки, стеки и т.п. Полиморфные структуры данных реализуют цель, сформулированную в начале лекции: объединение порождения и наследования для достижения максимальной гибкости и надежности. Имеет смысл напомнить рис. 10.1, иллюстрирующий эту мысль:
 Рис. 14.5. Измерения обобщения Типы, которые на рис. 10.1 неформально назывались SET_OF_BOOKS и т. п., заменены типами, выведенными из родового универсального типа, - SET [BOOK]. Такая комбинация универсальности и наследования является весьма сильным средством. Оно позволяет описывать структуру объектов с нужной степенью общности. Например, LIST [RECTANGLE]: может содержать квадраты, но не треугольники. LIST [POLYGON]: может содержать квадраты, прямоугольники, треугольники, но не круги. LIST [FIGURE]: может содержать экземпляры любого типа из иерархии FIGURE, но не книги или банковские счета. LIST [ANY]: может содержать объекты любого типа. В последнем случае использован класс ANY, который условимся считать предком любого класса (он будет подробнее рассмотрен далее). Варьируя место класса, выбираемого в качестве фактического родового параметра, в иерархии, можно точно установить границы типов объектов, допустимых в определяемом контейнере.
Повторное объявление функции как атрибута
Повторные объявления позволяют активно применять один из центральных принципов модульности - принцип Унифицированного Доступа (Uniform Access). Напомним (см. лекцию 3), что этот принцип утверждает (первоначально в менее технических терминах, но сейчас мы можем позволить себе быть более точными), что с точки зрения клиента не должно быть никакой существенной разницы между атрибутом и функцией без аргументов. В обоих случаях компонент является запросом и все, что их отличает, - это их внутреннее представление. Первым примером этого был класс, описывающий банковские счета, в котором компонент 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 будет полиморфно присоединяться к объектам разных классов.
Пределы полиморфизма
Неограниченный полиморфизм был бы несовместим со статическим понятием типа. Допустимость полиморфных операций определяется наследственностью. Все примеры полиморфных присваиваний, такие, как 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 (млекопитающее), то я имею право вернуть ее отправителю, даже если из нее доносится недвусмысленный лай и тявканье. Поскольку мой заказ не был исполнен в соответствии со спецификацией, я ничего не должен фирме Любимцы-По-Почте. |
Пример иерархии
В конце обсуждения полезно рассмотреть пример POLYGON-RECTANGLE в контексте более общей иерархии типов геометрических фигур.
 Рис. 14.2. Иерархия типов фигур Фигуры разбиты на замкнутые и незамкнутые. Примером замкнутой фигуры кроме многоугольника является также эллипс, а частным случаем эллипса является круг. Рядом с классами указаны их разные компоненты. Символ "++" означает "переопределено", а символы "+" и "*" будут объяснены далее. Ранее для простоты RECTANGLE был наследником класса POLYGON. Поскольку указанная классификация основана на числе вершин, то представляется разумным ввести промежуточный класс QUADRANGLE для четырехугольников на том же уровне, что и классы TRIANGLE, PENTAGON и т. п. Тогда компонент diagonal (диагональ) можно переместить на уровень класса QUADRANGLE. Отметим, что класс SQUARE, наследник класса RECTANGLE, характеризуется инвариантом side1 = side2. Аналогично, у эллипса имеются два фокуса, а у круга они сливаются в один, что определяет инвариант класса CIRCLE: equal (focus1 = focus2).
Прямоугольники
Предположим теперь, что нам требуется новый класс, представляющий прямоугольники. Можно было бы начать его проектировать заново. Но прямоугольники это специальный вид многоугольников и у них много общих компонент: их также можно сдвигать, поворачивать и выводить на экран. С другой стороны, у них есть ряд специфических компонентов (например, диагонали), специальные свойства (число вершин равно четырем, а углы являются прямыми) и возможны специальные варианты некоторых операций (вычисление периметра можно устроить проще, чем в приведенном выше алгоритме). Преимущества такой смеси общих и специфических компонентов можно использовать, определив класс 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.
Программы с дырами
Только что обсужденные методы являются центральным вкладом ОО-подхода в повторное использование: они предлагают не замороженные навсегда компоненты (которые можно обнаружить в библиотеках подпрограмм), а гибкие решения, которые предоставляют базисные схемы и могут быть адаптированы к нуждам многих разнообразных приложений. Одной из центральных тем при обсуждении повторного использования была необходимость соединить эту цель с адаптивностью во избежание дилеммы: переиспользовать или переделывать. Этому в точности соответствует только что описанная схема, для которой можно предложить название "программы с дырами". В отличие от библиотек подпрограмм, в которых все, кроме значений фактических параметров, жестко фиксировано, у программ с дырами, использующих классы, образцом для которых служит модель SEQUENTIAL_TABLE, имеется место для частей, создаваемых пользователем. Эти наблюдения помогают понять образ "блока Лего", часто используемый при обсуждении повторно использования. В наборе Лего компоненты фиксированы, детская фантазия направлена на составление из них интересной структуры. Тот же подход свойственен и программированию, - истоки его в традиционных библиотеках подпрограмм. Часто при разработке ПО требуется в точности обратное: сохранять структуру, но заменять компоненты. На самом деле, этих компонентов может еще и не быть, на их места помещаются "заглушки" (отложенные компоненты), вместо которых затем нужно вставить эффективные варианты.
| По аналогии с детскими игрушками можно вернуться в детство и представить себе игровую доску с отверстиями разной формы, в которые ребенок должен вставлять соответствующие фигуры. Он должен понять, что квадратный блок подходит для квадратного отверстия, а круглый блок - для круглого отверстия. |
Можно также представлять частично отложенный класс поведения (или набор таких классов, называемый "библиотекой"), как устройство с несколькими электрическими розетками - отложенными классами - в которые разработчик приложения будет вставлять совместимые с ними устройства. Эту метафору можно продолжить: для устройства важны меры предосторожности - утверждения, выражающие требования к допустимым съемным устройствам, например, спецификация розетки определяет допустимое напряжение, силу тока и другие электрические параметры.
Роль отложенных классов при анализе и глобальном проектировании
Отложенные классы играют также ключевую роль при использовании ОО-метода не только на уровне реализации, но и на самых ранних и верхних уровнях построения системы - анализе и глобальном проектировании. Целью является создание спецификации системы и ее архитектуры, для проекта требуется также абстрактное описание каждого модуля без деталей его реализации. Обычно даваемая в этом случае рекомендация состоит в использовании отдельных обозначений: некоторого "метода" анализа (за этим термином во многих случаях стоит просто некоторая графическая нотация) и некоторого ЯПП (PDL) (языка проектирования программ, зачастую тоже графического). Но у этого подхода много недостатков: Разрыв между последовательными шагами процесса разработки представляет серьезную угрозу для качества ПО. Необходимость трансляции из одного формализма в другой может привести к ошибкам и подвергает опасности целостность системы. ОО-технология, напротив, предлагает перспективу непрерывного процесса разработки ПО.Многоярусный подход является особенно губительным для этапов сопровождения и эволюции системы. Крайне сложно гарантировать согласованность проекта и реализации на этих этапах.Наконец, большинство существующих подходов к анализу и проектированию не предлагают никакой поддержки формальной спецификации функциональных свойств модулей, не зависящей от их реализации, например в форме утверждений.
Последний комментарий приводит к парадоксу уровней: точная нотация, подобная языку, используемому в этой книге, иногда отклоняется как "низкоуровневая" или "ориентированная на реализацию", поскольку внешне выглядит как язык программирования. На самом же деле, благодаря утверждениям и такому механизму абстракции как отложенные классы, их уровень существенно выше уровня большинства имеющихся подходов к анализу и проектированию. Многим требуется время, чтобы осознать это, поскольку раньше их учили тому, что высокий уровень абстракции означает неопределенность и что абстракция всегда должна быть неточной. Использование отложенных классов для анализа и проектирования позволяет нам одновременно быть абстрактными и точными, и применять один и тот же язык на протяжении всего процесса разработки. При этом устраняются разрывы в концепциях, переход от описания модуля на высоком уровне к реализациям может происходить плавно внутри одного формализма. Даже нереализованные операции проектируемых модулей, представленные отложенными процедурами, можно достаточно точно охарактеризовать с помощью предусловий, постусловий и инвариантов. Система обозначений, которая к этому моменту развернута почти до конца, покрывает этапы анализа и проектирования, а также и реализации. Одни и те же понятия и конструкции применяются на всех стадиях, различаются только уровни абстракции и детализации.
Смысл наследования
Мы уже рассмотрели основные способы наследования. Многое еще предстоит изучить, в частности, множественное наследование и детали того, что происходит с утверждениями в контексте наследования (понятие субконтрактов). Но вначале следует поразмышлять над этими фундаментальными понятиями и выяснить их значение для вопроса о качестве ПО и для процесса разработки ПО.
Соглашения о графических обозначениях
Сейчас можно полностью объяснить графические символы, использованные на рис. 14.8. Звездочкой отмечаются отложенные компоненты или классы:
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
Согласованность типов
Наследование согласовано с системой типов. Основные правила легко объяснить на приведенном выше примере. Предположим, что имеются следующие объявления:
p: POLYGON
r: RECTANGLE
Выделим в приведенной выше иерархии нужный фрагмент (рис. 14.6). Тогда законны следующие выражения: p.perimeter: никаких проблем, поскольку perimeter определен для многоугольников;p.vertices, p.translate (...), p.rotate (...) с корректными аргументами;r.diagonal, r.side1, r.side2: эти три компонента объявлены на уровне RECTANGLE или QUADRANGLE;r.vertices, r.translate (...), r.rotate (...): эти компоненты объявлены на уровне POLYGON или еще выше и поэтому применимы к прямоугольникам, наследующим все компоненты многоугольников;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: POLYGON статический тип ссылки, обозначенной p, есть POLYGON, после выполнения create p динамическим типом этой ссылки также является POLYGON, а после присваивания p := r, где r имеет тип RECTANGLE и не пусто, динамическим типом становится RECTANGLE. Правило согласования типов утверждает, что динамический тип всегда должен соответствовать статическому типу. Чтобы избежать путаницы напомним, что мы имеем дело с тремя уровнями: сущность - это некоторый идентификатор в тексте класса, во время выполнения ее значение является ссылкой (за исключением развернутого случая), ссылка может быть присоединена к объекту. У объекта имеется только динамический тип, который он получил в момент создания. Этот тип во время жизни объекта не изменяется. В каждый момент во время выполнения у ссылки имеется динамический тип, тип того объекта, к которому она сейчас присоединена (или специальный тип NONE, если эта ссылка пуста). Динамический тип может изменяться в результате операций переприсоединения. Только у сущности имеются и статический, и динамический типы. Ее статический тип - это тип, с которым она была объявлена: если объявление имеет вид x: T, то этим типом будет T. Ее динамический тип в каждый момент выполнения - это тип значения этой ссылки, т.е. того объекта, к которому она присоединена.
| В развернутом случае нет ссылки, значением x является объект типа T, и T является и статическим типом и единственно возможным динамическим типом для x. |
Статическое связывание как оптимизация
В некоторых случаях главным требованием является эффективность, и даже указанные выше небольшие накладные расходы нежелательны. В этом случае можно заметить, что они не всегда обоснованы. Вызов x.f (a, b, c...) не нуждается в динамическом связывании в следующих случаях: f нигде в системе не переопределяется (имеет только одно объявление);x не является полиморфной, иначе говоря, не является целью никакого присоединения, источник которого имеет другой тип.
В любом из таких случаев, выявляемых хорошим компилятором, сгенерированный для x.f (a, b, c...) код может быть таким же, как и код, генерируемый компиляторами C, Pascal, Ada или Fortran для вызова f (x, a, b, c...). Никакие накладные расходы не потребуются. Компилятор ISE, являющийся частью окружения, описанного в последней лекции, сейчас выполняет оптимизацию (1), планируется добавить и (2) (анализ (2) является, фактически, следствием механизмов анализа типов, описанных в лекции о типизации). Хотя (1) интересно и само по себе, непосредственная его польза ограничивается сравнительно низкой стоимостью динамического связывания (см. приведенную выше статистику). Настоящий выигрыш от него непрямой, поскольку (1) дает возможность третьей оптимизации: При любой возможности применять автоматическую подстановку кода процедуры.
Такая подстановка означает расширение тела программы текстом вызываемой процедуры в месте ее вызова. Например, для процедуры
set_a (x: SOME_TYPE) is -- Сделать x новым значением атрибута a. do a := x end
компилятор может сгенерировать для вызова s.set_a (some_value) такой же код, какой компилятор Pascal сгенерирует для присваивания s.a := some_value (недопустимое для нас обозначение, поскольку оно нарушает скрытие информации). В этом случае вообще нет накладных расходов, поскольку сгенерированный код не содержит вызова процедуры. Подстановка кода традиционно рассматривается как оптимизация, которую должны задавать программисты. Ada включает прагму (указание транслятору) inline, C и С++ предлагают аналогичные механизмы.
Но этому подходу присущи внутренние ограничения. Хотя для небольшой, статичной программы компетентный программист может сам определить, какие процедуры можно подставлять, для больших развивающихся проектов это сделать невозможно. В этом случае компилятор с приличным алгоритмом определения подстановок будет намного превосходить догадки программистов.
Для каждого вызова, к которому применимо автоматическое статическое связывание (1), ОО-компилятор может определить, основываясь на анализе соотношения между временем и памятью, стоит ли применять автоматическую подстановку кода процедуры (3). Это одна из самых поразительных оптимизаций - одна из причин, по которой можно достичь эффективности произведенного вручную кода Си или Фортрана, а иногда, на больших системах и превзойти ее.
К улучшению эффективности, растущему с увеличением размера и сложности программ, автоматическая подстановка кода добавляет преимущество большей надежности и гибкости. Как уже отмечалось, подстановка кода семантически корректна только для процедуры, которую можно статически ограничить, например, как в случаях (1) и (2). Это не только допустимо, но также вполне согласуется с ОО-методом, в частности, с принципом Открыт-Закрыт, если разработчик на полпути разработки большой системы добавит переопределение некоторого компонента, имевшего к этому моменту только одну реализацию. Если же код процедуры вставляется вручную, то в результате может получиться программа с ошибочной семантикой (поскольку в данном случае требуется динамическое связывание, а вставка кода, конечно, означает статическое связывание). Разработчики должны сосредотачиваться на построении корректных программ, не занимаясь утомительными оптимизациями, которые при выполнении вручную приводят к ошибкам, а на деле могут быть автоматизированы.
| Имеются и некоторые другие требования для того, чтобы подстановка кода была корректной, в частности, она применима только к нерекурсивным вызовам. Даже корректную подстановку следует применять при разумном соотношении между временем и памятью: подставляемая процедура должна быть небольшой и должна вызываться небольшое число раз. |
Последнее замечание об эффективности. Опубликованная статистика для ОО-языков показывает, что где-то от 30% до 60% вызовов на самом деле используют динамическое связывание. Это зависит от того, насколько интенсивно разработчики используют специфические свойства методов. В системе ISE это соотношение близко к 60%. С использованием только что описанных оптимизаций платить придется только за динамическое связывание только тех вызовов, которые действительно в нем нуждаются. Для оставшихся динамических вызовов накладные расходы не только малы (ограничены константой), но и логически необходимы, - в большинстве случаев для достижения результата, эквивалентного динамическому связыванию, придется использовать условные операторы (if ... then ... или case ... of ...), которые могут оказаться дороже приведенного выше простого механизма, основанного на доступе к массивам. Поэтому неудивительно, что ОО-программы, откомпилированные хорошим компилятором, могут соревноваться с нап исанным вручную кодом на C.
Типизация при наследовании
Замечательная гибкость, обеспечиваемая наследованием, не связана с потерей надежности, поскольку используется статическая проверка типов, гарантирующая во время компиляции отсутствие некорректных комбинаций типов во время выполнения.
У14.1 Многоугольники и прямоугольники
Дополните версии классов POLYGON и RECTANGLE, наброски которых приведены в начале лекции. Включите в них подходящие процедуры создания.
У14.2 Многоугольник с малым числом вершин
Инвариант класса POLYGON требует, чтобы у каждого многоугольника было, по крайней мере, три вершины; отметим, что функция perimeter не будет работать для пустого многоугольника. Измените определение этого класса так, чтобы он покрывал и случаи вырожденных многоугольников с числом вершин меньше трех.
У14.3 Геометрические объекты с двумя координатами
Опишите класс TWO_COORD, задающий объекты с двумя вещественными координатами, среди наследников которого были бы классы POINT (ТОЧКА), COMPLEX (КОМПЛЕКСНОЕ_ЧИСЛО) и VECTOR (ВЕКТОР). Будьте внимательны при помещении каждого компонента на подходящий для него уровень иерархии.
У14.4 Наследование без классов
В этой лекции были представлены два взгляда на наследование: будучи модулем, класс-наследник предлагает службы своего родителя плюс еще некоторые, будучи типом, он реализует отношение "является" (каждый экземпляр наследника является также экземпляром каждого из родителей). "Пакетами" модульных, но не ОО-языков (таких как Ада (Ada) или Модула-2 (Modula-2)) являются модули, но не типы. При первой интерпретации к ним можно было бы применить наследование. Обсудите, в каком виде наследование может быть введено в модульные языки. Не забудьте рассмотреть при этом принцип Открыт-Закрыт.
У14.5 Классы без объектов
Не разрешается создавать объекты отложенных классов. В одной из предыдущих лекций был указан другой способ создания класса без объектов: включить в него пустую процедуру создания. Эквивалентны ли эти два механизма? Можно ли выделить случаи, когда использование одного из них предпочтительнее, чем другого? (Указание: в отложенном классе должен быть хоть один отложенный компонент.)
У14.6 Отложенные классы и прототип
Отложенные классы нельзя инициализировать. С другой стороны, были приведены аргументы в пользу того, чтобы в первой версии класса в проекте все компоненты оставались отложенными. Может появиться желание "выполнить" такой проект: при проектировании ПО иногда хочется вступить в игру как можно раньше, исполнить неполные реализации, чтобы получить практический опыт и проверить некоторые аспекты системы даже при неполностью реализованных других аспектах. Обсудите доводы за и против того, чтобы иметь в компиляторе специальную параметр "прототип", позволяющий инициализировать отложенный класс и выполнить отложенный компонент (как пустую операцию). Обсудите детали.
У14.7 Библиотека поиска в таблицах (семестровый проект)
Основываясь на обсуждении таблиц в этой лекции и в лекции о повторном использовании, спроектируйте библиотеку классов таблиц, включающую различные категории представлений таблиц: хеш-таблицы, последовательные (линейные) таблицы, древообразные таблицы и др.
У14.9 Комплексные числа
(Это упражнение предполагает знакомство со всеми лекциями вплоть до 5-й курса "Основы объектно-ориентированного проектирования".) В примере, рассмотренном при обсуждении интерфейса модулей, использовались комплексные числа с двумя разными представлениями, при этом соответствующие изменения в представлениях остались "за кадром". Определите можно ли получить эквивалентный результат с помощью наследования, а именно, создать класс COMPLEX (КОМПЛЕКСНЫЕ) и его наследников CARTESIAN_COMPLEX (КОМПЛЕКСНЫЕ_В_ДЕКАРТОВЫХ_КООРДИНАТАХ) и POLAR_COMPLEX (КОМПЛЕКСНЫЕ_В_ПОЛЯРНЫХ_КООРДИНАТАХ). |
|  |
Взгляд на класс как на модуль
С этой точки зрения наследование особенно эффективно в качестве метода повторного использования. Модуль это множество служб, предлагаемых внешнему миру. Без наследования каждому новому модулю пришлось бы самому определять все предоставляемые им службы. Конечно, реализации этих служб могут основываться на службах, предоставляемых другими модулями: это и есть цель отношения "быть клиентом". Но единственным способом определить новый модуль является добавление новых служб к ранее определенным модулям. Наследование предоставляет эту возможность. Если B является наследником A, то все службы (компоненты) A автоматически доступны в B, и их не нужно в нем явно определять. В соответствии со своими целями B может добавить новые компоненты. Дополнительная гибкость обеспечивается переопределением, позволяющим B по-разному использовать реализации, предлагаемые A: некоторые из них не меняются, а другие переделываются в более подходящие для данного класса версии. Это приводит к такому стилю разработки ПО, при котором вместо попытки решать каждую новую задачу с нуля поощряется ее решение, основанное на предыдущих достижениях и на расширении их результатов. Его смысл состоит в экономии - зачем повторять то, что уже однажды было сделано? - и в скромности, в духе известного замечания Ньютона, что он смог достичь таких высот только потому, что стоял на плечах гигантов. Полное преимущество этого подхода лучше всего понимается в терминах принципа Открыт-Закрыт, введенного в одной из предыдущих лекций. (Стоило бы перечитать этот раздел в свете только что введенных понятий.) Этот принцип утверждает, что хорошая структура модуля должна быть и закрытой, и открытой. Закрытой, поскольку клиентам для выполнения их собственной разработки нужны службы модуля и, будучи один раз зафиксированы в некоторой его версии, они не должны изменяться при введении новых служб, в которых клиент не нуждается.Открытой, так как нет никакой гарантии, что с самого начала в модуль были включены все службы, потенциально необходимые некоторому клиенту.
Эти два требования представляют дилемму, и классическая структура модулей не дает ключа к ее разгадке. Но наследование эту проблему решает. Класс закрыт, так как он может компилироваться, заноситься в библиотеку и использоваться классами-клиентами. Но он также открыт, поскольку любой новый класс может его использовать в качестве родителя, добавляя новые компоненты и меняя объявления некоторых унаследованных компонентов, при этом совершенно не нужно изменять исходный класс и беспокоить его клиентов. Это фундаментальное свойство при применении наследования к построению повторно используемого расширяемого ПО.
Если бы довести эту идею до предела, то каждый класс просто добавлял бы один компонент к его родителям! Конечно, это не рекомендуется. Решение завершить класс не следует принимать легковесно, оно должно основываться на осознанном заключении о том, что класс в его нынешнем состоянии уже обеспечивает логически последовательный набор служб - стройную абстракцию данных - для потенциальных клиентов.
Следует помнить, что принцип Открыт-Закрыт не отменяет последующей переделки неадекватных служб. Если плохой результат явился следствием неверной спецификации компонента, то мы не сможем модифицировать класс так, чтобы это не отразилось на его клиентах. Однако, благодаря переопределению, принцип Открыт-Закрыт все еще применим, если вводимое изменение согласовано с объявленной спецификацией. |
Одним из самых трудных вопросов, связанных с проектированием повторно используемых структур модулей, была необходимость использовать преимущества большой общности, которая может существовать у разных однотипных групп абстракций данных - у всех хеш-таблиц, всех последовательных таблиц и т. п. Используя структуры классов, связанных наследованием, можно получить выигрыш, зная логические соотношения между разными реализациями. Внизу на диаграмме представлен грубый и частичный набросок возможной структуры библиотеки для работы с таблицами. В этой схеме естественно используется множественное наследование, которое будет детально обсуждаться в следующей лекции.
 Рис. 14.12. Набросок структуры библиотеки таблиц
| Эта диаграмма наследования представляет только набросок, хотя на ней показаны типичные для этих структур связи по наследованию. Систематическую классификацию таблиц и других контейнеров, основанную на наследовании, см. в [M 1994a]. |
При таком взгляде требование повторного использования можно выразить весьма точно: идея состоит в том, чтобы передвинуть определение каждого компонента как можно выше в иерархии наследования так, чтобы он мог наследоваться максимально возможным числом классов-потомков. Можно представлять этот процесс как игру переиспользования, в которую играют на доске, представляющей иерархии наследования (такие, как на рис. 14.12), фигурами, представляющими компоненты. Выигрывает тот, кто сможет в результате открытия абстракций более высокого уровня передвинуть как можно больше компонентов как можно выше, и по пути, благодаря обнаружению общих свойств, сможет слить наибольшее число фигур.
Взгляд на класс как на тип
С точки зрения типов наследование адресуется и к повторному использованию, и к расширяемости, в частности, к тому, что в предыдущем обсуждении называлось непрерывностью. Здесь ключом является динамическое связывание. Тип - это множество объектов, характеризуемых (как мы знаем из теории АТД) определенными операциями. INTEGER описывают множество целых чисел с арифметическими операциями, POLYGON - это множество объектов с операциями vertices, perimeter и другими. Для типов наследование представляет отношение "является", например, во фразах "каждая собака является млекопитающим", "каждое млекопитающее является животным". Аналогично, прямоугольник является многоугольником. Что означает это отношение? Если рассматривать значения каждого типа, то это отношение является просто отношением включения множеств: собаки образуют подмножество множества животных, экземпляры класса RECTANGLE образуют подмножество экземпляров класса POLYGON. (Это следует из определения "экземпляра" в начале этой лекции, заметим, что прямой экземпляр класса RECTANGLE не является прямым экземпляром класса POLYGON).Если рассматривать операции, применимые к каждому типу, то сказать, что B есть A, означает, что каждая операция, применимая к A применима также и к экземплярам B. (Однако при переопределении B может создать свою собственную реализацию, которая для экземпляров B заменит реализацию, предоставленную A.)
Используя это отношение можно описывать схемы отношения "является", представляющие многие варианты типов, например, все варианты класса FIGURE. Каждая новая версия таких подпрограмм как rotate и display определяется в классе, задающем соответствующий вариант типа. В случае таблиц, например, каждый класс на графе обеспечивает свою собственную реализацию операций search, insert, delete, разумеется, за исключением тех случаев, когда для него подходит реализация родителя. Предостережение об использовании отношения "является" ("is a").
Начинающие - но я полагаю, ни один из читателей, добравшийся до этого места даже с минимумом внимания, - иногда путают наследование с отношением "экземпляр - образец", считая класс SAN_FRANCISCO наследником класса CITY. Это, как правило, ошибка: CITY - это класс, у которого может быть экземпляр, представляющий Сан Франциско. Чтобы избежать таких ошибок, достаточно помнить, что термин "является" означает не "x является одним из A" (например, "Сан Франциско является городом (CITY)), т.е. отношением между экземпляром и категорией, а выражает "всякий B является A" (например, "всякий ГОРОД является ГЕОГРАФИЧЕСКОЙ_ЕДИНИЦЕЙ"), т.е. отношение между двумя категориями, в программировании - двумя классами. Некоторые авторы предпочитают называть это отношение "является разновидностью" или "может действовать как" [Gore 1996]. Отчасти это дело вкуса (и частично этот предмет будет обсуждаться в лекции о методологии наследования), но поскольку мы уже знаем, как избежать тривиальной ошибки, то будем и далее использовать наиболее распространенное название "является", не забывая при этом, что оно относится к отношению между категориями.
Задание семантики отложенных компонентов и классов
Хотя у отложенного компонента нет реализации, а у отложенного класса либо нет реализации, либо он реализован частично, часто требуется задать их абстрактные семантические свойства. Для этой цели можно использовать утверждения. Как и другие классы, отложенный класс может иметь инвариант, а у отложенного компонента может быть предусловие, постусловие или оба эти утверждения. Рассмотрим пример линейных списков, описанных независимо от конкретной реализации. Как и для многих других структур такого рода, удобно связать с каждым списком курсор, указывающий на текущий активный элемент.
 Рис. 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. Благодаря использованию утверждений, можно сделать отложенные классы достаточно информативными и семантически богатыми, несмотря на отсутствие у них реализаций. В конце этой лекции мы вновь обратимся к отложенным классам и исследуем глубже их роль в процессе ОО-анализа, проектирования и реализации.
Примеры множественного наследования
Брак по расчету
В приведенных примерах оба родителя играли симметричные роли, но это не всегда так. Иногда вклад каждого из них различен по своей природе. Важным приложением множественного наследования является обеспечение реализации абстракции, описанной отложенным классом, используя свойства, обеспечиваемые эффективным классом. Один класс абстрактен, второй - эффективен.
 Рис. 15.11. Брак по расчету Рассмотрим реализацию стека, заданную массивом. У нас уже есть классы для поддержки стеков и массивов в отдельности (абстрактный STACK и эффективный ARRAY, см. предыдущие лекции). Лучший способ реализации класса ARRAYED_STACK (стек, заданный массивом) - описать его как наследника классов STACK и ARRAY. Это концептуально верно: стек-массив одновременно является стеком (с точки зрения клиента) и массивом (с позиций поставщика). Вот описание класса:
indexing description: "Стек, реализованный массивом" class ARRAYED_STACK [G] inherit STACK [G] ARRAY [G] ... Здесь будут добавлены предложения переименования ... feature ...Реализация отложенных подпрограмм класса STACK в терминах операций класса ARRAY (см. ниже)... end
ARRAYED_STACK предлагает ту же функциональность, что и STACK, делая эффективными отложенные компоненты: full, put, count ..., реализуя их как операции над массивом. Вот схема некоторых типичных компонентов: full, count и put. Так, условие, при котором стек полон, имеет вид:
full: BOOLEAN is -- Является ли стек (его представление) заполненным? do Result := (count = capacity) end
Компонент capacity унаследован от класса ARRAY и задает емкость стека, равную числу элементов массива. Для count потребуется ввести атрибут:
count: INTEGER
Это пример эффективной реализации отложенного компонента как атрибута. Наконец,
put (x: G) is -- Втолкнуть x на вершину. require not full do count := count + 1 array_put (x, count) end
Процедура array_put унаследована от класса ARRAY. Ее цель - записать новое значение в указанный элемент массива.
| Компоненты capacity и array_put имели в классе ARRAY имена count и put. Смену прежних имен мы поясним позднее. | <
/p>
Класс ARRAYED_STACK типичен как вариант наследования, образно именуемый "брак по расчету". Оба класса, - абстрактный и эффективный, - дополняя друг друга, создают достойную пару.
Помимо эффективной реализации методов, отложенных (deferred) в классе STACK, класс ARRAYED_STACK способен переопределять реализованные. Компонент change_top, реализованный в STACK в виде последовательности вызовов remove и put, можно переписать более эффективно:
array_put (x, count)
Указание на переопределение компонента следует ввести в предложение наследования:
class ARRAYED_STACK [G] inherit STACK [G] redefine change_top end ... Остальное, как прежде ...
Инвариант этого класса может иметь вид
invariant non_negative_count: count >= 0 bounded: count <= capacity
Первое утверждение выражает свойство АТД. Фактически оно присутствует в родительском классе STACK и потому является избыточным. Здесь оно приводится в педагогических целях. Из окончательной версии класса его нужно изъять. Второе утверждение включает емкость массива - capacity. Это - инвариант реализации.
Сравнив ARRAYED_STACK с представленным ранее классом STACK2, вы увидите, как сильно он упростился благодаря наследованию. Это сравнение мы продолжим при обсуждении методологии наследования, в ходе которого ответим на критику, звучащую иногда в адрес наследования "по расчету" и так называемого наследования реализаций.
Числовые и сравнимые значения
Следующий пример напрямую относится к повседневной практике ОО-разработки и неразрывно связан с построением библиотеки Kernel. Ряд классов Kernel, потенциально необходимых всем приложениям, требуют поддержки таких операций арифметики, как infix "+", infix "-", infix "*", prefix "-", а также специальных значений zero (единичный элемент группы с операцией "+") и one (единичный элемент группы с операцией "*"). Эти компоненты используют отдельные классы библиотеки Kernel: INTEGER, REAL и DOUBLE. Впрочем, они нужны и другим, заранее не определенным классам, например, классу MATRIX, который описывает матрицы определенного вида. Приведенные абстракции уместно объединить в отложенном классе NUMERIC, являющемся частью библиотеки Kernel:
deferred class NUMERIC feature ... infix "+", infix "-", infix "*", prefix "-", zero, one... end
NUMERIC имеет строгое математическое определение. Его экземпляры служат для представления элементов кольца (множества с двумя операциями, каждая из которых индуцирует на нем группу, причем одна из операций коммутативна, а вторая дистрибутивна относительно первой). Многим классам необходимо отношение порядка с операциями сравнения элементов. Такая возможность полезна для классов Kernel, таких как STRING, и для многих других классов. Поэтому в состав библиотеки входит отложенный класс COMPARABLE:
deferred class COMPARABLE feature ... infix "<", infix "<=", infix ">", infix ">="... end
Математически его экземпляры - это полностью упорядоченные множества с заданным отношением порядком. Не все потомки COMPARABLE должны быть потомками NUMERIC. В классе STRING арифметика не нужна, однако нужен порядок. Обратно, не все потомки NUMERIC должны быть потомками COMPARABLE. Так, на множестве матриц с действительными коэффициентами есть сложение, умножение, единица, нуль, что придает ей свойства кольца, но нет отношения порядка. Поэтому COMPARABLE и NUMERIC должны оставаться различными классами, и ни один из них не должен быть потомком другого. Объекты некоторых типов, однако, имеют числовую природу и одновременно допускают сравнение. (Такие классы моделируют вполне упорядоченные кольца.) Примеры таких классов - REAL и INTEGER. Целые и действительные числа сравнивают, складывают и умножают. Их описание можно построить на множественном наследовании:
expanded class REAL inherit NUMERIC COMPARABLE feature ... end
 Рис. 15.4. Структура множественного и единичного наследования
Деревья - это списки и их элементы
Класс дерева TREE - еще один яркий пример множественного наследования. Деревом называется иерархическая структура, составленная из узлов с данными. Обычно ее определяют так: "Дерево либо пусто, либо содержит объект, именуемый его корнем, с присоединенным списком деревьев (рекурсивно определяемых) - потомков корневого узла". К этому добавляют определение узла: "Пустое дерево не содержит узлов; узлами непустого дерева являются его корень и по рекурсии узлы потомков". Эти определения, хотя и отражают рекурсивную сущность дерева, не способны показать его внутренней простоты. Мы же заметим, что между понятиями дерева и узла нет серьезных различий. Узел можно определить как поддерево, корнем которого он является. В итоге приходим к классу TREE [G], который описывает как узлы, так и деревья. Формальный родовой параметр G отражает тип данных в каждом узле. Следующее дерево, является, например, экземпляром TREE [INTEGER]:
 Рис. 15.6. Дерево целых чисел Вспомним также о понятии списка, чей класс LIST рассмотрен в предыдущих лекциях. В общем случае его реализация требует введения класса CELL для представления его элементов структуры.
 Рис. 15.7. Представление списка Эти понятия позволяют прийти к простому определению дерева: дерево (или его узел) есть список, - список его потомков, но является также потенциальным элементом списка, поскольку может представлять поддерево другого дерева. Определение: дерево Дерево - это список и элемент списка одновременно. Это определение еще потребует доработки, однако, уже сейчас позволяет описать класс:
deferred class TREE [G] inherit LIST [G] CELL [G] feature ... end
От класса LIST наследуются такие компоненты как количество узлов (count), добавление, удаление узлов и т. д. От класса CELL наследуются компоненты, позволяющие работать с узлами, задающими родителя или братьев: следующий брат, добавить брата, присоединить к другому родителю. Этот пример характерен тем, что иллюстрирует преимущества повторного использования при множественном наследовании. Создание специальных компонентов вставки или удаления поддеревьев означало бы повторение того, что уже сделано для списка элементов. Нам же остаются лишь косметические доработки. Кроме того, следует позаботиться о добавлении в предложение feature специфических компонентов, присущих только деревьям, и компонентов, являющихся результатом взаимных компромиссов, неизбежных при любой свадьбе, и обеспечивающих взаимную гармонию родительских классов. Их текст невелик и займет в классе TREE чуть больше страницы, поскольку наш класс вполне законный плод союза списков и элементов списка.
| Этот процесс подобен процессу, применяемому математиками при комбинировании теорий: топологическое векторное пространство является одновременно топологическим пространством и векторным пространством. Здесь тоже необходимы некоторые связующие аксиомы. |
Дублируемое наследование и универсальность
В завершение мы должны рассмотреть особый случай дублируемого наследования. Он касается компонентов, содержащих родовые параметры. Рассмотрим следующую схему (подобная ситуация может возникнуть не только при прямом, но и при косвенном дублируемом наследовании):
class A [G] feature f: G;... end class B inherit A [INTEGER] A [REAL] end
В классе B по правилу дублируемого наследования компонент f должен использоваться совместно. Но из-за универсализации возникает неоднозначность, - какой результат должен возвращать компонент - real или integer? Та же проблема возникнет, если f имеет параметр типа G. Подобная неоднозначность недопустима. Отсюда правило: Универсальность в правиле дублируемого наследования Тип компонента, совместно используемого в правиле дублируемого наследования, а также тип любого из его аргументов не может быть родовым параметром класса, от которого произошло дублируемое наследование компонента. Для устранения неоднозначности можно выполнить переименование в точке наследования.
Дублируемое наследование
Дядюшка Жак: С кем желаете Вы говорить, сударь, с конюхом или с поваром? Ибо я у Вас и то, и другое.Мольер, "Скупой"
Дублируемое наследование (repeated inheritance) возникает, когда класс является потомком другого класса более чем на одном пути наследования. При этом возникает потенциальная неоднозначность, которую и следует разрешить. В явном виде такой вариант наследования возникает только в достаточно серьезных разработках. Если вас интересуют лишь ключевые составляющие объектной методологии, то можно сразу перейти к чтению следующей лекции.
Играем в имена
Смена имен подчеркивает важность именования - как компонентов, так и классов - в практике ОО-разработки ПО. Формально, класс - это отображение имен компонентов в сами компоненты. Компоненты известны остальному миру благодаря именам. В последней лекции будет дан ряд правил выбора имен компонентов. Заметим, что предпочтение следует отдавать общеизвестным именам: count, put, item, remove, ... - выбор которых подчеркивает общность абстракций, существующую, несмотря на объективные различия классов. Придерживаясь этого стиля, вы увеличите вероятность конфликта имен при множественном наследовании, но отчасти избавитесь от переименований, имевших место в случае с классом WINDOW. Но каким бы правилам не отдавалось предпочтение, должна быть обеспечена гибкость в подборе имен, отвечающих потребностям каждого класса.
Использование родительской процедуры создания
Еще один пример иллюстрирует типичный случай переименования процедуры создания класса. Вспомните класс ARRAYED_STACK, полученный порождением от STACK и ARRAY. Процедура создания ARRAY размещает в памяти массив с заданными границами:
make (minb, maxb: INTEGER) is -- создать массив с границами minb и maxb -- (пустой если minb > maxb) do ... end
Для создания стека необходимо создать массив, позволяющий вместить заданное число элементов. Реализация основана на процедуре создания ARRAY:
class ARRAYED_STACK [G] inherit STACK [G] redefine change_top end ARRAY [G] rename count as capacity, put as array_put, make as array_make end creation make feature -- Initialization make (n: INTEGER) is -- Создать стек, допускающий размещение n элементов. require non_negative_size: n >= 0 do array_make (1, n) ensure capacity_set: capacity = n empty: count = 0 end ... Другие компоненты ... invariant count >= 0; count <= capacity end
Заметим, что выполнение соглашений об именах - выбор make как стандартного имени базовой процедуры создания - привело бы к конфликту, который, впрочем, не возникает благодаря переименованию, устраняющему заодно двусмысленность в отношении count и put. Оба имени встречаются в каждом классе.
Ключевые концепции
Подход к конструированию ПО, подобный конструированию из кубиков, требует возможности объединения нескольких абстракций в одну. Это достигается благодаря множественному наследованию.В самых простых и наиболее общих случаях множественного наследования два родителя представляют независимые абстракции.Множественное наследование часто необходимо как для моделирования систем, так и для повседневной разработки ПО, в частности, создания повторно используемых библиотек.Конфликты имен при множественном наследовании должны устраняться переименованием.Переименование позволяет ввести в классе контекстно-адаптированную терминологию.Компоненты следует отделять от их имен. Один и тот же компонент в разных классах может быть известен под разными именами. Класс определяет отображение имен в компоненты.Дублируемое наследование - мощная техника - возникает как результат множественного наследования, при котором один класс становится потомком другого несколькими способами.При дублируемом наследовании компонент общего предка становится одним компонентом, если он наследуется под одним именем, и несколькими независимыми компонентами в противном случае.Конкурирующие версии общего предка при динамическом связывании должна устраняться предложением select.Механизм репликации при дублируемом наследовании не должен дублировать компоненты, включающие родовые параметры.В ОО-среде семантическая перегрузка, поддерживаемая динамическим связыванием, более полезна, чем синтаксическая перегрузка.
Конфликт имен
Каждый класс обладает доступом ко всем компонентам своих родителей. Он может использовать их, не указывая тот класс, в котором они были описаны. После обработки inherit в классе class C inherit A ... метод f класса C становится известен как f. То же справедливо и для клиентов: при объявлении сущности x типа C вызов компонента записывается как x.f без каких-либо ссылок на A. Все метафоры "хромают", иначе можно было бы говорить, что наследование - форма усыновления: C усыновляет все компоненты A. Усыновление не меняет присвоенных имен, и набор имен компонентов данного класса содержит наборы имен компонентов каждого его родителя. А если родители класса разные компоненты назвали одним именем? Возникает противоречие, поскольку согласно установленному ранее правилу запрещена перегрузка имен: в классе имя компонента обозначает только один компонент. Это правило не должно нарушаться при наличии родителей класса. Рассмотрим пример:
class SANTA_BARBARA inherit LONDON NEW_YORK feature ... end-- class SANTA_BARBARA
Что предпринять, если LONDON и NEW_YORK имеют в своем составе компонент с именем, например, foo (нечто)? Ни при каких обстоятельствах нельзя нарушить запрет перегрузки имен компонентов. Как следствие, класс SANTA_ BARBARA окажется некорректным, что обнаружится при трансляции.
| Вспомним класс TREE, порожденный от классов CELL и LIST, каждый из которых имеет компонент с именем item. Кроме того, оба класса имеют метод, названный put. Выбор каждого имени не случаен, и мы не хотим менять их в исходных классах лишь потому, что кому-то пришла идея объединить эти классы в дерево. |
Что делать? Исходный код классов LONDON и NEW_YORK может быть недоступен; или на его исправления может быть наложен запрет; а при отсутствии такого запрета, возможно, вам не захочется ничего менять, поскольку LONDON написан не вами, и выход новой версии класса заставит все начинать с нуля. Наконец, самое главное, принцип Открыт-Закрыт не разрешает исправлять модули при их повторном использовании.
Всегда ошибочно обвинять в грехах своих родителей. Проблема конфликта имен возникла в самом классе. В нем должно найтись и решение.
Класс, наследующий от разных родителей разные компоненты с идентичным именем, не будет корректен, пока мы не включим в его декларацию наследования одно или несколько предложений переименования rename. Каждое из них назначает новое локальное имя одному или нескольким унаследованным компонентам. Например:
class SANTA_BARBARA inherit LONDON rename foo as fog end NEW_YORK feature ... end
Как внутри SANTA_BARBARA, так и во всех клиентах этого класса компонент LONDON с именем foo будет именоваться fog, а одноименный компонент NEW_YORK - просто foo. Клиенты LONDON, как и прежде, будут знать этот компонент под именем foo.
Этого достаточно для устранения конфликта (если других совпадений нет, а класс LONDON и класс NEW_YORK не содержат компонента с именем fog). В противном случае можно переименовать компонент класса NEW_YORK:
class SANTA_BARBARA inherit LONDON rename foo as fog end NEW_YORK rename foo as zoo end feature ... end
Предложение rename следует за указанием имени родителя и предшествует любым выражениям redefine, если таковые имеются. Можно переименовать и несколько компонентов, как в случае:
class TREE [G] inherit CELL [G] rename item as node_item, put as put_right end
где устраняется конфликт между одноименными компонентами CELL и LIST. Компоненту CELL с именем item дается идентификатор node_item, аналогично и put переименовывается в put_right.
Конфликт переопределений
Пока в ходе наследования мы меняли лишь имена. А что, если промежуточный предок, такой, как B или C (см. последний рисунок), переопределит дублируемо наследуемый компонент? При динамическом связывании это может привести к неоднозначности в D. Проблему решают два простых механизма: отмена определения (undefinition) и выделение (selection). Как обычно, вы сами примете участие в их разработке и убедитесь в том, что при четкой постановке задачи нужная конструкция языка становится совершенно очевидной. Пусть дублируемо наследуемый компонент переопределяется в одной из ветвей:
 Рис. 15.21. Переопределение - причина потенциальной неоднозначности Класс B переопределяет f. Поэтому в D этот компонент представлен в двух вариантах: результат переопределения в B и исходный вариант из A, полученный через класс C. (Можно предполагать, что и C переопределяет f, но это не внесет в наше рассуждение ничего нового.) Такое положение дел отличается от предыдущих случаев, в которых мы имели лишь один вариант компонента, возможно, наследуемый под разными именами. Что произойдет в результате? Ответ зависит от того, под одним или разными именами класс D наследует варианты компонентов. Подразумевает ли дублируемое наследование репликацию или совместное использование? Рассмотрим эти случаи по порядку.
Конфликт при совместном использовании: отмена определения и соединение компонентов
Предположим вначале, что две версии наследуются под одним и тем же именем. Это случай совместного использования. Одному имени должен в точности соответствовать один компонент. Возможны три ситуации. Если одна версия отложена, а другая - эффективна, то сложностей не возникает, будет использован эффективный вариант компонента. Заметим, что этот случай явно предусмотрен правилом одного имени: речь в нем идет лишь о конфликте имен двух эффективных версий.Каждая версия эффективна, однако обе они переопределяются в D в предложении redefine. Проблемы снова не возникает, поскольку обе версии сливаются в одну, переопределяемую в тексте класса.Обе версии эффективны, но обе не переопределяются, тогда действительно возникает конфликт имен. Класс D будет отвергнут, как нарушающий правило одного имени.
Нередко (3) означает ошибку: создана неоднозначность имен, и ее необходимо исправить. Тривиальным решением проблемы является переименование одного из вариантов, но тогда мы от рассматриваемого случая совместного использования переходим к репликации, изучаемой ниже. Есть и другая, более изощренная возможность решения конфликта (3). Она состоит в том, чтобы позволить одному из вариантов "взять верх" над другим. Дальнейшее очевидно - свести эту ситуацию к (1), сделав один из двух вариантов отложенным. Правила переопределения дают возможность переопределить компонент f как отложенный, хотя для этого и потребуется ввести промежуточный класс, скажем C', - наследника C, единственная роль которого - в переопределении отложенного f . Затем класс D должен быть порожден не от C, а от C'. Сложно и некрасиво. Вместо этого нам нужен простой языковой механизм: undefine. В секции наследования класса он приводит к появлению нового предложения:
class D inherit B C undefine f end feature ... end
Синтаксически предложение undefine следует за rename (всякая отмена определения должна действовать на окончательный вариант имени компонента), но до redefine (прежде, чем что-то переопределять, мы должны позаботиться об отмене ненужных определений).
Признаком того, что предлагаемый языковой механизм желателен, почти всегда является его направленность на решение нескольких проблем (соответственно, плохой механизм создает больше проблем, чем решает). Механизм отмены определений отвечает этому требованию: он позволяет соединять компоненты в условиях множественного (не обязательно - дублируемого) наследования. Пусть мы хотим свести воедино две абстракции:
 Рис. 15.22. Два родителя и слияние компонентов
Мы хотим, чтобы D трактовал f и g как один компонент. Очевидно, это возможно лишь при условии совместимости семантики и сигнатур обоих компонентов (числа и типов аргументов и результата, если он есть). Допустим, что имена компонентов различны, и мы хотели бы сохранить имя f. Добиться желаемого можно, объединив переименование с отменой определения:
class D inherit B C rename g as f undefine f end feature ... end
B получил полное превосходство над C, передавая классу D как сам компонент, так и его имя. Возможны и другие сочетания: компонент можно получить от одного из родителей, имя - от другого; можно переименовать оба компонента, присвоив им новое имя в D.
Еще один, более "симметричный" вариант соединения компонентов, заключается в замене обоих унаследованных вариантов на новый компонент. Достаточно указать оба компонента в предложении redefine, убедившись предварительно, что оба компонента имеют одно и то же финальное имя (добавив, если надо, выражение rename). В результате конфликта имен не возникнет (случай (2)), а объединение двух вариантов даст новый компонент.
Конфликты при репликации: выделение
Рассмотрим теперь случай конфликтов переопределений, связанных с репликацией. Пусть при дублируемом наследовании происходит переопределение и переименование эффективного компонента, так что имеем два эффективных компонента, наделенных собственными именами.
 Рис. 15.23. Необходимость выделения Представленный на рисунке класс B меняет имя f на bf и переопределяет сам компонент. При этом мы опять полагаем, что C никак не меняет f, иное предположение нисколько не повлияет на ход нашего рассуждения. Более того, результат остался бы прежним, если бы B переопределял компонент f без его переименования, которое мы могли отложить до описания D. Допустим также, что речь не идет о соединении компонентов (которое происходит при переопределении обоих или отмене определения одного). Поскольку компоненты наследуются под разными именами, то происходит их репликация. Класс D получает пару независимых компонентов, которые, в отличие от предыдущих случаев репликации, не являются копиями одного и того же компонента. В отличие от случая совместного использования не возникает конфликта имен. Однако возникают другие конфликты, относящиеся к динамическому связыванию. Пусть полиморфная сущность a1 типа A (общий предок) на этапе выполнения связывается с экземпляром типа D (общим потомком). Что тогда означает вызов a1.f? Правило динамического связывания гласит: вызываемый вариант f выбирается с учетом типа цели - объекта D. Но теперь это впервые нельзя истолковать однозначно: D содержит два равноценных варианта, известных под именами f и bf, соответствующих оригиналу f класса A. Как и при конфликте имен, нельзя позволять компилятору делать выбор, пользуясь собственными правилами, - это противоречило бы принципам ясности и надежности. Управление ситуацией должно оставаться за автором разработки. Для устранения неоднозначности необходим простой языковой механизм - предложение select. Вот версия класса, в которой предпочтение при динамическом связывании сущности f типа A отдается версии класса C:
class D inherit B C select f end feature ... end
В этом варианте предпочтение отдается версии класса B:
class D inherit B select bf end C feature ... end
Синтаксически предложение select следует за предложениями rename, undefine и redefine, если таковые имеются (выбор осуществляется после переименования и переопределения). Применение этого механизма регламентирует следующее правило: Правило выделения Класс, наследовавший две или более различные и эффективные версии компонента дублируемого предка и не переопределивший их, должен включить одну из них в предложение select. Механизм select устраняет неоднозначность раз и навсегда. Потомкам класса нет необходимости (и они не должны) повторять выделение.
Краткая плоская форма
Плоская форма класса дает корректное описание класса. Помимо роли, которую она играет в интересах документации, она представляет интерес для разработчиков, имеющих дело с самим классом или его потомками. Клиентам же класса нужна более абстрактная картина с меньшим числом деталей. В одной из предыдущих лекций мы уже видели, роль краткой формы класса (кнопка short на рисунке обеспечивает ее построение). Объединение двух понятий дает новое понятие краткой плоской формы (flat-short form). Как и краткая форма класса, она содержит лишь общедоступную информацию, в ней не указаны скрытые компоненты, а для экспортируемых компонентов не приводится реализация, в частности, предложения do. Как и плоская форма, краткая плоская форма задает все компоненты класса - и унаследованные, и описанные в нем самом. Краткая плоская форма является основным методом документирования классов, в том числе повторно используемых классов библиотек. В этом виде информация о классе становится доступна его клиентам (и тем, кто занимается сопровождением класса). Краткая плоская форма служит для описания всех классов в библиотеке Base [M 1994a].
Лунка и кнопка
Вот пример, в котором, как и раньше, без множественного наследования не обойтись. Идейно он близок к примеру с корпоративным самолетом, спальным вагоном и другими типами, полученными в результате объединения абстракций. Впрочем, теперь мы будем работать с понятиями из практики программирования. Среда разработки ISE, описанная в лекции 19 курса "Основы объектно-ориентированного проектирования", подобно другим графическим приложениям, содержит "кнопки" для выполнения определенных действий. В среду встроен механизм "выбрать и перетащить" (pick and throw), аналог традиционного механизма буксировки drag-and-drop. С его помощью можно выбрать объект на экране; при этом курсор мыши превращается в "камешек", форма которого указывает тип выбранного объекта. Камешек можно перетащить и опустить в лунку, форма которой соответствует камешку, инициируя тем самым определенное действие. Например, инструментарий Class Tool, позволяющий исследовать свойства класса, имеет "классную лунку", опустив в которую камешек нового класса, вы перенастроите инструмент на показ его свойств.
 Рис. 15.12. Pick and throw (Выбрать и перетащить) Обратите внимание на нижнюю строку с кнопками форматирования. Нажатие каждой из них позволяет получить разнообразную информацию о классе ARRAY, например краткую форму класса. Как показано на рисунке, пользователь, работая в окне Feature Tool, выбрал щелчком правой кнопки класс INTEGER. Он передвигает его в направлении "лунки" класса в окне Class Tool, настроенного сейчас на ARRAY. Перетаскивание завершается щелчком правой кнопки на "лунке" класса, форма которой соответствует форме камешка. Тем самым Class Tool будет перенастроен на работу с выбранным классом INTEGER. Иногда удобнее, чтобы "лунка" была одновременно и кнопкой, что позволяет не только "загонять" в нее объект, но независимо от этого щелкать по ней левой кнопкой. Таковой является наша "лунка" класса, точка внутри которой указывает на присутствие в ней объекта (сначала ARRAY, а затем INTEGER). Щелчок по ней левой кнопкой перенастроит инструмент на работу с текущим объектом, что полезно, когда дисплей отражает другую информацию. Такая лунка с кнопкой реализуется специальным классом BUTTONHOLE. Нетрудно догадаться, что класс BUTTONHOLE возникает в результате наследования от классов BUTTON и HOLE. Новый класс сочетает в себе компоненты и свойства обоих родителей, реагирует как кнопка, и допускает операции как над лункой.
Может ли самолет быть имуществом?
Наш первый подходящий пример относится скорее к моделированию систем, чем к проектированию программных продуктов. Однако он наглядно иллюстрирует ситуацию, в которой множественное наследование необходимо. Пусть класс AIRPLANE описывает самолет. Среди запросов к нему могут быть число пассажиров (passenger_count), высота (altitude), положение (position), скорость (speed); среди команд - взлететь (take_off), приземлиться (land), набрать скорость (set_speed). Независимо от него может иметься класс ASSET, описывающий понятие имущества. К его компонентам можно отнести такие атрибуты и методы, как цена покупки (purchase_price), цена продажи (resale_value), уменьшить в цене (depreciate), перепродать (resell), внести очередной платеж (pay_installment). Наверное, вы догадались, к чему мы клоним: компания ведь может владеть самолетом! И для пилота самолет компании это просто машина, способная взлетать, садиться, набирать скорость. Для финансиста это имущество, имеющее (очень высокую) цену покупки, (слишком низкую) цену продажи, и вынуждающее компанию ежемесячно платить по кредиту. Для моделирования понятия "самолет компании" прибегнем к множественному наследованию:
 Рис. 15.3. Самолет компании
class COMPANY_PLANE inherit PLANE ASSET feature ... Любой компонент, характерный для самолетов компании, (отличающийся от наследуемых компонентов родителей) ... end
Родителей класса достаточно перечислить в предложении inherit. (Как обычно, можно разделять их имена точкой с запятой, хотя это не обязательно.) Порядок перечисления классов не играет никакой роли. В моделировании систем найдется еще немало примеров, подобных COMPANY_PLANE. Наручные часы-калькулятор моделируются с применением множественного наследования. Один родитель позволяет устанавливать время и отвечать на такие запросы, как текущее время и текущая дата. Другой - электронный калькулятор - поддерживает арифметические операции.Наследником классов судно и грузовик является амфибия (AMPHIBIOUS_VEHICLE). Наследник классов: судно, самолет - гидросамолет (HYDROPLANE). (Как и с TEACHING_ASSISTANT, здесь также возможно дублируемое наследование, поскольку каждый из классов-родителей является потомком средства передвижения VEHICLE.)Ужин в ресторане; поездка в вагоне поезда - вагон-ресторан (EATING_CAR). Вариант: спальный вагон (SLEEPING_CAR).Диван-кровать (SOFA_BED), на котором можно не только читать, но и спать."Дом на колесах" (MOBILE_HOME) - вид транспорта (VEHICLE) и жилище (HOUSE) одновременно; и так далее.
С точки зрения программиста эти примеры представляют академический интерес - нам платят за построение систем, а не за построение модели мира. Впрочем, во многих практических приложениях с аналогичными комбинациями абстрактных понятий вы обязательно столкнетесь. Более подробный пример из графической среды разработки ISE мы изложим чуть ниже.
Наследование функциональных возможностей
Вот еще одна типичная ситуация. Многие программные инструменты должны сохранять "историю", что позволяет пользователям: просмотреть список последних команд;вторично выполнить последнюю команду;выполнить новую команду, отредактировав для этого предыдущую;аннулировать действие последней команды, которая не сумела закончить свою работу.
Такой механизм привлекателен для любой интерактивной среды, однако его создание требует больших усилий. Поэтому историю поддерживают лишь немногие инструменты (к примеру, ряд "командных оболочек" Unix и Windows), да и те нередко частично. Универсальные же решения не зависят от конкретного инструмента. Их можно инкапсулировать в класс, а от него - породить другой класс для управления рабочей сессией любого инструмента. (Решение с применением классов-клиентов допустимо, но не так привлекательно.) И снова без множественного наследования не обойтись, так как недостаточно иметь родителя, знающего только историю. Набор полезных возможностей предоставляет класс TEST, инкапсулирующий ряд механизмов тестирования класса: прием и хранение данных от пользователя, вывод и хранение результата, сравнение, регрессное тестирование и т.д. Хотя решение с использованием вложения может быть предпочтительным, неплохо иметь возможность при тестировании класса X определять класс X_TEST, порожденный от X и TEST. Далее мы будем встречать и другие примеры наследования функциональных возможностей, при котором один класс F инкапсулирует набор, например констант или методов математической библиотеки, а другой, объявляя себя потомком F, может ими воспользоваться.
Ненавязчивое дублирующее наследование
На практике не столь часто встречаются примеры, подобные "межконтинентальным" водителям, в которых нужны и репликация компонентов, и их совместное применение. Они не для новичков. Следует приобрести опыт, чтобы браться за них. Иначе в попытке использовать дублирующее наследование "в лоб", можно лишь все усложнить, когда это и не нужно.
 Рис. 15.19. Избыточное наследование На рисунке показана типичная ошибка начинающих (или рассеянных разработчиков): класс D объявлен наследником B, ему нужны также свойства класса A, но B сам является потомком A. Забыв о транзитивности наследования, разработчик пишет:
class D ... inherit B A ...
В итоге возникает дублируемое наследование. Его избыточность очевидна. Впрочем, при надлежащем соблюдении принятых соглашений все компоненты классов (при сохранении их имен) будут использоваться совместно, новых компонентов не появится, и дополнительных издержек не будет. Даже если в B часть имен атрибутов меняется, единственным следствием этого станет лишь некоторый расход памяти. Из этого есть только одно исключение: случай, когда B переопределяет один из компонентов A, что приведет к неоднозначности в D. Но тогда, как будет показано ниже, компилятор выдаст сообщение об ошибке, предлагая выбрать в D один из двух вариантов компонента. Избыточное, хотя и безвредное наследование может произойти, если A - это класс, реализующий универсальные функции, например ввода-вывода, необходимые B и D. В этом случае достаточно объявить D наследником B. Это автоматически делает D потомком A, что позволяет обращаться ко всем нужным функциям. Избыточное наследование не нанесет никакого вреда, оставшись практически без последствий.
| Такие случаи "безвредного" наследования могут происходить при порождении от универсальных классов ANY и GENERAL, речь о которых пойдет в следующей лекции. |
Общие предки
Множественное наследование не запрещает, например, того, чтобы класс D был наследником классов B и C, каждый из которых является потомком класса A. Эту ситуацию и называют дублируемым наследованием.
 Рис. 15.15. Дублируемое наследование Если B и C наследники потомков A, (случай 1), то такое наследование именуется косвенным. Если A, B и C - это один класс (случай 2), - наследование именуется прямым, что может быть записано в виде:
class D inherit A A ... feature ... end
Оценка
Приведенные примеры наглядно проиллюстрировали мощь и силу механизма множественного наследования. Необходимость его применения подтверждена опытом построения универсальных библиотек [M 1994a]. Как объединить две абстракции, если множественное наследование недоступно? Видимо, вы должны выбрать одну из них как "официальный" родительский класс, а все компоненты второй просто скопировать, превратив новый класс в ее "нелегального" потомка. В результате на нелегальной части класса теряется полиморфизм, все преимущества повторного использования и многое другое, что неприемлемо.
Окна - это деревья и прямоугольники
Рассмотрим оконную систему с произвольной глубиной вложения окон:
 Рис. 15.5. Окна и подокна В соответствующем классе WINDOW мы найдем компоненты двух основных видов: те, что рассматривают окно как иерархическую структуру (список подокон, родительское окно, число подокон, добавить, удалить подокно);те, что рассматривают окно как графический объект (высота, ширина, отобразить, спрятать, переместить окно).
Этот класс можно написать как единое целое, смешав все компоненты. Однако такой проект будет не самым удачным. Класс WINDOW следует рассматривать как сочетание двух абстракций: иерархической структуры, представленной классом TREE;прямоугольного экранного объекта, представленного классом RECTANGLE.
На практике класс будет описан так:
class WINDOW inherit TREE [WINDOW] RECTANGLE feature ... Характерные компоненты окна ... end
Обратите внимание, класс TREE является родовым (generic) классом, а потому требует указания фактического родового параметра, здесь - самого класса WINDOW. Рекурсивная природа определения отражает рекурсию, присущую моделируемой ситуации, - окно является одновременно деревом окон. Далее, можно подметить, что отдельные окна не содержат ничего, кроме текста. Эту особенность окон можно реализовать вложением, представив класс TEXT_WINDOW как клиента класса STRING, введя атрибут
text: STRING
Предпочтем, однако, вариант, в котором текстовое окно является одновременно строкой. В этом случае используем множественное наследование с родителями WINDOW и STRING. (Если же все наши окна содержат лишь текст, их можно сделать прямыми потомками TREE, RECTANGLE и STRING, однако и здесь решение "в два хода" возможно будет более предпочтительным.)
ОО-разработка и перегрузка
Анализ роли имен, сделанный в этой лекции, позволяет вернуться к вопросу о внутриклассовой перегрузке (in-class name overloading). Напомню, что в таких языках, как Ada 83 и Ada 95, перегрузка разрешена - можно давать одно имя разным компонентам одного синтаксического модуля. Например, в одном пакете возможны определения:
infix "+" (a, b: VECTOR) is... infix "+" (a, b: MATRIX) is...
Языки Java и C++ позволяют делать то же самое в пределах класса. Ранее мы называли эту возможность синтаксической перегрузкой. Это - статический механизм. Для однозначного разрешения вызова, например, x + y, достаточно посмотреть на тип аргументов x и y, который очевиден из текста программы. В объектной технологии применяется и более мощный механизм семантической (или динамической) перегрузки. Так, если классы VECTOR и MATRIX наследуют от общего предка NUMERIC компонент
infix "+" (a: T) is...
и каждый из них переопределяет его нужным образом, то понять, о какой операции + идет речь в выражении x + y, можно только динамически во время выполнения программы. Семантическая перегрузка - действительно интересный механизм, позволяющий использовать единое имя в тексте различных классов для представления разных вариантов по сути одной и той же операции, такой, как сложение в NUMERIC. Правила для утверждений, рассматриваемые в следующей лекции, уточнят эту ситуацию, требуя, чтобы переобъявления компонента сохраняли его фундаментальную семантику. Сохраняется ли роль синтаксической перегрузки в объектной технологии? Трудно найти разумные аргументы в ее поддержку. Можно понять, почему язык Ada 83, не имеющий классов, ее использовал. Но в ОО-языке выбор одного имени для обозначения разных операций - это прямой путь к созданию беспорядка. Проблема состоит еще и в том, что синтаксическая форма перегрузки вступает в конфликт с семантической, в активе которой - полиморфизм и динамическое связывание. Рассмотрим вызов x.f (a). Если он следует за полиморфными операторами присваивания x := y и a := b, то при сохранении имен его результат будет в точности тем же, что и для y.f (b), даже если типы b и y отличны от типов a и x.
Но при перегрузке это свойство не сохраняется! Теперь f может быть перегруженным именем двух разных компонентов: одного - типа a, другого - типа b. Чему отдать предпочтение: синтаксической перегрузке или динамическому связыванию? Хуже того, базовый класс типа y может переопределять один или оба перегруженных компонента. И таким комбинациям, как и причинам ошибок, нет числа.
То, что мы наблюдаем, является нежелательным результатом взаимодействия двух отдельных языковых черт. Предусмотрительный разработчик, предлагая новый язык и "поиграв" с некой новой возможностью, быстро откажется от нее, встретив несовместимость с более важными компонентами языка.
Таковы риски синтаксической перегрузки, а каковы все же ее плюсы? Ответить на этот вопрос нелегко. Простой принцип доступности кода гласит, что в тексте одного модуля читатель должен быть совершенно уверен в соответствии имени и значения. При внутриклассовой перегрузке это свойство теряется.
Типичный пример, иногда приводимый в подтверждение полезности перегрузки, связан с компонентами класса STRING. Чтобы к одной строке, при отсутствии перегрузки, добавить другую строку или отдельный символ, используются разные имена компонентов: s1.add_string (s2) и s1.add_character ('A'), или в инфиксной записи s := s1++ s2 и s := s1 + 'A'. При перегрузке обе операции можно назвать одинаково. Так ли это необходимо? Объекты типов CHARACTER и STRING наделены совершенно разными свойствами. Добавление символа всегда увеличивает длину строки на 1. Сцепление строк может оставить длину неизменной (если вторая строка пуста) или увеличить ее произвольным образом. Применение разных имен кажется не только разумным, но и желательным, особенно потому, что приведенные выше примеры ошибок действительно вполне возможны.
Предположим, даже, что решено использовать перегрузку, но и в этом случае придется подумать о более точном критерии, позволяющем выбирать нужный компонент. Общепринятый критерий синтаксической перегрузки различает компоненты по их сигнатуре, что не исключает неоднозначности.
Типичный пример - процедуры создания точек в полярной или декартовой системе координат: make_cartesian и make_polar. Сигнатуры обеих процедур одинаковы, - они имеют два аргумента типа REAL, однако, работают совершенно по-разному. Перегрузку здесь использовать нельзя. Для отражения того факта, что оба компонента и в самом деле различны, им следует дать разные имена.
| Реализацию процедур создания ("конструкторов") в Java и C++ нельзя описывать без иронии. Так, вы не вправе давать конструкторам разные имена, а вынуждены полагаться на перегрузку. Пытаясь решить эту проблему, я не нашел ничего лучше, чем ввести искусственный третий параметр. |
В итоге (внутриклассовая) синтаксическая перегрузка в ОО-среде создает немало проблем, не давая видимых преимуществ. (Тем же, кто использует Java, C++ или Ada 95, можно посоветовать полностью отказаться от перегрузки, прибегая к ней лишь при создании конструкторов, то есть тогда, когда язык не оставляет другого выбора.) Стараясь умело применять объектный подход, придерживайтесь простого правила: каждый компонент имеет имя, каждое имя означает только один компонент.
Переименование компонентов
Иногда при множественном наследовании возникает проблема конфликта имен (name clash). Ее решение - переименование компонентов (feature renaming) - не только снимает саму проблему, но и способствует лучшему пониманию природы классов.
Переименование
Любой язык, поддерживающий множественное наследование, должен как-то решать проблему конфликта имен. Коль скоро мы не можем и не должны требовать от разработчиков внесения изменений в исходные классы, есть всего два решения, помимо тех, что были описаны выше: требовать от клиентов устранения всех неоднозначностей;выбирать некую интерпретацию по умолчанию.
В соответствии с первым подходом, класс C, наследующий компонент f от A и B, будет нормально откомпилирован, возможно, с выдачей предупреждения. Ничего страшного не произойдет, пока в тексте клиента C не обнаружится нечто подобное:
x: C ... x.f ...
Клиенту придется квалифицировать ссылку на f, используя нотацию, например, такую: x.f | A, либо x.f | B, чтобы указать подразумеваемый класс. Это решение противоречит, однако, одному из принципов, важность которого мы подчеркивали в этой лекции: структура наследования класса касается лишь самого класса и его предков, но не клиентов, за исключением случаев полиморфного применения компонентов. Пользуясь f из C, я не должен знать о том, введена эта функция классом C либо получена им от A или B. Согласно второй стратегии, запись x.f корректна. Выбор одного из вариантов делается средствами языка. Критерием выбора является, например, порядок, в котором C перечисляет своих родителей. Для обращения к другим вариантам может существовать особая форма записи. Данный подход реализован в нескольких производных от Lisp языках с поддержкой множественного наследования. Тем не менее, выбор семантики по умолчанию весьма опасен ввиду потенциальной несовместимости со статической типизацией. Эти проблемы решает смена имен. Одним из ее преимуществ является возможность создания клиентского интерфейса с "понятными" именами компонентов.
Плоская форма класса
Наследование - это скорее инструмент поставщика класса, чем клиента; это прежде всего внутренний механизм эффективного построения классов. И действительно, клиенту нужно знать о наследовании и структуре семейства классов ровно столько, чтобы он мог применять полиморфизм и динамическое связывание. Как следствие, у нас должна быть возможность представить класс в самодостаточном виде независимо от его генеалогии. Это особенно важно, когда наследование служит для разделения различных компонентов сложной абстракции, как в случае концепции окон, частями которой являются деревья и прямоугольники. Эту задачу решает плоская форма класса. Но вам не придется ее создавать. Ее построит один из инструментов среды разработки, который можно запустить, введя команду сценария (flat class_name) или щелкнув по соответствующей пиктограмме. Плоская форма класса C - это корректная запись класса, имеющая, - с точки зрения клиента, не использующего полиморфизм, - ту же семантику, что и класс C, но лишенная всех предложений наследования. Именно так выглядел бы любой класс, если бы его создатель не мог пользоваться наследованием. Построение плоской формы предполагает: устранение предложения inherit, если оно есть;сохранение в неизменном виде всех определений и переопределений из C;введение в класс объявлений всех унаследованных компонентов, скопированных из соответствующих классов-родителей, с учетом всех указанных в inherit преобразований: переименования, переопределения, отмены определений, выделения (select), объединения компонентов;добавление к каждому унаследованному компоненту строки комментария вида: from ANCESTOR, где указано имя ближайшего предка, (пере)определившего компонент (а в случае объединения компонентов - победившая сторона);восстановление полной формы предусловий и постусловий унаследованных методов (по правилам наследования утверждений, изложенным в следующей лекции);восстановление полного инварианта класса как конъюнкции (and) всех родительских инвариантов с последующим преобразованием в случае применения переименованных или выделенных компонентов.
Полученный в результате класс содержит все компоненты оригинала, как введенные в самом классе, так и полученные им от предков (вторая категория компонентов от первой отличается лишь комментарием). В случае наличия меток в секциях объявления компонентов, например, feature - Access, подобные метки остаются. Секции с одинаковыми метками объединяются. В каждой секции компоненты выстраиваются по алфавиту. На рисунке показана часть плоской формы класса LINKED_TREE из библиотеки Base. Результат получен с применением Class Tool в среде разработки ISE. Для повторения результата настройте Class Tool на LINKED_TREE и щелкните по кнопке формата Flat.
 Рис. 15.14. Отображение плоской формы
Плоские структуры
Смена имен - лишь одно из средств, используемых мастером наследования для построения полноценных классов, удовлетворяющих потребностям своих клиентов. Другим таким средством является переопределение. В этой и следующей лекции мы увидим еще несколько таких механизмов: отмену определений (undefinition), соединение (join), выделение (select), скрытие потомков (descendant hiding). Мощь этих комбинируемых механизмов делает наследование излишне заметным, поэтому иногда возникает необходимость в существовании версии класса, свободной от наследования, - плоской форме (flat form).
По обе стороны океана
Следующий пример позволит нам промоделировать ситуацию дублируемого наследования и изучить возникающие проблемы. Пусть класс DRIVER имеет атрибуты:
age: INTEGER address: STRING violation_count: INTEGER -- Число записанных нарушений
и методы:
pass_birthday is do age := age + 1 end pay_fee is -- Оплата ежегодной лицензии. do ... end
Класс наследник, US_DRIVER учитывает налоговое законодательство США, другой, FRENCH_DRIVER, - налоговое законодательство Франции. Рассмотрим категорию людей, которым в течение года приходится водить машину в обеих странах. Нужного класса у нас еще нет, и простым решением этой проблемы кажется множественное наследование. Опишем класс FRENCH_US_DRIVER как порожденный от US_DRIVER и FRENCH_DRIVER. Налицо дублируемое наследование.
 Рис. 15.16. Типы водителей
Подбор локальных имен
Возможность переименования наследуемого компонента небезынтересна и при отсутствии конфликта имен. Она позволяет разработчику класса подбирать подходящие имена для всех компонентов, как описанных в самом классе, так и унаследованных от предков. Имя, под которым класс наследует компонент предка, может ничего не говорить клиентам класса. Его выбор определялся интересами клиентов предка, в то время как новый класс вписан в новый контекст и представляет иную абстракцию с собственной системой понятий. Смена имен позволяет решить возникающие проблемы, разделяя компоненты и их имена. Хорошим примером является класс WINDOW, порожденный от класса TREE. Последний описывает иерархическую структуру, единую для всех деревьев, в том числе и для окон, но имена, понятные в исходном контексте, могут не подходить для интерфейса между WINDOW и его клиентами. Смена имен дает возможность привести их в соответствие с местными обычаями:
class WINDOW inherit TREE [WINDOW] rename child as subwindow, is_leaf as is_terminal, root as screen, arity as child_count, ... end RECTANGLE feature ... Характерные компоненты window ... end
Аналогично, класс TREE, который сам порожден от CELL, может сменить имя right на right_sibling и т.д. Путем смены имен класс может создать удобный набор наименований своих "служб" вне зависимости от истории их создания.
Правила об именах
(В этом разделе мы только формализуем сказанное выше, поэтому при первом чтении книги его можно пропустить.) Мы уже видели, что в случае возможной неоднозначности конфликты имен пресекаются, хотя некоторые ситуации бывают вполне корректны. Чтобы в представлении множественного и дублируемого наследования не оставить никакой неоднозначности, полезно обобщить ограничения на конфликт имен в едином правиле: Заканчивая этот раздел, сведем изложенный ранее материал в единое правило: Конфликты имен: определение и правило В классе, образованном в результате множественного наследования, возникает конфликт имен, если два компонента, наследованные от разных родителей, имеют одно и то же финальное имя. Конфликт имен делает класс некорректным за исключением следующих случаев: Оба компонента унаследованы от общего предка, и ни один из них не получен повторным объявлением версии предка.Оба компонента имеют совместимые сигнатуры, и, по крайней мере, один из них наследуется в отложенной форме.Оба компонента имеют совместимые сигнатуры и переопределяются в новом классе.
Ситуация (1) описывает совместное использование при дублируемом наследовании. Для случая (2) "наследование в отложенной форме" возможно по двум причинам: либо отложенная форма задана родительским классом, либо компонент был эффективным, но порожденный класс отменил его реализацию (undefine). Ситуации (2) и (3) рассматриваются отдельно, однако, их можно представить как один вариант - вариант соединения (join). Переходя к n компонентам (n >= 2), можно сказать, что ситуации (2) и (3) возникают, когда от разных родителей класс принимает n одноименных компонентов с совместимыми сигнатурами. Конфликт имен не делает класс некорректным, если эти компоненты могут быть соединены, иными словами: все n компонентов отложены, так что некому вызвать конфликт определений;существует единственный эффективный компонент. Его реализация станет реализацией остальных компонентов;два или несколько компонентов эффективны. Класс должен их переопределить. Новая реализация будет использоваться как для переопределяемых компонентов, так и для любых отложенных компонентов, участвующих в конфликте.
И, наконец, точное правило употребления конструкции Precursor. Если в переопределении используется Precursor, то неоднозначность может возникнуть из-за того, что неясно, версию какого родителя следует вызывать. Чтобы решить эту проблему, следует использовать вызов вида Precursor {PARENT} (...), где PARENT - имя желаемого родителя. В остальных случаях указывать имя родителя не обязательно.
Правило переименования
В этом разделе мы не введем никаких новых понятий, а лишь точнее сформулируем известные правила и приведем пример, призванный пояснить сказанное. Начнем с запрета возникновения конфликта имен: Определение: финальное имя Финальным именем компонента класса является: Для непосредственного компонента (объявленного в самом классе) - имя, под которым оно объявлено.Для наследуемого компонента без переименования - финальное имя компонента (рекурсивно) в том родительском классе, от которого оно унаследовано.Для переименованного компонента - имя, полученное при переименовании.
Правило одного имени Разные эффективные компоненты одного класса не могут иметь одно и то же финальное имя. Конфликт имен происходит в том случае, когда два разных по сути компонента, оба эффективные (реализованные), имеют одно финальное имя. Такой конфликт делает класс некорректным, однако ситуацию легко исправить, добавив надлежащее предложение переименования. Ключевым в тексте правила является слово "разные". Если под одним именем мы наследуем от родителей компонентов их общего предка, действует принцип совместного использования компонентов: наследуется один компонент, и конфликта имен не возникает. Запрет на дублирование имен касается лишь эффективных компонентов. Если один или более компонентов с омонимичными именами являются отложенными, их можно фактически слить воедино, поскольку отсутствует несовместимость реализаций. Подробнее мы поговорим об этом чуть ниже. Приведенные правила просты и интуитивны. Чтобы в последний раз нам убедиться в их правильном понимании, построим простой пример, демонстрирующий допустимые и недопустимые варианты наследования.
 Рис. 15.20. Два варианта наследования
class A feature this_one_OK: INTEGER end class B inherit A feature portends_trouble: REAL end class C inherit A feature portends_trouble: CHARACTER end class D inherit -- Это неправильный вариант! B C end
Класс D наследует this_one_OK дважды, один раз от B, другой раз - от C. Конфликта имен не возникает, поскольку данный компонент будет использоваться совместно. На самом деле, это - один компонент предка A. Два компонента portend_trouble ("предвещающие беду") заслуженно получили такое имя. Они различны, потому их появление в D ведет к конфликту имен, делая класс некорректным. (У них разные типы, но и одинаковые типы никак не повлияли бы на ход нашего обсуждения.) Переименовав один из компонентов, мы с легкостью сделаем D корректным:
class D inherit -- Этот вариант класса теперь полностью корректен. B rename portends_trouble as does_not_portend_trouble_any_more end C end
Применение плоской формы
Плоская форма класса - ценный инструмент разработчика. Именно она позволяет увидеть все компоненты класса, собранные в одном месте, игнорируя то, как они были получены в играх с наследованием. При чтении текста класса трудно бывает понять, что стоит за именем каждого из его компонентов. Это один из недостатков наследования. Плоская форма класса решает эту проблему, формируя полную картину происходящего. Кроме того, она может оказаться полезной при построении автономной версии класса, не обремененной историей порождения. Потеря полиморфизма снижает ценность такого класса.
Пример, неподходящий для введения
Сначала покончим с одним бытующим заблуждением. Для этого рассмотрим пример, приводимый (в том или ином виде) во многих статьях, книгах и лекциях, но зачастую порождающий недоверие к множественному наследованию. И дело не в том, что этот пример неверен; просто при первом знакомстве с проблемой он не может служить иллюстрацией, поскольку являет собой образец нетипичного применения этого механизма. В стандартной формулировке примера речь заходит о классах TEACHER и STUDENT, и вам тут же предлагают отметить тот факт, что отдельные студенты тоже преподают, и советуют ввести класс TEACHING_ASSISTANT, порожденный от TEACHER и STUDENT.
 Рис. 15.1. Пример множественного наследования Выходит, в этой схеме что-то не так? Не обязательно. Но как начальный пример он весьма неудачен. Все дело в том, что STUDENT и TEACHER - не отдельные абстрактные понятия, а вариации на одну тему UNIVERSITY_PERSON. Поэтому, увидев картину в целом, мы обнаружим пример не просто множественного, но дублируемого (repeated) наследования - схемы, изучаемой позже в этой лекции, в которой класс является правильным наследником другого класса двумя или более различными путями:
 Рис. 15.2. А это пример дублируемого наследования Дублируемое наследование - это особый случай. Его применение требует большого опыта в использовании более простых форм порождения классов. Этот пример нельзя обсуждать с начинающими просто потому, что он создает впечатление конфликтов между отдельными компонентами, наследуемых от обоих родителей, в то время как речь идет о свойстве, приходящем от общего предка. При правильном подходе исправить эту проблему не составит труда. Но было бы серьезной ошибкой начинать разговор с таких исключительных и непростых случаев, делая вид, будто они характерны для всего множественного наследования. По-настоящему распространенные случаи множественного наследования не вызывают таких проблем. В их основе - не варианты одной, а сочетание различных абстракций. Именно это чаще всего и требуется при построении структур наследования, именно это и следует обсуждать при первом знакомстве с предметом. Дальнейшие примеры - из этой серии.
Пример повышенной сложности
Вот более сложный пример применения разных аспектов дублируемого наследования. Проблема, близкая по духу нашему примеру, возникла из интересного обсуждения в основной книге по C++ [Stroustrup 1991]. Рассмотрим класс WINDOW с процедурой display и двумя наследниками: WINDOW_WITH_BORDER и WINDOW_WITH_MENU. Эти классы описывают абстрактные окна, первое из них имеет рамку, а второе поддерживает меню. Переопределяя display, каждый класс выводит на экран стандартное окно, а затем добавляет к нему рамку (в первом случае) и меню (во втором). Опишем окно с рамкой и с поддержкой меню. В результате мы породим класс WINDOW_WITH_BORDER_AND_MENU.
 Рис. 15.24. Варианты окна Переопределим метод display в новом классе; новая версия вначале вызывает исходную, затем строит рамку, а потом строит меню. Исходный класс WINDOW имеет вид:
class WINDOW feature display is -- Отобразить окно (общий алгоритм) do ... end ... Другие компоненты ... end
Наследник WINDOW_WITH_BORDER осуществляет вызов родительской версии display и затем отображает рамку. В дублируемом наследовании нет необходимости, достаточно воспользоваться механизмом Precursor:
class WINDOW_WITH_BORDER inherit WINDOW redefine display end feature -- Output display is -- Рисует окно и его рамку. do Precursor draw_border end feature {NONE} -- Implementation draw_border is do ... end ... end
Обратите внимание на процедуру draw_border, рисующую рамку окна. Она скрыта от клиентов класса WINDOW_WITH_BORDER (экспорт классу NONE), поскольку для них вызов draw_border не имеет смысла. Класс WINDOW_WITH_MENU аналогичен:
class WINDOW_WITH_MENU inherit WINDOW redefine display end feature -- Output display is -- Рисует окно и его меню. do Precursor draw_menu end feature {NONE} -- Implementation draw_menu is do ... end ... end
Осталось описать общего наследника WINDOW_WITH_BORDER_AND_MENU этих двух классов, дублируемого потомка WINDOW. Предпримем первую попытку:
indexing WARNING: "Первая попытка - версия не будет работать корректно!" class WINDOW_WITH_BORDER_AND_MENU inherit WINDOW_WITH_BORDER redefine display end WINDOW_WITH_MENU redefine display end feature display is -- Рисует окно,его рамку и меню.
do Precursor {WINDOW_WITH_BORDER} Precursor {WINDOW_WITH_MENU} end ... end
Заметьте: при каждом обращении к Precursor мы вынуждены называть имя предка. Каждый предок имеет собственный компонент display, переопределенный под тем же именем. Впрочем, как замечает Страуструп, это решение некорректно: версии родителей дважды вызывают исходную версию display класса WINDOW, что приведет к появлению "мусора" на экране. Для исправления ситуации добавим еще один класс, получив тройку наследников класса WINDOW:
indexing note: "Это корректная версия" class WINDOW_WITH_BORDER_AND_MENU inherit WINDOW_WITH_BORDER redefine display export {NONE} draw_border end WINDOW_WITH_MENU redefine display export {NONE} draw_menu end WINDOW redefine display end feature display is -- Рисует окно,его рамку и меню. do Precursor {WINDOW} draw_border draw_menu end ... end
Заметьте, что компоненты draw_border и draw_menu в новом классе являются скрытыми, поскольку мы не видим причин, по которым клиенты WINDOW_WITH_BORDER_AND_MENU могли бы их вызывать непосредственно. Несмотря на активное применение дублируемого наследования, класс переопределяет все унаследованные им варианты display, что делает выражения select ненужными. В этом состоит преимущество спецификатора Precursor в сравнении с репликацией компонентов. Неплохим тестом на понимание дублируемого наследования станет решение этой задачи без применения Precursor, путем репликации компонентов промежуточных классов. При этом, разумеется, вам понадобится select (см. упражнение 15.10). В полученном варианте класса присутствует лишь совместное использование, но не репликация компонентов. Расширим пример Страуструпа: пусть WINDOW имеет запрос id (возможно, целого типа), направленный на идентификацию окон. Если идентифицировать любое окно только одним "номером", то id будет использоваться совместно, и нам не придется ничего менять. Если же мы хотим проследить историю окна, то экземпляр WINDOW_WITH_BORDER_AND_MENU будет иметь три id - независимых "номера".
Новый текст класса комбинирует совместное использование и репликацию id (изменения в тексте класса помечены стрелками):
indexing note: "Усложненная версия с независимыми id." class WINDOW_WITH_BORDER_AND_MENU inherit WINDOW_WITH_BORDER rename id as border_id redefine display export {NONE} draw_border end WINDOW_WITH_MENU rename id as menu_id redefine display export {NONE} draw_menu end WINDOW rename id as window_id redefine display select window_id end feature .... Остальное, как ранее... end
Обратите внимание на необходимость выбора (select) одного из вариантов id.
Примеры множественного наследования
Выясним, прежде всего, в каких ситуациях множественное наследование и в самом деле уместно. Для этого рассмотрим ряд типичных примеров, заимствованных из разных предметных областей. Такой краткий экскурс тем более необходим, что несмотря на элегантность, простоту множественного наследования и реальную потребность в нем, демонстрация этого механизма подчас создает впечатление чего-то сложного и таинственного. И хотя эту точка зрения не подтверждает ни практика, ни теория, она распространилась достаточно широко, и теперь мы просто обязаны потратить немного времени на изучение случаев, в которых множественное наследование действительно совершенно необходимо.
Результат переименования
Убедимся, что нам понятен результат этого действия. Пусть класс SANTA_BARBARA имеет вид (оба унаследованных компонента foo в нем переименованы):
 Рис. 15.13. Устранение конфликта имен (Обратите внимание на графическое обозначение операции смены имен.) Пусть также имеются сущности трех видов:
l: LONDON; n: NEW_YORK; s: SANTA_BARBARA
Вызовы l.foo и s.fog будут являться корректными. После полиморфного присваивания l := s все останется корректным, поскольку имена обозначают один и тот же компонент. Аналогично, корректны вызовы n.foo, s.zoo, которые после n := s также будут давать одинаковый результат. В то же время, следующие вызовы некорректны: l.zoo, l.fog, n.zoo, n.fog, так как ни LONDON, ни NEW_YORK не содержат компонентов с именем fog или zoo;s.foo, поскольку после смены имен класс SANTA_BARBARA уже не имеет компонента с именем foo.
При всей искусственности имен пример хорошо иллюстрирует природу конфликта имен. Хотите верьте, хотите нет, но приходилось слышать, что конфликт порождает "глубокую семантическую проблему". Это неправда. Конфликт имен - простая синтаксическая проблема. Если бы автор первого класса сменил имя компонента на fog, или автор второго - на zoo, конфликта бы не было, и в каждом случае - это всего лишь замена буквы. Конфликт имен - это обычная неудача, он не вскрывает никаких глубоких проблем, связанных с классами, и не свидетельствует об их неспособности работать совместно. Возвращаясь к метафоре брака, можно сказать, что конфликт имен - это не драма (обнаруженная несовместимость групп крови), а забавный факт (матери обоих супругов носят имя Татьяна, и это вызовет трудности для будущих внуков, которые можно преодолеть, договорившись, как называть обеих бабушек).
Смена имен и переопределение
В предыдущей лекции мы обсудили переопределение компонентов, полученных по наследству. (Помните, что переопределение эффективного компонента задает его новое определение, а для отложенного компонента задает его реализацию.) Сравнение переименования и переопределения компонентов поможет многое прояснить. Переопределение меняет компонент, но сохраняет его имя.Переименование меняет имя, но сохраняет компонент.
При помощи переопределения можно добиться того, чтобы одно и то же имя компонента ссылалось на фактически различные компоненты в зависимости от типа объекта, к которому оно применяется (в этом случае говорят о динамическом типе соответствующей сущности). Это - семантический механизм. Смена имен - это синтаксический механизм, позволяющий ссылаться на один и тот же компонент, фигурирующий в разных классах под разными именами. Иногда то и другое можно совмещать:
class SANTA_BARBARA inherit LONDON rename foo as fog redefine fog end ...
Если, как и раньше, l: LONDON; s: SANTA_BARBARA, и выполнено присваивание l := s, то оба вызова l.foo, s.fog включают переопределенную версию компонента fog, объявление которого должно появиться в предложении feature класса. Заметьте: redefine содержит уже новое имя компонента. Это нормально, поскольку под этим именем компонент известен классу. Именно поэтому rename должно находиться выше всех остальных предложений наследования (таких, как redefine и пока неизвестные читателю export, undefine, select). После выполнения rename компонент теряет свой прежний идентификатор и становится известным под новым именем классу, его потомкам и его клиентам.
Сохранение исходной версии при переопределении
(Этот раздел посвящен весьма специфичному вопросу, и при первом чтении книги его можно пропустить.) Приступая к изучению наследования, мы познакомились с простой конструкцией Precursor, позволявшей переопределяемому компоненту вызывать его исходную версию. Механизм дублируемого наследования дает возможность обратиться к более универсальному (хотя и более "тяжеловесному") решению, пригодному в тех редких случаях, когда базовых средств не хватает. Вернемся к известному нам классу BUTTON - потомку WINDOW, переопределяющему display:
display is -- Показ кнопки на экране. do window_display special_button_actions end
где window_display выводит кнопку как обычное окно, а special_button_actions добавляет элементы, специфические для кнопки, отображая, например, ее границы. Компонент window_display в точности совпадает с WINDOW-вариантом display. Мы уже знаем, как написать window_display, используя механизм Precursor. Если метод display переопределен в нескольких родительских классах, то желаемый класс можно указать в фигурных скобках: Precursor {WINDOW}. Того же результата можно достичь, прибегнув к дублируемому наследованию, заставив класс Button быть потомком двух классов Window:
indexing WARNING: "Это первая попытка - данная версия некорректна!" class BUTTON inherit WINDOW redefine display end WINDOW rename display as window_display end feature ... end
Одна из ветвей наследования меняет имя display, а потому, по правилу дублируемого наследования BUTTON, будет иметь два варианта компонента. Один из них переопределен, но имеет прежнее имя; второй переопределен не был, но именуется теперь window_display. Этот вариант кода почти корректен, однако в нем не хватает подвыражения select. Если, как это обычно бывает, мы хотим выбрать переопределенную версию, то запишем:
indexing note: "Это (корректная!)схема дублируемого наследования,% % использующая оригинальную версию переопределяемого компонента" class BUTTON inherit WINDOW redefine display select display end WINDOW rename display as window_display export {NONE} window_display end feature ...
end
Если такая схема должна применяться к целому ряду компонентов, их можно перечислить вместе. При этом нередко возникает необходимость разрешить все конфликты именно в пользу переопределенных компонентов. В этом случае можно воспользоваться select all.
| Предложение export (см. лекцию 16) определяет статус экспорта наследуемых компонентов класса. Так, WINDOW может экспортировать компонент display, а BUTTON сделать window_display скрытым (поскольку его клиенты в нем не нуждаются). Экспорт исходной версии наследуемого компонента может сделать класс формально некорректным, если она не соответствует новому инварианту класса. |
Для скрытия всех компонентов, полученных "в наследство" по одной из ветвей иерархии, служит запись export {NONE} all.
Такой вариант экспорта переопределенных компонентов и скрытия исходных компонентов под новыми именами весьма распространен, но отнюдь не универсален. Нередко классу наследнику необходимо скрывать или экспортировать оба варианта (если исходная версия не нарушает инвариант класса).
Насколько полезна такая техника дублируемого наследования для сохранения исходной версии компонента при переопределении? Обычно в ней нет необходимости, так как достаточно обратиться к Precursor. Поэтому этот способ следует использовать, когда старая версия нужна не только в целях переопределения, но и как один из компонентов нового класса.
Составные фигуры
Следующий пример больше чем пример, - он послужит нам образцом проектирования классов в самых различных ситуациях. Рассмотрим структуру, введенную в предыдущей лекции для изучения наследования и содержащую классы графических фигур: FIGURE, OPEN_FIGURE, POLYGON, RECTANGLE, ELLIPSE и т.д. До сих пор в этой структуре использовалось лишь единичное наследование.
 Рис. 15.8. Элементарные фигуры Пусть в этой иерархии представлены все нужные нам базовые фигуры. Однако в библиотеку классов хотелось бы включить и не базовые фигуры, имеющие широкое распространение. Конечно, любое изображение каждый раз можно строить из примитивов, но это неудобно. Поэтому мы создадим библиотеку фигур, часть которых будут базовыми, а часть - построена на их основе. Так, из экземпляров базисных классов: отрезка и окружности можно собрать колесо:
 Рис. 15.9. Составная фигура Колесо, в свою очередь, может пригодиться при рисовании велосипеда, и т. д. Итак, нам необходим универсальный механизм создания новых фигур, построенных на основе существующих, но, будучи построенными, используемыми наравне с базовыми. Назовем новые фигуры составными (COMPOSITE_FIGURE). Каждую такую фигуру, безусловно, надо порождать от FIGURE, что позволит ей быть "на равных" с базовыми примитивами. Составная фигура - это еще и список фигур, ее образующих, каждая из которых может быть базовой или составной. Воспользуемся множественным наследованием (рис. 15.10). Для получения эффективного класса COMPOSITE_FIGURE выберем одну из возможных реализаций списка, например связный список - LINKED_LIST. Объявление класса будет выглядеть так:
class COMPOSITE_FIGURE inherit FIGURE LINKED_LIST [FIGURE] feature ... end
 Рис. 15.10. Составная фигура - это фигура и список фигур одновременно Предложение feature записывать приятно вдвойне. Работа с составными фигурами во многом сводится к работе со всеми их составляющими. Например, процедура display может быть реализована так:
display is -- Отображает фигуру, последовательно отображая все ее компоненты.
do from start until after loop item.display forth end end | Как и в предыдущих рассмотрениях, мы предполагаем, что класс список предлагает механизм обхода элементов, основанный на понятии курсора. Команда start устанавливает курсор на первый элемент, если он есть (иначе after сразу же равно True), after указывает, обошел ли курсор все элементы, item дает значение элемента, на который указывает курсор, forth передвигает курсор к следующему элементу. |
Я нахожу эту схему прекрасной и, надеюсь, вы тоже пленитесь ее красотой. В ней вы найдете почти весь арсенал средств: классы, множественное наследование, полиморфные структуры данных (LINKED_LIST [FIGURE]), динамическое связывание (вызов item.display применяет метод display того класса, которому принадлежит текущий элемент списка), рекурсию (каждый элемент item сам может быть составной фигурой без ограничения глубины вложенности). Подумать только: есть люди, которые могут прожить всю жизнь и не увидеть этого великолепия!
Но можно пойти еще дальше. Обратимся к другим компонентам COMPOSITE_FIGURE - методам вращения (rotate) и переноса (translate). Они также должны выполнять надлежащие операции над каждым элементом фигуры, и каждый из них может во многом напоминать display. Для ОО-проектировщика это может стать причиной тревоги: хотелось бы избежать повторения; потому выполним преобразование - от инкапсуляции к повторному использованию. (Это могло бы стать девизом.) Техника, рассматриваемая здесь, состоит в использовании отложенного класса "итератор", чьи экземпляры способны выполнять цикл по COMPOSITE_FIGURE. Его эффективным потомком может стать DISPLAY_ ITERATOR, а также ряд других классов. Реализацию этой схемы мы оставляем читателю (см. упражнение 15.4).
Описание составных структур с применением множественного наследования и списка или иного контейнерного класса, как одного из родителей, - это универсальный образец проектирования. Примерами его воплощения являются подменю (см. упражнение 15.8), а также составные команды в ряде интерактивных систем.
Совместное использование и репликация
Из приведенного примера вытекает основная проблема дублируемого наследования: каков смысл компонентов дублируемого потомка (FRENCH_US_DRIVER), полученных от дублируемого предка (DRIVER)? Рассмотрим компонент age. Он наследуется от обоих потомков DRIVER, так что, на первый взгляд, возникает конфликт имен, требующий переименования. Однако такое решение было бы неадекватно проблеме, так как реального конфликта здесь нет - атрибут age унаследованный от DRIVER, задает возраст водителя, и он один и тот же для всех потомков (если только не менять свои данные в зависимости от страны пребывания). То же относится к процедуре pass_birthday. Внимательно перечитайте правило о конфликте имен: Класс, наследующий от разных родителей различные компоненты с идентичным именем, некорректен. Компоненты age (также как и pass_birthday), наследованные классом FRENCH_US_DRIVER от обоих родителей, не являются "различными", поэтому реального конфликта не возникает. Заметьте, неоднозначность могла бы возникнуть лишь в случае переопределения компонента в одном из классов. Чуть позже мы покажем, как справиться с этой проблемой, а пока предположим, что переопределений не происходит. Если компонент дублируемого предка под одним и тем же именем наследуется от двух и более родителей, он становится одним компонентом дублируемого потомка. Этот случай будем называть совместным использованием компонента (sharing). Всегда ли применяется совместное использование? Нет. Рассмотрим компоненты address, pay_fee, violation_count. Обращаясь в службу регистрации автотранспорта в разных странах, водители скорее всего будут указывать разные адреса и по-разному платить ежегодные сборы. Впрочем, и нарушения правил тоже будут различны. Каждый из таких компонентов, следует представить в дублируемом потомке двумя разными компонентами. Данный случай будем называть репликацией (replication). Этот, да и другие примеры, свидетельствует о том, что мы не добьемся желаемого, если все компоненты дублируемого предка будем использовать совместно или наоборот реплицировать.
Поэтому необходима возможность настройки каждого компонента при дублируемом наследовании.
Чтобы совместно использовать один из компонентов, достаточно под одним именем унаследовать исходную версию этого компонента от обоих родителей. Но как реализовать репликацию? Делая все наоборот: породив один компонент под двумя разными именами.
Эта идея не противоречит общему правилу, согласно которому каждое имя в классе служит обозначением лишь одного компонента. Поэтому репликация компонента означает переименование при наследовании.
Правило дублируемого наследования
У дублируемого потомка версии дублируемого компонента, наследуемые под одним и тем же именем, представляют один компонент. Версии, наследуемые под разными именами, представляют разные компоненты, являясь репликацией оригинала дублируемого предка.
Это правило, распространяясь как на атрибуты, так и на методы, дает нам мощный механизм репликации: из одного компонента класса его потомки могут получить два или более компонента. Для атрибутов оно означает введение нового поля во всех экземплярах класса, для метода - новую процедуру или функцию, изначально - с тем же алгоритмом работы.
За исключением особых случаев, включающих переопределение, репликация может носить только концептуальный характер: фактического дублирования кода не происходит, но дублируемый потомок имеет доступ к двум компонентам.
Правило придает желаемую гибкость процессу объединения классов. Вот как может выглядеть класс FRENCH_US_DRIVER:
class FRENCH_US_DRIVER inherit FRENCH_DRIVER rename address as french_address, violation_count as french_violation_count, pay_fee as pay_french_fee end US_DRIVER rename address as us_address, violation_count as us_violation_count, pay_fee as pay_us_fee end feature ... end
В данном случае смена имен происходит на последнем этапе - у дублируемого потомка, но полное или частичное переименование могло быть выполнено и родителями - US_DRIVER и FRENCH_DRIVER. Важно, что будет в конце, - получит ли компонент при дублируемом наследовании одно или разные имена.
Компоненты age и pass_birthday переименованы не были, а потому, как мы и хотели, они используются совместно.
Реплицируемый атрибут, скажем, address, в каждом экземпляре класса FRENCH_US_ DRIVER будет представлен несколькими полями данных. Тогда при условии, что эти классы содержат только указанные нами компоненты, их экземпляры будут выглядеть как на рис. 15.18.
 Рис. 15.17. Совместное использование и репликация
 Рис. 15.18. Репликация атрибутов
(Организация FRENCH_DRIVER и US_DRIVER аналогична организации DRIVER, см. рисунок.)
Особенно важным в реализации классов является умение избегать репликации совместно используемых компонентов, например age из FRENCH_US_DRIVER. Не имея достаточно опыта, можно легко допустить такую ошибку и реплицировать все поля класса. Тратить память впустую недопустимо, так как по мере спуска по иерархии "мертвое" пространство будет лишь возрастать, что приведет к катастрофически неэффективному расходованию ресурсов. (Помните, что каждый атрибут во время выполнения потенциально представлен во многих экземплярах класса и его потомков.)
Механизм компиляции, описанный в конце этой книги, на деле дает гарантию того, что потерь памяти на атрибуты не будет, - концептуально совместно используемые (shared) атрибуты класса будут располагаться в общей для них (shared) физической памяти. Это - один из сложнейших компонентов реализации наследования и вызовов при динамическом связывании. Ситуация усложняется еще и тем, что подобное дублируемое наследование не должно влиять на производительность, что означает:
нулевые затраты на поддержку универсальности;низкие, ограниченные константой, затраты на динамическое связывание (не зависящие от наличия в системе дублируемого наследования классов).
Поскольку существует реализация, отвечающая этим целям, то и в любой системе техника дублируемого наследования не должна требовать значительных издержек.
| Дублируемое наследование в С++ следует другому образцу. Уровень, на котором принимается решение, разделять или дублировать компоненты, - это класс. Поэтому при необходимости дублирования одного компонента, приходится дублировать все. В Java эта проблема исчезает, поскольку запрещено множественное наследование. |
Структурное наследование
Множественное наследование просто необходимо, когда необходимо задать для класса ряд дополнительных свойств, помимо свойств, заданных базовой абстракцией. Рассмотрим механизм создания объектов с постоянной структурой (способных сохраняться на долговременных носителях). Поскольку объект является "сохраняемым", то у него должны быть свойства, позволяющие его чтение и запись. В библиотеке Kernel за эти свойства отвечает класс STORABLE, который может быть родителем любого класса. Очевидно, такой класс, помимо STORABLE, должен иметь и других родителей, а значит, схема не сможет работать, не будь множественного наследования. Примером может служить изученное выше наследование с родителями COMPARABLE и NUMERIC. Форма наследования при которой родитель задает общее структурное свойство, и, чаще всего, имеет имя, заканчивающееся на - ABLE, называется схемой наследования структурного вида. Без множественного наследования нет способа указать, что некоторая абстракция обладает двумя структурными свойствами - числовыми и сохранения, сравнения и хеширования. Выбор только одного из родителей подобен выбору между отцом и матерью.
У15.1 Окна как деревья
Класс WINDOW порожден от TREE [WINDOW]. Поясните суть родового параметра. Покажите, какое новое утверждение появится в связи с этим в инварианте класса.
У15.10 Дублируемое наследование и репликация
Напишите класс WINDOW_WITH_BORDER_AND_MENU без обращения к Precursor. Для доступа к родительскому варианту переопределенного компонента используйте репликацию при дублируемом наследовании. Убедитесь в том, что вы используете правильные предложения select и назначаете каждому компоненту правильный статус экспорта. |
|  |
У15.2 Является ли окно строкой?
Окно содержит ассоциированный с ним текст, представленный атрибутом text типа STRING. Стоит ли отказаться от атрибута и объявить WINDOW наследником класса STRING?
У15.3 Завершение строительства
Завершите проектирование класса WINDOW, показав точно, что необходимо от лежащего в основе механизма управления выводом?
У15.4 Итераторы фигур
При обсуждении COMPOSITE_FIGURE мы говорили о применении итераторов для выполнения операций над составными фигурами. Разработайте соответтсвующие классы итераторов. (Подсказка: в [M 1994a] приведены классы библиотеки итераторов, которые послужат основой вашей работы.)
У15.5 Связанные стеки
Основываясь на классах STACK и LINKED_LIST, постройте класс LINKED_STACK, описывающий реализацию стека как связного списка.
У15.6 Кольцевые списки и цепи
Объясните, почему LIST нельзя использовать для создания кольцевых списков. (Подсказка: в этом вам может помочь изучение формальных утверждений, обсуждение которых вы найдете в начале следующей лекции.) Опишите класс CHAIN, который может служить родителем как для LIST, так и для нового класса кольцевых списков CIRCULAR. Обновите класс LIST и, если нужно, его потомков. Дополните структуру класса, обеспечивающую разные варианты реализации кольцевых списков.
У15.7 Деревья
Согласно одной из интерпретаций, дерево - это рекурсивная структура, представляющая собой список деревьев. Замените приведенное в этой лекции описание класса TREE как наследника LINKED_LIST и LINKABLE новым вариантом
class TREE [G] inherit LIST [TREE [G]] feature ...end
Расширьте это описание до полнофункционального класса. Сравните это расширение с тем, что было описано в тексте данной лекции.
У15.8 Каскадные или "шагающие" (walking) меню
Оконные системы вводят понятие меню, реализуемое классом MENU с запросом, возвращающим список элементов, и командами отображения, перехода к следующему элементу и т.д. Меню составлено из элементов, поэтому нам понадобится класс MENU_ENTRY с такими запросами, как parent_menu и operation (операция, выполняемая при выборе элемента) и такими командами, как execute (выполняет операцию operation). Среди меню нередко встречаются каскадные, или шагающие меню (walking menu), где выбор элемента приводит к появлению подменю (submenu). На рисунке приведено шагающее меню среды Open Windows, созданной корпорацией Sun:
 Рис. 15.25. Выпадающее меню Предложите описание класса SUBMENU. (Подсказка: подменю одновременно является меню и элементом меню, чья операция должна отображать подменю.) Можно ли это понятие с легкостью описать в языке без множественного наследования?
У15.9 Плоский precursor (предшественник)
Что должна показывать плоская форма класса при встрече с инструкцией, использующей Precursor?
Выделение всех компонентов
Любой конфликт переопределений должен быть разрешен посредством select. Если, объединяя два класса, вы натолкнулись на ряд конфликтов, возможно, вы захотите, чтобы один из классов "одержал верх" (почти) в каждом из них. В частности, так происходит в ситуации, метафорично названной "брак по расчету" (вспомните, ARRAYED_STACK - потомок STACK и ARRAY), в которой классы-родители имеют общего предка. (В библиотеках Base оба класса действительно являются удаленными (distant) потомками общего класса CONTAINER.) В этом случае один из родителей (STACK) служит источником спецификаций, и вам, быть может, захочется, чтобы (почти) все конфликты были разрешены именно в его пользу. Решение задачи упрощает следующая запись, дающая возможность не перечислять все конфликтующие компоненты. Предложение inherit класса может содержать такое описание (не более одного) родителя:
SOME_PARENT select all end
Результат очевиден: все конфликты переопределений, - точнее те из них, что останутся после обработки других select, - разрешатся в пользу SOME_PARENT. Последнее уточнение означает, что вы по-прежнему вправе отдать предпочтение другим родителям в отношении некоторых компонентов.
Наследование и утверждения
Абстрактные предусловия
Правило ослабления предусловий может оказаться чересчур жестким в случае, когда наследник понижает уровень абстракции, характерный для его предка. К счастью, есть легкий обходной путь, полностью согласующийся с теорией. Типичным примером этого является порождение BOUNDED_STACK от универсального класса стека (STACK). Процедура занесения в стек элемента (put) в порожденном классе имеет предусловие count <= capacity, где count - текущее число элементов в стеке, capacity - физическая емкость накопителя. В общем понятии стека нет понятия емкости. Поэтому создается впечатление, будто при переходе к BOUNDED_STACK предусловие приходится усилить (от бесконечной емкости перейти к конечной). Как выстроить структуру наследования, не нарушая правило Утверждения Переобъявления? Ответ становится очевиден, если мы ближе познакомимся с требованиями к клиенту. То, что нужно сохранить или ослабить, не обязательно является конкретным предусловием, как оно видится в реализации поставщика (реализация это его забота), но касается предусловия, как оно видится клиенту. Пусть процедура put класса STACK имеет вид:
put (x: G) is -- Поместить x на вершину. require not full deferred ensure ... end
где функция full всегда возвращает ложное значение, а значит, стек по умолчанию никогда не бывает полным.
full: BOOLEAN is -- Заполнено ли представление стека? -- (По умолчанию, нет) do Result := False end
Тогда в BOUNDED_STACK достаточно переопределить full:
full: BOOLEAN is -- Заполнено ли представление стека? -- (Да, если число элементов равно емкости стека) do Result := (count = capacity) end
Предусловие, такое как not full, включающее свойство, которое переопределяется потомками, называется абстрактным (abstract) предусловием. Такое использование абстрактных предусловий для соблюдения правила Утверждения Переобъявления может показаться обманом, однако это не так. Несмотря на то, что конкретное предусловие фактически становится более сильным, абстрактное предусловие не меняется. Важно не то, как реализуется утверждение, а то, как оно представлено клиентам в интерфейсе класса (краткой или плоско-краткой форме).
Предваренный условием вызов
if not s.full then s.put (a) end
будет корректен независимо от вида STACK, присоединенного к s.
Впрочем, есть доля справедливой критики этого подхода, так как он вступает в противоречие с принципом Открыт-Закрыт. При проектировании класса STACK мы должны предвидеть ограниченную емкость отдельных стеков. Не проявив должной предусмотрительности, нам придется вернуться к проектированию STACK и изменить интерфейс класса. Это неизбежно. Из следующих двух свойств только одно должно выполняться:
ограниченный стек является стеком;в стек всегда можно добавить еще один элемент.
Если предпочесть первое свойство и допускать порождение BOUNDED_STACK от STACK, мы должны согласиться с тем, что общее понятие стека включает предположение о невозможности в ряде случаев выполнить операцию put, абстрактно выраженное запросом full.
| Было бы ошибкой включить в виде постусловия подпрограммы full в классе STACK выражение Result = False или (придерживаясь рекомендуемого стиля, эквивалентный ему) инвариант not full. Это - случай излишней спецификации, ограничивающей свободу реализации компонентов потомками класса. |
Два стиля
Ряд основных различий между понятиями, о которых шла речь, мы представили в виде таблицы. Итак, есть два отношения - "быть потомком" и "быть клиентом"; две формы повторного использования - интерфейсов и реализаций; скрытие информации и его отсутствие; защита от изменений в поставляемых модулях и отсутствие таковой. Наличие альтернатив в любом случае не вносит противоречий, и в зависимости от контекста каждый из вариантов вполне оправдан. Отважимся на смелый шаг и сведем эти противоположности в одно целое:
Таблица 16.1. Слияние четырех противоположностейКлиентПотомок
Повторное использование интерфейсов
Информация скрывается
Исходная реализация защищена
| |
Повторное использование реализаций
Информация не скрывается
Исходная реализация не защищена
| |
Возможно, есть и другие подходы к решению этой проблемы, но я не знаю ни одного столь же простого, доступного и практичного.
Еще раз о базовых классах
С введением закрепленных типов нуждается в расширении понятие базового класса типа. Сначала классы и типы были для нас едины, и это их свойство - отправной пункт ОО-метода, - по существу, сохраняется, хотя нам пришлось немного расширить систему типов, добавляя в классы родовые параметры. Каждый тип основан на классе и для типа определено понятие базового класса. Для типов, порожденных универсальным классом с заданными фактическими родовыми параметрами, базовым классом является универсальный класс, в котором удалены фактические параметры. Так, например, для LIST [INTEGER] базовым классом является LIST. На классах основаны и развернутые типы; и для них аналогично: для expanded SOME_CLASS [...] базовый класс - SOME_CLASS. Закрепление типов - это еще одно расширение системы типов, которое, подобно двум предыдущим, сохраняет свойство выводимости каждого типа непосредственно из класса. Базовым для like anchor является базовый класс типа сущности anchor в текущем классе. Если anchor есть Current, базовым будет класс, в котором это объявление содержится.
Фиксированная семантика компонентов copy, clone и equality
Чаще всего замороженные (frozen) компоненты применяются в операциях общего назначения, подобных тем, что входили в состав класса GENERAL. Так, есть две версии базовой процедуры копирования:
copy, frozen standard_copy (other: ...) is -- скопировать поля other в поля текущего объекта. require other_not_void: other /= Void do ... ensure equal (Current, other) end
Два компонента (copy и standard_copy) описаны как синонимы. Правила разрешают совместно описывать два компонента класса, если они имеют общее определение. Заметьте, в данном случае только один из компонентов допускает повторное объявление, второй - заморожен. В итоге потомки вправе переопределить copy, что необходимо, например классам ARRAY и STRING, которые сравнивают содержимое, а не значение указателей. Однако параллельно удобно иметь и замороженный вариант компонента для вызова при необходимости исходной операции - standard_copy. Компонент clone, входящий в состав класса GENERAL, тоже имеет "двойника" standard_clone, однако обе версии заморожены. Зачем понадобилось замораживать clone? Причина кроется не в запрете задания иной семантики операции клонирования, а в необходимости сохранения совместимости семантик copy и clone, что, как побочный эффект, облегчает задачу разработчика. Общий вид объявления clone таков:
frozen clone (other:...): ... is -- Void если other пуст; иначе вернуть новый объект, содержимое которого скопировано из other. do if other /= Void then Result := "Новый объект того же типа, что other" Result.copy (other) end ensure equal (Result, other) end
Фраза "Новый объект того же типа, что other" есть неформальное обозначение вызова функции, которая создает и возвращает объект того же типа, что и other. (Result равен Void, если other - "пустой" указатель.) Несмотря на замораживание компонента clone, он будет изменяться, соответствуя любому переопределению copy, например в классах ARRAY и STRING. Это удобно (для смены семантики copy-clone достаточно переопределить copy) и безопасно (задать иную семантику clone было бы, скорее всего, ошибкой).
Переопределять clone не нужно (да и нельзя), однако при переопределении copy понадобится переопределить и семантику равенства. Как сказано в постусловиях компонентов copy и clone, результатом копирования должны быть тождественные объекты. Сама функция equal, по сути, зафиксирована, как и clone, но она зависит от компонентов, допускающих переопределение:
frozen equal (some, other: ...): BOOLEAN is -- Обе сущности some и other пусты или присоединены -- к объектам, которые можно считать равными? do Result := ((some = Void) and (other = Void)) or else some.is_equal (other) ensure Result = ((some = Void) and (other = Void)) or else some.is_equal (other) end
Вызов equal (a, b) не соответствует строгому ОО-варианту a.is_ equal (b), но на практике выгодно отличается от него, будучи применим, даже если a или b пусто. Базовый компонент is_equal не заморожен и требует согласованного переопределения в любом классе, переопределяющем copy. Это делается для того, чтобы семантика равенств оставалась совместимой с семантикой copy-clone, а постусловия copy и clone были по-прежнему верными.
Глобальная структура наследования
Ранее мы уже ссылались на универсальные (universal) классы GENERAL и ANY, а также на безобъектный (objectless) класс NONE. Пришло время пояснить их роль и представить глобальную структуру наследования.
И снова неограниченная универсальность
Конечно же, не все случаи универсальности ограничены. Форма - STACK [G] или ARRAY [G] - по-прежнему существует и называется неограниченной универсальностью. Пример DICTIONARY [G, H -> HASHABLE] показывает, что класс одновременно может иметь как ограниченные, так и неограниченные родовые параметры. Изучение ограниченной универсальности дает шанс лучше понять неограниченный случай. Вы, конечно же, вывели правило, по которому class C [G] следует понимать как class C [G -> ANY]. Поэтому если G - неограниченный типовой параметр (например, класса STACK), а x - сущность, имеющая тип G, то мы точно знаем, что можем делать с сущностью x: читать и присваивать значения, сравнивать (=, /=), передавать как параметр и применять в универсальных операциях clone, equal и прочее.
Игра в рекурсию
Вот некий трюк с нашим примером: спросим себя, возможен ли вектор векторов? Допустим ли тип VECTOR [VECTOR [INTEGER]]? Ответ следует из предыдущих правил: только если фактический родовой параметр совместим с NUMERIC. Сделать это просто - породить класс VECTOR от класса NUMERIC (см. упражнение 16.2):
indexing description: "Векторы, допускающие сложение" class VECTOR [G -> NUMERIC] inherit NUMERIC ... Остальное - как и раньше...
Векторы, подобные этому, можно и впрямь считать "числовыми". Операции сложение и умножение дают структуру кольца, в котором роль нуля (zero) играет вектор из G-нулей, и роль единицы (unity) - вектор из G-единиц. Операция сложения в этом кольце - это, строго говоря, векторный вариант infix "+", речь о котором шла выше. Можно пойти дальше и использовать VECTOR [VECTOR [VECTOR [INTEGER]]] и так далее - приятное рекурсивное приложение ограниченной универсальности.
Интерфейс и повторное использование реализаций
Знакомясь с объектным подходом по другим источникам, вы могли видеть в них предостережения использования "наследования реализаций". Однако в нем нет ничего плохого. Повторное использование имеет две формы: использование интерфейсов и использование реализаций. Любой класс - это реализация (возможно, частичная) АТД. Он содержит как интерфейс, выражающий спецификацию АТД и образующий лишь "вершину айсберга", так и набор решений, определяющих реализацию. Повторное использование интерфейса означает согласие со спецификацией, повторное использование реализации - ваше согласие положиться на свойства класса, а не только на АТД. Совместно для одних и тех же целей эти две возможности не применяются. Если вы хотите получить некоторое множество возможностей только через их абстрактные свойства и хотите быть защищенными от будущих изменений реализации, выбирайте повторное использование интерфейсов. Но в некоторых случаях вам может понравиться определенная реализация, поскольку она обеспечивает нужную основу вашего решения. Эти формы повторного использования взаимно дополняют друг друга и обе совершенно законны. По сути, их воплощением являются два вида межмодульных отношений, имеющих место при ОО-проектировании программ: клиент обеспечивает повторное использование интерфейсов, наследование поддерживает повторное использование реализаций. Повторно используя реализацию, вы безусловно принимаете более ответственное решение, так как не можете рассчитывать на неизменность реализации в перспективе. По этой причине, став наследником класса, вы свяжете себя более сильными обязательствами.
Инварианты
С правилом об инвариантах класса мы встречались и прежде: Правило родительских инвариантов Инварианты всех родителей применимы и к самому классу. Инварианты родителей добавляются к классу. Инварианты соединяются логической операцией and then. (Если у класса нет явного инварианта, то инвариант True играет эту роль.) По индукции в классе действуют инварианты всех его предков, как прямых, так и косвенных. Как следствие, выписывать инварианты родителей в инварианте потомка еще раз не нужно (хотя семантически такая избыточность не вредит: a and then a есть то же самое, что a). Полностью восстановленный инвариант класса можно найти в плоской и краткой плоской форме последнего (см. лекцию 15).
Как быть честным
Теперь нам понятно, как обманывать. Но как же быть честным? Объявляя подпрограмму повторно, мы можем сохранить ее исходные утверждения, но также мы вправе: заменить предусловие более слабым;заменить постусловие более сильным.
Первый подход символизирует щедрость и великодушие: мы допускаем большее число случаев, чем изначально. Это не причинит вред клиенту, который на момент вызова удовлетворяет исходному предусловию. Второй подход означает, что мы выдаем больше, чем от нас требовалось. Это не причинит вред клиенту, полагающемуся на выполнение по завершении вызова исходных постусловий. Итак, основное правило: Правило (1) Утверждения Переобъявления (Assertion Redeclaration) При повторном объявлении подпрограммы предусловие может заменяться лишь равным ему или более слабым, постусловие - лишь равным ему или более сильным. Это правило отражает тот факт, что новый вариант подпрограммы не должен отвергать вызовы, допустимые в оригинале, и должен, как минимум, представлять гарантии, эквивалентные гарантиям исходного варианта. Он вправе, хоть и не обязан, допускать большее число вызовов или давать более сильные гарантии. Как явствует из названия, это правило применимо к обеим формам повторного объявления: переопределению и реализации отложенного компонента. Второй случай важен особо, - утверждения будут связаны со всеми эффективными версиями потомков. Утверждения подпрограммы, как отложенной, так и эффективной, задают ее семантику, применимую к ней самой и ко всем повторным объявлениям ее потомков. Точнее говоря, они специфицируют область допустимого поведения подпрограммы и ее возможных версий. Любое повторное объявление может лишь сужать эту область, не нарушая ее. Как следствие, создатель класса должен быть осторожным при написании утверждений эффективной подпрограммы, не привнося излишнюю спецификацию (overspecification). Утверждения должны описывать намерения подпрограммы, - ее абстрактную семантику, - но не свойства реализации. Иначе можно закрыть возможность создания иной реализации подпрограммы у будущих потомков.
Как обмануть клиентов
Чтобы понять, как удовлетворить клиентов, мы должны сыграть роль адвокатов дьявола и на секунду представить себе, как их обмануть. Так поступает опытный криминалист, разгадывая преступление. Как мог бы поступить поставщик, желающий ввести в заблуждение своего честного клиента C, гарантирующего при вызове и ожидающего выполнения ?? Есть два пути: Потребовать больше, чем предписано предусловием . Формулируя более сильное предусловие, мы позволяем себе исключить случаи, которые, согласно исходной спецификации, были совершенно приемлемы.Гарантировать меньше, чем это следует из начального постусловия ?. Более слабое постусловие позволяет нам дать в результате меньше, чем было обещано исходной спецификацией.| Вспомните, что мы неоднократно говорили при обсуждении Проектирования по Контракту: усиление предусловия облегчает задачу поставщика ("клиент чаще не прав"), иллюстрацией чего служит крайний случай - предусловие false (когда "клиент всегда не прав"). |
Как уже было сказано, утверждение A называется более сильным, чем B, если A логически влечет B, но отличается от него: например, x >= 5 сильнее, чем x >= 0. Если утверждение A сильнее утверждения B, говорят еще, что утверждение B слабее утверждения A.
Ключевые концепции
К инварианту класса автоматически добавляются инварианты его родителей.В подходе Проектирования по Контракту наследование, переопределение и динамическое связывание приводят к идее субподрядов.Повторное объявление подпрограммы (переопределение или создание реализации) может сохранить или ослабить предусловие, сохранить или усилить постусловие.Повторное объявление утверждений может использовать только require else (при объединении с предусловием связкой "или") и ensure then (при объединении с постусловием связкой "и"). Применение require/ensure запрещено. В отсутствие названных предложений подпрограмма сохраняет исходные утверждения.Универсальный класс GENERAL и допускающий настройку его наследник обеспечивают переопределяемые компоненты, представляющие общий интерес для всех создаваемых разработчиком классов. Класс NONE замыкает решетку наследования снизу.Заморозив компонент, можно гарантировать его вечную семантическую уникальность.Ограниченная универсальность дает возможность использовать только родовые параметры со специфическими свойствами.Попытка присваивания позволяет динамически проверить, принадлежит ли объект ожидаемому типу. Эта операция не должна использоваться как замена динамического связывания.Потомок вправе переопределять тип любой сущности (атрибута, результата функции, формального параметра подпрограммы). Повторное определение должно быть ковариантным - заменять исходные типы соответствующими, согласуясь с требованиями потомка.Закрепленные объявления (like anchor) - это важная часть системы типов, облегчающая применение ковариантной типизации и позволяющая отказаться от избыточных повторных объявлений.Наследование и скрытие информации - это независимые механизмы. Потомки могут скрывать экспортированные компоненты и экспортировать скрытые компоненты.Компонент, доступный самому классу, доступен и его потомкам.
Кое-что о политике
Что происходит со статусом экспорта при передаче компонента потомку? Наследование и скрытие информации - ортогональные механизмы. Наследование определяет отношение между классом и его потомками, экспорт - между классом и его клиентами. Класс B может свободно экспортировать или скрывать любой из компонентов f, унаследованных им от классаA. При этом доступны все возможные комбинации: f экспортируется в классе A и в классе B (хотя и не обязательно одним и тем же клиентам);f скрыто в A и B;f скрыто в A, но полностью или частично экспортируется в B;f экспортируется в A, но скрыто в B.
Правило гласит: по умолчанию f сохраняет тот статус экспорта, которым компонент был наделен в A. Однако его можно изменить, добавив предложение export в предложение наследования класса. Например:
class B inherit A export {NONE} f end -- Скрыть f (возможно, экспортируемый в классе A) ...
или
class B inherit A export {ANY} f end -- Экспортировать f (возможно, скрытый в классе A) ...
или
class B inherit A export {X, Y, Z} f end -- Сделать f доступным определенным классам ...
Когда не используются закрепленные объявления
Не всякое объявление вида x: A в классе A следует менять на x: like Current и не в каждой паре компонентов одного типа следует один из них делать опорным, а другой - закрепленным. Закрепленное объявление - это своего рода обязательство изменения типа закрепленной сущности при смене типа опорного элемента. Как мы видели, оно не имеет обратной силы: объявив тип сущности как like anchor, вы теряете право на переопределение его в будущем (коль скоро новый тип должен быть совместим с исходным, а с закрепленным типом совместим только он сам). Пока не введено закрепление, остается свобода: если x типа T, то потомок может переопределить тип, введя более походящий тип U. Достоинства и недостатки закрепления сущностей очевидны. Закрепление гарантирует, что вам не придется выполнять повторные объявления вслед за изменением типа опорного элемента, но оно раз и навсегда привязывает вас к типу опорного элемента. Это типичный случай "свободы выбора". (В каком-то смысле Фауст объявил себя like Мефистофель.) Как пример нежелательного закрепления рассмотрим компонент first_child для деревьев, описывающий первого сына данного узла дерева. (При построении дерева он аналогичен компоненту first_element для списков, типом которого изначально является CELL [G] или LINKABLE [G].) Для деревьев требуется повторное объявление. Может показаться, что уместным использовать закрепленное объявление:
first_child: like Current
Но на практике это накладывает слишком много ограничений. Класс дерева может иметь потомков, представляющих разные виды деревьев (их узлов): UNARY_TREE (узлы с одним сыном), BINARY_TREE (узлы с двумя сыновьями) и BOUNDED_ARITY_TREE (узлы с ограниченным числом сыновей). При закреплении first_child все сыновья каждого узла должны иметь один и тот же отцовский тип. Это может быть нежелательным при построении более гибких структур, например бинарного узла с унарным потомком. Для этого компонент нужно описать без закрепления:
first_child: TREE [G]
Это решение не связано с какими-то ограничениями, и для создания деревьев с узлами одного типа вы, оставив класс TREE без изменений, можете породить от него HOMOGENEOUS_TREE, где переопределить first_child как
first_child: like Current
что гарантирует неизменность типов всех узлов дерева.
Когда правила типов становятся несносными
Цель правил типов, введенных вместе с наследованием, в достижении статически проверяемого динамического поведения, так чтобы система, прошедшая проверку при компиляции, не выполняла неадекватных операций над объектами во время выполнения. Вот два основных правила, представленных в первой лекции о наследовании (лекция 14). Правило Вызова Компонентов: запись x.f осмысленна лишь тогда, когда базовый класс x содержит и экспортирует компонент f.Правило Совместимости Типов: при передаче a как аргумента или при присваивании его некой сущности необходимо, чтобы тип a был совместим с ожидаемым, то есть основан на классе, порожденным от класса сущности.
Правило Вызова Компонентов не является причиной каких-либо проблем - это фундаментальное условие всякой работы с объектами. Естественно, что обращаясь к компоненту объекта, нужно проверить, действительно ли данный класс предлагает и экспортирует данный компонент. Правило Совместимости Типов требует больше внимания. Оно предполагает наличие у нас всей информации о типах объектов, с которыми мы работаем. Как правило, это так, - создав объекты, мы знаем, чем они являются, но иногда информация может частично отсутствовать. Вот два таких случая. В полиморфной структуре данных мы располагаем лишь информацией, общей для всех объектов структуры; однако нам может понадобиться и специфическая информация, применимая только к отдельному объекту.Если объект приходит из внешнего мира - файл или по сети - мы обычно не можем доверять тому, что он принадлежит определенному типу.
Давайте займемся исследованием примеров этих двух случаев. Рассмотрим для начала полиморфную структуру данных, такую как список геометрических фигур:
figlist: LIST [FIGURE]
В предыдущих лекциях рассматривалась иерархия наследования фигур. Пусть нам необходимо найти самую длинную диагональ среди всех прямоугольников списка (и вернуть -1, если прямоугольников нет). Сделать это непросто. Выражение item (i).diagonal, где item (i) - i-й элемент списка, идет вразрез с правилом вызова компонентов: item (i) имеет тип FIGURE, а этот класс, в отличие от его потомка RECTANGLE, не содержит в своем составе компонента diagonal.
Механизм решения
И снова запись механизма решения напрямую вытекает из анализа поставленной проблемы. Введем новую форму присваивания, назвав ее попыткой присваивания (assignment attempt):
target ?= source
Знак вопроса указывает на предварительный характер операции. Пусть сущность target имеет тип T, тогда попытка присваивания дает следующий результат: если source ссылается на объект совместимого с T типа, присоединить target к объекту так, как это делает обычное присваивание;иначе (если source равно void или ссылается на объект несовместимого типа) приписать target значение void.
На эту инструкцию не действуют никакие ограничения типов, кроме одного: тип target (T) должен быть ссылочным. Новое средство быстро и элегантно решает поставленные проблемы и, прежде всего, дает возможность обращаться к объектам полиморфной структуры с учетом их типа:
maxdiag (figlist: LIST [FIGURE]): REAL is -- Максимальная длина диагонали прямоугольника в списке; -- если прямоугольников нет, то -1. require list_exists: figlist /= Void local r: RECTANGLE do from figlist.start; Result := -1.0 until figlist.after loop r ?= figlist.item if r /= Void then Result := Result.max (r.diagonal) end figlist.forth end end
Здесь применяются обычные итерационные механизмы работы с последовательными структурами данных (лекция 5 курса "Основы объектно-ориентированного проектирования"). Компонент start служит для перехода к первому элементу (если он есть), after - для выяснения того, имеются ли еще не пройденные элементы, forth - для перехода на одну позицию, item (определенный, если not after) - для выборки текущего элемента. В попытке присваивания используется локальная сущность r типа RECTANGLE. Успех присваивания проверяется сравнением значения r с Void. Если r не Void, то r прямоугольник и можно обратиться к r.diagonal. Эта схема проверки вполне типична. Заметим, что мы никогда не нарушаем правило Вызова Компонентов: обращения к r.diagonal защищены дважды: статически - компилятором, проверяющим, является ли diagonal компонентом класса RECTANGLE, и динамически - нашей гарантией того, что r не Void, а имеет присоединенный объект. Обращение к элементу списка - потомку класса RECTANGLE, например SQUARE (квадрат), связывает r с объектом, и его диагональ будет участвовать в вычислениях. Пример с универсальной функцией чтения объектов retrieval выглядит так:
my_last_book: BOOK ... Сравните с := в первой попытке my_last_book ?= retrieved (my_book_file) if my_last_book /= Void then ... "Обычные операции над my_last_book" ... else ... "Полученное не соответствует ожиданию" ... end
Наследование и скрытие информации
Последний вопрос, оставшийся пока без ответа, как наследование взаимодействует с принципом Скрытия информации. В отношениях между классом и его клиентами скрытие информации определяет разработчик класса. Именно он определяет политику в отношении каждого компонента класса: экспортируя его всем клиентам, разрешая выборочный экспорт, или делая компонент закрытым.
Наследование и утверждения
Обладая изрядной мощью, наследование может быть и опасным. Не будь механизма утверждений, создатели классов могли бы весьма "вероломно" пользоваться повторными объявлениями и динамическим связыванием для изменения семантики операций без возможности контроля со стороны клиента. Утверждения способны на большее: они дают нам боле глубокое понимание природы наследования. Не будет преувеличением сказать, что лишь понимание принципов Проектирования по Контракту позволяет в полной мере постичь сущность концепции наследования. Вкратце мы уже очертили основные правила, управляющие взаимосвязью наследования и утверждений: все утверждения (предусловие и постусловия подпрограмм, инварианты классов), заданные в классах-родителях, остаются в силе и для их потомков. В этом разделе мы уточним эти правила и используем полученные результаты, чтобы дать новый взгляд на наследование как на субподряды (subcontracts).
Не ОО-подход
Переходя к решению этой проблемы, посмотрим, как с такой задачей справлялись другие, не ОО-языки. В языке Ada нет классов, но зато есть пакеты для группировки взаимосвязанных типов и операций. Пакет может быть родовым, с родовыми параметрами, представляющими типы. При этом возникает та же проблема: пакет VECTOR_PROCESSING может включать объявление типа VECTOR и эквивалент нашей функции infix "+". Решение в языке Ada рассматривает необходимые операции, например инфиксное сложение, как родовые параметры. Параметрами пакета могут быть не только типы, как при объектном подходе, но и подпрограммы. Например:
generic type G is private; with function "+" (a, b: G) return G is <>; with function "*" (a, b: G) return G is <>; zero: G; unity: G; package VECTOR_HANDLING is ... Интерфейс пакета ... end VECTOR_HANDLING
Заметим, что наряду с типом G и подпрограммами родовым параметром служит значение zero - нулевой элемент сложения. Типичное использования пакета:
package BOOLEAN_VECTOR_HANDLING is new VECTOR_HANDLING (BOOLEAN, "or", "and", false, true);
В этом примере логическая операция or используется как сложение, and - умножение, а также задаются соответствующие значения для zero и unity. Подробнее мы обсудим этот пример в одной из следующих лекций курса. Являясь решением для Ada, данный прием не применим в объектной среде. Основа ОО-подхода - приоритет типов данных над операциями при декомпозиции ПО, чьим следствием является отсутствие независимых операций. Всякая операция принадлежит некоторому типу данных, основанному на классе. Следовательно, возникшая "на пустом месте" функция, скажем, infix "+", не может быть фактическим родовым параметром, стоящим в одном ряду с типами INTEGER и BOOLEAN. То же касается и значений, таких как zero и unity, обязанных знать свое место - быть компонентами класса - вполне респектабельными членами ОО-сообщества.
Не злоупотребляйте замораживанием
Приведенные примеры замораживания - это типичные образцы применения механизма, гарантирующего точное соответствие копий и клонов семантике исходного класса. Замораживание компонентов не следует делать по соображениям эффективности. (Эту ошибку иногда совершают программисты, работающие на C++ или Smalltalk, которым внушили мысль, будто динамическое связывание накладно и его нужно по возможности избегать.) Хотя вызов замороженных компонентов означает отсутствие динамического связывания, это лишь побочный эффект механизма frozen, а не его конечная цель. Выше мы подробно говорили о том, что безопасное статическое связывание - это проблема оптимизации, и решает ее компилятор, а не программист. В грамотно спроектированном языке компилятор обладает всем необходимым для такой и даже более сильной оптимизации, скажем, для подстановки тела функции в точку вызова (routine inlining). Поиск возможностей оптимизации - задача машин, а не человека. Пользуйтесь frozen в редких, но важных для себя случаях, когда это действительно необходимо (для обеспечения точного соответствия семантике исходной реализации), и пусть ваш язык и ваш компилятор делают свою работу.
Несогласованность типов
Рассмотрим пример с участием класса LINKED_LIST. Пусть мы имеем процедуру добавления в список нового элемента с заданным значением, который вставляется справа от текущего элемента. В деталях процедуры нет ничего необычного, но все же обратим внимание на потребность создания локальной сущности new типа LINKABLE, представляющей элемент списка, который будет создан и включен в список.
 Рис. 16.10. Добавление элемента
put_right (v: G) is -- Вставить элемент v справа от курсора. -- Не передвигать курсор. require not after local new: LINKABLE [T] do create new.make (v) put_linkable_right (new) ... ensure ... См. приложение A ... end
Для вставки нового элемента, имеющего значение v, необходимо предварительно создать элемент типа LINKABLE [G]. Вставка производится закрытой процедурой put_linkable_right, принимающей LINKABLE как параметр (и связывающей его с текущим элементом, используя процедуру put_right класса LINKABLE). Эта процедура осуществляет все нужные манипуляции со ссылками. У потомков LINKED_LIST, таких как TWO_WAY_LIST или LINKED_TREE, процедура put_right тоже должна быть применимой. Но у них она работать не будет! Хотя алгоритм ее остается корректным, сущность new для них должна иметь другой тип - BI_LINKABLE или LINKED_TREE. Поэтому в каждом потомке нужно переопределять и переписывать целую процедуру, и это притом, что ее тело будет идентично оригиналу, за исключением переопределения new! Для подхода, претендующего на решение проблемы повторного использования, это серьезный порок.
Нижняя часть иерархии
На рис. 16.4 представлен также класс NONE, антипод класса ANY, потомок всех классов, не имеющих собственных наследников и превращающий глобальную иерархию наследования классов в решетку (математическую структуру). NONE не имеет потомков, его нельзя переопределить - это лишь удобная фикция, однако, теоретическое существование такого класса оправдано и служит двум практическим целям: Void - пустая ссылка, используемая наряду с другими ссылками, по соглашению имеет тип NONE. (Фактически, Void -это один из компонентов класса GENERAL.)Чтобы скрыть компонент от всех клиентов, достаточно экспортировать его только классу NONE. Предложение feature {NONE}(практически эквивалентное feature {}, но записанное явно) или предложение наследования export {NONE}(на практике дающее тот же результат, что и export {}), делает компонент недоступным для любого класса, написанного разработчиком, ибо NONE не имеет потомков. Обратите внимание на то, что NONE скрывает и все свои компоненты.
Первое свойство объясняет, почему значение Void можно присвоить любому элементу ссылочного типа данных. До сих пор статус Void оставался некой загадкой, теперь, когда Void связано с классом NONE, этот статус становится очевидным, официальным и согласующимся с системой типов: по построению NONE является потомком всех классов, а потому мы можем использовать Void как допустимое значение любой ссылки, не нарушая правил описания типов. По симметрии ко второму свойству заметим, что объявление, начинающееся с feature и экспортирующее все компоненты во все классы, написанные разработчиком, считается сокращением от feature {ANY}. Для повторного экспорта во все классы компонента родителя, доступ к которому был ограничен, можно использовать предложение export {ANY} или его не столь очевидное сокращение export. Классы ANY и NONE обеспечивают замкнутость системы типов и полноту структуры наследования: решетка (это строго определенный математический термин) имеет свой верхний и нижний элемент.
Одно- и двусвязные элементы
В следующем примере мы обратимся к базовым структурам данных. Рассмотрим библиотечный класс LINKABLE, описывающий односвязные элементы, используемые в LINKED_LIST - одной из реализаций списков. Вот частичное описание класса:
indexing description: "Односвязные элементы списка" class LINKABLE [G] feature item: G right: LINKABLE [G] put_right (other: LINKABLE [G]) is -- Поместить other справа от текущего элемента. do right := other end ... Прочие компоненты ... end
 Рис. 16.7. Односвязный элемент списка Ряд приложений требуют двунаправленных списков. Класс TWO_WAY_LIST - наследник LINKED_LIST должен быть также наследником класса BI_LINKABLE, являющегося наследником класса LINKABLE.
 Рис. 16.8. Параллельные иерархии Двусвязный элемент списка имеет еще одно поле:
 Рис. 16.9. Двусвязный элемент списка В состав двунаправленных списков должны входить лишь двусвязные элементы (хотя последние, в силу полиморфизма, вполне можно внедрять и в однонаправленные структуры). Переопределив right и put_right, мы гарантируем однородность двусвязных списков.
indexing description: "Элементы двусвязного списка" class BI_LINKABLE [G] inherit LINKABLE [G] redefine right, put_right end feature left, right: BI_LINKABLE [G] put_right (other: BI_LINKABLE [G]) is -- Поместить other справа от текущего элемента. do right := other if other /= Void then other.put_left (Current) end end put_left (other: BI_LINKABLE [G]) is -- Поместить other слева от текущего элемента. ... Упражнение для читателя ... ... Прочие компоненты ... invariant right = Void or else right.left = Current left = Void or else left.right = Current end
(Попробуйте написать put_left. Здесь скрыта ловушка! См. приложение A.)
Ограничение родового параметра
Эти наблюдения дают решение. Мы должны оперировать исключительно терминами классов и типов. Потребуем, чтобы любой фактический параметр, используемый классом VECTOR (в других примерах по аналогии), был типом, поставляемым с множеством операций: infix "+", zero для инициализации суммы и т.д. Владея наследованием, мы знаем, как снабдить тип нужными операциями, - нужно просто сделать его потомком класса, отложенного или эффективного, обладающего этими операциями. Синтаксически это выглядит так:
class C [G -> CONSTRAINING_TYPE] ... Все остальное как обычно ...
где CONSTRAINING_TYPE - произвольный тип, именуемый родовым ограничением (generic constraint). Символ -> обозначает стрелку на диаграммах наследования. Результат этого объявления в том, что: в роли фактических родовых параметров могут выступать лишь типы, совместимые с CONSTRAINING_TYPE;в классе C над сущностью типа G допускаются только те операции, которые допускаются над сущностью CONSTRAINING_TYPE, другими словами, представляющими собой компоненты базового класса этого типа.
Какое родовое ограничение использовать для класса VECTOR? Обсуждая множественное наследование, мы ввели в рассмотрение NUMERIC - класс объектов, допускающих базисные арифметические операции: сложение и умножение с нулем и единицей (лежащая в его основе математическая структура называется кольцом). Эта модель кажется вполне уместной, хотя нам необходимо пока только сложение. Соответственно, класс будет описан так:
indexing description: "Векторы, допускающие сложение" class VECTOR [G -> NUMERIC] ... Остальное - как и раньше (но теперь правильно!) ...
После чего ранее некорректная конструкция в теле цикла
Result.put(item (i) + other.item (i), i)
становится допустимой, поскольку item (i) и other.item (i) имеют тип G, а значит, к ним применимы все операции NUMERIC, включая, инфиксный "+". Следующие родовые порождения корректны, если полагать, что все классы, представленные как фактические родовые параметры, являются потомками NUMERIC:
VECTOR [NUMERIC] VECTOR [REAL] VECTOR [COMPLEX]
Класс EMPLOYEE не порожден от NUMERIC, так что попытка использовать VECTOR [EMPLOYEE] приведет к ошибке времени компиляции. Абстрактный характер NUMERIC не вызывает никаких проблем. Фактический параметр при порождении может быть как эффективным (примеры выше), так и отложенным (VECTOR [NUMERIC_COMPARABLE]), если он порожден от NUMERIC. Аналогично описываются класс словаря и класс, поддерживающий сортировку:
class DICTIONARY [G, H -> HASHABLE] ... class SORTABLE [G -> COMPARABLE] ...
Ограниченная универсальность
Расширяя базовое понятие класса, мы представляли наследование и универсальность (genericity) как своего рода "партнеров". Объединить их нам позволило знакомство с полиморфными структурами данных: в контейнер - объект, описанный сущностью типа SOME_CONTAINER_TYPE [T] с родовым параметром T - можно помещать объекты не только самого типа T, но и любого потомка T. Однако есть и другая интересная комбинация партнерства, в которой наследование используется для задания ограничения на возможный тип фактического родового параметра класса.
Опорный элемент Current
В качестве опорного элемента можно использовать Current, обозначающий текущий экземпляр класса (о текущем экземпляре см. лекцию 7). Сущность, описанная в классе A как like Current, будет считаться в нем имеющей тип A, а в любом B, порожденном от A, - имеющей тип B. Эта форма закрепленного объявления помогает решить оставшиеся проблемы. Исправим объявление conjugate, получив правильный тип результата функции класса POINT:
conjugate: like Current is ... Все остальное - в точности, как раньше ...
Теперь в каждом порожденном классе тип результата conjugate автоматически определяется заново. Так, в классе PARTICLE он меняется на класс PARTICLE. В классе LINKABLE, найдя объявления
right: LINKABLE [G] put_right (other: LINKABLE [G]) is...
замените LINKABLE [G] на like Current. Компонент left класса BI_LINKABLE объявите аналогично. Эта схема применима ко многим процедурам set_attribute. В классе DEVICE имеем:
class DEVICE feature alternate: like Current set_alternate (a: like Current) is -- Пусть a - альтернативное устройство. do alternate := a end ... Прочие компоненты ... end
Понятие опорного элемента
В отличие от других проблем, решение которых предложено в этой лекции, такое тиражирование кода не связано с тем, что система типов препятствует нам в выполнении задуманного. Повторное объявление ковариантных типов разрешает их переопределение, но заставляет нас заниматься утомительным копированием текста. Заметим: наши примеры действительно требуют переопределения типа, но ничего более. Все сводится только к этому. Из этого следует решение проблемы - необходимо создать механизм не абсолютного, а относительного объявления типа сущности. Назовем такое объявление закрепленным (anchored). Пусть закрепленное объявление типа имеет вид
like anchor
где anchor, именуемый опорным (anchor) элементом объявления, - это либо запрос (атрибут или функция) текущего класса, либо предопределенное выражение Current. Описание my_entity: like anchor в классе A, где anchor - запрос, означает выбор для сущности типа, аналогичного anchor, с оговоркой, что любое переопределение anchor вызовет неявное переопределение my_entity. Если anchor имеет тип T, то в силу закрепленного объявления my_entity в классе A будет трактоваться так, будто тоже имеет тип T. Рассматривая лишь класс A, вы не найдете различий между объявлениями:
my_entity: like anchor my_entity: T
Различия проявятся только в потомках A. Будучи описана подобной (like) anchor, сущность my_entity автоматически будет следовать всем переопределениям типа anchor, освобождая от них автора класса. Обнаружив, что класс содержит ряд сущностей, чьи потомки должны переопределяться одинаково, вы можете избавить себя от всех переопределений, кроме одного, объявив все элементы "подобными" (like) первому и определяя заново лишь его. Остальное будет сделано автоматически. Вернемся к LINKED_LIST. Выберем first_element в качестве опорного для других сущностей типа LINKABLE [G]:
first_element: LINKABLE [G] previous, active, next: like first_element
Локальная сущность new процедуры put_right класса LINKED_LIST тоже должна иметь тип like first_element, и это - единственное изменение в процедуре. Теперь достаточно переопределить first_element как BI_LINKABLE в классе TWO_WAY_LIST, как LINKED_TREE в LINKED_TREE и т.д. Сущности, описанные как like, не нужно указывать в предложении redefine. Не требуется и повторное определение put_right. Итак, закрепленные определения есть весьма важное средство сохранения возможности повторного использования при статической типизации.
Попытка присваивания
Наша следующая техника адресуется к тем областям Объектной страны, в которых из страха тиранического поведения мы не можем позволить править простым правилам типизации, не встречая никакого сопротивления.
Повторное объявление функции как атрибута
Правило Утверждения Переобъявления нуждается в небольшом дополнении ввиду возможности при повторном объявлении задать функцию как атрибут. Что произойдет с предусловием функции и ее постусловием, если таковые имелись? Атрибут доступен всегда, а потому мы вправе считать, что его предусловие равно True. В итоге можно полагать, что предусловие атрибута, согласно правилу Утверждения Переобъявления, было ослаблено. Но атрибут не имеет постусловий. Мы же должны гарантировать, что он наделен всеми свойствами, заданными исходной функцией. Поэтому (в дополнение к правилу Утверждения Переобъявления) будем считать, что в этом случае автоматически постусловие добавляется к инварианту класса. Плоская форма класса будет содержать это условие в составе своего инварианта.
| Для функции без параметров, формулируя некое свойство ее результата, вы всегда можете выбрать, включать ли его в постусловие или в инвариант. С точки зрения стиля предпочтительно пользоваться инвариантом. Соблюдение этого правила позволит отказаться от внесения изменений в утверждения в будущем, если при повторном объявлении функция становится атрибутом. |
Правила о закрепленных типах
Теоретически ничто не мешает нам записать like anchor для самого элемента anchor как сущности закрепленного типа. Достаточно ввести правило, которое запрещало бы циклы в декларативных цепочках.
| Вначале закрепленные опорные элементы (anchored anchor) были запрещены, но это новое, более либеральное правило придает системе типов большую гибкость. |
Пусть T - тип anchor (текущий класс, если anchor есть Current). Тогда тип like anchor совместим как с самим собой, так и с T. Обратное определение не симметрично: единственный тип, совместимый с like anchor, - это он сам. В частности, с ним не совместим тип T. Если бы следующий код был верен:
anchor, other: T; x: like anchor ... create other x := other -- предупреждение: ошибочное присваивание
то в порожденном классе, где anchor меняет свой тип на U, совместимый с T, но основанный на его потомке, сущности x был бы присвоен объект типа T, а не объект типа U или U-совместимого типа, что некорректно. Будем говорить, что x опорно-эквивалентен y, если x есть y или имеет тип like z, где z по рекурсии опорно-эквивалентен y. Присваивания: x := anchor, anchor := x, как и присваивания опорно-эквивалентных (anchor-equivalent) элементов, конечно же, допустимы. При закреплении формального параметра или результата, как в случае
r (other: like Current)
фактический параметр вызова, например, b в a.r(b), должен быть опорно-эквивалентен a.
Правильное использование попытки присваивания
Необходимость попытки присваивания обусловлена, как правило, тем, что на статически объявленный тип сущности положиться нельзя, а опознать тип фактически адресуемого объекта необходимо "на лету". Например, при работе с полиморфными структурами данных и получении объектов из третьих рук. Заметьте, как тщательно был спроектирован механизм, дающий разработчикам шанс забыть об устаревшем стиле разбора вариантов (case-by-case). Если вы действительно хотите перехитрить динамическое связывание и отдельно проверять каждый вариант типа, вы можете это сделать, хотя вам и придется немало потрудиться. Так, вместо обычного f.display, использующего ОО-механизмы полиморфизма и динамического связывания, можно, - но не рекомендуется, - писать:
display (f: FIGURE) is -- Отобразить f, используя алгоритм, -- адаптируемый к истинной природе объекта. local r: RECTANGLE; t: TRIANGLE; p: POLYGON; s: SQUARE sg: SEGMENT; e: ELLIPSE; c: CIRCLE;? do r ?= f; if r /= Void then "Использовать алгоритм вывода прямоугольника" end t ?= f; if t /= Void then "Использовать алгоритм вывода треугольника" end c ?= f; if c /= Void then "Использовать алгоритм вывода окружности" end ... и т.д. ... end
На практике такая схема даже хуже, чем кажется, так как структура наследования имеет несколько уровней, а значит, усложнения управляющих конструкций не избежать. Из-за трудностей написания таких закрученных конструкций попытки присваивания новичкам вряд ли придет в голову использовать их вместо привычной ОО-схемы. Однако и опытные специалисты должны помнить о возможности неправильного использования конструкции.
Немного похожий на попытку присваивания механизм "сужения" (narrowing) есть в языке Java. В случае несоответствия типов он выдает исключение. Это похоже на самоубийство, неуспех присваивания вовсе не является чем-то ненормальным, это ожидаемый результат. Оператор instanceof в языке Java выполняет проверку типов на совместимость.
Из-за отсутствия в языке универсальности Java активно использует оба механизма. Отчасти это связано с тем, что в отсутствие множественного наследования Java не содержит класса NONE, а потому не может выделить эквиваленту Void надежное место в собственной системе типов. | |
Правило языка
Правило Утверждений Переобъявления, так как оно сформулировано, является концептуальным руководством. Как преобразовать его в безопасное и проверяемое правило языка? В принципе, чтобы убедиться в том, что старые предусловия влекут новые, а новые постусловия - старые, следует провести логический анализ тех и других утверждений. К сожалению, это требует наличия сложного механизма доказательства теорем (несмотря на десятилетия исследований в области искусственного интеллекта). Его применение в компиляторе пока не реально. К счастью, возможно простое техническое решение. Нужное нам правило можно сформулировать через простое лингвистическое соглашение, основанное на том наблюдении, что для любых утверждений a и b:
влечет or ? независимо от значения ?;? and влечет ? независимо от значения .
Итак, гарантируется, что новое предусловие слабее исходного либо равно ему, если оно имеет вид or ?. Гарантируется, что новое постусловие сильнее исходного ? либо равно ему, если оно имеет вид ? and . Отсюда следует искомое языковое правило: Правило (2) Утверждения Переобъявления При повторном объявлении подпрограммы нельзя использовать предложения require или ensure. Вместо них следует использовать предложение, начинающееся с: require else, объединенное с исходным предусловием логической связкой orensure then, объединенное с исходным постусловием логической связкой and.
При отсутствии таких предложений действуют исходные утверждения. Заметим, что используются нестрогие булевы операторы and then и or else, а не обычные and и or, хотя чаще всего это различие несущественно. Иногда получаемые утверждения могут оказаться сложнее, чем необходимо на самом деле. В примере с подпрограммой обращения матриц, где исходным было утверждение
invert (epsilon: REAL) is -- Обращение текущей матрицы с точностью epsilon require epsilon >= 10 ^ (-6) ... ensure ((Current * inverse) |-| One) <= epsilon мы не вправе в повторном объявлении использовать require и ensure, поэтому результат примет вид ...
require else epsilon >= 10 ^ (-20) ... ensure then ((Current * inverse) |-| One) <= (epsilon / 2)
а стало быть, предусловие формально станет таким: (epsilon >= 10 ^ (-20)) or else (epsilon >= 10 ^ (-6)).
Ситуация с постусловием аналогична. Такое расширение не имеет особого значения, поскольку преобладает более слабое предусловие или более сильное постусловие. Если ? влечет , то or else ? имеет то же значение, что и . Если ? влечет , то ? and then имеет то же значение, что и ?. Поэтому математически предусловие повторного объявления есть: epsilon >= 10 ^ (-20), а его постусловие есть: ((Current * inverse) |-| One) <= (epsilon / 2), хотя запись утверждений в программе (а также, вероятно, их расчет во время выполнения при отсутствии средств символьных преобразований) является более сложной.
Правило повторного объявления типов
Примеры, рассмотренные выше, несмотря на все их различия, объединяет необходимость повторного объявления типов. Спуск по иерархии наследования означает специализацию классов, и в соответствии со специализацией изменяются типы функций и типы аргументов подпрограмм, как, например, a в set_alternate и other в put_right; изменяются типы запросов - alternate и right. Этот аспект повторного объявления выражает следующее правило: Правило повторного объявления типов При повторном объявлении компонента можно заменить тип компонента (для атрибутов и функций) или тип формального параметра (для подпрограмм) любым совместимым типом. Правило использует понятие совместимости типов. Связка "или", стоящая в тексте правила, не исключает того, что при повторном объявлении функции мы можем одновременно изменить как тип результата функции, так и тип одного или нескольких ее аргументов. Любое повторное объявление ведет к специализации, а, следовательно, к изменению типов. Так, с переходом к двунаправленным спискам параметры и результаты функций сменили свой тип на BI_LINKABLE. Отсюда становится понятен тот термин, которым часто описывают политику редекларации типов, - ковариантная типизация (covariant typing), где приставка "ко" указывает на параллельное изменение типов при спуске по диаграмме наследования. Ковариантная типизация таит в себе немало проблем, которые возникают у создателей компиляторов, нередко перекладывающих их решение на плечи разработчиков приложений.
Предусловия и постусловия при наличии динамического связывания
В случае с предусловиями и постусловиями ситуация чуть сложнее. Общая идея, как отмечалось, состоит в том, что любое повторное объявление должно удовлетворять утверждениям оригинальной подпрограммы. Это особенно важно, если подпрограмма отложена: без такого ограничения на будущую реализацию, задание предусловие и постусловий для отложенных подпрограмм было бы бесполезным или, хуже того, привело бы к нежелательному результату. Те же требования к предусловию и постусловию остаются и при переопределении эффективных подпрограмм. Анализируя механизмы повторного объявления, полиморфизма и динамического связывания, можно дать точную формулировку искомого правила. Но для начала представим типичный случай. Рассмотрим класс и его подпрограммы, имеющие как предусловие, так и постусловие:
 Рис. 16.1. Подпрограмма, клиент и контракт На рис. 16.1 показан клиент C класса A. Чтобы быть клиентом, класс C, как правило, включает в одну из своих подпрограмм объявление и вызов вида:
a1: A ... a1.r
Для простоты мы проигнорируем все аргументы, которые может требовать r, и положим, что r является процедурой, хотя наши рассуждения в равной мере применимы и к функциям. Вызов будет корректен лишь тогда, когда он удовлетворяет предусловию. Гарантировать, что C соблюдает свою часть контракта, можно, к примеру, предварив вызов проверкой предусловия, написав вместо a1.r конструкцию:
if a1. then a1.r check a1.? end -- постусловие должно выполняться ... Инструкции, которые могут предполагать истинность a1.. ... end
(Как отмечалось при обсуждении утверждений, не всегда требуется проверка: достаточно, с помощью if или без него, гарантировать выполнение условия a перед вызовом r. Для простоты будем использовать if-форму, игнорируя предложение else.) Обеспечив соблюдение предусловия, клиент C рассчитывает на выполнение постусловия a1.? при возврате из r. Все это является основой Проектирования по Контракту: в момент вызова подпрограммы клиент должен обеспечить соблюдение предусловия, а в ответ при возврате из подпрограммы он полагается на выполнение постусловия.
Что происходит, когда вводится наследование?
 Рис. 16.2. Подпрограмма, клиент, контракт и потомок
Пусть новый класс A' порожден от A и содержит повторное объявление r. Как он может, если вообще может, заменить прежнее предусловие новым ?, а прежнее постусловие ? - новым ?
Чтобы найти ответ, рассмотрим обязательства клиента. В вызове a1.r цель a1 может - в силу полиморфизма - иметь тип A'. Однако C об этом не знает! Единственным объявлением a1 остается исходная строка
a1: A
где упоминается A, но не A'. На деле C может использовать A', даже если его автор не знает о наличии такого класса. Вызов подпрограммы r может произойти, например, в процедуре C вида:
some_routine_of_C (a1: A) is do ...; a1.r;... end
Тогда при вызове some_routine_of_C из другого класса в нем может использоваться фактический параметр типа A', даже если в тексте клиента C класс A' нигде не упоминается. Динамическое связывание как раз и означает тот факт, что обращение к r приведет в этом случае к использованию переопределенной версии A'.
Итак, может сложиться ситуация, в которой C, являясь только клиентом A, фактически во время выполнения использует версии компонентов класса A'. (Можно сказать, что C - "динамический клиент" A', хотя в тексте C об этом и не говорится.)
Что это значит для C? Только одно - проблемы, которые возникнут, если не предпринять никаких действий. Клиент C может добросовестно выполнять свою часть контракта, и все же в результате он будет обманут. Например,
if a1. then a1.r end
если a1 полиморфно присоединена к объекту типа A', инструкция вызовет подпрограмму, ожидающую выполнения ? и гарантирующую выполнение , в то время как клиент получил указание соблюдать и ожидать выполнения ?. Налицо возможное расхождение во взглядах клиента и поставщика на контракт.
Применение
Характерным примером является создание нескольких вариантов одной абстракции. Представим себе GENERAL_ACCOUNT - класс, содержащий все необходимые операции для работы с банковскими счетами: процедуры open, withdraw, deposit, code (для снятия денег через банкомат), change_code и т.д.,- но не предназначенный для использования клиентами напрямую, а потому не экспортирующий никаких подпрограмм. Его потомки выступают как разные облики родителя: они не содержат новых компонентов и отличаются лишь предложениями экспорта. Один экспортирует open и deposit, второй, наряду с ними, - withdraw и code, и т. д.
 Рис. 16.12. Разные облики одной абстракции Эта схема в обсуждении методологии наследования (см. лекцию 6 курса "Основы объектно-ориентированного проектирования") носит название наследования функциональных возможностей (facility inheritance). Понятие облика (view) является классическим в области баз данных, где необходимо дифференцировать пользователей, работающих с данными, предоставляя им разные права. Другой пример касается классов, введенных, когда речь шла о множественном наследовании. Компонент right класса CELL скрыт в нем или, точнее говоря, экспортируется лишь классу LIST. Фактически так обстоят дела со всеми компонентами CELL, поскольку этот класс был изначально нацелен на работу со списками. Однако в дереве (классе TREE), потомке как CELL, так и LIST, right теперь означает доступ к правому брату и является респектабельным членом общества экспортируемых компонентов.
я написал класс MATRIX, реализующий
Предположим, я написал класс MATRIX, реализующий операции линейной алгебры. Среди прочих возможностей я предлагаю своим клиентам подпрограмму расчета обратной матрицы. Фактически это сочетание команды и двух запросов: процедура invert инвертирует матрицу, присваивает атрибуту inverse значение обратной и устанавливает логический атрибут inverse_valid. Значение атрибута inverse имеет смысл тогда и только тогда, когда inverse_valid является истинным; в противном случае матрицу инвертировать не удалось, так как она вырождена. В ходе нашего обсуждения случай вырожденной матрицы мы можем проигнорировать. Конечно же, я могу найти лишь приближенное значение обратной матрицы и готов гарантировать определенную точность расчетов, однако, не владея численными подпрограммами в совершенстве, буду принимать лишь запросы с точностью не выше 10-6. В итоге, моя подпрограмма будет выглядеть приблизительно так:
invert (epsilon: REAL) is -- Обращение текущей матрицы с точностью epsilon require epsilon >= 10 ^ (-6) do "Вычисление обратной матрицы" ensure ((Current * inverse) |-| One) <= epsilon end
Постусловие предполагает, что класс содержит инфиксную функцию infix "|-|" такую, что m1 |-| m2 есть |m1 - m2| (норма разности матриц m1 и m2), а также функцию infix "*", результатом которой является произведение двух матриц. One - единичная матрица. Как человек негордый, летом я приглашу программиста, и он перепишет мою подпрограмму invert, используя более удачный алгоритм, лучше аппроксимирующий результат и допускающий меньшее значение epsilon (как повторное объявление, эта запись синтаксически некорректна:
require epsilon >= 10 ^ (-20) ... ensure ((Current * inverse) |-| One) <= (epsilon / 2)
Автор новой версии достаточно умен, чтобы не переписывать MATRIX в целом. Изменения коснутся лишь нескольких подпрограмм. Они будут включены в состав порожденного от MATRIX класса NEW_MATRIX.
| Если повторное объявление содержит новые утверждения, они должны иметь иной синтаксис, нежели приведенный выше. Правило появится чуть позднее. | <
Изменения, внесенные в утверждения, удовлетворяют правилу повторного объявления: новое предусловие epsilon >= 10 ^ (-20) слабее исходного epsilon >= 10 ^ (-6), новое же постусловие сильнее сформулированного вначале. Вот как все должно происходить. Клиент исходного класса MATRIX запрашивает расчет обратной матрицы именно у него, но на деле - ввиду динамического связывания - вызывает реализацию класса NEW_MATRIX. Тот же клиент может иметь в своем составе подпрограмму
some_client_routine (m1: MATRIX; precision: REAL) is do ... ; m1.invert (precision); ... -- Возможен вызов версии как MATRIX, так и NEW_MATRIX end
которой один из его собственных клиентов передает первый параметр типа NEW_MATRIX. NEW_MATRIX должен воспринимать и корректно обрабатывать любой вызов, который принимается его предком. Используя более слабое предусловие и более сильное постусловие, мы корректно обработаем все обращения клиентов MATRIX и предложим своим клиентам решение, лучше прежнего. При усилении предусловия invert, например, epsilon >= 10 ^ (-5), вызов, корректный для класса MATRIX, мог стать теперь некорректным. При ослаблении постусловия возвращаемый результат стал бы хуже, чем гарантируемый для MATRIX.
Примеры из практики
Было бы ошибочно полагать, что проблема неоправданного переопределения возникает лишь там, где структура ориентирована на реализацию, как в LINKED_LIST. В любой схеме вида
some_attribute: SOME_TYPE set_attribute (a: SOME_TYPE) is do ... end
переопределение some_attribute подразумевает соответствующее переопределение set_attribute. В случае с put_right из BI_LINKABLE (не путайте с подпрограммой из LINKED_LIST) повторное определение необходимо, поскольку фактически меняется алгоритм. Но во многих широко распространенных случаях (к примеру, в set_alternate) новый алгоритм идентичен исходному. Вот еще один пример, показывающий глубину проблемы (не ограниченной лишь процедурами set_xxx, которые сами появились в силу принципа Скрытия информации). Добавим в класс POINT функцию, которая возвращает точку, сопряженную с данной, - ее зеркальное отражение относительно горизонтальной оси:
 Рис. 16.11. Исходная и сопряженная точка
conjugate: POINT is -- Точка, сопряженная с текущей do Result := clone (Current) -- Получить копию текущей точки Result.move (0, -2*y) -- Перенести результат по вертикали end
Рассмотрим теперь некий класс, порожденный от POINT, например PARTICLE. К атрибутам частиц, помимо координат, относятся, вероятно, масса и скорость. По идее, функция conjugate применима и к PARTICLE и выдает в результате ту же частицу с противоположным значением координаты y. Но если оставить все как есть, функция работать не будет из-за несоблюдения правила совместимости типов:
p1, p2: PARTICLE; create p1.make (...); ... p2 := p1.conjugate
Правая часть подчеркнутого оператора имеет тип POINT, левая часть - тип PARTICLE. Правило совместимости типов этого не допускает. Поэтому мы должны переписать conjugate для PARTICLE с единственной целью - обеспечить соблюдение правила.
| Предприняв попытку присваивания, мы не решим проблему, а лишь запишем в p2 пустой указатель. |
Проблема
Из этих примеров ясно: нам может понадобиться механизм удостоверения типа объекта. Решение этой проблемы, возникающей в специфических, но критически важных случаях, должно быть найдено без потери преимуществ ОО-стиля разработки. В частности, мы не хотим возвращаться к той схеме, которую сами и осудили:
if "f типа RECTANGLE" then ... elseif "f типа CIRCLE" then ... и т.д.
Это решение идет вразрез с принципами Единственного Выбора и Открыт-Закрыт. Избежать риска потерь нам помогут два обстоятельства. Нет смысла создавать универсальный механизм выяснения типа объектов. В том и другом случае тип объекта предположительно известен. Все, что нам нужно, - это способ проверки гипотезы. Определение принадлежности объекта данному типу носит более частный характер, чем запрос на определение типа. Кроме того, нам не требуется вводить в наш язык никаких операций над типами, к примеру, их сравнение - ужасающая мысль.Как уже говорилось, мы не должны влиять на правило Вызова Компонентов. Ни при каких обстоятельствах мы не должны проверять применимость вызова компонента, если класс прошел статистическую проверку. Все, что нам нужно, - это более свободная версия другого правила - правила совместимости типов, позволяющая "испытать тип" и проверить результат.
Серьезное затруднение
Изучив класс LINKED_LIST в тексте приложения A, вы поймете, что проблема еще масштабнее. В теле класса содержится множество объявлений со ссылкой на тип LINKABLE [G], а с переходом к двунаправленным спискам почти все они потребуют повторного определения. Так, вариант представления списка включает четыре ссылки на отдельные элементы:
first_element, previous, active, next: LINKABLE [G]
В классе TWO_WAY_LIST каждая из этих сущностей должна быть объявлена заново. Аналогичная процедура ждет и другие порожденные классы. Многие функции, такие как put_right, имеют "односвязные" аргументы и нуждаются в повторном определении. В итоге реализация TWO_WAY_LIST будет во многом дублировать оригинал.
Слово в защиту реализаций
В чем же причина недоверия к наследованию реализаций? Я пришел к выводу, что ответ лежит в области психологии. Тридцатилетний программистский опыт оставил нам лишь сомнения насчет самой идеи реализаций. И даже слово "реализация" приобрело в отдельных кругах почти неприличный характер. По этой причине мы ведем речь о проектировании и анализе, а если и упоминаем реализацию, то начинаем разговор с "но", "лишь" или "только". Объектная технология в корне меняет все: ОО-реализации настолько элегантны, полезны, с ясно выраженной корректностью, что уже можно забыть об неприятных оттенках этого слова в языке. Для многих из нас программа часто оказывается вещью наиболее абстрактной, дает описание на самом высоком уровне и наиболее понимаема, чем большая часть того, что в анализе и проектировании провозглашается "величайшим достижением мысли".
Статический механизм
Устранить последнее неясности в понимании закрепленного объявления поможет следующее замечание: это чисто статический механизм, не предполагающий никаких изменений объектов в период выполнения. Все ограничения могут быть проверены в период компиляции. Закрепленное объявление можно считать синтаксическим приемом, позволяющим переложить переопределения на компилятор. Кроме того, оно является важнейшим инструментом достижения компромисса между повторным использованием и контролем типов.
Субподряды
Правило Утверждения Переобъявления великолепно сочетается с теорией Проектирования по Контракту. Мы видели, что утверждения подпрограммы описывают связанный с ней контракт, в котором клиент гарантирует выполнение предусловия, получая право рассчитывать на истинность постусловия; для поставщика все наоборот. Наследование совместно с повторным объявлением и динамическим связыванием приводит к созданию субподрядов. Приняв условия контракта, вы не обязаны выполнять его сами. Подчас вы знаете кого-то еще, способного сделать это лучше и с меньшими издержками. Так происходят, когда клиент запрашивает подпрограмму из MATRIX, но благодаря динамическому связыванию может на этапе выполнения фактически вызывать версию, переопределенную в потомке. "Меньшие издержки" означают здесь более эффективную реализацию, как в знакомом нам примере с периметром прямоугольника, а "лучше" - усовершенствование утверждений, в описанном здесь смысле. Правило Утверждения Переобъявления просто устанавливает, что честный субподрядчик, приняв условия контракта, должен выполнить работу на тех же условиях, что и подрядчик или лучших, но никак не худших. С позиции Проектирования по Контракту, инварианты классов - это ограничения общего характера, применимые и к подрядчикам, и к клиентам. Правило родительских инвариантов отражает тот факт, что все подобные ограничения передаются субподрядчикам. Свое истинное значение для ОО-разработки наследование приобретает лишь совместно с утверждениями и двумя приведенными выше правилами. Метафора контрактов и субподрядов - прекрасная аналогия, помогающая разрабатывать корректное ОО-ПО. Несомненно, в этом - одна из центральных идей теории проектирования.
Типизация и повторное объявление
Повторное объявление компонентов не требует сохранения сигнатуры. Пока оно виделось нам как замена одного алгоритма другим или - для отложенного компонента - запись алгоритма, соответствующего ранее заданной спецификации. Но, воплощая идею о том, что класс способен предложить более специализированную версию элемента, описанного его предком, мы вынуждены иногда изменять типы данных. Приведем два характерных примера.
У16.1 Наследование: простота и эффективность
Перепишите и упростите ранее созданную реализацию защищенного стека, сделав класс STACK3 потомком, а не клиентом STACK, чтобы избежать излишних обходных путей. (Подсказка: см. правила взаимодействия наследования и скрытия информации.)
У16.2 Векторы
Напишите класс VECTOR, представляющий числовые вектора (кольцо) с обычными математическими операциями. Сам класс рекурсивно должен относиться к численному типу, допуская вектора векторов. Возможно, для этого вам придется самостоятельно дописать класс NUMERIC (или воспользоваться готовым из [M 1994a]).
У16.3 Экстракт?
В случае, когда x1 имеет тип X, y1 имеет тип Y, и Y является потомком X, оператор y1 := x1 будет недопустимым. Однако полезным мог бы показаться универсальный компонент extract, такой, что y1.extract (x1) копирует значения полей объекта x1 в соответствующие поля объекта y1 при условии, что ни в одной из этих ссылок не содержится Void. Объясните, почему компонент extract стоит отвергнуть. (Подсказка: обратитесь к вопросам корректности, в частности, к понятию инварианта.) Выясните, можно ли спроектировать удовлетворительный механизм, решающий эту задачу каким-то иным способом. |
|  |
Универсальные классы
Удобно использовать следующее соглашение: Правило Универсального Класса Любой класс, не содержащий предложение наследования, неявно содержит предложение вида: inherit ANY, ссылающееся на класс ANY из библиотеки Kernel. Тем самым становится возможным определить по умолчанию целый ряд компонентов, наследуемых всеми классами. Эти компоненты реализуют общие, универсальные операции: копирование, клонирование, сравнение, базовый ввод и вывод. Для большей гибкости поместим эти компоненты в класс GENERAL, чьим потомком является ANY. Сам класс ANY по умолчанию не имеет никаких компонентов, будучи классом вида: class ANY inherit GENERAL end. При создании нового проекта его менеджер может решить, какие общие для проекта компоненты следует включить в класс ANY, в то время как GENERAL остается всегда неизменным.
| Для построения нетривиального ANY можно прибегнуть к наследованию. В самом деле, класс ANY можно породить от некоторого HOUSE_STYLE или нескольких таких классов, не вводя циклы в иерархию наследования и не нарушая правило об универсальном классе: достаточно сделать класс HOUSE_STYLE и другие классы потомками GENERAL. Вынесенный на рис. 16.4 текст "Классы разработчика" означает все классы, написанные разработчиком и не порожденные от GENERAL явным образом. |
 Рис. 16.4. Глобальная структура наследования
Универсальные компоненты
Вот лишь некоторые компоненты, содержащиеся в классе GENERAL, а значит, доступные всем другим классам. Часть из них была введена и использована в предшествующих лекциях курса: clone для создания клона (дубля) объекта, а также его "глубинный" вариант deep_clone для рекурсивного дублирования полной структуры объекта;copy для копирования содержимого одного объекта в другой;equal для сравнения объектов (поле-с-полем), а также его "глубинный" вариант deep_equal;print и print_line - печать простого представления по умолчанию любого объекта (default representation);tagged_out - строка, содержащая представление по умолчанию любого объекта, в котором каждое поле сопровождается своей меткой (tag) (соответствующим именем атрибута);same_type и conforms_to - булевы функции, сопоставляющие тип текущего объекта с типом другого;generator - возвращает имя порождающего (generating) класса объекта, то есть класса, экземпляром которого является данный объект.
Устранение посредника
Последний комментарий указывает на весьма интересное следствие правила Утверждений Переобъявления. В общей схеме
 Рис. 16.3. Подпрограмма, клиент и подрядчик утверждения ? и , введенные при повторном объявлении, предпочтительнее для клиентов, если они отличаются от и ? (предусловия - более слабые, постусловия - более сильные). Но клиент класса A, использующий A' благодаря полиморфизму и динамическому связыванию, не может в полной мере воспользоваться более выгодным контрактом, ибо единственный контракт клиента заключен с классом A. Воспользоваться преимуществом нового контракта можно лишь став непосредственным клиентом A' (пунктирная связь с вопросительным знаком на рисунке 16.3), как в случае:
a1: A' ... if a1.? then a1.r end check a1. end -- постусловие выполняется
При этом вы, естественно, объявляете a1 как объект типа A', а не объект типа A, как прежде. В результате теряется универсальность полиморфизма, идущая от A. Компромисс ясен. Клиент класса MATRIX должен обеспечивать выполнение исходного (более сильного) предусловия, а в ответ вправе ожидать выполнения исходного (более слабого) постусловия. Даже если его запрос динамически подготовлен к обслуживанию классом NEW_MATRIX, воспользоваться новыми возможностями - большей толерантностью входа и большей точностью выхода - ему никак не удастся. Для обращения к улучшенной спецификации клиент должен объявить матрицу типа NEW_MATRIX, тем самым, потеряв доступ к иным порожденным от MATRIX реализациям, не являющимся производными классами самого NEW_MATRIX.
Устройства и принтеры
Вот простой пример переопределения типа. Рассмотрим понятие устройства, включив предположение о том, что для любого устройства есть альтернатива, так что устройство можно заменить, если оно по каким-либо причинам недоступно:
class DEVICE feature alternate: DEVICE set_alternate (a: DEVICE) is -- Пусть a - альтернативное устройство. do alternate := a end ... Прочие компоненты ... end
Принтер является устройством, так что использование наследования оправдано. Но альтернативой принтера может быть только принтер, но не дисковод для компакт-дисков или сетевая карта, - поэтому мы должны переопределить тип:
 Рис. 16.6. Устройства и принтеры
class PRINTER inherit DEVICE redefine alternate, set_alternate feature alternate: PRINTER set_alternate (a: PRINTER) is -- Пусть a - альтернативное устройство. ... Тело как у класса DEVICE ... ... Прочие компоненты ... end
В этом и проявляется специализирующая природа наследования.
Вектора, допускающие сложение
Приведем простой, но характерный пример, демонстрирующий необходимость введения ограниченной универсальности. Он поможет в обосновании метода решения поставленной задачи и в выборе соответствующей конструкции языка. Предположим, что мы хотим объявить класс VECTOR, над элементами которого определена операция сложения. Потребность в подобном базовом классе неоспорима. Вот первый вариант:
indexing description: "Векторы со сложением" class VECTOR [G] feature -- Доступ count: INTEGER -- Количество элементов item, infix "@" (i: INTEGER): G is -- Элемент вектора с индексом i (нумерация с 1) require ... do ... end feature -- Основные операции infix "+" (other: VECTOR [G]): VECTOR is -- Поэлементное сложение текущего вектора с other require ... do ... end ... Прочие компоненты ... invariant non_negative_count: count >= 0 end
Применение инфиксной записи продиктовано соображениями удобства. Для удобства введены и синонимы в обозначении i-го компонента вектора: v.item (i) или просто v @ i. Обратимся к функции "+". Сначала сложение двух векторов кажется очевидным и состоящим в суммировании элементов на соответствующих местах. Общая его схема такова:
infix "+" (other: VECTOR [G]): VECTOR is -- Поэлементное сложение текущего вектора с other require count = other.count local i: INTEGER do "Создать Result как массив из count элементов" from i := 1 until i > count loop Result.put(item (i) + other.item (i), i) i := i + 1 end end
Выражение в прямоугольнике - результат сложения i-го элемента текущего вектора с i-м элементом other. Процедура put сохраняет это значение в i-м элементе Result, и хотя она не показана в классе VECTOR, данная процедура в нем, безусловно, присутствует.
 Рис. 16.5. Поэлементное сложение векторов Но подобная схема не работает! Операция +, которую мы определили для сложения векторов (VECTOR), здесь применяется к объектам совсем другого типа (G), являющегося родовым параметром.
По определению, родовой параметр представлен неизвестным типом - фактическим параметром, появляющимся только тогда, когда нам понадобится для каких либо целей родовой класс. Процесс порождения класса при задании фактического родового параметра называется родовым порождением (generic derivation). Если фактическим параметром служит INTEGER либо иной тип (класс), содержащий функцию infix "+" правильной сигнатуры, корректная работа обеспечена. Но что если параметром станет ELLIPSE, STACK, EMPLOYEE или другой тип без операции сложения?
С прежними родовыми классами: контейнерами STACK, LIST и ARRAY - этой проблемы не возникало, поскольку их действия над элементами (типа G как формального параметра) были универсальны - операции (присваивание, сравнение) могли выполняться над элементами любого класса. Но для абстракций, подобных векторам, допускающих сложение, нужно ограничить круг допустимых фактических родовых параметров, чтобы быть уверенными в допустимости проектируемых операций.
Этот случай отнюдь не является исключением. Вот еще два примера того же рода.
Предположим, вы проектируете класс, описывающий структуру данных с операцией sort, упорядочивающей элементы структуры в соответствии с некоторым критерием сортировки. Тогда элементы этой структуры должны принадлежать типу, для которого определена операция сравнения infix "<=", задающая порядок для любой пары соответствующих объектов.При разработке таких базисных структур данных как словари зачастую используется для хранения данных хеш-таблица, в которой место элемента определяется ключом, вычисляемым по значению элемента. Элементы, размещаемые в словаре должны принадлежать классу, допускающему применение хеш-функции, вычисляющей ключ каждого элемента.
Выборочный экспорт
Говоря о наследовании и скрытии информации, нельзя обойти вопрос о выборочном экспорте компонентов. Класс A, выборочно экспортирующий f классу B:
class A feature {B, ...} f... ...
делает f доступным в реализации собственных компонентов B. Потомки B, в свою очередь, имеют доступ к реализации предка, а потому они должны быть вправе обращаться ко всем доступным B возможностям, в том числе, к f. Практические наблюдения подтверждают этот теоретический обоснование. Все, что необходимо классу, обычно требуется и его потомкам. Однако нам не хотелось бы с появлением очередного порожденного класса B возвращаться в A и расширять его предложение экспорта. Согласно принципу Скрытия информации, а также принципу Открыт-Закрыт, разработчику A дано право решать, делать ли f доступным для B, однако, ему запрещено ограничивать свободу разработчика B. Тем самым, имеет место правило: Правило наследования при выборочном экспорте Выборочно экспортированный компонент доступен как самому классу, так и всем его потомкам.
Зачем нужна такая гибкость?
Стратегия экспорта, согласно которой каждый потомок класса имеет свою политику, хотя и усложняет проверку типов, но придает необходимую гибкость действиям разработчика. Предпринимались и иные попытки. Так, отдельные объектные языки определяют не только то, будет ли компонент экспортирован клиентам класса, но и то, будет ли он доступен его потомкам. Преимущества этого подхода неочевидны. В частности: мне не известно о публикации рекомендаций по применению этой возможности, неясно, когда компонент должен передаваться потомкам, а когда быть скрытым. Конструкции языка, за которыми нет ни малейшей теории, имеют весьма сомнительную ценность. (Для сравнения: правило, посвященное методологии скрытия информации, совершенно прозрачно: то, что принадлежит АТД, и надлежит экспортировать; прочее следует скрыть.)механизмы ограничения порожденных классов, введенные в языке Simula и др., редко используются разработчиками.
При близком рассмотрении отсутствие ясных методологических установок не удивляет. Наследование является воплощением принципа Открыт-Закрыт: позволяя выбрать готовый класс, написанный вами или другим программистом вчера или 20 лет назад, и обнаружить, что с ним можно делать нечто полезное, что даже не предполагалось при его проектировании. Позволить создателю класса определять, что могут и что не могут использовать потомки класса, - значит лишиться основного свойства наследования. Пример классов CELL и TREE характерен: при разработке CELL его целью была лишь поддержка работоспособности LIST, а потому right и put_right служили в нем исключительно внутренним целям. И лишь позднее этим компонентам нашли новое применение в классе-потомке TREE. Не будь этой открытости, наследование почти полностью утратило бы свой шарм. Если нет основы для принятия решения об экспорте компонентов потомкам, то еще более абсурдно пытаться догадаться, что потомки могут экспортировать своим клиентам. Единственная задача разработчика порожденного класса - предоставление своим клиентам как можно более удобного для них класса. Наследование - это лишь средство, которое позволяет быстрее добиться желаемого результата. Все правила ОО-игры определяются утверждениями и ограничениями типизации, - не более того. Найти полезный для клиентов потомка компонент предка - это большая удача, ну а то, как поступал предок с этим компонентом, - экспортировал ли он его, это дело предка и волнует потомка меньше всего. В итоге, единственной стратегией, сочетающейся с принципиальной открытостью наследования, нам кажется та, что была описана выше: предоставить каждому разработчику возможность самостоятельно решать, что делать с компонентами предка, выбирая собственную политику экспорта в интересах своих клиентов.
Закрепленные объявления
Правило повторного объявления типов способно свести на нет целый ряд преимуществ наследования. Почему это происходит и каково решение данной проблемы?
Замечание математического характера
Неформально, правило Утверждения Переобъявления гласит: "Повторное объявление утверждений может лишь сужать область допустимого поведения, не нарушая ее". Сейчас, завершая обсуждение этой темы, приведем строгую формулировку данного свойства. Пусть подпрограмма реализует частичную функцию r, отображающую множество возможных входных состояний I в множество возможных выходных состояний O. Утверждения подпрограммы определяют правила действия r и ее возможных переопределений. Предусловие задает область определения DOM функции r (подмножество I, на котором r гарантированно вырабатывает результат).Постусловие задает для каждого x из DOM подмножество RESULTS(x) множества O, такое, что r (x) RESULTS (x). Так как постусловие не всегда однозначно описывает результат, это подмножество может иметь больше одного элемента.
Правило Утверждения Переобъявления означает, что повторное объявление может расширять область определения и сужать множество результатов. Пометив новые множества знаком ', запишем требования, закрепленные этим правилом:
DOM' DOM RESULTS' (x) RESULTS (x) для всех x из DOM
Предусловие устанавливает, что подпрограмма и ее повторные объявления, как минимум, должны принимать некоторые входы (DOM), хотя повторные объявления могут это множество и расширить. Постусловие говорит, что результаты, возвращаемые подпрограммой и ее повторными объявлениями, могут, самое большее, содержать значения из RESULTS(x), однако, постусловия при повторных объявлениях могут это множество сузить. В этом описании состояние системы в период выполнения определяется состоянием (значениями) всех достижимых объектов. Кроме того, входные состояния (элементы I) также включают в себя значения аргументов. Более подробное введение в математическое описание программ и языков программирования см. в [M 1990].
Замороженные компоненты
При обсуждении идеи наследования неоднократно подчеркивался принцип Открыт-Закрыт - право, взяв компонент класса-родителя, переопределить его, возложив на него иные задачи. Могут ли появиться причины запрета такой возможности?
Запрет повторного объявления
Обсуждение утверждений в начале лекции дало нам теоретическое понимание сути переопределений. Часть "Открыт" принципа Открыт-Закрыт дает возможность изменять компоненты потомков, но под контролем утверждений. Разрешены лишь те повторные объявления, для которых реализация согласуется со спецификацией, заданной предусловием и постусловиям оригинала. В ряде случаев клиентам класса и клиентам классов потомков нужна гарантия, что компонент не только соблюдает спецификацию, но и пользуется в точности исходной реализацией. Достичь этого можно лишь "заморозив" его реализацию - полностью запретив переопределение компонента. Подобную возможность дает простая языковая конструкция:
frozen feature_name ... is... Остальные объявления - как обычно...
При таком описании ни один из потомков класса не может включать данный компонент в предложения redefine и undefine ни под своим, ни под любым другим именем (смена имен, конечно же, по-прежнему разрешена). Отложенный компонент по своей сути должен быть переопределен и, следовательно, не может быть заморожен.
Проблема типизации
Аргументы в пользу динамической типизации
Несмотря на все это, динамическая типизация не теряет своих приверженцев, в частности, среди Smalltalk-программистов. Их аргументы основаны прежде всего на реализме, речь о котором шла выше. Они уверены, что статическая типизация чересчур ограничивает их, не давая им свободно выражать свои творческие идеи, называя иногда ее "поясом целомудрия". С такой аргументацией можно согласиться, но лишь для статически типизированных языков, не поддерживающих ряд возможностей. Стоит отметить, что все концепции, связанные с понятием типа и введенные в предыдущих лекциях, необходимы - отказ от любой из них чреват серьезными ограничениями, а их введение, напротив, придает нашим действиям гибкость, а нам самим дает возможность в полной мере насладиться практичностью статической типизации.
Базисная конструкция
Простота типизации в ОО-подходе есть следствие простоты объектной вычислительной модели. Опуская детали, можно сказать, что при выполнении ОО-системы происходят события только одного рода - вызов компонента (feature call):
x.f (arg)
означающий выполнение операции f над объектом, присоединенным к x, с передачей аргумента arg (возможно несколько аргументов или ни одного вообще). Программисты Smalltalk говорят в этом случае о "передаче объекту x сообщения f с аргументом arg", но это - лишь отличие в терминологии, а потому оно несущественно. То, что все основано на этой Базисной Конструкции (Basic Construct), объясняет частично ощущение красоты ОО-идей. Из Базисной Конструкции следуют и те ненормальные ситуации, которые могут возникнуть в процессе выполнения: Определение: нарушение типа Нарушение типа в период выполнения или, для краткости, просто нарушение типа (type violation) возникает в момент вызова x.f (arg), где x присоединен к объекту OBJ, если либо: не существует компонента, соответствующего f и применимого к OBJ,такой компонент имеется, однако, аргумент arg для него недопустим.
Проблема типизации - избегать таких ситуаций: Проблема типизации ОО-систем Когда мы обнаруживаем, что при выполнении ОО-системы может произойти нарушение типа? Ключевым является слово когда. Рано или поздно вы поймете, что имеет место нарушение типа. Например, попытка выполнить компонент "Пуск торпеды" для объекта "Служащий" не будет работать и при выполнении произойдет отказ. Однако возможно вы предпочитаете находить ошибки как можно раньше, а не позже.
Глобальный анализ
Этот раздел посвящен описанию промежуточного подхода. Основные практические решения изложены в лекции 17. Изучая вариант с закреплением, мы заметили, что его основной идеей было разделение ковариантного и полиморфного наборов сущностей. Так, если взять две инструкции вида
s := b ... s.share (g)
каждая из них служит примером правильного применения важных ОО-механизмов: первая - полиморфизма, вторая - переопределения типов. Проблемы начинаются при объединении их для одной и той же сущности s. Аналогично:
p := r ... p.add_vertex (...)
проблемы начинаются с объединения двух независимых и совершенно невинных операторов. Ошибочные вызовы ведут к нарушению типов. В первом примере полиморфное присваивание присоединяет объект BOY к сущности s, что делает g недопустимым аргументом share, так как она связана с объектом GIRL. Во втором примере к сущности r присоединяется объект RECTANGLE, что исключает add_vertex из числа экспортируемых компонентов. Вот и идея нового решения: заранее - статически, при проверке типов компилятором или иными инструментальными средствами - определим набор типов (typeset) каждой сущности, включающий типы объектов, с которыми сущность может быть связана в период выполнения. Затем, опять же статически, мы убедимся в том, что каждый вызов является правильным для каждого элемента из наборов типов цели и аргументов. В наших примерах оператор s := b указывает на то, что класс BOY принадлежит набору типов для s (поскольку в результате выполнения инструкции создания create b он принадлежит набору типов для b). GIRL, ввиду наличия инструкции create g, принадлежит набору типов для g. Но тогда вызов share будет недопустим для цели s типа BOY и аргумента g типа GIRL. Аналогично RECTANGLE находится в наборе типов для p, что обусловлено полиморфным присваиванием, однако, вызов add_vertex для p типа RECTANGLE окажется недопустимым. Эти наблюдения наводят нас на мысль о создании глобального подхода на основе нового правила типизации: Правило системной корректности
Вызов x.f (arg) является системно-корректным, если и только если он классово-корректен для x, и arg, имеющих любые типы из своих соответствующих наборов типов.
В этом определении вызов считается классово-корректным, если он не нарушает правила Вызова Компонентов, которое гласит: если C есть базовый класс типа x, компонент f должен экспортироваться C, а тип arg должен быть совместим с типом формального параметра f. (Вспомните: для простоты мы полагаем, что каждый подпрограмма имеет только один параметр, однако, не составляет труда расширить действие правила на произвольное число аргументов.)
Системная корректность вызова сводится к классовой корректности за тем исключением, что она проверяется не для отдельных элементов, а для любых пар из наборов множеств. Вот основные правила создания набора типов для каждой сущности:
Для каждой сущности начальный набор типов пуст.Встретив очередную инструкцию вида create {SOME_TYPE} a, добавим SOME_TYPE в набор типов для a. (Для простоты будем полагать, что любая инструкция create a будет заменена инструкцией create {ATYPE} a, где ATYPE - тип сущности a.)Встретив очередное присваивание вида a := b, добавим в набор типов для a все элементы набора типов для b.Если a есть формальный параметр подпрограммы, то, встретив очередной вызов с фактическим параметром b, добавим в набор типов для a все элементы набора типов для b.Будем повторять шаги (3) и (4) до тех пор, пока наборы типов не перестанут изменяться.
Данная формулировка не учитывает механизма универсальности, однако расширить правило нужным образом можно без особых проблем. Шаг (5) необходим ввиду возможности цепочек присваивания и передач (от b к a, от c к b и т. д.). Нетрудно понять, что через конечное число шагов этот процесс прекратится.
| Число шагов ограничено длиной максимальной цепочки присоединений; другими словами максимум равен n, если система содержит присоединения от xi+1 к xi для i=1, 2, ... n-1. Повторение шагов (3) и (4) известно как метод "неподвижной точки". |
Как вы, возможно, заметили, правило не учитывает последовательности инструкций. В случае
create {TYPE1} t; s := t; create {TYPE2} t
в набор типов для s войдет как TYPE1, так и TYPE2, хотя s, учитывая последовательность инструкций, способен принимать значения только первого типа. Учет расположения инструкций потребует от компилятора глубокого анализа потока команд, что приведет к чрезмерному повышению уровня сложности алгоритма. Вместо этого применяются более пессимистичные правила: последовательность операций:
create b s := b s.share (g)
будет объявлена системно-некорректной, несмотря на то, что последовательность их выполнения не приводит к нарушению типа.
Глобальный анализ системы был (более детально) представлен в 22-й главе монографии [M 1992]. При этом была решена как проблема ковариантности, так и проблема ограничений экспорта при наследовании. Однако в этом подходе есть досадный практический недочет, а именно: предполагается проверка системы в целом, а не каждого класса в отдельности. Убийственным оказывается правило (4), которое при вызове библиотечной подпрограммы будет учитывать все ее возможные вызовы в других классах.
Хотя затем были предложены алгоритмы работы с отдельными классами в [M 1989b], их практическую ценность установить не удалось. Это означало, что в среде программирования, поддерживающей возрастающую компиляцию, необходимо будет организовать проверку всей системы. Желательно проверку вводить как элемент (быстрой) локальной обработки изменений, внесенных пользователем в некоторые классы. Хотя примеры применения глобального подхода известны, - так, программисты на языке C используют инструмент lint для поиска несоответствий в системе, не обнаруживаемых компилятором, - все это выглядит не слишком привлекательно.
В итоге, как мне известно, проверка системной корректности осталась никем не реализованной. (Другой причиной такого исхода, возможно, послужила сложность самих правил проверки.)
Классовая корректность предполагает проверку, ограниченную классом, и, следовательно, возможна при возрастающей компиляции.Системная корректность предполагает глобальную проверку всей системы, что входит в противоречие с возрастающей компиляцией.
Однако, несмотря на свое имя, фактически можно проверить системную корректность, используя только возрастающую проверку классов (в процессе работы обычного компилятора). Это и будет финальным вкладом в решение проблемы.
Использование родовых параметров
Универсальность лежит в основе интересной идеи, впервые высказанной Францем Вебером (Franz Weber). Объявим класс SKIER1, ограничив универсализацию родового параметра классом ROOM:
class SKIER1 [G -> ROOM] feature accommodation: G accommodate (r: G) is ... require ... do accommodation := r end end
Тогда класс GIRL1 будет наследником SKIER1 [GIRL_ROOM] и т. д. Тем же приемом, каким бы странным он не казался на первый взгляд, можно воспользоваться и при отсутствии параллельной иерархии: class SKIER [G -> SKIER]. Этот подход позволяет решить проблему ковариантности. При любом использовании класса необходимо задать фактический родовой параметр ROOM или GIRL_ROOM, так что неверная комбинация просто становится невозможной. Язык становится безвариантным, а система полностью отвечает потребностям ковариантности благодаря родовым параметрам. К сожалению, эта техника неприемлема как общее решение, поскольку ведет к разрастанию списка родовых параметров, по одному на каждый тип возможного ковариантного аргумента. Хуже того, добавление ковариантной подпрограммы с аргументом, тип которого отсутствует в списке, потребует добавления родового параметра класса, а, следовательно, изменит интерфейс класса, повлечет изменения у всех клиентов класса, что недопустимо.
Ключевые концепции
Статическая типизация - залог надежности, читабельности и эффективности.Чтобы быть реалистичной, статической типизации требуется совместное применение механизмов: утверждений, множественного наследования, попытки присваивания, ограниченной и неограниченной универсальности, закрепленных объявлений. Система типов не должна допускать ловушек (приведений типа).Практические правила повторного объявления должны допускать ковариантное переопределение. Типы результатов и аргументов при переопределении должны быть совместимыми с исходными.Ковариантность, также как и возможность скрытия потомком компонента, экспортированного предком, в сочетании с полиморфизмом порождают редко встречающуюся, но весьма серьезную проблему нарушения типов.Этих нарушений можно избежать, используя: глобальный анализ, (что непрактично) ограничивая ковариантность закрепленными типами (что противоречит принципу "Открыт-Закрыт"), решение Кэтколл, препятствующее вызову полиморфной целью подпрограммы с ковариантностью или скрытием потомком.
Контравариантность и безвариантность
Контравариантность устраняет теоретические проблемы, связанные с нарушением системной корректности. Однако при этом теряется реалистичность системы типов, по этой причине рассматривать этот подход в дальнейшем нет никакой необходимости. Оригинальность языка C++ в том, что он использует стратегию безвариантности (novariance), не позволяя менять тип аргументов в переопределяемых подпрограммах! Если бы язык C++ был строго типизированным языком, его системной типов было бы трудно пользоваться. Простейшее решение проблемы в этом языке, как и обход иных ограничений C++ (скажем, отсутствия ограниченной универсальности), состоит в использовании кастинга - приведения типа, что позволяет полностью игнорировать имеющийся механизм типизации. Это решение не кажется привлекательным. Заметим, однако, что ряд предложений, обсуждаемых ниже, будет опираться на безвариантность, смысл которой придаст введение новых механизмов работы с типами взамен ковариантного переопределения.
Корректность систем и классов
Для обсуждения проблем ковариантности и скрытия потомком нам понадобится несколько новых терминов. Будем называть классово-корректной (class-valid) систему, удовлетворяющую трем правилам описания типов, приведенным в начале лекции. Напомним их: каждая сущность имеет свой тип; тип фактического аргумента должен быть совместимым с типом формального, аналогичная ситуация с присваиванием; вызываемый компонент должен быть объявлен в своем классе и экспортирован классу, содержащему вызов. Система называется системно-корректной (system-valid), если при ее выполнении не происходит нарушения типов. В идеале оба понятия должны совпадать. Однако мы уже видели, что классово-корректная система в условиях наследования, ковариантности и скрытия потомком может не быть системно-корректной. Назовем такую ошибку нарушением системной корректности (system validity error).
Корректность систем: первое приближение
Давайте сконцентрируемся вначале на проблеме ковариантности, более важной из двух рассматриваемых. Этой теме посвящена обширная литература, предлагающая ряд разнообразных решений.
Ковариантность и скрытие потомком
Если бы мир был прост, то разговор о типизации можно было бы и закончить. Мы определили цели и преимущества статической типизации, изучили ограничения, которым должны соответствовать реалистичные системы типов, и убедились в том, что предложенные методы типизации отвечают нашим критериям. Но мир не прост. Объединение статической типизации с некоторыми требованиями программной инженерии создает проблемы более сложные, чем это кажется с первого взгляда. Проблемы вызывают два механизма: ковариантность (covariance) - смена типов параметров при переопределении, скрытие потомком (descendant hiding) - способность класса потомка ограничивать статус экспорта наследуемых компонентов.
Ковариантность
Что происходит с аргументами компонента при переопределении его типа? Это важнейшая проблема, и мы уже видели ряд примеров ее проявления: устройства и принтеры, одно- и двухсвязные списки и т. д. (см. разделы 16.6, 16.7). Вот еще один пример, помогающий уяснить природу проблемы. И пусть он далек от реальности и метафоричен, но его близость к программным схемам очевидна. К тому же, разбирая его, мы будем часто возвращаться к задачам из практики. Представим себе готовящуюся к чемпионату лыжную команду университета. Класс GIRL включает лыжниц, выступающих в составе женской сборной, BOY - лыжников. Ряд участников обеих команд ранжированы, показав хорошие результаты на предыдущих соревнованиях. Это важно для них, поскольку теперь они побегут первыми, получив преимущество перед остальными. (Это правило, дающее привилегии уже привилегированным, возможно и делает слалом и лыжные гонки столь привлекательными в глазах многих людей, являясь хорошей метафорой самой жизни.) Итак, мы имеем два новых класса: RANKED_GIRL и RANKED_BOY.
 Рис. 17.4. Классификация лыжников Для проживания спортсменов забронирован ряд номеров: только для мужчин, только для девушек, только для девушек-призеров. Для отображения этого используем параллельную иерархию классов: ROOM, GIRL_ROOM и RANKED_GIRL_ROOM. Вот набросок класса SKIER:
class SKIER feature roommate: SKIER -- Сосед по номеру. share (other: SKIER) is -- Выбрать в качестве соседа other. require other /= Void do roommate := other end ... Другие возможные компоненты, опущенные в этом и последующих классах ... end
Нас интересуют два компонента: атрибут roommate и процедура share, "размещающая" данного лыжника в одном номере с текущим лыжником:
s1, s2: SKIER ... s1.share (s2)
При объявлении сущности other можно отказаться от типа SKIER в пользу закрепленного типа like roommate (или like Current для roommate и other одновременно). Но давайте забудем на время о закреплении типов (мы к ним еще вернемся) и посмотрим на проблему ковариантности в ее изначальном виде.
Как ввести переопределение типов? Правила требуют раздельного проживания юношей и девушек, призеров и остальных участников. Для решения этой задачи при переопределении изменим тип компонента roommate, как показано ниже (здесь и далее переопределенные элементы подчеркнуты).
class GIRL inherit SKIER redefine roommate end feature roommate: GIRL -- Сосед по номеру. end
Переопределим, соответственно, и аргумент процедуры share. Более полный вариант класса теперь выглядит так:
class GIRL inherit SKIER redefine roommate, share end feature roommate: GIRL -- Сосед по номеру. share (other: GIRL) is -- Выбрать в качестве соседа other. require other /= Void do roommate := other end end
Аналогично следует изменить все порожденные от SKIER классы (закрепление типов мы сейчас не используем). В итоге имеем иерархию:
 Рис. 17.5. Иерархия участников и повторные определения
Так как наследование является специализацией, то правила типов требуют, чтобы при переопределении результата компонента, в данном случае roommate, новый тип был потомком исходного. То же касается и переопределения типа аргумента other подпрограммы share. Эта стратегия, как мы знаем, именуется ковариантностью, где приставка "ко" указывает на совместное изменение типов параметра и результата. Противоположная стратегия называется контравариантностью.
Все наши примеры убедительно свидетельствуют о практической необходимости ковариантности.
Элемент односвязного списка LINKABLE должен быть связан с другим подобным себе элементом, а экземпляр BI_LINKABLE - с подобным себе. Ковариантно потребуется переопределяется и аргумент в put_right.Всякая подпрограмма в составе LINKED_LIST с аргументом типа LINKABLE при переходе к TWO_WAY_LIST потребует аргумента BI_LINKABLE.Процедура set_alternate принимает DEVICE-аргумент в классе DEVICE и PRINTER-аргумент - в классе PRINTER.
Ковариантное переопределение получило особое распространение потому, что скрытие информации ведет к созданию процедур вида
set_attrib (v: SOME_TYPE) is -- Установить attrib в v. ...
для работы с attrib типа SOME_TYPE. Подобные процедуры, естественно, ковариантны, поскольку любой класс, который меняет тип атрибута, должен соответственно переопределять и аргумент set_attrib. Хотя представленные примеры укладываются в одну схему, но ковариантность распространена значительно шире. Подумайте, например, о процедуре или функции, выполняющей конкатенацию односвязных списков (LINKED_LIST). Ее аргумент должен быть переопределен как двусвязный список (TWO_ WAY_LIST). Универсальная операция сложения infix "+" принимает NUMERIC-аргумент в классе NUMERIC, REAL - в классе REAL и INTEGER - в классе INTEGER. В параллельных иерархиях телефонной службы процедуре start в классе PHONE_SERVICE может требоваться аргумент ADDRESS, представляющий адрес абонента, (для выписки счета), в то время как этой же пр оцедуре в классе CORPORATE_SERVICE потребуется аргумент типа CORPORATE_ADDRESS.
 Рис. 17.6. Службы связи
Что можно сказать о контравариантном решении? В примере с лыжниками оно означало бы, что если, переходя к классу RANKED_GIRL, тип результата roommate переопределили как RANKED_GIRL, то в силу контравариантности тип аргумента share можно переопределить на тип GIRL или SKIER. Единственный тип, который не допустим при контравариантном решении, - это RANKED_GIRL! Достаточно, чтобы возбудить наихудшие подозрения у родителей девушек.
Назад, в Ялту
Суть решения Кэтколл (Catcall), - смысл этого понятия мы поясним позднее, - в возвращении к духу Ялтинских соглашений, разделяющих мир на полиморфный и ковариантный (и спутник ковариантности - скрытие потомков), но без необходимости обладания бесконечной мудростью. Как и прежде, сузим вопрос о ковариантности до двух операций. В нашем главном примере это полиморфное присваивание: s := b, и вызов ковариантной подпрограммы: s.share (g). Анализируя, кто же является истинным виновником нарушений, исключим аргумент g из числа подозреваемых. Любой аргумент, имеющий тип SKIER или порожденный от него, нам не подходит ввиду полиморфизма s и ковариантности share. А потому если статически описать сущность other как SKIER и динамически присоединить к объекту SKIER, то вызов s.share (other) статически создаст впечатление идеального варианта, но приведет к нарушению типов, если полиморфно присвоить s значение b. Фундаментальная проблема в том, что мы пытаемся использовать s двумя несовместимыми способами: как полиморфную сущность и как цель вызова ковариантной подпрограммы. (В другом нашем примере проблема состоит в использовании p как полиморфной сущности и как цели вызова подпрограммы потомка, скрывающего компонент add_vertex.) Решение Кэтколл, как и Закрепление, носит радикальный характер: оно запрещает использовать сущность как полиморфную и ковариантную одновременно. Подобно глобальному анализу, оно статически определяет, какие сущности могут быть полиморфными, однако, не пытается быть слишком умным, отыскивая для сущностей наборы возможных типов. Вместо этого всякая полиморфная сущность воспринимается как достаточно подозрительная, и ей запрещается вступать в союз с кругом почтенных лиц, включающих ковариантность и скрытие потомком.
Оценка
Прежде чем мы сведем воедино все, что узнали о ковариантности и скрытии потомком, вспомним еще раз о том, что нарушения корректности систем возникают действительно редко. Наиболее важные свойства статической ОО-типизации были обобщены в начале лекции. Этот впечатляющий ряд механизмов работы с типами совместно с проверкой классовой корректности, открывает дорогу к безопасному и гибкому методу конструирования ПО. Мы видели три решения проблемы ковариантности, два из которых затронули и вопросы ограничения экспорта. Какое же из них правильное? На этот вопрос нет окончательного ответа. Следствия коварного взаимодействия ОО-типизации и полиморфизма изучены не так хорошо, как вопросы, изложенные в предыдущих лекциях. В последние годы появились многочисленные публикации по этой теме, ссылки на которые приведены в библиографии в конце лекции. Кроме того, я надеюсь, что в настоящей лекции мне удалось представить элементы окончательного решения или хотя бы к нему приблизиться. Глобальный анализ кажется непрактичным из-за полной проверки всей системы. Тем не менее, он помог нам лучше понять проблему. Решение на основе Закрепления чрезвычайно привлекательно. Оно простое, интуитивно понятное, удобное в реализации. Тем сильнее мы должны сожалеть о невозможности поддержки в нем ряда ключевых требований ОО-метода, отраженных в принципе Открыт-Закрыт. Если бы мы и впрямь обладали прекрасной интуицией, то закрепление стало бы великолепным решением, но какой разработчик решится утверждать это, или, тем более, признать, что такой интуицией обладали авторы библиотечных классов, наследуемых в его проекте?
| Это предположение сужает сферу применения многих опубликованных методов, в том числе, основанных на типовых переменных. Если бы мы были уверены в том, что разработчик всегда заранее знает о будущих изменениях типов, задача бы упростилась в теоретическом плане, но из-за ошибочности гипотезы она не имеет практической ценности. |
Если от закрепления мы вынуждены отказаться, то наиболее подходящим кажется Кэтколл-решение, достаточно легко объяснимое и применимое на практике. Его пессимизм не должен исключать полезные комбинации операторов. В случае, когда полиморфный кэтколл порожден "легитимным" оператором, всегда можно безопасно допустить его, введением попытки присваивания. Тем самым ряд проверок можно перенести на время выполнения программы. Однако количество таких случаев должно быть предельно мало. В качестве пояснения я должен заметить, что на момент написания книги решение Кэтколл не было реализовано. До тех пор, пока компилятор не будет адаптирован к проверке правила типов Кэтколл и не будет успешно применен к репрезентативным системам - большим и малым, - рано говорить, что в проблеме примирения статической типизации с полиморфизмом, сочетаемым с ковариантностью и скрытием потомком, сказано последнее слово.
Одно правило и несколько определений
Правило типов для решения Кэтколл имеет простую формулировку: Правило типов для Кэтколл Полиморфные кэтколлы некорректны. В его основе - столь же простые определения. Прежде всего, полиморфная сущность: Определение: полиморфная сущность Сущность x ссылочного (не развернутого) типа полиморфна, если она обладает одним из следующих свойств: Встречается в присваивании x := y, где сущность y имеет иной тип или по рекурсии полиморфна.Встречается в инструкциях создания create {OTHER_TYPE} x, где OTHER_TYPE не является типом, указанным в объявлении x.Является формальным аргументом подпрограммы.Является внешней функцией.
Цель этого определения - придать статус полиморфной ("потенциально полиморфной") любой сущности, которую при выполнении программы можно присоединить к объектам разных типов. Это определение применимо лишь к ссылочным типам, так как развернутые сущности по природе не могут быть полиморфными. В наших примерах лыжник s и многоугольник p - полиморфны по правилу (1). Первому из них присваивается объект BOY b, второму - объект RECTANGLE r. Если вы познакомились с формулировкой понятия набора типов, то заметили, насколько пессимистичнее выглядит определение полиморфной сущности, и насколько проще его проверить. Не пытаясь отыскать все всевозможные динамические типы сущности, мы довольствуемся общим вопросом: может данная сущность быть полиморфной или нет? Наиболее удивительным выглядит правило (3), по которому полиморфным считается каждый формальный параметр (если его тип не расширен, как в случае с целыми и т. д.). Мы даже не утруждаем себя анализом вызовов. Если у подпрограммы есть аргумент, то он находится в полном распоряжении клиента, а значит, и полагаться на указанный в объявлении тип нельзя. Это правило тесно связано с повторным использованием - целью объектной технологии, - где любой класс потенциально может быть включен в состав библиотеки, и будет многократно вызываться различными клиентами. Характерным свойством этого правила является то, что оно не требует никаких глобальных проверок.
Для выявления полиморфности сущности достаточно просмотреть текст самого класса. Если для всех запросов (атрибутов или функций) сохранять информацию об их статусе полиморфности, то не приходится изучать даже тексты предков. В отличие от отыскания наборов типов, можно обнаружить полиморфные сущности, проверяя класс за классом в процессе возрастающей компиляции.
| Как было сказано при обсуждении наследования, подобный анализ может также представлять ценность при оптимизации кода. |
Вызовы, как и сущности, могут быть полиморфными:
Определение: полиморфный вызов
Вызов является полиморфным, если его цель полиморфна.
Оба вызова в наших примерах полиморфны: s.share (g) ввиду полиморфизма s, p.add_ vertex (...) ввиду полиморфизма p. Согласно определению, только квалифицированные вызовы могут быть полиморфны. (Придав неквалифицированному вызову f (...) вид квалифицированного Current.f (...), мы не меняем суть дела, поскольку Current, присвоить которому ничего нельзя, не является полиморфным объектом.)
Далее нам потребуется понятие Кэтколла, основанное на понятии CAT. (CAT - это аббревиатура Changing Availability or Type - изменение доступности или типа). Подпрограмма является CAT подпрограммой, если некоторое ее переопределение потомком приводит к изменениям одного из двух видов, которые, как мы видели, являются потенциально опасными: изменяет тип аргумента (ковариантно) или скрывает ранее экспортировавшийся компонент.
Определение: CAT-подпрограммы
Подпрограмма называется CAT-подпрограммой, если некоторое ее переопределение изменяет статус экспорта или тип любого из ее аргументов.
Это свойство опять-таки допускает возрастающую проверку: любое переопределение типа аргумента или статуса экспорта делают процедуру или функцию CAT-подпрограммой. Отсюда следует понятие Кэтколла: вызова CAT-подпрограммы, который может оказаться ошибочным.
Определение: Кэтколл
Вызов называется Кэтколлом, если некоторое переопределение подпрограммы сделало бы его ошибочным из-за изменения статуса экспорта или типа аргумента.
Созданная нами классификация позволяет выделять специальные группы вызовов: полиморфные и кэтколлы. Полиморфные вызовы придают выразительную мощь объектному подходу, кэтколлы позволяют переопределять типы и ограничивать экспорт. Используя терминологию, введенную ранее в этой лекции, можно сказать, что полиморфные вызовы расширяют полезность (usefulness), кэтколлы - используемость(usability).
Вызовы share и add_vertex, рассмотренные в наших примерах, являются кэт-коллами. Первый осуществляет ковариантное переопределение своего аргумента. Второй экспортируется классом RECTANGLE, но скрыт классом POLYGON. Оба вызова также и полиморфны, а потому они служат прекрасным примером полиморфных кэтколлов. Они являются ошибочными согласно правилу типов Кэтколл.
Остерегайтесь полиморфных кэтколлов!
Правило Системной Корректности пессимистично: в целях упрощения оно отвергает и вполне безопасные комбинации инструкций. Как ни парадоксально, но последний вариант решения мы построим на основе еще более пессимистического правила. Естественно, это поднимет вопрос о том, насколько реалистичным будет наш результат.
Параллельные иерархии
Чтобы не оставить камня на камне, рассмотрим вариант примера SKIER с двумя параллельными иерархиями. Это позволит нам смоделировать ситуацию, уже встречавшуюся на практике: TWO_ WAY_LIST > LINKED_LIST и BI_LINKABLE > LINKABLE; или иерархию с телефонной службой PHONE_SERVICE. Пусть есть иерархия с классом ROOM, потомком которого является GIRL_ROOM (класс BOY опущен):
 Рис. 17.7. Лыжники и комнаты Наши классы лыжников в этой параллельной иерархии вместо roommate и share будут иметь аналогичные компоненты accommodation (размещение) и accommodate (разместить):
indexing description: "Новый вариант с параллельными иерархиями" class SKIER1 feature accommodation: ROOM accommodate (r: ROOM) is ... require ... do accommodation:= r end end
Здесь также необходимы ковариантные переопределения: в классе GIRL1 как accommodation, так и аргумент подпрограммы accommodate должны быть заменены типом GIRL_ROOM, в классе BOY1 - типом BOY_ROOM и т.д. (Не забудьте: мы по-прежнему работаем без закрепления типов.) Как и в предыдущем варианте примера, контравариантность здесь бесполезна.
Пессимизм
Статическая типизация приводит по своей природе к "пессимистической" политике. Попытка дать гарантию, что все вычисления не приводят к отказам, отвергает вычисления, которые могли бы закончиться без ошибок. Рассмотрим обычный, необъектный, Pascal-подобный язык с различными типами REAL и INTEGER. При описании n: INTEGER; r: Real оператор n := r будет отклонен, как нарушающий правила. Так, компилятор отвергнет все нижеследующие операторы:
n := 0.0 [A] n := 1.0 [B] n := -3.67 [C] n := 3.67 - 3.67 [D]
Если мы разрешим их выполнение, то увидим, что [A] будет работать всегда, так как любая система счисления имеет точное представление вещественного числа 0,0, недвусмысленно переводимое в 0 целых. [B] почти наверняка также будет работать. Результат действия [C] не очевиден (хотим ли мы получить итог округлением или отбрасыванием дробной части?). [D] справится со своей задачей, как и оператор:
if n ^ 2 < 0 then n := 3.67 end [E]
куда входит недостижимое присваивание (n ^ 2 - это квадрат числа n). После замены n ^ 2 на n правильный результат даст только ряд запусков. Присваивание n большого вещественного значения, не представимого целым, приведет к отказу. В типизированных языках все эти примеры (работающие, неработающие, иногда работающие) безжалостно трактуются как нарушения правил описания типов и отклоняются любым компилятором. Вопрос не в том, будем ли мы пессимистами, а в том, насколько пессимистичными мы можем позволить себе быть. Вернемся к требованию реализма: если правила типов настолько пессимистичны, что препятствуют простоте записи вычислений, мы их отвергнем. Но если достижение безопасности типов достигается небольшой потерей выразительной силы, мы примем их. Например, в среде разработки, предоставляющей функции округления и выделения целой части - round и truncate, оператор n := r считается некорректным справедливо, поскольку заставляет вас явно записать преобразование вещественного числа в целое, вместо использования двусмысленных преобразований по умолчанию.
Полагаясь на закрепление типов
Почти готовое решение проблемы ковариантности мы найдем, присмотревшись к известному нам механизму закрепленных объявлений. При описании классов SKIER и SKIER1 вас не могло не посетить желание, воспользовавшись закрепленными объявлениями, избавиться от многих переопределений. Закрепление - это типичный ковариантный механизм. Вот как будет выглядеть наш пример (все изменения подчеркнуты):
class SKIER feature roommate: like Current share (other: like Current) is ... require ... do roommate := other end ... end class SKIER1 feature accommodation: ROOM accommodate (r: like accommodation) is ... require ... do accommodation := r end end
Теперь потомки могут оставить класс SKIER без изменений, а в SKIER1 им понадобится переопределить только атрибут accommodation. Закрепленные сущности: атрибут roommate и аргументы подпрограмм share и accommodate - будут изменяться автоматически. Это значительно упрощает работу и подтверждает тот факт, что при отсутствии закрепления (или другого подобного механизма, например, типовых переменных) написать ОО-программный продукт с реалистичной типизацией невозможно. Но удалось ли устранить нарушения корректности системы? Нет! Мы, как и раньше, можем перехитрить проверку типов, выполнив полиморфные присваивания, вызывающие нарушения системной корректности. Правда, исходные варианты примеров будут отклонены. Пусть:
s: SKIER; b: BOY; g: GIRL ... create b;create g;-- Создание объектов BOY и GIRL. s := b; -- Полиморфное присваивание. sl share (g)
Аргумент g, передаваемый share, теперь неверен, так как здесь требуется объект типа like s, а класс GIRL не совместим с этим типом, поскольку по правилу закрепленных типов ни один тип не совместим с like s, кроме него самого. Впрочем, радоваться нам не долго. В другую сторону это правило говорит о том, что like s совместим с типом s. А значит, используя полиморфизм не только объекта s, но и параметра g, мы можем снова обойти систему проверки типов:
s: SKIER; b: BOY; g: like s; actual_g: GIRL; ...
create b; create actual_g -- Создание объектов BOY и GIRL. s := actual_g; g := s -- Через s присоединить g к GIRL. s := b -- Полиморфное присваивание. s.share (g)
В результате незаконный вызов проходит.
Выход из положения есть. Если мы всерьез готовы использовать закрепление объявлений как единственный механизм ковариантности, то избавиться от нарушений системной корректности можно, полностью запретив полиморфизм закрепленных сущностей. Это потребует изменения в языке: введем новое ключевое слово anchor (эта гипотетическая конструкция нужна нам исключительно для того, чтобы использовать ее в данном обсуждении):
anchor s: SKIER
Разрешим объявления вида like s лишь тогда, когда s описано как anchor. Изменим правила совместимости так, чтобы гарантировать: s и элементы типа like s могут присоединяться (в присваиваниях или передаче аргумента) только друг к другу.
| В исходном варианте правила существовало понятие опорно-эквивалентных элементов. При новом подходе опорно-эквивалентными должны быть как правая, так и левая часть любого присваивания, в котором участвует опорная или закрепленная сущность. |
При таком подходе мы устраняем из языка возможность переопределения типа любых аргументов подпрограммы. Помимо этого, мы могли запретить переопределять тип результата, но в этом нет необходимости. Возможность переопределения типа атрибутов, конечно же, сохраняется. Все переопределения типов аргументов теперь будут выполняться неявно через механизм закрепления, инициируемый ковариантностью. Там, где при прежнем подходе класс D переопределял наследуемый компонент как:
r (u: Y) ...
тогда как у класса C - родителя D это выглядело
r (u: X) ...
где Y соответствовало X, то теперь переопределение компонента r будет выглядеть так:
r (u: like your_anchor) ...
Остается только в классе D переопределить тип your_anchor.
Это решение проблемы ковариантности - полиморфизма будем называть подходом Закрепления (Anchoring). Более аккуратно следовало бы говорить: "Ковариация только через Закрепление".
Свойства подхода привлекательны:
Закрепление основано на идее строгого разделения ковариантных и потенциально полиморфных (или, для краткости, полиморфных) элементов. Все сущности, объявленные как anchor или like some_anchor ковариантны; прочие-полиморфны. В каждой из двух категорий допустимы любые присоединения, но нет сущности или выражения, нарушающих границу. Нельзя, например, присвоить полиморфный источник ковариантной цели.Это простое и элегантное решение нетрудно объяснить даже начинающим.Оно полностью устраняет возможность нарушения системной корректности в ковариантно построенных системах.Оно сохраняет заложенную выше концептуальную основу, в том числе понятия ограниченной и неограниченной универсальности. (В итоге это решение, по-моему, предпочтительнее типовых переменных, подменяющих собой механизмы ковариантности и универсальности, предназначенных для решения разных практических задач.)Оно требует незначительного изменения языка, - добавляя одно ключевое слово, отраженное в правиле соответствия, - и не связано с ощутимыми трудностями в реализации.Оно реалистично (по крайней мере, теоретически): любую ранее возможную систему можно переписать, заменив ковариантные переопределения закрепленными повторными объявлениями. Правда, некоторые присоединения в результате станут неверными, но они соответствуют случаям, которые могут привести к нарушениям типов, а потому их следует заменить попытками присваивания и разобраться в ситуации во время выполнения.
Казалось бы, дискуссию можно на этом закончить. Так почему же подход Закрепления не полностью нас устраивает? Прежде всего, мы еще не касались проблемы скрытия потомком. Кроме этого, основной причиной продолжения дискуссии является проблема, уже высказанная при кратком упоминании типовых переменных. Раздел сфер влияния на полиморфную и ковариантную часть, чем-то похож на результат Ялтинской конференции. Он предполагает, что разработчик класса обладает незаурядной интуицией, что он в состоянии для каждой введенной им сущности, в частности для каждого аргумента раз и навсегда выбрать одну из двух возможностей:
Сущность является потенциально полиморфной: сейчас или позднее она (посредством передачи параметров или путем присваивания) может быть присоединена к объекту, чей тип отличается от объявленного. Исходный тип сущности не сможет изменить ни один потомок класса.Сущность является субъектом переопределения типов, то есть она либо закреплена, либо сама является опорным элементом.
Но как разработчик может все это предвидеть? Вся привлекательность ОО-метода во многом выраженная в принципе Открыт-Закрыт как раз и связана с возможностью изменений, которые мы вправе внести в ранее сделанную работу, а также с тем, что разработчик универсальных решений не должен обладать бесконечной мудростью, понимая, как его продукт смогут адаптировать к своим нуждам потомки.
При таком подходе переопределение типов и скрытие потомком - своего рода "предохранительный клапан", дающий возможность повторно использовать существующий класс, почти пригодный для достижения наших целей:
Прибегнув к переопределению типов, мы можем менять объявления в порожденном классе, не затрагивая оригинал. При этом чисто ковариантное решение потребует правки оригинала путем описанных преобразований.Скрытие потомком защита от многих неудач при создании класса. Можно критиковать проект, в котором RECTANGLE, используя тот факт, что он является потомком POLYGON, пытается добавить вершину. Взамен можно было бы предложить структуру наследования, в которой фигуры с фиксированным числом вершин отделены от всех прочих, и проблемы не возникало бы. Однако при разработке структур наследования предпочтительнее всегда те, в которых нет таксономических исключений. Но можно ли их полностью устранить? Обсуждая ограничение экспорта в одной из следующих лекций, мы увидим, что подобное невозможно по двум причинам. Во-первых, это наличие конкурирующих критериев классификации. Во-вторых, вероятность того, что разработчик не найдет идеального решения, даже если оно существует.
Желая сохранить гибкость адаптации порожденных классов для наших нужд, мы должны разрешить и ковариантное переопределение типов, и скрытие потомком.Далее мы узнаем, как этого добиться.
Полное соответствие
Завершая обсуждение ковариантности, полезно понять, как общий метод можно применить к решению достаточно общей проблемы. Метод появился как результат Кэтколл-теории, но может использоваться в рамках базисного варианта языка без введения новых правил. Пусть существуют два согласованных списка, где первый задает лыжников, а второй - соседа по комнате для лыжника из первого списка. Мы хотим выполнять соответствующую процедуру размещения share, только если она разрешена правилами описания типов, которые разрешают поселять девушек с девушками, девушек-призеров с девушками-призерами и так далее. Проблемы такого вида встречаются часто. Возможно простое решение, основанное на предыдущем обсуждении и попытке присваивания. Рассмотрим универсальную функцию fitted (согласовать):
fitted (other: GENERAL): like other is -- Текущий объект (Current), если его тип соответствует типу объекта, -- присоединенного к other, иначе void. do if other /= Void and then conforms_to (other) then Result ?= Current end end
Функция fitted возвращает текущий объект, но известный как сущность типа, присоединенного к аргументу. Если тип текущего объекта не соответствует типу объекта, присоединенного к аргументу, то возвращается Void. Обратите внимание на роль попытки присваивания. Функция использует компонент conforms_to из класса GENERAL, выясняющий совместимость типов пары объектов. Замена conforms_to на другой компонент GENERAL с именем same_type дает нам функцию perfect_fitted (полное соответствие), которая возвращает Void, если типы обоих объектов не идентичны. Функция fitted - дает нам простое решение проблемы соответствия лыжников без нарушения правил описания типов. Так, в код класса SKIER мы можем ввести новую процедуру и использовать ее вместо share, (последнюю можно сделать скрытой процедурой).
safe_share (other: SKIER) is -- Выбрать, если допустимо, other как соседа по номеру. -- gender_ascertained - установленный пол local gender_ascertained_other: like Current do gender_ascertained_other := other .fitted (Current) if gender_ascertained_other /= Void then share (gender_ascertained_other) else "Вывод: совместное размещение с other невозможно" end end
Для other произвольного типа SKIER (а не только like Current) определим версию gender_ascertained_other, имеющую тип, закрепленный за Current. Гарантировать идентичность типов нам поможет функция perfect_ fitted. При наличии двух параллельных списков лыжников, представляющих планируемое размещение:
occupant1, occupant2: LIST [SKIER]
можно организовать цикл, выполняя на каждом шаге вызов:
occupant1.item.safe_share (occupant2.item)
сопоставляющий элементы списков, если и только если их типы полностью совместимы.
Практический аспект
Простота проблемы создает своеобразный парадокс: пытливый новичок построит контрпример за считанные минуты, в реальной практике изо дня в день возникают ошибки классовой корректности систем, но нарушения системной корректности даже в больших, многолетних проектах возникают исключительно редко. Однако это не позволяет игнорировать их, а потому мы приступаем к изучению трех возможных путей решения данной проблемы. Далее мы будем затрагивать весьма тонкие и не столь часто дающие о себе знать аспекты объектного подхода. Читая книгу впервые, вы можете пропустить оставшиеся разделы этой лекции. Если вы лишь недавно занялись вопросами ОО-технологии, то лучше усвоите этот материал после изучения лекций 1-11 курса "Основы объектно-ориентированного проектирования", посвященной методологии наследования, и в особенности лекции 6 курса "Основы объектно-ориентированного проектирования", посвященной методологии наследования.
Правила типизации
Наша ОО-нотация является статически типизированной. Ее правила типов были введены в предыдущих лекциях и сводятся к трем простым требованиям. При объявлении каждой сущности или функции должен задаваться ее тип, например, acc: ACCOUNT. Каждая подпрограмма имеет 0 или более формальных аргументов, тип которых должен быть задан, например: put (x: G; i: INTEGER).В любом присваивании x := y и при любом вызове подпрограммы, в котором y - это фактический аргумент для формального аргумента x, тип источника y должен быть совместим с типом цели x. Определение совместимости основано на наследовании: B совместим с A, если является его потомком, - дополненное правилами для родовых параметров (лекцию 14).Вызов x.f (arg) требует, чтобы f был компонентом базового класса для типа цели x, и f должен быть экспортирован классу, в котором появляется вызов (см. 14.3).
Преимущества
Причины применения статической типизации в объектной технологии мы перечислили в начале лекции. Это надежность, простота понимания и эффективность. Надежность обусловлена обнаружением ошибок, которые иначе могли проявить себя лишь во время работы, и только в некоторых случаях. Первое из правил, заставляющее объявлять сущности, как, впрочем, и функции, вносит в программный текст избыточность, что позволяет компилятору, используя два других правила, обнаруживать несоответствия между задуманным и реальным применением сущностей, компонентов и выражений. Раннее выявление ошибок важно еще и потому, что чем дольше мы будем откладывать их поиск, тем сильнее вырастут издержки на исправление. Это свойство, интуитивно понятное всем программистам-профессионалам, количественно подтверждают широко известные работы Бема (Boehm). Зависимость издержек на исправление от времени отыскания ошибок приведена на графике, построенном по данным ряда больших промышленных проектов и проведенных экспериментов с небольшим управляемым проектом:
 Рис. 17.1. Сравнительные издержки на исправление ошибок ([Boehm 1981], публикуется с разрешения) Читабельность или Простота понимания (readability) имеет свои преимущества. Во всех примерах этой книги появление типа у сущности дает читателю информацию о ее назначении. Читабельность крайне важна на этапе сопровождения.
| Исключив читабельность из круга приоритетов, можно было бы получить другие преимущества, не вводя явных объявлений. В самом деле, возможна неявная форма типизации, когда компилятор, не требуя явного указания типа, пытается автоматически определить его из контекста применения сущности. Эта стратегия известна как выведение типов (type inference). Но в программной инженерии явные объявления типов это помощь, а не наказание, - тип должен быть ясен не только машине, но и читающему текст человеку. |
Наконец, эффективность может определять успех или отказ от объектной технологии на практике. В отсутствие статической типизации на выполнение x.f (arg) может уйти сколько угодно времени.
Причина этого в том, что на этапе выполнения, не найдя f в базовом классе цели x, поиск будет продолжен у ее потомков, а это верная дорога к неэффективности. Снять остроту проблемы можно, улучшив поиск компонента по иерархии. Авторы языка Self провели большую работу, стремясь генерировать лучший код для языка с динамической типизацией. Но именно статическая типизация позволила такому ОО-продукту приблизиться или сравняться по эффективности с традиционным ПО.
Ключом к статической типизации является уже высказанная идея о том, что компилятор, генерирующий код для конструкции x.f (arg), знает тип x. Из-за полиморфизма нет возможности однозначно определить подходящую версию компонента f. Но объявление сужает множество возможных типов, позволяя компилятору построить таблицу, обеспечивающую доступ к правильному f с минимальными издержками, - с ограниченной константой сложностью доступа. Дополнительно выполняемые оптимизации статического связывания (static binding) и подстановки (inlining) - также облегчаются благодаря статической типизации, полностью устраняя издержки в тех случаях, когда они применимы.
Проблема типизации
О типизации при ОО-разработке можно сказать одно: эта задача проста в своей постановке, но решить ее подчас нелегко.
Реализм
Хотя определение статически типизированного языка дано совершенно точно, его недостаточно, - необходимы неформальные критерии при создании правил типизации. Рассмотрим два крайних случая. Совершенно корректный язык, в котором каждая синтаксически правильная система корректна и в отношении типов. Правила описания типов не нужны. Такие языки существуют (представьте себе польскую запись выражения со сложением и вычитанием целых чисел). К сожалению, ни один реальный универсальный язык не отвечает этому критерию.Совершенно некорректный язык, который легко создать, взяв любой существующий язык и добавив правило типизации, делающее любую систему некорректной. По определению, этот язык типизирован: так как нет систем, соответствующих правилам, то ни одна система не вызовет нарушения типов.
Можно сказать, что языки первого типа пригодны, но бесполезны, вторые, возможно, полезны, но не пригодны. На практике необходима система типов, пригодная и полезная одновременно: достаточно мощная для реализации потребностей вычислений и достаточно удобная, не заставляющая нас идти на усложнения для удовлетворения правил типизации. Будем говорить, что язык реалистичен, если он пригоден к применению и полезен на практике. В отличие от определения статической типизации, дающего безапелляционный ответ на вопрос: "Типизирован ли язык X статически?", определение реализма отчасти субъективно. В этой лекции мы убедимся, что предлагаемая нами нотация реалистична.
Скрытие потомком
Прежде чем искать решение проблемы ковариантности, рассмотрим еще один механизм, способный в условиях полиморфизма привести к нарушениям типа. Скрытие потомком (descendant hiding) - это способность класса не экспортировать компонент, полученный от родителей.
 Рис. 17.8. Скрытие потомком Типичным примером является компонент add_vertex (добавить вершину), экспортируемый классом POLYGON, но скрываемый его потомком RECTANGLE (ввиду возможного нарушения инварианта - класс хочет оставаться прямоугольником):
class RECTANGLE inherit POLYGON export {NONE} add_vertex end feature ... invariant vertex_count = 4 end
Не программистский пример: класс "Страус" скрывает метод "Летать", полученный от родителя "Птица". Давайте на минуту примем эту схему такой, как она есть, и поставим вопрос, будет ли легитимным сочетание наследования и скрытия. Моделирующая роль скрытия, подобно ковариантности, нарушается из-за трюков, возможных из-за полиморфизма. И здесь не трудно построить вредоносный пример, позволяющий, несмотря на скрытие компонента, вызвать его и добавить прямоугольнику вершину:
p: POLYGON; r: RECTANGLE ... create r; -- Создание объекта RECTANGLE. p := r; -- Полиморфное присваивание. p.add_vertex (...)
Так как объект r скрывается под сущностью p класса POLYGON, а add_vertex экспортируемый компонент POLYGON, то его вызов сущностью p корректен. В результате выполнения в прямоугольнике появится еще одна вершина, а значит, будет создан недопустимый объект.
Статическая и динамическая типизация
Хотя возможны и промежуточные варианты, здесь представлены два главных подхода: Динамическая типизация: ждать момента выполнения каждого вызова и тогда принимать решение.Статическая типизация: с учетом набора правил определить по исходному тексту, возможны ли нарушения типов при выполнении. Система выполняется, если правила гарантируют отсутствие ошибок.
Эти термины легко объяснимы: при динамической типизации проверка типов происходит во время работы системы (динамически), а при статической типизации проверка выполняется над текстом статически (до выполнения).
| Термины типизированный и нетипизированный (typed/untyped) нередко используют вместо статически типизированный и динамически типизированный (statically/dynamically typed). Во избежание любых недоразумений мы будем придерживаться полных именований. |
Статическая типизация предполагает автоматическую проверку, возлагаемую, как правило, на компилятор. В итоге имеем простое определение: Определение: статически типизированный язык ОО-язык статически типизирован, если он поставляется с набором согласованных правил, проверяемых компилятором, соблюдение которых гарантирует, что выполнение системы не приведет к нарушению типов. В литературе встречается термин "сильная типизация" (strong). Он соответствует ультимативной природе определения, требующей полного отсутствия нарушения типов. Возможны и слабые (weak) формы статической типизации, при которых правила устраняют определенные нарушения, не ликвидируя их целиком. В этом смысле некоторые ОО-языки являются статически слабо типизированными. Мы будем бороться за наиболее сильную типизацию. В динамически типизированных языках, известных как нетипизированные, отсутствуют объявления типов, а к сущностям в период выполнения могут присоединяться любые значения. Статическая проверка типов в них невозможна.
Статическая типизация: как и почему
Хотя преимущества статической типизации очевидны, неплохо поговорить о них еще раз.
Своенравие полиморфизма
Не довольно ли примеров, подтверждающих практичность ковариации? Почему же кто-то рассматривает контравариантность, которая вступает в противоречие с тем, что необходимо на практике (если не принимать во внимание поведения некоторых молодых людей)? Чтобы понять это, рассмотрим проблемы, возникающие при сочетании полиморфизма и стратегии ковариантности. Придумать вредительскую схему несложно, и, возможно, вы уже создали ее сами:
s: SKIER; b: BOY; g: GIRL ... create b; create g;-- Создание объектов BOY и GIRL. s := b; -- Полиморфное присваивание. s.share (g)
Результат последнего вызова, вполне возможно приятный для юношей, - это именно то, что мы пытались не допустить с помощью переопределения типов. Вызов share ведет к тому, что объект BOY, известный как b и благодаря полиморфизму получивший псевдоним s типа SKIER, становится соседом объекта GIRL, известного под именем g. Однако вызов, хотя и противоречит правилам общежития, является вполне корректным в программном тексте, поскольку share -экспортируемый компонент в составе SKIER, а GIRL, тип аргумента g, совместим со SKIER, типом формального параметра share. Схема с параллельной иерархией столь же проста: заменим SKIER на SKIER1, вызов share - на вызов s.accommodate (gr), где gr - сущность типа GIRL_ROOM. Результат - тот же. При контравариантном решении этих проблем не возникало бы: специализация цели вызова (в нашем примере s) требовала бы обобщения аргумента. Контравариантность в результате ведет к более простой математической модели механизма: наследование - переопределение - полиморфизм. Данный факт описан в ряде теоретических статей, предлагающих эту стратегию. Аргументация не слишком убедительна, поскольку, как показывают наши примеры и другие публикации, контравариантность не имеет практического использования.
| В литературе для программистов нередко встречается призыв к методам, основанных на простых математических моделях. Однако математическая красота - всего лишь один из критериев ценности результата, - есть и другие - полезность и реалистичность. |
Поэтому, не пытаясь натянуть контравариантную одежду на ковариантное тело, следует принять ковариантную действительность и искать пути устранения нежелательного эффекта.
Типизация и связывание
Хотя как читатель этой книги вы наверняка отличите статическую типизацию от статического связывания, есть люди, которым подобное не под силу. Отчасти это может быть связано с влиянием языка Smalltalk, отстаивающего динамический подход к обеим задачам и способного сформировать неверное представление, будто они имеют одинаковое решение. (Мы же в своей книге утверждаем, что для создания надежных и гибких программ желательно объединить статическую типизацию и динамическое связывание.) Как типизация, так и связывание имеют дело с семантикой Базисной Конструкции x.f (arg), но отвечают на два разных вопроса: Типизация и связывание Вопрос о типизации: когда мы должны точно знать, что во время выполнения появится операция, соответствующая f, применимая к объекту, присоединенному к сущности x (с параметром arg)?Вопрос о связывании: когда мы должны знать, какую операцию инициирует данный вызов?
Типизация отвечает на вопрос о наличии как минимум одной операции, связывание отвечает за выбор нужной. В рамках объектного подхода: проблема, возникающая при типизации, связана с полиморфизмом: поскольку x во время выполнения может обозначать объекты нескольких различных типов, мы должны быть уверены, что операция, представляющая f, доступна в каждом из этих случаев;проблема связывания вызвана повторными объявлениями: так как класс может менять наследуемые компоненты, то могут найтись две или более операции, претендующие на то, чтобы представлять f в данном вызове.
Обе задачи могут быть решены как динамически, так и статически. В существующих языках представлены все четыре варианта решения. Ряд необъектных языков, скажем, Pascal и Ada, реализуют как статическую типизацию, так и статическое связывание. Каждая сущность представляет объекты только одного типа, заданного статически. Тем самым обеспечивается надежность решения, платой за которую является его гибкость.Smalltalk и другие ОО-языки содержат средства динамического связывания и динамической типизации. При этом предпочтение отдается гибкости в ущерб надежности языка.Отдельные необъектные языки поддерживают динамическую типизацию и статическое связывание.
Среди них - языки ассемблера и ряд языков сценариев (scripting languages).Идеи статической типизации и динамического связывания воплощены в нотации, предложенной в этой книге.
Отметим своеобразие языка C++, поддерживающего статическую типизацию, хотя и не строгую ввиду наличия приведения типов, статическое связывание (по умолчанию), динамическое связывание при явном указании виртуальных (virtual) объявлений.
Причина выбора статической типизации и динамического связывания очевидна. Первый вопрос: "Когда мы будем знать о существовании компонентов?" - предполагает статический ответ: "Чем раньше, тем лучше", что означает: во время компиляции. Второй вопрос: "Какой из компонентов использовать?" предполагает динамический ответ: "тот, который нужен", - соответствующий динамическому типу объекта, определяемому во время выполнения. Это единственно приемлемое решение, если статическое и динамическое связывание дает различные результаты.
Следующий пример иерархии наследования поможет прояснить эти понятия:
 Рис. 17.3. Виды летательных аппаратов
Рассмотрим вызов:
my_aircraft.lower_landing_gear
Вопрос о типизации: когда убедиться, что здесь будет компонент lower_landing_gear ("выпустить шасси"), применимый к объекту (для COPTER его не будет вовсе) Вопрос о связывании: какую из нескольких возможных версий выбрать.
Статическое связывание означало бы, что мы игнорируем тип присоединяемого объекта и полагаемся на объявление сущности. В итоге, имея дело с Boeing 747-400, мы вызвали бы версию, разработанную для обычных лайнеров серии 747, а не для их модификации 747-400. Динамическое связывание применяет операцию, требуемую объектом, и это правильный подход.
При статической типизации компилятор не отклонит вызов, если можно гарантировать, что при выполнении программы к сущности my_aircraft будет присоединен объект, поставляемый с компонентом, соответствующим lower_landing_gear. Базисная техника получения гарантий проста: при обязательном объявлении my_aircraft требуется, чтобы базовый класс его типа включал такой компонент.Поэтому my_aircraft не может быть объявлен как AIRCRAFT, так как последний не имеет lower_landing_gear на этом уровне; вертолеты, по крайней мере в нашем примере, выпускать шасси не умеют. Если же мы объявим сущность как PLANE, - класс, содержащий требуемый компонент, - все будет в порядке.
Динамическая типизация в стиле Smalltalk требует дождаться вызова, и в момент его выполнения проверить наличие нужного компонента. Такое поведение возможно для прототипов и экспериментальных разработок, но недопустимо для промышленных систем - в момент полета поздно спрашивать, есть ли у вас шасси.
Типизация: слагаемые успеха
Каковы механизмы реалистичной статической типизации? Все они введены в предыдущих лекциях, а потому нам остается лишь кратко о них напомнить. Их совместное перечисление показывает согласованность и мощь их объединения. Наша система типов полностью основана на понятии класса. Классами являются даже такие базовые типы, как INTEGER, а стало быть, нам не нужны особые правила описания предопределенных типов. (В этом наша нотация отличается от "гибридных" языков наподобие Object Pascal, Java и C++, где система типов старых языков сочетается с объектной технологией, основанной на классах.) Развернутые типы дают нам больше гибкости, допуская типы, чьи значения обозначают объекты, наряду с типами, чьи значения обозначают ссылки. Решающее слово в создании гибкой системы типов принадлежит наследованию и связанному с ним понятию совместимости. Тем самым преодолевается главное ограничение классических типизированных языков, к примеру, Pascal и Ada, в которых оператор x := y требует, чтобы тип x и y был одинаковым. Это правило слишком строго: оно запрещает использовать сущности, которые могут обозначать объекты взаимосвязанных типов (SAVINGS_ACCOUNT и CHECKING_ACCOUNT). При наследовании мы требуем лишь совместимости типа y с типом x, например, x имеет тип ACCOUNT, y - SAVINGS_ACCOUNT, и второй класс - наследник первого. На практике статически типизированный язык нуждается в поддержке множественного наследования. Известны принципиальные обвинения статической типизации в том, что она не дает возможность по-разному интерпретировать объекты. Так, объект DOCUMENT (документ) может передаваться по сети, а потому нуждается в наличия компонентов, связанных с типом MESSAGE (сообщение). Но эта критика верна только для языков, ограниченных единичным наследованием.
 Рис. 17.2. Множественное наследование Универсальность необходима, например, для описания гибких, но безопасных контейнерных структур данных (например class LIST [G] ...). Не будь этого механизма, статическая типизация потребовала бы объявления разных классов для списков, отличающихся типом элементов.
В ряде случаев универсальность требуется ограничить, что позволяет использовать операции, применимые лишь к сущностям родового типа. Если родовой класс SORTABLE_LIST поддерживает сортировку, он требует от сущностей типа G, где G - родовой параметр, наличия операции сравнения. Это достигается связыванием с G класса, задающего родовое ограничение, - COMPARABLE:
class SORTABLE_LIST [G -> COMPARABLE] ...
Любой фактический родовой параметр SORTABLE_LIST должен быть потомком класса COMPARABLE, имеющего необходимый компонент.
Еще один обязательный механизм - попытка присваивания - организует доступ к тем объектам, типом которых ПО не управляет. Если y - это объект базы данных или объект, полученный через сеть, то оператор x ?= y присвоит x значение y, если y имеет совместимый тип, или, если это не так, даст x значение Void.
Утверждения, связанные, как часть идеи Проектирования по Контракту, с классами и их компонентами в форме предусловий, постусловий и инвариантов класса, дают возможность описывать семантические ограничения, которые не охватываются спецификацией типа. В таких языках, как Pascal и Ada, есть типы-диапазоны, способные ограничить значения сущности, к примеру, интервалом от 10 до 20, однако, применяя их, вам не удастся добиться того, чтобы значение i являлось отрицательным, всегда вдвое превышая j. На помощь приходят инварианты классов, призванные точно отражать вводимые ограничения, какими бы сложными они не были.
Закрепленные объявления нужны для того, чтобы на практике избегать лавинного дублирования кода. Объявляя y: like x, вы получаете гарантию того, что y будет меняться вслед за любыми повторными объявлениями типа x у потомка. В отсутствие этого механизма разработчики беспрестанно занимались бы повторными объявлениями, стремясь сохранить соответствие различных типов.
Закрепленные объявления - это особый случай последнего требуемого нам языкового механизма - ковариантности, подробное обсуждение которого нам предстоит позже.
При разработке программных систем на деле необходимо еще одно свойство, присущее самой среде разработки - быстрая, возрастающая (fast incremental) перекомпиляция.Когда вы пишите или модифицируете систему, хотелось бы как можно скорее увидеть эффект изменений. При статической типизации вы должны дать компилятору время на перепроверку типов. Традиционные подпрограммы компиляции требуют повторной трансляции всей системы (и ее сборки), и этот процесс может быть мучительно долгим, особенно с переходом к системам большого масштаба. Это явление стало аргументом в пользу интерпретирующих систем, таких как ранние среды Lisp или Smalltalk, запускавшие систему практически без обработки, не выполняя проверку типов. Сейчас этот аргумент позабыт. Хороший современный компилятор определяет, как изменился код с момента последней компиляции, и обрабатывает лишь найденные изменения.
"Типизирована ли кроха"?
Наша цель - строгая статическая типизация. Именно поэтому мы и должны избегать любых лазеек в нашей "игре по правилам", по крайней мере, точно их идентифицировать, если они существуют. Самой распространенной лазейкой в статически типизированных языках является наличие преобразований, меняющих тип сущности. В C и производных от него языках их называют "приведением типа" или кастингом (cast). Запись (OTHER_TYPE) x указывает на то, что значение x воспринимается компилятором, как имеющее тип OTHER_TYPE, при соблюдении некоторых ограничениях на возможные типы. Подобные механизмы обходят ограничения проверки типов. Приведение широко распространено при программировании на языке C, включая диалект ANSI C. Даже в языке C++ приведение типов, хотя и не столь частое, остается привычным и, возможно, необходимым делом. Придерживаться правил статической типизации не так просто, если в любой момент их можно обойти путем приведения. Далее будем полагать, что система типов является строгой и не допускает приведения типа.
| Возможно, вы заметили, что попытка присваивания - неотъемлемый компонент реалистичной системы типов - напоминает приведение. Однако есть существенное отличие: попытка присваивания выполняет проверку, действительно ли текущий тип соответствует заданному типу, - это безопасно, а иногда и необходимо. |
Типовые переменные
Ряд авторов, среди которых Ким Брюс (Kim Bruce), Дэвид Шенг (David Shang) и Тони Саймонс (Tony Simons), предложили решение на основе типовых переменных (type variables), значениями которых являются типы. Их идея проста: взамен ковариантных переопределений разрешить объявление типов, использующее типовые переменные;расширить правила совместимости типов для управления такими переменными;считать язык (в остальном) безвариантным;обеспечить возможность присваивания типовым переменным в качестве значений типы языка.
Подробное изложение этих идей читатели могут найти в ряде статей по данной тематике, а также в публикациях Карделли (Cardelli), Кастаньи (Castagna), Вебера (Weber) и др. Начать изучение вопроса можно с источников, указанных в библиографических заметках к этой лекции. Мы же не будем заниматься этой проблемой, и вот почему. Надлежаще реализованный механизм типовых переменных относится к категории, позволяющей использовать тип без полной его спецификации. Эта же категория включает универсальность и закрепление объявлений. Этот механизм мог бы заменить другие механизмы этой категории. Вначале это можно истолковать в пользу типовых переменных, но результат может оказаться плачевным, так как не ясно, сможет ли этот всеобъемлющий механизм справиться со всеми задачами с той легкостью и простотой, которая присуща универсальности и закреплению типов.Предположим, что разработан механизм типовых переменных, способный преодолеть проблемы объединения ковариантности и полиморфизма (все еще игнорируя проблему скрытия потомком). Тогда от разработчика классов потребуется незаурядная интуиция для того, чтобы заранее решить, какие из компонентов будут доступны для переопределения типов в порожденных классах, а какие - нет. Ниже мы обсудим эту проблему, имеющую место в практике создания программ и, увы, ставящую под сомнение применимость многих теоретических схем.
Это заставляет нас вернуться к уже рассмотренным механизмам: ограниченной и неограниченной универсальности, закреплению типов и, конечно, наследованию.
Константы базовых типов
Атрибуты-константы
Как и все сущности, символические константы должны быть определены внутри класса. Будем рассматривать константы как атрибуты с фиксированным значением, одинаковым для всех экземпляров класса. Синтаксически вновь используем служебное слово is, применяемое при описании методов, только здесь за ним будет следовать не алгоритм, а значение нужного типа. Вот примеры определения констант базовых типов INTEGER, BOOLEAN, REAL и CHARACTER:
Zero: INTEGER is 0 Ok: BOOLEAN is True Pi: REAL is 3.1415926524 Backslash: CHARACTER is '\'
Как видно из этих примеров, имена атрибутов-констант рекомендуется начинать с заглавной буквы, за которой следуют только строчные символы. Потомки не могут переопределять значения атрибутов-констант. Как и другие атрибуты, класс может экспортировать константы или скрывать. Так, если C - класс, экспортирующий выше объявленные константы, а у клиента класса к сущности x присоединен объект типа C, то выражение x.Backslash обозначает символ '\'. В отличие от атрибутов-переменных, константы не занимают в памяти места. Их введение не связано с издержками в период выполнения, а потому не страшно, если их в классе достаточно много.
Библиографические замечания
Проблемы перечислимых типов были изучены в работах [Welsh 1977] и [Moffat 1981]. Некоторые приемы, рассмотренные в этой лекции, впервые представлены в [M 1988b].
Инициализация: подходы языков программирования
Проблема, решаемая в этой лекции, - это общая проблема языков программирования: как работать с глобальными константами и разделяемыми объектами, в частности, как выполнять их инициализацию в библиотеках компонентов? Для библиотек более общей задачей является включение в каждый компонент возможности определения того, что его вызов является первым запросом к службам библиотеки, что и позволяет определить, была ли сделана инициализация. Последнюю задачу можно свести к более простой: как разделять переменные булевого типа и согласованно их инициализировать? Свяжем с глобальным объектом p или группой глобальных объектов, нуждающихся в одновременной инициализации, булеву переменную, скажем, ready, истинную, если и только если инициализация проведена. Тогда любому обращению к p нетрудно предпослать инструкцию
if not ready then "Создать или вычислить p" ready := True end
Теперь проблема инициализации касается только ready - еще одного глобального объекта, который необходимо инициализировать значением False. Как же решается эта задача в языках программирования? С момента их появления в этом плане почти ничего не менялось. В блочно-структурированных языках, среди которых Algol и Pascal, типичным было описание ready как глобальной переменной на верхнем синтаксическом уровне; ее инициализация производилась в главной программе. Но такая техника непригодна для библиотек автономных модулей. В языке Fortran, позволяющем независимую компиляцию подпрограмм (что придает им известную автономность), можно поместить все глобальные объекты в общий блок (common block), идентифицируемый по имени. Всякая подпрограмма, обращающаяся к общему блоку, должна содержать такую директиву:
COMMON /common_block_name/ data_item_names
При этом возникают две проблемы: Две совокупности подпрограмм могут использовать одноименные общие блоки, что приведет к конфликту, если одной из программ понадобится как первый, так и второй блок. Смена имени блока вызовет трудности у других программ.Как инициализировать сущности общего блока, такие как ready? Из-за отсутствия инициализации по умолчанию, ее нужно выполнять в особом модуле, называемом блоком данных (block data unit).
В Fortran 77 допускаются именованные модули, что позволяет разработчикам объединять глобальные данные разных общих блоков. При этом есть немалый риск несогласованности инициализации и объявления глобальных объектов.
Принцип решения этой задачи в языке C по сути не отличается от решения Fortran 77. Признак ready нужно описать как "внешнюю" переменную, общую для нескольких "файлов" (единиц компиляции языка). Объявление переменной с указанием ее значения может содержать только один файл, остальные, используя директиву extern, подобную COMMON в Fortran 77, лишь заявляют о необходимости доступа к переменной. Обычно такие определения объединяют в "заголовочные" (header) .h-файлы, которые соответствуют блоку данных в Fortran. При этом наблюдаются те же проблемы, отчасти решаемые утилитами make, призванными отслеживать возникающие зависимости.
Решение может быть близко к тому, что предлагают модульные языки наподобие Ada или Modula 2, подпрограммы которых можно объединять в модули более высокого уровня. В Ada эти модули называют "пакетами" (package). Если все подпрограммы, использующие группу взаимосвязанных глобальных объектов, собраны в одном пакете, то соответствующие признаки ready можно описать в этом же пакете и здесь же выполнить их инициализацию. Однако этот подход (применимый также в C и Fortran 77) не решает проблему инициализации автономных библиотек. Еще более деликатный вопрос связан с тем, как поступать с глобальными объектами, разделяемых подпрограммами разных независимых модулей. Языки Ada и Modula не дают простого ответа на этот вопрос.
Механизм "однократных" методов, сохраняя независимость классов, допускает контекстно-зависимую инициализацию.
Использование констант
Вот пример, показывающий, как клиент может применять константы, определенные в классе:
class FILE feature error_code: INTEGER; -- Атрибут-переменная Ok: INTEGER is 0 Open_error: INTEGER is 1 ... open (file_name: STRING) is -- Открыть файл с именем file_name -- и связать его с текущим файловым объектом do error_code := Ok ... if "Что-то не так" then error_code := Open_error end end ... Прочие компоненты ... end
Клиент можем вызвать метод open и проверить успешность операции:
f: FILE; ... f.open if f.error_code = f.Open_error then "Принять меры" else ... end
Нередко нужны и наборы констант, не связанных с конкретным объектом. Их, как и раньше, можно объединить в класс, выступающий в роли родителя всех классов, которым необходимы константы. В этом случае можно не создавать экземпляр класса:
class EDITOR_CONSTANTS feature Insert: CHARACTER is 'i' Delete: CHARACTER is 'd'; -- и т.д. ... end class SOME_CLASS_FOR_THE_EDITOR inherit EDITOR_CONSTANTS ...Другие возможные родители ... feature ... ... подпрограммы класса имеют доступ к константам, описанным в EDITOR_CONSTANTS ... end
Класс, подобный EDITOR_CONSTANTS, служит лишь для размещения в нем группы констант, и его роль как "реализации АТД" (а это - наше рабочее определение класса) не столь очевидна, как в предыдущих примерах. Теоретическое обоснование введения таких классов мы обсудим позднее. Представленная схема работоспособна только при множественном наследовании, поскольку классу SOME_CLASS_FOR_THE_EDITOR могут потребоваться и другие родители.
Ключевые концепции
При любом подходе к конструированию ПО возникает проблема работы с глобальными объектами, совместно используемыми компонентами разных модулей, и инициализируемыми в период выполнения, когда какой-либо из компонентов первым к ним обратился.Константы могут быть манифестными и символическими. Первые задаются значениями, синтаксис которых определен так, что значение одновременно описывает и тип константы, а потому является манифестом. Символические константы представлены именами, а их значение указывается в определении константы.Манифестные константы базовых типов можно объявлять как константные атрибуты, не требующие памяти в объектах.За исключением строк, типы, определенные пользователем, не имеют манифестных констант, нарушающих принципы Скрытия информации и расширяемости.Однократная подпрограмма синтаксически отличается от обычной лишь ключевым словом once, заменяющим do. Она полностью выполняется лишь один раз (при первом вызове). При последующих вызовах однократной функции возвращается результат, вычисленный при первом вызове, последующие вызовы процедуры не имеют эффекта и могут быть проигнорированы.Разделяемые объекты могут быть реализованы как однократные функции. Можно использовать инвариант для указания их константности.Однократные процедуры используются там, где операции должны быть выполнены только однажды во время выполнения системы, чаще всего, это связано с инициализацией глобальных параметров системы.Тип однократной функции не может быть закрепленным или родовым типом.Константы строковых типов внутренне интерпретируются как однократные функции, однако, внешне они выглядят как манифестные константы, значения которых заключается в двойные кавычки.Перечислимые типы в стиле языка Pascal не соответствуют объектной методологии. Для представления объектов с несколькими возможными вариантами значений используются символические unique константы. Инициализация значений таких констант выполняется компилятором.
Константы базовых типов
Начнем с формы записи констант. Правило стиля - принцип символических констант - гласит, что обращение к конкретному значению (числу, символу или строке) почти всегда должно быть косвенным. Должно существовать определение константы, задающее имя, играющее роль символической константы (symbolic constant), и связанное с ним значение - константа, называемаю манифестной (manifest constant). Далее в алгоритме следует использовать символическую константу. Тому есть два объяснения. Читабельность: читающему текст легче понять смысл US_states_count, чем числа 50;Расширяемость: символическую константу легко обновить, исправив лишь ее определение.
Принцип допускает применение манифестных или, как часто говорят, неименованных констант в качестве "начальных" элементов разнообразных операций, как в случае с циклом from i = 1 until i > n (Но n, конечно, должно быть символической константой). Итак, нам нужен простой и ясный способ определения символических констант.
Константы пользовательских классов
Символические константы полезны не только при работе с предопределенными типами, такими как INTEGER. Они нужны и тогда, когда их значениями являются объекты классов, созданных разработчиком. В этом случае решение не столь очевидно.
Константы с манифестом для этого непригодны
Первым примером служит класс, описывающий комплексное число:
class COMPLEX creation make_cartesian, make_polar feature x, y: REAL -- Действительная и мнимая часть make_cartesian (a, b: REAL) is -- Установить действительную часть a, мнимую - b. do x := a; y := b end ... Прочие методы (помимо x и y, других атрибутов нет) ... end
Пусть мы хотим определить константу - комплексное число i, действительная часть которого равна 0, а мнимая 1. Первое, что приходит в голову, - это буквальная константа вида
i: COMPLEX is "Выражение, определяющее комплексное число (0, 1)"
Как записать выражение после is? Для пользовательских типов данных никакой формы записи неименованных констант не существует. Можно представить себе вариант нотации на основе атрибутов класса:
i: COMPLEX is COMPLEX (0, 1)
Но этот подход, хотя и реализован в некоторых ОО-языках, противоречит принципу модульности - основе объектной методологии. Приняв этот подход, мы согласились бы с тем, что клиенты COMPLEX должны описывать константы в терминах реализации класса, а это нарушает принцип Скрытия информации. Кроме того, как гарантировать соответствие неименованной константы инварианту класса, если таковой имеется? Последнее замечание позволяет найти правильное решение. Мы уже говорили о том, что в момент рождения объекта ответственность за соблюдение инварианта возлагается на процедуру создания. Создание объекта иным путем (помимо безопасного клонирования clone) ведет к ситуациям ошибки. Поэтому мы должны найти путь, основанный на обычном методе создания объектов класса.
Константы строковых типов
В начале этой лекции были введены символьные константы, значением которых является символ. Например:
Backslash: CHARACTER is '\'
Однако нередко классам требуются строковые константы, использующие, как обычно, для записи константы двойные кавычки: [S1]
Message: STRING is "Syntax error" -- "Синтаксическая ошибка"
Вспомните, что STRING - не простой тип. Это - библиотечный класс, поэтому значение, связанное с сущностью Message во время работы программы, является объектом, то есть экземпляром STRING. Как вы могли догадаться, такое описание является сокращенной формой объявления однократной функции вида: [S2]
Message: STRING is -- Строка из 12 символов once create Result.make (12) Result.put ('S', 1) Result.put ('y', 2) ... Result.put ('r', 12) end
Строковые значения являются не константами, а ссылками на разделяемые объекты. Любой класс, имеющий доступ к Message, может изменить значение одного или нескольких символов строки. Строковые константы можно использовать и как выражения при передаче параметров или присваивании:
Message_window.display ("НАЖМИТЕ ЛЕВУЮ КНОПКУ ДЛЯ ВЫХОДА") greeting := "Привет!"
Однократные функции с результатами базовых типов
Еще одним применением однократных функций является моделирование глобальных значений - "системных параметров", которые обычно нужны сразу нескольким классам, но не меняются в ходе программной сессии. Их начальная установка требует информации от пользователя или операционной среды. Например: компонентам низкоуровневой системы может понадобиться объем доступной им памяти, выделенный средой при инициализации;система эмуляции терминала может начать работу с отправки среде запроса о числе терминальных портов. Затем эти данные будут использоваться в ряде модулей приложения.
Такие глобальные данные аналогичны совместно используемым объектам, хотя обычно они являются значениями базовых типов. Схема их реализации однократными функциями такова:
Const_value: T is -- Однократно вычисляемый системный параметр local envir_param: T ' -- Любой тип (T и не только) once "Получить envir_param из операционной среды" Result := "Значение, рассчитанное на основе envir_param" end
Такие однократные функции описывают динамически вычисляемые константы. Предположим, данное объявление находится в классе ENVIR. Класс, которому надо воспользоваться константой Const_value, получит ее значение, указав ENVIR в списке своих родителей. В отличие от классического подхода к расчету константы, здесь не нужна процедура инициализации системы, вычисляющая все глобальные параметры системы, как это делается в классическом подходе. Как отмечалось в начальных лекциях, такая процедура должна была бы иметь доступ к внутренним деталям многих модулей, что нарушало бы ряд критериев и принципов модульности: декомпозиции, скрытия информации и других. Наоборот, классы, подобные ENVIR, могут разрабатываться как согласованные модули, каждый задающий множество логически связанных глобальных значений. Процесс вычисления такого параметра, к примеру, Const_value, инициирует первый из компонентов, который запросит этот параметр при выполнении системы. Хотя Const_value является функцией, использующие
его компоненты могут полагать, что имеют дело с константным атрибутом. Как уже говорилось, ни один модуль не имеет больше прав на разделяемые данные, чем остальные. Это особенно справедливо для только что рассмотренных случаев. Если расчет значения способен инициировать любой модуль, нет смысла и говорить о том, будто один из них выступает в роли владельца. Такое положение дел и отражает модульная структура системы.
Однократные функции, закрепление и универсальность
В этом разделе мы обсудим конкретную техническую проблему, поэтому при первом чтении книги его можно пропустить. Однократные функции, тип которых не является встроенным, вносят потенциальную несовместимость с механизмом закрепления типов и универсальностью. Начнем с универсальности. Пусть в родовом классе EXAMPLE [G] есть однократная функция, чей тип родовой параметр:
f: G is once ... end
Рассмотрим пример ее использования:
character_example: EXAMPLE [CHARACTER] ... print (character_example.f)
Пока все в порядке. Но если попытаться получить константу с другим родовым параметром:
integer_example: EXAMPLE [INTEGER] ... print (integer_example.f + 1)
В последней инструкции мы складываем два числа. Первое значение, результат вызова f, к сожалению, уже найдено, поскольку f - однократная функция, причем символьного, а не числового типа. Сложение окажется недопустимым. Проблема заключается в попытке разделения значения разными формами родового порождения, ожидающими значения, тип которого определяется родовым параметром. Аналогичная ситуация возникает и с закреплением типов. Представим себе класс B, добавляющий еще один атрибут к компонентам своего родителя A:
class B inherit A feature attribute_of_B: INTEGER end
Пусть A имеет однократную функцию f, возвращающую результат закрепленного типа:
f: like Current is once create Resultl make end
и пусть первый вызов функции f имеет вид:
a2 := a1.f
где a1 и a2 имеют тип. Вычисление f создаст экземпляр A и присоединит его к сущности a2. Все прекрасно. Но предположим, далее следует:
b2 := b1.f
где b1 и b2 имеют тип B. Не будь f однократной функцией, никакой проблемы бы не возникло. Вызов f породил бы экземпляр класса B и вернул его в качестве результата. Но функция является однократной, а ее результат был уже найден при первом вызове. И это - экземпляр A, но не B. Поэтому инструкция вида:
print (b2.attribute_of_B)
попытается обратиться к несуществующему полю объекта A. Проблема в том, что закрепление вызывает неявное переопределение типов.
Если бы f была переопределена явно, с применением в классе B объявления
f: B is once create Resultl make end
при условии, что исходный вариант f в классе A возвращает результат типа A (а не like Current), все было бы замечательно: экземпляры A обращались бы к версии f для A, экземпляры B - к версии f для B. Однако закрепление типов было введено как раз для того, чтобы избавить нас от таких явных переопределений.
Эти примеры - свидетельства несовместимости семантики однократных функций (с процедурами все прекрасно) с результатами применения закрепленных типов и формальных родовых параметров. Одно из решений проблемы в том, чтобы трактовать такие случаи как явные переопределения, приняв за правило то, что результат однократной функции совместно используется лишь в пределах одной формы родовой порождения, а при закреплении результата - лишь среди экземпляров своего класса. Недостатком такого подхода, впрочем, является, что он не отвечает интуитивной семантике однократных функций, которые, с позиции клиента, должны быть эквивалентны разделяемым атрибутам. Во избежание недоразумений и возможных ошибок можно пойти на более суровые меры, наложив полный запрет на сценарии подобного рода:
Правило для однократной функции
Тип результата однократной функции не может быть закреплен и не может включать любой родовой параметр.
Однократные функции
Пусть константный объект - это функция. Например, i можно (в иллюстративных целях) описать внутри самого класса COMPLEX как
i: COMPLEX is -- Комплексное число, re= 0, а im= 1 do create Result.make_cartesian (0, 1) end
Это почти решает нашу задачу, поскольку функция всегда возвратит ссылку на объект нужного вида. Коль скоро мы полагаемся на обычную процедуру создания объекта, условие инварианта будет соблюдено, - как следствие, получим корректный объект. Однако результат не соответствует потребностям: каждое обращение клиента к i порождает новый объект, идентичный всем остальным, а это - трата времени и пространства. Поэтому необходим особый вид функции, выполняемой только при первом вызове. Назовем такую функцию однократной (once function). В целом она синтаксически аналогична обычной функции и отличается лишь служебным словом once, начинающего вместо do ее тело:
i: COMPLEX is -- Комплексное число, re= 0, im= 1 once create Result.make_cartesian (0, 1) end
При первом вызове однократной функции она создает объект, который представляет желаемое комплексное число, и возвращает на него ссылку. Каждый последующий вызов приведет к немедленному завершению функции и возврату результата, вычисленного в первый раз. Что касается эффективности, то обращение к i во второй, третий и т.д. раз должно отнимать времени ненамного больше, чем операция доступа к атрибуту. Результат, найденный при первом вызове однократной функции, может использоваться во всех экземплярах класса, включая экземпляры потомков, где эта функция не переопределена. Переопределение однократных функций как обычных (и обычных как однократных) допускается без всяких ограничений. Так, если COMPLEX1, порожденный от класса COMPLEX, заново определяет i, то обращение к i в экземпляре COMPLEX1 означает вызов переопределенного варианта, а обращение к i в экземпляре самого COMPLEX или его потомка, отличного от COMPLEX1, означает вызов однократной функции, то есть значения, найденного ею при первом вызове.
Однократные процедуры
Функция close должна вызываться только один раз. Контроль над количеством ее вызовов рекомендуется возложить на глобальную переменную приложения.
Из руководства к коммерческой библиотеке функций языка C Механизм однократных функций интересен и при работе с процедурами. Однократные процедуры могут применяться для инициализации общесистемного свойства, когда заранее неизвестно, какому компоненту это свойство понадобится первому. Примером может стать графическая библиотека, в которой любая функция, вызываемая первой, должна предварительно провести настройку, учитывающую параметры дисплея. Автор библиотеки мог, конечно, потребовать, чтобы каждый клиент начинал работу с библиотекой с вызова функции настройки. Этот нюанс, в сущности, не решает проблему - чтобы справиться с ошибками, любая функция должна обнаруживать, не запущена ли она без настройки. Но если функции такие "умные", то зачем что-то требовать от клиента, когда можно нужную функцию настройки вызывать самостоятельно. Однократные процедуры решают эту проблему лучше:
check_setup is -- Настроить терминал, если это еще не сделано. once terminal_setup -- Фактические действия по настройке. end
Теперь каждая экранная функция должна начинаться с обращения к check_setup, первый вызов которой приведет к настройке параметров, а остальные не сделают ничего. Заметьте, что check_setup не должна экспортироваться клиентам. Однократная процедура - это важный прием, упрощающий применение библиотек и других программных пакетов.
и функции могут иметь параметры,
Однократные процедуры и функции могут иметь параметры, необходимые, по определению, лишь при первом вызове.
Применение однократных подпрограмм
Понятие однократных подпрограмм расширяет круг задач, позволяя включить разделяемые объекты, глобальные системные параметры, инициализацию общих свойств.
Разделяемые объекты
Для ссылочных типов, таких как COMPLEX, наш механизм фактически предлагает константные ссылки, а не обязательно константные объекты. Он гарантирует, что тело функции выполняется при первом обращении, возвращая результат, который будет также возвращаться при последующих вызовах, уже не требуя никаких действий. Если функция возвращает значение ссылочного типа, то в ее теле, как правило, есть инструкция создания объекта, и любой вызов приведет к получению ссылки на этот объект. Хотя создание объекта не повторяется, ничто не мешает изменить сам объект, воспользовавшись полученной ссылкой. В итоге мы имеем разделяемый объект, не являющийся константным. Пример такого объекта - окно вывода информации об ошибках. Пусть все компоненты интерактивной системы могут направлять в это окно свои сообщения:
Message_window.put_text ("Соответствующее сообщение об ошибке")
где Message_window имеет тип WINDOW, чей класс описан следующим образом:
class WINDOW creation make feature make (...) is -- Создать окно; аргументы задают размер и положение. do ... end text: STRING -- Отображаемый в окне текст put_text (s: STRING) is -- Сделать s отобржаемым в окне текстом. do text := s end ... Прочие компоненты ... end -- класс WINDOW
Ясно, что объект Message_window должен быть одним для всех компонентов системы. Это достигается описанием соответствующего компонента как однократной функции:
Message_window: WINDOW is -- Окно для вывода сообщений об ошибках once create Result.make ("... Аргументы размера и положения ...") end
В данном случае окно сообщений должно находиться в совместном пользовании всех сторон, но не являться константным объектом. Каждый вызов put_text будет изменять объект, помещая в него новую строку текста. Лучшим местом описания Message_window станет класс, от которого порождены все компоненты системы, нуждающиеся в окне выдачи сообщений.
| Создав разделяемый объект, играющий роль константы, (например, i), вы можете запретить вызовы i.some_procedure, способные его изменять. Для этого, например, в классе COMPLEX достаточно ввести в инвариант класса предложения i.x = 0 и i.y = 1. |
Строковые константы
Строковые константы (а точнее, разделяемые строковые объекты) объявляются в языках программирования в манифестной форме с использованием двойных кавычек. Это находит отражение в правилах языка, и как следствие любой компилятор предполагает присутствие в библиотеке класса STRING. Это - своего рода компромисс между "полярными" решениями. STRING рассматривается как встроенный тип, каким он является во многих языках программирования. Это означает введение в язык операций над строками: конкатенации, сравнения, выделения подстроки и других, что усложняет язык. Преимуществом введения такого класса является возможность снабдить его операции точными спецификациями, благодаря утверждениям, и способность порождать от него другие классы.STRING рассматривается как обычный класс, создаваемый разработчиком. Тогда задавать его константы в манифестной форме [S1] уже нельзя, от разработчиков потребуется соблюдение формата [S2]. Кроме того, данный подход препятствует оптимизации компилятором таких операций, как прямой доступ к символам строки.
Поэтому строки STRING, как и массивы ARRAY, ведут "двойную жизнь", принимая вид предопределенного типа при задании констант и оптимизации кода, и становясь классом, когда речь заходит о гибкости и универсальности.
У18.1 Эмуляция перечислимых типов однократными функциями
Покажите, что при отсутствии unique-типов перечислимый тип языка Pascal
type ERROR = (Normal, Open_error, Read_error)
может быть представлен классом с однократной функцией для каждого значения типа.
У18.2 Однократные функции для эмуляции unique-значений
Покажите, что в языке без поддержки unique-объявлений результат, аналогичный
value: INTEGER is unique
можно получить, воспользовавшись объявлением вида
value: INTEGER is once...end
где вам необходимо написать тело однократной функции и все, что может еще понадобиться.
У18.3 Однократные функции в родовых классах
Приведите пример однократной функции, чей результат включает родовой параметр, и, если он не корректен, порождает ошибку времени выполнения.
У18.4 Однократные атрибуты?
Исследуйте полезность понятия "однократного атрибута", полученного по образцу однократной функции? Будет ли такой атрибут общим для всех экземпляров класса? Как инициализировать однократные атрибуты? Являются ли они избыточными при наличии однократных функций без аргументов? Если нет, объясните, когда использовать тот или иной механизм. Предложите хороший синтаксис объявления однократных атрибутов. |
|  |
Unique-значения и перечислимые типы
Pascal и производные от него языки допускают описание переменной вида
code: ERROR
где ERROR - это "перечислимый тип":
type ERROR = (Normal, Open_error, Read_error)
Переменная code может принимать только значения типа ERROR. Мы уже видели, как добиться того же самого в ОО-нотации: при выполнении кода результат будет почти идентичен, поскольку Pascal-компиляторы традиционно реализуют значения перечислимого типа как целые числа. Введение объявления unique не порождает нового типа. Понятие перечислимых типов, кажется, трудно совместить с объектным подходом. Все наши типы основаны на классах, характеризующих реально осуществимые операции и их свойства. Перечислимые типы не обладают такими характеристиками, а представляют обычные множества чисел. Проблемы с этими типами данных возникают и в необъектных языках. Статус символических имен не вполне ясен. Могут ли два перечислимых типа иметь общие символические имена (скажем, Orange в составе типов FRUIT и COLOR)? Можно ли их экспортировать как переменные и распространять на них те же правила видимости?Значения перечислимых типов трудно получать и передавать программам, написанным на других языках, к примеру, C и Fortran, не поддерживающих такое понятие. В тоже время значения, описанные как unique, - это обычные числа, работа с которыми не вызывает никаких проблем.Перечислимые типы данных могут требовать специальных операторов. Так, можно представить себе оператор next, возвращающий следующее значение и неопределенный для последнего элемента перечисления. Помимо него потребуется оператор, сопоставляющий элементу целое значение (индекс). В итоге синтаксическое и семантическое усложнение языка кажется непропорциональным вкладу этого механизма.
Объявления перечислимых типов в Pascal и Ada обычно принимают вид:
type FIGURE_SORT = (Circle, Rectangle, Square, ...)
и используются совместно с вариантными полями записей:
FIGURE = record perimeter: INTEGER; ... Другие атрибуты, общие для фигур всех типов ... case fs: FIGURE_SORT of Circle: (radius: REAL; center: POINT); Rectangle:...
Специальные атрибуты прямоугольника ...; ... end end
Этот механизм позволяет организовать разбор случаев в операторе выбора case:
procedure rotate (f: FIGURE) begin case f of Circle:... Специальные операции поворота окружности ...; Rectangle:...; ...
Мы уже познакомились с лучшим способом решения этой проблемы, сохраняющим расширяемость при появлении новых вариантов, - достаточно определить различные версии процедур, подобных rotate для каждого нового варианта, представленного классом.
Когда это наиболее важное применение перечислимых типов исчезло, все, что осталось необходимым в некоторых случаях, - это выбор целочисленных кодов для фиксированного множества возможных значений. Определив их как обычные целые, мы избежим многих семантических неопределенностей, связанных с перечислимыми типами, например, нет ничего необычного в выражении Circle +1, если известно, что Circle типа integer. Введение unique-значения позволяет обойти единственное неудобство, связанное с необходимостью инициализации значений, позволяя выполнять ее автоматически.
Unique-значения
Иногда при разработке программ возникает потребность в сущности, принимающей лишь несколько значений, характеризующих возможные ситуации. Так, операция чтения может вернуть код результата, значениями которого будут признаки успешной операции, ошибки при открытии и ошибки при считывании. Простым решением проблемы было бы применение целочисленного атрибута:
code: INTEGER
и набора символьных констант
[U1] Successful: INTEGER is 1 Open_error: INTEGER is 2 Read_error: INTEGER is 3
которые позволяют записывать условные инструкции вида
[U2] if code = Successful then ...
или инструкции выбора
[U3] inspect code when Successful then ... when ... end
Но такой перебор значений констант утомляет. Следующий вариант записи действует так же, как [U1]:
[U4] Successful, Open_error, Read_error: INTEGER is unique
Спецификатор unique, записанный вместо буквального значения в объявлении атрибута-константы целого типа, указывает на то, что это значение выбирает компилятор, а не сам разработчик. При этом условная инструкция [U2] и оператор выбора [U3] по-прежнему остаются в силе. Каждое unique-значение в теле класса положительно и отличается от других. Если, как в случае [U4], константы будут описаны вместе, то их значения образуют последовательность. Чтобы ограничить значение code этими тремя константами, в инвариант класса можно включить условие
code >= Successful; code <= Read_error
Располагая подобным инвариантом, производные классы, обладающие правом специализации инварианта, но не его расширением, могут сузить, но не расширить перечень возможных значений code, сведя его, скажем, всего к двум константам. Значения, заданные как unique, следует использовать только для представления фиксированного набора возможных значений. Если допустить его пополнение, то это приведет к необходимости внесения изменений в тексты инструкций, подобных [U3]. В общем случае для классификации не рекомендуется использовать unique-значения, так как ОО-методология располагает лучшими приемами решения этой задачи.
Данный выше пример является образцом правильного обращения с описанным механизмом. Правильными можно считать и объявления цветов семафора: green, yellow, red: INTEGER is unique; нот: do, re, mi, ...: INTEGER is unique. Объявление savings, checking, money_market: INTEGER is unique возможно будет неверным, поскольку различные финансовые инструменты, список которых здесь приведен, имеют различные свойства или допускают различную реализацию. Более удачным решением в этом случае, пожалуй, станут механизмы наследования и переопределения.
Объединим сказанное в форме правила:
Принцип дискриминации
Используйте unique для описания фиксированного набора возможных альтернатив. Используйте наследование для классификации абстракций с изменяющимися свойствами.
Хотя объявление unique-значений напоминает определение перечислимых типов (enumerated type) языков Pascal и Ada, оно не вводит новые типы, а только целочисленные значения. Дальнейшее обсуждение позволит объяснить разницу подходов.
Программирование: Языки - Технологии - Разработка
|