那会儿我刚从 C 走到 C with classes。
项目不大。
人也不多。
我们写代码的标准很简单。
能跑。
就算赢。
直到有一天。
同一份程序。
在我机子上不炸。
到了客户那台机器。
啪一下。
我去问原因。
没人能一句话说清。
当年我们只有 assert
这种“只在客户那台机器炸”的事。
我见多了就知道。
大概率不是你的 if 写反了。
更像是。
有个规矩没人写下来。
然后某天被现实抽了一巴掌。
那会儿我们能用的,主要还是 C 时代那套。
也就是 assert。
它的逻辑很朴素:程序跑到这里,不满足就当场停下。 问题也很朴素:它得真的跑到这里,才会停。
更坑的是,很多项目会在发布版里把 assert 关掉。
你一旦定义了 NDEBUG,它就当场“蒸发”。
你写库,写工具,或者写一段“平时很少走到”的分支。 这个断言就像没写。
所以当年大家开始琢磨一件事:能不能让编译器早点知道。 别等我跑起来,更别等我上线。
巫术时代:用编译错误当闹钟
没有现成工具,那就自己造。 有人写宏,有人玩模板,甚至有人去用 Boost(一个老牌 C++ 库合集)这种“第三方大补丸”。
这里的“宏”。 其实就是预处理器做的文本替换。 编译器还没开始干活,它就先把你的代码“改写一遍”。
这里的“模板”。 你先把它理解成:让编译器帮你“按类型生成代码”。 顺手还能拿来做一些“类型层面的计算”。
核心思想就一个:让编译器报错。 拿编译错误当闹钟。
最常见的一个套路。 用数组长度逼编译器报错。
typedef char must_be_64bit[(sizeof(void*) == 8) ? 1 : -1];
条件不成立,数组长度就是 -1,编译器就会报错。 能用。 但你得到的往往只是“size of array is negative”,还得自己翻译成“哦,我想要的是 64 位”。
当年还有另一条路。 更“C 味儿”。
另一条路:预处理器的 #error
预处理器既然能改写文本。 那它也能直接把编译卡死。
#if INTPTR_MAX != INT64_MAX
#error "need 64-bit"
#endif
这段代码的意思是。 如果“指针能表示的最大整数”不是 64 位。 那就直接报错。
它的优点是。 报错很直白。 比“数组长度为负”强。
它的缺点也很现实。 它只能在预处理阶段做判断。 很多“跟类型有关”的检查,它做不动。
再加一句。
这种宏通常来自 <cstdint>(或者 C 里的 <stdint.h>)。
新手看到 INTPTR_MAX 这种名字,不用慌。
它就是标准库给的“平台信息”。
后来大家又走了一步。 把这些土法。 尽量做成“能复用的东西”。
Boost:把土法做成现成的
Boost 里就有过这种东西。
最出名的一个叫 BOOST_STATIC_ASSERT。
再往前一点。 模板圈子里也早就有人玩“编译期检查”。 比如 Andrei Alexandrescu 在《Modern C++ Design》里就用过类似的套路。
你不用记人名。
你只要记住一件事。
委员会不是凭空发明了 static_assert。
它更像是把民间偏方收编成了处方药。
BOOST_STATIC_ASSERT(sizeof(void*) == 8);
它的思路并不神秘。 底层还是那套“逼你编译失败”的招。 只是帮你把细节包起来,让你少写点怪代码。
再后来。 隔壁的 C 标准也补了一刀。
C11 的 _Static_assert:隔壁先补上
到了 C11。 C 语言也加了一个“编译期断言”。
_Static_assert(sizeof(void*) == 8, "need 64-bit");
你看语法是不是很眼熟。 它就是在告诉你:这件事不是 C++ 才会遇到。 只要你写的是“偏底层、偏库、偏跨平台”的代码,你迟早都要这根拐杖。
横向对比:这些办法各自擅长什么
数组长度那套。 胜在“什么年代都能用”。 输在“报错像谜语”。
#error 那套。
胜在“报错你说了算”。
输在“它只能在预处理阶段做判断”。
Boost 那套。 胜在“封装得像人话”。 输在“你得带上那套库”。
C11 的 _Static_assert。
胜在“C 也有编译期断言了”。
输在“它解决不了 C++ 模板那一堆类型约束”。
C++11 的 static_assert。
胜在“语言原生”。
同时也顺手照顾了模板场景。
你会发现。 它们不是互相打败。 它们更像一条长出来的进化链。
这就是那个年代的日常。 能活。 但活得不体面。
你看,大家其实不是想炫技。
只是想让错误早点发生。
最好在你把包发出去之前。
线上啪一下:一个小项目就够了
当时我们写了个很小的功能。 把内存里的数据直接写进文件,第二天再读出来。
代码大概长这样。
struct Record {
int id;
void* ptr;
};
你别笑。 这种“先写起来再说”的代码,每个人都写过。
先不写文件。
光看 sizeof 就够复现了。
printf("%zu\n", sizeof(Record));
你在 64 位上跑一次,可能是 16。
你在 32 位上跑一次,可能是 8。
这时候你才会追问:为什么同一个 struct,会变?
答案不玄学。 32 位 / 64 位,你先把它理解成:指针(地址)在内存里占几字节。 32 位机器指针通常是 4 字节,64 位机器指针通常是 8 字节。
Record 里有个 void*。
所以它的大小很容易跟着平台一起变。
再加上编译器为了跑得快,会在字段之间塞一些“空隙”,这叫 padding(填充)。
然后我们把它写进文件。
Record r{42, nullptr};
fwrite(&r, sizeof(r), 1, fp);
在开发机上,一切正常。 上线后客户用的是 32 位环境,文件里的布局变了。 你读出来的东西就开始错位。
错位这种事很讨厌。 它不一定立刻崩。 它可能只是悄悄把数据读歪,等你拿歪掉的数据去当指针用,就“啪”。
我们当时当然也写断言
第一反应很朴素。
加个 assert。
assert(sizeof(void*) == 8);
意思是:我只支持 64 位。 但它有个致命的问题:它得跑到这一行,才会告诉你“不对”。
也就是说,它属于“运行期检查”。 先跑起来,再验证。
你想想线上最常见的尴尬:
那一行不一定会被跑到。
或者跑到的时候。
已经是凌晨三点。
这时候你才明白。
有些规矩,靠人记不住。
static_assert:让编译器先吵起来
后来到了 C++11,终于有人把这件事做成了“正经工具”。
它叫 static_assert。
它像是你在代码里塞了一个“门禁”。
刷不过。
连可执行文件(也就是你最后能跑起来的那个程序)都不让你出门。
你可以把它理解成:程序还没跑,编译器先把你拦在门口。 这就叫“编译期检查”。 也就是“编译的时候就能算出来、就能确定对不对”的那种检查。
你可以把它当成。
语言层面把 Boost 那些“库里的土法”,收编成了标准。
语法也刻意做得接近 C11 的 _Static_assert。
还有一个小细节。 它允许你写一段“给人看的话”。 这事看起来很小。 但能救很多新人。
static_assert(sizeof(void*) == 8, "need 64-bit pointers");
你把这句写得像人话。 未来的你就少翻译一次谜语。
static_assert(sizeof(void*) == 8, "need 64-bit");
这一行的意思很直白:如果指针不是 8 字节,编译直接失败。
它跟 assert 最大的区别。
不是“更凶”。
而是“更早”。
早点失败。
人就少遭点罪。
有点残忍。
但很负责。
有些错误。
早点失败。
比晚点成功更便宜。
再来几个更像日常的例子
比如你写了个网络包。 你不想在奇怪的平台上跑。
static_assert(CHAR_BIT == 8, "only supports 8-bit bytes");
CHAR_BIT 的意思就是。
一个 char 有多少个 bit。
(它在 <climits> 里。)
再比如。
你不想默认假设 int 一定是 32 位。
static_assert(sizeof(int) == 4, "this code assumes 32-bit int");
这句话不是说“所有平台都该这样”。 它是在把你的假设写出来。 写给编译器看。 也写给同事看。
再给一个更贴近现实的例子
更常见的不是“你支持不支持 64 位”。 而是“我这个数据格式是不是被你改坏了”。
比如你有个文件头。 你希望它永远是 8 字节。
struct Header {
std::uint32_t magic;
std::uint16_t version;
std::uint16_t flags;
};
你在旁边立一块牌子。
static_assert(sizeof(Header) == 8, "Header layout changed");
以后谁要是手滑,往 Header 里多塞一个字段,或者调整了字段顺序。
编译器会第一个跳出来骂人。
你不用等测试,也不用等线上。
这比 code review 的肉眼靠谱。
有时候你不仅关心大小。 你还关心对齐。
static_assert(alignof(Header) == 4, "Header alignment changed");
alignof 你先理解成。
这个类型在内存里“起步必须站在哪个格子上”。
对齐变了。
你做二进制读写的时候也会难受。
有时候你甚至关心“字段有没有挪位置”。
static_assert(offsetof(Header, flags) == 6, "Header field offset changed");
offsetof(Header, flags) 的意思是。
从 Header 的开头算起。
flags 这个字段在第几个字节。
这种检查很土。
但对二进制协议很有用。
(顺手一提:offsetof 在 <cstddef> 里。)
如果你真要把它当成二进制格式写进文件。 还可以再加一道保险。
static_assert(std::is_standard_layout<Header>::value, "Header must be standard layout");
static_assert(std::is_trivially_copyable<Header>::value, "Header must be trivially copyable");
“standard layout”。 你可以先理解成:字段布局比较规矩。 不玩虚继承、不把对象搞成“编译器私活很多”的样子。
“trivially copyable”。 前面我们已经见过了。 它更像是在告诉你:按字节搬运是安全的。
它能断言什么,不能断言什么
static_assert 只做一件事:断言“编译期就能算出来”的东西。
像 sizeof(...) 这种,编译器现在就能算。
所以它就能断言。
但你别拿它断言运行时输入。
新手最容易卡的就是这句。 什么叫“编译期就能算出来”?
编译期到底是哪一段时间
你写一行 C++。 它走的路大概是这样的。
源代码 -> 预处理 -> 编译 -> 链接 -> 运行
#error 那类东西。
通常发生在“预处理”。
static_assert。
通常发生在“编译”。
而 assert。
要等到“运行”。
你可以粗暴地记成两类。
第一类是“看代码就能确定”的。
比如 sizeof(T)、alignof(T)、还有一些类型性质。
第二类是“得跑起来才知道”的。 比如用户输入、文件内容、网络包。
static_assert 只管第一类。
先说一个能过的。
你把值写成 constexpr(编译期常量)。
constexpr int x = 42;
static_assert(x > 0, "x must be positive");
这时候编译器能在编译期算出 x。
所以它能断言。
但下面这样就不行。 哪怕你觉得它“看起来也是 42”。
int x = 42;
static_assert(x > 0, "x must be positive");
看起来 x 写死了。
但它还是一个“运行时变量”,编译器不会把它当成编译期常量。
因为 x 的值,得程序跑起来才算数。
这不是 static_assert 的工作。
如果你想更具体一点。
constexpr 你可以把它理解成:我尽量让这个东西在编译期就能算。
算不出来也行。
那它就退回到运行期。
比如 constexpr 函数。
constexpr int page_size() { return 4096; }
static_assert(page_size() % 2 == 0, "page size must be even");
这个例子里。
编译器能直接把 page_size() 算出来。
所以它也能断言。
再往前走一步。
你会发现 static_assert 最爽的地方其实不在平台检查。
而是在泛型代码里。
还能用在泛型里:把类型要求写出来
你写模板函数的时候。 最怕的是“什么类型都能传进来”。 然后报错在模板深处炸成一锅粥。
还有一种更常见。 模板参数不是类型。 而是一个数字。
template <int N>
struct Buffer {
static_assert(N > 0, "N must be positive");
char data[N];
};
你可以把它理解成。
我这个 Buffer<N> 是“编译期就定死大小”的数组。
你要是传个 0 或负数。
那就别生成了。
template <class T>
T add_one(T x) {
static_assert(std::is_integral<T>::value, "T must be an integer type");
return x + 1;
}
这段代码的意思是。
你可以用 int、long 这种整数来用我。
但你别拿 double、std::string 来碰瓷。
std::is_integral<T>::value 这种写法。
叫 type traits(类型萃取)。
你先把它理解成:标准库给你的一堆“类型体检表”。
你问它“这个 T 是不是整数”。
它回你一个 true/false。
(顺手一提:这些 traits 在 <type_traits> 里。)
再把它拉回我们一开始的故事。 你不是在写二进制文件吗。 那就把规矩写死。
template <class T>
void write_binary(FILE* fp, const T& v) {
static_assert(std::is_trivially_copyable<T>::value, "T must be trivially copyable");
fwrite(&v, sizeof(v), 1, fp);
}
“trivially copyable”。 你可以先翻译成:能不能直接按字节拷贝。 不需要构造函数、析构函数在背后偷偷干活。
你一旦把这个规矩写进去。 就能少掉很多“线上啪一下”。
它更像是。
你在设计上立的规矩。
让编译器替你盯着。
static_assert 的价值。
很多时候不是“能不能做到”。
而是“能不能把话说清楚”。
把规矩写进代码里。
让编译器每天帮你巡逻。
它把那些“只存在于老同事口头交接里的规矩”。 变成了编译器每天都能检查的规则。 这其实是一种很朴素的工程进步。
最后一句话
你写下 static_assert。
其实是在给未来的人留路标。
未来的你。
未来的同事。
还有未来那个凌晨三点被电话叫醒的人。
让编译器替你把架吵完。
你就少一次去线上道歉的机会。