Доброго времени суток, хабр!
Сегодня я хочу рассказать Вам о контрактном программировании и его реализации в D. Это очень интересная концепция построения API. Смысл заключается в формальной спецификации работы функции или класса на уровне кода, а не комментария.
Пример подобной спецификации для функции:
Код в блоках in и out это обычный код, за исключением того, что на него не распостраняются ограничения модификатора nothrow, что логично, так как смысл контракта — выбросить исключение при его несоблюдении.
Важно понимать, что контракты не заменяют штатную проверку входных данных (внутри тела функции) и они не должны никак влиять на логику работы (изменять какие-либо данные), так как они не попадают в release. Это дополнительный инструмент проверки ПО, «кодовая документация» конкретного API, если так можно выразиться. То есть debug версия программы прогоняется по реальным данным и в случае несоблюдения какого либо контракта прекращает выполнение с диагностической ошибкой, что сигнализирует, в первую очередь, о неправильном использовании какого либо API или некорректности алгоритма, а не об ошибке во входных данных. Если ошибок не было, значит все вызовы проходят согласно спецификации контрактов, а не то что входные данные прошли проверку.
С общими моментами разобрались, далее более сочные: контракты и ООП.
Начнём с простого:
Добавляется новое ключевое слово invariant. Блок invariant-кода выполняет проверку состояния объекта 2 раза — до и после вызовов публичных и защищённых методов. Первый раз блок invariant выполняется после блока in, до тела метода, второй раз сразу после тела метода и до блока out. Блоков invariant может быть несколько, порядок объявления не имеет значения. Их можно использовать только внутри классов, структур и объединений (union), для интерфейсов это не разрешено, как и объявление блоков invariant на уровне модуля. Код таких блоков должен использовать const методы доступа к состоянию объекта.
Вызов приватных методов не приводит к вызову блока invariant, что логично — внутри публичных и защищённых методов может происходить вызов нескольких приватных методов, которые приводят объект в невалидное состояние, главное, чтобы состояние стало валидным к концу выполнения вызывающего публичного или защищённого метода. Таким образом всегда гарантируется валидность состояния объекта при вызове его методов извне, а то, что он делается внутри — детали реализации, всё равно пользователь объекта не сможет «застать в расплох» объект, когда между вызовами приватных методов он находится в невалидном состоянии.
Блок invariant не вызывается до вызова конструктора, так как объект, ожидающий конструирования по определению не валиден (иначе бы не было надобности писать конструктор). Обратный момент насчёт деструктора — после него не вызывается invariant, что тоже логично, так как объект после разрушения не валиден. Так же для конструкторов и деструкторов запрещён (за неимением смысла) блок out.
При наследовании должны соблюдаться invariant блоки как базового, так и производного класса, то есть они не должны быть взаимоисключающими. Так же с блоками out для переопределённых методов: результат работы метода должен удовлетворять контракту как базового, так и производного класса.
Контракты in для переопределённых методов имеют другую логику, которая лично для меня была совершенно не очевидной.
Пример:
Можно предположить, что такой код свалится на проверке условия в блоке in перегруженной функции calc (B.calc), но мы видим другую картину:
Блок in для перегруженного метода вообще не был вызван! Это объясняется тем, что блок in расширяет возможный диапазон вариантов входа, то есть в производном классе внутри in мы как бы пишем «а ещё и вот такое я теперь тоже могу обработать», в отличии от других контрактов (out и invariant), которые характеризуются такой фразой: «должно ещё и вот это условие выполняться». Можно сравнить поведение комбинаций блоков out и invariant c вычислением логического значения операцией AND (все должны быть true, чтобы результат был true), а поведение in с операцией OR (true должно хотя бы одно). Следовательно блок in для B.calc будет вызываться только тогда, когда блок in для A.calc отработал с исключением.
По сути код в примере выше не корректен, его стоит переписать так:
Теперь при вызове calc( 10 ) мы будет видеть:
При указании значения не подходящего ни одному входному контракту (в данном случае 0) исключение выбросится из блока in производного класса.
Вот кажется и все аспекты контрактного программирования в D, о которых я знаю. Надеюсь Вы подчерпнули для себя что-то новое.
Хочу отметить, что встроенная система документирования не добавляет код контрактов в документацию, только сигнатуру функции, в отличии от сторонних систем генерации документации, например hmod.
Сегодня я хочу рассказать Вам о контрактном программировании и его реализации в D. Это очень интересная концепция построения API. Смысл заключается в формальной спецификации работы функции или класса на уровне кода, а не комментария.
Пример подобной спецификации для функции:
float foo( float value )
in // ключевое слово, элемент языка, блок вызывается до тела функции
{
assert( 0 <= value && value <= 1, "value out of range [0,1]" ); // проверка правильности вызова метода "мы ожидаем именно такие значения"
}
out( result ) // блок вызывается после тела функции и берёт на вход результат выполнения функции, если она не void
{
assert( 0 <= result && result < 100, "bad result" ); // проверка выходных данных: "мы обещаем результат, удовлетворяющий таким условиям, если все входные контракты соблюдены"
}
body // при использовании контрактов (in,out) ключевое слово body обязательно, собственно тело функции
{
return value * 20 + sqrt( value );
}
Код в блоках in и out это обычный код, за исключением того, что на него не распостраняются ограничения модификатора nothrow, что логично, так как смысл контракта — выбросить исключение при его несоблюдении.
Важно понимать, что контракты не заменяют штатную проверку входных данных (внутри тела функции) и они не должны никак влиять на логику работы (изменять какие-либо данные), так как они не попадают в release. Это дополнительный инструмент проверки ПО, «кодовая документация» конкретного API, если так можно выразиться. То есть debug версия программы прогоняется по реальным данным и в случае несоблюдения какого либо контракта прекращает выполнение с диагностической ошибкой, что сигнализирует, в первую очередь, о неправильном использовании какого либо API или некорректности алгоритма, а не об ошибке во входных данных. Если ошибок не было, значит все вызовы проходят согласно спецификации контрактов, а не то что входные данные прошли проверку.
С общими моментами разобрались, далее более сочные: контракты и ООП.
Начнём с простого:
class Foo
{
float val;
invariant
{
assert( val >= 0, "bad val" );
}
}
Добавляется новое ключевое слово invariant. Блок invariant-кода выполняет проверку состояния объекта 2 раза — до и после вызовов публичных и защищённых методов. Первый раз блок invariant выполняется после блока in, до тела метода, второй раз сразу после тела метода и до блока out. Блоков invariant может быть несколько, порядок объявления не имеет значения. Их можно использовать только внутри классов, структур и объединений (union), для интерфейсов это не разрешено, как и объявление блоков invariant на уровне модуля. Код таких блоков должен использовать const методы доступа к состоянию объекта.
Вызов приватных методов не приводит к вызову блока invariant, что логично — внутри публичных и защищённых методов может происходить вызов нескольких приватных методов, которые приводят объект в невалидное состояние, главное, чтобы состояние стало валидным к концу выполнения вызывающего публичного или защищённого метода. Таким образом всегда гарантируется валидность состояния объекта при вызове его методов извне, а то, что он делается внутри — детали реализации, всё равно пользователь объекта не сможет «застать в расплох» объект, когда между вызовами приватных методов он находится в невалидном состоянии.
Блок invariant не вызывается до вызова конструктора, так как объект, ожидающий конструирования по определению не валиден (иначе бы не было надобности писать конструктор). Обратный момент насчёт деструктора — после него не вызывается invariant, что тоже логично, так как объект после разрушения не валиден. Так же для конструкторов и деструкторов запрещён (за неимением смысла) блок out.
При наследовании должны соблюдаться invariant блоки как базового, так и производного класса, то есть они не должны быть взаимоисключающими. Так же с блоками out для переопределённых методов: результат работы метода должен удовлетворять контракту как базового, так и производного класса.
Контракты in для переопределённых методов имеют другую логику, которая лично для меня была совершенно не очевидной.
Пример:
import std.stdio;
import std.string;
void label(string f=__FUNCTION__,Args...)( Args args ){ writeln( f, ": ", args ); }
class A
{
float calc( float val )
in { label; assert( val > 0 ); }
out( res ) { assert( res > 0, format( "%f < 0", res ) ); label; }
body { label; return val * 2; }
}
class B : A
{
override float calc( float val )
in { label; assert( val < 0 ); }
body { label; return -val * 4; }
}
void main()
{
auto x = new B;
x.calc( 10 );
}
Можно предположить, что такой код свалится на проверке условия в блоке in перегруженной функции calc (B.calc), но мы видим другую картину:
contract_in.A.calc.__require: // произошёл вызов блока in базового класса
contract_in.B.calc: // далее сразу выполняется тело calc производного класса
core.exception.AssertError@contract_in.d(10): -40.000000 < 0 // программа падает на проверке out в базовом классе
Блок in для перегруженного метода вообще не был вызван! Это объясняется тем, что блок in расширяет возможный диапазон вариантов входа, то есть в производном классе внутри in мы как бы пишем «а ещё и вот такое я теперь тоже могу обработать», в отличии от других контрактов (out и invariant), которые характеризуются такой фразой: «должно ещё и вот это условие выполняться». Можно сравнить поведение комбинаций блоков out и invariant c вычислением логического значения операцией AND (все должны быть true, чтобы результат был true), а поведение in с операцией OR (true должно хотя бы одно). Следовательно блок in для B.calc будет вызываться только тогда, когда блок in для A.calc отработал с исключением.
...
x.calc( -10 );
...
contract_in.A.calc.__require: // вызван in базового класса - провален
contract_in.B.calc.__require: // вызван in производного класса - успешно
contract_in.B.calc:
contract_in.A.calc.__ensure: // проверка результата - успешно
По сути код в примере выше не корректен, его стоит переписать так:
class B : A
{
override float calc( float val )
in { label; assert( val < 0 ); } // новый контракт
body
{
label;
if( val < 0 ) return -val * 4; // новый код для новых входных условий (с учётом старых выходных)
else return super.calc( val ); // иначе делигируем выполнение старому коду
}
}
Теперь при вызове calc( 10 ) мы будет видеть:
contract_in.A.calc.__require: // вызов in базового класса - успешно
contract_in.B.calc: // входим в перегруженный метод и по else вызываем базовый метод
contract_in.A.calc.__require: // ещё раз проверка in базового класса
contract_in.A.calc: // тело базового метода
contract_in.A.calc.__ensure: // выход из базового метода, контракт базового метода
contract_in.A.calc.__ensure: // выход из перегруженного метода, но контракт базового метода (так как все out должны выполняться)
При указании значения не подходящего ни одному входному контракту (в данном случае 0) исключение выбросится из блока in производного класса.
Вот кажется и все аспекты контрактного программирования в D, о которых я знаю. Надеюсь Вы подчерпнули для себя что-то новое.
Хочу отметить, что встроенная система документирования не добавляет код контрактов в документацию, только сигнатуру функции, в отличии от сторонних систем генерации документации, например hmod.