那些年,编译器还不太会“帮你干活”
C++11 刚落地那会儿。
大家一边兴奋。
一边心里发虚。
兴奋是因为。
终于能写点新东西了。
发虚是因为。
编译器跟不上。
你写的代码。
不一定能过。
更烦的是。
它就算过了。
也可能在你看不见的地方“偷偷干活”。
比如。
启动时算一堆东西。
第一次用到时再算一堆东西。
你不写什么大项目。
也照样会被它绊一跤。
事故现场:线上啪一下,第一波请求超时
我当年写过一个很小的服务。
活很简单。
把输入里的字符分个类。
数字算一类。
字母算一类。
结果那天一上线。
监控一红。
第一波请求全都慢得离谱。
最省事的写法其实很多人都写过。
就是搞一张 256 大小的表。
第一次用到的时候再初始化。
#include <array>
const std::array<unsigned char, 256>& class_table() {
static std::array<unsigned char, 256> t = [] {
std::array<unsigned char, 256> x{};
for (int i = 0; i < 256; ++i) {
if (i >= '0' && i <= '9') x[i] = 1;
else if ((i >= 'a' && i <= 'z') || (i >= 'A' && i <= 'Z')) x[i] = 2;
}
return x;
}();
return t;
}
这段代码本身不脏。
但它有个小脾气。
第一次调用 class_table() 的那一下。
会真的跑一遍循环。
你的“第一次请求”。
就顺手替你把初始化做了。
当时我们排查的第一反应也很真实。
“谁在热路径里 new 了?”
翻了半天。
才发现是初始化。
而且还是那种。
你不盯着这几行就想不到的初始化。
当年的两难:你要的不是“聪明”,是“确定”
你当然可以把它提前到启动时。
把 static 换成全局对象就行。
但这只是把账单从“第一次请求”挪到“启动”。
你会得到另一个熟悉的词。
冷启动变慢。
更糟一点。
你还可能撞上“初始化顺序”这种玄学。
两个全局对象。
谁先构造。
真不一定按你想的来。
所以你真正想要的是。
这张表在编译期就算好。
程序一跑起来就已经在那里。
像常量一样。
先补一个坑:函数里的 static 是“第一次才初始化”
如果你只写过 C,你看到函数里的 static,第一反应通常是“它不会跟着栈一起没了”。这没错。
但 C++ 里还有一层:这种 static 对象会在第一次执行到这行的时候初始化,所以第一次调用的人,就会踩到初始化的成本。
从 C++11 开始,这个初始化还要求线程安全。好处是不会出错。 代价就是:你更不想把它留在“第一次请求”上。
有人给它起了个外号叫 magic statics。 意思就是“看起来像常量,实际上第一次会变个魔术”。
再补两个词:const 和 constexpr
const 的意思很朴素:不让你改。
但它不保证“编译期就有值”。
constexpr 更像一句契约:它要求这个东西能在编译期算出来。
算不出来就别装,直接编译失败。
int read_config();
const int a = 10;
const int b = read_config();
constexpr int c = 10;
上面这段里,a、b 都是只读。
但只有 c 明确告诉编译器:它应该是“编译期的数”。
如果你写成 constexpr int d = read_config();,编译器会拒绝你。
因为它没法在编译期去“读配置”。
先把话说清:什么叫“编译期算出来”
简单说。 就是让编译器在编译的时候把结果算完,然后把结果直接塞进最终的可执行文件里。
运行时不做初始化。 也不会在第一次调用时“突然忙一下”。
这事在 C++ 里对应的关键词叫 constexpr。
这里有个很常见的误会:constexpr 不是一句咒语。
它更像一个“允许编译期求值”的通行证。
你把它放在一个必须是常量的地方,编译器就只能编译期算。 你把它放在普通运行时路径里,它也可以老老实实运行时算。
constexpr 的前辈:宏、生成脚本、模板元编程
在 C 时代,你想要“编译期的东西”,最常见的办法是宏。 好处是省事。 坏处是它不讲类型,也不讲边界,错了还不爱报错。
再往后一点,工程里也会用脚本生成头文件。
比如跑一段 Python,把 256 个值写进 .h。
能用,但它把“编译”硬拆成了两步,流程一复杂,就容易漏。
C++ 更早期的答案,是模板元编程。 你可以先把模板理解成“让编译器按配方生成代码”。 于是大家就开始顺手让编译器算点数。
template<int N>
struct SumTo {
static constexpr int value = N + SumTo<N - 1>::value;
};
template<>
struct SumTo<0> {
static constexpr int value = 0;
};
这类写法当年很流行。 Boost.MPL 这类库把它玩到了极致。 但你也看得出来,它一点都不像“人写的业务代码”。
C++11 的 constexpr:你想写循环,它说“不行”
C++11 也有 constexpr。
但当年的规则很苛刻,你写出来的函数通常得尽量“像一条 return”。
所以你经常会看到这种递归写法。 能跑,也能在编译期算。
constexpr int sum_to_cxx11(int n) {
return n <= 0 ? 0 : n + sum_to_cxx11(n - 1);
}
这段代码看起来还行。 但它的味道跟 C 很不一样:你明明想写循环,却被迫写成递归。
而且一旦算的东西稍微大一点,递归层数就会让编译器先累。 你会很直观地感受到:编译期计算不是免费的。
C++14 relaxed constexpr:终于能写“像人写的代码”
C++14 做的事很朴素。
放宽限制,让 constexpr 里能写局部变量,能写循环,能写 if。
你终于可以写回你熟悉的那种风格。
constexpr int sum_to_cxx14(int n) {
int s = 0;
for (int i = 0; i <= n; ++i) s += i;
return s;
}
同一件事。 这回不用拐弯。 也不用把代码写成谜语。
#include <array>
#include <cstddef>
#include <cstdint>
constexpr std::array<std::uint8_t, 256> make_class_table() {
std::array<std::uint8_t, 256> t{};
for (std::size_t i = 0; i < t.size(); ++i) {
if (i >= '0' && i <= '9') t[i] = 1;
else if ((i >= 'a' && i <= 'z') || (i >= 'A' && i <= 'Z')) t[i] = 2;
}
return t;
}
constexpr auto class_table_const = make_class_table();
这回你写的东西就跟平时写的一样。 没有“祖传递归”。 也不用把初始化塞进运行时。
什么时候它一定在编译期
最简单的办法,是用 static_assert。
你可以把它理解成“编译期的断言”:它要是过不去,编译就直接停。
static_assert(class_table_const['7'] == 1);
static_assert(class_table_const['A'] == 2);
这两行能过。
说明 class_table_const 至少在这里确实是编译期的值。
你等于把一类“线上才会发现的错误”,提前到了编译阶段。
还有一种常见场景。 当你把值塞进模板参数,它也必须是编译期能确定的。
#include <array>
constexpr int n = sum_to_cxx14(10);
std::array<int, n> a{};
std::array<int, n> 里的 n 不是运行时变量。
它决定了数组有多大。
这类地方,编译器不可能等你运行起来再决定。
什么时候它会在运行时跑
constexpr 函数也可以在运行时被调用。
只要你的输入是运行时才知道的,它就只能运行时算。
constexpr int add(int a, int b) { return a + b; }
int x = 0;
auto y = add(x, 2);
这段里 x 是运行时变量。
所以 add(x, 2) 也就只是一次普通函数调用。
但这不代表 constexpr 白写。
同一个函数,你既能拿来做编译期计算,也能拿来做运行时计算。
它更像“同一份逻辑,两种用法”。
横向对比:几种老办法,各自的代价
手写表。 最直接,也最容易在第 137 个位置写错一个数,然后线上跑半年都没人发现。
运行时初始化。 写起来舒服。 代价是“第一次用到的人”替你付账,而且这个账单经常落在最不该落的地方。
脚本生成。 能把运行时成本挪走。 代价是构建链路变长,团队里总有人会忘记跑生成脚本。
模板元编程。 能做到纯编译期。 代价是代码不像人话,而且编译时间和报错信息都很刺激。
relaxed constexpr 解决的其实是同一个矛盾。 你既想要“编译期就把脏活干完”。 又想让代码看起来像正常代码。
你会问:那运行时会不会再算一遍
如果你用 constexpr 对象去接它,并且它被用在“必须是常量”的地方。
编译器就得在编译期把它算出来。
很多实现会把结果直接放进只读数据段。 运行时连“第一次算一下”的机会都没有。
一句话的结论
C++14 的 relaxed constexpr。
就是把“线上不确定的初始化成本”。
塞回编译器里。
最后留个亮点
我后来越来越觉得。
constexpr 不是为了炫技。
它更像一种工程纪律。
你把一段本来要在运行时发生的事提前到编译期。 就等于把一类线上问题变成了编译错误。 这才是它最值钱的地方。