我第一次认真在意“常量表达式”。
不是在写算法题。
是在写一个老项目。
那种老到什么程度呢。
#define 还在当家。
配置值散落在头文件里。
你想改个缓冲区大小。
得全工程搜一遍。
那时候我们总爱说一句话。
“反正是常量。”
然后事故就来了。
有个同事写了个宏。
#define SQUARE(x) x * x
看着挺朴素。
后来调用点写成了这样。
int v = SQUARE(a + b);
线上一跑。
结果比你想的“更有创造力”。
你以为是 (a + b) * (a + b)。
它给你的是 a + b * a + b。
宏不讲理。
它只会展开。
那天我第一次觉得。
“编译期”这件事。
如果只靠预处理器。
迟早要出事。
后来 C++11 给了一个更像语言的答案。
constexpr。
它不是为了炫技。
它更像委员会给工程师的道歉信。
“对不起。”
“以前你们只能靠一些歪招。”
“现在给你们一条正路。”
在 constexpr 之前:大家都怎么凑合
C/C++ 很长一段时间里。
编译期的表达能力。
不够用。
但工程又逼着你必须“提前算”。
于是大家就各显神通。
宏:最快,但最不讲武德
宏能做的事。
说白了就是文本替换。
#define KB(x) ((x) * 1024)
你要的其实是“单位”。
宏给你的只是“展开”。
它没有类型。
也没有作用域。
更不会替你检查什么。
你写错了。
它只会很努力地把错扩散到全工程。
enum 伪常量:老派,但挺实用
后来大家学聪明了。
既然宏不可靠。
那就用语言本身。
enum { BufferSize = 4096 };
它看起来像常量。
也确实是编译期常量。
而且有作用域(至少比宏强)。
但它也很“别扭”。
你只是想写一个常量。
却得借用枚举的语法。
有点像你只是想钉个钉子。
结果得先学会开挖掘机。
const:你以为稳了,其实还不够
很多人会写。
const int N = 10;
在很多场景下。
这确实能当常量。
但 const 的核心语义是。
“只读”。
不是“编译期”。
你很快就会遇到这种。
int read();
const int N = read();
它当然是只读的。
但它不是常量表达式。
你别指望它能去当数组长度。
也别指望它能去当模板参数。
工程里最烦的就是这种。
你以为你写的是“常量”。
结果它只是“运行时的只读变量”。
模板元编程:硬算出来的编译期
再后来。
高手们开始用模板做计算。
不是因为他们喜欢折磨自己。
是因为没得选。
template <int N>
struct Factorial {
static const int value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static const int value = 1;
};
你看懂了。
说明你已经在 C++ 里混过一阵。
你看不懂。
也没关系。
这玩意儿的可读性本来就不打算照顾普通人。
但它确实能在编译期算。
也确实能塞进模板参数里。
那时候的工程经验就是。
“能不用就别用。”
“真要用,也别让新人维护。”
C++11 的 constexpr:终于像个人话了
constexpr 做了一件很朴素的事。
它把“能在编译期求值”
变成语言层面的承诺。
你可以写一个函数。
让它在需要的时候变成编译期计算。
不需要的时候。
它也能当普通函数跑。
这点很重要。
工程里从来不是“全编译期”。
更多是。
“能提前就提前。”
先来个经典:编译期的阶乘
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
注意这句。
它长得像普通递归。
但它多了 constexpr。
这就等于你跟编译器说。
“如果参数是常量表达式。”
“你就给我在编译期算出来。”
然后你可以把结果塞进需要编译期常量的地方。
#include <array>
std::array<int, factorial(5)> a{};
这不是语法糖。
这是一种工程上的“止损”。
数组长度。
模板参数。
这些地方最怕运行时的暧昧。
要么是常量。
要么别来。
同一个函数,也可以在运行时用
有意思的点在这里。
constexpr 函数不是只能编译期跑。
你把参数换成运行时值。
它就老老实实在运行时算。
int f(int n) {
return factorial(n);
}
这里的 factorial(n)。
不会要求 n 必须是常量表达式。
编译器会做它能做的。
做不到就算了。
这也是我喜欢 constexpr 的地方。
它不像某些“全 or 无”的设计。
它更像一个现实主义的工程工具。
常量表达式到底是什么
“常量表达式”听起来像学术。
其实你可以把它理解成一句人话。
编译器能不能在编译时就把值定死。
如果能。
它就能出现在一些很苛刻的位置。
比如数组大小。
比如模板参数。
比如 case 标签。
const 和 constexpr,差的不是一个字母
写久了你会发现。
const 解决的是“能不能改”。
constexpr 更在乎“能不能提前算”。
const int a = 10;
constexpr int b = 10;
这两行看起来差不多。
但 b 的语义更硬。
它就是编译期常量。
你不能拿一个运行时结果去初始化它。
int read();
// constexpr int x = read(); // 这句别指望能过
这种“硬”。
在工程里是好事。
它会逼你把边界说清楚。
到底是常量。
还是只是不会改。
场景:你真的会用到它
constexpr 很容易被讲成“优化”。
但我更愿意把它讲成。
“把某些错误推到更早发生。”
早发生。
通常就更便宜。
场景一:协议、文件格式、魔数
做协议解析的时候。
你会遇到很多固定值。
你希望它们是编译期常量。
因为这些值本来就不该变。
#include <cstdint>
constexpr std::uint32_t kMagic = 0xDEADBEEF;
这行代码的好处。
不是它更快。
是它更不容易被人改坏。
而且你能拿它去做编译期检查。
static_assert(kMagic != 0, "");
我知道这个断言看起来有点傻。
但在大工程里。
这种“傻”经常救命。
场景二:用 std::array 做静态表
有些表就是固定的。
比如某些字符分类。
比如查表加速的权重。
如果你用 std::vector。
它通常是运行时构造。
你还要考虑初始化顺序。
你还要考虑动态分配。
有时候你就是不想要这些戏。
#include <array>
constexpr std::array<int, 4> kDx{1, 0, -1, 0};
constexpr std::array<int, 4> kDy{0, 1, 0, -1};
它们像常量。
也真的是常量。
而且没有动态分配。
没有初始化顺序地雷。
场景三:让类型也能“提前活起来”
constexpr 不只是给数字用。
它也可以给类型的构造用。
struct Point {
int x;
int y;
constexpr Point(int x, int y) : x(x), y(y) {}
};
constexpr Point origin{0, 0};
你看。
对象也能是编译期常量。
这在写数学库。
写几何。
写编译期配置的时候。
很顺手。
它让“常量”不再只是一堆裸数字。
而是带语义的对象。
一点现实:C++11 的 constexpr 其实很克制
很多人今天看 constexpr。
会觉得它“能写循环”。
“能写复杂逻辑”。
但那是后来的事。
C++11 刚引入它的时候。
要求很严格。
你基本只能写一个很“纯”的函数。
更像数学表达式。
所以你会看到一堆递归。
不是因为大家都爱递归。
是因为当时的 constexpr。
就允许你这么写。
再往后。
C++14 放宽了限制。
C++20 又来了 consteval。
把“必须编译期”这件事说得更死。
但这些都是后话。
你先把 C++11 的这个口子打开。
工程就已经能少掉一批祖传技巧。
结尾:constexpr 的真正价值
我用 constexpr 用得越久。
越不把它当成“性能技巧”。
它当然可能更快。
但这不是我最在意的。
我最在意的是。
它把一些事情变得更明确。
哪些值是天生不该变的。
哪些计算是可以提前确定的。
哪些接口是你不该让运行时去猜的。
你把这些东西提前钉死。
代码就更像一个可靠的系统。
而不是一堆“碰巧能跑”的表达式。