Пробуем контрактное программирование С++20 уже сейчас

  • Tutorial


В С++20 появилось контрактное программирование. На текущий момент ни один компилятор ещё не реализовал поддержку этой возможности.


Но есть способ уже сейчас попробовать использовать контракты из C++20, так как это описано в стандарте.


TL;DR


Есть форк clang, поддерживающий контракты. На его примере я рассказываю как пользоваться контрактами, чтобы как только фича появилась в вашем любимом компиляторе, вы сразу же могли начать её использовать.


Про контрактное программирование уже написано много, но в двух словах расскажу что это такое и для чего нужно.


Логика Хоара


В основе парадигмы контрактов лежит логика Хоара (1, 2).


Логика Хоара – это способ формального доказательства корректности алгоритма.
Она оперирует такими понятиями, как предусловие, постусловие и инвариант.
С практической точки зрения, использование логики Хоара это, во-первых, способ формального доказательства корректности программы в тех случаях, когда ошибки могут привести к катастрофе или гибели людей. Во-вторых, способ повысить надёжность программы, наряду со статическим анализом и тестированием.


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


(1, 2)


Основная идея контрактов в том, что по аналогии с контрактами в бизнесе, для каждой функции или метода описываются договорённости. Эти договорённости должны соблюдать как вызывающая сторона, так и вызываемая.
Неотъемлемой частью контрактов является как минимум два режима сборки – отладочный и продуктовый. В зависимости от режима сборки контракты должны себя вести по разному. Наиболее распространённой практикой является проверка контрактов в отладочной сборке и их игнорирование в продуктовой.


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


Основное отличие использования контрактов от «классического» подхода в том, что вызывающая сторона должна соблюдать предусловия вызываемой стороны, которые описываются в контракте, а вызываемая должна соблюдать свои постусловия и инварианты.
Соответственно, вызываемая сторона не обязана проверять корректность передаваемых её параметров. Эта обязанность возлагается контрактом на вызывающую сторону.


Несоблюдение контрактов должно быть обнаружено на этапе тестирования и дополняет все виды тестов: модульные интеграционные и т. д.


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


Итак, какую пользу дают контракты:


  • Улучшают читаемость кода за счёт явного документирования.
  • Повышают надёжность кода, дополняя собой тестирование.
  • Позволяют компиляторам использовать низкоуровневые оптимизации и генерировать более быстрый код в расчёте на соблюдение контракта. В последнем случае несоблюдение контракта в релизной сборке может вести к UB.

Контрактное программирование в C++


Контрактное программирование реализовано во многих языках. Наиболее яркие примеры, это Eiffel, где парадигма была впервые реализована, и D, в D контракты являются частью языка.


В C++, до стандарта C++20, контракты можно было использовать в виде отдельных библиотек.


Такой подход имеет ряд недостатков:


  • Весьма неуклюжий синтаксис с использованием макросов.
  • Отсутствие единого стиля.
  • Невозможность использования контрактов компилятором для оптимизации кода.

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


Использование контрактов в таком виде, действительно делает код уродливым и нечитаемым. Это одна из причин, почему использование контрактов в C++ мало практикуется.


Забегая вперёд, покажу как в C++20 будет выглядеть использование контрактов.
А затем, разберём всё это подробнее:


int f(int x, int y)
    [[ expects: x > 0 ]]       // precondition
    [[ expects: y > 0 ]]       // precondition
    [[ ensures r: r < x + y ]] // postcondition
{
    int z = (x - x%y) / y;
    [[ assert: z >= 0 ]];      // assertion
    return z + y;
}

Пробуем


К сожалению, на текущий момент ни один из широко используемых компиляторов ещё не реализовал поддержку контрактов.
Но есть выход.


ARCOS research group из Universidad Carlos III de Madrid реализовали экспериментальную поддержку контрактов в форке clang++.


Чтобы не «писать код на бумажке», а иметь возможность сразу же попробовать новые возможности в деле, мы можем собрать этот форк и с его помощью пробовать приводимые ниже примеры.


Инструкция по сборке описана в readme репозитория на Гитхабе
https://github.com/arcosuc3m/clang-contracts


git clone https://github.com/arcosuc3m/clang-contracts/
mkdir -p clang-contracts/build/ && cd clang-contracts/build/
cmake -G "Unix Makefiles" -DLLVM_USE_LINKER=gold -DBUILD_SHARED_LIBS=ON -DLLVM_USE_SPLIT_DWARF=ON  -DLLVM_OPTIMIZED_TABLEGEN=ON ../
make -j8

У меня не возникло проблем при сборке, но компиляция исходников занимает очень много времени.


Для компиляции примеров вам нужно будет явно указать путь к бинарнику clang++.
Например, у меня это выглядит примерно так


/home/valmat/work/git/clang-contracts/build/bin/clang++ -std=c++2a -build-level=audit -g test.cpp -o test.bin

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


git clone https://github.com/valmat/cpp20-contracts-examples/
cd cpp20-contracts-examples
make CPP=/path/to/clang++

Здесь /path/to/clang++ путь к бинарнику clang++ вашей сборки экспериментального компилятора.


Кроме самого компилятора, ARCOS research group подготовили свою версию Compiler Explorer для своего форка.


Контрактное программирование в C++20


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


Как уже было сказано выше, контракты строятся из предусловий, постусловий и инвариантов (утверждений).


В C++20 для этого используются атрибуты со следующим синтаксисом


[[contract-attribute modifier identifier: conditional-expression]]

Где contract-attribute может принимать одно из следующих значений:
expects, ensures или assert.


expects используется для предусловий, ensures для постусловий и assert для утверждений.


conditional-expression – это булево выражение, проверяемый в контракте предикат.
modifier и identifier могут быть опущены.


Зачем нужен modifier я напишу чуть ниже.


identifier используется только с ensures и служит для представления возвращаемого значения.


Предусловия имеют доступ к аргументам.


Постусловия имеют доступ к возвращаемому функцией значению. Для этого используется синтаксис


[[ensures return_variable: expr(return_variable)]]

Где return_variable любое валидное выражение для переменной.


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


Считается, что предусловия и постусловия являются частью интерфейса функции, в то время как утверждения являются частью её реализации.


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


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


Если при проверке выражения в контракте возникло исключение, то будет вызван std::terminate().


Предусловия и постусловия всегда описываются вне тела функции и не могут иметь доступ к локальным переменным.


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


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


пример 1


Определим два предусловия, одно постусловие и один инвариант:


int foo(int x, int y)
    [[ expects: x > y ]]   // precondition  #1
    [[ expects: y > 0 ]]   // precondition  #2
    [[ ensures r: r < x ]] // postcondition #3
{
    int z = (x - x%y) / y;
    [[ assert: z >= 0 ]];  // assertion
    return z;
}

int main()
{
    std::cout << foo(117, 20) << std::endl;
    std::cout << foo(10,  20) << std::endl; // <-- contract violation #1
    std::cout << foo(100, -5) << std::endl; // <-- contract violation #2

    return 0;
}

пример 2


Предусловие публичного метода не может ссылаться на защищённое или приватное поле:


struct X
{
//protected:
    int m = 5;
public:
    int foo(int n)
        [[expects: n < m]]
    {
        return n*n;
    }
};

Не допускается модификация переменных внутри выражений, описываемых атрибутами контракта. Если это нарушено, будет UB.


Выражения, описываемые в контрактах, не должны иметь побочных эффектов. Хотя компиляторы могут это проверять, такая обязанность на них не возлагается. Нарушение этого требования считается неопределённым поведением.


struct X
{
    int m = 5;
    int foo(int n)
        [[ expects: n < m++ ]]  // UB: Modifies variable m
    {
        int k = n*n;
        [[ assert: ++k < 100 ]] // UB: Modifies variable k
        return n*n;
    }
};

Требование не изменять состояние программы в выражениях контрактов станет очевидно чуть ниже, когда я расскажу про уровни модификаторов контрактов и режимы сборки.


Сейчас просто отмечу, что корректная программа должна работать так же, как если бы контрактов вообще не было.


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


Это означает, что в первую очередь всегда проверяются предусловия, как проиллюстрировано в следующем примере:


int foo(int n)
    [[ expects:   expr(n) ]] // # 1
    [[ ensures r: expr(r) ]] // # 4
    [[ expects:   expr(n) ]] // # 2
    [[ expects:   expr(n) ]] // # 3
    [[ ensures r: expr(r) ]] // # 5
{...}

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


int foo(int &n) [[ ensures: expr(n) ]];

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


Если постусловие ссылается на аргумент функции, то этот аргумент рассматривается в точке выхода из функции, а не в точке входа, как в случае с предусловиями.


Нет никакого способа ссылаться на оригинальное (в точке входа в функцию) значение в постусловии.


пример:


void incr(int &n)
    [[ expects: 3 == n ]]
    [[ ensures: 4 == n ]]
{++n;}

Предикаты в контрактах могут ссылаться на локальные переменные, только если время жизни этих переменных соответствует времени вычисления предиката.


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


пример:


int a = 1;
constexpr int b = 100;

constexpr int foo(int n)
  [[ expects: a <= n ]] // error: `a` is not constexpr
  [[ expects: n <  b ]] // OK
{
  [[assert: n > 2*a]];  // error: `a` is not constexpr
  [[assert: n < 2*b]];  // OK
  return 2*n;
}

Контракты для указателей на функцию


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


пример:


int foo(int n)
    [[expects: n < 10]]
{
    return n*n;
}

int (*pfoo)(int n) = &foo;

Вызов pfoo(100) приведёт к нарушению контракта.


Контракты при наследовании


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


В реализации C++20 это не так.


Во-первых, инварианты в C++20 являются частью реализации, а не интерфейса. По этой причине, их можно как усилить, так и ослабить. Если в реализации виртуальной функции assert отсутствует, то он не будет унаследован.


Во-вторых, требуется, чтобы при наследовании функции были ODR идентичны.
А, поскольку предусловия и постусловия являются частью интерфейса, то в наследнике они должны в точности совпадать.


При этом, описание предусловий и постусловий при наследовании можно опустить. Но если они объявлены, то должны в точности совпадать с определением в базовом классе.


пример:


struct Base
{
    virtual int foo(int n)
        [[ expects:   n < 10  ]]
        [[ ensures r: r > 100 ]]
    {
        return n*n;
    }
};

struct Derived1 : Base
{
    virtual int foo(int n) override
        [[ expects:   n < 10  ]]
        [[ ensures r: r > 100 ]]
    {
        return n*n*2;
    }
};

struct Derived2 : Base
{
    // Inherits contracts from Base
    virtual int foo(int n) override
    {
        return n*3;
    }
};

Замечание

К сожалению, пример выше не работает в экспериментальном компиляторе как ожидается.


Если у foo из Derived2 опустить контракт, то он не будет унаследован из базового класса. Кроме того, компилятор позволяет определить для подкласса контракт несовпадающий с контрактом базового.


Ещё одна ошибка экспериментального компилятора:


синтаксически правильной должна быть запись


virtual int foo(int n) override
    [[expects: n < 10]]
{...}

Однако в таком виде я получил ошибку компиляции


inheritance1.cpp:20:36: error: expected ';' at end of declaration list
    virtual int foo(int n) override
                                   ^
                                   ;

и пришлось заменить на


virtual int foo(int n)
    [[expects: n < 10]]
override
{...}

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


Модификаторы контрактов


Проверки предикатов контрактов могут нести дополнительные вычислительные расходы.
Поэтому распространённой практикой является проверка контрактов в девелоперской и тестовой сборках и их игнорирование в релизной сборке.


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


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

Пример


[[expects: expr]]         // Неявно default
[[expects default: expr]] // Явно default
[[expects axiom  : expr]] // Run-time проверки не выполняются
[[expects audit  : expr]] // Вычислительно дорогая проверка

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


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


На усмотрение компилятора, могут быть предоставлены средства для включения проверок выражений, помеченных как axiom.


В нашем случае, это опция компилятора


-axiom-mode=<mode>

-axiom-mode=on включает режим аксиом и, соответственно, выключает проверку утверждений с идентификатором axiom,


-axiom-mode=off выключает режим аксиом и, соответственно, включает проверку утверждений с идентификатором axiom.


пример:


int foo(int n)
    [[expects axiom: n < 10]]
{
    return n*n;
}

Программа может быть скомпилирована с тремя разными уровнями проверки:


  • off выключает все проверки выражений в контрактах
  • default проверяются только выражения с модификатором default
  • audit расширенный режим, когда выполняются все проверки с модификатором default и audit

Как именно реализовывать установку уровня проверки отводится на усмотрение разработчиков компилятора.


В нашем случае, для этого используется опция компилятора


-build-level=<off|default|audit>

По умолчанию используется -build-level=default


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


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


Перехват нарушения контракта


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


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


При компиляции можно установить обработчик violation handler, вызываемый при нарушении контракта.


Способ реализации установки обработчика отводится на усмотрение создателей компилятора.


В нашем случае это


-contract-violation-handler=<violation_handler>

Сигнатура обработчика должна иметь вид


void(const std::contract_violation& info)

или


void(const std::contract_violation& info) noexcept

std::contract_violation эквивалентна следующему определению:


struct contract_violation
{
    uint_least32_t   line_number()     const noexcept;
    std::string_view file_name()       const noexcept;
    std::string_view function_name()   const noexcept;
    std::string_view comment()         const noexcept;
    std::string_view assertion_level() const noexcept;
};

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


Если обработчик violation handler задан, то, в случае нарушения контракта, по умолчанию, сразу после его выполнения будет вызван std::abort() (Без указания обработчика вызывается std::terminate()).


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


Способ реализации этих средств остаётся на усмотрение разработчиков компилятора.
В нашем случае, это опция компилятора


-fcontinue-after-violation

Опции -fcontinue-after-violation и -contract-violation-handler могут быть установлены независимо друг от друга. Например, можно установить -fcontinue-after-violation, но не устанавливать -contract-violation-handler. В последнем случае, после нарушения контракта программа просто продолжит работу.


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


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


Это связано с возможностью компилятора выполнять низкоуровневые оптимизации в рассчёте на выполнение контрактов.


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


Определим свой обработчик и с его помощью перехватим нарушение контракта


void violation_handler(const std::contract_violation& info)
{
    std::cerr << "line_number     : " << info.line_number()     << std::endl;
    std::cerr << "file_name       : " << info.file_name()       << std::endl;
    std::cerr << "function_name   : " << info.function_name()   << std::endl;
    std::cerr << "comment         : " << info.comment()         << std::endl;
    std::cerr << "assertion_level : " << info.assertion_level() << std::endl;
}

И рассмотрим пример нарушения контракта:


#include "violation_handler.h"

int foo(int n)
    [[expects: n < 10]]
{
    return n*n;
}

int main()
{
    foo(100); // <-- contract violation
    return 0;
}

Скомпилируем программу с опциями -contract-violation-handler=violation_handler и -fcontinue-after-violation и запустим


$ bin/example8-handling.bin
line_number     : 4
file_name       : example8-handling.cpp
function_name   : foo
comment         : n < 10
assertion_level : default

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


Рассмотрим следующий пример:


#include "violation_handler.h"

int foo(int n)
    [[ expects axiom   : n < 100 ]]
    [[ expects default : n < 200 ]]
    [[ expects audit   : n < 300 ]]
{
    return 2 * n;
}

int main()
{
    foo(350); // audit
    foo(250); // default

    return 0;
}

Если собрать его с опцией -build-level=off то как и ожидается, контракты не будут проверяться.


Собрав с уровнем default (с опцией -build-level=default), получим следующий вывод:


$ bin/example9-default.bin
line_number     : 5
file_name       : example9.cpp
function_name   : foo
comment         : n < 200
assertion_level : default

line_number     : 5
file_name       : example9.cpp
function_name   : foo
comment         : n < 200
assertion_level : default

И сборка с уровнем audit даст:


 $ bin/example9-audit.bin
line_number     : 5
file_name       : example9.cpp
function_name   : foo
comment         : n < 200
assertion_level : default

line_number     : 6
file_name       : example9.cpp
function_name   : foo
comment         : n < 300
assertion_level : audit

line_number     : 5
file_name       : example9.cpp
function_name   : foo
comment         : n < 200
assertion_level : default

Замечания


violation_handler может бросать исключения. В этом случае можно настроить программу так, чтобы нарушение контракта вело к выбросу исключения.


Если функция, у которой описаны контракты, помечена как noexcept и при проверке контракта вызван violation_handler, который бросает исключение, то будет вызван std::terminate().


Пример


void violation_handler(const std::contract_violation&)
{
    throw std::exception();
}

int foo(int n) noexcept
    [[ expects: n  > 0 ]]
{
    return n*n;
}

int main()
{
    foo(0); // <-- std::terminate() when violation handler throws an exception
    return 0;
}

Если компилятору передан флаг: не продолжать выполнение программы после нарушения контракта (continuation mode=off), но обработчик violation handler бросает исключение, то будет принудительно вызвана std::terminate().


Заключение


Контракты относятся к неинтрузивным проверкам времени выполнения. Они играют очень важную роль в обеспечении качества выпускаемого программного обеспечения.


C++ используется очень широко. И наверняка найдётся достаточное количество претензий к спецификации контрактов. На мой субъективный взгляд, реализация получилась довольно удобной и наглядной.


Контракты C++20 позволят сделать наши программы ещё более надёжными, быстрыми и понятными. С нетерпением жду их реализацию в компиляторах.




PS
В личке мне подсказывают, что вероятно в окончательной редакции стандарта expects и ensures заменят на pre и post, соответственно.

Поделиться публикацией

Комментарии 52

    0

    Ну и что делать с этим?


    Осторожно, содержимое может задеть чувство прекрасного


      +2
      Там, ещё не хватает сообщения: «Ты, что тупой программист?» :)

      P.S. @«Береженого — бог бережет! — сказала монашка, надевая презерватив на свечку»
      (в каждой шутке есть доля шутки)
        0
        В каждой шутки есть доля правды. Вариация с «долей шутки» — прикол Задорного из 1990, а не мудрость.
          +3
          Вы не правы, «В каждой шутке есть доля шутки» — гораздо более интересное высказывание. Во-первых это тоже шутка. Во-вторых это высказывание самоприменимо, и, раз оно — шутка, значит нём только доля шутки, а остальное — что-то, что следует воспринимать всерьёз. Сделав так, вы обнаружите, что это очень тонко подмечено и почти всегда правда, тоесть на самом деле — в каждой шутке — только часть шуточна, иначе нам было бы не интересно.
          P.S. Автор высказывания — Андрей Кнышев, а вовсе не Задорнов.
            0
            Это все-таки IT форум, поэтому утверждение «В каждом X есть толя X» являтся тождественно истинным и при этом рекурсивным одновременно. :)
            PS. Про авторство Кнышева удивлен, никогда раньше не видел такого утверждения. А у Задорного так назывался концерт 1992 года.
              0
              И правда, не нашёл подтверждения авторства Кнышева. Видимо это у меня ложная память.
      0
      как заменить контрактом макрос VERIFY()? он всегда вычисляет выражение (например, вызывает системную функцию), но проводит проверку только в отладочной сборке.
        +1

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

          0
          ну вот, всё пытаются от макросов избавиться, но даже в C++20 без них никуда :)
            +1

            Именно поэтому и пытаются избавиться. Не надо смешивать вычисления и проверки.

        +4
        Весьма неуклюжий синтаксис с использованием макросов.

        Какой вариант синтаксиса является более неуклюжим?


        ASSERT(z >= 0);

        или


        [[ assert: z >= 0 ]];
          +2

          Конкретно в случае с assert и, если говорить только о внешнем виде, то — дело вкуса. Мне лично [[assert: z >= 0]] больше нравится.
          Если появляется ещё и предусловие, то вариант из стандарта уже становится чуть нагляднее, а если появляется ещё и постусловие и несколько точек выхода из функции, то разница становится очевидной. Кроме того, макросы нельзя сделать частью интерфейса. Нельзя их поместить в заголовочный файл отдельно от реализации.

            +1
            ИМХО сама идея контрактов очень интересная, но добавлять их в Плюсы — это просто очередной гвоздь в его крышку.
              +3

              Если они вам не нравятся, можете их не использовать. Не вижу чем они портят язык.

                +1
                Ну, навскидку, замедлят компиляцию, поломают кучу внешних инструментов типа генераторов тэгов, подсветки синтаксиса, статик чекеров — все это придется переделывать под новый синтаксис.
                  +5

                  Синтаксис, кстати, не то чтобы новый: это продолжение синтаксиса атрибутов, поэтому поломаться ничего не должно. А чтобы не замедлять компиляцию, можно выставить соответствующий build-level.

                    +1

                    Кстати, эти самые атрибуты как-то тихо оказались в стандарте. В отличие от других новых фич C++ я что-то совсем не видел их обсуждения, бурления го, статей здесь же. Как так? Ведь казалось бы революционное изменение, позволяющее добавлять любую метаинформацию компилятору. Где статьи по написанию плагина со своим атрибутам к любимому компилятору? Статьи про внутреннее устройство атрибутов, про их возможности, историю возникновения, сравнения с макросами, шаблонами и D? Метапрограммирование на атрибутах? Где, наконец, статьи про вылавливания неуловимых багов, суть которых в новом атрибуте, ломающем давно написанный код и т.д. и т.п.?

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

                        Если их использовать для кодогенерации (основной кейс, как мне кажется), то, например, программа может просто не компилироваться без них (ибо нет того, что они нагенерировали). Раз не компилируется, то сравнивать семантику не с чем, а значит можно сказать, что любая семантика, появляющаяся от атрибутов, корректная.


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

                          0
                          Если их использовать для кодогенерации (основной кейс, как мне кажется), то, например, программа может просто не компилироваться без них (ибо нет того, что они нагенерировали).
                          Нет, не может. В стандарте есть специальный пункт, что программа должна корректно компилироваться, встречая неизвестные компилятору атрибуты.
                  0

                  Когда говорят что-то, на что потом отвечают "Не вижу чем портят", обычно подразумевается, что автор-то может и не использовать, но точно будет читать код, где это будет, его компилятор точно будет компилировать код, где это будет и ему точно придется отлаживать код, где это будет. Так что "не использовать" никак не получается. И это отсутствие выбора удручает. Вот что имеют ввиду, когда говорят про "гвозди".


                  По теме: как предполагается проверять постусловие, если оно будет разным в зависимости от точки выхода?

                    +1
                    При таком подходе любое развитие любого языка — это «гвоздь в крышку его гроба». Например, когда в Java добавили дженерики — это был, по такой логике, «гвоздь в крышку гроба Java» потому что «автор-то может и не использовать, но точно будет читать код, где это будет, его компилятор точно будет компилировать код, где это будет и ему точно придется отлаживать код, где это будет», а также это «замедлило компиляцию, поломало кучу внешних инструментов типа генераторов тэгов, подсветки синтаксиса, статик чекеров — все это пришлось переделывать под новый синтаксис», и все такое прочее. По-моему это все надуманные аргументы. Язык должен развиваться.
                      0

                      Логично. Но я же не осуждаю развитие, а объясняю причины недовольства и несостоятельность аргумента "не нужно — не используй".

                      +1
                      Постусловие — часть контракта. Оно выполняется независимо от точки выхода.
                      Точка выхода — это имплементация. Если в разных путях есть более строгие контракты — можно добавить их [[ assert ]]'ами прямо перед выходом для проверки.

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

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

                    0
                    Для этого надо выразительную систему типов, а наворачивать её поверх имеющегося — совсем непонятно, как.
                0
                А при работе они изменяющиеся параметры функций тоже проверяют, или это только для заданных до компиляции значений работает? Любопытно, можно ли заменить проверки типа:
                if (a <= 0) {
                //Do smth
                } else {
                //Don't do
                }
                  0

                  Если я правильно понял ваш вопрос, то ответ — нет. Контракты не предназначены для ветвления. Только проверки. Причём функция должна работать также, как если бы контрактов вовсе не было.
                  По поводу


                  только для заданных до компиляции значений

                  Если функция, не constexpr, то она может ссылаться на не-constexpr локальные переменные из своей области видимости (есть нюанс с приватными и защищёнными полями класса).
                  Например:


                  int a = 1;
                  int foo(int n) [[ expects: a <= n ]];

                  Функция не-constexpr — может ссылаться на не-constexpr переменную.

                    0

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

                      0

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

                        0
                        Понял, спасибо
                  +1
                  использование логики Хоара это, во-первых, способ формального доказательства корректности программы в тех случаях, когда ошибки могут привести к катастрофе или гибели людей

                  А если нет угрозы катастрофы или гибели людей, то контракты не дают способ доказательства корректности программ?!
                    0

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

                      0
                      В смысле пригрозил убить — и контракты стали доказательством? А перестал грозить — перестали?
                        +1

                        Нет. Если надо перед кем-то отчитаться о том, что сделали надёжно — используешь контракты. Если надо сделать надёжно — то используешь средства формальной верификации.

                          0
                          Собственно, непонятно, как можно в двух последующих абзацах говорить о верификации программ и о каких-то там проверках времени выполнения. Во время выполнения инварианты уже поздно проверять.
                            0

                            Верификация во время выполнения должна отрабатывать на тестовом стенде. В проде проверки вообще желательно отключать.

                              0
                              В идеале верификация должна выполняться во время компиляции.
                                0

                                Конечно. Но иногда этого недостаточно. Поэтому придумали тесты, valgrind, контракты и прочие проверки времени выполнения.

                    +2
                    так будет ли контроль переполнения чисел (checked arithmetics)? можно ли будет в контракте сказать, что int a=MAX_INT+1 — это плохо?
                      0

                      Жаль, что установку хандлера отдали на усмотрение компилятора…


                      Ожидал что либо сделают что-то вроде std::contract_violation_exception, которое бы вылетало в случае нарушения контракта в дебаге (а ::what() уже оставить деталям реализации), либо хотя бы std::set_contract_handler() (no-op в релизе с правом выкинуть функцию, ясное дело).


                      А так придётся делать что-то вроде


                      set_property(TARGET program PROPERTY CXX_CONTRACT_HANDLER "mynamespace::contract_handler")

                      в CMake'е. И ещё получим варнинг от анализатора о неиспользуемой функции, прелестно.


                      Ещё не совсем понял один момент: чтобы проверять мои вызовы к либе на валидность, мне придётся её тоже собрать в debug? Звучит как-то не очень.

                        0
                        Ещё не совсем понял один момент: чтобы проверять мои вызовы к либе на валидность, мне придётся её тоже собрать в debug?

                        Да. Нужна будет отдельная сборка либы.

                        0
                        Для critical safety систем как раз хотелось бы отреагировать на некорректную ситуацию прямо в вызываемой функции, а не «размазывать» по коду проверки ДО вызова или вообще получить крэш системы. Пока для себя не вижу пользы в хоть сколько нибудь массовом использовании контрактов.
                          +3
                          Несколько замечаний:
                          1. Логика Хоара позволяет доказывать не корректность алгоритма, а корректность программы. Это важно. Например, существует не один алгоритм сортировки массивов, пред- и постусловия у каждой конкретной реализации будут совпадать, только вот инварианты циклов и условия корректности будут существенно отличаться (как следствие, их доказательство).
                          2. Возможность проводить анализ частичной корректности программы (то есть соответствие её спецификации — «контрактам» в описываемой терминологии) существует достаточно давно: достаточно вспомнить проект VCC. Есть даже плагин для MS Visual Studio.
                          3. Совершенно неясен вопрос с семантикой. Например, как моделируется память?
                          [[ expects: n == 5 ]]
                          [[ ensures n: n == 7 ]]
                          {
                          int n;
                          int *p = &n;
                          *p = 6;
                          }
                          В данном примере в контрактах побочных эффектов нет. Что будет в результате обработки данного кода?
                          4. Без инвариантов циклов всё это будет более-менее работать только в безцикловых программах (фрагментах программ). Причём автоматически породить инвариант цикла невозможно (в общем случае). И если пред- и постусловие программист в общем-то, думаю, в состоянии написать (или довести до ума в процессе отладки), то вот с инвариантами циклов не всё так просто. Причём чем выше уровень вложенности цикла, тем задача становится сложнее.
                          5. Ещё про семантику: как реально компилятор будет проверять соблюдение контрактов? Нужна семантика, чтобы порождать условия корректности, а потом их доказывать. С автоматическим доказательством в общем виде тоже всё плохо. В уже упомянутом VCC идёт связка с Z3 (SMT-решатель), но и тот не бог.

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

                            Такой пример не скомпилируется.
                            Если n определён в аргументах функции, то нельзя его переопределить.
                            Видимо вы имели ввиду вот такой пример


                            int foo(int n)
                                [[ expects:   n == 5 ]]
                                [[ ensures n: n == 7 ]]
                            {
                                int *p = &n;
                                *p = 7;
                                return n;
                            }

                            Нет никаких проблем. n в ensures будет идентификатором возвращаемого значения.
                            Хотя, конечно, так лучше не делать т.к. страдает читаемость кода


                            5 Ещё про семантику: как реально компилятор будет проверять соблюдение контрактов? Нужна семантика, чтобы порождать условия корректности, а потом их доказывать.

                            Я думаю компиляторы не будут заниматься формальным доказательством корректности программы по контрактам. Такие инструменты, наверное, можно будет сделать. Но это не задача компилятора. Задача компилятора просто проверить соблюдение контракта для каждой отдельно взятой функции и что-то сделать, в зависимости от настроек сборки. Например, вызывать std::terminate или violation_handler.

                              0
                              Я как раз имел ввиду, что в контракте хотим, чтобы n == 7 (постусловие), но в теле через указатель присваиваем 6. Вот и хочу понять, как компилятор определит, что контракт не выполнен.

                              Задача компилятора просто проверить соблюдение контракта для каждой отдельно взятой функции

                              Так вот что это значит — «проверить соблюдение контракта»? Вы имеете ввиду для каждого конкретного вызова с конкретными значениями параметров? Ну тогда тут логика Хоара вообще не при чём.
                                0
                                хочу понять, как компилятор определит, что контракт не выполнен

                                При проверке постусловия не анализируется что происходит в теле функции. Просто проверяется предикат с теми значениями, которые получились при выходе из функции.


                                Ну тогда тут логика Хоара вообще не при чём

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


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


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

                                  0
                                  При проверке постусловия не анализируется что происходит в теле функции. Просто проверяется предикат с теми значениями, которые получились при выходе из функции.

                                  Так в итоге, для примера выше — компилятор поймёт, что n не 7, а 6 при выходе из функции?
                                  Стандарт не накладывает на компиляторы обязанность проверять корректность всей программы по контрактам. И программист может не описывать контракты для вообще всех функций в программе. Это допустимо. Но если всё же все контракты описаны, то проверить программу по логике Хоара возможно. Но не с помощью компилятора.

                                  И в чём тогда польза? Не вижу тогда принципиального отличия от банального перебора различных значений параметров функции и возвращаемых значений.
                                    0
                                    Так в итоге, для примера выше — компилятор поймёт, что n не 7, а 6 при выходе из функции?

                                    Да, конечно.


                                    И в чём тогда польза?

                                    вот:


                                    какую пользу дают контракты:
                                    • Улучшают читаемость кода за счёт явного документирования.
                                    • Повышают надёжность кода, дополняя собой тестирование.
                                    • Позволяют компиляторам использовать низкоуровневые оптимизации и генерировать более быстрый код.

                                      0
                                      На счёт тестирования — не убедили. Впрочем, посмотрим, что будет из всего этого дальше. Глядишь, и до настоящей верификации дозреют.
                              0

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

                                0
                                Это вряд ли. Статанализ несколько другими вещами занимается, в отличие от вопросов корректности.

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

                            Самое читаемое