Контракти на D

Доброго часу доби, хабр!

Сьогодні я хочу розповісти Вам про контрактне програмуванні та його реалізації у 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.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.