Два года назад мне посчастливилось побывать на лекции замечательного человека, одного из разработчиков языка
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#.
Самое главное, что использование принципа проектирования по контракту поможет вам обеспечить автоматическое тестирование вашего кода.
Данную статью можно назвать введением в принцип проектирования по контракту. Если появится интерес со стороны пользователей, то я продолжу серию об этом принципе. Ведь все, что я описал – это лишь верхушка айсберга.
комментарии (54)
А кстати избавить код от некрасивых проверок возвращаемого функциями значения также может паттерн Null object
en.wikipedia.org/wiki/Null_Object_pattern
...не гарантирует…
или
… гарантирует завершение…
Поправьте плз.
...не гарантирует…
или
… гарантирует завершение…
Поправьте плз.
(сразу оговорюсь, это Си++)
precond(ptr != 0); invariant(sz = size()); postcond(*ptr > 0);В отличие от стандартного assert'а, эти кидают исключения в релизе, а в дебуге это просто ассерт.
Конечно, assert предполагает то, чего не должно быть никогда (т.е. если ассерт сработал, надо менять код программы), но к сожалению, люди ошибаются, а в релизе если выстрелит, то ассерт там уже промолчит, а программа навернется позднее и окажется в неизвестном состоянии.
Просто исключения я предпочитаю кидать тогда, когда ошибка вполне может быть, и код программы тут не виноват.
Ну а коды возврата — это когда и ошибки-то никакой нет по сути, вполне нормальная ситуация.
Или это для примера так написано?
пытался сделать пример нагляднее
>Вся ответственность за передачу «правильных» данных лежит на вызывающей программе.
поясните пожалуйста вот эту часть
у вас сохранились конспекты этой лекции?
Очень много литературы учит нас использовать так называемое «защитное программирование» (defensive programming) (в следующих статьях постараюсь сделать сравнение обоих подходов).
Оно и говорит, что лучше на всякий случай перестраховаться и проверить (доверяй, но проверяй ;)).
А проектирование по контракту предлагает использовать некое соглашение, контракт, чтобы убрать лишние проверки (тем самым уменьшив объем кода и упростив читабельность программы).
Попробую привести пример из реальной жизни.
Допустим, наша компания производит слоеное тесто для выпечки пирогов. С конечным покупателем (читай, пользователем) мы заключаем такой контракт:
Если покупатель предварительно 15 минут разморозит наше тесто, затем сдобрит его маслом и будет выпекать в течении 10 минут, то у него получатся вкусные пирожки.
Т.е. мы обязуемся предоставить покупателю готовый продукт при выполнении некоторых условий.
Но тут другая компания (ООО «СкоростьНашеВсе») решила помочь покупателям и продавать уже готовые горячие пирожки.
Она решила не тратить время на разморозку и выпекала всего 7 минут. В итоге, пирожки ее приготовления получились не очень вкусными и несовсем готовыми.
А вот тут уже конечному покупателю выбирать: покупать пирожки этой фирмы, обратится к другой или сделать все самому по инструкции (читай, контракту).
Также и в случае программирования. Мы всю ответственность по передачи входных данных для нашей функции возлагаем на вызывающую программу.
При этом мы обязуемся ей вернуть валидные данные (если обещали вернуть double, то вернем double, а не null).
Надеюсь, что теперь стал более понятен этот принцип.
Данная функция имеет предусловие — ее аргумент не должен быть отрицательным. Если, к примеру, пользователь вводит отрицательное, то это забота вызывающей программы проверить это число.
Одна вызывающая программа завершит работу аварийно, другая выдаст предупреждение и заставит пользователя вводить еще раз значение. А третья программа вообще схитрит — она сделает это число положительным, а потом, когда получит результат, то прибавит мнимую единицу.
Поэтому, это не забота функции CalcSqrt() обрабатывать предусловия.
Кроме того, г-н Blackened написал хорошее замечание чуть выше.
К сожалению это так только если не взаимодействовать с внешним миром.
Если же имеем обрывы связи, файлы с оибками и т.п — то надо заранее это все обрабатывать и куда-то делать эти предпроверки. Возможно в функции типа bool ВсеOK?(окружение).
Эхх если бы среда програмирования сама прятала бы эти проверки — то мороки бы и не было. Были бы они в Debug mode в виде assert-ов, но показывались только по желанию. Чтобы красиво было :)
Ну тут и предпроверки, и постпроверки (формат ввода, CRC и прочее). Их можно спрятать в адаптеры, взаимодействующие с внешним миром с одной стороны, а с другой выдающие вызывающей программе результат уже по контракту. Конечно, было бы хорошо, если бы всё это делала среда, но только везде нужно реагировать на конкретные неточности извне по-своему.
> Были бы они в Debug mode в виде assert-ов, но показывались только по желанию.
assert-ы к внешнему миру не применимы, т.к. они помогают удостовериться, что всё идёт так, как предполагает разработчик, а предположения относительно внешнего мира делать трудно.
Могу посоветовать вам почитать его книгу «Object-Oriented Software Construction», Bertrand Meyer, Prentice Hall, 2nd edition 1997.
К сожалению нормально заработала у меня только на VS2008 SP1
1. Контракты формулируют довольно сложные математические теоремы над данными, автоматическое доказательство\опровержение которых, часто очень трудоёмкий\ресурсоёмкий (читай длительный) процесс. Компилировать исходник по часу не каждому понравится.
2. Полная статическая проверка невозможна, т.к. в любой нетривиальной программе существуют внешние по отношению к программе данные (сокеты, файлы, консольный ввод, т.д.), на которые так просто статические ограничения не наложить, что приводит всё к той же концепции обработки исключений, а это как раз то от чего контракты нас пытются избавить.
Несколько лет уже размышлю над контрактами, и чем больше думаю, тем чаще вспоминаю виртовский Оберон :)
25195908475657893494027183240048398571429282126204032027777137836043662020
70759555626401852588078440691829064124951508218929855914917618450280848912
00728449926873928072877767359714183472702618963750149718246911650776133798
59095700097330459748808428401797429100642458691817195118746121515172654632
28221686998754918242243363725908514186546204357679842338718477444792073993
42365848238242811981638150106748104516603773060562016196762561338441436038
33904414952634432190114657544454178424020924616515723350778707749817125772
46796292638635637328991215483143816789988504044536402352738195137863656439
1212010397122822120720357
Компилировать программу придётся не один год в распределённой сети из не одной тысячи машин :)
string ToPhysicalPath(string notNullVirtualPathWithSlashesAsSeparatorsAndNoEndingSlash) :)
Spec# же включает хоть какое нибудь описание контракта в сигнатуру метода, которая потом в IntelliSense отображается
вот это самое главное замечание, которое я, к своему стыду, забыл упомянуть.
Радует, что программирование наконец-то переходит на семантический уровень, только и «мозг» компиляторма надо будет иметь соответсвующий. Отчего-то вспоминается проблема останова ;)
Быть более конкретным — как я узнаю в большой системе почему «пирожки ее приготовления получились не очень вкусными и несовсем готовыми»?
(function(i) {
links[i].onclick = function() {
alert(i);
}
})(i);
}
это не контракт, а доверенность. а когда все друг-другу доверяют — невыполнение контракта одним звеном приводит к падению другого и танцам с бубном вокруг отладчика
а вот контракт выглядит так: каждый объект обязуется реализовать некоторый интерфейс (подписывает контракт) и за выполнением контрактов следит специальный объект-аудитор (обычно его роль возлагается на интерпретатор, что сильно уменьшает гибкость). соответственно на продакшене и на тестинге могут быть разные аудиторы — разной степени скрупулёзности и с разными последствиями в случае нарушения контракта.
Не согласен. Поступая таким образом, вы устанавливаете неявные условия, необходимые для корректной работы программы, и если они будут нарушены (кто-то или вы сами случайно передадите неверные исходные данные), то программа поведёт себя непредсказуемо. Я считаю, что нужно избегать таких неявных зависимостей, так как это грозит неприятностями в будущем.
Но не для русских программистов. Мозг построен немного не так =)
Скорее всего, имело ввиду: «Центральными фигурами этоЙ концепции».
Мне кажется лучше сказать на момент завершения подпрограммы.
Интересно, что в классической логике Хоара наличие постусловия никак не связано собственно с её завершением. Формулируется два понятия:
Программа с предусловие P и постусловием Q корректна, если начиная работать на входных данных удовлетворяющих P, она либо не завершается, либо завершается в состоянии удовлетворяющем Q.
Программа с предусловие P и постусловием Q тотально корректна, если начиная работать на входных данных удовлетворяющих P она завершается в состоянии удовлетворяющем Q.
Вообще завершение или не завершение программы больше зависит от предусловия, чем от постусловия.
Как правильно было сказано — переносим со своими контрактами (важна документация данных контрактов). Кроме того, переносимость наоборот облегчается за счет снижения связанности модулей.
А уж использовать такой модуль или нет (со всеми контрактами) — решать клиенту. См. мой комментарий про пирожки ;)