跳过正文
  1. Welcome to My Blog/

C++26 Contracts (P2900):函数契约编程与编译期断言

JekYUlll
作者
JekYUlll
C++ / Go / Linux 开发者

C++ 的 assert 宏已经六十多岁了。它来自 C,功能极其有限:没有前置条件和后置条件的区分,没有标准化的违反处理,NDEBUG 一定义就彻底消失。P2900(Contracts for C++)改变了这一切。

2025 年 2 月,ISO C++ 委员会在哈根堡会议上正式将 P2900R14 纳入 C++26。这是 SG21 合同研究组前后八年的设计成果。提议者名单包括 Joshua Berne、Timur Doumler 和 Andrzej Krzemieński,几乎是 C++ 社群在契约设计领域的全部核心人物。

设计动机很直接。assert() 只能表达 “某个条件必须成立”,但无法分清楚这个条件是谁的责任。调用者传了非法参数,还是实现者返回了错误结果?assert(n > 0) 说不清楚。Contracts 用 prepost 把这个责任归属直接写进了函数签名里。

三剑客:pre、post、contract_assert
#

P2900 定义了三类契约断言:

1
2
3
4
5
6
7
int divide(int x, int y)
    pre (y != 0)                     // 前置条件:调用者负责
    post (r : r * y == x)            // 后置条件:实现者负责
{
    contract_assert(x >= 0);         // 断言语句:内部检查点
    return x / y;
}

pre (condition) 声明前置条件,在函数体执行前检查。违反时责任在调用者。post (condition)post(r: condition) 声明后置条件,正常返回后检查。r 是结果绑定,你可以在条件中引用函数的返回值。contract_assert (condition) 是函数体内的通用断言,等价于旧 assert() 但有标准化语义。

三者可以混合顺序。同一个函数可以有多个 prepost

1
2
3
4
5
template<typename T>
void push_back(std::vector<T>& v, const T& val)
    pre (v.size() < v.capacity())
    post (v.size() == __old_size + 1)
    post (v.back() == val);

这个声明一读就懂:调用前确保有空间,调用后大小加一,新元素在尾部。如果违反其中任何一条,框架会给出准确的信息,告诉你违反的是哪条断言、语义是什么。

需要注意,post 的参数引用需要显式 const。按值传递的参数如果在 post 中被引用,必须标记为 const。这在头文件签名的长期维护中是个好习惯。

四种评估语义
#

每个断言采用哪种语义由实现决定。标准划了四个等级:

语义是否检查违反后行为
ignore不检查无效果
observe检查调用 handler,执行继续
enforce检查调用 handler,然后合约终止
quick-enforce检查立即合约终止

ignore 把断言编译掉,效果类似 NDEBUG 但走标准路径。observe 适合测试:你得到通知,程序继续跑。enforcequick-enforce 是生产环境的看门人,区别在于 enforce 允许 handler 介入(记录日志后再终止),而 quick-enforce 裸终止,开销最小。

同一个断言在同一次执行中可能使用不同语义。标准设计上容忍了这个自由度。实现可以基于编译选项、函数属性、甚至是运行时的动态选择。

这种设计不是过度工程。想象一下:你在调试阶段用 observe 捕获所有违规,在 CI 中用 enforce 确保零容忍,在线上用 quick-enforce 把性能损失降到零。同一个断言,三个环境,三种语义。

契约违规处理
#

断言违反时调用 std::contracts::handle_contract_violation(const contract_violation&)

contract_violation 对象提供以下信息:

  • assertion_kind()prepost 还是 assert
  • evaluation_semantic():当前使用的评估语义
  • is_terminating():这个 violation 是否会导致终止
  • evaluation_exception():若谓词因异常退出,捕获异常指针
  • comment():实现定义的人可读描述

你可以替换默认 handler,前提是实现允许(标准把替换性设为 implementation-defined)。安装自定义 handler 的一个场景:在生产环境中记录所有 observe 语义下的违规,然后上报到监控系统,而不是默默吞掉。

P2900 还引入了 mixed mode 的概念:同一个翻译单元内的不同函数可以使用不同的评估语义。这意味着你可以在一个模块中开启严格检查,另一个模块中完全跳过,而不需要全局编译开关。这种粒度控制在实际工程中非常实用——核心模块的契约检查可以始终保持开启,边缘模块的性能敏感路径可以走 ignore

代码实战
#

写一个带契约的向量查找函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <vector>
#include <contracts>
#include <algorithm>

int find_index(const std::vector<int>& vec, int target)
    pre (!vec.empty())
    post (r : r == -1 || (r >= 0 && static_cast<std::size_t>(r) < vec.size()))
{
    auto it = std::find(vec.begin(), vec.end(), target);
    if (it == vec.end()) return -1;
    return static_cast<int>(std::distance(vec.begin(), it));
}

这个 post 保证了返回值要么是 -1,要么是合法索引。任何人看到签名就知道函数承诺什么,不需要读函数体。

安装自定义违规处理 handler:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <contracts>
#include <iostream>

void handle_contract_violation(const std::contracts::contract_violation& v) {
    std::cerr << "Contract violation: "
              << (v.assertion_kind() == std::contracts::assertion_kind::pre ? "pre" :
                  v.assertion_kind() == std::contracts::assertion_kind::post ? "post" : "assert")
              << " violated\n";
    if (v.evaluation_semantic() == std::contracts::evaluation_semantic::enforce) {
        std::abort();
    }
}

后置条件的结果绑定可以自定义名字。一个常见的困惑是:为什么不用 auto 让编译器推断?提案者给出了明确的理由:显式命名可以让后置条件在不同编译单元间保持一致,这在 LTO 和分离编译场景下至关重要。post (r : r > 0) 中的 r 就是你命名的左值,引用函数的返回值对象。这在多返回路径的函数中特别有用。

一个经典例子,阶乘函数:

1
2
3
4
5
6
7
int factorial(int n)
    pre (n >= 0)
    post (r : r > 0 || n == 0)
{
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

没有契约的时候,你只能写 “n must be non-negative” 在注释里。现在编译器知道这条约束,运行时可以验证,读者看一眼签名就明白。

与现有 C++ 特性的关系
#

Contracts 不是凭空出现的。它与 C++ 已有的语言设施形成互补:

  • 与 noexcept 的关系noexcept 说 “这个函数不会抛异常”,pre/post 说 “这个函数在满足条件时行为正确”。两者可以共存在同一个函数签名上。
  • 与概念(concepts)的关系:Concepts 约束类型参数,Contracts 约束运行时值。std::sort(Sortable auto& rng) 要求 rng 的元素类型支持比较;pre (!rng.empty()) 要求范围不空。一个是编译期类型约束,一个是运行时值约束。
  • 与异常的关系:Contracts 设计的初衷之一就是替代用异常做参数校验的惯用法。if (x < 0) throw std::invalid_argument(...)pre (x >= 0) 在语义上等价,但后者是声明式的,不涉及异常控制流。

生态现状
#

编译器C++26 Contracts 状态备注
Clang实现中实验性支持在 trunk,追踪 P2900
GCC未开始等待 C++26 正式发布
MSVC评估中表态会跟进
Apple Clang跟随上游等 Clang 稳定后合并
EDG eccp已支持实现了 P2900R14 全部内容

Feature-test 宏:__cpp_contracts == 202502L__cpp_lib_contracts 对应标准库部分。

Contracts 的推动力很强。除了 P2900 外,SG21 还在并行推进多个扩展提案:P3097(虚函数的契约)、P3098(后置条件的捕获语法)、P3099(用户自定义诊断消息)、P3290(将现有 assert 与 Contracts 集成)、P3400(断言属性控制)。C++29 中 Contracts 还会继续扩展。

今日可执行动作
#

  1. 关注 Clang 的 contracts 实现进度。在即将发布的 Clang 20/21 中尝试 -fcontracts 实验标志,试用 pre/post 语法看编译错误提示是否清晰。
  2. 把现有项目中的 assert() 换为 contract_assert()。配合 observe 语义收集生产环境中的违规数据。你可能会惊讶于有多少隐藏的 API 误用。
  3. pre 重构核心 API 的参数校验。与其在注释里写 “参数不能为空”,不如直接写成 pre (!vec.empty())。检查、文档、沟通一次完成。

参考
#

相关文章

C++26 时代的 AI 应答:当 C++ 开始为机器学习铺路

··6 分钟
每次 AI 编程工具出新版本,评论区就有人问:“C++ 是不是快被 AI 淘汰了?” 这问题每年出现一次,跟季节一样准时。逻辑链大概是这样:AI 写 Python 又快又准 → Python 是 AI 的主力语言 → C++ 语法复杂、特性臃肿 → LLM 生成的 C++ 代码质量堪忧 → C++ 没前途。 表面看有点道理。C++ 的语法确实复杂,模板元编程的坑 AI 不一定能避开,指针错误、生命周期问题,这些对 LLM 来说全是重灾区。 但另一面是:C++ 26 标准可能是十多年来对 AI 场景最友好的一次更新。 这篇文章不讲焦虑,只讲事实:C++26 带来了哪些直接服务于 AI/ML 开发的新技术,以及委员会的提案方向在怎么回应"AI 时代"这个命题。