1 сентября 2008 в 18:14

Проектирование по контракту

Два года назад мне посчастливилось побывать на лекции замечательного человека, одного из разработчиков языка Eiffel, Бертрана Мейера. Он читал в нашем университете (СПб ГУ ИТМО) лекцию о довольно интересной концепции проектирования ПО. Называется она «проектирование по контракту». Суть этой концепции я попытаюсь описать ниже.

Вот, например, когда вы с клиентом договариваетесь о совместной работе, то вы заключаете контракт. Т.е. вы описываете обязанности обоих сторон и возможные последствия в случае неожиданных ситуаций. Данный подход можно применить и к разработке ПО, где в качестве сторон выступают программные модули.
Проектирование по контракту является довольно простой, но, в то же время, мощной методикой, основанной на документировании прав и обязанностей программных модулей для обеспечения корректности программы. Я считаю, что корректная программа – это программа, которая выполняет не больше и не меньше того, на что она претендует.
Центральными фигурами этой концепции являются утверждения (assertions) – это булевы выражения, описывающие состояние программы. Можно выделить три основных типа утверждений: предусловия, постусловия и инварианты класса.

Предусловия.
Предусловия – это требования подпрограммы, т.е. то, что обязано быть истинным для выполнения подпрограммы. Если данные предусловия нарушены, то подпрограмма не должна вызываться ни в коем случае. Вся ответственность за передачу «правильных» данных лежит на вызывающей программе.
Этот подход противоречит многим концепция, которым учат в огромном количестве учебников. Там постоянная проверка вынесена во главу угла. Проектирование по контракту утверждает обратное – лишние проверки могут только навредить. Вообще, принцип проектирования по контракту «смотрит» на проектирования с позиции «Сложность – главный враг качества» (но об этом в следующий раз ;) ).

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

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

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

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

Чтобы не быть голословным приведу пример (здесь и далее весь код написан на C#).
Давайте рассмотрим такую ситуацию: пользователь вводит код зап. части по каталогу и хочет получить информацию об этой детали. Известно, что код состоит из 9 символов. Вот классический пример реализации данной функции:

private ComponentProvider componentProvider;

public ComponentInfo GetComponentInfo(string id)
{
   if( String.IsNullOrEmpty(id) || id.Length != 9)
   {
      throw new Exception(“Wrong id”);
   }
   return componentProvider.GetComponent(id);
}


* This source code was highlighted with Source Code Highlighter.


Во многих классических учебниках по созданию «качественного ПО» этот пример назвали бы отличным. Но вот с точки зрения принципа проектирования по контракту этот пример является ошибочным.
Начнем с того, что проверку на валидность значения атрибута id должна осуществлять вызывающая программа. Ведь именно она (вызывающая программа) может воспользоваться несколькими вариантами: завершить работу, выдать предупреждение и начать считывать новое число. А может существует возможность вводить только последние 4 цифры, а первые пять программа сформирует исходя из VIN-номера автомобиля. В любом случае, какой бы вариант не использовался, он ни как не связан с функцией GetComponentInfo().
Тогда исходный пример перепишем в следующем виде

public ComponentInfo GetComponentInfo(string id)
{
   return componentProvider.GetComponent(id);
}


* This source code was highlighted with Source Code Highlighter.


А вот дальше начинается самое интересное :). Если уж мы заявили, что данная функция возвращает объект типа ComponentInfo, то мы должны обеспечить это. Ведь метод GetComponent объекта componentProvider может вернуть значение null. И тогда уже вызывающей программе придется делать проверку на null-значение, иначе можем «нарваться» на «object reference» исключение. Т.е. пример стоит переписать так:

public ComponentInfo GetComponentInfo(string id)
{
   ComponentInfo componentInfo = this.componentProvider.GetComponent(id);
   if(componentInfo == null)
   {
      throw new ContractException(“Can’t find component”);
   }
   return componentInfo;
}


* This source code was highlighted with Source Code Highlighter.


По крайней мере, так говорится во многих статьях и примерах. НО. Давайте рассуждать логически. Если уж мы используем принцип проектирования по контракту, то, опираясь на мое «золотое правило», мы можем быть уверены, что метод GetComponent() объекта componentProvider вернет нам истинное значение (т.к. его параметр по определению истинный). Поэтому, я не вижу смысла загромождать программу лишним кодом. Но с другой стороны, объект типа ComponentProvider может быть спроектирован сторонним разработчиком, который не придерживался принципа проектирования по контракту. Вот тут и встает дилемма. Вот мой совет для данной ситуации – если вы вызываете подпрограмму, которая была написана вами, то не пишите лишнего кода. Доверяйте себе. Но если вы вызываете подпрограмму, написанную сторонним разработчиком, и вы не уверены в ней, то произведите проверку. Самый наглядный пример – функция извлечения квадратного корня Math.Sqrt(). Понятно, что нельзя извлечь квадратный корень из отрицательного числа, но если в данную функцию передать отрицательное число, то никакого исключения сгенерировано не будет, а вернется значение типа NaN.

«Доверительный» вариант
public ComponentInfo GetComponentInfo(string id)
{
   try
   {
      return this.componentProvider.GetComponent(id);
   }
   catch(ContractException ex)
   {
     throw new ContractException(ex.ToString());
   }
}


* This source code was highlighted with Source Code Highlighter.


Все приведенные примеры основываются на некоторых ваших (команды разработчиков) соглашениях. Но существуют и специальные расширения для различных языков программирования. Например, препроцессор iContract для Java или расширение eXtensible C#.

Самое главное, что использование принципа проектирования по контракту поможет вам обеспечить автоматическое тестирование вашего кода.
Данную статью можно назвать введением в принцип проектирования по контракту. Если появится интерес со стороны пользователей, то я продолжу серию об этом принципе. Ведь все, что я описал – это лишь верхушка айсберга.
Никита @ifa
карма
39,6
рейтинг 0,0
Самое читаемое Разработка

Комментарии (56)

  • –9
    под кат
  • +2
    Респект за статью, пишите еще. Поддержал, как смог
    • +8
      Спасибо. буду стараться.
      • 0
        Когда ожидать продолжения?
    • НЛО прилетело и опубликовало эту надпись здесь
  • +2
    Спасибо! С удовольствием прочитал бы продолжение!

    А кстати избавить код от некрасивых проверок возвращаемого функциями значения также может паттерн Null object
    en.wikipedia.org/wiki/Null_Object_pattern
  • 0
    Спасибо за топик хорош, заряд истрачен — отплюсую позже.
    Кроме того, наличие постусловия в подпрограмме гарантирует ее завершения (т.е. не будет бесконечного цикла, например)
    ...не гарантирует…
    или
    … гарантирует завершение
    Поправьте плз.
  • 0
    Спасибо за топик хорош, заряд истрачен — отплюсую позже.
    Кроме того, наличие постусловия в подпрограмме гарантирует ее завершения (т.е. не будет бесконечного цикла, например)
    ...не гарантирует…
    или
    … гарантирует завершение
    Поправьте плз.
    • +1
      Похоже, фокус остался на кнопке внутри скрытого элемента.
    • 0
      да, конечно, гарантирует завершениЕ подпрограммы. Спасибо! Исправил.
  • +1
    Кстати, да. Перенесите топик в ненормальное программирование, или еще куда — пусть и другие прочитают.
  • +1
    Я у себя для такого случая завёл макросы
    (сразу оговорюсь, это Си++)
    precond(ptr != 0); invariant(sz = size()); postcond(*ptr > 0);

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

      public ComponentInfo GetComponentInfo(string id)
      {
         ComponentInfo info = new ComponentInfo();
         try
         {
           //собираем этот объект разными способами
          //с возможной генерацией исключения   
         }
         catch(ContractException ex)
         {
            //обеспечиваем возвращение валидного объекта
            info.Name = «Не известно»;
            info.Description = «Искомая деталь не найдена»;
            info.Price = 0;
         }
         return info;
      }* This source code was highlighted with Source Code Highlighter.


  • 0
    Кстати, в «доверительном варианте» try-catch не нужен, исключение же всё равно полетит дальше.
    Или это для примера так написано?
    • 0
      да, try-catch можно смело убрать :)
      пытался сделать пример нагляднее
  • НЛО прилетело и опубликовало эту надпись здесь
  • 0
    исходя из ваших примеров (первый и последний) вы просто выкинули вылидацию
    >Вся ответственность за передачу «правильных» данных лежит на вызывающей программе.
    поясните пожалуйста вот эту часть

    у вас сохранились конспекты этой лекции?
    • 0
      Да, валидацию я выкинул. Это как раз и есть основа принципа проектирования по контракту.
      Очень много литературы учит нас использовать так называемое «защитное программирование» (defensive programming) (в следующих статьях постараюсь сделать сравнение обоих подходов).
      Оно и говорит, что лучше на всякий случай перестраховаться и проверить (доверяй, но проверяй ;)).
      А проектирование по контракту предлагает использовать некое соглашение, контракт, чтобы убрать лишние проверки (тем самым уменьшив объем кода и упростив читабельность программы).
      Попробую привести пример из реальной жизни.
      Допустим, наша компания производит слоеное тесто для выпечки пирогов. С конечным покупателем (читай, пользователем) мы заключаем такой контракт:
      Если покупатель предварительно 15 минут разморозит наше тесто, затем сдобрит его маслом и будет выпекать в течении 10 минут, то у него получатся вкусные пирожки.
      Т.е. мы обязуемся предоставить покупателю готовый продукт при выполнении некоторых условий.

      Но тут другая компания (ООО «СкоростьНашеВсе») решила помочь покупателям и продавать уже готовые горячие пирожки.
      Она решила не тратить время на разморозку и выпекала всего 7 минут. В итоге, пирожки ее приготовления получились не очень вкусными и несовсем готовыми.
      А вот тут уже конечному покупателю выбирать: покупать пирожки этой фирмы, обратится к другой или сделать все самому по инструкции (читай, контракту).

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

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

          //вызывающая программа


          //обеспечиваем предусловия для вызываемого метода
          if(String.IsNullOrEmpty(objectName) || objectName.Contains(«@»))
          {
             //выводим сообщение об ошибке, прекращаем выполнение программы или передаем управление куда-либо
          }

          SomeObject some = GetSomeObject(objectName);

          //т.к. мы выполнили предусловия по нашему контракту, то можем смело дергать методы объекта
          //не боясь «object reference» исключения
          Console.WriteLine(some.Name);
          Console.WriteLine(some.Description);

          ...


          //вызываемая программа

          //нет необходимости выполнять проверку на пустую строку или еще на что-нибудь
          //полагаемся на контракт
          public SomeObject GetSomeObject(string objectName)
          {
             //по условиям контракта должны вернуть объект
             SomeObject result = new SomeObject();

             try
             {
                //собираем объект
             }
             catch(SomeException ex)
             {
                //не удалось собрать объект
                //но условия контракта выполнить надо
                result.Name = «Unknown name»;
                result.Description = «Can't find object»;
             }
             
             return result;
          }
          • +1
            получается что вместо одной проверки внутри вызываемой подпрограммы, мы делаем проверку перед каждым ее вызовом? Что мы тогда выигрываем в читабельности кода, если мы вынесли проверку из оной подпрограммы и разместили во всех местах ее вызова?
            • 0
              согласен, появляется соблазн сделать проверку внутри подпрограммы. Приведу классический пример с функцией вычисления квадратного корня (назовем ее CalcSqrt(), т.е. это будет наша функция (абстрагируясь от реализации для различных языков)).
              Данная функция имеет предусловие — ее аргумент не должен быть отрицательным. Если, к примеру, пользователь вводит отрицательное, то это забота вызывающей программы проверить это число.
              Одна вызывающая программа завершит работу аварийно, другая выдаст предупреждение и заставит пользователя вводить еще раз значение. А третья программа вообще схитрит — она сделает это число положительным, а потом, когда получит результат, то прибавит мнимую единицу.
              Поэтому, это не забота функции CalcSqrt() обрабатывать предусловия.
              • 0
                Но ведь вызванная функция может сгенерировать исключение, а вызывающая — обработать его как хочет, чем это плохо?
                • 0
                  Тем, что нужно исключение(я) ловить и обрабатывать. DBC говорит о том, что если перед вызовом ситуация удовлетворяет предусловию функции, то она выполнится без ошибок и ничего обрабатывать не придётся. И перед вызовом проверять ничего не нужно, т.к. убедиться в выполнении предусловия можно исходя из постусловий и инвариантов предшествующих вызовов. Проверять нужно только внешние данные, от которых нельзя ждать выполнения каких-либо контрактов.
                  • 0
                    А если функция вызывается много раз на разных данных? Обрамлять её каждый раз проверками утомительно, а обработку исключений можно написать в один блок после вызовов.
                    • 0
                      так я об этом и писал чуть выше. возможны вызовы различными клиентами, соответственно, возможны и различные поведения в случае ошибки. Или вы предлагаете создать несколько типов объектов-исключений для описанного выше примера?
                      Кроме того, г-н Blackened написал хорошее замечание чуть выше.
                  • 0
                    > DBC говорит о том, что если перед вызовом ситуация удовлетворяет предусловию функции, то она выполнится без ошибок

                    К сожалению это так только если не взаимодействовать с внешним миром.
                    Если же имеем обрывы связи, файлы с оибками и т.п — то надо заранее это все обрабатывать и куда-то делать эти предпроверки. Возможно в функции типа bool ВсеOK?(окружение).

                    Эхх если бы среда програмирования сама прятала бы эти проверки — то мороки бы и не было. Были бы они в Debug mode в виде assert-ов, но показывались только по желанию. Чтобы красиво было :)
                    • 0
                      >Если же имеем обрывы связи, файлы с оибками и т.п — то надо заранее это все обрабатывать и куда-то делать эти предпроверки… Эхх если бы среда програмирования сама прятала бы эти проверки

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

                      > Были бы они в Debug mode в виде assert-ов, но показывались только по желанию.

                      assert-ы к внешнему миру не применимы, т.к. они помогают удостовериться, что всё идёт так, как предполагает разработчик, а предположения относительно внешнего мира делать трудно.
    • 0
      Лекций, к сожалению, нет :(
      Могу посоветовать вам почитать его книгу «Object-Oriented Software Construction», Bertrand Meyer, Prentice Hall, 2nd edition 1997.
  • 0
    Спасибо за интересный материал. Надеюсь, развитие темы не задержится? :)
  • 0
    спасибо
  • 0
    Забавная штука — research.microsoft.com/SpecSharp/, как раз по теме. Вот только бы развили ее до промышленного уровня.
    К сожалению нормально заработала у меня только на VS2008 SP1
  • 0
    По-логике, все условия и инварианты должны быть статически проверяемые, т.е. все глюки отлавливаются на этапе компиляции. Но это только в теории, на практике две проблемы:

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

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

    Несколько лет уже размышлю над контрактами, и чем больше думаю, тем чаще вспоминаю виртовский Оберон :)
    • 0
      Spec# как раз и пытается решить задачу статической проверки соблюдения контрактов еще на этапе компиляции
      • 0
        Пытается, только что он будет делать когда я потребую в контракте передачу функции только n-битного простого числа, и передам например такое:

        25195908475657893494027183240048398571429282126204032027777137836043662020
        70759555626401852588078440691829064124951508218929855914917618450280848912
        00728449926873928072877767359714183472702618963750149718246911650776133798
        59095700097330459748808428401797429100642458691817195118746121515172654632
        28221686998754918242243363725908514186546204357679842338718477444792073993
        42365848238242811981638150106748104516603773060562016196762561338441436038
        33904414952634432190114657544454178424020924616515723350778707749817125772
        46796292638635637328991215483143816789988504044536402352738195137863656439
        1212010397122822120720357

        Компилировать программу придётся не один год в распределённой сети из не одной тысячи машин :)
        • 0
          Ну, даже банальная проверка на null может сэкономить некоторое количество нейронов. Кроме того контрактное программирование без документации, имхо, это смерть. Спасает только читабельная сигнатура метода типа

          string ToPhysicalPath(string notNullVirtualPathWithSlashesAsSeparatorsAndNoEndingSlash) :)

          Spec# же включает хоть какое нибудь описание контракта в сигнатуру метода, которая потом в IntelliSense отображается
          • 0
            >Кроме того контрактное программирование без документации, имхо, это смерть.
            вот это самое главное замечание, которое я, к своему стыду, забыл упомянуть.
          • 0
            На счёт проверок на null, попадалась мне на глаза с полгода назад любопытная «бумага», описывался в ней способ статической проверки на основе уже имеющейся информации об исключениях в существующем C#/CIL коде. Вывод был такой: если в программе обработка и кидание исключений сделаны правильно, надобность в пред/пост условиях не столь велика. Хотя, для отделения мух от котлет, конечно лучше иметь в арсенале и исключения и весь спектр средств контрактного программирования.

            Радует, что программирование наконец-то переходит на семантический уровень, только и «мозг» компиляторма надо будет иметь соответсвующий. Отчего-то вспоминается проблема останова ;)
  • 0
    Интересный материал. Надо будет попробовать. Жду продолжения
  • 0
    очень интересен процесс модифицации кода(от рефакторинга, до изменения функционала), тестирования и дебага. Не смотря на парадигму «обязался — сделай» всеравно возможны ошибки, которые довольно просто найти используя валидацию.
    Быть более конкретным — как я узнаю в большой системе почему «пирожки ее приготовления получились не очень вкусными и несовсем готовыми»?
  • 0
    Весьма интересный подход. Записываюсь в ряды ожидающих продолжения.
  • 0
    (for var i = 0; i < links.length; i++) {
    (function(i) {
    links[i].onclick = function() {
    alert(i);
    }
    })(i);
    }
    • +1
      окном ошибся =)

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

      а вот контракт выглядит так: каждый объект обязуется реализовать некоторый интерфейс (подписывает контракт) и за выполнением контрактов следит специальный объект-аудитор (обычно его роль возлагается на интерпретатор, что сильно уменьшает гибкость). соответственно на продакшене и на тестинге могут быть разные аудиторы — разной степени скрупулёзности и с разными последствиями в случае нарушения контракта.
  • 0
    Вот мой совет для данной ситуации – если вы вызываете подпрограмму, которая была написана вами, то не пишите лишнего кода. Доверяйте себе.


    Не согласен. Поступая таким образом, вы устанавливаете неявные условия, необходимые для корректной работы программы, и если они будут нарушены (кто-то или вы сами случайно передадите неверные исходные данные), то программа поведёт себя непредсказуемо. Я считаю, что нужно избегать таких неявных зависимостей, так как это грозит неприятностями в будущем.
  • 0
    интересная методика. Местами согласен.
    Но не для русских программистов. Мозг построен немного не так =)
  • НЛО прилетело и опубликовало эту надпись здесь
  • 0
    > Центральными фигурами это концепции
    Скорее всего, имело ввиду: «Центральными фигурами этоЙ концепции».
  • 0
    Первый пример «качественного ПО» пропустит код длиной более 9 символов.
    • 0
      спасибо. поправил
  • 0
    Постусловия выражают состояния «окружающего мира» на момент выполнения подпрограммы.

    Мне кажется лучше сказать на момент завершения подпрограммы.

    Кроме того, наличие постусловия в подпрограмме гарантирует ее завершение (т.е. не будет бесконечного цикла, например).


    Интересно, что в классической логике Хоара наличие постусловия никак не связано собственно с её завершением. Формулируется два понятия:

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

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

    Вообще завершение или не завершение программы больше зависит от предусловия, чем от постусловия.
  • 0
    Мног интересного, для размышления. Насколько понимаю, такой код по сути не переносим в другие проекты? Тогда польза такого кода в его компактности?
    • 0
      Переносим, но только вместе со своими контрактами
    • 0
      Почему не переносим? Очень даже, проверено опытом. ;)
      Как правильно было сказано — переносим со своими контрактами (важна документация данных контрактов). Кроме того, переносимость наоборот облегчается за счет снижения связанности модулей.
      А уж использовать такой модуль или нет (со всеми контрактами) — решать клиенту. См. мой комментарий про пирожки ;)
  • 0
    наличие постусловия в подпрограмме гарантирует ее завершение


    Очень странная для меня фраза. Банальная проверка завершения программы во всех случаях (т.н. Проблема остановки), как мне известно, ещё не имеет общего решения.
  • 0
    а есть пример явного профита? типа: до контракта программист мучился, после применения контракта все встало на свои места. Или: до применения контракта тратили 2 часа, после применения 2 минуты

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