1. 从一张运行时窗函数表说起:constexpr 解决了我在工程里遇到的什么问题
1.1 启动阶段的计算为什么让人头疼
前几年我维护一个实时音频引擎,里面有一段很经典的“启动时代码”:程序会根据当前采样率,用 std::sin 算出一张 4096 点的窗函数表,供后续滤波模块使用。
cpp复制std::vector<float> g_window(4096);
void InitWindow(float* buf, int n) {
const double kPi = 3.14159265358979323846;
for (int i = 0; i < n; ++i) {
buf[i] = static_cast<float>(
0.5 * (1.0 - cos(2.0 * kPi * i / (n - 1))));
}
}
一开始大家都没把这段代码当回事。直到后来采样率档位越加越多,启动阶段要做的事越来越重,性能监控开始频繁报警:初始化一个窗口函数表就要花掉几毫秒,而且是在单线程启动路径上,直接拖慢整体起播速度。
更要命的是,这种“运行时算常量”的模式在工程里非常普遍:协议序号映射表、CRC 表、波形表、滤波器系数、各种分段阈值数组……它们本质上都是常量,却被写成了 std::vector 加循环赋值的初始化代码。程序每次跑起来都要重新算一遍,配置文件改个参数还得担心初始化顺序。
真正让我决定系统性使用 C++ constexpr 的契机,是我把其中一张 4096 点正弦表从运行期挪到编译期之后,启动耗时直接少了将近 2 毫秒。这 2 毫秒听起来不多,但在首帧渲染和音频起播这种对延迟敏感的路径上,足够让用户感知到“变快了一点”。所以这篇文章我不会去讲 C++ 标准里的繁文缛节,只把我实际在工程里用 constexpr 的场景、选型过程和踩过的坑完整讲一遍。
1.2 constexpr 与 const 不是一回事
很多入门 C++ 的同事会把 constexpr 简单理解成“加强版的 const”,这个印象需要纠正。
const 表示“这个变量不允许被修改”,它只约束了变量本身的可变性,并没有强制要求在编译期就知道值。比如:
cpp复制int x = 42;
const int v = x + 1;
如果 x 是运行期变量,v 也完全可能是运行期常量。编译器在某些优化级别下会折叠它,但那不是语言层面的保证。
constexpr 的含义是“这个值必须可以在编译期求出来”,或者“这个函数在参数是常量表达式的条件下,可以被编译期求值”。它把“编译期可知”这件事从编译器的可选项,变成了语言层面的一部分。
理解这一点很重要:在工程里,constexpr 不代表“一定会编译期求值”。真正的求值时机,取决于调用位置是否有“必须编译期求值”的需求。举例来说:
cpp复制constexpr int square(int x) { return x * x; }
int a = square(3); // 运行期也可以调用,编译器通常优化
constexpr int b = square(3); // 强制编译期求值
这种区分不是咬文嚼字,它在 debug 构建下会直接影响程序行为。后面我会专门讲这个坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 能写多少取决于标准版本:C++11 到 C++20 的 constexpr 能力边界
2.1 C++11/14 的宽松化过程,是工程可用的分水岭
C++11 刚引入 constexpr 时,限制非常苛刻。一个 constexpr 函数体里基本只能写一条 return 语句,不能有局部变量、不能有循环、不能有 if-else,想做一个稍复杂的编译期计算,只能靠递归和三元运算符硬凑。
所以在 C++11 时代,工程里大规模使用 constexpr 的代价很高,代码几乎都是模板元编程的画风。比如生成一张查表,你得用递归模板展开去构造数组。代码能跑,但维护的同学看了想骂人。
到了 C++14,标准放开了函数体限制:局部变量、循环、if、switch 都能写进 constexpr 函数里。这是工程真正开始受益的分水岭。前面提到的 4096 点窗函数表,我在 C++14 模式下可以直接写成一个普通的 for 循环,逻辑完全透明,可读性比模板递归好太多。
| 版本 | 核心能力 | 对工程的影响 |
|---|---|---|
| C++11 | constexpr 函数仅限单 return,变量限字面类型 | 可用于简单常量计算,复杂表需模板递归 |
| C++14 | 允许循环、局部变量、if 等 | 编译期算法可以写成常规代码 |
| C++17 | constexpr lambda、if constexpr、内联变量 | 表可放头文件且不产生多份拷贝,条件分支清晰 |
| C++20 | consteval、constinit、constexpr 动态分配 | 可强制编译期求值,string/vector 部分可用 |
如果你所在项目还停留在 C++11,看到很多现代 constexpr 示例会以为不现实。其实不是 constexpr 不行,而是当时标准还太年轻。
2.2 C++17/20 的关键突破:头文件表、lambda 与强约束
C++17 带来了三样对 constexpr 工程化特别重要的东西。
第一是 if constexpr。它能让编译期条件分支真正参与类型推断,比如“如果 T 是整数,走 A 分支;如果 T 是指针,走 B 分支”,分支里的代码在条件不满足时不会实例化。这跟普通 constexpr 函数加上 if 有本质区别,普通 if 在 constexpr 函数里仍然只是运行期语句,两个分支都必须能编译通过。
第二是 constexpr lambda。这意味着我可以在一个函数内部定义局部 lambda,再用来做编译期计算。工程里很多“临时算个表”的需求,不用再单独拆一个全局函数。
第三是 inline constexpr 变量。C++17 之前,constexpr 变量在头文件里默认是内部链接,每个翻译单元都会有一份拷贝,内存地址还不一样。C++17 支持内联变量后,同一个编译期对象在全程序只有一个实体。这对大表的工程化至关重要。
C++20 则把 constexpr 带进了更广阔的空间:constexpr 虚函数、constexpr 动态分配,std::vector 和 std::string 在受控条件下也能参与常量表达式计算,还多了 consteval 和 constinit 两个新技能。我会在第 6 节专门展开。
2.3 是否编译期求值,由调用方式决定,而不是函数写法
在实际项目中,这是大家理解 constexpr 最容易乱的地方。
一个 constexpr 函数,并不会因为“它是 constexpr”就保证每次调用都在编译期执行。它只是“具备编译期求值的能力”。判断标准是调用上下文:
cpp复制constexpr int table_size = 128;
int arr[table_size]; // 数组长度要求编译期常量
constexpr std::array<int, 4> fixed = {1,2,3,4}; // 强制
int runtime_index = 2;
int v = fixed[runtime_index]; // 这里访问的是运行期对象
我在代码评审里经常看到有人以为用了 constexpr 函数,就等同于“编译期一定求值”,于是担心函数里面调用了运行时 API 会不会出问题。其实只要函数体符合 constexpr 语法要求,编译器不会因为你写了一个 return 就停止检查。调用点的约束才是最终裁判。
换句话说,constexpr 更像是给了编译器一把“编译期可执行的钥匙”。用不用这把钥匙,取决于外部环境是不是常量表达式上下文。
3. 编译期查找表:从计算到内存布局的完整工程实践
3.1 生成CRC表的 constexpr 实现
查找表是 constexpr 在工程里最经典、收益最直接的应用。我拿 CRC32 表举例,因为它在网络协议、文件校验、消息完整性检查里到处都会出现。
CRC32 表是 256 个 32 位整数,运行时生成通常长这样:
cpp复制std::array<uint32_t, 256> MakeCrc32Table() {
std::array<uint32_t, 256> table{};
for (uint32_t i = 0; i < 256; ++i) {
uint32_t c = i;
for (int k = 0; k < 8; ++k) {
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
}
table[i] = c;
}
return table;
}
在 C++14 之后,你只需要把 MakeCrc32Table 改成 constexpr 函数,再一次性赋给一个 inline constexpr 数组,这张表就永久变成了编译期产物:
cpp复制inline constexpr std::array<uint32_t, 256> kCrc32Table = MakeCrc32Table();
这里有几个工程上的关键点,不是随口一说。
第一,std::array 是字面类型,可以被 constexpr 构造和返回。这比用 C 风格数组方便得多,因为 std::array 在函数之间传值语义清晰,而且可以直接用 .size() 拿到长度。
第二,C++17 之前,如果你在头文件里定义 constexpr std::array<uint32_t, 256> kCrc32Table = ...,那每个包含这个头文件的 .cpp 都会有一份独立拷贝。有些模块比对表地址时会发现不一致。C++17 后一定写成 inline constexpr。
第三,编译期表除了节省运行时间,还会改变数据的内存位置。表一旦是常量表达式,编译器就有机会把它放到 .rodata 这类只读段,在嵌入式场景里这意味着可以映射到 flash 区域,而不是每次开机从启动代码初始化到 RAM。对内存吃紧的开发板来说,这个收益比省掉几毫秒更大。
3.2 实测数据:4096点正弦表,运行期 vs 编译期
我之前做窗函数表时,在 x86 机器上跑过一轮对比。
用 std::sin 在运行时生成 4096 个 float,循环加上 std::vector 初始化,平均耗时在 0.8 毫秒到 1.2 毫秒之间。如果工程里同时初始化多张表,总耗时很容易堆到四五毫秒。
改成 constexpr 之后,运行时间直接归零,程序里完全不存在“初始化窗口表”这个步骤;代价是编译时间增加了 30 到 50 毫秒左右,对大多数项目来说可以接受。
但要注意,这个“编译时间增加”不是永远线性增长。比如我尝试过把 65536 点的一维插值表也改成 constexpr,编译时间直接暴涨到秒级,而且内存占用量也明显上升。使用 constexpr 生成大表需要评估编译机性能和增量编译体验。
一个折中方案是:大表不做成全量编译期,而是用 constexpr 函数生成“关键节点”,再在运行期做少量插值。很多傅里叶查表、滤波器系数表,其实精度要求并没有高到必须全表一次算好。
3.3 编译期表的正确打开方式:验证、容器、跨文件共享
编译期表不是“写出来就完事”。我会在工程里加一层编译期验证,避免未来有人改坏。
最简单的做法是把表内容的关键属性和 static_assert 绑在一起。例如 CRC32 表的总和:
cpp复制constexpr uint32_t CrcTableChecksum() {
uint32_t sum = 0;
for (auto v : kCrc32Table) sum ^= v;
return sum;
}
static_assert(CrcTableChecksum() == 0xE8A0A23Au, "CRC32 table was corrupted");
这个常量值怎么来?第一次合法编译时得到结果后固化进断言里。以后任何人改动算法、改动数组大小,编译都会直接失败。这相当于给“常量数据”上了一道回归测试,而且测试成本是零运行时间。
跨文件共享方面,建议把表定义放到一个专用的头文件里,结构如下:
- 声明函数:
inline constexpr std::array<uint32_t, 256> MakeCrc32Table(); - 定义表:
inline constexpr std::array<uint32_t, 256> kCrc32Table = MakeCrc32Table(); - 定义断言:
static_assert(...);
这样既不会重复实例化,也能让所有源文件看到同一张表。
4. 静态校验的艺术:用 constexpr 把错误挡在编译期
4.1 static_assert + constexpr 校验协议结构体
工程里比性能优化更重要的,是把能前置的错误前置。constexpr 在这种场景下比测试框架还可靠,因为编译不过就什么都没有。
处理网络协议或者二进制文件格式时,经常会出现这种情况:结构体定义改了一处字段顺序,忘记同步注释里的字节偏移,然后运行期就出现诡异的解析失败。用 constexpr 加 static_assert 可以把这类错误直接拦住。
cpp复制struct PacketHeader {
uint32_t magic;
uint16_t version;
uint16_t flags;
uint32_t payload_size;
};
constexpr size_t kPacketHeaderSize =
offsetof(PacketHeader, payload_size) + sizeof(PacketHeader::payload_size);
static_assert(kPacketHeaderSize == 12,
"PacketHeader layout mismatch");
offsetof 和 sizeof 本身都是编译期常量表达式,配合 constexpr 变量可以写出语义更清晰的偏移量。你甚至可以继续断言每个字段的偏移,比如 version 从第 4 字节开始,flags 从第 6 字节开始,防止有人把 uint16_t 改大后没有同步协议文档。
这里要注意一点:offsetof 只能用于标准布局类型。如果是含虚函数、访问限定不一致的复杂结构体,会触发编译错误。这也是好事,它倒逼你把协议结构体设计成能安全做内存映射的形式。
4.2 校验枚举映射表完整性,防漏关键反向映射
枚举到字符串的映射表在日志、状态机、协议解码里非常常见。很多人会写一个数组,然后默认枚举值和数组下标一一对应。一旦枚举中间插入了一个新值,数组内容可能整体错位。
我习惯在枚举定义之后立刻写一个 constexpr 数组,并于旁边放编译期校验:
cpp复制enum class Color : uint8_t {
Red = 0,
Green,
Blue,
Count
};
constexpr std::array<const char*, static_cast<int>(Color::Count)> kColorNames = {
"red",
"green",
"blue"
};
constexpr bool AllNamesNonEmpty() {
for (auto* name : kColorNames) {
if (name == nullptr || name[0] == '\0') return false;
}
return true;
}
static_assert(kColorNames.size() == static_cast<int>(Color::Count),
"enum name count mismatch");
static_assert(AllNamesNonEmpty(),
"all enum values need a non-empty name");
这里的关键是“所有枚举值都有名字”,而不仅是“数量相同”。数量相同只能保证数组大小一致,如果某个元素忘了初始化,它会被默认初始化为空指针,运行期一访问就崩。AllNamesNonEmpty 这个 constexpr 函数检查了每个指针非空且首字符非空,把所有隐患消掉了。
4.3 把业务规则写进编译期,减少运行期错误面
协议结构体校验还算“数据层”,更进一步,你可以把简单业务规则也变成编译期校验。
比如服务器里有一组功率档位配置,所有档位必须严格递增,否则调速逻辑会有重复分支。这种规则完全可以编译期检查:
cpp复制constexpr std::array<int, 4> kPowerLevels = {10, 20, 50, 100};
template <std::size_t N>
constexpr bool IsStrictMonotonic(const std::array<int, N>& arr) {
for (std::size_t i = 1; i < arr.size(); ++i) {
if (arr[i - 1] >= arr[i]) return false;
}
return true;
}
static_assert(IsStrictMonotonic(kPowerLevels),
"power levels must be strictly increasing");
以后谁改配置,往数组里插了一个乱序的数,编译直接报错。这比 code review 提醒一百遍都管用。
这种写法的好处是“规则和常量住在一起”。不用单独维护测试代码,也不需要运行到某个特定分支才能触发检查。凡是能在编译期算清楚的关系,就不要留到运行期去兜底。
5. 在模板元编程中引入 constexpr:摆脱递归模板的写法
5.1 从编译期递归到 constexpr 循环,代码可读性直接提升
C++ 模板元编程的传统写法,是让编译器通过递归实例化“算”出一个值。比如计算一组整型常量里的最小值,很多老代码会写成这样:
cpp复制template <int... Args> struct Min;
template <int N> struct Min<N> {
static constexpr int value = N;
};
template <int H, int... Rest> struct Min<H, Rest...> {
static constexpr int value = H < Min<Rest...>::value ? H : Min<Rest...>::value;
};
static_assert(Min<10, 2, 30, 5>::value == 2);
这种代码逻辑不复杂,但阅读门槛很高。尤其是当递归层级多、参数数量是动态时,编译器还容易触发最大实例化深度限制。
C++14 以后,完全可以用 constexpr 函数替代:
cpp复制template <std::size_t N>
constexpr int MinOf(const std::array<int, N>& arr) {
int result = arr[0];
for (std::size_t i = 1; i < arr.size(); ++i) {
if (arr[i] < result) result = arr[i];
}
return result;
}
constexpr std::array<int, 4> kNumbers = {10, 2, 30, 5};
static_assert(MinOf(kNumbers) == 2);
两者的编译期能力几乎没有差异,但第二种写法的思路和运行期代码完全一致,普通 C++ 工程师看一眼就懂。我在很多老项目里做过这种“模板元编程重构”,收益最大的不是性能,而是可维护性。
5.2 用 constexpr 计算类型属性,配合 if constexpr 或 SFINAE
constexpr 函数不只可以处理数值,还能参与类型特性和分支选择。
一个常见场景:写内存分配器或序列化代码时,需要判断一个类型是否具有超对齐需求。如果 alignof(T) 大于默认最大对齐值,就要走 aligned_alloc;否则可以直接用普通 new。
cpp复制template <typename T>
constexpr bool HasOverAlignment() {
return alignof(T) > alignof(std::max_align_t);
}
template <typename T>
void* AllocateBuffer() {
if constexpr (HasOverAlignment<T>()) {
// 超对齐类型,走专用分配路径
return nullptr; // 示例,实际调用平台对齐分配
} else {
return ::operator new(sizeof(T));
}
}
放在 C++17 之前,这种分派通常要靠 std::enable_if 写两个重载,函数签名又长又笨重。有了 if constexpr 加 constexpr 函数,代码流线型好很多。
这里还有一个隐藏优势:HasOverAlignment<T>() 是在编译期必然求值的,编译器会把不符合条件的分支完全剔除。理论上 branch predictor 都不需要参与,因为根本没有分支。
5.3 降低模板实例化数量:不只是漂亮,还可能减小二进制体积
递归模板的每一次实例化都会产生独立的模板符号和调试信息。一个 Min<1,2,3,...> 可能生成几十个实例,即使编译器优化掉了运行时代码,调试信息、依赖分析、编译中间表示仍然会膨胀。
用 constexpr 函数处理编译期计算时,通常只需要对一个 std::array 做一次普通函数调用,模板实例化数量降到常数级别。我在一个协议解析模块里从递归模板迁移到 constexpr 计算后,编译后 .o 文件体积大约减少了 8% 到 12%。当然这个数字和编译选项有关,但在大型项目里,模板实例化爆炸不是玄学,是会实打实拖慢构建速度的。
需要提醒的是,constexpr 函数本身也是模板的话,它依旧遵循模板实例化规则。好处是函数体只有一份,不会因为不同的调用参数组合而疯狂展开。
6. C++20 的高级形态:consteval 与 constinit 的工程价值
6.1 consteval:把“编译期求值”变成硬性要求
constexpr 函数允许在运行期调用,这在很多场景下是优点,因为保持了代码灵活性。但有些地方,你希望“如果不能在编译期算出来,就直接编译失败”,这时候 constexpr 就不够强了。
C++20 的 consteval 就是为这类需求设计的,它明确要求函数必须产生常量表达式。典型的例子是字符串哈希:
cpp复制consteval uint32_t Fnv1aHash(const char* s) {
uint32_t hash = 2166136261u;
while (*s) {
hash ^= static_cast<uint8_t>(*s++);
hash *= 16777619u;
}
return hash;
}
constexpr uint32_t kMagic = Fnv1aHash("MAGIC");
void Handle(const char* s) {
// 这里不能直接用 s 调用 Fnv1aHash,因为 s 不是编译期常量
}
工程价值在于:如果你有一个“必须使用编译期哈希值”的模块,比如把字符串映射到 switch case 的整数常量,用 consteval 能保证这个哈希一定在编译期完成。普通 constexpr 函数在非强制上下文里,编译器可以偷懒选择运行期执行,某些严格场景下会造成不确定性。
我在一个命令注册模块里就踩过类似的坑:函数签名写的 constexpr,调用处没有强制常量上下文,结果那张命令名到处理函数的哈希映射表在 debug 构建里每次启动都要重新算一遍。改成 consteval 后,编译期计算被强制固定下来,debug/release 行为彻底一致。
6.2 constinit:解决静态初始化顺序问题的另一把钥匙
C++20 的 constinit 和 constexpr 不一样,它不是限制“必须是编译期值”,而是断言“这个静态变量必须静态初始化”。
“静态初始化顺序问题”在大型 C++ 项目里是经典难题:一个全局对象在其构造函数里访问了另一个翻译单元里的全局对象,如果两者初始化顺序不确定,轻则拿到默认值,重则崩溃。
cpp复制struct Config {
int sampleRate;
int channels;
};
// 原来可能这样,依赖某个全局函数动态初始化
constinit Config g_serverConfig = {48000, 2};
这里 g_serverConfig 能保证在程序加载阶段完成静态初始化,不会进入“动态初始化”阶段,自然也就不会出现顺序依赖。
如果初始化值不是常量表达式,编译器会直接拒绝:
cpp复制// 错误:LoadConfig() 不是常量表达式
constinit Config g_serverConfig = LoadConfig();
这正是它的价值:把“潜在初始化顺序问题”从运行期风险变成编译期错误。
6.3 渐进式升级策略:老项目怎么向 C++20 靠
并不是所有项目都有条件立刻升级到 C++20。如果你维护一个基于 C++14 的大工程,我建议不要一步到位换编译器,而是分阶段引入。
第一阶段,先把标准版本抬到 C++14,把那些明显是“启动时算常量表”的代码逐渐改成 constexpr 函数。这一阶段不需要修改构建系统太多,风险最低。
第二阶段,如果编译器支持 C++17,把 std::vector 的初始化表、常见协议常量表定义成 inline constexpr,顺手把带条件分支的模板元编程用 if constexpr 重写。
第三阶段,等真正升级到 C++20 后,再逐个场景把 constexpr 升级成 consteval 或 constinit。也就是说,先用 constexpr 拿到收益,再逐步加强约束。我在项目中是这样做的,收益比较平滑,中途没有出现大规模编译告警或行为差异。
7. 我踩过的坑和排查思路,附带可复现的避坑清单
7.1 debug/release 行为不一致的根因
constexpr 函数有个让大家很困惑的特性:它可以编译期求值,但不保证每次调用都编译期求值。
在 release 构建里,编译器优化强,一个 constexpr 函数调用很可能被直接折叠成常量,运行期不会真的执行。但 debug 构建经常关闭优化,很多 constexpr 调用会退化成普通函数调用,按运行期逻辑执行。
如果这个函数内部只做数学计算,退化成运行期调用只是性能问题,行为还一致。但如果函数内部依赖了某些“本应在编译期完成的资源分配”或“通过模板特化保证的条件”,debug/release 就有可能出现差异。
我遇到过一种实际场景:一个全局表变量定义为 constexpr std::array,但初始化那个表的 constexpr 函数内部,在调试模式下因为无法折叠执行,导致表的初始化被推迟到第一次调用时。如果另一个全局模块在构造时就访问了这张表,就会访问到未完成初始化的数据。
排查思路是:先在源头强制验证。把初始化表达式直接赋给一个 constexpr 变量,测试能否编译期求值。如果不行,编译器会告诉你为什么。想彻底消除这种不确定性,就用 C++20 的 consteval 强制编译期执行。
7.2 浮点 constexpr 的移植性陷阱
constexpr 函数支持浮点算术,但浮点运算在编译期和运行期的行为并不总是完全一致。
编译期求值一般由编译器所在宿主机环境执行,这意味着宿主机上的浮点指令、舍入模式、扩展精度都可能和目标运行平台不同。对于普通业务数据,这点差异无关紧要。但如果这个 constexpr 结果要用于网络协议校验、加密哈希、或者需要跨平台保持一致的文件格式,浮点表就可能成为隐患。
我在一个跨平台编解码库里遇到过:同一张基于 sin 的滤波器系数表,在 Windows 上生成的常量和在 Linux 上生成的常量最后几位不同。原因不是标准库有 bug,而是两边的编译期浮点环境不一样。
最稳妥的方案:对需要“全平台完全一致”的表,用定点数或纯整数算法实现。CRC 表、查找表用整数没问题;非要浮点,就考虑显式固定计算顺序,并放弃“编译期浮点结果跨平台完全一致”这个假设。
7.3 constexpr 递归/循环深度限制和编译内存暴涨
constexpr 递归在 C++11 里是唯一的选择,但递归深度非常容易踩到编译器的上限。C++14 后虽然可以用循环,可循环次数和复杂度过高时,编译器的常量求值器会在内部展开大量步骤,导致两个问题:编译时间变长、编译内存暴涨。
我在写一个 65536 点的表时,曾经把编译时间从 0.8 秒拉长到 32 秒,构建进程内存占用接近 1GB。后来缩减成“编译期生成 256 个关键点 + 运行期线性插值”,编译时间和运行精度都满足了需求。
遇到编译慢,可以先怀疑是不是 constexpr 计算规模太大。典型的排查手段:把表规模缩小一半,看编译时间是否近似减半。如果减少一半后时间大幅下降,说明求值器在实际列算,而不是做了整体优化。
7.4 让编译器把常量值“说出来”:一个实用调试技巧
constexpr 函数执行结果不容易直接打印,因为编译期没有标准输出。我常用的技巧是用一个故意不完整定义的模板,把常量作为模板参数暴露到错误信息里。
cpp复制template <int N>
struct DebugConstant; // 只声明,不定义
constexpr int kCrcByte = /* 某个 constexpr 计算的结果 */;
DebugConstant<kCrcByte> dummy;
这段代码无法编译,但编译器会把错误信息报成类似这样的形式:
code复制error: aggregate 'DebugConstant<244> dummy' has incomplete type
你直接就能在错误信息里看到计算出来的常量值。这个方法在 C++11 之前的老代码里也很常见,至今依然好用。用它排查“constexpr 算出来的结果为什么不符合预期”会非常直观。
另一个更简单的辅助方法是故意写一个不可能的 static_assert,让常数信息出现在编译错误里,但大部分编译器只会提示断言失败,不会自动打印值。所以我个人更推荐上面这个模板技巧。
7.5 什么时候我建议别用 constexpr
聊了这么多应用场景,最后也必须说清楚边界。不是所有常量都应该一股脑改成 constexpr。
如果只是一次启动只需要算几十个值、耗时在一微秒以内,而且项目构建主机性能很差,那用 constexpr 换来的收益可能抵消不了编译时长增加。过早把简单代码复杂化,也是一种技术债。
如果项目还在 C++11 环境下,复杂表的 constexpr 实现会让代码很难读。与其折磨维护者,不如保留运行期初始化,或者用脚本在提交时生成一组常量数组文件。
如果某个浮点表和目标平台编译器环境高度耦合,并且要求跨平台完全一致,我宁愿选择运行期计算或外部代码生成,也不会让编译期浮点结果成为隐藏的不确定性来源。
“能编译期算”不等于“必须在编译期算”。我在实际工程里的判断标准是:这个常量是否位于启动热路径?是否属于协议/格式层要求严格一致的数据?它会不会因为运行期初始化导致全局构造顺序问题?这几个问题的答案哪怕有一个是肯定的,才值得引入 constexpr,并且根据需要的强度选择 constexpr、consteval 还是 constinit。
