C++ 20 已經移除 Contract 特性, 它被待定在之後的 C++ 標準中加入. 本文存在極大的過時風險!

warning : 本文基於目前已給出的 C++ 屬性 Contract 以及一篇關於 Contract 的 paper《A Contract Design》. 到目前為止, 暫時還未有任何編碼器對於 Contract 特性進行實現, 所以本文的程式碼到發布本網誌為止, 都暫時不可通過任何編碼器的編碼; C++ 2a 的標準目前還在處於討論階段, 最終的草案或者標準中可能會出現一些與本文衝突的地方

最近看了一下 C++ 2a, 有一個叫做 Contract 的特性被引入 C++, 順便看了一下對應的其中一個 paper, 發現這個特性主要傾向於 C++ 程式庫的作者來使用. 目前, Google 上針對 Contract 的討論還都是英文, 並沒有中文的版本, 所以在這裡寫一個中文版本的介紹, 並且對一些特性進行一定程度的討論

本篇將會對 Contract 進行介紹

關鍵字 : expects, ensures, assert, default (已有), axiom, audit

對於關鍵字而言, 其實現很可能會以保留關鍵字 (具有特殊含義的標識符號) 的形式實現, 也就是類似於 C++ 11 的 overridefinal. 換句話來說, 這些關鍵字在不同的地方有不同的含義, 在 Contract 中是一個關鍵字, 在函式中可能會成為變數的名稱

1. Contract 基礎

對於 Contract 所對應的表達式, 我暫時稱之為合約表達式 :

[[contract-keywords (contract-level) (identifier) : expression]]

其中,

contract-keywords 主要指的是 expects, assertensures

expects 承包 preconditions, 也就是前條件. 它只能出現在函式的參數列表之後, 表達了函式對於某些變數的值的期望 :

template <typename Container>

void push(queue<Container> &q, const_reference value) [[expects : not q.full()]];        //期望佇列不為滿

assert 承包 assertion, 也就是斷言. 它只能出現在函式之中, 表達了在函式運作時對某些變數的值的期望. 但是需要注意的是, 此處的 assert 並不是 macro, 而是 Contract 的一個關鍵字, 它們之間也不會產生衝突 :

void func(int n) {

    int m {};

    //...

    [[assert : n > m]];        //期望 n > m

}

ensures 承包 postcondition, 也就是後條件. 它只能出現在函式參數列表之後, 表達了函式終結之後對於某些變數的值的期望 :

template <typename InputIterator>

InputIterator find(InputIterator first, InputIterator last, const typename remove_reference<decltype(*InputIterator)>::type &) [[ensures : first <= last]];        //期望在函式終結之後, first <= last

expectsassert 不同的是, ensures 可以對回傳值進行約束 :

int func() [[ensures x : x > 0]];        //其中 x 為泛型合約表達式中的 identifier, 期望回傳值 x > 0

合約表達式是通過將泛型合約表達式中的 expression 轉型為 bool 型別進行判斷, 若 expression 轉型的最終結果為 true, 則說明合約表達式成功, 也就是程式碼正確. 對於正確的程式碼, 合約表達式幾乎不會產生什麼明顯的影響; 若 expression 轉型的最終結果為 false, 即合約表達式失敗, 那麼會造成程式 termination (呼叫 std::terminate)

合約表達式並不會對函式的型別產生影響, 所以合約表達式並不是函式性別的一部分 :

void func(int i) [[expects : i > 0]];

void func2(int);

void (*p)(int) = func;        //OK

p(1);        //OK

p = &func2;        //OK

p(2);        //OK

對於通過函式指標 (這裡其實可以引申為函式物件, 但是 cppreference 和 paper 中只提到函式指標) 呼叫對應的函式, Contracts 至少會被檢查一次

Contracts 什麼時候被檢查、預設是否對 Contracts 進行檢查是實作定義行為

Tip : 任何程式員都不應該依賴編碼器所實作的實作定義行為和未定行為, 我們應該自己保證我們的程式盡可能不出錯, 並且避免這些行為

在對於 Contracts 的檢查中, Contracts 中所包含的任何非 volatile 變數, 除了其生命週期之外, 不應該有任何變化. 否則的話, 會造成未定行為. 換句話說, 合約表達式中的任何物件在合約表達式中都不可被更改, 但是編碼器無需對此進行檢查 :

int add(int &x) {

    return ++x;

}

void func(int x) [[expects : x + 1 < 0]];        //OK

void func(int x) [[expects : add(x) < 0]];        //未定行為, 因為 x 發生變動

當一個函式存在 Contracts 的時候, 第一次對於函式的宣告必須同時宣告其具有的 Contracts. 接下來的宣告可以省略 Contracts. 如果之後在函式的宣告中, 同時宣告了函式的合約表達式的, 那麼應該和第一次宣告時的合約表達式相同 (包括形式也應該相同) :

void func(int i) [[expects : i >= 0]];        //第一次宣告, OK

void func(int i);        //第二次宣告, OK

void func(int j) [[expects : j >= 0]];        //第三次宣告, OK

void func(int i) [[expects : i > 0]];;        //第四次宣告, 病式宣告, 合約表達式與第一次宣告的合約表達式不同

void func(int i) [[expects : 0 <= i]];        //第五次宣告, 病式宣告, 合約表達式與第一次宣告的合約表達式不同

合約表達式的 assert 表達式一般處於函式之中, 所以其可以訪問到的變數和函式是一樣的, 也就是可視範圍相同; 而 expectsensures 處於函式的參數列表之後, 並沒有進入函式, 所以其變數可視範圍只有在函式之外和參數列表中的變數. 在成員函式中, expectsensures 對於 private 或者 protected 的成員也沒有任何訪問的特權

而對於被 constexpr 所限定的函式中, 若函式具有 expectsensures 合約表達式, 那麼其可視範圍只有函式之外的常數和其參數列表中的常數

對於友誼函式來說, 合約表達式只可以出現在友誼函式實作時的參數列表之後 :

struct Foo {

    friend void func(int i) [[expects : i > 0]];        //Error, 對於 func 函式目前只被宣告為被實作, 因為 func 為 Foo 類別的友誼函式, 所以這裡不可以出現合約表達式

    friend void func2(int i) [[expects : i > 0]] {}        //OK, 此處的 func 函式已經被實作, 所以可以出現合約表達式

    friend void func3(int);        //OK, 此處沒有出現合約表達式

}

void func3(int i) [[expects : i > 0]] {}        //OK, func3 為 Foo 類別的友誼函式

當一個虛擬函式繼承自基礎類別的函式, 它也同時會繼承基礎類別中對應的函式的合約表達式. 並且, 在衍生類別中, 這個 overrided 的虛擬函式的合約表達式是不可以被改變的 :

struct Base {

    virtual void f(int x) [[expects : x > 0]];

};

struct Derived {

    void f(int x) override;        //相當於 void f(int x) override [[expects : x > 0]];

};

對於具有高複雜度的 expects 合約表達式, 推薦使用 assert 合約表達式來替代

2. 合約等級

泛型合約表達式中的 contract-level 主要是指 : default, axiom, audit. 這其中, 只有 default 是關鍵字, 另外兩個都是標識符號

當一個合約表達式沒有標示出合約等級的時候, 該合約表達式的合約等級為預設等級, 即 default. 所以在第一部分中所有合約表達式都可以寫為 :

[[contract-keywords default : expression]]

對於預設的合約等級 default 來說, 其運作時所耗費的時間較小 :

[[expects : expression]]        //隱含 default 合約等級

[[expects default : expression]]        //明確 default 合約等級

而合約等級 axiom, 其並沒有運作期消耗. 因為它的作用只是用於精確註解或 IDE 的靜態分析 :

[[expects axiom : expression]]        //運作期不檢查

合約等級 audit, 它的運作期消耗是三者中最多的, paper 中稱為 expensive run-time costing :

[[expects audit : expression]]

不過, audit 甚至是一個合約表達式是否會被編碼器所檢查, 是一個編碼器實作定義行為. 個人認為, 一般來說, 當一個編碼器引進 Contract 時, 預設只有 default 合約等級的合約表達式才會被檢查. 對於 audit 合約等級的合約表達式來說, 很可能會被編碼器隱含地轉型為 default 合約等級的合約表達式進行檢查. 除非明確進行自訂, 這個時候編碼器才會不檢查合約表達式或者明確花費大量精力去檢查 audit 合約等級的合約表達式

paper 中, 規定了這些為編碼器實作定義行為 :

  • 預設是否檢查合約表達式
  • 合約表達式失敗之後的違例處理
  • 違例處理完成之後的行為

對於違例處理來說, 編碼器內部會有一個型別為 void (const std::contract_violation) [noexcept] 的函式, 其中 noexcept 為可選. 當發生違例, 編碼器會呼叫這樣型別的函式進行違例處理

而這個型別的函式, 是可以由用戶自訂的. 其中, std::contract_violation (paper 中為 void (const violation_info &)) 中, 具有如下成員 (根據 paper) :

  • int line_number;
  • const char *file_name;
  • const char *function_name;
  • const char *comment;        //字面值形式的合約表達式

根據 paper, 預設的違例處理和 std::abort 有關

對於合約等級來說, 另外一個值得注意的地方是 : axiomaudit 到底是標識符號還是變數名稱?

C++ 規定, 如果出現這樣的合約表達式, 那麼都將其當作標識符號處理 :

void func(int axiom) [[expects axiom : axiom < 0]];        //OK, axiom 等級的 expects 合約表達式, 期望函式參數 axiom < 0

int func() [[ensures audit : audit > 0]];        //OK, audit 等級的 ensures 合約表達式, 期望函式回傳值 audit > 0

當我們需要明確指出其為變數而並非標識符號的時候, 我們應該修改其名稱 (呃, 本來就應該是這樣, 編碼器能識別, 但是人不一定能嘛) :

void func(int x) [[expects axiom : x < 0]];

int func() [[ensures default audit : audit > 0]];

當一個函式具有不同等級的合約表達式的時候, 合約表達式的檢查相互之間不受影響

3. 評論

C++ 不斷引入新的特性, 這本來是一件好事. 但是我並不覺得 Contract 特性的引入是一件好事. 拿一個簡單的例子來說明 :

template <typename InputIterator>

void func(InputIterator, InputIterator);

對於這個函式來說, 我們希望引數是一個由疊代器表示的範圍, 也就是 [first, last) 並且 first <= last

first > last 會發生一些什麼呢?

在 Clang 下 :

#include <iostream>

using namespace std;

int main(int argc, char *argv[]) {

    string str {"f23gew"};

    string(str.cend(), str.cbegin());

}

上述程式碼產生的後果是 ABORT TRAP (6)

#include <iostream>

using namespace std;

int main(int argc, char *argv[]) {

    string str {"f23gew"};

    sort(str.end(), str.begin());

}

上述程式碼產生的後果是 SEGMENTATION FAULT (11)

而引入 Contract 之後, 這些程式碼的後果可能會和第一段程式碼產生的後果是相似的

所以 Contract 並不是一個很重要的特性. 看起來, Contract 只是將給人看的註解讓編碼器也能看懂, 增加了低層界面的約束而已. 顯然, Contract 中的一些特性明顯會增加編碼器作者的工作量

但是目前 C++ 2a 還是在討論中, 最後會變成怎麼樣, 還未知

另外, 我們來談談 axiom 合約表達式

從介紹中, 我們不難得出, 這只是一個被編碼器忽略的合約表達式, 也就是合約表達式形式的註解. 但是可以讓 IDE 看懂, 幫助提升靜態分析. 我則更加希望可以有編碼期檢查能力的 Contract (Constant Contract Expression), 這有助於降低程式運作時的消耗. 當然, 不排除編碼器會這麼進行優化

對於關鍵字的選擇, static 也許比 axiom 更容易讓人理解, 也不會產生分歧. 就像是 default, 它已經是一個關鍵字了, 關鍵字是不可以作為變數名稱的. 除此之外, 我認為 require 也可以替代 expects (如果 expects 最終不是關鍵字而是標識符號的話)

總的來說, 引入有一定道理, 不過總感覺可有可無. 不過最終的特性取決於 C++ 2a (C++ 20) 標準

4. 參考資料

《A Contract Design》

cppreference.com : C++ attribute: expects, ensures, assert (C++20)