Hacker News 中文摘要

RSS订阅

C++26 Contracts:前置条件与后置条件简介 -- C++26 Contracts: Introduction to Preconditions and Postconditions

在编写函数时,我们常需要确认输入满足某些条件。例如,一个简单的除法函数会用 assert(b != 0) 检查除数不为零,若断言失败程序会中止并提示。标准库中的 assert(来自 cassert 头文件)只在调试阶段生效,发布版本中通常会被移除。C++26 引入的契约(Contracts)特性延续了这一思路,用前置条件和后置条件描述这类检查。

正文

C++26 Contracts 主题配图

在之前关于标准库加固的文章里,我们看了针对无效标准库容器访问以及其他被破坏的前置条件的检查。我当时提到,这些要求是用“契约”(contracts)来表达的。但我们自己的代码呢?

接下来,我们将探讨 C++26 的契约,从简单的 cassert 出发,把要求移进函数内部,并看看如何检查结果。我们也会看声明和定义、编译器选项,以及一些可能让你意外的细节。

从断言开始

先看一个小例子。首先是头文件:

// foo.h
#include <vector>
int get_value(const std::vector<int>& values, std::size_t index);

然后是它的实现:

// foo.cpp
#include "foo.h" // 我们的函数声明……
#include <cassert>

int get_value(const std::vector<int>& values, std::size_t index) {
    assert(index < values.size());
    return values[index];
}

函数要求传入有效的索引。如果条件为假,assert 会打印诊断信息并终止程序。不过,这只在断言启用时发生。定义 NDEBUG 会禁用检查,基本上把检查从代码里去掉了。

这种简单做法有什么问题?要求写在实现内部。只读声明的人看到的是一个 vector 和一个索引,看不到它们之间的关系。

添加前置条件

有了 C++26 的契约,我们可以把这个要求从函数体内的断言移到函数本身上:

int get_value(const std::vector<int>& values, std::size_t index)
    pre(index < values.size())
{
    return values[index];
}

注意参数列表后面新的 pre(...) 语法。它引入了一个前置条件断言(precondition assertion):进入函数时预期成立的条件。

而且,我们可以把这个前置条件加在声明上:

int get_value(const std::vector<int>& values, std::size_t index)
    pre(index < values.size());

这个特性通过 P2900, Contracts for C++ 引入。

接着看它如何工作,以及和 assert 有什么不同。

为什么用契约而不是 assert?

一开始,pre(index < values.size()) 看起来只是换种方式写 assert(index < values.size())。两者都表达一个应当为真的条件。那用契约的好处是什么?

有几个重要区别:

  • 要求可以写在声明里:我们可以直接把 pre 和 post 放在函数声明中。这样,任何看 API 的人都能看到这些要求,不用去看实现。
  • 检查方式更灵活:传统的 assert 通过 NDEBUG 启用或禁用。契约定义了几种求值语义,包括忽略检查、报告违反并继续、或者终止程序。编译器实现决定哪些语义可用,以及如何选择它们。
  • 前置条件和后置条件:契约区分调用方必须提供什么,以及函数承诺返回什么。如果用 assert,我们得手动在函数体内放置检查,包括在不同 return 语句之前的检查。

这也让契约即使在运行期检查被禁用时,也能很好地用于记录 API。

一个完整的前置条件示例

下面是一个完整的例子(为了简单,暂时不用头文件):

#include <print>
#include <vector>

int get_value(const std::vector<int>& values, std::size_t index)
    pre(index < values.size())
{
    return values[index];
}

int main() {
    std::vector<int> values { 10, 20, 30 };

    std::println("{}", get_value(values, 1));
    // get_value(values, 10); // 违反前置条件
}

有效的调用会打印:

20

下面是把有问题的调用取消注释后供你试玩的版本:

见 Compiler Explorer

在 GCC 上,我得到:

Program returned: 134
Program stdout
20
Program stderr
contract violation in function int get_value(const std::vector<int>&, std::size_t) at /app/example.cpp:6: index < values.size()
[assertion_kind: pre, semantic: enforce, mode: predicate_false, terminating: yes]
terminate called without an active exception
Program terminated with signal SIGABRT (6)

声明和定义上的契约

为了清晰,契约可以出现在声明上、定义上,或者两者都有:

int get_value(const std::vector<int>& values, std::size_t index)
    pre(index < values.size());

定义里就可以省略契约说明符:

int get_value(const std::vector<int>& values, std::size_t index)
{
    return values[index];
}

契约断言由函数的第一个声明确立。后面的声明或定义可以像上面那样省略契约说明符,或者重复相同的序列。

后面的声明不能引入一组不同的前置条件或后置条件。

实践中,如果函数在头文件里声明,那第一个声明自然就是放置其前置条件和后置条件的地方。如果你在看 API,最好不用看实现内部就能看到所有前置条件。

另外,契约和函数关联,但它们不是函数类型的一部分。比如,重载决议不会根据契约来区分函数。

契约求值语义

C++26 定义了四种求值语义:

| 语义 | 运行时发生什么? | | --- | --- | | Ignore | 谓词不被求值。 | | Observe | 违反时调用处理函数;如果处理函数正常返回,则继续执行。 | | Enforce | 违反时调用处理函数;如果处理函数正常返回,则程序终止。 | | Quick-enforce | 违反时直接终止执行,不调用处理函数。 |

采用哪种语义由实现决定。C++26 没有可移植语法来强制某个普通的契约断言使用某一种特定策略。这些规则出现在契约求值规范中。

在 ignore 语义下,不做运行期检查。在 observe 下,违反会被报告,但如果处理函数正常返回,执行会继续。在我们的例子里,这两种策略都可能让程序带着无效索引走到 values[index],从而导致未定义行为。

因此,加上 pre(...) 表达了要求,而所选策略决定了检查如何响应。

用 GCC 编译

截至 2026 年 10 月,只有 GCC 16 支持契约。我们可以用特殊的编译标志来使用它:

g++ -std=c++26 -fcontracts \
    -fcontract-evaluation-semantic=enforce example.cpp

在这个策略下,无效调用会调用违反处理函数。如果处理函数正常返回,执行会在函数体执行无效访问之前终止。GCC 的默认处理函数会发出诊断信息。参见 GCC 选项文档 - contracts。

GCC 还允许我们替换默认的契约违反处理函数。例如,检测到违反时,我们可以记录自定义消息。

处理函数接收一个 std::contracts::contract_violation 对象,它提供关于失败契约的信息,包括谓词、位置和求值语义。

#include <contracts>
#include <print>

void handle_contract_violation(
    const std::contracts::contract_violation& violation)
{
    std::println(stderr, "Contract violated: {}",
                 violation.comment());
}

示例见 @Compiler Explorer

注意,替换处理函数并不会改变它返回后发生的事情。在 observe 下,执行继续。在 enforce 下,程序终止。

并非每个实现都必须支持替换处理函数,所以这个特性是由实现定义的。

添加后置条件

到目前为止,我们描述了调用方必须提供什么。我们也可以描述函数正常返回时承诺什么。

看这个例子:

int clamp_value(int value, const int low, const int high)
    pre(low <= high)
    post(result: result >= low && result <= high)
{
    if (value < low)
        return low;
    if (value > high)
        return high;
    return value;
}

这里有两个有用的部分:

  • pre(low <= high) 检查边界构成有效区间。
  • post(result: ...) 检查返回值落在该区间内。

名字 result 由我们自己选。它在该后置条件中指代函数的结果。

方便的是,同一个后置条件覆盖所有正常返回路径。我们不必在每个 return 前重复断言。

下面是我故意违反后置条件的例子:

#include <print>

int clamp_value(int value, const int low, const int high)
    pre(low <= high)
    post(result: result >= low && result <= high)
{
    if (value < low)
        return low-10; // <<
    if (value > high)
        return high;
    return value;
}

int main() {
    std::print("{}", clamp_value(10, 20, 30));
}

见 @Compiler Explorer

在 GCC 上我得到:

contract violation in function int clamp_value(int, int, int) at /app/example.cpp:5: result >= low && result <= high
[assertion_kind: post, semantic: enforce, mode: predicate_false, terminating: yes]
terminate called without an active exception
Program terminated with signal SIGABRT (6)

前置条件没问题,但后置条件被打破了。

后置条件中的 const 参数

参数列表里还有个细节:low 和 high 是 const。

如果你试图把它们改成非 const,会得到下面的错误:

<source>:5:28: error: a value parameter used in a postcondition must be const
    5 |     post(result: result >= low && result <= high)

当后置条件使用按值传递的参数时,该参数在所有声明中都必须具有 const 类型。我们的比较这样使用了两个边界。参数 value 只在函数体里使用,所以这个要求不适用于它。

后置条件适用于正常退出;当函数体通过异常退出时,它们不检查结果。参见函数契约规则。

函数内部的断言

对于实现内部的条件的,我们有 contract_assert:

#include <string_view>

std::size_t count_spaces(std::string_view text) {
    std::size_t count = 0;

    for (char ch : text) {
        if (ch == ' ')
            ++count;
    }

    contract_assert(count <= text.size());
    return count;
}

这个条件很简单,但它表达了一个有用的性质:空格数量不可能超过字符数量。

在更大的算法里,这种检查可以描述计数器之间的关系、中间结果,或者处理一块数据后的状态。

pre、post 和 contract_assert 一起覆盖了函数入口、正常出口,以及函数内部的点。

避免谓词中的修改

迁移时要注意一件事:谓词里的修改(mutation):

void process(int count)
    pre(++count > 0) // error
{
}

见 @Compiler Explorer

GCC 报的错误:

error: increment of read-only location '(const int)count'
    2 |     pre(++count > 0) // error

契约谓词使用特殊的 const 化规则。在这个例子中,count 在谓词里被当作 const,所以 ++count 被编译器拒绝。

但这并不代表谓词完全没有副作用。比如,谓词仍然可以调用修改全局状态的函数。由于契约求值可能被忽略,而且即使在检查语义下副作用也不保证发生,谓词不应该修改程序状态。

契约不是输入验证

还有一个实践上的区别:输入验证。

假设索引来自配置文件。如果无效值需要一条错误消息,这种处理属于普通的控制流:

if (index >= values.size()) {
    std::println("Invalid index: {}", index);
    return;
}

std::println("{}", get_value(values, index));

输入验证处理的是应用边界上的预期失败。前置条件记录的是内部辅助函数的要求。

P2900 明确讨论了这个观点:

原则 12:契约断言不是流程控制:虽然契约断言提供了一种验证正确性的算法,但契约断言并不保证与该语法构造相关的任何特定运行期行为。

还有:

重要的是,契约的这个方面正是为什么契约断言绝不能用于错误处理和输入验证:如果一个函数有契约内的要求,要把某些事件报告为错误,那种处理必须用不可选的标准 C++ 控制语句完成,绝不能用契约断言。

考虑谓词的开销

最后一点关于性能影响。

我们的索引比较很便宜。但检查整个集合是否已排序需要检查它的元素。昂贵的检查在测试时可能有用,但你应该测量它对代表性工作负载的影响。

总结

C++26 契约给了我们三种直接在语言中表达假设和保证的方式:

  • pre(...) 描述进入函数时预期成立的条件,
  • post(...) 描述函数正常返回时预期成立的条件,
  • contract_assert(...) 检查函数内部特定点的性质。

前置条件和后置条件通过函数的声明与函数关联。特别是,第一个声明确立了契约,这使得头文件中的声明成为记录 API 要求的自然位置。

契约断言并不一定意味着总会执行运行期检查。所选的求值语义可以忽略(ignore)、观察(observe)、强制执行(enforce)或快速强制执行(quick-enforce)该断言。因此,代码不应该依赖契约谓词来完成必需的应用逻辑或副作用。

契约也不能替代正常的输入验证。对于程序预期并需要处理的错误,要使用普通的控制流。

评论

社区对 C++ 标准引入 Contracts 整体以质疑和疲惫为主,争论主轴是它到底只是「更花哨的 assert / 守卫语句」,还是能在文档、优化、AI 辅助或形式化验证上带来不可替代的价值。多数意见认为它语义混乱、工程代价高且并无新意,少数人则看好约束式编程与智能体工作流,或追问优化器与求解器能如何利用它。

认为契约只是标准化了的 assert,语义混乱且并无新意

  • 在不少评论看来,契约并不能把运行时错误变成编译期错误,只是承诺「真的会断言」,效果不比 assert 更好;标准又规定它不得用于错误处理和输入验证,实际却在做类似参数校验的事,定义十分模糊(较多人)
  • 它也并非 Hoare 逻辑之类能用于形式化验证的东西,而只是把普通的守卫语句标准化;若写在头文件会为检查成员而被迫包含大量声明、拖慢编译,写在源文件又失去「头文件即文档」的唯一优势,等于用标准几十页换来一个更花哨的 assert(较多人) > 我们难道是给标准加了 50 页,只为换一个稍微花哨点的 assert?其实一个内置的 contract_assert 加脚注就够了:表达式为假时行为未定义,鼓励实现在调试版加运行时检查或在能静态判定到失败时直接报错(@nananana9)

反对运行时断言,主张优雅错误处理或仅用注释记录契约

  • 部分人反对任何形式的运行时断言,认为它太重,程序出错时应优雅处理而不是直接终止;如果前后置条件重要,直接在头文件写注释块就够了。还有人整体抱怨 C++ 这类庞大复杂的语言越来越不吸引人,手写代码时更渴望优美愉悦的语言,这类新机制只会继续劝退(少数人)

看好约束式编程,但认为当前语法太初级、需要分离式约束与工具链/AI 配合

  • 少数人对基于约束的编程非常乐观,但认为把条件直接附加在函数声明上太基础;理想方案是支持与声明分离的约束定义,单独放在命名空间或附加说明中,便于加注释、打标签,并让函数声明能通过智能提示跳转到约束,AI 智能体也能依据明确元数据给出高置信解释(少数人) > 我平时本来会讨厌这个,但有了智能体之后就觉得很棒(@hsaliak)

认为契约设计对形式化验证毫无帮助,或只是重走 D 等语言的老路

  • 有评论明确指出,如果目标是形式化验证,这套契约的设计完全跑偏;它顶多能用于运行时断言和文档,而这些没有 Contracts 也基本能做到(少数人)
  • 一位资深语言实现者提到,自己曾在 90 年代的 Zortech C++ 编译器以及 D 语言中实现过契约,但最终发现它并不很实用;随后有人用玩笑把 C++ 比作「自减的 D」,暗示 C++ 正在捡起 D 里已被证明不太有用的特性(少数人)

有价值的补充

  • 有人提问:文章完全没提优化场景,编译器能否基于「契约恒成立」的假设来优化,就像 if (!(条件)) { _builtinunreachable(); } 那种惯用法;也有人问求解器能否借此证明代码在平凡情形下正确,以及单元测试和契约应如何分工(少数人)
  • 有评论指出,这就是 Eiffel 语言中 Design by Contract(契约式设计)概念的翻版,可视为同类思想的早期来源(少数人)
  • 有人多年前就写过 GCC 编译器插件,给 C/C++ 提前加上了 Contracts,相关博客后来更像是一篇 GCC 插件写作入门(少数人)
  • 关于断言信息不足:如果比较失败,只输出类似「index < container.size()」的表达式并不够用,自定义断言应能打印参与比较的具体值,这对调试更有帮助(少数人)