写代码时脑子里总有一堆「我认为一定成立」的假设:int是 4 字节、模板参数一定是指针、这个分支永远走不到。这些假设平时只活在你的记忆里,等哪天有人在 32 位平台上编译、或者把std::string塞进只接受算类型的模板,它们才会以「线上崩了」的形式暴露出来。断言(assertion)要做的就是把这类假设从脑子里搬到代码里:能在编译期验证的交给static_assert,只能运行时验证的交给assert,两者都救不了的场景才轮到异常和错误码。挑选的依据只有一个,条件是编译期常量还是运行期值。
一个「不可能走到」的分支
先看一段很常见的代码:
// 片段(无 main,不参与编译校验)intclassify(std::size_t n){if(n==0)return0;if(n>0)return1;return-1;// 作者心里想:size_t 不可能小于 0,这里永远走不到}size_t是无符号的,n < 0确实不可能。但这个「不可能」只写在作者脑子里,编译器不知道,测试覆盖率也测不到。半年后有人把它改成int n参数,那个「永远走不到」的return -1立刻就成了真实行为。
把假设写进代码有两条路,检查时机完全不同:
源代码里的「假设」分两类,检查时机完全不同: 编译期 运行期 ──────────────────────────── ────────────────────────────────── static_assert(条件, "消息") assert(条件) ├ 条件成立 → 编译继续, ├ 条件成立 → 继续往下跑 │ 运行时零开销 └ 条件不成立 → 打印 "Assertion ... failed" └ 条件不成立 → 直接编译报错 并调用 abort() (后文贴真实报错) 前提:条件必须是常量表达式 前提:编译时没有定义 NDEBUG └ 定义了 NDEBUG(Release 构建的常见配置) → assert 在预处理阶段被整体抹掉, 连里面的表达式都不会被求值static_assert:编译期断言的三种典型用法
static_assert是声明,可以写在命名空间作用域、类里、函数里,条件必须是常量表达式。C++11 起就有,C++17 起第二个「消息」参数可以省略。
C++ Core Guidelines 的 P.5 就是为它写的:「优先编译期检查而非运行期检查」。理由很实在:编译期报错不需要你写任何错误处理代码,也没有任何运行时开销。
官方文档:static_assert 声明、Core Guidelines P.5:Prefer compile-time checking to run-time checking
最常用的三类:钉平台尺寸、钉常量关系、钉类型性质。
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<cstdint>#include<iostream>#include<type_traits>constexprintkMaxItems=100;// 业务上限,顺带当编译期常量用static_assert(sizeof(int)==4,"未预期的 int 宽度,本代码假定 4 字节");static_assert(sizeof(std::int32_t)==4,"int32_t 必须是 4 字节");static_assert(kMaxItems%4==0,"kMaxItems 必须是 4 的倍数,才能按 4 对齐分块");static_assert(std::is_unsigned<std::uint32_t>::value,"uint32_t 必须无符号");intmain(){std::cout<<"sizeof(int) = "<<sizeof(int)<<'\n'<<"sizeof(std::int32_t) = "<<sizeof(std::int32_t)<<'\n'<<"kMaxItems = "<<kMaxItems<<'\n';}sizeof(int) = 4 sizeof(std::int32_t) = 4 kMaxItems = 100条件不成立时长什么样?故意把宽度写错,GCC 13.2.0 的原话是这样的:
// 片段(无 main,不参与编译校验):这行刻意写错,下面贴它的真实报错static_assert(sizeof(int)==8,"int 必须是 8 字节");prog.cc:2:27: error: static assertion failed: int 必须是 8 字节 2 | static_assert(sizeof(int) == 8, "int 必须是 8 字节"); | ~~~~~~~~~~~~^~~~ prog.cc:2:27: note: the comparison reduces to '(4 == 8)'注意最后那行note: the comparison reduces to '(4 == 8)',编译器连为什么不成立都算给你看了。这比运行期挂掉后再倒查强太多:报错时刻离错误引入的时刻最近。
C++17 起可以省略消息,只剩条件:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>static_assert(sizeof(longlong)>=8);intmain(){std::cout<<"静态断言通过,sizeof(long long) = "<<sizeof(longlong)<<'\n';}静态断言通过,sizeof(long long) = 8省掉消息更简洁,代价是报错信息里没有你自己的解释。经验做法:让条件本身足够自解释(比如static_assert(sizeof(T) >= 4)就够清楚),否则还是把为什么写进消息里。
用 static_assert 约束模板实参(Concepts 之前的常用手段)
模板里static_assert会在实例化时刻触发,这是 C++20 Concepts 普及之前最常用的实参约束手法。它拦不住重载决议,但能让错误信息从「几百行模板崩溃记录」变成一句人话:
// 片段(无 main,不参与编译校验)template<typenameT>classRing{static_assert(sizeof(T)>=4,"Ring 的元素类型不能小于 4 字节");};Ring<char>bad;// T = char 只有 1 字节prog.cc: In instantiation of 'class Ring<char>': prog.cc:6:12: required from here prog.cc:3:29: error: static assertion failed: Ring 的元素类型不能小于 4 字节 3 | static_assert(sizeof(T) >= 4, "Ring 的元素类型不能小于 4 字节"); | ~~~~~~~~~~^~~~ prog.cc:3:29: note: the comparison reduces to '(1 >= 4)'报错第一行就是「在实例化Ring<char>时」,比 Concepts 缺失时的模板错误可读得多。C++20 之后这里更该用requires/concept,它能在重载决议阶段就排除候选,而不是等实例化才炸。但static_assert仍有用武之地:检查那些无法写进 concept 的常量关系(比如「缓冲区字节数必须是 64 的倍数」)。
assert:运行期断言
assert来自<cassert>,是个宏,参数是一个运行时表达式:为真则什么都不做;为假则打印一条包含文件名、行号、函数名和原始表达式的消息,然后调用abort()。
官方文档:assert 宏
assert失败的现场长什么样?把下面这个片段补上int main() { checkPort(70000); }真跑一次(GCC 13.2.0 / glibc),stderr 的原话是:
#include<cassert>voidcheckPort(intport){assert(port>=1&&port<=65535&&"port out of range");}prog.exe: prog.cc:3: void checkPort(int): Assertion `port >= 1 && port <= 65535 && "port out of range"' failed.这一行里信息很全:程序名、源文件与行号prog.cc:3、出错函数签名void checkPort(int)、以及被断言的原始表达式文本。
注意条件 && "文字"这个老技巧:字符串字面量恒为真、不影响判断,但它会随表达式一起被打印出来,相当于一条始终存在的说明(assert是宏,没有消息参数,这是唯一能带上文字的办法)。
官方文档:
NDEBUG与 assert 的交互规则——标准原文写的是「表达式不被求值」,而不是「求值后忽略结果」,这个区别就是下一节的坑。
NDEBUG:断言会整体消失,所以里面绝不能放副作用
这是全文最需要记住的一段。标准规定:如果在包含<cassert>的那一刻已经定义了宏NDEBUG,那么assert就被定义成空操作。注意这两个字的准确含义:整个表达式在预处理阶段被丢弃,一次都不会求值。
Release 构建通常由构建系统传入-DNDEBUG(CMake 的Release/RelWithDebInfo就是这么做的),所以下面这段代码在 Debug 下「看起来没问题」,一到 Release 就出错:
// demo.cpp — 反例,不要这么写:断言里有副作用(违反「断言只用于检查,不做实事」的原则)#defineNDEBUG// 模拟 Release 构建:真实项目里由编译器的 -DNDEBUG 传入#include<cassert>#include<iostream>intg_calls=0;intfetch(){++g_calls;return0;}intmain(){assert(fetch()==0);// 断言被抹掉 => fetch() 从来没被调用过std::cout<<"fetch() 被调用次数 = "<<g_calls<<'\n';}fetch() 被调用次数 = 0把同一段代码的#define NDEBUG删掉,输出立刻变了:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<cassert>#include<iostream>intg_calls=0;intfetch(){++g_calls;return0;}intmain(){assert(fetch()==0);std::cout<<"fetch() 被调用次数 = "<<g_calls<<'\n';}fetch() 被调用次数 = 1同一个assert(fetch() == 0),Debug 下fetch()被调用 1 次,Release 下 0 次。行为随构建类型改变,这是最阴的一类 bug。Debug 版和 Release 版跑出两种结果,复现起来足够让人怀疑人生。所以断言的铁律是:
| 允许写 | 禁止写 |
|---|---|
纯读取、无副作用的表达式:比较、算术、size() | 递增/递减、赋值、++i |
调用无副作用的const查询函数 | 调用会改状态、写文件、发请求的函数 |
assert(v.size() < cap) | assert(push(v) < cap)、assert(++n < 10) |
官方文档:GCC / libstdc++ 手册:Macros——讲了
_GLIBCXX_ASSERTIONS这类构建期开关,和NDEBUG是同一类思路。
顺带说:assert消失不代表「Release 就没有检查了」。标准库容器在 GCC 下可以开-D_GLIBCXX_ASSERTIONS给vector::operator[]等加上轻量边界检查,这是独立于NDEBUG的开关,专门用来做发布版的廉价兜底。
该断什么、不该断什么:最常见的误用是「拿断言做输入校验」
断言的定位是「内部逻辑不变量」和「绝不该发生的情况」。它有两条隐含前提:一是失败意味着程序本身写错了,二是失败时只能整体崩掉,没有优雅退路。
所以下面两种写法必须分开:
| 场景 | 正确工具 | 为什么 |
|---|---|---|
| 用户输入、配置文件、网络数据非法 | 异常 / 错误码 /std::optional | 非法输入是正常业务,不是程序 bug;Release 下还必须继续处理 |
| 内部不变量:类的状态、循环不变量 | assert | 一旦不成立就是自己写错了,越早崩溃定位越容易 |
| 类型 / 常量关系 / 平台尺寸 | static_assert | 编译期就能判,零运行时代价 |
| 可能发生但无法本地处理的失败 | 异常(按值抛、按引用捕) | 必须被调用方处理,不能被静默忽略 |
把用户输入塞进assert是经典误用:它在 Debug 下看着起作用,Release 下却连检查都不剩,程序带着非法数据一路跑到内存越界。看下面这个完整示例,两种检查各就各位:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<cassert>#include<iostream>#include<stdexcept>#include<string>#include<vector>// 内部逻辑不变量 -> 用 assert(绝不该发生,发生了就是自己写错)classRingBuffer{public:explicitRingBuffer(std::size_t cap):cap_(cap){assert(cap_>0);// 调用方是内部代码,容量为 0 属于逻辑错误buf_.reserve(cap_);}voidpush(intv){assert(buf_.size()<=cap_);// 不变量:任何时刻都不超过容量if(buf_.size()==cap_){buf_.erase(buf_.begin());}buf_.push_back(v);}std::size_tsize()constnoexcept{returnbuf_.size();}intfront()const{assert(!buf_.empty());returnbuf_.front();}private:std::size_t cap_;std::vector<int>buf_;};// 外部输入非法是常态 -> 用异常(错误处理),绝不用 assertintparsePort(conststd::string&s){intport=0;try{port=std::stoi(s);}catch(conststd::exception&){throwstd::invalid_argument("端口不是数字: "+s);}if(port<1||port>65535){throwstd::out_of_range("端口超出 1..65535: "+s);}returnport;}intmain(){RingBufferrb(3);for(inti=1;i<=4;++i){rb.push(i);// 容量 3,第 4 次挤压掉最旧的}std::cout<<"size = "<<rb.size()<<", front = "<<rb.front()<<'\n';std::cout<<"parsePort(\"8080\") = "<<parsePort("8080")<<'\n';try{parsePort("70000");}catch(conststd::out_of_range&e){std::cout<<"被捕获: "<<e.what()<<'\n';}try{parsePort("abc");}catch(conststd::invalid_argument&e){std::cout<<"被捕获: "<<e.what()<<'\n';}}size = 3, front = 2 parsePort("8080") = 8080 被捕获: 端口超出 1..65535: 70000 被捕获: 端口不是数字: abc性能视角:为什么「能用 static_assert 就别用 if」
static_assert与运行期if的差别不只是报错早晚,代价也完全不同:
static_assert(条件) 不成立 static_assert(条件) 成立 │ │ ▼ ▼ 编译失败,无二进制 条件在编译期求值完毕 → 直接消失 │ ▼ 生成的机器码里没有分支、没有比较、 没有为「万一不成立」准备的错误路径 │ ▼ 分支预测器没有额外压力,热循环里的指令缓存没被稀释 运行期 if (条件) 不成立 → 需要一条 cmp + 条件跳转(可预测性取决于数据) 失败路径的代码还常驻在指令流附近,影响 I-cache 并且在 Release 下依然每次都要比较一次对一个每帧执行几百万次的循环来说,「每次多一条比较跳转」不是零成本;而一个static_assert(sizeof(int) == 4)的成本是绝对的零。这就是 Core Guidelines P.5 背后的实际理由:能在编译期判定的东西,别拖到运行期每个指令周期去付钱。
选型表:四种检查手段怎么分
| 手段 | 检查时机 | Release 下是否还在 | 失败后的行为 | 适用场景 |
|---|---|---|---|---|
static_assert | 编译期 | 不适用(早就不在二进制里) | 编译失败 + 你的消息 | 类型约束、常量关系、平台尺寸、模板实参 |
assert | 运行期 | 默认消失(定义NDEBUG时) | 打印位置 +abort() | 内部不变量、绝不该发生的分支 |
| 异常 | 运行期 | 始终有效 | 沿栈展开,可被捕获 | 无法在本地处理的失败、用户输入非法、资源获取失败 |
错误码 /std::optional | 运行期 | 始终有效 | 由调用方显式检查 | 失败属于常态、不想要栈展开开销、跨 ABI 边界 |
决策就两个问题。条件是编译期常量吗?是就用static_assert。失败说明我写错了、必须崩吗?是就用assert。两个都不是,交给异常或错误码。
[[assume]]与契约编程:断言的两个进化方向
C++23 引入了[[assume(条件)]]属性,它说的是「我担保这个条件成立,让编译器放开优化」。这与assert正好相反:assert在条件不成立时帮你抓住错误,[[assume]]在条件不成立时会产生未定义行为。它是给优化器递刀,用错了连错误现场都没有。C++17 环境里用不到,知道它存在即可。
官方文档:
[[assume]]属性
更长远的方向是契约编程(contract programming):把前置条件(precondition)、后置条件(postcondition)、断言统一成语言设施。Core Guidelines 的 I.5 / I.6 现在建议用Expects()/Ensures()这类 GSL 写法,就是因为在语言支持到来之前,assert承担了「前置条件检查」这个它并不完全胜任的职责。
官方文档:Core Guidelines I.6:Prefer Expects() for expressing preconditions
延伸阅读
- static_assert 声明:三种写法(带消息 / 不带消息)与合法出现位置的完整规则
- assert 宏:明确了
NDEBUG会导致「表达式不求值」而不是「求值但忽略结果」 - Core Guidelines P.5:Prefer compile-time checking to run-time checking:
static_assert最有力的论据 [[assume]]:C++23 的「反向断言」,知道它危险在哪比会用它更重要- GCC / libstdc++ Macros:发布版如何用
_GLIBCXX_ASSERTIONS做廉价兜底
收个尾
这两样东西的分工其实很清楚:static_assert盯编译期能判定的假设,零开销;assert盯运行期的内部不变量,代价是 Release 下整行消失。
真正容易翻车的是第三条:把外部输入塞进assert。Debug 下它看着挺负责,Release 下连检查都没了,程序带着脏数据一路跑到越界。这类失败属于正常业务,得由异常或错误码接住,断言管不着。