干过几年C++服务端的人,大概都有过这种经历:半夜被电话叫醒,说线上某个服务进程崩了,上去一看日志,核心栈停在某个memcpy或者一个诡异的空指针解引用上。你花了半天时间,把可能出错的地方翻了个底朝天,最后发现是上游调用方传了一个不满足前置条件的参数过来。那一刻你大概率会骂一句:调用方怎么不按套路出牌。
这种问题的根子,在于C++这门语言默认"信任"程序员。函数声明里写了int count,可没说这个count必须大于0、必须小于缓冲区长度、必须是偶数。于是代码写得越久,隐形的约定就越多,散落在注释里、口口相传里、甚至只存在于某个老同事的脑子里。等到这些约定被打破,就是上面说的那种凌晨事故。
这就是我想聊的契约编程(Programming by Contract)想要解决的痛。这篇文章不是要给你讲什么新标准的新特性——虽然C++26的契约提案确实在路上——而是想结合我自己的实际经验,说说在C++这门语言里,怎么用现有的手段,把代码里那些"心照不宣的约定"变成"机器能强制检查的规则"。这篇文章适合正在写业务代码、被隐藏bug折磨过的C++开发者,也适合准备面试、想弄明白"断言到底该不该用"这类问题的朋友。
1. 契约编程的来龙去脉:为什么C++迟迟没有原生支持
1.1 契约的核心三要素
契约编程这个思想,最早可以追溯到1986年Bertrand Meyer设计的Eiffel语言。它提出了三个非常朴素但极其好用的概念:前置条件(precondition)、后置条件(postcondition)和类不变式(class invariant)。
用大白话解释就是:一个函数在干活之前,必须满足某些条件(前置条件);函数干完活之后,必须保证某些结果为真(后置条件);而对一个类来说,不管它的方法怎么被调用,这个类内部的某些状态必须一直成立(类不变式)。
举个例子,你写一个std::vector的at函数,前置条件是pos < size(),后置条件是返回的引用确实指向第pos个元素。如果调用方违反了前置条件,那是调用方的错;如果函数执行完后置条件不成立,那被调函数的实现就有问题。
这个思想最大的价值在于明确了责任边界。出了bug,先看是谁违反了哪一条,极大地减少了扯皮和排查的范围。很多团队常说的"防御式编程"其实是另一条路:被调函数谁都不信,进来先把所有参数检查一遍,不行就返回错误码。这种做法不是不对,但会让错误处理逻辑和业务逻辑混在一起,而且很多时候调用方根本不会检查返回值,等于白查。
1.2 从Eiffel到C++标准提案的漫长路
Eiffel在语言层面原生支持契约,所以在Eiffel里写契约是件很自然的事。但C++不一样,C++极其推崇"零开销抽象",而且它的历史包袱太重了,要往标准里塞一个新特性,需要经过漫长甚至残酷的讨论。
其实C++的契约提案(Contracts)已经折腾了将近十年。早年间,有一版被称为**"轻量级契约"的方案,核心思路是给函数声明加上pre和post这样的属性,由编译器在运行时插入检查。后来又一版变成了所谓的"基于属性的契约"**,语法上有了更成熟的方案。但是这个过程一波三折,提案越大,争议越多——比如契约检查遇到未定义行为怎么办、虚函数重写时契约怎么继承和强化、契约能不能用于重载决议这种编译期问题,以及性能敏感性用户希望契约可以完全关闭但又不影响语义,这些矛盾很难调和。
结果就是,我们期待的C++原生契约至今没有进标准。C++20发布的时候大家以为要来了,实际被砍了;C++23也没戏;目前看C++26能不能进,还得看后面几个月的讨论。不过好消息是,C++26引入了编译期契约的思想基础——在核心语言层面支持了static_assert的完善和consteval这类工具,但运行时契约还是没影。
所以,在标准的"官方铁锤"落下来之前,我们这些普通开发者只能自己动手。好消息是,用现有技术完全可以把契约编程的核心理念落地到工程里,而且落得还挺好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 标准未落地之前,工程上怎么写出"契约感"
2.1 assert不是玩具:合理使用断言体系
提到C++里的契约检查,大多数人第一反应就是assert。但我见过的很多项目里,assert的使用极其混乱。有的团队从头到尾没见过一个assert,所有检查全用if处理;有的团队反过来了,把所有能想到的检查全塞进assert,发布版一关,留下一个千疮百孔的程序。
我个人的经验是:assert是C++实现前置条件和后置条件最直接、最便宜的手段,前提是你得知道它在干什么。
assert是一个宏,在标准库的<cassert>里定义。它的行为分两种情况:
- 如果定义了
NDEBUG宏(通常在发布构建里会定义),assert(x)展开为空,什么都不做。 - 如果没有定义
NDEBUG,assert(x)在x为假时,向stderr输出诊断信息并调用abort()终止程序。
所以,assert天然适合做那些**"如果被违反就说明代码有bug"**的检查。比如一个解析器函数要求输入非空、一个排序函数要求迭代器范围合法、一个数值计算函数要求除数不为零、一个资源分配函数要求句柄有效。这类检查不应该是错误处理,它们是程序员之间的契约,违反了就是有人写了错代码,继续执行只会把错误扩散出去。
cpp复制// 用assert表达前置条件
double safe_divide(double a, double b) {
assert(b != 0.0 && "division by zero in safe_divide"
