Понятие "контрактного проектирования" (Design by Contract) - сердцевина "Метода Эйфеля", разработанного автором систематического подхода к созданию надежного объектно-ориентированного программного обеспечения. Это понятие столь же важно для ОО парадигмы, как и классы, объекты, наследование, полиморфизм и динамическое связывание. Для получения уверенности в надлежащей работе ОО ПО необходим систематический подход к специфицированию и реализации ОО программных сущностей и их взаимосвязей в программной системе. Эта статья содержит введение в подход "Контрактное Проектирование", предложенный компанией Interactive Software Engineering.

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

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

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

Но и это еще не все. Если мы хотим быть уверенными в надлежащей работе ОО ПО, то мы нуждаемся в систематическом подходе к специфицированию и реализации ОО программных сущностей и их взаимосвязей в программной системе. Этот подход, известен как "Контрактное Проектирование" ("Design by Contract") и в его рамках программная система рассматривается в виде множества взаимодействующих компонентов, чьи отношения строятся на основе точно определенной спецификации взаимных обязательств - контрактов. Контрактное Проектирование обеспечивает:

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

Все идеи, подробно рассмотренные здесь, являются составной частью Эйфеля (Eiffel), который надо рассматривать не только (и не столько!) как конкретный язык программирования, а как метод разработки ПО [1-3].

Специфицирование и отладка

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

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

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

Теория Контрактного Проектирования предполагает приложение спецификации к каждому программному элементу. Эти спецификации (или "контракты") и управляют взаимодействием элемента с окружающим его миром.

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

Понятие Контракта

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

Для выражения условий контракта (например, между авиаперевозчиком — поставщиком и пассажиром — клиентом) часто является удобной табличная форма (см. табл. 1).

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

Те же идеи применимы и при разработке программ. Рассмотрим программный элемент E. Чтобы достичь своей цели (выполнить свой контракт), E использует определенную стратегию, которая включает ряд подзадач t1,..., tn. Если подзадача ti является нетривиальной, то она будет выполняться с помощью вызова некоторой процедуры R. Иными словами, E передает работу по контракту процедуре R. Такая ситуация должна управляться хорошо определенным реестром обязательств и выгод — контрактом.

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

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

Что здесь действительно важно, так это то, что мы не можем удовлетвориться неформальной спецификацией контракта в представленном виде. Если следовать духу "бесшовности" (поощряющем включать в один программный текст всю относящуюся к делу информацию на всех уровнях), то мы должны дополнить текст процедуры записью необходимых условий. Предполагая, что имя процедры - put, можно записать (используя синтаксические соглашения Eiffel) в виде части generic класса DICTIONARY[ELEMENT]:


put (x: ELEMENT; key: STRING) is // Вставить x так, чтобы его можно было найти по ключу

require count <= capacity not key.empty do // ...некоторый алгоритм вставки

ensure has (x) item (key) = x count = old count + 1 end

Здесь раздел require содержит входное условие или предусловие; раздел ensure вводит выходное условие - или постусловие. Оба условия являются примерами утверждений (assertions) или логических условий (пунктов контракта), ассоциированных с программными сущностями. В предусловии, count - это текущее число элементов, а capacity - максимальное число элементов в таблице. В постусловии, has - это запрос, отвечющий, на вопрос - присутствует ли уже элемент в таблице, item - возвращает элемент, ассоциированный с определенным ключом. Наконец, old count ссылается на величину count на входе в процедуру.

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

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

  • запросы типа Да/Нет: is_empty, is_full?
  • другие запросы: in_valve, out_valve (оба типа VALVE), gauge_reading, capacity?
  • команды: fill, empty?

Затем, чтобы охарактеризовать команды (например, fill), мы можем использовать предусловия и постусловия:


fill is // Заполнить резервуар жидкостью

require in_valve.open out_valve.closed deferred // т.е. без реализации

ensure in_valve.closed out_valve.closed is_full end

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

Единственной ОО методикой, которая в полной мере интегрирует эти идеи на уровне анализа и проектирования, является Business Object Notatoion (BON) [5], которая обеспечивает соответствующую графическую нотацию.

Таблица 1. Пример контракта

  Обязательства Выгоды
Клиент Должен гарантировать удовлетворение предусловий. Быть в аэропорту Санта Барбара по крайней мере за 5 минут до обозначенного в расписании времени отлета. Не иметь недозволенных вложений в багаже. Заплатить за билет. Может ожидать выгоды от постусловия. Попасть в Чикаго.
Поставщик Должен гарантировать выполнение постусловия. Доставить клиента в Чикаго. Может предполагать, что предусловие выполняется. Нет никакой нужды перевозить пассажира, если он опоздал, имеет недозволенные вложения в багаже или не заплатил за билет.

Контракт о включении в таблицу

  Обязательства Выгоды
Клиент Должен гарантировать удовлетворение предусловий. Удостовериться, что таблица не заполнена полностью и что ключ не является пустой строкой. Может ожидать выгоды от постусловия. Получить обновленную таблицу, где присутствует заданный элемент, ассоциированный с задданным ключом.
Поставщик Должен гарантировать выполнение постусловия. Записать заданный элемент в таблицу и установить его связь с заданным ключом. Может предполагать, что предусловие выполняется. Нет необходимости выполнять какие-либо действия, если таблица заполнена до отказа или ключ является пустой строкой.

Инварианты

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


invariant 0 <= count count <= capacity

А инвариант класса TANK может установить, что is_full на самом деле означает "почти полный":


invariant is_full = (0.97 * capacity <= gauge) and gauge <= 1.03 * capacity) ...другие clauses

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

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

Документирование

 

Еще одно важное применение контрактов - обеспечение стандартного способа документирования программных сущностей - классов. Программисты, разрабатывающие клиентские приложения нуждаются в надлежаще оформленном описании интерфейсных свойств класса; основанный на контрактах подход позволяет использовать версию класса, известную под названием "короткая форма" (short form). В ней опущены все детали реализации, но зато представлена исчерпывающая информация об использовании класса - контракт.

В среде EiffelBench, короткую форму класса можно получить в интерактивном режиме - по нажатию одной клавиши, причем в любом желаемом формате (RTF, HTML, MIF или MML для FrameMaker, TEX, troff, Postscript и т.д.) через один из предопределенных в среде фильтров, к которым можно добавить и любой другой фильтр - данный механизм носит открытый характер.

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


class interface DICTIONARY [ELEMENT] feature put (x: ELEMENT; key: STRING) is // Вставить x так, чтобы его можно было найти по ключу. require count <= capacity not key.empty do ...некоторый алгоритм вставки... ensure has (x) item (key) = x count = old count + 1 end ...спецификации итерфейсов других особенностей... invariant 0 <= count count <= capacity end

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

Тестирование, отладка и гарантии качества

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

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

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

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

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

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

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

Класс B, который наследует от класса A, может обеспечить новую декларацию для некоторой унаследованной особенности r, до того определенной в A. Например, специализирующая реализация класса DICTIONARY может переопределить алгоритм для put. Такого рода переопределения несут в себе потенциальную опасность, так как переопределенная версия может, в принципе, иметь совершенно другую семантику. Это особенно настораживает в присутствии полиморфизма, который означает, что в вызове типа a.r — целевой объект вызова a, через статически декларированный тип A, может фактически во время исполнения быть "пришпилен" к объекту типа B. Затем, вступающее в действие динамическое связывание предполагает, что в этом случае будет вызываться именно "B - версия" r. По-существу, мы имеем здесь дело с производным контрактом - суб-контрактом ("subcontract"): A передает контракт ("суб-контрактирует") для r объекту B для цели соответствующего типа. Но суб-контрактор должен быть связан оригинальным контрактом. Если клиент осуществляет вызов в форме: if a.pre then a.r end, то ему должен быть гарантирован обещанный контрактом результат: сам же вызов будет выполняться корректно, так как удовлетворяется предусловие (предполагая, что pre означает предусловие для r); на выходе же a.post будет истинно, где post - постусловие для r.

Отсюда следует принцип производного контрактирования (subcontracting): переопределенная версия r может сохранять или ослаблять предусловие и сохранять или усиливать постусловие. Усиление предусловия или ослабление постусловия (что можно обозначить как "нечестный производный контракт") способно привести к катастрофе. Язык Eiffel содержит правила для переопределения утверждений в соответствии с принципом производного контрактирования.

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

Обработка исключений

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

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

В таких случаях имеют смысл три возможные реакции.

  • Повтор (Retrying): при условии, что возможна альтернативная стратегия. Процедура восстановит инвариант и сделает еще одну попытку, задействовав эту новую стратегию.
  • "Организованная паника" (Organized panic): никаких альтернатив не существует. В этом случае необходимо восстановить инвариант, завершить работу и рапортовать вызывавшей процедуре о неудаче, инициируя новое исключение.
  • "Ложная тревога" (False alarm): в этом случае на самом деле можно продолжить работу после принятия некоторых корректирующих мер. Впрочем, этот случай достаточно редок (к сожалению - ведь его легче всего реализовать).

Механизм исключений непосредственно следует из этого анализа. Он основан на понятии "раздел спасения" (rescue clause), ассоциированном с процедурой, и "команде повтора" (retry instruction), реализующей повторную попытку выполнения. Легко видеть здесь сходство с контрактами из обыденной жизни, которые обычно включают пункт или раздел, связанный с действиями в исключительных, заранее незапланированных обстоятельствах. Если такой "rescue clause" присутствует в контракте, то любое исключение, случившееся во время выполнения процедуры, прервет исполнение тела (раздел "do") и инициирует выполнение раздела "rescue". Этот раздел может содержать одну или более инструкций; одна из них - "retry" вызовет повторное выполнение тела процедуры (раздел "do"). Целочисленная локальная переменная failure всегда инициализируется при входе в процедуру нулевым значением (но, конечно, после retry).

Приведем пример, иллюстрирующий этот механизм [2,3]. Пусть низкоуровневая процедура unsafe_transmit передает сообщение по сети. Мы не имеем никакого контроля над этой процедурой, но знаем, что ее действия могут закончиться неудачей. В этом случае хотелось бы бы повторить все снова; после 100 таких неудачных попыток было бы целесообразно их прекратить, передав исключение вызывающей процедуре. Механизм Rescue/ Retry непосредственно поддерживает такую схему:


attempt_transmission (message: STRING) is // Попытка передачи сообщения по

// коммуникационной линии с использованием низкоуровневой (например, Си ) процедуры

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

// безуспешных попыток, прекратить действие // (инициируя исключение в вызывающей процедуре)

local failures: INTEGER do unsafe_transmit (message) rescue failures := failures + 1 if failures < 100 then retry end end

Подведем итоги

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

  • Параллельность и распределенность - принципы контрактного проектирования позволяют по новому взглянуть на фундаментальные проблемы параллельного (одновременного - concurrent) и распределенного ОО программирования (избегая при этом проблемы "аномальной наследственности" и некоторых других подобных псевдопроблем, проистекающих из непонимания существа ОО технологии). Принятый в Eiffel подход, основанный на контрактном проетировании и реализованный для ISE Eiffel 4.2 подробно разобран в [1,4].
  • Расширенный язык спецификаций - позволяет выражать еще более представительное множество утверждений.

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

Бертран Мейер (Bertrand Meyer) - основатель и президент компании Interactive Software Engineering (ISE) со штаб-квартирой в Санта Барбара, Калифорния. Один из признанных классиков объектного подхода, которому он посвятил 10 книг, д-р Мейер автор языка Eiffel. Член редколлегий и колумнист ряда ведущих компьютерных журналов.

Литература

  1. B. Meyer, "Object-Oriented Software Construction", Prentice Hall, 1997.
  2. B. Meyer, "Applying Design by Contract", //Computer, Vol.25, No. 10, October 1992, pp. 40-51.
  3. B. Meyer, "Eiffel: The Language", //Prentive Hall, 1992.
  4. B. Meyer, "Systematic Concurrent Object-Oriented Programming", //Communicatons of the ACM, Vol. 36, No. 9, September 1993, p. 56-80.
  5. K. Walden and J.-M. Nerson, "Seamless Object-Oriented Software Architecture: Analysis and Design of Reliable Systems", //Prentice Hall, 199.