C++ 编译期计算
"编译期计算"是 C++ 区别于大多数语言的核心能力:把能在编译期算出来的东西全部前移到编译期——更早发现错误、运行期零开销、生成更紧凑的代码。这条路从 C++98 的模板元编程一路演进到 C++26 的静态反射,工具越来越多也越来越人道。
这篇把整套编译期计算的版图画出来:模板元编程、if constexpr、fold 表达式、type traits、constexpr/consteval/constinit 三件套、C++26 反射。concepts 的语法见 C++ Concept,这里聚焦"怎么用这些工具在编译期算东西"。文末还会带一笔 const 家族的另一半——const 正确性这条只读语义线,因为它和编译期求值线共享 const 这个名字,常被放在一起讲。
编译期能算什么:两个层面
编译期计算分两个层面,别混在一起:
- 值计算:算出一个值(整数、字符串、容器……)。工具是
constexpr/consteval函数、模板参数、fold 表达式。C++20 后连std::vector/std::string都能在编译期用。 - 类型计算:算出一个类型、或者根据类型做分支。工具是模板、
if constexpr、type traits、concepts。这是模板元编程的地盘。
1 | |
值计算读起来像普通函数;类型计算读起来像在"编程类型系统"。现代 C++ 的趋势是能用值计算就别用类型计算——前者直观得多。
模板元编程:编译期的"函数式编程"
模板元编程(TMP)是 C++98 时代唯一的编译期计算手段。它的本质是:模板实例化本身就是一台图灵完备的求值机。每个模板特化相当于一次"函数调用",递归实例化相当于递归,特化相当于模式匹配。
经典的编译期阶乘:
1 | |
这写法读起来像 Haskell:递归定义 + 基础情形。代价是编译慢、报错灾难、可读性差——每个 Factorial<N> 都是编译器要实例化的一个新类型,N=5 就实例化 6 个类。现在几乎不再用 TMP 做"算值",但它仍然是做"类型分支"的基础设施。
SFINAE:编译期的"试错"
SFINAE(Substitution Failure Is Not An Error)是 TMP 的核心技巧:模板实参替换时如果产生非法类型,不算硬错误,只是这个重载被淘汰。利用它可以"根据类型特性选择不同重载":
1 | |
SFINAE 能用但极其难读、报错极差。C++20 后 concepts 取代了它,同样的需求写成:
1 | |
清晰、报错友好、还能做 subsumption 优先级。新代码请忘掉 SFINAE,把它当历史遗留理解即可。
type traits:编译期的类型查询
<type_traits> 提供一堆编译期"问类型属性"的工具,返回 bool 或类型。它们是 if constexpr 和 SFINAE/concepts 的底层弹药。
1 | |
_v 后缀是值(::value 的简写),_t 后缀是类型(::type 的简写),C++14 起提供。写 traits 时几乎只用这两个后缀,原始的 std::is_integral<T>::value 已是上古写法。
if constexpr:编译期的 if
C++17 的 if constexpr 是编译期计算的转折点——它让"按类型分支"从 TMP/SFINAE 的噩梦变成一行普通 if。条件必须是常量表达式,false 分支不会被实例化,所以可以在分支里写只对某些类型合法的代码:
1 | |
对比 C++14 用 SFINAE 或 tag dispatch 写四个重载的痛苦,if constexpr 让模板代码的可读性上了个台阶。它的语义是"丢弃未选中的分支"——不是运行期跳过,是编译期直接不生成那部分代码。
fold 表达式:变参模板的归约
C++17 的 fold 表达式让变参模板里"对每个参数做同一操作"变得一行搞定,不用再手写递归特化:
1 | |
四种 fold 形式:
| 写法 | 含义 | 空包 |
|---|---|---|
(... op pack) | 左折叠 ((e1 op e2) op ...) op eN | 错(除非 op 有幺元) |
(pack op ...) | 右折叠 e1 op (e2 op (... op eN)) | 错 |
(init op ... op pack) | 带初值左折叠 | init |
(pack op ... op init) | 带初值右折叠 | init |
实用例子:编译期检查所有类型都满足某 concept,或拼接格式串:
1 | |
逗号 fold 是变参"展开副作用"的惯用法,因为逗号运算符有自然的从左到右求值顺序。
const 家族:编译期求值的三件套
C++ 里有三个"编译期求值"相关的关键字:constexpr、consteval、constinit。名字里都带 const,但职责各不相同——一个承诺"编译期可算",一个强制"只能在编译期算",一个解决"静态变量别出初始化顺序问题"。它们是值计算的主力工具。
一张表先建立全局印象
| 关键字 | 作用于 | 求值时机 | 能否修改 |
|---|---|---|---|
constexpr | 变量、函数 | 必须可在编译期求值 | 否 |
consteval | 函数 | 仅编译期,禁止运行期调用 | — |
constinit | 静态/线程存储期变量 | 编译期初始化 | 是(运行期可改) |
记住一句话:constexpr 管"编译期可算",consteval 管"只能在编译期算",constinit 管"静态变量别出初始化顺序坑"。
constexpr:编译期就能算出来
constexpr(constant expression)要求对象或函数必须能在编译期求值。相比只承诺"运行期不可改"的 const,它把计算推到了编译期。constexpr 函数尤其特别——它能在编译期和运行期两种语境下复用同一份定义,这就是下面要讲的"双态求值"。
1 | |
关键:双态求值
constexpr 函数最强大的地方在于它是双态(dual-mode)的——同一个函数体,编译器会根据调用点上下文决定在编译期还是运行期执行:
- 当所有实参都是常量表达式、且结果被用于需要常量表达式的语境(模板参数、
static_assert、constexpr变量初始化、数组大小等)时,函数在编译期求值; - 当实参包含运行期值,或调用点不需要常量结果时,函数退化为普通函数在运行期执行。
1 | |
最后一种情况值得注意:constexpr 是"可以"在编译期算,不是"必须"。只有当实参是常量、且结果又被常量语境使用时,编译期求值才被强制;否则算在哪个阶段由实现决定(标准允许但不要求编译期折叠)。真正"必须在编译期算"是 consteval 的事。
这种双态带来一个巨大好处:一处定义,两处可用。你不需要像 C 那样写一份宏给编译期用、再写一份函数给运行期用,constexpr 函数自动适配两种调用语境:
1 | |
正因为双态,constexpr 才能逐步"吃掉"越来越多原本只能运行期的逻辑。从 C++11 只能写 return 一条语句,到 C++14 允许循环和局部变量,到 C++20 放开 std::vector/std::string 等类型……标准一路放宽,本质就是让同一个函数在编译期语境里能干的事越来越多。这种放宽让你可以把运行期函数直接加个 constexpr 关键字,而不必为编译期另写一套——这是现代 C++ 把计算"上提"到编译期的核心机制。
constexpr 还能用在构造函数上,让类型在编译期构造:
1 | |
C++20 起放宽了 constexpr 的限制:可以用的语句越来越多,甚至能在 constexpr 函数里用 std::vector、std::string(C++20/26 逐步支持)。这让大量原本只能运行期的逻辑得以"上提"到编译期。
consteval:只能在编译期算
consteval(C++20)是 constexpr 的严格版本——它强制函数只能在编译期求值,禁止运行期调用。
1 | |
什么时候用 consteval?当你不希望这段代码在运行期执行时——比如:
- 编译期生成查找表、格式串校验、正则编译,避免运行期开销;
- 强制某段逻辑必须由编译器验证,而不是偷偷退化成运行期调用。
一个典型场景是格式串检查。C++20 的 std::format 系列会校验格式串,但只有把校验放在 consteval 函数里,才能保证错误在编译期报出,运行期零成本:
1 | |
一句话区分:
constexpr是"可以在编译期算",consteval是"只能在编译期算"。
constinit:编译期初始化,但能改
constinit(C++20)解决的是另一个问题:静态变量的初始化顺序。
具有静态存储期的变量(全局变量、函数内 static 变量、类的 static 成员)的初始化分两种:
- 常量初始化(constant initialization):编译期完成,零开销;
- 动态初始化(dynamic initialization):运行期执行初始化代码。
跨翻译单元的动态初始化顺序是未定义的,这就是臭名昭著的"静态初始化顺序灾难"(SIOF):A 的初始化依赖 B,但 B 还没初始化。
constinit 强制变量进行常量初始化,杜绝动态初始化,从而消除 SIOF:
1 | |
关键区别在于:constinit 不保证只读。它和 const 是正交的两个维度:
| 编译期初始化 | 运行期只读 | |
|---|---|---|
const | 不保证 | 是 |
constexpr | 是 | 是 |
constinit | 是 | 否 |
constinit const | 是 | 是 |
1 | |
用法口诀:需要"静态变量在编译期就初始化好、避免初始化顺序坑,但运行期还想改它"——用
constinit。
三者之间的关系
把这三个放在一起看,C++ 的"编译期求值"设计是分层的:
constexpr是编译期可算的通用承诺,覆盖变量和函数,且能在编译期/运行期双态运行;consteval把"可算"收紧为"必须算",专供编译期专用逻辑,禁止运行期退化;constinit关注的是静态存储期的初始化时机,和只读无关,是 SIOF 的解药。
选择上有个简单的决策树:
- 变量/函数编译期可算、运行期也能用 →
constexpr; - 函数只在编译期用,禁止运行期退化 →
consteval; - 静态变量怕初始化顺序、但运行期要改它 →
constinit。
1 | |
constexpr 求值实战:把运行期逻辑上提
三件套讲清了分工,这里看几个把"运行期逻辑"上提到编译期的实战形态。
编译期生成查找表
1 | |
编译期字符串哈希用于 switch
1 | |
C++20/26:编译期能用的容器
C++20 起 constexpr 函数里可以用 std::vector、std::string(配合 C++20 的 constexpr 内存分配与 C++26 的 constexpr cast 放宽)。这意味着大量原本只能运行期的算法现在能直接 constexpr 化:
1 | |
编译期分配的内存在 constexpr 上下文结束时必须释放(不能泄漏到运行期常量),所以一个返回 constexpr vector 的函数其实是把结果"物化"成常量数据。
static_assert:编译期断言
static_assert(常量表达式, "消息") 在编译期求值,失败即编译错误。它是验证编译期假设、给模板写"概念文档"的标配:
1 | |
结合 concepts 还能写成更声明式的约束,但 static_assert 适合那种"我要明确把这个不变式钉死、违反就给一句人话"的场景。
把模板参数当编译期值
非类型模板参数(NTTP)让"值"成为模板的一部分,编译器据此生成不同代码。C++20 起 NTTP 可以是浮点数和字面量类类型,玩法大增:
1 | |
这是编译期反射、序列化框架、强类型别名库的底层手段——把运行期的字符串/配置变成编译期类型的一部分。
C++26:静态反射
C++26 引入 ^^T 反射运算符,返回一个 std::meta::info(编译期类型反射值),能在编译期遍历类的成员、枚举项、函数签名。这是几十年来 C++ 社区最想要的特性之一,终于让"枚举转字符串"“自动序列化”"自动注册"不再依赖宏地狱:
1 | |
^^Color 拿到枚举的反射,members_of 枚举其成员,[:...:](splice)把反射值重新拼回代码。配合 consteval/constexpr,反射是纯编译期机制,运行期零开销。它把过去必须靠宏 + X-macro + 代码生成才能做的事变成了语言内一等公民。
const 正确性:const 家族的另一半(只读语义线)
上面三个关键字都带 const,但真正叫 const 的那个才是 const 家族的本体。它的语义只有一条:只读——不是"编译期算出来",不是"不可变",只是"这一路你不能通过它改"。这一条引出了 const 正确性的一整套体系。它和编译期求值是正交的两条线,只因共享 const 这个名字常被并提。
顶层 const 与底层 const
指针是个"两层"类型——指针本身、和它指向的东西。const 加在哪一层,语义完全不同:
1 | |
- 顶层 const(top-level):修饰对象本身,如
int* const、const int x。它只影响这个对象本身能否被改,和类型转换无关。 - 底层 const(low-level):修饰指针/引用所指的对象,如
const int*。它和类型转换强相关。
关键规则:底层 const 在拷贝/赋值时必须匹配。你可以把 int* 转成 const int*(加只读,安全),但不能反过来(去掉只读,危险):
1 | |
顶层 const 不参与这个判断——const int x 拷给 int y 完全合法,因为拷贝产生新对象,原对象的只读不传染。引用的 const T& 是"底层 const"视角的引用:它承诺不通过这个引用改对象,但对象本身可能被别的途径改。
指针/引用的 const 细节(
const与*的组合、函数指针、成员指针等)见 C++ Const Pointer。
const 成员函数
成员函数末尾的 const 声明"这个函数不会修改对象状态",本质是把 this 的类型从 T* 变成 const T*:
1 | |
这条规则是 const 正确性的基石:const 对象只能调用 const 成员函数。如果一个 getter 忘了加 const,所有持有 const Account& 的代码都调不了它——这就是为什么"能加 const 就加 const"。反过来,非 const 对象两种都能调,编译器优先选非 const 重载。
mutable:const 里的逃生口
有时一个方法逻辑上"不改对象状态"(所以该是 const),但需要改某个内部细节——缓存、互斥量、惰性计算的缓存值。mutable 让特定成员在 const 方法里也可改:
1 | |
mutable 的正当用途是"物理可变但逻辑不变"的成员——缓存、互斥量、统计计数器。别拿它绕过 const 正确性去改真正的逻辑状态,那只是在欺骗自己。
const 重载:按 const 性返回不同类型
利用"const 对象调 const 重载、非 const 对象调非 const 重载",可以给同一个访问器做两个版本,返回不同类型的引用:
1 | |
std::vector、std::map 的 operator[]、begin()/end()、front()/back() 都是这套 const 重载——const 容器返回 const_iterator/const T&,非 const 返回可写版本。这是 const 正确性能贯穿整个标准库的原因。
const 与参数、返回值
按值参数加顶层 const 没意义——拷贝本来就是你的,改改不关调用方的事,而且声明里的 const 还会进签名干扰重载:
1 | |
只读引用/指针参数尽量加 const:既避免拷贝又向调用方保证不改,这是 C++ 函数签名的默认审美。返回值同理——返回 const T& 表示"给你看,但不许改",返回 T 或 T&& 则把所有权/可写权交出去。
const 正确性的传染性
const 正确性是"全有或全无"的:一旦某处拿到 const T&,所有经它调用的方法、访问的成员都被传染成只读。如果中途某个类型漏了 const 成员函数,整条 const 链就断了——你被迫 const_cast 掉只读去调它,const 正确性形同虚设。所以"能 const 就 const"不是洁癖,而是维持整条链不断裂的唯一办法。
编译期计算的代价
编译期能力不是免费的,主要代价集中在三处:
- 编译时间:模板实例化和
constexpr递归展开都吃编译时间。深度 TMP 递归(如Factorial<1000>)能让编译器卡住,标准一般要求至少支持 1024 层递归实例化,但实际要克制。 - 二进制体积:每个不同的模板实参生成一份代码,
std::vector<int>和std::vector<double>是两份完整实现。模板用得激进时体积膨胀明显。 - 报错可读性:TMP 报错曾经是 C++ 之耻,concepts 大幅缓解但类型计算密集处仍可能很绕。
工程上有个朴素的优先级:能用普通 constexpr 函数算值,就别用模板算类型;能用 if constexpr 分支,就别用 SFINAE/特化;能用 concept 约束,就别用 enable_if。 每一步都是把编译期计算从"元编程黑魔法"往"普通代码"拉近。
一句话总结
const 家族其实是两条正交的线:
- 编译期求值线:
constexpr(可算)/consteval(必算)/constinit(编译期初始化)——把计算和初始化前移到编译期,是值计算的主力; - 只读语义线:
const本身——顶层/底层 const、const 成员函数、mutable、const 重载——维持只读契约的传染性。
编译期计算这条线的方向,是从 TMP 到 if constexpr 到 concepts 到反射,始终是把编译期能力做得越来越像运行期代码——值计算用 constexpr/consteval,类型分支用 if constexpr + traits,约束用 concepts,未来再加反射。能用直观的手段就别上 TMP 黑魔法,编译期计算的目标是更早出错、更快运行,而不是炫技。而 const 正确性那条线则更基础,让只读契约在类型系统里贯穿到底。两条线各司其职,理解它们的分工,写出的代码会更安全、更高效,也更"有品味"。






