constexpr 能力扩张:从 C++11 到 C++20 的编译期计算

constexpr 是 C++ 里最能体现「版本差异」的关键字:同一个函数,C++11 下连个 for 循环都不能写,C++20 下却能在里面 push_back 到 std::vector、调用 std::sort。这篇按标准版本把这条演进线捋直,重点是搞清楚编译期与运行期的边界到底在哪 :constexpr 的含义是「可以被编译期求值」,不是「必须」。

需要 C++20:这篇标了 // verify: std=c++20 的示例必须用 -std=c++20 编译;标 C++17 的示例是工作环境下的基线写法。

1. 引子:C++11 的 constexpr 连循环都不给

一个求和的 constexpr 函数,在 C++11 下是这样:

cpp 复制代码
// verify: std=c++11
// 片段(无 main,不进校验):C++11 的 constexpr 函数体只能是一条 return 语句
constexpr int sum_to(int n) {
    int s = 0;
    for (int i = 1; i <= n; ++i) {
        s += i;
    }
    return s;
}
// gcc-13.2.0 实测报错:
//   error: body of 'constexpr' function 'constexpr int sum_to(int)' not a return-statement

int s = 0; 是局部变量,for 是循环。两样在 C++11 的 constexpr 函数里都不许出现,所以只能写成递归:

cpp 复制代码
// 片段(无 main,不进校验):C++11 能接受的写法 ------ 把循环展开成递归
constexpr int sum_to(int n) {
    return n <= 0 ? 0 : n + sum_to(n - 1);
}

递归深度受 constexpr 求值步数限制,写起来也别扭。C++14 把这两条限制一起去掉 ,constexpr 函数体跟普通函数体一样,可以有局部变量、循环、多分支、if:

cpp 复制代码
// 片段(无 main,不进校验):C++14 起合法,也是现在该写的形态
constexpr int sum_to(int n) {
    int s = 0;
    for (int i = 1; i <= n; ++i) {
        s += i;
    }
    return s;
}

这一条(P0292 之前就有的 C++14 放宽)是后面所有能力的起点:函数体能写的东西越多,能在编译期算的东西就越多。

2. 演进表:C++11 → C++20

标准 新增的 constexpr 能力 影响的写法 特性测试宏
C++11 引入 constexpr 变量与函数;函数体只能是单条 return(外加 using / static_assert) constexpr int k = 42;、递归形态的 constexpr 函数 __cpp_constexpr = 200704
C++14 函数体放宽 :局部变量、循环、多分支、多次 return;constexpr 成员函数不再是隐式 const 终于能写循环版 constexpr,如 sum_to __cpp_constexpr = 201304
C++17 if constexpr 编译期分支;constexpr lambda (捕获的 lambda 也可 constexpr);constexpr 静态数据成员隐式 inline 模板里按类型分叉;把 lambda 直接当编译期函数的参数 __cpp_constexpr = 201603
C++20 常量求值里允许 new / delete (即 constexpr std::vector / std::string);大部分 <algorithm> 与 <numeric> 变 constexpr ;constexpr 虚函数与 try;新增 consteval、constinit 编译期建表、编译期排序、编译期字符串拼接 __cpp_constexpr = 201907(gcc-13.2.0 实测报 202002)、__cpp_consteval = 201811、__cpp_constinit = 201907

三个关键节点值得单独记:

  • C++17 的 if constexpr 解决的是模板里的「按类型分叉」,被丢弃的分支不参与实例化------它和 constexpr 变量/函数是两套机制,别混为一谈(《if constexpr:C++17 编译期分支,为什么它能取代 SFINAE》)有完整说明。
  • C++20 的 constexpr 容器 是这条线上最大的一步:std::vector、std::string 能用在常量求值里,意味着编译期可以「先用动态容器倒腾,再输出成静态结果」。
  • C++20 的 <algorithm> constexpr 化 让 std::sort、std::is_sorted、std::accumulate 全都能在编译期跑。下面这段是真跑过的:
cpp 复制代码
// verify: std=c++20
// 片段(无 main,不进校验):C++20 起 std::sort 可以在编译期执行
#include <algorithm>
#include <array>
#include <numeric>

constexpr std::array<int, 6> make_sorted() {
    std::array<int, 6> a{5, 3, 1, 4, 2, 0};
    std::sort(a.begin(), a.end());          // C++20:编译期排序
    return a;
}

constexpr auto kSorted = make_sorted();
static_assert(kSorted[0] == 0 && kSorted[5] == 5);
static_assert(std::accumulate(kSorted.begin(), kSorted.end(), 0) == 15);

官方文档:constexpr specifier --- cppreference

3. 编译期与运行期的边界:constexpr 是「可以」不是「必须」

这是全文最该记住的一句话。constexpr 函数同时是一个普通函数:如果所有实参都是常量表达式,求值可以发生在编译期;否则它就在运行期老老实实执行一遍。两类用法可以共用同一份代码:

text 复制代码
同一个 constexpr 函数的两条路(以 fib 为例)

                    constexpr long long fib(int n) { ...循环... }
                                   │
              ┌────────────────────┴────────────────────┐
              │                                         │
     常量表达式上下文                            普通运行期调用
     · static_assert(fib(10) == 55)              · fib(i),i 是运行期变量
     · std::array<int, fib(10)> a;               · 每次调用都真执行一遍
     · constexpr long long k = fib(20);
              │                                         │
              ▼                                         ▼
     编译期求值(常量折叠)                        运行期求值(普通调用)
     · 结果直接进目标码 / 只读段                  · 指令正常执行
     · 运行期 0 开销                              · 可以用运行期的数据
     · 能当模板参数、数组长度、case 标签           · 不能当模板参数
     · 不能用运行期的值当实参                      · 不能用 static_assert 校验
cpp 复制代码
// constexpr_both.cpp --- 编译: g++ -std=c++17 -Wall -O2 constexpr_both.cpp -o cb
#include <array>
#include <cstdio>

constexpr long long fib(int n) {          // 加了 constexpr 只是「允许」编译期求值
    long long a = 0;
    long long b = 1;
    for (int i = 0; i < n; ++i) {
        const long long next = a + b;
        a = b;
        b = next;
    }
    return a;
}

// 用法①:当编译期常量断言
static_assert(fib(10) == 55, "fib(10) 应该是 55");
static_assert(fib(20) == 6765, "fib(20) 应该是 6765");

// 用法②:当数组长度 / 模板参数
constexpr auto kFib15 = fib(15);
const std::array<long long, static_cast<std::size_t>(fib(10))> kArr{};

int main() {
    std::printf("编译期 fib(10) = %lld\n", fib(10));
    // 用法③:同一个函数,拿运行期的值调用,照样合法
    for (int i = 30; i <= 32; ++i) {
        std::printf("运行期 fib(%d) = %lld\n", i, fib(i));
    }
    std::printf("数组长度 = %zu, kFib15 = %lld\n", kArr.size(), kFib15);
}
text 复制代码
编译期 fib(10) = 55
运行期 fib(30) = 832040
运行期 fib(31) = 1346269
运行期 fib(32) = 2178309
数组长度 = 55, kFib15 = 610

一个 fib 顶三种活:编译期断言、模板参数、运行期计算。这就是 constexpr 与 consteval 的分水岭:前者是「可以」,后者是「必须」。

4. consteval 与 constinit:C++20 的两个新关键字

关键字 约束的是什么 运行期还能用吗 变量是否需要 const 典型用途
constexpr 变量必须是常量表达式初值;函数允许编译期求值 函数可以,变量不行 变量隐式 const 通用编译期常量与常量函数
consteval(C++20) 函数必须编译期求值(immediate function) 不行,一律编译期展开 不适用(只修饰函数) 保证「零运行期开销」的元编程入口
constinit(C++20) 静态存储期变量的初始化必须是静态初始化,禁止运行期动态初始化 变量可以随便改 不需要,变量仍是可写的 消灭「静态初始化顺序问题」(SIOF)
cpp 复制代码
// verify: std=c++20
// constexpr_consteval.cpp --- 编译: g++ -std=c++20 -Wall -O2 constexpr_consteval.cpp -o cc
#include <cstdio>

constexpr int sq(int x) { return x * x; }        // 允许编译期,也允许运行期
consteval int sq_now(int x) { return x * x; }    // 必须编译期

constinit int g_ready = sq(6);   // 静态初始化;g_ready 本身仍是可写变量

int main() {
    constexpr int a = sq(5);     // 编译期
    int n = 5;
    const int b = sq(n);         // 运行期调用同一个函数,合法

    g_ready += 1;                // constinit 不禁止写 ------ 它只管初始化时机

    std::printf("a=%d b=%d\n", a, b);
    std::printf("consteval sq_now(7) = %d\n", sq_now(7));
    std::printf("constinit g_ready = %d\n", g_ready);
    std::printf("__cpp_constexpr = %ldL\n", __cpp_constexpr);
    std::printf("__cpp_consteval = %ldL\n", __cpp_consteval);
    std::printf("__cpp_constinit = %ldL\n", __cpp_constinit);
}
text 复制代码
a=25 b=25
consteval sq_now(7) = 49
constinit g_ready = 37
__cpp_constexpr = 202002L
__cpp_consteval = 201811L
__cpp_constinit = 201907L

sq_now 的实参是字面量 7,所以这里合法。一旦把运行期的值喂给 consteval 函数,编译器当场拒绝:

cpp 复制代码
// 片段(无 main,不进校验):反例,不要这么写 ------ consteval 不接受运行期实参
consteval int sq_now(int x) { return x * x; }

int f(int n) {
    return sq_now(n);   // ✗ gcc-13.2.0 实测:error: 'n' is not a constant expression
}

constinit 的战场完全不同:它解决的是静态初始化顺序问题 (Static Initialization Order Fiasco)。两个翻译单元里的全局对象,如果初始化互相依赖,谁先谁后是未指定的。把初始化强制成「静态初始化」(编译期就能算出初值),就根本没有顺序问题。注意它和 constexpr 的分工:constexpr 变量既要求常量初值,又自带 const;constinit 只要前者,变量照样可写。

官方文档:consteval --- cppreference、constinit --- cppreference

5. C++20 的 constexpr 容器:编译期建一张查找表

C++20 允许在常量求值里 new / delete,于是 std::vector 进了编译期。但有一条硬规则必须记住:

常量求值期间分配的内存,必须在这次求值结束前释放------分配可以「短暂」,不能「逃逸」。

所以这段代码编不过:

cpp 复制代码
// 片段(无 main,不进校验):反例,不要这么写 ------ 分配跟着返回值逃逸出常量求值
#include <vector>

constexpr std::vector<int> make_table_bad() {
    std::vector<int> t{1, 2, 3};
    return t;             // ✗ 分配活过了这次常量求值
}

constexpr auto kBad = make_table_bad();
// gcc-13.2.0 实测:
//   error: 'make_table_bad()' is not a constant expression because it refers to
//          a result of 'operator new'

正确的姿势是:函数内部用 std::vector / std::string 尽情倒腾,最后把结果拷进 std::array 返回 。std::array 没有堆分配,才能成为真正的编译期常量对象。

cpp 复制代码
// verify: std=c++20
// constexpr_table.cpp --- 编译: g++ -std=c++20 -Wall -O2 constexpr_table.cpp -o table
#include <array>
#include <cstdio>
#include <vector>

namespace {

constexpr int kLimit = 1000;

// 纯函数(无副作用)才能在常量求值里用
constexpr bool is_prime(int n) {
    if (n < 2) return false;
    for (int d = 2; d * d <= n; ++d) {
        if (n % d == 0) return false;
    }
    return true;
}

// 内部用 vector 写埃氏筛(短暂分配,求值结束前销毁),返回 array 让它能当常量
constexpr std::array<bool, kLimit + 1> make_prime_table() {
    std::vector<bool> tmp(static_cast<std::size_t>(kLimit) + 1, true);
    tmp[0] = false;
    tmp[1] = false;
    for (int n = 2; n * n <= kLimit; ++n) {
        if (!tmp[static_cast<std::size_t>(n)]) continue;
        for (int m = n * n; m <= kLimit; m += n) {
            tmp[static_cast<std::size_t>(m)] = false;
        }
    }
    std::array<bool, kLimit + 1> out{};
    for (int n = 0; n <= kLimit; ++n) {
        out[static_cast<std::size_t>(n)] = tmp[static_cast<std::size_t>(n)];
    }
    return out;
}

constexpr auto kPrimeTable = make_prime_table();   // 编译期算好,运行期只是一张常量表

long long g_trial = 0;                             // 运行期试除计数
bool is_prime_rt(int n) {                          // 有副作用 → 只能运行期用
    if (n < 2) return false;
    for (int d = 2; d * d <= n; ++d) {
        ++g_trial;
        if (n % d == 0) return false;
    }
    return true;
}

}  // namespace

int main() {
    static_assert(kPrimeTable[997], "997 是素数");
    static_assert(!kPrimeTable[1000], "1000 不是素数");

    // 方案 A:查编译期的表 ------ 运行期一次除法都不做
    g_trial = 0;
    int hitsA = 0;
    for (int n = 2; n <= kLimit; ++n) {
        if (kPrimeTable[static_cast<std::size_t>(n)]) ++hitsA;
    }
    const long long trialA = g_trial;

    // 方案 B:每个数现算 ------ 每次都要试除
    g_trial = 0;
    int hitsB = 0;
    for (int n = 2; n <= kLimit; ++n) {
        if (is_prime_rt(n)) ++hitsB;
    }
    const long long trialB = g_trial;

    std::printf("表大小 = %zu 字节(常量对象,可直接放进只读段)\n", sizeof(kPrimeTable));
    std::printf("方案 A 查表 : 命中 %d 个素数, 运行期试除 %lld 次\n", hitsA, trialA);
    std::printf("方案 B 现算 : 命中 %d 个素数, 运行期试除 %lld 次\n", hitsB, trialB);
}
text 复制代码
表大小 = 1001 字节(常量对象,可直接放进只读段)
方案 A 查表 : 命中 168 个素数, 运行期试除 0 次
方案 B 现算 : 命中 168 个素数, 运行期试除 5288 次

两边命中数都是 168(π(1000) = 168,正确性由 static_assert 兜底),但方案 A 运行期一次试除都没做,方案 B 做了 5288 次整数除法。这就是「编译期算好」的收益量化:整数除法在 x86 上是几十个周期的指令,5288 次足够拉出肉眼可见的差距;而表的制造成本被彻底移到了编译期。

数字来自 gcc-13.2.0 -O2 的实测输出;换编译器/优化级别,试除次数不变(它只取决于算法),耗时变化另论。

官方文档:constexpr dynamic allocation 背景与规则 --- cppreference

6. 收益与代价:什么时候值得往编译期搬

编译期计算不是免费的午餐,它把开销从运行期搬到了编译期。而编译期的成本由写代码的人承担。务实地说:

维度 编译期算(constexpr / consteval) 运行期算
运行期开销 0(结果已是常量) 每次执行都要算
数据位置 常量对象,可放只读段(.rodata) 通常在堆或栈上
能否当模板参数 / 数组长度 能 不能
启动期成本 0(常量初始化,不参与动态初始化) 有;全局对象还要防 SIOF
编译时间 变长(求值步数越多越慢) 无影响
调试难度 高:编译期求值不能打断点,static_assert 只给一句消息 低:正常单步
代码可读性 下降:为了「能常量求值」常要迁就写法(例如必须有纯函数、不能用运行期数据) 好写
适用场景 真正的热路径、需要编译期常量的场合(表、掩码、类型计算) 其余全部情况

结论很简单:先看这个计算有没有必要进编译期 。判断标准有两条:它的结果是不是「编译期常量」(要当模板参数、数组长度、case 标签,就必须进),或者它是不是在热路径上被反复调用(进编译期等于把它从运行期账本上划掉)。除此之外,把逻辑留在运行期通常更好写、更好调;毕竟 Core Guidelines 的 Per.4 就是「别为了聪明而聪明」。

7. 完整示例:把编译期的表接上运行期的逻辑

cpp 复制代码
// verify: std=c++20
// constexpr_full.cpp --- 编译: g++ -std=c++20 -Wall -O2 constexpr_full.cpp -o full
#include <algorithm>
#include <array>
#include <cstdio>
#include <numeric>
#include <vector>

namespace {

constexpr int kMaxN = 64;

// ① 同一个函数:编译期当模板参数用,运行期照常调用
constexpr long long square(long long v) { return v * v; }

// ② 编译期建表,返回 std::array ------ 结果是一个真正的常量对象
constexpr std::array<long long, kMaxN + 1> make_squares() {
    std::array<long long, kMaxN + 1> out{};
    for (int i = 0; i <= kMaxN; ++i) {
        out[static_cast<std::size_t>(i)] = square(i);
    }
    return out;
}

// ③ C++20:常量求值里用 std::vector ------ 分配只要不逃逸就合法
constexpr int count_primes() {
    std::vector<int> tmp;
    for (int n = 2; n <= kMaxN; ++n) {
        bool p = true;
        for (int d = 2; d * d <= n; ++d) {
            if (n % d == 0) { p = false; break; }
        }
        if (p) tmp.push_back(n);
    }
    return static_cast<int>(tmp.size());   // 临时 vector 在求值内销毁
}

constexpr auto kSquares = make_squares();
constexpr int kPrimeCount = count_primes();
constexpr long long kSum = std::accumulate(kSquares.begin(), kSquares.end(), 0LL);

// ④ C++20 起 <algorithm> / <numeric> 也能在编译期跑
static_assert(std::is_sorted(kSquares.begin(), kSquares.end()), "平方表应单调递增");
static_assert(kSquares[64] == 4096, "64^2 = 4096");
static_assert(kSum == 89440, "0..64 的平方和是 89440");
static_assert(kPrimeCount == 18, "1..64 里有 18 个素数");
static_assert(square(9) == 81, "编译期调用同一个 square");

}  // namespace

int main() {
    std::printf("0..8 的平方: ");
    for (int i = 0; i <= 8; ++i) {
        std::printf("%lld ", kSquares[static_cast<std::size_t>(i)]);
    }
    std::printf("...\n");
    std::printf("平方和 = %lld(static_assert 已校验)\n", kSum);
    std::printf("1..%d 的素数个数 = %d(编译期算出)\n", kMaxN, kPrimeCount);

    long long side = 7;      // 运行期的值
    std::printf("编译期 square(9) = %lld,运行期 square(%lld) = %lld\n",
                square(9), side, square(side));
}
text 复制代码
0..8 的平方: 0 1 4 9 16 25 36 49 64 ...
平方和 = 89440(static_assert 已校验)
1..64 的素数个数 = 18(编译期算出)
编译期 square(9) = 81,运行期 square(7) = 49

这个程序把三条线并在一起:square 是「两种用法都行」的 constexpr 函数;make_squares 产出的 kSquares 是编译期常量对象;count_primes 内部用了 std::vector(C++20 的常量求值分配)。整段代码没有裸 new / delete,所有变量声明即初始化,能 constexpr 的都加了 constexpr,输出用 '\n' 不用 std::endl。符合 Core Guidelines 的 Con.1 与 ES.20。

8. 延伸阅读

本知识库内的相关篇目:

9. 一句话总结

constexpr 的演进就是一条不断拆限制的路:C++11 只能写单条 return,C++14 放开循环与局部变量,C++17 加上 if constexpr 和 constexpr lambda,C++20 让 std::vector / std::string / 大部分 <algorithm> 都能进常量求值,同时补上 consteval(必须编译期)和 constinit(只保证静态初始化)。 用的时候只要抓住一句话:constexpr 函数是「可以 在编译期求值」,不是「必须」------同一份代码既能在 static_assert 里当常量、当模板参数,也能拿运行期的值照常调用。C++20 里用容器建编译期查找表的硬规则是「分配不能逃逸 」:内部可以 push_back,最后必须把结果拷进 std::array 这类无分配的类型才能变成常量。收益是运行期开销归零、数据进只读段、结果能当模板参数;代价是编译时间、调试难度和可读性------只在真正的热路径或必须用编译期常量的场合才往编译期搬。

相关推荐
007张三丰1 小时前
C/C++ 内存管理详解:从内存分布到 new/delete 底层原理
java·c语言·c++·内存管理
三月微暖寻春笋2 小时前
【和春笋一起学C++】(七十二)类模板
开发语言·c++·实例·类模板·使用类模板·数组模板
xlq223222 小时前
全面复习4
c++
殷色玫瑰2 小时前
C++ STL:map 与 set 详解——从底层红黑树到实际应用
c语言·数据结构·c++·算法·visualstudio·rpc
yunwei372 小时前
eBPF 入门开发实践教程一:Hello World,基本框架和开发流程
linux·后端·性能优化
Tri_Function2 小时前
背包:从入门到实战
c++
j7~3 小时前
【C++ 标准项目】发布订阅消息队列 (篇七):项目创建与基础工具模块搭建
开发语言·c++·log4j·uuid·protobuf·helper工具·消息定义类型和交换机类型定义
Lazionr3 小时前
unordered_map和unordered_set的使用
开发语言·c++·算法
余槐i3 小时前
无慢查询 RT 却飙升:cProfile 定位 FastAPI 事件循环阻塞
后端·python·性能优化·fastapi·asyncio