
1. 先从一个编译报错说起一个特别经典的画面新手在代码里写const int n 5; int arr[n];发现arr居然能编过于是心里烙下一个结论——const就是编译期常量。等哪天需求变了n来自一个函数返回值代码变成const int n getCount(); int arr[n];编译器立刻翻脸expression must have a constant value。这一刻很多人第一次意识到const和编译期能算出值根本不是一回事。我想先把两件事从根上掰开。const本质是一个类型修饰符、一个访问约束它表达的是你不应该通过这个名字修改这个对象。至于这个对象的值是什么时候确定的它根本不管。const int x rand();完全合法——对象就安安静静躺在运行期栈上只是代码里不能拿x当左值去改写。constexpr才是在表达另一件事这个值必须在编译期就能确定并且永远不可变。所以它能放进数组大小、模板实参、static_assert、switch的case标签这些必须要求常量表达式的位置。一个容易误导人的细节是const int n 5; int arr[n];之所以能通过是因为 C 标准里保留了一条历史兼容规则——const 限定的整型对象非 volatile如果它的初始化器是整数常量表达式那么这个对象本身也可以当整型常量表达式用。这条规则只对整型和枚举类型有效换成const double d 5.0; int arr[d];立刻报错。所以准确地说不是const 等于常量而是const int 加字面量初始化恰好凑巧进入了常量表达式的通道。很多老代码、老教科书还在用这种写法但读完这篇之后你最好别再这么写了。打个比方const像一个贴着请勿触碰标签的柜子。柜子可以放到运行期才打开里面的东西也可以是程序运行过程中才放进去的。constexpr像施工图纸上写死的参数。拿到图纸那一刻就必须算出来现场施工只能照着做。这两者的差异不只是语法层面背后是类型系统的只读约束与求值时机要求两个不同维度的东西。搞不清楚这个后面看模板元编程、看现代 C 代码里的constexpr函数、看std::array的声明全都会觉得云里雾里。1.1 为什么我们总是把两者混为一谈会混淆很大程度是历史惯性。C98 时代没有专门表达编译期常量的关键词数组大小、模板参数需要常量的时候大家普遍用const int写整型常量还经常用enum { N 5 };来绕开类内const int初始化的种种限制。enum当时几乎是模板元编程里唯一的编译期整数常量载体模板特化、数组维度、位运算都靠它撑场面。这种历史惯性一直延续到 C11 引入constexpr之后很多年。我见过不少项目里static const int和constexpr混着用注释里全写着常量其实语义天差地别。先放一个最简单的对照const int a 10; // 运行期只读对象但 int 类型时偶尔也能当常量表达式用 constexpr int b 10; // 明确要求编译期求值b 是真正的编译期常量 const int c std::rand(); // 合法运行期才知道值只是只读而已 constexpr int d std::rand();// 编译错误rand() 不是常量表达式到这一步先把核心结论立住凡是你需要编译器在编译阶段就知道这个数的场景优先写constexpr凡是我不希望这个变量在这个作用域里被改的场景写const。后面所有内容本质上都在细化这句话。2. 历史源头为什么 C11 要在 const 之外再造一个 constexpr了解一段历史比死记规则有用得多。const是从 C 语言血脉里继承下来的出身就带着接口合约的使命告诉调用者这个函数不会修改你的参数这个指针指向的东西不允许改。它跟编译期求值从来就不是绑定关系。2.1 const 的本职工作运行时契约C 里的const主要干四件事修饰普通变量表示这么名字下不可写修饰函数参数const std::string s表示引用传递但只读修饰成员函数int size() const表示 this 指针是指向 const 对象的指针修饰返回值比如const T防止外部修改内部状态。这些职责里没有一个要求值在编译期就确定。const约束的是代码行为不是求值时机。正因为如此函数形参可以是const int——形参本身就是一个运行期才确定的值rand()的结果也可以赋给const int——对象只读值照样是运行期算出来的。但在 C98 时代模板元编程需要一个编译期整数常量。大家被迫用enum hacktemplate int N struct Factorial { enum { value N * FactorialN - 1::value }; }; template struct Factorial0 { enum { value 1 }; };enum没有对象实体、不会被 ODR-use多数情况下、天生就是编译期常量所以它能当模板参数。但语法丑陋、可读性差而且这个用类型系统算算术的姿势跟普通函数差距太大。C11 引入constexpr本质上是给这些需求一个正规军你有函数、有变量、有构造函数都可以声明为编译期可知从此不用再靠enum和模板特化硬凑。2.2 constexpr 的演进路线constexpr 不是一次到位的它经历了一条非常明显的放宽曲线版本constexpr 相关变化C11变量、函数、构造函数可加 constexpr函数体只能包含一条 return 语句C14函数体内允许局部变量、循环、if/switch仍禁止 goto、static 变量C17lambda 可声明为 constexprconstexpr 静态成员变量变相成为 inline内联变量落地C20允许 constexpr 函数内出现 try 块std::string、std::vector 的部分操作可 constexprconsteval/constinit 加入C23进一步放宽constexpr 函数内允许 static constexpr 局部变量constexpr 相关边界继续软化早期 constexpr 函数只能有一条return充分说明当年委员会非常谨慎编译期求值是一个大规模静态展开的过程函数体太复杂编译器负担会剧增。所以 C11 把它压到一行表达式。后来实践表明模板元编程社区急需更自然的递归和循环表达C14 才放开。我自己的感觉是constexpr的历史就是把原本依赖模板黑科技的编译期计算逐步还给普通函数的历史。C20 之后你甚至可以在编译期构造一个std::vector算数据再把它交给运行时这在 C11 时代想都不敢想。3. 六大高频场景逐一对照const 和 constexpr 到底该用谁很多人的困惑是给我具体代码我到底该写哪个我不爱讲玄学直接按场景来。3.1 局部变量看要不要参与编译期计算void demo() { const int limit compute(); // 运行期算没问题读代码的人知道 limit 不会变 constexpr int batch 64; // 如果后续数组、模板、断言需要这个才是正解 }局部变量只要不放进要求常量表达式的上下文用const就够了。const让你和后来维护者都知道这个变量只读constexpr额外承诺编译器知道它的值。如果只是constexpr int batch 64然后当普通变量用也合法但没必要。一个反直觉点const对象不一定比constexpr对象“更轻”——前者可能占用运行期栈空间后者通常直接优化成立即数。所以真正的性能收益来自编译期算完、运行期零成本用。3.2 函数形参与返回值constexpr 函数是双模函数形参本身绝对不可能是constexpr。函数一调用形参的值就是运行期的它不满足常量表达式要求。常看到新手写void f(constexpr int x) {} // 错误constexpr 不能修饰形参正确姿势是形参用const表达我不改它然后函数整体加constexprconstexpr int twice(int x) { return x * 2; } int main() { constexpr int a twice(10); // 编译期算a 是常量 int n 42; int b twice(n); // 退化成普通函数运行期算 return a b; }这个双模特性特别像inline函数本身具备编译期求值能力但具体在哪个阶段执行取决于实参是否常量表达式、以及上下文是否强制要求常量。这是第四章要重点展开的机制。3.3 类成员变量static 成员的经典困境C17 之前类内静态常量整型可以这样写struct Config { static const int max_retry 3; // 整型可以类内初始化 // static const double ratio 0.5; // 错误C17 前 double 不行 };但如果你对max_retry取地址或者它在程序里被 ODR-used就需要在类外再定义一次麻烦得很。C17 落地 inline 变量之后推荐写法变成struct Config { static constexpr int max_retry 3; // 隐式 inline不需要 .cpp 里再定义 static constexpr double ratio 0.5; // 编译期常量double 同样支持 };constexpr static成员变量自带 inline 属性从 C17 起就不用在类外补定义这是团队代码里大量转向constexpr的现实原因之一。3.4 数组大小与 std::array必须编译期常量int arr[n]里 n 必须是常量表达式。std::arrayT, N的 N 同样是模板非类型参数必须是编译期常量。C 标准保留的 const int 整型可兼容 规则虽然让const int n 5能过但一旦这个 n 的来源变成函数参数、成员变量、C20 前的非字面量类型就会立刻翻车。所以凡是数组维度、容器容量、位宽这种编译期架构层面的大小一律写constexprconstexpr int kMaxEntries 128; using Table std::arraydouble, kMaxEntries;3.5 模板非类型参数NTTP 的标准答案模板参数N必须编译期可知。constexpr是标准答案但static constexpr也能用。C20 还允许浮点、类类型作为非类型模板参数前提是常量表达式。在这个场景里const顶多靠整型兼容规则勉强凑合但语义含混代码审查时我会直接打回去。template int N struct FixedBuffer { char data[N]; }; constexpr int kBufSize 1024; FixedBufferkBufSize buf; // OK3.6 静态存储期变量constinit 解决初始化顺序问题这里有第三个关键词constinit它通常和constexpr一起被讨论。constinit保证静态存储期的变量在编译期初始化但对象本身不一定是只读的它解决的是C 静态初始化顺序混乱问题constinit int counter compute(); // 编译期初始化运行期可以改 counter // constinit int x rand(); // 错误不能保证编译期初始化constinit不要求不可变性所以它和const是两回事constexpr则隐式包含 const 语义初始化必然编译期发生。如果你有一个运行期可能修改的全局计数变量但希望避开初始化顺序问题constinit就是工具。我把六大场景的结论压成一个速查表使用位置const 的表现constexpr 的表现实战推荐局部变量运行期只读编译期常量需要编译期值时用 constexpr否则 const函数形参接口只读合约不合法一律 const函数返回值值类型上意义有限双模可在编译期/运行期求值模板与算法里用 constexpr类静态成员C17 前整型类内初始化麻烦隐式 inline简洁constexpr数组/array 容量int 类型可凑合其他类型翻车语义明确constexpr模板非类型参数仅限整型且碰巧满足条件标准答案constexpr静态存储期变量只读但初始化时机不保证既 const 又编译期初始化需要可变则 constinit4. 深入 constexpr 函数编译期准入规则与惰性求值机制constexpr真正的威力在函数上。要把它用明白必须理解“什么样的函数能成为 constexpr 函数”和“constexpr 函数什么时候真的在编译期执行”。4.1 为什么不是所有函数都能加 constexpr从 C14 开始一个函数要被标记为 constexpr函数体通常只允许实参类型和返回类型是字面量类型函数体内不能有goto、不能有static或thread_local变量、不能是虚函数C20 虚函数也可 constexpr这是个重大放宽、不能进行未定义行为。C20 进一步允许 try 块但编译期求值中真正抛异常的路径仍然不被接受——try 块只是允许析构和异常安全代码在 constexpr 函数里存在。为什么要有这么多条条框框因为常量表达式求值是一个彻底的静态执行过程编译器要把函数体按控制流逐步展开执行。如果里面混入 static 变量、线程局部存储、虚函数分派就会引入运行期状态静态求值根本没办法保证确定性。goto会让控制流分析复杂度爆炸早期干脆禁掉。一个非常容易踩的坑是C23 之前constexpr函数里不能用静态局部变量。例如constexpr int get_id() { static int counter 0; // C23 之前错误 return counter; }这在常量表达式求值里会产生跨调用状态语义上说不通。C23 的 P2644 才允许static constexpr局部变量普通 static 仍然不行。4.2 constexpr 函数是双模的不是调用它就会编译期执行这是新手最容易误解的一点。constexpr函数并不保证每次调用都发生在编译期。它只承诺如果实参是常量表达式并且上下文需要常量表达式那么编译器有能力在编译期把它算出来。用代码感受一下constexpr int square(int x) { return x * x; } constexpr int a square(5); // 编译期求值因为 a 本身是 constexpr int arr[square(3)]; // 编译期求值数组维度需要常量表达式 int b square(0); // 可能仍是编译期求值优化但标准不强制 int x 4; int c square(x); // 一定运行期求值x 不是常量表达式注意最后一行的square(x)它调用的是同一个square函数但完全没有编译期魔法行为和一个普通内联函数一致。这正是 constexpr 函数能兼顾模板元编程工具和普通运行期函数的原因。你在编译期和运行期用的是同一份逻辑代码不会分裂成两套。这带来一个特别大的好处消除宏。以前想做调用式编译期计算只能用#define宏没有类型、没有作用域、可能被多次求值。constexpr函数有类型、有作用域、参数量级清楚还能被调试器识别。但凡有#define MAX(a,b) ((a)(b)?(a):(b))这种代码都值得认真考虑换成 constexpr 函数。4.3 C20 之后consteval 和 constinit 让语义更清晰consteval是强制编译期求值版本只用于函数。如果一个函数用consteval声明它不能被运行期调用实参必须是常量表达式consteval int cube(int x) { return x * x * x; } constexpr int a cube(3); // OK int n 3; int b cube(n); // 错误consteval 函数必须在常量求值上下文中调用什么时候需要它当函数内部依赖编译期才能完成的操作或者你非常确定该函数的意义只存在于编译期。我一般用在字符串哈希、编译期配置转换这种场景防止某次误调用把它拖进运行期。注意consteval和constexpr不能同时修饰一个函数。再加上constinit现代 C 的三件套可以这样总结const管只读constexpr管编译期常量consteval强制编译期函数求值constinit管静态变量的编译期初始化但不保证只读。它们分工完全不同混着用才是问题根源。5. 排查链路我的 constexpr 代码为什么编译不过前面讲原理这部分分享实战排查思路。我见过太多人把 constexpr 报错截图发到群里其实大部分问题就那几类。我把典型报错和一个完整排查过程放一起照着走基本能解决。5.1 常见编译错误全景典型报错信息大概率原因排查方向initializer element is not constant用非常量表达式初始化全局/static 变量检查初始化器是否调用非 constexpr 函数、访问运行期变量expression must have a constant value数组维度/模板参数/switch case 用了非常量表达式变量加 constexpr确认没有运行期依赖call to non-constexpr function fooconstexpr 函数里调用了非 constexpr 函数把被调用函数也改成 constexpr如果允许constexpr variable cannot have non-literal type变量类型不是字面量类型看看类型是否有非平凡析构/构造函数限制variable of non-literal type in constexpr functionconstexpr 函数体内声明了非字面量类型的局部变量C20 某些类型放宽但自定义类型仍受限constexpr function never produces a constant expression函数没有任何实参组合能在编译期求值检查是否依赖运行时设施比如rand()、地址比较一条经验报错信息行号往往指向 constexpr 变量声明那一行但真实病灶可能在初始化表达式深处。比如constexpr int bad get_number(); // get_number 不是 constexpr报错会直接打在这行可问题在get_number是不是常量表达式。排查时先问三个问题我在初始化什么初始化器里调用了谁被调用的那个函数/表达式是否满足常量表达式规则5.2 一个完整案例编译期求斐波那契数列假设我想写一个编译期算 Fibonacci 的函数第一版constexpr int fib(int n) { if (n 2) return n; return fib(n - 1) fib(n - 2); } static_assert(fib(10) 55);这在 C14 以后没问题因为函数体允许 if。但如果拿到 C11 里就报错“body of constexpr function not a return-statement”。旧标准里必须写成三元表达式constexpr int fib(int n) { return n 2 ? n : fib(n - 1) fib(n - 2); }接着我想用循环版本C14 之后也合法constexpr int fib_loop(int n) { if (n 2) return n; int a 0, b 1; for (int i 2; i n; i) { int tmp a b; a b; b tmp; } return b; } static_assert(fib_loop(20) 6765);这段代码在 C11 下会给出变量 a 不是字面量之类的报错因为函数体里连局部变量都不允许。很多人拿旧标准写新语法报错后第一反应是编译器坏了其实是被语言版本卡住了。接着我尝试在 constexpr 函数里用std::vectorconstexpr int sum_squares(int n) { std::vectorint v; // 取决于工具链和标准C20 起允许 constexpr vector for (int i 0; i n; i) v.push_back(i * i); int total 0; for (int x : v) total x; return total; }C17 需要报错“variable of non-literal type in constexpr function”因为那时std::vector不是字面量类型。C20 的工具链通常能过但注意 flip side编译期构造 vector 会显著增加编译负担东西虽好别滥用。C20 之前不能这么写不是语法问题是库类型根本不具备 constexpr 能力。再升级一个恶心的坑在 constexpr 函数里用了static局部变量C23 之前不可能编过constexpr int counter() { static constexpr int seed 1; // C23 前不能出现在 constexpr 函数体 return seed; }遇到这类报错别急着魔改先确认你用的标准版本。我的经验是项目里-stdc17的代码就别想着用 C20 的 constexpr vector升级标准版本时要顺带把 constexpr 边界一起 review。5.3 浮点、字符串、自定义类型的编译期求值边界浮点常量表达式是允许的但有个大坑编译器在编译期求值时可能使用扩展精度不同优化级别、不同平台计算结果可能不完全一致。如果你用 constexpr 浮点算完再跟某个字面量做static_assert可能产生玄学报错。我的建议是编译期浮点计算不要拿来跟精确小数做相等比较用static_assert(std::abs(result - expected) 1e-9)这类容差断言。字符串和自定义类型C20 之后范围扩大很多。std::string的构造、比较、拼接在 C20 起可以在常量表达式中使用自定义类型的字面量要求是拥有 constexpr 构造函数、平凡析构函数、成员类型都是字面量类型。C20 放宽了很多允许有析构函数具体是 C20 允许非平凡的 constexpr 析构我记得 P0784 允许 constexpr 析构函数在 C20。如果报错提示非字面量类型先把类的析构函数、成员变量类型过一遍。6. 结合搜索热度const 所有使用场景12 位置速查与选择规则网上搜const 所有使用场景的人特别多因为 const 出现的姿势实在太多。我结合自己整理代码笔记的习惯把 const 可能出现的 12 个位置一次性梳理出来顺便在每个位置点明这里要不要换成 constexpr。全局/命名空间作用域变量const int kGlobal 5;只读对象。若值是字面量且需要编译期可见写constexpr int kGlobal 5;更好。局部变量见 3.1按需选择。类的静态数据成员static const int n 5;或static constexpr int n 5;。C17 前 class 内整型静态常量可以初始化但 ODR-use 麻烦C17 后 constexpr 直接内联。类的非静态数据成员const int id;对象创建后该成员不可改必须在构造函数初始化列表里初始化。非静态成员不可能是 constexpr因为每个对象的值是运行期构造的。函数值传递形参void f(const int x)很少有意义函数内本来就是 x 的副本。一般用不着。指针的顶层/底层 constconst int* p表示指向 const 对象int* const p表示指针本身不可改const int* const p两者都不可改。这是 C 里最容易被考倒但最常见的场景。引用形参void f(const T t)是现代 C 最常用的防拷贝只读传参姿势。这里 const 跟 constexpr 无关形参不可能 constexpr。成员函数尾部 constint size() const表示 this 是const T*这个函数不会修改对象状态。它不是编译期相关但它是 const 语义的核心。函数返回值const T foo();对值类型基本没有意义C11 移动语义后更不建议const T foo();用于返回内部引用。若希望函数可编译期求值用constexpr T foo();。const_iterator vs const iteratorstd::vectorint::const_iterator it比const std::vectorint::iterator it更常用前者是指向的内容不能改后者是迭代器本身不能改。C 容器的 const 语义这里最容易混。模板参数中的 consttemplate typename T void f(const T t)配合模板实参推导能自动接收左值和右值。这不是编译期常量问题是泛型接口设计。const_castconst_castT用来去掉 const 属性是我在明确打破只读约束的逃生门。它和 constexpr 完全不搭一般不推荐在真正 const 的对象上使用否则是未定义行为。把 12 个位置过完你会发现绝大多数 const 场景属于接口语义——告诉读代码的人这里不可改跟编译期无关。只有少数几个位置静态成员、数组大小、模板非类型参数、全局常量才真正需要 constexpr。给一个我当时总结给自己的选择规则屡试不爽先问编译器在编译阶段必须知道这个值吗是数组大小、模板参数、static_assert、case 标签、constexpr 变量初始化用constexpr否再问这个对象需要只读语义吗是用const都不需要就普通变量。7. 最后再分享几个实战小技巧这篇写到这里核心概念和案例都过完了。最后分享三个我在实际项目里养成的习惯希望能帮你少走弯路。第一个习惯代码审查时把所有纯字面量初始化 只读的命名常量默认要求写成constexpr。比如const int kRetryCount 3;这种如果你打开文件看到直接改成constexpr往往没有任何副作用还能让读代码的人瞬间知道这是个编译期常量。而const就留给真正的接口语义函数参数、成员函数尾部、返回值引用。第二个习惯写完 constexpr 变量或函数后随手加一行static_assert作为编译期校验。这既是测试也是给编译器一个必须在编译期求值的强制上下文等于把我可能用错的风险提前引爆constexpr int kMaxFrameSize 4096; static_assert(kMaxFrameSize % 64 0, size must align to cache line); static_assert(fib(20) 6765, compile-time check);那句static_assert不仅仅是防错更是在告诉后来者这个值是被编译期计算过的别改成运行时变量糊弄我。第三个习惯升级标准时把 constexpr 边界一起过一遍。C17 到 C20 的变化里constexpr 函数能用的库类型扩展、try 块、consteval/constinit 都是大重点。我见过一个项目从 C17 升到 C20 后一堆std::vector在 constexpr 函数里突然能编过了结果编译时间从 2 分钟涨到 10 分钟——因为原本被拒绝的代码现在真的在编译期跑了。能力变强不一定是免费的编译期计算也要算成本。还有一个 C20 之后特别值得玩的新特性std::is_constant_evaluated()。它可以让你在一个函数里根据当前是不是编译期求值选择不同实现比如编译期用精确算法、运行期用快速近似算法。这种双模函数把 constexpr 的灵活度又往上推了一层新手可以先跳过但它确实是现代 C 编译期编程最有趣的角落。老实说const和constexpr的区别我在读标准之前也含糊了很多年。真正把它俩从都是表示常量的模糊认知里剥离开之后再看模板代码、再读标准库源码很多之前觉得纯属编译器脾气的报错都有了明确解释。希望这篇整理也能帮你把这一块彻底焊死。