Chapter 47: Constexpr STL
constexpr STL 讨论的主问题是:标准库容器和算法在什么条件下能进入常量求值,并把运行期常见的数据整理、校验和常量生成提前到编译期。这里的 constexpr 先指 C++ 语言的常量求值能力,再指标准库接口被标记为可在这种求值中调用。两层条件同时满足,std::vector、std::sort、std::array 这类对象和算法才会形成真正可用的编译期数据处理链。
本章用一个贯穿材料串起所有小节:把一组端口号在编译期排序、去重,并生成固定大小的 std::array。这个例子刻意使用 std::vector 作为临时工作区,因为它能暴露 constexpr STL 的核心边界:对象可以在常量求值中创建、修改和销毁,动态分配产生的存储需要在同一次常量求值结束前释放,最终保存下来的结果通常应落到无动态所有权的对象中。
#include <algorithm>
#include <array>
#include <cstddef>
#include <vector>
constexpr auto build_ports() {
std::vector<int> ports{443, 80, 443, 22, 8080, 80};
std::sort(ports.begin(), ports.end());
auto last = std::unique(ports.begin(), ports.end());
ports.erase(last, ports.end());
std::array<int, 4> result{};
for (std::size_t i = 0; i < ports.size(); ++i) {
result[i] = ports[i];
}
return result;
}
constexpr auto allowed_ports = build_ports();
static_assert(allowed_ports[0] == 22);
static_assert(allowed_ports[1] == 80);
static_assert(allowed_ports[2] == 443);
static_assert(allowed_ports[3] == 8080);
读完整章后,应能按固定顺序判断一段 STL 代码能否放进常量求值:先看标准版本和库实现是否提供对应 constexpr 接口,再看调用点是否要求常量表达式,再看容器元素、分配、析构和算法前提是否满足,最后决定结果能否作为编译期常量长期保存。
47.1 constexpr 容器支持
constexpr 容器支持首先是一条对象生命周期规则。容器对象在常量求值中经历构造、修改、迭代、析构,编译器要能在求值器内部完整模拟这些动作。这个能力的入口可以从 C++ 语言的 constexpr 说明看起:cppreference 对 constexpr 的说明 明确指出,constexpr 声明使变量或函数结果可以出现在常量表达式中;标准库容器要进入这条路径,构造函数、成员函数、迭代器操作和析构路径都要能被常量求值器接受。
容器支持可以分成三类判断。第一类是固定存储容器,例如 std::array。它自身没有动态容量管理,元素直接成为对象的一部分,所以在常量求值中最容易成立。第二类是非拥有视图,例如 std::span、std::string_view。它们保存指针和长度,能否成为安全常量取决于被指向对象的生命周期。第三类是动态容器,例如 std::vector、std::string。这类对象需要分配和释放存储,常量求值器需要追踪分配是否成对结束。
贯穿材料中的 ports 属于第三类。std::vector<int> 在 build_ports 内部构造,std::sort 和 std::unique 通过它的迭代器修改元素顺序,erase 缩短逻辑区间。函数返回前,ports 的析构函数运行,内部动态存储随之释放。最后保存到 allowed_ports 的对象是 std::array<int, 4>,它把最终结果放进固定大小对象中。这个状态转换体现了动态容器在编译期的安全用法:动态工作区服务计算过程,固定结果承接计算产物。
常见实现形状可以按普通 vector 理解:对象内部仍然维护起始指针、结束指针和容量末尾指针;push_back、列表构造、erase 等成员函数仍然调整这些状态;区别在于这些动作由常量求值器模拟。标准语义决定接口能否在常量表达式中使用,具体标准库实现决定某个编译器版本是否已经落地对应标记和内部路径。
这张图的重点是“同一次求值”。std::vector 可以作为常量求值里的临时容器,但动态存储的所有权需要在求值完成前闭合。cppreference 对 std::vector 的说明 也把这一点写成边界:所有成员函数自 C++20 起可以是 constexpr,因此能在常量表达式求值中创建和使用;但把 std::vector 本身定义成 constexpr 变量通常会失败,因为动态分配的存储需要在同一次求值中释放。
47.2 constexpr algorithm
constexpr algorithm 的核心是算法循环可以由编译器执行。算法本身仍然接收半开区间、迭代器、比较器或谓词;编译期版本只改变执行位置。std::sort 在运行期接收 first 和 last,在编译期也接收同样的迭代器范围;区别是比较、交换、递归或循环调整都需要满足常量求值规则。
贯穿材料中的 std::sort(ports.begin(), ports.end()) 有四个前提。第一,ports.begin() 和 ports.end() 返回的迭代器要能在常量求值中复制、比较、递增和解引用。第二,元素类型 int 的比较和移动赋值可以在常量求值中完成。第三,std::sort 对随机访问迭代器的要求由 std::vector<int>::iterator 满足。第四,整个调用发生在 constexpr 函数被常量表达式上下文触发时,也就是 constexpr auto allowed_ports = build_ports(); 这一行。
算法进入编译期后,复杂度语义仍然存在。std::sort 仍然需要多次比较和交换,std::unique 仍然线性扫描相邻元素,erase 仍然移动尾部元素并缩短区间。编译期执行把这部分工作从运行期转移到编译阶段,但它会增加编译器常量求值负担。小规模表生成、协议字段归一化、静态校验适合放进这条路径;大规模数据处理会把时间成本转移到编译过程。
constexpr bool contains_port(int value) {
constexpr auto ports = build_ports();
return std::binary_search(ports.begin(), ports.end(), value);
}
static_assert(contains_port(443));
static_assert(!contains_port(25));
这个例子继续复用 build_ports 的结果,并用 std::binary_search 做编译期存在性检查。它能成立的原因来自上一章的二分前提:输入区间已经排序,比较器与排序阶段一致,迭代器能被算法推进。constexpr algorithm 没有降低算法契约,反而让契约错误更早暴露。区间未排序时,运行期代码可能只在特定输入下表现异常;放入 static_assert 后,错误会在编译阶段变成可定位的失败。
标准版本需要单独检查。C++20 引入了大量 constexpr 算法支持,cppreference 的算法页 在 feature-test 表中列出 __cpp_lib_constexpr_algorithms,其中 C++20 对应值为 201806L。工程代码中需要兼容多个编译器和标准库版本时,可以把 feature-test macro 作为条件编译入口,而非只看 -std=c++20 开关。
47.3 编译期计算
编译期计算的工程价值在于把“可由源码常量决定的结果”提前固化。这里的输入来自字面量、模板参数、constexpr 对象或其他常量表达式;处理过程使用普通 C++ 控制流和 STL 算法;输出进入 constexpr 变量、static_assert、模板实参或分支选择。贯穿材料把端口列表变成固定数组,就是一个最小完整链条。
这条链条适合三类任务。第一类是表生成,例如把枚举、协议号、状态码、字符分类表整理成只读数组。第二类是校验,例如检查配置项是否排序、是否重复、是否落在合法范围。第三类是小规模变换,例如归一化大小写、排序字段、构造查找表。它们的共同点是输入规模有限,输出稳定,失败可以在编译阶段直接阻断构建。
下面的函数把“端口号合法性”从运行期判断提前到编译期。它没有引入新容器,只复用上文得到的 allowed_ports。
constexpr bool all_ports_in_range() {
constexpr auto ports = build_ports();
return std::all_of(ports.begin(), ports.end(), [](int port) {
return port > 0 && port < 65536;
});
}
static_assert(all_ports_in_range());
这个检查的输入是 build_ports 生成的固定数组,处理过程是 std::all_of 的线性扫描,输出是布尔常量。失败时,编译器给出的信息通常只指向 static_assert,所以工程代码应把断言拆成有语义的函数名或断言消息。编译期失败的诊断信息未必比运行期日志详细,写清函数边界和断言目标可以降低定位成本。
constexpr 函数仍然可以在运行期调用。build_ports() 写成 constexpr 表示它具备进入常量求值的资格;当调用点使用普通局部变量接收结果时,编译器可以选择运行期求值。需要强制编译期执行时,应把结果放入 constexpr 变量、static_assert、数组大小、模板非类型实参,或者使用 C++20 的 consteval 函数表达“必须立即求值”的接口意图。
consteval auto required_ports() {
return build_ports();
}
constexpr auto checked_ports = required_ports();
consteval 适合包装必须在编译期完成的生成入口。它比 constexpr 更强,因为每次调用都要求产生常量表达式。工程上可以把内部处理函数保留为 constexpr,方便测试和运行期复用;把对外的策略入口写成 consteval,用来表达构建期配置、协议表、编译期路由这类强约束。
47.4 constexpr vector 的限制
constexpr vector 的限制来自动态存储。std::vector 的对象状态本身只是一组指针和 allocator 状态,真正的元素通常在动态存储中。C++20 允许常量求值中出现动态分配,但这种分配在标准化路径上按“瞬时分配”处理:分配发生在某次常量求值内部,也要在那次求值结束前释放。WG21 P0784R7 对更多 constexpr 容器的动机和边界做过说明,其中 transient allocation 指的就是常量求值期间分配并在同一次求值完成前释放的存储。
贯穿材料选择返回 std::array<int, 4>,正是为了让 std::vector 的动态所有权停留在函数内部。下面这种形态体现了失败边界:试图把持有动态存储的 std::vector 自身保存成 constexpr 对象。
constexpr auto make_vector() {
std::vector<int> values{1, 2, 3};
return values;
}
// constexpr auto values = make_vector();
// 常量求值失败:返回对象需要携带 vector 的动态存储离开本次求值。
这段代码的关键问题在返回值的所有权。values 在函数内部通过动态分配持有元素;返回 std::vector<int> 意味着最终 constexpr 对象也要指向那块动态存储。C++20 路径没有把这类非瞬时分配提升成可长期保存的静态对象,所以该形态通常无法通过。把结果拷贝进 std::array、std::tuple、聚合结构或其他固定存储对象,才是更稳定的工程形态。
constexpr vector 还受到元素类型限制。元素构造、赋值、比较、析构都要能在常量求值中执行。std::vector<std::unique_ptr<int>> 这类拥有运行期资源的类型即使能在普通运行期代码中安全管理资源,也很难成为常量求值中合适的元素。编译期容器更适合存放整数、枚举、轻量结构体、固定字符串片段、编译期可比较的 key/value 记录。
allocator 也会形成边界。默认 std::allocator 在 C++20 的 constexpr 动态分配路径中有标准支持;自定义 allocator 如果调用运行期系统接口、保存运行期状态、或者成员函数没有 constexpr 资格,就会阻断容器进入常量求值。源码阅读时应把 allocator 看成容器常量求值能力的一部分,而非外部细节。
工程判断顺序可以固定为六步。先确认标准版本和 feature-test macro;再确认容器成员函数、迭代器、算法是否有 constexpr 支持;然后确认调用点确实要求常量表达式;接着确认元素类型的构造、移动、比较和析构路径;再确认动态分配在同一次求值中释放;最后确认返回值保存的是固定结果,或者调用方只拿到布尔校验结果。
47.5 C++20 / C++23 STL 演进
C++20 是 constexpr STL 的分水岭。语言层面扩大了常量求值对动态分配、析构和若干表达式的支持;库层面给算法、迭代器、内存工具、字符串和动态数组补上了大量 constexpr 标记。__cpp_lib_constexpr_vector、__cpp_lib_constexpr_string、__cpp_lib_constexpr_algorithms、__cpp_lib_constexpr_memory 这类 feature-test macro 可以用来判断某个标准库是否提供对应能力。cppreference 的 feature-test 页面 列出了这些宏在 C++20 中的取值,例如 __cpp_lib_constexpr_vector 为 201907L,__cpp_lib_constexpr_algorithms 为 201806L。
C++20 之前,constexpr 更适合表达标量计算、固定数组、轻量结构体和简单函数。C++14 放宽了函数体限制,循环和局部变量让编译期计算变得可读;C++17 增强了 constexpr lambda 等能力;但动态容器和大量 STL 算法仍然受限。C++20 把普通 STL 编程模型的一部分搬进常量求值,使“临时 vector 加 algorithm,再生成固定表”成为可维护写法。
C++23 的变化更像继续铺路。语言层面继续放宽 constexpr 函数体限制,并引入 if consteval,让同一个函数可以在编译期路径和运行期路径之间显式分流。库层面延续 ranges、迭代器和工具类型的改进,但它没有把所有标准容器统一变成可长期保存的编译期对象。阅读代码时应把 C++23 看成表达和诊断能力增强,而非把动态所有权边界整体取消。
| 能力方向 | C++20 前常见形态 | C++20 后常见形态 | 工程判断 |
|---|---|---|---|
| 固定数据 | std::array、枚举、字面量 | 继续作为结果承载对象 | 适合长期保存编译期结果 |
| 动态工作区 | 手写固定缓冲或两次扫描 | 临时 std::vector、std::string | 适合在一次常量求值内完成处理 |
| 算法处理 | 手写循环较多 | std::sort、std::unique、std::all_of 等 | 算法前提和迭代器能力仍要满足 |
| 版本判断 | 主要看语言标准 | 结合 feature-test macro | 需要检查编译器和标准库两侧支持 |
| 编译期分流 | 模板特化和 traits | if consteval、consteval 入口 | 适合明确表达编译期专用路径 |
这个演进过程给源码阅读一个稳定信号:看到某个 STL 接口被标记为 constexpr,应继续追问它依赖的内部操作是否也能进入常量求值。以 vector 为例,构造、迭代器、元素访问、修改和析构都要打通;以算法为例,比较器、投影、交换、移动、递归或循环都要打通。单个函数签名上的 constexpr 只是入口标记,完整路径还包括元素类型、allocator 和调用上下文。
47.6 Compile-Time STL Safety Checks and Constant Generation
编译期 STL 最适合沉淀两类工程能力:安全校验和常量生成。安全校验把“源码中已经能确定的错误”提前到构建阶段;常量生成把“运行期每次都会得到同一结果的准备工作”变成一次编译期产物。两者都依赖同一个判断:输入是常量,处理路径可常量求值,输出类型适合长期保存。
贯穿材料可以扩展成一个小型安全入口。假设端口表来自源码内配置,编译期先排序去重,再检查数量和范围。运行期代码只读取 allowed_ports,无需再做重复归一化。
constexpr bool is_unique_sorted() {
constexpr auto ports = build_ports();
return std::is_sorted(ports.begin(), ports.end()) &&
std::adjacent_find(ports.begin(), ports.end()) == ports.end();
}
static_assert(is_unique_sorted());
这个检查的价值在于把容器和算法契约变成构建条件。std::is_sorted 验证顺序,std::adjacent_find 验证相邻重复。由于 build_ports 已经执行排序和去重,这个断言也可以当成回归保护:后续有人修改生成逻辑或输入规模时,错误会在编译阶段出现。
常量生成应控制输出形态。输出对象最好具备固定布局、无动态所有权、易于被链接器放入只读段、易于被运行期代码按引用读取。std::array、聚合结构、std::tuple、枚举映射表都适合。std::string_view 可以作为输出的一部分,但它指向的字符存储必须有足够生命周期,例如字符串字面量或静态数组。
编译期 STL 的可复用判断顺序如下。
- 输入常量性:输入来自字面量、模板参数、
constexpr对象或可常量求值函数。 - 接口支持:容器成员函数、迭代器操作、算法和谓词都具备对应标准版本下的
constexpr支持。 - 对象生命周期:临时对象在同一次常量求值中完成构造、修改和销毁。
- 动态存储闭合:动态分配只作为临时工作区存在,并在求值完成前释放。
- 输出可保存:最终结果使用固定存储或无所有权视图,并且视图绑定的底层对象生命周期充足。
- 编译成本可控:输入规模和算法复杂度适合放在编译阶段,构建时间增长有明确收益。
这套顺序能把 constexpr STL 从“能不能编译”的试错,转成源码级判断。先定版本和调用上下文,再看对象生命周期和动态分配,最后看输出形态和构建成本。按这个顺序检查,std::vector 可以成为编译期处理的临时工具,std::array 可以成为稳定结果,STL 算法可以成为编译期校验和生成的公共执行层。
最小自检任务
阅读下面代码,判断两个声明分别会发生什么,并说明原因。
#include <array>
#include <vector>
constexpr auto make_fixed() {
std::vector<int> values{3, 1, 2};
std::array<int, 3> result{};
for (std::size_t i = 0; i < values.size(); ++i) {
result[i] = values[i];
}
return result;
}
constexpr auto make_dynamic() {
std::vector<int> values{3, 1, 2};
return values;
}
constexpr auto fixed = make_fixed();
// constexpr auto dynamic = make_dynamic();
答案要点
fixed 可以作为常量表达式结果成立。make_fixed 内部使用 std::vector<int> 作为临时工作区,动态存储随函数内局部对象析构在同一次常量求值中释放;返回值是 std::array<int, 3>,没有携带 vector 的动态存储离开求值过程。
dynamic 通常会在 C++20 / C++23 的常量求值规则下失败。make_dynamic 返回 std::vector<int> 本身,返回对象需要保留动态分配得到的元素存储;这类动态存储没有在同一次常量求值结束前被释放。正确改法是返回固定存储结果,或者把 vector 保留在 constexpr 函数内部,只输出布尔校验、整数结果或固定布局对象。
判断步骤是:先确认 std::vector 成员函数在目标标准库中具备 C++20 constexpr 支持;再确认调用点 constexpr auto 强制常量求值;接着检查动态分配是否在本次求值内闭合;最后检查返回对象是否携带动态所有权。make_fixed 在最后两步通过,make_dynamic 在动态所有权保存阶段失败。
本章知识点总结
- 主问题:
constexpr STL判断的是标准库对象和算法能否在常量求值中完成完整对象生命周期。 - 调用上下文:
constexpr函数具备编译期资格,只有常量表达式上下文才会强制它产生编译期结果。 - 固定容器:
std::array这类固定存储对象适合承接编译期计算的最终结果。 - 动态工作区:
std::vector可以在 C++20 起作为常量求值中的临时工作区使用。 - 分配闭合:动态分配需要在同一次常量求值中释放,返回结果应摆脱动态所有权。
- 算法契约:
constexpr algorithm仍然遵守区间、迭代器、比较器和复杂度前提。 - 编译成本:编译期 STL 把执行成本转移到构建阶段,适合小规模稳定计算。
- 安全校验:
static_assert配合 STL 算法可以把排序、去重、范围合法性提前到编译期检查。 - 版本边界:C++20 是动态容器和大量算法进入常量求值的关键版本,C++23 继续增强表达和诊断能力。
- 宏检查:
__cpp_lib_constexpr_vector、__cpp_lib_constexpr_algorithms等 feature-test macro 比单看编译参数更可靠。 - allocator 边界:自定义 allocator 的
constexpr能力会影响动态容器能否进入常量求值。 - 判断顺序:先看版本和接口支持,再看调用上下文、元素类型、动态分配、输出形态和编译成本。