在全球最大可悲人士集散中心 (aka WG21) 中, 有这么一帮人叫做 Study Group 21. 和其他 Study Group 一样, 他们致力于让 C++ 变得更复杂且难以理解.

wg21-structure
Figure 1. WG21 结构图

我们可以看到, SG21 和大家不断谈论的 Concurrency, Modules, Networking 和 Reflection 等重要特性有着平起平坐的地位 (也就是拥有自己的 Study Group), 但长久以来, 它似乎并未得到同等的关注. 也许是因为 SG21 的工作内容相对较为 "简单", 且其成果并不像其他特性那样直接可见地提升 C++ 的语言能力.

确实, Contracts 并非语言能力上的提升, 但是专门的 Study Group 的存在, 还是说明了它是一个 "领域", 在工程与语言上存在专门研究与应用的价值. 在 P0592 中, Contracts 也被作为当下 C++ 语言标准正在努力的一个方向:

In a nutshell, the plan is thus: for C++26, let's work towards having the following things in that standard:

* Execution
* More Ranges work
* Reflection

Without a particular ship vehicle yet, we should also make progress on

* *Contracts*
* Pattern matching

而估计是为了备战 11 月的 Wroclaw 会议, SG21 又递交了一份 P3343 的PPT, 里面详细介绍了 Contracts 的现状, 研究和未来计划. 本篇文章借此机会, 以此为基础, 对基于 P2900 的 Contracts 进行简要介绍.

定义

我们回答一些问题:

我们想要在语言中实现的 Contracts 是什么?

是 Contract Checking Facility, 即 Contract 验证设施.

如何理解 Contract 验证设施是什么?

Contract 验证设施是一种用于在程序中检查 Contract 是否被违反的机制.

那么 Contract 是什么?

那么你听我讲.

Contracts 的核心是保证程序员写出 "正确" 的程序. 而一个 "正确" 的程序, 用 Contracts 领域的定义来说, 应该:

  1. 在库的实现者与使用者, 程序员与目标平台, 用户与正在运行的程序等多方交互中, 各方面人员拥有, 且遵守一定的约定与协议.

  2. (1) 中的协议约定了程序何种行为是 "正确" 的, 何种行为又是不 "正确" 的.

  3. 程序在任何时候, 任何输入的情况下, 都不违反 (1) 中的协议与约定.

  4. 程序在任何时候, 任何输入的情况下, 都不出现目标平台上未进行定义的行为.

  5. 代码必须是 well-formed 的. (可以理解为语法不出错)

数学好的朋友就要说了, "任何输入"是个伪概念, 是不现实的.

你说得对. 因此, 我们发明了 "正确程序评估" 的概念, 以粗略地判断一个程序的正确性:

  1. (以特定输入) 运行并验证程序, 检查其是否违反了上文 (1) 中的协议与约定.

  2. 检验该程序是否会出现目标平台上的未定义行为.

这样说的话, 我们也就定义出了什么是 "不正确" 的程序:

  1. 代码是 ill-formed 的, 或在 well-formed 的情况下符合 (2) 的定义.

  2. 程序在部分输入下, 违反了上文 (1) 中的 "协议与约定".

Contracts 就是其中的 "约定与协议". 在实践中我们会发现, 这种 Contracts 并不全是在代码中 (static_assert()throw 中) 表现出来的, 它可以是文档, 注释, 甚至是口头约定, 是一切在用户群体 (包括实现者, 使用者与用户) 中达成共识的语言表述. 总之, 它以各种形式出现, 显式或隐式地规定了一个程序的 "正确" 行为.

这就决定了 Contract 的验证过程也是各种形式的. 它可以是一行 static_assert(x > 0), 也可以是用户一个电话 call 到你工位, 问你程序怎么跑不起来, 是不是他配置出问题了. 但不论如何, Contract 的验证方式, 也应该是约定的一部分.

我们想在语言中落实的 Contracts, 也就是Contract 验证设施, 即帮助我们约定和执行 Contract 的验证方式的工具——的一部分.

Contract 验证设施应该满足如下的定义:

  1. 可以作为描述 Contract 的验证方式的工具.

  2. 可以让 (1) 中的描述施加实际效力. 例如如下形式:

    文档

    要求文档读者遵守一定的约定规范.

    运行时检查

    根据 Contract 的约定, 在运行时检查程序的行为和输出是否正确.

    运行时异常处理

    尽量消除不正确程序的负面作用.

    静态分析

    依据 (1) 中约定的正确性检验方式, 在编译时分析并辨别程序的正确性.

    优化

    对程序进行性能上的优化. 优化的进行必须满足 "程序满足 (1) 中的正确性约定" 这一基于 Contracts 的假设.

  3. 不能拥有如下的行为:

    1. 使程序出现 Contract 约定的范围之外的行为.

    2. 对程序的 "正确行为" 的定义作出改动.

    3. 构成程序正确执行时控制流的一部分.

    4. 成为实现 Aspect-oriented Programming 的工具.

原则

而在设计标准 Contract 验证设施的过程中, SG21 也试图提出过各种指导原则:

你都不用看, 我也没看完. 总之这帮人杰总结出来了这个 "最高指导原则":

Contract 验证设施的使用, 不改变程序原本的正确性.

还有几条来自他们的亲情 tips:

  • 如果你要写的东西违反了 "最高指导原则", 那它就不是 Contract 验证设施, 而是程序逻辑的一部分.

  • 我们将尽力保证 "最高指导原则" 能在编译时执行.

  • 我们将确保 "不小心" 违反 "最高指导原则" 的情况非常难出现.

"执行" 一节中我们将探讨这三条 tip 的具体体现.

在此, 我们先用几个可能出现的情况说明 "最高指导原则" 为什么是 "最高指导原则". 记住, 原文是:

Contract 验证设施的使用, 不改变程序原本的正确性.
如果 Contract 验证设施违反了 "最高指导原则"…​
  • 它无法保证不执行 Contract 验证时程序的正确性.

  • Bug 可能在你着手调试程序, 复现 bug 时消失又出现. 即传说中的 "Heisenbug".

  • 若程序中拥有 n 个 Contract 验证设施, 且它们都会改变程序的正确性, 则第 m 处的 Contract 验证结果将取决于前 2^(m-1) 个 Contract 的验证结果, 你的程序在 Contract 验证过程中实际上就拥有了 2^m 种 "变体" (也可以视作 state, 状态).

如果 Contract 验证设施遵守 "最高指导原则"…​
  • 可以随意忽略 Contract 受到验证与否. 一旦确认某部分程序的正确性, 我们将可以忽略 Contract 验证, 消除验证过程带来的额外开销.

  • 静态分析程序不用处理 2^n 个程序变体, 只需处理 1 个程序状态.

  • 避免 "Heisenbug".

  • 让 SG21 非常高兴, 以至于 PPT 上出现了第四点的标识, 却没有给具体内容.

当然, 我们已经有了一些 Contract 验证设施, 比如注释. 注释的好处是在语言标准的范围内毫无违反 "最高指导原则" 的能力, 但它同时也没有达成其他任何目标的能力. 一些工具可能会以 (特定结构的) 注释的形式获取额外的静态分析信息, 但总体来说注释并不是一个很好/全面的 Contract 验证设施.

另一个例子是 <cassert>, 它允许我们在运行时验证 Contract. 但它太过自由, 一不小心就能违反 "最高指导原则". (什么? 你甚至故意让它违反 "最高指导原则"?)

SG21 的大聪明们就设计了 "契约断言" (contract assertion) 这一设施. 它也就是你听说过的 pre, postcontract_assert, 在设计上保证了尽量落实 "最高指导原则". SG21 把这凝结为另一个原则:

契约断言在程序中的存在或执行, 不应改变程序原本的正确性.

他们还非常友好地说明了这一子原则十分深层, 难以理解的内涵. 我给大家翻译一下就是这两个意思:

  • 如果契约断言仅仅是存在就能影响程序的正确性, 那程序的正确性就被影响了. 这违反了 "最高指导原则".

  • 我们没法杜绝契约断言影响程序正确性的可能, 所以我们会尽力防止误用.

非常复杂, 难以理解, 以至于 SG21 有必要在 "最高指导原则" 之外再拿三页 PPT 讲这个 "子原则".

执行

SG21 是傻逼, 但他们不完全是.

"最高指导原则"之外,为了保证

  • 如果你要写的东西违反了 "最高指导原则", 那它就不是 Contract 验证设施, 而是程序逻辑的一部分.

  • 我们将尽力保证 "最高指导原则" 能在编译时执行.

  • 我们将确保 "不小心" 违反 "最高指导原则" 的情况非常难出现.

的落实, 他们确实提出了一些有价值的论断:

在编译时防止违反 "最高指导原则"
Concept 不应观测 Contract

这一原则最根本的影响是使 Contracts 不归入 "immediate context", 也就是无法被 SFINAE 探测.
同时, 它还影响了编译时验证 Contract 的具体行为的设计, 也 (在一处原则上) 允许了在 Contracts 中进行隐式 lambda 捕获.

在运行时防止违反 "最高指导原则"
定义 "毁灭性断言"

在验证时会改变程序原有的正确性的契约断言, 被称作 "毁灭性断言".

鉴别 "毁灭性断言"

我们没有任何系统化鉴别 "毁灭性断言" 的手段.
你不信? 走着瞧.

毁灭性断言

它毁灭性吗? (i)
void f() pre(true);

一个永远会满足的契约断言 (contract assertion) 怎么会违反任何 Contract? 考虑如下 Contract:

  1. 该程序不会使用 C++ 标准语言特性中的 Contracts.

  2. 该程序中使用的所有标识符, 都不会在 C 中以宏的形式存在.

pre(true) 至少违反了这两条 Contract, 并因此拥有破坏程序原有正确性的能力. 除此之外它还挺不错的. 它完全能在编译时完成验证呢.

它毁灭性吗? (ii)
int *binary_search(int* begin, int* end, int v)
  pre(std::is_sorted(begin, end));

这个好懂. 验证该契约断言需要遍历整个数组, 这显然是线性时间复杂度的. 如果该程序的 Contract 包含 "binary_search() 函数拥有指数时间复杂度" 这条保证, 那么这断言就是个毁灭性断言.

它毁灭性吗? (iii)
bool test(int x)
{
  x = x & 1;
  return x > 0;
}
void f(int x)
  pre(test(x));

这个契约断言很大可能上并不毁灭性. 然而, 这条契约断言除了 "验证某个条件是否成立" - 也就是单纯求 "判断契约断言成立与否" 的那个布尔表达式的值 - 之外, 在事实上还造成了其他的效应: 它修改了 x 的值. 这里指的并非 f() 的形参 x, 而是 test() 的那个形参 x. 所幸的是, x 的生命周期始于断言对 test() 的调用, 终于 test() 的返回. 因此, 该断言并产生的额外效应不对外界产生任何的影响.

为后文讨论方便, 这种副作用我们称为 "求值中" 作用.

它毁灭性吗? (iv)
template<typename T, typename U>
void f(const std::map<T,int>& m, const U& k)
  pre(m.contains(k));

大概率也是不毁灭性的. 但如果说 (iii) 的契约断言拥有求值中的副作用, 那么这条断言就可能有 "求值外" 的副作用:

  • T 特化为 std::string, U 特化为 const char* 的情况下, 该断言会构造一个 std::string 临时对象, 才能够进行 m.contains(k) 的计算.

  • 这包含了内存空间的申请与释放.

  • 我们无法保证该过程的内存空间申请前与释放后, 整个程序的状态相等. 毕竟 std::string 本身不对自身使用的 allocator 的行为作出任何保障. (也就是没有这方面的 Contract 存在)

  • 我们 (用户) 必须想办法保证申请与释放所造成的程序状态的改变得以恢复, 以尽力确保程序的正确性不被破坏.

它毁灭性吗? (v)
template<typename T>
void f(std::map<T,int>& m, const T& k)
  pre(m[k] == 0);

如果 k 不在 m 中, 那么 m[k] 处会插入一个新元素, 其值为 0. 程序的状态被改变了, 并可能影响程序的正确性.

它毁灭性吗? (vi)
bool test() // 原文大括号没换行的都被我换行了, 嘻嘻
{
  printf("Test was called");
  return true;
}
void f()
  pre(test());

如果程序对标准输出流的输出内容也在 Contract 约定中, 那么这个契约断言的求值过程就打破了程序的正确性. 但如果大家本来就约定, 标准输出流的内容将用作日志和调试用, 那么该断言就并不毁灭任何约定.

它毁灭性吗? (vii)
int testCalls = 0;
bool test()
{
  ++testCalls;
  return true;
}
void f()
  pre(test());

如果程序的正确逻辑依赖于 testCalls 的值, 那么这个契约断言的求值过程就打破了程序的正确性.

它毁灭性吗? (viii)
struct List { int d_data; List * d_next; };
void f(List *lp)
{
  //#ifndef NDEBUG
  int index = 0;
  //#endif
  while (lp)
  {
    contract_assert(++index < 5);
    lp = lp->d_next;
  }
}

这一断言总是毁灭性的, 它毁灭在对 Contract 验证设施的滥用.

  1. 让我们不要忘记, 如果 Contract 改变了程序的逻辑, 甚至参与了程序本身逻辑的构成, 那它就不应该是 Contract, 更不应该写成契约断言的形式. 如果要以 Contract 形式限制 lp 列表的长度, 我们应该在 pre 中使用专门的代码片段进行检查, 或者写在文档里, 这样才不会造成任何 "求值外" 的副作用. 将 contract_assert() 写在这里, 不仅造成了求值外作用, 还对程序的逻辑造成了影响.

  2. 而验证契约断言与否不应该改变程序任何的行为, 不论原行为正确与否. 契约断言, 或者说整个 Contracts 机制的目的, 并非 "使程序正确", 而是 "逼迫程序员写出正确的程序".

  3. 现在 index 是专为 Contracts 存在的变量, 但没有任何机制保障 index 不被滥用, 并因此影响到程序正确性.

省流时间:

  • 任何断言的毁灭性都取决于上下文.

  • 修改本地对象的断言, 大概率是毁灭性的.

  • 限制副作用于求值中的断言具有毁灭性的概率是很低的.

  • 产生了求值外作用的断言也不一定具有毁灭性.

因此, 我们提出以下几条有助于在运行时遵守 "最高指导原则" 的策略:

  1. 不鼓励任何东西依赖于契约断言的求值.

  2. 尽量避免断言过程中对已存在对象的修改.

  3. 使用 const, 并相信它能确保程序状态不被修改.

设计决策

每个 C++ 提案最沉重的时刻. "设计决策" 意味着总有些需求不能同时满足, 而作者们根据今天的天气, 早饭与心情, 挥下了自己的屠刀.

以下是当前的 Contracts 提案和实现中, 遇到的一些困难和当前的解决方案. 没有读过 当前的 Contracts 提案 并了解过其实现的朋友理解起来也许会花些时间.

断言省略

  • 省略非毁灭性的断言总是安全的.

  • 忽略 (ignore) 契约断言应当与省略契约断言对程序的状态具有相同的效应.
    * 程序员可以选择向编译器手动证明某个断言是正确的, 该断言此时具有 "忽略" 语义.

  • 目标平台应已经能够对正确的契约断言进行省略.

重复求值

  • 对非毁灭性的断言进行重复求值通常是安全的. (SG21 认为存在部分极端情况, 但它们有点太极端了)

  • 重复求值的机制允许编译器实现和用户自由决定在何处生成验证 Contract 的代码. (比如, 一个函数的 pre 断言应该在调用该函数时就地求值, 还是在该函数的执行开始处求值?)

  • 重复求值可能可以找出契约断言毁灭性的副作用.

  • P3336R0 — only issues were pedantic testing 的宝贵实践经验.

对象 const

断言求值过程中, 将自动为使用的外部对象加上 const 修饰. 它被认为:

throw 为基础的异常处理

  • throw 为基础的异常处理机制, 将是直接终止程序之外, Contracts 主要的异常处理方式.

  • 大部分 C++ 用户并不能接受直接终止程序的异常机制. (见 P2968R2 - Make std::ignore a first-class object)

observe 语义

断言一旦遭到违反, 在 observe 语义下, 程序将调用指定的函数. 若该函数正常返回, 那么正常执行此断言后的内容.

  • 著名的 Hryum’s law 认为, 不论一个机制设计得如何严格且优秀, 用户总是会用意想不到的方式依赖于不应该依赖的东西.
    根据这一定律, 决定让违反断言必然导致程序崩溃, 也许会演化出不可预料的后果.

  • Contract 本身也会不断进化. 这个过程中我们可以使用 observe 语义进行调试

编译时行为

  • 我们需要 ignore 语义. 对过于复杂的契约断言进行编译时求值可能导致编译器错误, 让整个程序根本无法编译. 此时我们需要在编译时允许 ignore 语义.

  • 我们也需要 observe 语义. 不论是编译时使用的库, 还是运行时使用的库, 都会面临修改和更新, 并可能要求我们对 Contract 进行修改. observe 语义使得项目在升级期间仍然可以正常编译和运行, 并调试契约断言中的具体内容.

未定义行为

  • 一旦契约断言的语义设计发生了改动, 想要重新确定这些断言在语义中具体的行为, 会是一件很难顶的事. (我也不知道这句话在这到底什么意思, 毕竟我没听他们报告啊)

  • 我们应当阻止契约断言求值过程中的未定义行为传播, 并引起上下文内其他的未定义行为.
    P1494 为我们提供了相应的机制, P3328 将这一机制落实到了当前的 Contracts 提案中.

实现定义行为

有些人觉得 Contracts 过于依赖实现定义行为了, 而 SG21 说不.

他们提出, Contracts 目前只有 5 个实现定义行为:

  1. 语义的选择.

  2. 在标准规定 "终止程序" 的情况下, 程序到底以何种方式终止.

  3. 重复估值到底重复几次

  4. 契约断言不成立时, 如果当前语义允许调用错误处理函数, 那么该函数是否可以在上下文中被替换.

  5. 何时选择省略契约断言.

做这个 PPT 的时候还没有 P3321, 但现在有了. 这是篇详细论述 Contracts 的实现定义行为的论文, 有兴趣者自行参阅.

结语

Joshua Berne 这个啥b最后还抖了个星际迷航的机灵. 对, 那个 "最高指导原则" 是 neta 星际迷航的. 我也是 ST 迷, 但翻到这我有点累了, 大家想看的自己去看吧.

Contracts 整个系统还是有很多很多很多很多很多屁大的东西可以讲的, 但我自己也没读完. 对 Contracts 机制在 C++ 之外的语言的应用感兴趣的, 可以了解 D 语言和我忘了的某个小破老语言 (如果其实不小也不破, 那我先给大家磕头了).

累了. 到这就差不多了, 大家生活要是愉快, 别让我知道, 要是不愉快, 请马上告诉我.