← 文章 / 编程开发
Hacker News 18小时前 · 2026-10-16 03:52:51 · 8 阅读

C++26 契约:前置条件与后置条件入门

目录

在关于标准库加固的文章中,我们探讨了针对无效的标准库容器访问及其他违反前置条件的检查。文中提到这些要求是以“契约”的形式表达的。那么,我们的代码又该怎么办呢?

在这篇文章中,我们将深入探索 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,检查功能就会失效,基本上相当于直接从代码中移除了这项检查。

这种简单方法存在什么问题?约束逻辑隐藏在实现内部。仅阅读声明的人会看到一个向量和一个索引,却无法得知二者之间的约束关系。

添加前置条件  

借助 C++26 契约,我们可以将原本在函数体内的断言需求,提升为函数本身的属性:

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

注意参数列表后新增的 pre(...) 语法。它引入了一个前置条件断言:即进入函数时必须满足的条件。

更重要的是,我们还可以将这个前置条件添加到函数声明中:

int get_value(const std::vector<int>& values, std::size_t index)
    pre(index < values.size());
该特性通过 P2900, Contracts for C++ 引入。 下面我们来了解它的工作原理,以及它与 assert 的区别。

为什么用 Contracts 而不是 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 支持 Contracts。我们可以通过特殊编译选项来使用它:

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 分别覆盖了函数入口、正常出口以及函数内部。

避免在谓词中产生副作用

在迁移过程中,需特别注意谓词中的副作用:

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

参见 @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 要求变得自然而然。

契约断言并不保证运行时一定会执行检查。所选的求值语义可能忽略、观测、强制执行或快速强制执行该断言。因此,代码不应依赖契约谓词来实现必要的应用逻辑或产生副作用。

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

参考资料

原始来源: Hacker News

评论 (0)