Следствия красоты базисных идей:
Вопросам наследования будут посвящены еще две лекции: обзор проблем типизации представлен в лекции 17, а подробное обсуждение методологии наследования - в лекции 6 курса "Основы
Большинство разделов этой лекции строится по единому принципу: экзаменуются следствия идей предыдущих двух лекций, обнаруживаются проблемы, они подробно анализируются, предлагается обоснованное решение. Ключевым является шаг анализа - как только проблема становится ясной, зачастую решение ее находится сразу же.
Обладая изрядной мощью, наследование может быть и опасным. Не будь механизма утверждений, создатели классов могли бы весьма "вероломно" пользоваться повторными объявлениями и динамическим связыванием для изменения семантики операций без возможности контроля со стороны клиента. Утверждения способны на большее: они дают нам более глубокое понимание природы наследования. Не будет преувеличением сказать, что лишь понимание принципов Проектирования по Контракту позволяет в полной мере постичь сущность концепции наследования.
Вкратце мы уже очертили основные правила, управляющие взаимосвязью наследования и утверждений: все утверждения (предусловие и постусловия подпрограмм, инварианты классов), заданные в классах-родителях, остаются в силе и для их потомков. В этом разделе мы уточним эти правила и используем полученные результаты, чтобы дать новый взгляд на наследование как на субподряды (subcontracts).
С правилом об инвариантах класса мы встречались и прежде:
Правило родительских инвариантов
Инварианты всех родителей применимы и к самому классу.
Инварианты родителей добавляются к классу. Инварианты соединяются логической операцией and then. (Если у класса нет явного инварианта, то инвариант True играет эту роль.) По индукции в классе действуют инварианты всех его предков, как прямых, так и косвенных.
Как следствие, выписывать инварианты родителей в инварианте потомка еще раз не нужно (хотя семантически такая избыточность не вредит: a and then a есть то же самое, что a ).
Полностью восстановленный инвариант класса можно найти в плоской и краткой плоской форме последнего (см. лекцию 15).
В случае с предусловиями и постусловиями ситуация чуть сложнее. Общая идея, как отмечалось, состоит в том, что любое повторное объявление должно удовлетворять утверждениям оригинальной подпрограммы. Это особенно важно, если подпрограмма отложена: без такого ограничения на будущую реализацию, задание предусловие и постусловий для отложенных подпрограмм было бы бесполезным или, хуже того, привело бы к нежелательному результату. Те же требования к предусловию и постусловию остаются и при переопределении эффективных подпрограмм.
Анализируя механизмы повторного объявления, полиморфизма и динамического связывания, можно дать точную формулировку искомого правила. Но для начала представим типичный случай.
Рассмотрим класс и его подпрограммы, имеющие как предусловие, так и постусловие:
(рис 16.1) Подпрограмма, клиент и контракт
a1: A ... a1.r
Для простоты мы проигнорируем все аргументы, которые может требовать r, и положим, что r является процедурой, хотя наши рассуждения в равной мере применимы и к функциям.
Вызов будет корректен лишь тогда, когда он удовлетворяет предусловию. Гарантировать, что C соблюдает свою часть контракта, можно, к примеру, предварив вызов проверкой предусловия, написав вместо a1.r конструкцию:
(Как отмечалось при обсуждении утверждений, не всегда требуется проверка: достаточно, с помощью if или без него, гарантировать выполнение условия a перед вызовом r. Для простоты будем использовать if -форму, игнорируя предложение else.)
Обеспечив соблюдение предусловия, клиент C рассчитывает на выполнение постусловия $$a1.\beta$$ при возврате из r.
Все это является основой Проектирования по Контракту: в момент вызова подпрограммы клиент должен обеспечить соблюдение предусловия, а в ответ при возврате из подпрограммы он полагается на выполнение постусловия.
Что происходит, когда вводится наследование?
(рис 16.2) Подпрограмма, клиент, контракт и потомокПусть новый класс A' порожден от A и содержит повторное объявление r. Как он может, если вообще может, заменить прежнее предусловие $$\alpha$$ новым $$\gamma,$$ а прежнее постусловие $$\beta$$ - новым $$\delta?$$
Чтобы найти ответ, рассмотрим обязательства клиента. В вызове 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 может добросовестно выполнять свою часть контракта, и все же в результате он будет обманут. Например,
если a1 полиморфно присоединена к объекту типа A', инструкция вызовет подпрограмму, ожидающую выполнения $$\gamma$$ и гарантирующую выполнение $$\delta,$$ в то время как клиент получил указание соблюдать $$\alpha$$ и ожидать выполнения $$\beta.$$ Налицо возможное расхождение во взглядах клиента и поставщика на контракт.
Чтобы понять, как удовлетворить клиентов, мы должны сыграть роль адвокатов дьявола и на секунду представить себе, как их обмануть. Так поступает опытный криминалист, разгадывая преступление. Как мог бы поступить поставщик, желающий ввести в заблуждение своего честного клиента C, гарантирующего при вызове $$\alpha$$ и ожидающего выполнения $$\beta?$$ Есть два пути:
| Вспомните, что мы неоднократно говорили при обсуждении Проектирования по Контракту: усиление предусловия облегчает задачу поставщика ("клиент чаще не прав"), иллюстрацией чего служит крайний случай - предусловие false (когда "клиент всегда не прав"). |
Как уже было сказано, утверждение A называется более сильным, чем B, если A логически влечет B, но отличается от него: например, x >= 5 сильнее, чем x >= 0. Если утверждение A сильнее утверждения B, говорят еще, что утверждение B слабее утверждения A.
Теперь нам понятно, как обманывать. Но как же быть честным? Объявляя подпрограмму повторно, мы можем сохранить ее исходные утверждения, но также мы вправе:
Первый подход символизирует щедрость и великодушие: мы допускаем большее число случаев, чем изначально. Это не причинит вред клиенту, который на момент вызова удовлетворяет исходному предусловию. Второй подход означает, что мы выдаем больше, чем от нас требовалось. Это не причинит вред клиенту, полагающемуся на выполнение по завершении вызова исходных постусловий.
Итак, основное правило:
Правило (1) Утверждения Переобъявления (Assertion Redeclaration)
При повторном объявлении подпрограммы предусловие может заменяться лишь равным ему или более слабым, постусловие - лишь равным ему или более сильным.
Это правило отражает тот факт, что новый вариант подпрограммы не должен отвергать вызовы, допустимые в оригинале, и должен, как минимум, представлять гарантии, эквивалентные гарантиям исходного варианта. Он вправе, хоть и не обязан, допускать большее число вызовов или давать более сильные гарантии.
Как явствует из названия, это правило применимо к обеим формам повторного объявления: переопределению и реализации отложенного компонента. Второй случай важен особо, - утверждения будут связаны со всеми эффективными версиями потомков.
Утверждения подпрограммы, как отложенной, так и эффективной, задают ее семантику, применимую к ней самой и ко всем повторным объявлениям ее потомков. Точнее говоря, они специфицируют область допустимого поведения подпрограммы и ее возможных версий. Любое повторное объявление может лишь сужать эту область, не нарушая ее.
Как следствие, создатель класса должен быть осторожным при написании утверждений эффективной подпрограммы, не привнося излишнюю спецификацию (overspecification). Утверждения должны описывать намерения подпрограммы, - ее абстрактную семантику, - но не свойства реализации. Иначе можно закрыть возможность создания иной реализации подпрограммы у будущих потомков.
Предположим, я написал класс MATRIX, реализующий операции линейной алгебры. Среди прочих возможностей я предлагаю своим клиентам подпрограмму расчета обратной матрицы. Фактически это сочетание команды и двух запросов: процедура инвертирует матрицу, присваивает атрибуту значение обратной и устанавливает логический атрибут inverse_valid. Значение атрибута имеет смысл тогда и только тогда, когда inverse_valid является истинным; в противном случае матрицу инвертировать не удалось, так как она вырождена. В ходе нашего обсуждения случай вырожденной матрицы мы можем проигнорировать.
Конечно же, я могу найти лишь приближенное значение обратной матрицы и готов гарантировать определенную точность расчетов, однако, не владея численными подпрограммами в совершенстве, буду принимать лишь запросы с точностью не выше 10-6. В итоге, моя подпрограмма будет выглядеть приблизительно так:
invert (epsilon: REAL) is
-- Обращение текущей матрицы с точностью epsilon
require
epsilon >= 10 ^ (-6)
do
"Вычисление обратной матрицы"
ensure
((Current * inverse) |-| One) <= epsilon
end
Постусловие предполагает, что класс содержит инфиксную функцию такую, что m1 |-| m2 есть |m1 - m2| (норма разности матриц m1 и m2 ), а также функцию , результатом которой является произведение двух матриц. One - единичная матрица.
Как человек негордый, летом я приглашу программиста, и он перепишет мою подпрограмму , используя более удачный алгоритм, лучше аппроксимирующий результат и допускающий меньшее значение 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 и предложим своим клиентам решение, лучше прежнего.
При усилении предусловия , например, epsilon >= 10 ^ (-5), вызов, корректный для класса MATRIX, мог стать теперь некорректным. При ослаблении постусловия возвращаемый результат стал бы хуже, чем гарантируемый для MATRIX.
Последний комментарий указывает на весьма интересное следствие правила Утверждений Переобъявления. В общей схеме
(рис 16.3) Подпрограмма, клиент и подрядчикутверждения $$\gamma$$ и $$\delta,$$ введенные при повторном объявлении, предпочтительнее для клиентов, если они отличаются от $$\alpha$$ и $$\beta$$ (предусловия - более слабые, постусловия - более сильные). Но клиент класса A, использующий A' благодаря полиморфизму и динамическому связыванию, не может в полной мере воспользоваться более выгодным контрактом, ибо единственный контракт клиента заключен с классом A.
Воспользоваться преимуществом нового контракта можно лишь став непосредственным клиентом A' (пунктирная связь с вопросительным знаком на рисунке 16.3), как в случае:
При этом вы, естественно, объявляете a1 как объект типа A', а не объект типа A, как прежде. В результате теряется универсальность полиморфизма, идущая от A.
Компромисс ясен. Клиент класса MATRIX должен обеспечивать выполнение исходного (более сильного) предусловия, а в ответ вправе ожидать выполнения исходного (более слабого) постусловия. Даже если его запрос динамически подготовлен к обслуживанию классом NEW_MATRIX, воспользоваться новыми возможностями - большей толерантностью входа и большей точностью выхода - ему никак не удастся. Для обращения к улучшенной спецификации клиент должен объявить матрицу типа NEW_MATRIX, тем самым, потеряв доступ к иным порожденным от MATRIX реализациям, не являющимся производными классами самого NEW_MATRIX.
Правило Утверждения Переобъявления великолепно сочетается с теорией Проектирования по Контракту.
Мы видели, что утверждения подпрограммы описывают связанный с ней контракт, в котором клиент гарантирует выполнение предусловия, получая право рассчитывать на истинность постусловия; для поставщика все наоборот.
Наследование совместно с повторным объявлением и динамическим связыванием приводит к созданию субподрядов. Приняв условия контракта, вы не обязаны выполнять его сами. Подчас вы знаете кого-то еще, способного сделать это лучше и с меньшими издержками. Так происходит, когда клиент запрашивает подпрограмму из MATRIX, но благодаря динамическому связыванию может на этапе выполнения фактически вызывать версию, переопределенную в потомке. "Меньшие издержки" означают здесь более эффективную реализацию, как в знакомом нам примере с периметром прямоугольника, а "лучше" - усовершенствование утверждений, в описанном здесь смысле.
Правило Утверждения Переобъявления просто устанавливает, что честный субподрядчик, приняв условия контракта, должен выполнить работу на тех же условиях, что и подрядчик или лучших, но никак не худших.
С позиции Проектирования по Контракту, инварианты классов - это ограничения общего характера, применимые и к подрядчикам, и к клиентам. Правило родительских инвариантов отражает тот факт, что все подобные ограничения передаются субподрядчикам.
Свое истинное значение для ОО-разработки наследование приобретает лишь совместно с утверждениями и двумя приведенными выше правилами. Метафора контрактов и субподрядов - прекрасная аналогия, помогающая разрабатывать корректное ОО-ПО. Несомненно, в этом - одна из центральных идей теории проектирования.
Правило ослабления предусловий может оказаться чересчур жестким в случае, когда наследник понижает уровень абстракции, характерный для его предка. К счастью, есть легкий обходной путь, полностью согласующийся с теорией.
Типичным примером этого является порождение 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. Это - случай излишней спецификации, ограничивающей свободу реализации компонентов потомками класса. |
Правило Утверждений Переобъявления, так как оно сформулировано, является концептуальным руководством. Как преобразовать его в безопасное и проверяемое правило языка?
В принципе, чтобы убедиться в том, что старые предусловия влекут новые, а новые постусловия - старые, следует провести логический анализ тех и других утверждений. К сожалению, это требует наличия сложного механизма доказательства теорем (несмотря на десятилетия исследований в области искусственного интеллекта). Его применение в компиляторе пока не реально.
К счастью, возможно простое техническое решение. Нужное нам правило можно сформулировать через простое лингвистическое соглашение, основанное на том наблюдении, что для любых утверждений a и b:
Итак, гарантируется, что новое предусловие слабее исходного $$\alpha$$ либо равно ему, если оно имеет вид $$\alpha$$ or $$\gamma$$ . Гарантируется, что новое постусловие сильнее исходного $$\beta$$ либо равно ему, если оно имеет вид $$\beta$$ and $$\delta$$ . Отсюда следует искомое языковое правило:
Правило (2) Утверждения Переобъявления
При повторном объявлении подпрограммы нельзя использовать предложения require или ensure. Вместо них следует использовать предложение, начинающееся с:
При отсутствии таких предложений действуют исходные утверждения.
Заметим, что используются нестрогие булевы операторы 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)).
Ситуация с постусловием аналогична. Такое расширение не имеет особого значения, поскольку преобладает более epsilon >= 10 ^ (-20), а его постусловие есть: ((Current * , хотя запись утверждений в программе (а также, вероятно, их расчет во время выполнения при отсутствии средств символьных преобразований) является более сложной.
Правило Утверждения Переобъявления нуждается в небольшом дополнении ввиду возможности при повторном объявлении задать функцию как атрибут. Что произойдет с предусловием функции и ее постусловием, если таковые имелись?
Атрибут доступен всегда, а потому мы вправе считать, что его предусловие равно True. В итоге можно полагать, что предусловие атрибута, согласно правилу Утверждения Переобъявления, было ослаблено.
Но атрибут не имеет постусловий. Мы же должны гарантировать, что он наделен всеми свойствами, заданными исходной функцией. Поэтому (в дополнение к правилу Утверждения Переобъявления) будем считать, что в этом случае автоматически постусловие добавляется к инварианту класса. Плоская форма класса будет содержать это условие в составе своего инварианта.
| Для функции без параметров, формулируя некое свойство ее результата, вы всегда можете выбрать, включать ли его в постусловие или в инвариант. С точки зрения стиля предпочтительно пользоваться инвариантом. Соблюдение этого правила позволит отказаться от внесения изменений в утверждения в будущем, если при повторном объявлении функция становится атрибутом. |
Неформально, правило Утверждения Переобъявления гласит: "Повторное объявление утверждений может лишь сужать область допустимого поведения, не нарушая ее". Сейчас, завершая обсуждение этой темы, приведем строгую формулировку данного свойства.
Пусть подпрограмма реализует частичную функцию r, отображающую множество возможных входных состояний I в множество возможных выходных состояний O. Утверждения подпрограммы определяют правила действия r и ее возможных переопределений.
DOM функции r (подмножество I, на котором r гарантированно вырабатывает результат).x из DOM подмножество RESULTS(x) множества O, такое, что $$r (x) \in RESULTS (x)$$. Так как постусловие не всегда однозначно описывает результат, это подмножество может иметь больше одного элемента.Правило Утверждения Переобъявления означает, что повторное объявление может расширять область определения и сужать множество результатов. Пометив новые множества знаком ', запишем требования, закрепленные этим правилом:
Предусловие устанавливает, что подпрограмма и ее повторные объявления, как минимум, должны принимать некоторые входы ( DOM ), хотя повторные объявления могут это множество и расширить. Постусловие говорит, что результаты, возвращаемые подпрограммой и ее повторными объявлениями, могут, самое большее, содержать значения из RESULTS(x), однако, постусловия при повторных объявлениях могут это множество сузить.
В этом описании состояние системы в период выполнения определяется состоянием (значениями) всех достижимых объектов. Кроме того, входные состояния (элементы I ) также включают в себя значения аргументов. Более подробное введение в математическое описание программ и языков программирования см. в [M 1990].
Ранее мы уже ссылались на универсальные (universal) классы GENERAL и ANY, а также на безобъектный (objectless) класс NONE. Пришло время пояснить их роль и представить глобальную структуру наследования.
Удобно использовать следующее соглашение:
Правило Универсального Класса
Любой класс, не содержащий предложение наследования, неявно содержит предложение вида:
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) Глобальная структура наследования
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 обеспечивают
Вот лишь некоторые компоненты, содержащиеся в классе GENERAL, а значит, доступные всем другим классам. Часть из них была введена и использована в предшествующих лекциях курса:
clone для создания клона (дубля) объекта, а также его "глубинный" вариант deep_clone для рекурсивного дублирования полной структуры объекта;copy для копирования содержимого одного объекта в другой;equal для сравнения объектов (поле-с-полем), а также его "глубинный" вариант deep_equal ;print и print_line - печать простого представления по умолчанию любого объекта (default representation);tagged_out - строка, содержащая представление по умолчанию любого объекта, в котором каждое поле сопровождается своей меткой (tag) (соответствующим именем атрибута);same_type и conforms_to - булевы функции, сопоставляющие тип текущего объекта с типом другого;generator - возвращает имя порождающего (generating) класса объекта, то есть класса, экземпляром которого является данный объект.При обсуждении идеи наследования неоднократно подчеркивался принцип Открыт-Закрыт - право, взяв компонент класса-родителя, переопределить его, возложив на него иные задачи. Могут ли появиться причины запрета такой возможности?
Обсуждение утверждений в начале лекции дало нам теоретическое понимание сути переопределений. Часть "Открыт" принципа Открыт-Закрыт дает возможность изменять компоненты потомков, но под контролем утверждений. Разрешены лишь те повторные объявления, для которых реализация согласуется со спецификацией, заданной предусловием и постусловиям оригинала.
В ряде случаев клиентам класса и клиентам классов потомков нужна гарантия, что компонент не только соблюдает спецификацию, но и пользуется в точности исходной реализацией. Достичь этого можно лишь "заморозив" его реализацию - полностью запретив переопределение компонента. Подобную возможность дает простая языковая конструкция:
frozen feature_name ... is... Остальные объявления - как обычно...
При таком описании ни один из потомков класса не может включать данный компонент в предложения redefine и undefine ни под своим, ни под любым другим именем (смена имен, конечно же, по-прежнему разрешена). Отложенный компонент по своей сути должен быть переопределен и, следовательно, не может быть заморожен.
Чаще всего замороженные (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 были по-прежнему верными.
Приведенные примеры замораживания - это типичные образцы применения механизма, гарантирующего точное соответствие копий и клонов семантике исходного класса.
Замораживание компонентов не следует делать по соображениям эффективности. (Эту ошибку иногда совершают программисты, работающие на C++ или Smalltalk, которым внушили мысль, будто динамическое связывание накладно и его нужно по возможности избегать.) Хотя вызов замороженных компонентов означает отсутствие динамического связывания, это лишь побочный эффект механизма frozen, а не его конечная цель. Выше мы подробно говорили о том, что безопасное
Расширяя базовое понятие класса, мы представляли наследование и универсальность (genericity) как своего рода "партнеров". Объединить их нам позволило знакомство с полиморфными структурами данных: в контейнер - объект, описанный сущностью типа SOME_CONTAINER_TYPE [T] с родовым параметром T - можно помещать объекты не только самого типа T, но и любого потомка T. Однако есть и другая интересная комбинация
Приведем простой, но характерный пример, демонстрирующий необходимость введения ограниченной универсальности. Он поможет в обосновании метода решения поставленной задачи и в выборе соответствующей конструкции языка.
Предположим, что мы хотим объявить класс 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 "<=", задающая порядок для любой пары соответствующих объектов.Переходя к решению этой проблемы, посмотрим, как с такой задачей справлялись другие, не ОО-языки.
В языке 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, обязанных знать свое место - быть компонентами класса - вполне респектабельными членами ОО-сообщества.
Эти наблюдения дают решение. Мы должны оперировать исключительно терминами классов и типов.
Потребуем, чтобы любой фактический параметр, используемый классом 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] ...
Вот некий трюк с нашим примером: спросим себя, возможен ли вектор векторов? Допустим ли тип 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]]] и так далее - приятное рекурсивное приложение ограниченной универсальности.
Конечно же, не все случаи универсальности ограничены. Форма - STACK [G] или ARRAY [G] - по-прежнему существует и называется неограниченной универсальностью. Пример DICTIONARY [G, H -> HASHABLE] показывает, что класс одновременно может иметь как ограниченные, так и неограниченные родовые параметры.
Изучение ограниченной универсальности дает шанс лучше понять неограниченный случай. Вы, конечно же, вывели правило, по которому class C [G] следует понимать как class C [G -> ANY]. Поэтому если G - неограниченный STACK ), а x - сущность, имеющая тип G, то мы точно знаем, что можем делать с сущностью x: читать и присваивать значения, сравнивать ( =, /= ), передавать как параметр и применять в универсальных операциях clone, equal и прочее.
Наша следующая техника адресуется к тем областям Объектной страны, в которых из страха тиранического поведения мы не можем позволить править простым правилам типизации, не встречая никакого сопротивления.
Цель правил типов, введенных вместе с наследованием, в достижении статически проверяемого динамического поведения, так чтобы система, прошедшая проверку при компиляции, не выполняла неадекватных операций над объектами во время выполнения.
Вот два основных правила, представленных в первой лекции о наследовании (лекция 14).
x.f осмысленна лишь тогда, когда базовый класс x содержит и экспортирует компонент f.a как аргумента или при присваивании его некой сущности необходимо, чтобы тип a был совместим с ожидаемым, то есть основан на классе, порожденным от Правило Вызова Компонентов не является причиной каких-либо проблем - это фундаментальное условие всякой работы с объектами. Естественно, что обращаясь к компоненту объекта, нужно проверить, действительно ли данный класс предлагает и экспортирует данный компонент.
Правило
Давайте займемся исследованием примеров этих двух случаев. Рассмотрим для начала полиморфную структуру данных, такую как список геометрических фигур:
figlist: LIST [FIGURE]
В предыдущих лекциях рассматривалась item (i)., где item (i) - i -й элемент списка, идет вразрез с правилом вызова компонентов: item (i) имеет тип FIGURE, а этот класс, в отличие от его потомка RECTANGLE, не содержит в своем составе компонента . Решение, используемое до сих пор, изменяло определение класса, - в нем появлялся атрибут, задающий тип фигуры. Однако это решение не столь элегантно, как нам хотелось бы.
Теперь пример второго рассматриваемого случая. Пусть имеется механизм хранения объектов в файле или передачи их по сети, аналогичный STORABLE, описанному нами ранее. Для получения объекта используем:
my_last_book: BOOK ... my_last_book := retrieved (my_book_file)
Значение, возвращаемое , имеет тип STORABLE библиотеки Kernel, хотя с тем же успехом оно может иметь тип ANY. Но мы не ожидали STORABLE или ANY, - мы надеялись получить именно BOOK. Присваивание my_last_book нарушает правило
Даже если написать собственную функцию , учитывающую специфику приложения и объявленную с подходящим типом, вам не удастся полностью на нее положиться. В отличие от объектов вашего ПО, в котором EMPLOYEE вместо объекта BOOK, файл мог быть подделан, а при сетевом доступе данные могли быть искажены при передаче.
Из этих примеров ясно: нам может понадобиться механизм удостоверения типа объекта.
Решение этой проблемы, возникающей в специфических, но критически важных случаях, должно быть найдено без потери преимуществ ОО-стиля разработки. В частности, мы не хотим возвращаться к той схеме, которую сами и осудили:
if "f типа RECTANGLE" then ... elseif "f типа CIRCLE" then ... и т.д.
Это решение идет вразрез с принципами Единственного Выбора и Открыт-Закрыт. Избежать риска потерь нам помогут два обстоятельства.
И снова запись механизма решения напрямую вытекает из анализа поставленной проблемы. Введем новую форму присваивания, назвав ее попыткой присваивания (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.. Эта схема проверки вполне типична.
Заметим, что мы никогда не нарушаем правило Вызова Компонентов: обращения к r. защищены дважды: статически - компилятором, проверяющим, является ли компонентом класса RECTANGLE, и динамически - нашей гарантией того, что r не Void, а имеет присоединенный объект.
Обращение к элементу списка - потомку класса RECTANGLE, например SQUARE (квадрат), связывает r с объектом, и его диагональ будет участвовать в вычислениях.
Пример с выглядит так:
my_last_book: BOOK
... Сравните с := в первой попытке
my_last_book ?= retrieved (my_book_file)
if my_last_book /= Void then
... "Обычные операции над my_last_book" ...
else
... "Полученное не соответствует ожиданию" ...
end
Необходимость попытки присваивания обусловлена, как правило, тем, что на статически объявленный
Заметьте, как тщательно был спроектирован механизм, дающий разработчикам шанс забыть об устаревшем стиле разбора вариантов (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. В случае Из-за отсутствия в языке универсальности Java активно использует оба механизма. Отчасти это связано с тем, что в отсутствие множественного наследования Java не содержит класса |
Повторное объявление компонентов не требует сохранения сигнатуры. Пока оно виделось нам как замена одного алгоритма другим или - для отложенного компонента - запись алгоритма, соответствующего ранее заданной спецификации.
Но, воплощая идею о том, что класс способен предложить более специализированную версию элемента, описанного его предком, мы вынуждены иногда изменять типы данных. Приведем два характерных примера.
Вот простой пример переопределения типа. Рассмотрим понятие устройства, включив предположение о том, что для любого устройства есть альтернатива, так что устройство можно заменить, если оно по каким-либо причинам недоступно:
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
В этом и проявляется специализирующая природа наследования.
В следующем примере мы обратимся к базовым структурам данных. Рассмотрим библиотечный класс 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.)
Примеры, рассмотренные выше, несмотря на все их различия, объединяет необходимость повторного объявления типов. Спуск по a в set_alternate и other в put_right ; изменяются типы запросов - alternate и right.
Этот аспект повторного объявления выражает следующее правило:
Правило повторного объявления типов
При повторном объявлении компонента можно заменить тип компонента (для атрибутов и функций) или тип формального параметра (для подпрограмм) любым
Правило использует понятие
Любое повторное объявление ведет к специализации, а, следовательно, к изменению типов. Так, с переходом к двунаправленным спискам параметры и результаты функций сменили свой тип на BI_LINKABLE. Отсюда становится понятен тот термин, которым часто описывают политику редекларации типов, - ковариантная типизация (covariant typing), где приставка "ко" указывает на параллельное изменение типов при спуске по диаграмме наследования.
Ковариантная типизация таит в себе немало проблем, которые возникают у создателей компиляторов, нередко перекладывающих их решение на плечи разработчиков приложений.
Правило повторного объявления типов способно свести на нет целый ряд преимуществ наследования. Почему это происходит и каково решение данной проблемы?
Рассмотрим пример с участием класса 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! Для подхода, претендующего на решение проблемы повторного использования, это серьезный порок.
Было бы ошибочно полагать, что проблема неоправданного переопределения возникает лишь там, где структура ориентирована на реализацию, как в 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 пустой указатель. |
Изучив класс LINKED_LIST в тексте приложения A, вы поймете, что проблема еще масштабнее. В теле класса содержится множество объявлений со ссылкой на тип LINKABLE [G], а с переходом к двунаправленным спискам почти все они потребуют повторного определения. Так, вариант представления списка включает четыре ссылки на отдельные элементы:
first_element, previous, active, next: LINKABLE [G]
В классе TWO_WAY_LIST каждая из этих сущностей должна быть объявлена заново. Аналогичная процедура ждет и другие порожденные классы. Многие функции, такие как put_right, имеют "односвязные" аргументы и нуждаются в повторном определении. В итоге реализация TWO_WAY_LIST будет во многом дублировать оригинал.
В отличие от других проблем, решение которых предложено в этой лекции, такое тиражирование кода не связано с тем, что система типов препятствует нам в выполнении задуманного. Повторное объявление ковариантных типов разрешает их переопределение, но заставляет нас заниматься утомительным копированием текста.
Заметим: наши примеры действительно требуют переопределения типа, но ничего более. Все сводится только к этому. Из этого следует решение проблемы - необходимо создать механизм не абсолютного, а относительного объявления
Назовем такое объявление закрепленным (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.
Итак, закрепленные определения есть весьма важное средство сохранения возможности повторного использования при
В качестве опорного элемента можно использовать 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
С введением закрепленных типов нуждается в расширении понятие базового класса типа.
Сначала классы и типы были для нас едины, и это их свойство - отправной пункт ОО-метода, - по существу, сохраняется, хотя нам пришлось немного расширить систему типов, добавляя в классы родовые параметры. Каждый тип основан на классе и для типа определено понятие базового класса. Для типов, порожденных LIST [INTEGER] базовым классом является LIST. На классах основаны и SOME_CLASS.
Закрепление типов - это еще одно расширение системы типов, которое, подобно двум предыдущим, сохраняет свойство выводимости каждого типа непосредственно из класса. Базовым для like anchor является базовый класс anchor в текущем классе. Если anchor есть Current, базовым будет класс, в котором это объявление содержится.
Теоретически ничто не мешает нам записать 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-
При закреплении формального параметра или результата, как в случае
r (other: like Current)
фактический параметр вызова, например, b в a.r(b), должен быть опорно-эквивалентен a.
Не всякое объявление вида x: A в классе A следует менять на x: like Current и не в каждой паре компонентов одного типа следует один из них делать опорным, а другой - закрепленным.
Закрепленное объявление - это своего рода обязательство изменения типа закрепленной сущности при смене типа опорного элемента. Как мы видели, оно не имеет обратной силы: объявив 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
что гарантирует неизменность типов всех узлов дерева.
Устранить последние неясности в понимании закрепленного объявления поможет следующее замечание: это чисто статический механизм, не предполагающий никаких изменений объектов в период выполнения. Все ограничения могут быть проверены в период компиляции.
Закрепленное объявление можно считать синтаксическим приемом, позволяющим переложить переопределения на компилятор. Кроме того, оно является важнейшим инструментом достижения компромисса между повторным использованием и контролем типов.
Последний вопрос, оставшийся пока без ответа, как наследование взаимодействует с принципом Скрытия информации.
В отношениях между классом и его клиентами скрытие информации определяет разработчик класса. Именно он определяет политику в отношении каждого компонента класса: экспортируя его всем клиентам, разрешая выборочный экспорт, или делая компонент закрытым.
Что происходит со статусом экспорта при передаче компонента потомку? Наследование и скрытие информации - ортогональные механизмы. Наследование определяет отношение между классом и его потомками, экспорт - между классом и его клиентами. Класс 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 доступным определенным классам
...
Характерным примером является создание нескольких вариантов одной абстракции.
Представим себе GENERAL_ACCOUNT - класс, содержащий все необходимые операции для работы с банковскими счетами: процедуры open, withdraw, deposit, code (для снятия денег через банкомат), change_code и т.д.,- но не предназначенный для использования клиентами напрямую, а потому не экспортирующий никаких подпрограмм. Его потомки выступают как разные облики родителя: они не содержат новых компонентов и отличаются лишь предложениями экспорта. Один экспортирует open и deposit, второй, наряду с ними, - withdraw и code, и т. д.
(рис 16.12) Разные облики одной абстракцииЭта схема в обсуждении методологии наследования (см. лекцию 6 курса "Основы
Понятие облика (view) является классическим в области баз данных, где необходимо дифференцировать пользователей, работающих с данными, предоставляя им разные права.
Другой пример касается классов, введенных, когда речь шла о множественном наследовании. Компонент right класса CELL скрыт в нем или, точнее говоря, экспортируется лишь классу LIST. Фактически так обстоят дела со всеми компонентами CELL, поскольку этот класс был изначально нацелен на работу со списками. Однако в дереве (классе TREE ), потомке как CELL, так и LIST, right теперь означает доступ к правому брату и является респектабельным членом общества экспортируемых компонентов.
Стратегия экспорта, согласно которой каждый потомок класса имеет свою политику, хотя и усложняет проверку типов, но придает необходимую гибкость действиям разработчика.
Предпринимались и иные попытки. Так, отдельные
При близком рассмотрении отсутствие ясных методологических установок не удивляет. Наследование является воплощением принципа Открыт-Закрыт: позволяя выбрать готовый класс, написанный вами или другим программистом вчера или 20 лет назад, и обнаружить, что с ним можно делать нечто полезное, что даже не предполагалось при его проектировании.
Позволить создателю класса определять, что могут и что не могут использовать потомки класса, - значит лишиться основного свойства наследования.
Пример классов CELL и TREE характерен: при разработке CELL его целью была лишь поддержка работоспособности LIST, а потому right и put_right служили в нем исключительно внутренним целям. И лишь позднее этим компонентам нашли новое применение в классе-потомке TREE. Не будь этой открытости, наследование почти полностью утратило бы свой шарм.
Если нет основы для принятия решения об экспорте компонентов потомкам, то еще более абсурдно пытаться догадаться, что потомки могут экспортировать своим клиентам. Единственная задача разработчика порожденного класса - предоставление своим клиентам как можно более удобного для них класса. Наследование - это лишь средство, которое позволяет быстрее добиться желаемого результата. Все правила ОО-игры определяются утверждениями и ограничениями типизации, - не более того. Найти полезный для клиентов потомка компонент предка - это большая удача, ну а то, как поступал предок с этим компонентом, - экспортировал ли он его, это дело предка и волнует потомка меньше всего.
В итоге, единственной стратегией, сочетающейся с принципиальной открытостью наследования, нам кажется та, что была описана выше: предоставить каждому разработчику возможность самостоятельно решать, что делать с компонентами предка, выбирая собственную политику экспорта в интересах своих клиентов.
Знакомясь с объектным подходом по другим источникам, вы могли видеть в них предостережения использования "наследования реализаций". Однако в нем нет ничего плохого.
Повторное использование имеет две формы: использование интерфейсов и использование реализаций. Любой класс - это реализация (возможно, частичная) АТД. Он содержит как интерфейс, выражающий спецификацию АТД и образующий лишь "вершину айсберга", так и набор решений, определяющих реализацию. Повторное использование интерфейса означает согласие со спецификацией, повторное использование реализации - ваше согласие положиться на свойства класса, а не только на АТД.
Совместно для одних и тех же целей эти две возможности не применяются. Если вы хотите получить некоторое множество возможностей только через их
Эти формы повторного использования взаимно дополняют друг друга и обе совершенно законны.
По сути, их воплощением являются два вида межмодульных отношений, имеющих место при ОО-проектировании программ: клиент обеспечивает повторное использование интерфейсов, наследование поддерживает повторное использование реализаций.
Повторно используя реализацию, вы безусловно принимаете более ответственное решение, так как не можете рассчитывать на неизменность реализации в перспективе. По этой причине, став наследником класса, вы свяжете себя более сильными обязательствами.
В чем же причина недоверия к наследованию реализаций? Я пришел к выводу, что ответ лежит в области психологии. Тридцатилетний программистский опыт оставил нам лишь сомнения насчет самой идеи реализаций. И даже слово "реализация" приобрело в отдельных кругах почти неприличный характер. По этой причине мы ведем речь о проектировании и анализе, а если и упоминаем реализацию, то начинаем разговор с "но", "лишь" или "только".
Объектная технология в корне меняет все: ОО-реализации настолько элегантны, полезны, с ясно выраженной корректностью, что уже можно забыть об неприятных оттенках этого слова в языке. Для многих из нас программа часто оказывается вещью наиболее абстрактной, дает описание на самом высоком уровне и наиболее понимаема, чем большая часть того, что в анализе и проектировании провозглашается "величайшим достижением мысли".
Ряд основных различий между понятиями, о которых шла речь, мы представили в виде таблицы.
Итак, есть два отношения - "быть потомком" и "быть клиентом"; две формы повторного использования - интерфейсов и реализаций; скрытие информации и его отсутствие; защита от изменений в поставляемых модулях и отсутствие таковой.
Наличие альтернатив в любом случае не вносит противоречий, и в зависимости от контекста каждый из вариантов вполне оправдан. Отважимся на смелый шаг и сведем эти противоположности в одно целое:
| Клиент | Потомок |
|---|---|
Повторное использование интерфейсов Информация скрывается Исходная реализация защищена |
Повторное использование реализаций Информация не скрывается Исходная реализация не защищена |
Возможно, есть и другие подходы к решению этой проблемы, но я не знаю ни одного столь же простого, доступного и практичного.
Говоря о наследовании и скрытии информации, нельзя обойти вопрос о выборочном экспорте компонентов. Класс A, выборочно экспортирующий f классу B:
class A feature {B, ...}
f...
...
делает f доступным в реализации собственных компонентов B. Потомки B, в свою очередь, имеют доступ к реализации предка, а потому они должны быть вправе обращаться ко всем доступным B возможностям, в том числе, к f.
Практические наблюдения подтверждают это теоретическое обоснование. Все, что необходимо классу, обычно требуется и его потомкам. Однако нам не хотелось бы с появлением очередного порожденного класса B возвращаться в A и расширять его предложение экспорта.
Согласно принципу Скрытия информации, а также принципу Открыт-Закрыт, разработчику A дано право решать, делать ли f доступным для B, однако, ему запрещено ограничивать свободу разработчика B. Тем самым, имеет место правило:
Правило наследования при выборочном экспорте
Выборочно экспортированный компонент доступен как самому классу, так и всем его потомкам.
GENERAL и допускающий настройку его наследник обеспечивают переопределяемые компоненты, представляющие общий интерес для всех создаваемых разработчиком классов. Класс NONE замыкает решетку наследования снизу.Иную точку зрения на взаимосвязь наследования и скрытия информации см. в [Snyder 1986].
Перепишите и упростите ранее созданную реализацию защищенного стека, сделав класс STACK3 потомком, а не клиентом STACK, чтобы избежать излишних обходных путей. ( Подсказка: см. правила взаимодействия наследования и скрытия информации.)
Напишите класс VECTOR, представляющий числовые вектора (кольцо) с обычными математическими операциями. Сам класс рекурсивно должен относиться к численному типу, допуская вектора векторов. Возможно, для этого вам придется самостоятельно дописать класс NUMERIC (или воспользоваться готовым из [M 1994a]).
В случае, когда x1 имеет тип X, y1 имеет тип Y, и Y является потомком X, оператор y1 := x1 будет недопустимым. Однако полезным мог бы показаться универсальный компонент extract, такой, что y1.extract (x1) копирует значения полей объекта x1 в соответствующие поля объекта y1 при условии, что ни в одной из этих ссылок не содержится Void.
Объясните, почему компонент extract стоит отвергнуть. ( Подсказка: обратитесь к вопросам корректности, в частности, к понятию инварианта.) Выясните, можно ли спроектировать удовлетворительный механизм, решающий эту задачу каким-то иным способом.
Следствия красоты базисных идей:
Вопросам наследования будут посвящены еще две лекции: обзор проблем типизации представлен в лекции 17, а подробное обсуждение методологии наследования - в лекции 6 курса "Основы
Большинство разделов этой лекции строится по единому принципу: экзаменуются следствия идей предыдущих двух лекций, обнаруживаются проблемы, они подробно анализируются, предлагается обоснованное решение. Ключевым является шаг анализа - как только проблема становится ясной, зачастую решение ее находится сразу же.
Обладая изрядной мощью, наследование может быть и опасным. Не будь механизма утверждений, создатели классов могли бы весьма "вероломно" пользоваться повторными объявлениями и динамическим связыванием для изменения семантики операций без возможности контроля со стороны клиента. Утверждения способны на большее: они дают нам более глубокое понимание природы наследования. Не будет преувеличением сказать, что лишь понимание принципов Проектирования по Контракту позволяет в полной мере постичь сущность концепции наследования.
Вкратце мы уже очертили основные правила, управляющие взаимосвязью наследования и утверждений: все утверждения (предусловие и постусловия подпрограмм, инварианты классов), заданные в классах-родителях, остаются в силе и для их потомков. В этом разделе мы уточним эти правила и используем полученные результаты, чтобы дать новый взгляд на наследование как на субподряды (subcontracts).
С правилом об инвариантах класса мы встречались и прежде:
Правило родительских инвариантов
Инварианты всех родителей применимы и к самому классу.
Инварианты родителей добавляются к классу. Инварианты соединяются логической операцией and then. (Если у класса нет явного инварианта, то инвариант True играет эту роль.) По индукции в классе действуют инварианты всех его предков, как прямых, так и косвенных.
Как следствие, выписывать инварианты родителей в инварианте потомка еще раз не нужно (хотя семантически такая избыточность не вредит: a and then a есть то же самое, что a ).
Полностью восстановленный инвариант класса можно найти в плоской и краткой плоской форме последнего (см. лекцию 15).
В случае с предусловиями и постусловиями ситуация чуть сложнее. Общая идея, как отмечалось, состоит в том, что любое повторное объявление должно удовлетворять утверждениям оригинальной подпрограммы. Это особенно важно, если подпрограмма отложена: без такого ограничения на будущую реализацию, задание предусловие и постусловий для отложенных подпрограмм было бы бесполезным или, хуже того, привело бы к нежелательному результату. Те же требования к предусловию и постусловию остаются и при переопределении эффективных подпрограмм.
Анализируя механизмы повторного объявления, полиморфизма и динамического связывания, можно дать точную формулировку искомого правила. Но для начала представим типичный случай.
Рассмотрим класс и его подпрограммы, имеющие как предусловие, так и постусловие:
(рис 16.1) Подпрограмма, клиент и контракт
a1: A ... a1.r
Для простоты мы проигнорируем все аргументы, которые может требовать r, и положим, что r является процедурой, хотя наши рассуждения в равной мере применимы и к функциям.
Вызов будет корректен лишь тогда, когда он удовлетворяет предусловию. Гарантировать, что C соблюдает свою часть контракта, можно, к примеру, предварив вызов проверкой предусловия, написав вместо a1.r конструкцию:
(Как отмечалось при обсуждении утверждений, не всегда требуется проверка: достаточно, с помощью if или без него, гарантировать выполнение условия a перед вызовом r. Для простоты будем использовать if -форму, игнорируя предложение else.)
Обеспечив соблюдение предусловия, клиент C рассчитывает на выполнение постусловия $$a1.\beta$$ при возврате из r.
Все это является основой Проектирования по Контракту: в момент вызова подпрограммы клиент должен обеспечить соблюдение предусловия, а в ответ при возврате из подпрограммы он полагается на выполнение постусловия.
Что происходит, когда вводится наследование?
(рис 16.2) Подпрограмма, клиент, контракт и потомокПусть новый класс A' порожден от A и содержит повторное объявление r. Как он может, если вообще может, заменить прежнее предусловие $$\alpha$$ новым $$\gamma,$$ а прежнее постусловие $$\beta$$ - новым $$\delta?$$
Чтобы найти ответ, рассмотрим обязательства клиента. В вызове 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 может добросовестно выполнять свою часть контракта, и все же в результате он будет обманут. Например,
если a1 полиморфно присоединена к объекту типа A', инструкция вызовет подпрограмму, ожидающую выполнения $$\gamma$$ и гарантирующую выполнение $$\delta,$$ в то время как клиент получил указание соблюдать $$\alpha$$ и ожидать выполнения $$\beta.$$ Налицо возможное расхождение во взглядах клиента и поставщика на контракт.
Чтобы понять, как удовлетворить клиентов, мы должны сыграть роль адвокатов дьявола и на секунду представить себе, как их обмануть. Так поступает опытный криминалист, разгадывая преступление. Как мог бы поступить поставщик, желающий ввести в заблуждение своего честного клиента C, гарантирующего при вызове $$\alpha$$ и ожидающего выполнения $$\beta?$$ Есть два пути:
| Вспомните, что мы неоднократно говорили при обсуждении Проектирования по Контракту: усиление предусловия облегчает задачу поставщика ("клиент чаще не прав"), иллюстрацией чего служит крайний случай - предусловие false (когда "клиент всегда не прав"). |
Как уже было сказано, утверждение A называется более сильным, чем B, если A логически влечет B, но отличается от него: например, x >= 5 сильнее, чем x >= 0. Если утверждение A сильнее утверждения B, говорят еще, что утверждение B слабее утверждения A.
Теперь нам понятно, как обманывать. Но как же быть честным? Объявляя подпрограмму повторно, мы можем сохранить ее исходные утверждения, но также мы вправе:
Первый подход символизирует щедрость и великодушие: мы допускаем большее число случаев, чем изначально. Это не причинит вред клиенту, который на момент вызова удовлетворяет исходному предусловию. Второй подход означает, что мы выдаем больше, чем от нас требовалось. Это не причинит вред клиенту, полагающемуся на выполнение по завершении вызова исходных постусловий.
Итак, основное правило:
Правило (1) Утверждения Переобъявления (Assertion Redeclaration)
При повторном объявлении подпрограммы предусловие может заменяться лишь равным ему или более слабым, постусловие - лишь равным ему или более сильным.
Это правило отражает тот факт, что новый вариант подпрограммы не должен отвергать вызовы, допустимые в оригинале, и должен, как минимум, представлять гарантии, эквивалентные гарантиям исходного варианта. Он вправе, хоть и не обязан, допускать большее число вызовов или давать более сильные гарантии.
Как явствует из названия, это правило применимо к обеим формам повторного объявления: переопределению и реализации отложенного компонента. Второй случай важен особо, - утверждения будут связаны со всеми эффективными версиями потомков.
Утверждения подпрограммы, как отложенной, так и эффективной, задают ее семантику, применимую к ней самой и ко всем повторным объявлениям ее потомков. Точнее говоря, они специфицируют область допустимого поведения подпрограммы и ее возможных версий. Любое повторное объявление может лишь сужать эту область, не нарушая ее.
Как следствие, создатель класса должен быть осторожным при написании утверждений эффективной подпрограммы, не привнося излишнюю спецификацию (overspecification). Утверждения должны описывать намерения подпрограммы, - ее абстрактную семантику, - но не свойства реализации. Иначе можно закрыть возможность создания иной реализации подпрограммы у будущих потомков.
Предположим, я написал класс MATRIX, реализующий операции линейной алгебры. Среди прочих возможностей я предлагаю своим клиентам подпрограмму расчета обратной матрицы. Фактически это сочетание команды и двух запросов: процедура инвертирует матрицу, присваивает атрибуту значение обратной и устанавливает логический атрибут inverse_valid. Значение атрибута имеет смысл тогда и только тогда, когда inverse_valid является истинным; в противном случае матрицу инвертировать не удалось, так как она вырождена. В ходе нашего обсуждения случай вырожденной матрицы мы можем проигнорировать.
Конечно же, я могу найти лишь приближенное значение обратной матрицы и готов гарантировать определенную точность расчетов, однако, не владея численными подпрограммами в совершенстве, буду принимать лишь запросы с точностью не выше 10-6. В итоге, моя подпрограмма будет выглядеть приблизительно так:
invert (epsilon: REAL) is
-- Обращение текущей матрицы с точностью epsilon
require
epsilon >= 10 ^ (-6)
do
"Вычисление обратной матрицы"
ensure
((Current * inverse) |-| One) <= epsilon
end
Постусловие предполагает, что класс содержит инфиксную функцию такую, что m1 |-| m2 есть |m1 - m2| (норма разности матриц m1 и m2 ), а также функцию , результатом которой является произведение двух матриц. One - единичная матрица.
Как человек негордый, летом я приглашу программиста, и он перепишет мою подпрограмму , используя более удачный алгоритм, лучше аппроксимирующий результат и допускающий меньшее значение 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 и предложим своим клиентам решение, лучше прежнего.
При усилении предусловия , например, epsilon >= 10 ^ (-5), вызов, корректный для класса MATRIX, мог стать теперь некорректным. При ослаблении постусловия возвращаемый результат стал бы хуже, чем гарантируемый для MATRIX.
Последний комментарий указывает на весьма интересное следствие правила Утверждений Переобъявления. В общей схеме
(рис 16.3) Подпрограмма, клиент и подрядчикутверждения $$\gamma$$ и $$\delta,$$ введенные при повторном объявлении, предпочтительнее для клиентов, если они отличаются от $$\alpha$$ и $$\beta$$ (предусловия - более слабые, постусловия - более сильные). Но клиент класса A, использующий A' благодаря полиморфизму и динамическому связыванию, не может в полной мере воспользоваться более выгодным контрактом, ибо единственный контракт клиента заключен с классом A.
Воспользоваться преимуществом нового контракта можно лишь став непосредственным клиентом A' (пунктирная связь с вопросительным знаком на рисунке 16.3), как в случае:
При этом вы, естественно, объявляете a1 как объект типа A', а не объект типа A, как прежде. В результате теряется универсальность полиморфизма, идущая от A.
Компромисс ясен. Клиент класса MATRIX должен обеспечивать выполнение исходного (более сильного) предусловия, а в ответ вправе ожидать выполнения исходного (более слабого) постусловия. Даже если его запрос динамически подготовлен к обслуживанию классом NEW_MATRIX, воспользоваться новыми возможностями - большей толерантностью входа и большей точностью выхода - ему никак не удастся. Для обращения к улучшенной спецификации клиент должен объявить матрицу типа NEW_MATRIX, тем самым, потеряв доступ к иным порожденным от MATRIX реализациям, не являющимся производными классами самого NEW_MATRIX.
Правило Утверждения Переобъявления великолепно сочетается с теорией Проектирования по Контракту.
Мы видели, что утверждения подпрограммы описывают связанный с ней контракт, в котором клиент гарантирует выполнение предусловия, получая право рассчитывать на истинность постусловия; для поставщика все наоборот.
Наследование совместно с повторным объявлением и динамическим связыванием приводит к созданию субподрядов. Приняв условия контракта, вы не обязаны выполнять его сами. Подчас вы знаете кого-то еще, способного сделать это лучше и с меньшими издержками. Так происходит, когда клиент запрашивает подпрограмму из MATRIX, но благодаря динамическому связыванию может на этапе выполнения фактически вызывать версию, переопределенную в потомке. "Меньшие издержки" означают здесь более эффективную реализацию, как в знакомом нам примере с периметром прямоугольника, а "лучше" - усовершенствование утверждений, в описанном здесь смысле.
Правило Утверждения Переобъявления просто устанавливает, что честный субподрядчик, приняв условия контракта, должен выполнить работу на тех же условиях, что и подрядчик или лучших, но никак не худших.
С позиции Проектирования по Контракту, инварианты классов - это ограничения общего характера, применимые и к подрядчикам, и к клиентам. Правило родительских инвариантов отражает тот факт, что все подобные ограничения передаются субподрядчикам.
Свое истинное значение для ОО-разработки наследование приобретает лишь совместно с утверждениями и двумя приведенными выше правилами. Метафора контрактов и субподрядов - прекрасная аналогия, помогающая разрабатывать корректное ОО-ПО. Несомненно, в этом - одна из центральных идей теории проектирования.
Правило ослабления предусловий может оказаться чересчур жестким в случае, когда наследник понижает уровень абстракции, характерный для его предка. К счастью, есть легкий обходной путь, полностью согласующийся с теорией.
Типичным примером этого является порождение 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. Это - случай излишней спецификации, ограничивающей свободу реализации компонентов потомками класса. |
Правило Утверждений Переобъявления, так как оно сформулировано, является концептуальным руководством. Как преобразовать его в безопасное и проверяемое правило языка?
В принципе, чтобы убедиться в том, что старые предусловия влекут новые, а новые постусловия - старые, следует провести логический анализ тех и других утверждений. К сожалению, это требует наличия сложного механизма доказательства теорем (несмотря на десятилетия исследований в области искусственного интеллекта). Его применение в компиляторе пока не реально.
К счастью, возможно простое техническое решение. Нужное нам правило можно сформулировать через простое лингвистическое соглашение, основанное на том наблюдении, что для любых утверждений a и b:
Итак, гарантируется, что новое предусловие слабее исходного $$\alpha$$ либо равно ему, если оно имеет вид $$\alpha$$ or $$\gamma$$ . Гарантируется, что новое постусловие сильнее исходного $$\beta$$ либо равно ему, если оно имеет вид $$\beta$$ and $$\delta$$ . Отсюда следует искомое языковое правило:
Правило (2) Утверждения Переобъявления
При повторном объявлении подпрограммы нельзя использовать предложения require или ensure. Вместо них следует использовать предложение, начинающееся с:
При отсутствии таких предложений действуют исходные утверждения.
Заметим, что используются нестрогие булевы операторы 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)).
Ситуация с постусловием аналогична. Такое расширение не имеет особого значения, поскольку преобладает более epsilon >= 10 ^ (-20), а его постусловие есть: ((Current * , хотя запись утверждений в программе (а также, вероятно, их расчет во время выполнения при отсутствии средств символьных преобразований) является более сложной.
Правило Утверждения Переобъявления нуждается в небольшом дополнении ввиду возможности при повторном объявлении задать функцию как атрибут. Что произойдет с предусловием функции и ее постусловием, если таковые имелись?
Атрибут доступен всегда, а потому мы вправе считать, что его предусловие равно True. В итоге можно полагать, что предусловие атрибута, согласно правилу Утверждения Переобъявления, было ослаблено.
Но атрибут не имеет постусловий. Мы же должны гарантировать, что он наделен всеми свойствами, заданными исходной функцией. Поэтому (в дополнение к правилу Утверждения Переобъявления) будем считать, что в этом случае автоматически постусловие добавляется к инварианту класса. Плоская форма класса будет содержать это условие в составе своего инварианта.
| Для функции без параметров, формулируя некое свойство ее результата, вы всегда можете выбрать, включать ли его в постусловие или в инвариант. С точки зрения стиля предпочтительно пользоваться инвариантом. Соблюдение этого правила позволит отказаться от внесения изменений в утверждения в будущем, если при повторном объявлении функция становится атрибутом. |
Неформально, правило Утверждения Переобъявления гласит: "Повторное объявление утверждений может лишь сужать область допустимого поведения, не нарушая ее". Сейчас, завершая обсуждение этой темы, приведем строгую формулировку данного свойства.
Пусть подпрограмма реализует частичную функцию r, отображающую множество возможных входных состояний I в множество возможных выходных состояний O. Утверждения подпрограммы определяют правила действия r и ее возможных переопределений.
DOM функции r (подмножество I, на котором r гарантированно вырабатывает результат).x из DOM подмножество RESULTS(x) множества O, такое, что $$r (x) \in RESULTS (x)$$. Так как постусловие не всегда однозначно описывает результат, это подмножество может иметь больше одного элемента.Правило Утверждения Переобъявления означает, что повторное объявление может расширять область определения и сужать множество результатов. Пометив новые множества знаком ', запишем требования, закрепленные этим правилом:
Предусловие устанавливает, что подпрограмма и ее повторные объявления, как минимум, должны принимать некоторые входы ( DOM ), хотя повторные объявления могут это множество и расширить. Постусловие говорит, что результаты, возвращаемые подпрограммой и ее повторными объявлениями, могут, самое большее, содержать значения из RESULTS(x), однако, постусловия при повторных объявлениях могут это множество сузить.
В этом описании состояние системы в период выполнения определяется состоянием (значениями) всех достижимых объектов. Кроме того, входные состояния (элементы I ) также включают в себя значения аргументов. Более подробное введение в математическое описание программ и языков программирования см. в [M 1990].
Ранее мы уже ссылались на универсальные (universal) классы GENERAL и ANY, а также на безобъектный (objectless) класс NONE. Пришло время пояснить их роль и представить глобальную структуру наследования.
Удобно использовать следующее соглашение:
Правило Универсального Класса
Любой класс, не содержащий предложение наследования, неявно содержит предложение вида:
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) Глобальная структура наследования
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 обеспечивают
Вот лишь некоторые компоненты, содержащиеся в классе GENERAL, а значит, доступные всем другим классам. Часть из них была введена и использована в предшествующих лекциях курса:
clone для создания клона (дубля) объекта, а также его "глубинный" вариант deep_clone для рекурсивного дублирования полной структуры объекта;copy для копирования содержимого одного объекта в другой;equal для сравнения объектов (поле-с-полем), а также его "глубинный" вариант deep_equal ;print и print_line - печать простого представления по умолчанию любого объекта (default representation);tagged_out - строка, содержащая представление по умолчанию любого объекта, в котором каждое поле сопровождается своей меткой (tag) (соответствующим именем атрибута);same_type и conforms_to - булевы функции, сопоставляющие тип текущего объекта с типом другого;generator - возвращает имя порождающего (generating) класса объекта, то есть класса, экземпляром которого является данный объект.При обсуждении идеи наследования неоднократно подчеркивался принцип Открыт-Закрыт - право, взяв компонент класса-родителя, переопределить его, возложив на него иные задачи. Могут ли появиться причины запрета такой возможности?
Обсуждение утверждений в начале лекции дало нам теоретическое понимание сути переопределений. Часть "Открыт" принципа Открыт-Закрыт дает возможность изменять компоненты потомков, но под контролем утверждений. Разрешены лишь те повторные объявления, для которых реализация согласуется со спецификацией, заданной предусловием и постусловиям оригинала.
В ряде случаев клиентам класса и клиентам классов потомков нужна гарантия, что компонент не только соблюдает спецификацию, но и пользуется в точности исходной реализацией. Достичь этого можно лишь "заморозив" его реализацию - полностью запретив переопределение компонента. Подобную возможность дает простая языковая конструкция:
frozen feature_name ... is... Остальные объявления - как обычно...
При таком описании ни один из потомков класса не может включать данный компонент в предложения redefine и undefine ни под своим, ни под любым другим именем (смена имен, конечно же, по-прежнему разрешена). Отложенный компонент по своей сути должен быть переопределен и, следовательно, не может быть заморожен.
Чаще всего замороженные (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 были по-прежнему верными.
Приведенные примеры замораживания - это типичные образцы применения механизма, гарантирующего точное соответствие копий и клонов семантике исходного класса.
Замораживание компонентов не следует делать по соображениям эффективности. (Эту ошибку иногда совершают программисты, работающие на C++ или Smalltalk, которым внушили мысль, будто динамическое связывание накладно и его нужно по возможности избегать.) Хотя вызов замороженных компонентов означает отсутствие динамического связывания, это лишь побочный эффект механизма frozen, а не его конечная цель. Выше мы подробно говорили о том, что безопасное
Расширяя базовое понятие класса, мы представляли наследование и универсальность (genericity) как своего рода "партнеров". Объединить их нам позволило знакомство с полиморфными структурами данных: в контейнер - объект, описанный сущностью типа SOME_CONTAINER_TYPE [T] с родовым параметром T - можно помещать объекты не только самого типа T, но и любого потомка T. Однако есть и другая интересная комбинация
Приведем простой, но характерный пример, демонстрирующий необходимость введения ограниченной универсальности. Он поможет в обосновании метода решения поставленной задачи и в выборе соответствующей конструкции языка.
Предположим, что мы хотим объявить класс 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 "<=", задающая порядок для любой пары соответствующих объектов.Переходя к решению этой проблемы, посмотрим, как с такой задачей справлялись другие, не ОО-языки.
В языке 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, обязанных знать свое место - быть компонентами класса - вполне респектабельными членами ОО-сообщества.
Эти наблюдения дают решение. Мы должны оперировать исключительно терминами классов и типов.
Потребуем, чтобы любой фактический параметр, используемый классом 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] ...
Вот некий трюк с нашим примером: спросим себя, возможен ли вектор векторов? Допустим ли тип 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]]] и так далее - приятное рекурсивное приложение ограниченной универсальности.
Конечно же, не все случаи универсальности ограничены. Форма - STACK [G] или ARRAY [G] - по-прежнему существует и называется неограниченной универсальностью. Пример DICTIONARY [G, H -> HASHABLE] показывает, что класс одновременно может иметь как ограниченные, так и неограниченные родовые параметры.
Изучение ограниченной универсальности дает шанс лучше понять неограниченный случай. Вы, конечно же, вывели правило, по которому class C [G] следует понимать как class C [G -> ANY]. Поэтому если G - неограниченный STACK ), а x - сущность, имеющая тип G, то мы точно знаем, что можем делать с сущностью x: читать и присваивать значения, сравнивать ( =, /= ), передавать как параметр и применять в универсальных операциях clone, equal и прочее.
Наша следующая техника адресуется к тем областям Объектной страны, в которых из страха тиранического поведения мы не можем позволить править простым правилам типизации, не встречая никакого сопротивления.
Цель правил типов, введенных вместе с наследованием, в достижении статически проверяемого динамического поведения, так чтобы система, прошедшая проверку при компиляции, не выполняла неадекватных операций над объектами во время выполнения.
Вот два основных правила, представленных в первой лекции о наследовании (лекция 14).
x.f осмысленна лишь тогда, когда базовый класс x содержит и экспортирует компонент f.a как аргумента или при присваивании его некой сущности необходимо, чтобы тип a был совместим с ожидаемым, то есть основан на классе, порожденным от Правило Вызова Компонентов не является причиной каких-либо проблем - это фундаментальное условие всякой работы с объектами. Естественно, что обращаясь к компоненту объекта, нужно проверить, действительно ли данный класс предлагает и экспортирует данный компонент.
Правило
Давайте займемся исследованием примеров этих двух случаев. Рассмотрим для начала полиморфную структуру данных, такую как список геометрических фигур:
figlist: LIST [FIGURE]
В предыдущих лекциях рассматривалась item (i)., где item (i) - i -й элемент списка, идет вразрез с правилом вызова компонентов: item (i) имеет тип FIGURE, а этот класс, в отличие от его потомка RECTANGLE, не содержит в своем составе компонента . Решение, используемое до сих пор, изменяло определение класса, - в нем появлялся атрибут, задающий тип фигуры. Однако это решение не столь элегантно, как нам хотелось бы.
Теперь пример второго рассматриваемого случая. Пусть имеется механизм хранения объектов в файле или передачи их по сети, аналогичный STORABLE, описанному нами ранее. Для получения объекта используем:
my_last_book: BOOK ... my_last_book := retrieved (my_book_file)
Значение, возвращаемое , имеет тип STORABLE библиотеки Kernel, хотя с тем же успехом оно может иметь тип ANY. Но мы не ожидали STORABLE или ANY, - мы надеялись получить именно BOOK. Присваивание my_last_book нарушает правило
Даже если написать собственную функцию , учитывающую специфику приложения и объявленную с подходящим типом, вам не удастся полностью на нее положиться. В отличие от объектов вашего ПО, в котором EMPLOYEE вместо объекта BOOK, файл мог быть подделан, а при сетевом доступе данные могли быть искажены при передаче.
Из этих примеров ясно: нам может понадобиться механизм удостоверения типа объекта.
Решение этой проблемы, возникающей в специфических, но критически важных случаях, должно быть найдено без потери преимуществ ОО-стиля разработки. В частности, мы не хотим возвращаться к той схеме, которую сами и осудили:
if "f типа RECTANGLE" then ... elseif "f типа CIRCLE" then ... и т.д.
Это решение идет вразрез с принципами Единственного Выбора и Открыт-Закрыт. Избежать риска потерь нам помогут два обстоятельства.
И снова запись механизма решения напрямую вытекает из анализа поставленной проблемы. Введем новую форму присваивания, назвав ее попыткой присваивания (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.. Эта схема проверки вполне типична.
Заметим, что мы никогда не нарушаем правило Вызова Компонентов: обращения к r. защищены дважды: статически - компилятором, проверяющим, является ли компонентом класса RECTANGLE, и динамически - нашей гарантией того, что r не Void, а имеет присоединенный объект.
Обращение к элементу списка - потомку класса RECTANGLE, например SQUARE (квадрат), связывает r с объектом, и его диагональ будет участвовать в вычислениях.
Пример с выглядит так:
my_last_book: BOOK
... Сравните с := в первой попытке
my_last_book ?= retrieved (my_book_file)
if my_last_book /= Void then
... "Обычные операции над my_last_book" ...
else
... "Полученное не соответствует ожиданию" ...
end
Необходимость попытки присваивания обусловлена, как правило, тем, что на статически объявленный
Заметьте, как тщательно был спроектирован механизм, дающий разработчикам шанс забыть об устаревшем стиле разбора вариантов (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. В случае Из-за отсутствия в языке универсальности Java активно использует оба механизма. Отчасти это связано с тем, что в отсутствие множественного наследования Java не содержит класса |
Повторное объявление компонентов не требует сохранения сигнатуры. Пока оно виделось нам как замена одного алгоритма другим или - для отложенного компонента - запись алгоритма, соответствующего ранее заданной спецификации.
Но, воплощая идею о том, что класс способен предложить более специализированную версию элемента, описанного его предком, мы вынуждены иногда изменять типы данных. Приведем два характерных примера.
Вот простой пример переопределения типа. Рассмотрим понятие устройства, включив предположение о том, что для любого устройства есть альтернатива, так что устройство можно заменить, если оно по каким-либо причинам недоступно:
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
В этом и проявляется специализирующая природа наследования.
В следующем примере мы обратимся к базовым структурам данных. Рассмотрим библиотечный класс 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.)
Примеры, рассмотренные выше, несмотря на все их различия, объединяет необходимость повторного объявления типов. Спуск по a в set_alternate и other в put_right ; изменяются типы запросов - alternate и right.
Этот аспект повторного объявления выражает следующее правило:
Правило повторного объявления типов
При повторном объявлении компонента можно заменить тип компонента (для атрибутов и функций) или тип формального параметра (для подпрограмм) любым
Правило использует понятие
Любое повторное объявление ведет к специализации, а, следовательно, к изменению типов. Так, с переходом к двунаправленным спискам параметры и результаты функций сменили свой тип на BI_LINKABLE. Отсюда становится понятен тот термин, которым часто описывают политику редекларации типов, - ковариантная типизация (covariant typing), где приставка "ко" указывает на параллельное изменение типов при спуске по диаграмме наследования.
Ковариантная типизация таит в себе немало проблем, которые возникают у создателей компиляторов, нередко перекладывающих их решение на плечи разработчиков приложений.
Правило повторного объявления типов способно свести на нет целый ряд преимуществ наследования. Почему это происходит и каково решение данной проблемы?
Рассмотрим пример с участием класса 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! Для подхода, претендующего на решение проблемы повторного использования, это серьезный порок.
Было бы ошибочно полагать, что проблема неоправданного переопределения возникает лишь там, где структура ориентирована на реализацию, как в 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 пустой указатель. |
Изучив класс LINKED_LIST в тексте приложения A, вы поймете, что проблема еще масштабнее. В теле класса содержится множество объявлений со ссылкой на тип LINKABLE [G], а с переходом к двунаправленным спискам почти все они потребуют повторного определения. Так, вариант представления списка включает четыре ссылки на отдельные элементы:
first_element, previous, active, next: LINKABLE [G]
В классе TWO_WAY_LIST каждая из этих сущностей должна быть объявлена заново. Аналогичная процедура ждет и другие порожденные классы. Многие функции, такие как put_right, имеют "односвязные" аргументы и нуждаются в повторном определении. В итоге реализация TWO_WAY_LIST будет во многом дублировать оригинал.
В отличие от других проблем, решение которых предложено в этой лекции, такое тиражирование кода не связано с тем, что система типов препятствует нам в выполнении задуманного. Повторное объявление ковариантных типов разрешает их переопределение, но заставляет нас заниматься утомительным копированием текста.
Заметим: наши примеры действительно требуют переопределения типа, но ничего более. Все сводится только к этому. Из этого следует решение проблемы - необходимо создать механизм не абсолютного, а относительного объявления
Назовем такое объявление закрепленным (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.
Итак, закрепленные определения есть весьма важное средство сохранения возможности повторного использования при
В качестве опорного элемента можно использовать 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
С введением закрепленных типов нуждается в расширении понятие базового класса типа.
Сначала классы и типы были для нас едины, и это их свойство - отправной пункт ОО-метода, - по существу, сохраняется, хотя нам пришлось немного расширить систему типов, добавляя в классы родовые параметры. Каждый тип основан на классе и для типа определено понятие базового класса. Для типов, порожденных LIST [INTEGER] базовым классом является LIST. На классах основаны и SOME_CLASS.
Закрепление типов - это еще одно расширение системы типов, которое, подобно двум предыдущим, сохраняет свойство выводимости каждого типа непосредственно из класса. Базовым для like anchor является базовый класс anchor в текущем классе. Если anchor есть Current, базовым будет класс, в котором это объявление содержится.
Теоретически ничто не мешает нам записать 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-
При закреплении формального параметра или результата, как в случае
r (other: like Current)
фактический параметр вызова, например, b в a.r(b), должен быть опорно-эквивалентен a.
Не всякое объявление вида x: A в классе A следует менять на x: like Current и не в каждой паре компонентов одного типа следует один из них делать опорным, а другой - закрепленным.
Закрепленное объявление - это своего рода обязательство изменения типа закрепленной сущности при смене типа опорного элемента. Как мы видели, оно не имеет обратной силы: объявив 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
что гарантирует неизменность типов всех узлов дерева.
Устранить последние неясности в понимании закрепленного объявления поможет следующее замечание: это чисто статический механизм, не предполагающий никаких изменений объектов в период выполнения. Все ограничения могут быть проверены в период компиляции.
Закрепленное объявление можно считать синтаксическим приемом, позволяющим переложить переопределения на компилятор. Кроме того, оно является важнейшим инструментом достижения компромисса между повторным использованием и контролем типов.
Последний вопрос, оставшийся пока без ответа, как наследование взаимодействует с принципом Скрытия информации.
В отношениях между классом и его клиентами скрытие информации определяет разработчик класса. Именно он определяет политику в отношении каждого компонента класса: экспортируя его всем клиентам, разрешая выборочный экспорт, или делая компонент закрытым.
Что происходит со статусом экспорта при передаче компонента потомку? Наследование и скрытие информации - ортогональные механизмы. Наследование определяет отношение между классом и его потомками, экспорт - между классом и его клиентами. Класс 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 доступным определенным классам
...
Характерным примером является создание нескольких вариантов одной абстракции.
Представим себе GENERAL_ACCOUNT - класс, содержащий все необходимые операции для работы с банковскими счетами: процедуры open, withdraw, deposit, code (для снятия денег через банкомат), change_code и т.д.,- но не предназначенный для использования клиентами напрямую, а потому не экспортирующий никаких подпрограмм. Его потомки выступают как разные облики родителя: они не содержат новых компонентов и отличаются лишь предложениями экспорта. Один экспортирует open и deposit, второй, наряду с ними, - withdraw и code, и т. д.
(рис 16.12) Разные облики одной абстракцииЭта схема в обсуждении методологии наследования (см. лекцию 6 курса "Основы
Понятие облика (view) является классическим в области баз данных, где необходимо дифференцировать пользователей, работающих с данными, предоставляя им разные права.
Другой пример касается классов, введенных, когда речь шла о множественном наследовании. Компонент right класса CELL скрыт в нем или, точнее говоря, экспортируется лишь классу LIST. Фактически так обстоят дела со всеми компонентами CELL, поскольку этот класс был изначально нацелен на работу со списками. Однако в дереве (классе TREE ), потомке как CELL, так и LIST, right теперь означает доступ к правому брату и является респектабельным членом общества экспортируемых компонентов.
Стратегия экспорта, согласно которой каждый потомок класса имеет свою политику, хотя и усложняет проверку типов, но придает необходимую гибкость действиям разработчика.
Предпринимались и иные попытки. Так, отдельные
При близком рассмотрении отсутствие ясных методологических установок не удивляет. Наследование является воплощением принципа Открыт-Закрыт: позволяя выбрать готовый класс, написанный вами или другим программистом вчера или 20 лет назад, и обнаружить, что с ним можно делать нечто полезное, что даже не предполагалось при его проектировании.
Позволить создателю класса определять, что могут и что не могут использовать потомки класса, - значит лишиться основного свойства наследования.
Пример классов CELL и TREE характерен: при разработке CELL его целью была лишь поддержка работоспособности LIST, а потому right и put_right служили в нем исключительно внутренним целям. И лишь позднее этим компонентам нашли новое применение в классе-потомке TREE. Не будь этой открытости, наследование почти полностью утратило бы свой шарм.
Если нет основы для принятия решения об экспорте компонентов потомкам, то еще более абсурдно пытаться догадаться, что потомки могут экспортировать своим клиентам. Единственная задача разработчика порожденного класса - предоставление своим клиентам как можно более удобного для них класса. Наследование - это лишь средство, которое позволяет быстрее добиться желаемого результата. Все правила ОО-игры определяются утверждениями и ограничениями типизации, - не более того. Найти полезный для клиентов потомка компонент предка - это большая удача, ну а то, как поступал предок с этим компонентом, - экспортировал ли он его, это дело предка и волнует потомка меньше всего.
В итоге, единственной стратегией, сочетающейся с принципиальной открытостью наследования, нам кажется та, что была описана выше: предоставить каждому разработчику возможность самостоятельно решать, что делать с компонентами предка, выбирая собственную политику экспорта в интересах своих клиентов.
Знакомясь с объектным подходом по другим источникам, вы могли видеть в них предостережения использования "наследования реализаций". Однако в нем нет ничего плохого.
Повторное использование имеет две формы: использование интерфейсов и использование реализаций. Любой класс - это реализация (возможно, частичная) АТД. Он содержит как интерфейс, выражающий спецификацию АТД и образующий лишь "вершину айсберга", так и набор решений, определяющих реализацию. Повторное использование интерфейса означает согласие со спецификацией, повторное использование реализации - ваше согласие положиться на свойства класса, а не только на АТД.
Совместно для одних и тех же целей эти две возможности не применяются. Если вы хотите получить некоторое множество возможностей только через их
Эти формы повторного использования взаимно дополняют друг друга и обе совершенно законны.
По сути, их воплощением являются два вида межмодульных отношений, имеющих место при ОО-проектировании программ: клиент обеспечивает повторное использование интерфейсов, наследование поддерживает повторное использование реализаций.
Повторно используя реализацию, вы безусловно принимаете более ответственное решение, так как не можете рассчитывать на неизменность реализации в перспективе. По этой причине, став наследником класса, вы свяжете себя более сильными обязательствами.
В чем же причина недоверия к наследованию реализаций? Я пришел к выводу, что ответ лежит в области психологии. Тридцатилетний программистский опыт оставил нам лишь сомнения насчет самой идеи реализаций. И даже слово "реализация" приобрело в отдельных кругах почти неприличный характер. По этой причине мы ведем речь о проектировании и анализе, а если и упоминаем реализацию, то начинаем разговор с "но", "лишь" или "только".
Объектная технология в корне меняет все: ОО-реализации настолько элегантны, полезны, с ясно выраженной корректностью, что уже можно забыть об неприятных оттенках этого слова в языке. Для многих из нас программа часто оказывается вещью наиболее абстрактной, дает описание на самом высоком уровне и наиболее понимаема, чем большая часть того, что в анализе и проектировании провозглашается "величайшим достижением мысли".
Ряд основных различий между понятиями, о которых шла речь, мы представили в виде таблицы.
Итак, есть два отношения - "быть потомком" и "быть клиентом"; две формы повторного использования - интерфейсов и реализаций; скрытие информации и его отсутствие; защита от изменений в поставляемых модулях и отсутствие таковой.
Наличие альтернатив в любом случае не вносит противоречий, и в зависимости от контекста каждый из вариантов вполне оправдан. Отважимся на смелый шаг и сведем эти противоположности в одно целое:
| Клиент | Потомок |
|---|---|
Повторное использование интерфейсов Информация скрывается Исходная реализация защищена |
Повторное использование реализаций Информация не скрывается Исходная реализация не защищена |
Возможно, есть и другие подходы к решению этой проблемы, но я не знаю ни одного столь же простого, доступного и практичного.
Говоря о наследовании и скрытии информации, нельзя обойти вопрос о выборочном экспорте компонентов. Класс A, выборочно экспортирующий f классу B:
class A feature {B, ...}
f...
...
делает f доступным в реализации собственных компонентов B. Потомки B, в свою очередь, имеют доступ к реализации предка, а потому они должны быть вправе обращаться ко всем доступным B возможностям, в том числе, к f.
Практические наблюдения подтверждают это теоретическое обоснование. Все, что необходимо классу, обычно требуется и его потомкам. Однако нам не хотелось бы с появлением очередного порожденного класса B возвращаться в A и расширять его предложение экспорта.
Согласно принципу Скрытия информации, а также принципу Открыт-Закрыт, разработчику A дано право решать, делать ли f доступным для B, однако, ему запрещено ограничивать свободу разработчика B. Тем самым, имеет место правило:
Правило наследования при выборочном экспорте
Выборочно экспортированный компонент доступен как самому классу, так и всем его потомкам.
GENERAL и допускающий настройку его наследник обеспечивают переопределяемые компоненты, представляющие общий интерес для всех создаваемых разработчиком классов. Класс NONE замыкает решетку наследования снизу.Иную точку зрения на взаимосвязь наследования и скрытия информации см. в [Snyder 1986].
Перепишите и упростите ранее созданную реализацию защищенного стека, сделав класс STACK3 потомком, а не клиентом STACK, чтобы избежать излишних обходных путей. ( Подсказка: см. правила взаимодействия наследования и скрытия информации.)
Напишите класс VECTOR, представляющий числовые вектора (кольцо) с обычными математическими операциями. Сам класс рекурсивно должен относиться к численному типу, допуская вектора векторов. Возможно, для этого вам придется самостоятельно дописать класс NUMERIC (или воспользоваться готовым из [M 1994a]).
В случае, когда x1 имеет тип X, y1 имеет тип Y, и Y является потомком X, оператор y1 := x1 будет недопустимым. Однако полезным мог бы показаться универсальный компонент extract, такой, что y1.extract (x1) копирует значения полей объекта x1 в соответствующие поля объекта y1 при условии, что ни в одной из этих ссылок не содержится Void.
Объясните, почему компонент extract стоит отвергнуть. ( Подсказка: обратитесь к вопросам корректности, в частности, к понятию инварианта.) Выясните, можно ли спроектировать удовлетворительный механизм, решающий эту задачу каким-то иным способом.
Для получения официальных документов о завершении программы дополнительного профессионального образования (удостоверения о повышении квалификации, дипломов о профессиональной переподготовке и MBA) необходимо предоставить:
Внимание! Вы можете не заказывать доставку бумажной версии официального документы, а скачать его в электронном виде и распечатать самостоятельно. Информация о выданном документе в течение 1 месяца загружается в Федеральную информационную систему «Федеральный реестр сведений о документах об образовании и (или) о квалификации, документах об обучении» - ФИС ФРДО.
Доступ на новый сайт осуществляется с использованием адреса электронной почты, который был указан вами при регистрации на "старом". Мы постарались перенести все ваши данные с прежнего ресурса, однако не исключена вероятность потери части информации.
При возникновении проблемы со входом, воспользуйтесь функцией сброса пароля
Если вы обнаружите несоответствия, пожалуйста, сообщите нам.