dlang-book/book/10-контрактное-программиров...
Alexander Zhirov b90bbc94c2 Готовые ссылки 2023-03-06 08:09:22 +03:00
..
README.md Готовые ссылки 2023-03-06 08:09:22 +03:00

README.md

10. Контрактное программирование

🢀 9. Обработка ошибок 10. Контрактное программирование 11. Расширение масштаба 🢂

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

  • Обработка ошибок (тема главы 9) имеет дело с методами и идиомами, помогающими справиться с ожидаемыми ошибками времени исполнения.
  • Техника обеспечения надежности анализирует способность всей системы (например, программно-аппаратного комплекса) функционировать в соответствии со спецификацией. (В этой книге техника обеспечения надежности не рассматривается.)
  • Корректность программ это исследовательская область языка программирования, его статические и динамические средства, позволяющие доказать, что программа точно соответствует заданной спецификации. Системы типов лишь наиболее известное средство, применяемое при доказательстве корректности программ (настоятельно рекомендуется прочесть увлекательную монографию «Доказательства это программы» Уодлера). В этой главе обсуждается контрактное программирование парадигма обеспечения корректности программ.

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

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

В начало ⮍

10.1. Контракты

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

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

Ключевые понятия парадигмы контрактного программирования:

  • Утверждение (assertion) это не привязанная к конкретной функции проверка времени исполнения: проверяется некоторое условие, задаваемое с помощью инструкции if. Если условие ненулевое, инструкция assert не влияет на выполнение программы. В противном случае assert порождает исключение типа AssertError. После исключения AssertError восстановление невозможно: этот класс не является потомком класса Exception, он прямой потомок класса Error, то есть исключения AssertError не следует обрабатывать.
  • Предусловие (precondition) функции это сумма условий, которые клиент должен выполнить, чтобы инициировать ее вызов. Условия могут относиться непосредственно к месту вызова (например, значения параметров) или к состоянию системы (например, доступность памяти).
  • Постусловие (postcondition) функции это сумма обязательств функции по нормальному возврату при условии удовлетворения предусловия.
  • Инвариант (invariant) это состояние, остающееся неизменным на протяжении цепочки вычислений. В языке D под инвариантами всегда понимается состояние объекта до и после вызова метода.

Контрактное программирование изящно обобщает ряд проверенных временем понятий, которые мы сегодня воспринимаем как данность. Например, сигнатура функции это настоящий контракт. Рассмотрим функцию из модуля std.math стандартной библиотеки:

double sqrt(double x);

Сама сигнатура заключение контракта: инициатор должен предоставить ровно одно значение типа double, а функция в свою очередь вернуть одно значение типа double. Нельзя сделать вызов sqrt("hello") или присвоить результат вызова sqrt строке. Еще интереснее, что можно сделать вызов sqrt(2), даже если 2 значение типа int, а не double: сигнатура дает компилятору достаточно информации, чтобы тот мог привести значение 2 к типу double и тем самым помочь клиенту выполнить требования, предъявляемые к входным данным. Функция может обладать побочными эффектами, а если их нет, этот факт можно отразить с помощью атрибута pure:

// Никаких побочных эффектов
pure double sqrt(double x);

Это гораздо более строгий, более обязывающий контракт с точки зрения sqrt, поскольку он запрещает sqrt иметь побочные эффекты. Наконец, атрибут nothrow позволяет заключить еще более детальный (и ограничивающий) контракт:

// Никаких побочных эффектов, никаких исключений (именно такое объявление находится в модуле std.math)
pure nothrow double sqrt(double x);

Теперь мы точно знаем, что эта функция или вернет значение типа double, или приведет к останову программы, или инициирует бесконечный цикл. Кроме этого она абсолютно ничего сделать не может. Итак, мы уже вовсю пользуемся контрактами, просто записывая сигнатуры. Чтобы вы ощутили контрактную мощь сигнатур функций, приведем одно небольшое историческое свидетельство. Ранняя, еще до появления стандарта версия языка C (названная «K&R C» в честь своих создателей Кернигана и Ричи) была с сюрпризом: при вызове необъявленной функции компилятор K&R C считал, что она обладает следующей сигнатурой:

// Если вы не объявили функцию sqrt, но вызвали ее, то это равносильно тому, что вы объявили ее следующим образом, а потом вызвали
int sqrt(...);

Другими словами, если вы забывали включить с помощью директивы #include заголовочный файл math.h (предоставляющий корректную сигнатуру для sqrt), то могли без помех со стороны компилятора сделать вызов sqrt("hello"). (Многоточием обозначено переменное число аргументов, одно из самых небезопасных средств C.)

Коварство ошибки заключалось в том, что вызов sqrt(2), скомпилированный с файлом math.h и без этого файла, делал совершенно разные вещи. С директивой #include компилятор перед вызовом sqrt конвертировал аргумент 2 в 2.0, а без директивы между сторонами возникало трагическое непонимание: инициатор отправлял 2, а sqrt считывала бинарное представление этого значения так, будто это было число с плавающей запятой, что в 32-разрядном формате IEEE составляет 2.8026e-45. Язык C осознал серьезность этой проблемы и устранил ее, введя требование для всех функций предоставлять прототипы.

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

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

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

В начало ⮍ Наверх ⮍

10.2. Утверждения

Выражение assert было определено в разделе 2.3.4.1, и с тех пор мы использовали его повсеместно, по умолчанию признавая полезность понятия «утверждение». В дополнение отметим, что большинство языков включают некоторый механизм проверки утверждений в виде примитива языка или библиотечной конструкции.

Напомним, что выражение с ключевым словом assert это утверждение: по плану такое выражение должно быть истинным (ненулевым) при любом запуске программы и независимо от входных данных:

int a, b;
...
assert(a == b);
assert(a == b, "a и b разные");

Утверждаемое выражение обычно является логическим, но может иметь любой тип, значение которого можно проверить с помощью конструкции if: числовой тип, массив, указатель или ссылка на класс. Если выражение нулевое, при выполнении инструкции assert порождается исключение типа AssertError; в противном случае ничего не происходит. Если порождено исключение класса AssertError, необязательный строковый параметр становится частью этого объекта. Строка вычисляется, только если утверждаемое выражение действительно нулевое, что позволяет сэкономить на некоторых потенциально затратных вычислениях:

import std.conv;

void fun()
{
    int a, b;
    ...
    assert(a == b);
    assert(a == b, text(a, " и ", b, " разные"));
}

Функция std.conv.text конвертирует и объединяет все свои аргументы в строку. Чтобы выполнить эти операции, нужно потрудиться: выделить память, провести преобразования и т. д. Было бы расточительно выполнять всю эту работу в случае успешного выполнения инструкции assert, так что второй аргумент вычисляется, только если первый оказывается нулевым.

Что должна делать инструкция assert в случае неудачи? Возможный вариант (именно так и поступает одноименный макрос из C) принудительно завершить выполнение программы. А в языке D инструкция assert порождает в таком случае исключение. Тем не менее это не обычное исключение; это объект класса AssertError, дочернего класса Error сверхисключения, о котором шла речь в разделе 9.2.

Объект типа AssertError, порожденный инструкцией assert, проходит через обработчики catch(Exception), как горячий нож сквозь масло. Это хорошо, поскольку неудачи при проверке утверждений свидетельствуют о логических ошибках в вашей программе, и обычно хочется, чтобы логические ошибки как можно скорее и аккуратнее останавливали выполнение приложения.

Чтобы перехватить исключение типа AssertError, укажите в обработчике catch в качестве аргумента не класс Exception или его потомка, а класс Error или прямо AssertError. Но повторяю: вряд ли вам когда-либо пригодится перехват исключений типа Error.

В начало ⮍ Наверх ⮍

10.3. Предусловия

Предусловия это контрактные обязательства, которые должны быть выполнены при входе в функцию. Предположим, мы хотим с помощью контракта потребовать для функции fun неотрицательные входные данные. Это предусловие, которое функция fun предъявляет вызывающему ее коду. В языке D предусловие записывается так:

double fun(double x)
in
{
    assert(x >= 0);
}
body
{
    // Реализация fun
    ...
}

Контракт in автоматически выполняется перед выполнением тела функции. Фактически это более простая версия кода:

double fun(double x)
{
    assert(x >= 0);
    // Реализация fun
    ...
}

Но мы еще увидим, как важно отделить предусловие от тела функции, особенно при использовании объектов и наследования.

Некоторые языки сводят ограничения к логическим выражениям и автоматически порождают исключение, если такое логическое выражение ложно, например:

// Не D
double fun(double x)
in (x >= 0)
body
{
    ...
}

D более гибок он позволяет проверять даже те предусловия, которые трудно отобразить одиночным логическим выражением. Кроме того, он предоставляет вам право порождать исключения любого типа, а не только AssertError. Например, функция fun может породить исключение, запоминающее ошибочные входные данные:

import std.conv, std.exception;

class CustomException : Exception
{
    private string origin;
    private double val;
    this(string msg, string origin, double val)
    {
        super(msg);
        this.origin = origin;
        this.val = val;
    }
    override string toString()
    {
        return text(origin, ": ", super.toString(), val);
    }
}

double fun(double x)
in
{
    if (x !>= 0)
    {
        throw new CustomException("Отрицательное значение входного параметра", "fun", x);
    }
}
body
{
    double y;
    // Реализация fun
    ...
    return y;
}

Но не злоупотребляйте этой гибкостью. Как уже говорилось, инструкция assert порождает исключение типа AssertError, которое не является обычным исключением. Сигнализировать о невыполнении предусловия лучше всего с помощью исключений типа AssertError и других потомков класса Error, а не Exception, потому что невыполнение предусловия свидетельствует о серьезной логической ошибке в вашей программе, а такие ошибки не планируется обрабатывать обычным способом.

На самом деле, чтобы запретить такое некорректное использование контрактов, компилятор предпринимает определенные меры. Во-первых, внутри блока in нельзя выполнить инструкцию return, а значит, вам не разрешат полностью пропустить тело функции с помощью контракта. Во-вторых, D строго запрещает изменять параметры внутри контракта. Например, следующий код ошибочен:

double fun(double x)
in
{
    if (x <= 0) x = 0; // Ошибка!
    // Нельзя изменить параметр x внутри контракта!
}
body
{
    double y;
    ...
    return y;
}

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

В начало ⮍ Наверх ⮍

10.4. Постусловия

Имея лишь in-контракт, функция fun остается антисимметричной и как бы нечестной: она предъявляет инициатору вызова требования, но не предоставляет никаких гарантий. С какой стати тогда инициатору вызова трудиться, передавая в fun неотрицательное число? Для проверки постусловий служит out-контракт. Предположим, fun гарантирует, что вернет результат в диапазоне от 0 до 1:

double fun(double x)
// Как и раньше
in
{
    assert(x >= 0);
}
// добавлено
out(result)
{
    assert(result >= 0 && result <= 1);
}
body
{
    // Реализация fun
    double y;
    ...
    return y;
}

Если in-контракт тела функции породит исключение, блок out вообще не будет выполнен. Постусловие выполняется, только если предусловие выполнено и тело функции без проблем вернуло результат. Параметр result, передаваемый в блок out, содержит значение, которое функция готова вернуть. Передача параметра result не является необходимой; out{...} тоже корректный out-контракт, который не использует результат либо применяется к функции типа void. В нашем примере в качестве result выступает копия y.

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

int fun(int x)
out(result)
{
    x = 42;                     // Ошибка!
    // Нельзя изменить параметр x внутри контракта!
    if (result < 0) result = 0; // Ошибка!
    // Нельзя изменить результат внутри контракта!
}
body
{
    ...
}

В начало ⮍ Наверх ⮍

10.5. Инварианты

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

Более узконаправленный инвариант может касаться индивидуального объекта, и именно с такой моделью работает D. Рассмотрим, например, простой класс Date, который хранит значения дня, месяца и года в виде отдельных целых чисел:

class Date
{
    private uint year, month, day;
    ...
}

Разумно предположить, что в течение всего времени жизни объекта типа Date его члены day, month и year не должны принимать бессмысленные значения. Выразить такое требование можно с помощью инварианта:

import std.algorithm, std.range;

class Date
{
private:
    uint year, month, day;
    invariant()
    {
        assert(1 <= month && month <= 12);
        switch (day) {
            case 29:
                assert(month != 2 || leapYear(year));
                break;
            case 30:
                assert(month != 2);
                break;
            case 31:
                assert(longMonth(month));
                break;
            default:
                assert(1 <= day && day <= 28);
                break;
        }
        // Никаких ограничений на год
    }
    // Вспомогательные функциии
    static pure bool leapYear(uint y)
    {
        return (y % 4) == 0 && (y % 100 || (y % 400) == 0);
    }
    static pure bool longMonth(uint m)
    {
        return !(m & 1) == (m > 7);
    }
public:
    ...
}

С помощью трех проверок для чисел месяца 29, 30 и 31 выполняется обработка особых случаев для февраля високосного года. Проверяющая функция longMonth возвращает true, если в месяце 31 день, и работает по принципу «месяц с четным номером является длинным тогда и только тогда, когда наступает после июля», что соответствует истине (длинные месяцы имеют номера 1, 3, 5, 7, 8, 10 и 12).

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

// Внутри класса Date
void copy(Date another)
{
    year = another.year;
    __call_invariant(); // Вставлено компилятором
    month = another.month;
    __call_invariant(); // Вставлено компилятором
    day = another.day;
    __call_invariant(); // Вставлено компилятором
}

Вполне возможно, что где-то между этими инструкциями состояние Date временно становится некорректным, так что вставка вычисления инварианта после каждой инструкции прием тоже некорректный. (Например, в ходе присвоения дате, в текущий момент содержащей 29 февраля 2012 г., даты 1 августа 2015 г. объект временно переводится в состояние 29 февраля 2015, а эта дата некорректна.)

А если вставлять вызовы инварианта в начале и в конце каждого метода? Снова не то. Предположим, что функция переводит дату на месяц вперед. Такая функция могла бы, например, отслеживать ежемесячные события. Функция должна уделять внимание лишь корректировке дня ближе к концу месяца, так чтобы дата изменялась, например с 31 августа на 30 сентября.

// Внутри класса Date
void nextMonth()
{
    __call_invariant();             // Вставлено компилятором
    scope(exit) __call_invariant(); // Вставлено компилятором
    if (month == 12)
    {
        ++year;
        month = 1;
    }
    else
    {
        ++month;
        adjustDay();
    }
}
// Вспомогательная функция
private void adjustDay()
{
    __call_invariant();             // Вставлено компилятором (ПРОБЛЕМАТИЧНО)
    scope(exit) __call_invariant(); // Вставлено компилятором (ПРОБЛЕМАТИЧНО)
    switch (day)
    {
        case 29:
            if (month == 2 && !leapYear(year)) day = 28;
            break;
        case 30:
            if (month == 2) day = 28 + leapYear(year);
            break;
        case 31:
            if (month == 2) day = 28 + leapYear(year);
            else if (!isLongMonth(month)) day = 30;
            break;
        default:
            // Ничего не делать
            break;
    }
}

Функция nextMonth заботится о смене лет и использует вспомогательную локальную (private) функцию adjustDay, чтобы обеспечить корректность даты. Здесь-то и кроется проблема: на входе в adjustDay инвариант может оказаться «сломанным»! Разумеется, может ведь функция adjustDay предназначена именно для исправления объекта класса Date!

Что делает функцию adjustDay особенной? Уровень доступа: это локальная (закрытая) функция, доступная только другим функциям, имеющим право модифицировать объект класса Date. В общем случае на входе в закрытую функцию и на выходе из нее допускается несоблюдение инварианта. Где инвариант точно должен выполняться, так это на границах общедоступных методов: объект не допустит, чтобы клиентские операции застигли или оставили его в некорректном состоянии.

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

Если класс определяет инвариант, компилятор автоматически вставляет обращения к этому инварианту в следующих местах:

  1. В конце всех конструкторов.
  2. В начале деструктора.
  3. В начале и конце всех общедоступных нестатических методов.

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

class Date
{
    private uint day, month, year;
    invariant() { ... }
    this(uint day, uint month, uint year)
    {
        scope(exit) __call_invariant();
        ...
    }
    ~this()
    {
        __call_invariant();
        ...
    }
    void somePublicMethod()
    {
        __call_invariant();
        scope(exit) __call_invariant();
        ...
    }
}

Добавим пару слов о конструкторе и деструкторе. Вспомните, что говорилось о жизненном цикле объекта в разделе 6.3: после выделения памяти под объект он считается полностью дееспособным. Следовательно, даже если конструктор порождает исключение, он все равно должен оставить объект в состоянии, подчиняющемся условию инварианта.

В начало ⮍ Наверх ⮍

10.6. Пропуск проверок контрактов. Итоговые сборки

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

Любой компилятор языка D предоставляет флаг (-release в случае эталонной реализации), при задании которого контракты полностью игнорируются, то есть компилятор выполняет синтаксический анализ и проверки типов для всего кода контрактов, но не оставляет от него и следа в исполняемом бинарном файле. Результат итоговой сборки исполняется без проверки контрактов (что рискованнее), но зато на полной скорости. Если в приложении все разложено по полочкам, дополнительный риск, связанный с пропуском проверок контрактов, очень мал и полностью искупается увеличением скорости. Возможность запуска без контрактов веский довод в пользу предупреждения о том, что код не должен использовать контракты для обычных проверок, которые вполне могут завершаться неудачей. Контракты должны быть зарезервированы для недопустимых ошибок, отражающих изъян логики вашего приложения. Повторяю: никогда не проверяйте корректность ввода данных пользователем с помощью контрактов. Кроме того, вспомните неоднократные предупреждения о том, что внутри assert, in и out нельзя выполнять никакие важные действия. Теперь совершенно ясно почему: программа, которая так нехорошо поступает, почему-то по-разному ведет себя в промежуточной и итоговой сборках.

Одна из наиболее частых ошибок утверждение выражений с побочными эффектами, например assert(++x < y), которому суждено стать настоящей головоломкой. Это худшее из возможного: ошибка появляется только в итоговой сборке, когда у вас по определению меньше средств для обнаружения источника проблемы.

В начало ⮍ Наверх ⮍

10.6.1. enforce это не (совсем) assert

Жаль, что удобные инструкции с ключевым словом assert исчезают из итоговых сборок. Вместо

if (!expr1) throw new SomeException;
...
if (!expr2) throw new SomeException;
...
if (!expr3) throw new SomeException;

можно было бы написать просто

assert(expr1);
...
assert(expr2);
...
assert(expr3);

Ввиду лаконичности инструкции assert множество библиотек предоставляют «assert с гарантией» средство, которое проверяет условие и в случае нулевого результата порождает исключение независимо от того, как вы провели компиляцию в режиме итоговой сборки или нет. Такие «контролеры» есть в C это VERIFY, ASSERT_ALWAYS и ENFORCE. Язык D определяет аналогичную функцию enforce в модуле std.exception. Используйте enforce с тем же синтаксисом, что и assert:

enforce(expr1);
enforce(expr2, "Это не совсем верно");

Если выражение-аргумент нулевое, функция enforce порождает исключение типа Exception независимо от того, как вы скомпилировали программу в режиме итоговой или промежуточной сборки. Порождение исключения другого типа задается так:

import std.exception;
bool something = true;

enforce(something, new Error("Что-то не так"));

Если значение something нулевое, порождается объект, переданный во втором аргументе; функция enforce использует механизм ленивых аргументов1, так что если значение выражения something ненулевое, никакого создания объекта не произойдет.

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

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

В начало ⮍ Наверх ⮍

10.6.2. assert(false) останов программы

Если во время компиляции известно, что константа равна нулю, то утверждение с этой константой (вида assert(false), assert(0) и assert(null)) ведет себя несколько иначе, чем обычное утверждение.

В режиме промежуточной сборки инструкция assert(false); не делает ничего особенного: она лишь порождает исключение типа AssertError.

Зато в режиме итоговой сборки инструкция assert(false); не исключается при компиляции; она всегда вызывает останов программы. Но в этом случае не будет ни исключения, ни шанса продолжить выполнение после того, как очередь дошла до assert(false). Произойдет программный сбой. На машинах марки Intel для этого есть инструкция HLT (от halt стой!), принудительно завершающая выполнение программы.

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

Для чего нужно это особое поведение assert(false)? Самое очевидное применение касается программ системного уровня. Необходим переносимый способ выполнить HLT, а assert(false) хорошо вписывается в язык. Добавим, что компилятор в курсе семантики assert(false), например он запрещает оставлять после выражения assert(false) «мертвый» код:

int fun(int x)
{
    ++x;
    assert(false);
    return x; // Ошибка! Инструкция недоступна!
}

В других ситуациях, наоборот, assert(false) поможет пресечь ошибку компилятора. Рассмотрим, например, вызов только что упомянутой стандартной функции std.exception.enforсe(false):

import std.exception;

string fun()
{
    ...
    enforce(false, "продолжать невозможно"); // Всегда порождает исключение
    assert(false);                           // Эта инструкция недоступна
}

Вызов enforce(false) всегда порождает исключение, но компилятор не знает об этом. Инструкция assert(false); дает компилятору понять, что эта точка недостижима. Завершить выполнение fun можно и с помощью инструкции return "";, но если позже кто-нибудь закомментирует вызов enforce, fun начнет возвращать фиктивные значения. Выражение assert(false) настоящий deus ex machina, избавляющий ваш код от таких ситуаций.

В начало ⮍ Наверх ⮍

10.7. Контракты не для очистки входных данных

Этот раздел посвящен спорному вопросу в связи с контрактами, послужившему источником длительных дебатов. Суть вопроса: «Куда лучше поместить выполняемую функцией проверку: в контракт или в тело функции?»

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

import std.file, std.utf;

string readText(in char[] filename)
out(result)
{
    std.utf.validate(result);
}
body
{
    return cast(string) read(filename);
}

(На самом деле, readText это функция из стандартной библиотеки; найти ее можно в модуле std.file.)

Функция readText полагается на две другие функции для работы с файлом. Во-первых, чтобы целиком загрузить файл в буфер памяти, readText вызывает функцию read. Буфер памяти имеет тип void[]; функция readText преобразует это значение в строку с помощью оператора cast. Но останавливаться на этом нельзя: что если файл содержит некорректные UTF-знаки? Чтобы проверить результат преобразования, out-контракт вызывает применительно к результату readText функцию std.utf.validate, которая порождает исключение типа UtfException, если буфер содержит некорректный UTF-знак.

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

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

import std.file, std.utf;

string readText(in char[] filename)
{
    auto result = cast(string) read(filename);
    std.utf.validate(result);
    return result;
}

Все это приводит к такому ответу на вопрос о месте расположения проверок: если проверка касается логики приложения, то ее следует поместить в контракт; в противном случае проверку помещают в тело функции и никогда не пропускают.

Звучит неплохо, но как определить понятие «логика приложения» для приложения, построенного из отдельных стандартных библиотек, написанных независимыми сторонами? Представьте большую библиотеку общего назначения, такую как Microsoft Windows API или K Desktop Environment. Подобные API используются множеством приложений, и библиотечные функции неизбежно получают аргументы, не соответствующие спецификации. (На самом деле, API операционной системы должен быть рассчитан на получение всевозможных некорректных аргументов.) Если приложение не выполнило предусловие вызова библиотечной функции, чья это вина? Очевидно, что это ошибка приложения, но именно библиотека получает удар в виде нестабильности, непредсказуемого поведения, испорченного внутреннего состояния библиотеки, сбоев, всех этих неприятностей сразу. Хоть это и явная несправедливость, но, к сожалению, достается за эти проблемы в основном библиотеке («Библиотека Xyz склонна к нестабильности и неожиданным причудам»), а не кривой логике приложений, которые ее используют.

Широко распространенный API общего назначения должен проверять входные данные всех своих функций как положено не в контрактах. Отсутствие проверки аргумента однозначно ошибка библиотеки. Ни один пресс-секретарь не станет размахивать книгой или статьей со словами: «Мы везде использовали контрактное программирование, так что это не наша вина».

Разве это помешало бы указывать в предусловии те же диапазоны аргументов функций? Вовсе нет. Все зависит от определения и разграничения «логики приложения» и «пользовательского ввода». С точки зрения функции как неотъемлемой части приложения получение аргументов часть логики приложения. А для функции общего назначения из независимо поставляемой библиотеки аргументы не что иное, как пользовательский ввод.

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

В начало ⮍ Наверх ⮍

10.8. Наследование

Часто цитируемый принцип подстановки Барбары Лисков гласит, что наследование это возможность подстановки: экземпляр производного класса (потомка) можно подставить везде, где ожидается экземпляр его базового класса (предка). Такое понимание, по сути, определяет взаимодействие контрактов с наследованием.

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

В начало ⮍ Наверх ⮍

10.8.1. Наследование и предусловия

Вернемся к примеру с классом Date. Допустим, мы определили очень простой, легковесный класс BasicDate, который предоставляет лишь минимальную функциональность, а реализацию усовершенствований оставляет классам-потомкам. В BasicDate есть функция format, которая принимает аргумент типа string (спецификация формата) и возвращает строку с датой, отформатированную заданным образом:

import std.conv;

class BasicDate
{
    private uint day, month, year;
    string format(string spec)
    in
    {
        // Требование равенства spec строке "%Y/%m/%d"
        assert(spec == "%Y/%m/%d");
    }
    body
    {
        // Упрощенная реализация
        return text(year, '/', month, '/', day);
    }
    ...
}

Контракт, заключенный функцией Date.format, требует, чтобы спецификация формата точно соответствовала "%Y/%m/%d", то есть «год в четырехзначном формате, затем косая черта, затем месяц, затем косая черта, затем день». Это единственный формат, о поддержке которого заботится BasicDate. Классы-потомки могут добавить локализацию, интернационализацию и все, что только можно.

Наследник класса BasicDate класс Date желает предложить лучший примитив формата, например, позволяющий спецификаторам %Y, %m и %d занимать любые позиции и перемешиваться с произвольными знаками. Кроме того, должно быть разрешено сочетание знаков %%, поскольку оно представляет сам знак %. Повторное вхождение одних и тех же спецификаторов также должно быть разрешено. Чтобы воплотить все это в жизнь, Date пишет собственный контракт:

import std.regex;

class Date : BasicDate
{
    override string format(string spec)
    in
    {
        auto pattern = regex("^(%[mdY%]|[^%])*$");
        assert(!match(spec, pattern).empty);
    }
    body
    {
        string result;
        ...
        return result;
    }
    ...
}

Date накладывает свои ограничения на spec с помощью регулярного выражения. Регулярные выражения это бесценная помощь в манипуляции строками: классический труд Фридла «Регулярные выражения» не просто рекомендуется к прочтению, а горячо рекомендуется. Не углубляясь в регулярные выражения, достаточно сказать, что "^(%[mdY%]|[^%])*$" означает: «строка, которая может быть пустой или содержать повторяющуюся сколько угодно раз следующую комбинацию знаков: %, а за ним любой знак из m, d, Y и % либо любой знак, отличный от %». Эквивалентный код, проводящий сопоставление такому шаблону «вручную», оказался бы гораздо более многословным. Утверждение гарантирует, что при сопоставлении строки и шаблона совпадений будет больше нуля, то есть то, что сопоставление сработает. (Более подробно применение регулярных выражений в D описано в онлайн-документации по стандартному модулю std.regex.)

Каков совокупный контракт Date.format? Он должен учитывать контракт BasicDate.format, но в то же время ослаблять его. Вполне приемлемо, если in-контракт соблюден не будет, но при этом обязательно должен выполняться контракт потомка. Кроме того, контракт Date.format ни при каких обстоятельствах не должен ужесточать контракт BasicDate.format. Появляется правило: в переопределенном методе сначала выполнить контракт предка если выполнение завершится удачей, выполнить тело функции; в противном случае выполнить контракт потомка в случае успеха выполнить тело функции, иначе сообщить о неудаче.

Другими словами, in-контракты комбинируются с использованием дизъюнкции в сочетании с коротким замыканием: точно один контракт должен выполниться, и контракт предка пробуется первым. Таким образом, исключается вероятность того, что контракт потомка труднее удовлетворить, чем контракт предка. С другой стороны, потомок предоставляет второй шанс пройти тест предусловия, не пройденный в первый раз.

Это правило прекрасно работает для Date и BasicDate. Сначала составной контракт проверяет входные данные на соответствие шаблону "%Y/%m/%d". В случае успеха форматирование продолжается. Иначе выполняется проверка на соответствие контракту класса-потомка. В случае удачного исхода этого теста форматирование снова может быть продолжено.

Код, сгенерированный для комбинированного контракта, выглядит так:

void __in_contract_Date_format(string spec)
{
    try
    {
        // Попробовать контракт предка
        this.BasicDate.__in_contract_format(spec);
    }
    catch (Throwable)
    {
        // Контракт предка не выполнен, попробовать контракт потомка
        this.Date.__in_contract_format(spec);
    }
    // Успех, можно выполнить тело функции
}

В начало ⮍ Наверх ⮍

10.8.2. Наследование и постусловия

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

С другой стороны, это означает, что класс-предок должен заключать максимально свободный контракт, не рискуя чересчур ограничить класс-потомок. Например, потребовав от возвращаемой строки соответствия формату «год/месяц/день», метод BasicDate.format полностью запретил бы использование любым классом-потомком любого другого формата. BasicDate.format мог бы обязать своих потомков выполнять не столь строгий контракт например, если строка формата не пуста, то использовать пустую строку в качестве выходных данных запрещено:

import std.range, std.string;

class BasicDate
{
    private uint day, month, year;
    string format(string spec)
    out(result)
    {
        assert(!result.empty || spec.empty);
    }
    body
    {
        return std.string.format("%04s/%02s/%02s", year, month, day);
    }
    ...
}

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

import std.algorithm, std.regex;
class Date : BasicDate
{
    override string format(string spec)
    out(result)
    {
        bool escaping;
        size_t expectedLength;
        foreach (c; spec)
        {
            switch (c)
            {
                case '%':
                    if (escaping)
                    {
                        ++expectedLength;
                        escaping = false;
                    }
                    else
                    {
                        escaping = true;
                    }
                    break;
                case 'Y':
                    if (escaping)
                    {
                        expectedLength += 4;
                        escaping = false;
                    }
                    break;
                case 'm': case 'd':
                    if (escaping)
                    {
                        expectedLength += 2;
                        escaping = false;
                    }
                    break;
                default:
                    assert(!escaping);
                    ++expectedLength;
                    break;
            }
        }
        assert(walkLength(result) == expectedLength);
    }
    body
    {
        string result;
        ...
        return result;
    }
    ...
}

(Почему walkLength(result) вместо result.length? Потому что количество знаков в строке в кодировке UTF может быть меньше, чем ее длина в кодовых единицах.) Даны два контракта. Каким должен быть комбинированный out-контракт? Ответ прост: контракт класса-предка также должен быть проверен. Далее, если класс-потомок обещает выполнить дополнительные контрактные обязательства, они также должны быть соблюдены. Это простая конъюнкция. Следующий код представляет собой то, что должен сгенерировать компилятор, чтобы соединить контракты базового и производного классов:

void __out_contract_Date_format(string spec)
{
    this.BasicDate.__out_contract_format(spec);
    this.Date.__out_contract_format(spec);
    // Успех
}

В начало ⮍ Наверх ⮍

10.8.3. Наследование и инварианты

Как и в случае out-контрактов, мы имеем дело с конъюнкцией, отношением «И»: помимо собственного инварианта класс должен следить за соблюдением инвариантов всех своих предков. Для класса не существует способа ослабить инвариант своего предка. Текущая версия компилятора делает вызовы блоков invariant сверху донизу по иерархии, но для того, кто реализует invariant, порядок не важен, ведь инварианты не должны обладать побочными эффектами.

В начало ⮍ Наверх ⮍

10.9. Контракты и интерфейсы

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

Предположим, что требуется усовершенствовать интерфейс Stack, определенный в разделе 6.14. Приведем его для справки:

interface Stack(T)
{
    @property bool empty();
    @property ref T top();
    void push(T value);
    void pop();
}

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

interface Stack(T)
{
    @property bool empty();
    @property ref T top()
    in
    {
        assert(!empty);
    }

    void push(T value)
    out
    {
        assert(value == top);
    }

    void pop()
    in
    {
        assert(!empty);
    }
}

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

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

interface NVIStack(T)
{
protected:
    ref T topImpl();
    void pushImpl(T value);
    void popImpl();
public:
    @property bool empty();

    final @property ref T top()
    {
        enforce(!empty);
        return topImpl();
    }

    final void push(T value)
    {
        pushImpl(value);
        enforce(value == topImpl());
    }

    final void pop()
    {
        assert(!empty);
        popImpl();
    }
}

NVIStack повсюду использует enforce-тест, который невозможно стереть во время компиляции, а также определяет методы push, pop и top как финальные, то есть запрещает реализациям их переопределять. Хорошо здесь то, что всю основную обработку ошибок можно переложить с каждой из реализаций на интерфейс неплохой метод повторного использования кода и разделения ответственности. Реализации интерфейса NVIStack могут без опаски полагаться на то, что pushImpl, popImpl и topImpl всегда вызываются в корректных состояниях, и оптимизировать свои методы с учетом этого.

В начало ⮍ Наверх ⮍

🢀 9. Обработка ошибок 10. Контрактное программирование 11. Расширение масштаба 🢂


  1. Ленивые аргументы описаны в разделе 5.2.4. Прим. науч. ред. ↩︎