Опубликован: 04.04.2012 | Уровень: для всех | Доступ: платный
Лекция 4:

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

< Лекция 3 || Лекция 4: 123 || Лекция 5 >
Аннотация: В лекции объясняется роль контрактов при наследовании, как изменяются контракты в процессе наследования. Класс наследует от родителя не только компоненты, но и инвариант класса. Методы наследуют предусловия и постусловия. Наследники обязаны выполнять контракт родителя, но могут брать новые обязательства, делая контракт более привлекательным для клиентов класса. В лекции подробно рассматривается также общая структура наследования и проблемы, возникающие при множественном наследовании.

Что происходит с контрактами?

В определение метода входит не только имя, сигнатура и (для эффективных методов) реализация, — определение может также включать предусловие и постусловие. Для класса может быть задан инвариант класса. Мы знаем, что означают эти понятия в отсутствие наследования. А как наследование влияет на эту картину?

Аккумуляция инварианта

Первое правило воздействует на инвариант класса. Оно отражает взгляд на наследование как на отношение "является" и на роль наследования как механизма таксономии. Указание того, что класс TAXI является наследником класса VEHICLE, не только избавляет от дублирования кода, но и задает полиморфизм: когда ожидается транспортное средство LIST[VEHICLE], то возможно появление такси. Отсюда следует, что любое ограничение, определенное для экземпляров родительского класса, должно применяться и к наследникам. В классе VEHICLE находим:

invariant
  not_too_small: count >= 0
  not_too_large: count <= capacity

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

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

invariant
   legal_limit: capacity = 4
   …Другие предложения, которые воздействуют на методы, специфические для такси…

Эти утверждения дополняют утверждения родителя. В плоской форме класса вначале перечисляются утверждения родителя, затем потомка.

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

Следующее определение задает семантику.

Определение: инвариант класса
Инвариантом класса является утверждение (p1 and... and pn) and then i, где i является утверждением, явно заданным в собственном инварианте класса (или True в случае его отсутствия), а p1... pn являются (рекурсивно) инвариантами родительских классов, если таковые есть.

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

Ослабление предусловия и усиление постусловия

Вторая проблема — влияние наследования на предусловие и постусловие — ведет к важному правилу разработки ПО. Для ее понимания следует рассмотреть ее в контексте с полиморфизмом и динамическим связыванием:

 Контекст адаптации контракта

Рис. 3.1. Контекст адаптации контракта

Рассмотрим метод r из класса поставщика S, заданный с предусловием и постусловием (названными α и β на рисунке). Потомок T переопределяет метод r, что может быть эффективизацией (заданием реализации), если r был отложенным в S. Возникает вопрос: какие изменения в контракте (α и β) допустимы для новой версии r?

Для получения правильного ответа следует рассмотреть эту ситуацию с позиций класса C — клиента класса S, в методах которого встречается вызов x.r (...), где x по объявлению имеет тип S. Контракт устанавливает права и обязанности клиента: он должен перед вызовом метода гарантировать выполнение предусловия, в этом случае по завершении метода ему гарантируется выполнение постусловия. Возможна, например, такая схема работы клиента:

if x._ then
  x.r (...)
    — Здесь гарантируется, что выполняется x.β
end
Листинг 3.1.

Это прямое применение принципа проектирования по контракту. Но теперь включается полиморфизм. Наш x, типа S по объявлению, во время выполнения не обязан быть присоединенным к прямому экземпляру класса S, он может обозначать объект класса T или экземпляр любого другого класса потомка S.

Из-за динамического связывания будет вызвана версия r, переопределенная потомком. Но, конечно же, клиенту нет необходимости знать это — он заключил контракт с классом S, более того, во время написания клиентского класса C класс T мог вообще не существовать. Приведенный выше код мог быть частью кода такого метода класса C :

do_something_with_an_S_object (x: S)

Как видите, в данном случае x — это аргумент метода, имеющий тип S. Фактический аргумент, приходит в класс C, возможно, из внешнего мира, и его тип должен быть лишь согласован с S, так что сам класс C изначально может находиться в неведении о фактическом типе x в момент вызова. Класс T мог быть написан и добавлен в иерархию наследования спустя два года после создания класса C. Некий другой программист, знающий о появлении класса T, мог написать в своей программе: c1.do_something_with_an_S_object(t1), где c1 — класса C, а t1 — класса T. Можно только пожалеть автора исходного кода класса C, который должен написать клиентский код и гарантировать его корректность даже в том случае, когда он имеет дело с объектами, не существовавшими в момент написания кода.

Для обеспечения корректности C может опираться на известные ему свойства поставщиков, таких как S, и их методов, таких как r. Это строго ограничивает потомков, например T, не позволяя им "баловаться" с контрактом, допуская, например, такие вольности:

  • усиливать предусловие r в T. В этом случае вызов уже не гарантировал бы нормального выполнения, поскольку клиент гарантирует выполнения условия α, а этого становится недостаточно для объектов типа T ;
  • ослаблять постусловие r в T. В этом случае при вызове клиенту не гарантировалось бы выполнение ожидаемого постусловия β.

Другими словами, T как субподрядчик должен выполнять обязательства, взятые исходным подрядчиком S, которого только и знают такие клиенты, как C.

В этом обсуждении "a сильнее b" означает (a implies b) and not (a = b). Утверждение "быть слабее" означает обращение приведенной формулы.

Из этих наблюдений следует правило:

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

Переопределяемая версия метода может только: сохранить или ослабить предусловие метода; сохранить или усилить постусловие метода.

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

Как пример ослабления предусловия приведем предусловие метода take класса TAXI, которое говорит, что для посадки в такси пассажир должен находиться в радиусе 100 метров от текущей позиции такси. Потомок DISPATCH_TAXI ослабляет предусловие этого метода, требуя лишь, чтобы пассажир находился в пределах региона1 Для корректности понимайте под границами региона границы административной области, расширенные на 100 метров.. Понятно, что выполнение этого условия влечет и выполнение исходного предусловия метода из класса TAXI.

Как можно в языке программирования задать переопределение контракта? Решение, принятое в Eiffel (и в других нотациях, использующих проектирование по контракту), просто:

при переопределении метода не разрешается запись предусловия и постусловия в базисной форме — require и ensure ;

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

для ослабления предусловия следует использовать предложение в форме require else new_pred. В этом случае семантика такова: переопределяемый метод имеет предусловие old_pred or else new_pred, где old_pred наследуемое предусловие;

для усиления постусловия следует использовать предложение в форме ensure then new_post. В этом случае семантика такова: переопределяемый метод имеет предусловие old_post and then new_post, где old_post — наследуемое постусловие.

Это решение удовлетворяет правилу, поскольку по правилам логики: "a implies a or b" и "а and b implies a".

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

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

Предусловие take в классе DISPATCH_TAXI, отражающее наше обсуждение, имеет вид:

require else
in_zone: customer.is_in_zone (Current)

Много других примеров можно найти при анализе текстов библиотеки EiffelBase.

Контракты в отложенных классах

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

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

Пример для forth является типичным:

forth
    — Передвинуть курсор к следующей позиции
  require
    in_range: not after
  deferred
  ensure
    increased: index = old index + 1
  end

Здесь рассматривается метод, изменяющий положение курсора в списке. Как при этом перемещается курсор, зависит от реализации. По этой причине метод является отложенным. Но какую бы реализацию не выбрал потомок, он должен работать корректно — для любой позиции, отличной от after, индекс курсора должен увеличиваться на 1. Все остальное допустимо до тех пор, пока реализация удовлетворяет этим требованиям.

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

Точно так же search позволяет подключать различные версии forth и других программ, определенных потомками класса LINEAR, но при условии, что они удовлетворяют контрактам, заданным в классе LINEAR для этих методов.

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

Контракты усмиряют наследование

Как для отложенных, так и для эффективных классов правила адаптации контрактов служат основой корректного использования наследования. Полиморфизм и динамическое связывание являются мощным механизмом, который по этой причине одновременно и опасен. Так как каждый тип может адаптировать наследуемый компонент, как можно гарантировать, что вызов myvehicle. turn _ left после переопределения не заставит ваше транспортное средство поворачивать направо, или останавливаться, или ездить по кругу? Гибкость, которую привносит в программирование комбинация переопределения, полиморфизма и динамического связывания, может зайти слишком далеко. Как проектировщик метода turn _ left, требующего левого поворота, вы хотите позволить потомку дать собственную реализацию, но при условии сохранения исходной семантики, гарантирующей левый поворот.

Правило переопределения контракта и связанный с ним механизм языка ( require else... ) обеспечивают нужную степень контроля. Можно указать границы, в рамках которых допустима свобода реализации. Наследование и связанная с ним техника — не просто мощная форма повторного использования, но и техника субподрядов. Классы используют переопределение как субподряд на выполнение некоторых операций потомками. Из-за полиморфизма и динамического связывания клиент не знает, какой субподрядчик будет работать в текущем вызове. Ситуация аналогична покупке iPhone: вы не знаете, где сделана та или иная часть аппарата — в Шанхае, Тайване, Бангалоре или Будапеште. Правило переопределения контракта с успехом может быть названо правилом сохранения честного субподряда.

< Лекция 3 || Лекция 4: 123 || Лекция 5 >
Надежда Александрова
Надежда Александрова

Уточните пожалуйста, какие документы для этого необходимо предоставить с моей стороны. Курс "Объектно-ориентированное программирование и программная инженения". 

Юрий Симонов
Юрий Симонов
Россия, Москва, Московский Государственный Университет им. М.В. Ломоносова, 2011
Юрий Бедарев
Юрий Бедарев
Россия, Новосибирская область