拓冰建站拓冰建站
首页 / 资讯中心 / 正文

C++20/23 编译期计算新范式:constexpr 与 std::ranges 实战

写这篇的时候我刚把项目里一个用了三年多的模板元编程解析器换成 constexpr std::ranges 的实现。以前那套 TMP模板元编程代码变量模板套着递归特化稍一改动就得连编译带猜老半天。换完之后第一感觉是C20 以后编译期计算终于可以像写普通算法一样写了std::ranges 比我想象中更早地具备了在 constexpr 上下文里干活的能力。这篇文章不聊空洞的语法特性就写我踩过的边界、验证过的代码和一些实际项目里能用上的结论。1. std::ranges 凭什么能进编译期从标准演进到库设计1.1 C20 把可编译期执行的库这个边界往前推了一大步先说一个很多人容易忽略的事实constexpr 关键字本身的历史是分化的。C11 时期的 constexpr 函数被严格限制为单条 return 语句循环、分支、局部变量统统不允许所以那个年代想在编译期算点什么基本只能走模板递归和 metafunction 的老路。到了 C14constexpr 函数体放宽到普通函数循环和局部变量都合法了理论上你可以在编译期写一个 for 循环。但限制还在constexpr 函数里能用的库函数极其有限因为标准库里的算法大多数都没有标记 constexpr。C20 是关键转折。它给标准库打了一个大补丁大量算法被标记为 constexpr而且 std::ranges 作为新家族在标准草案阶段就已经把 constexpr 属性写进了规格说明。换句话说ranges 算法从出生那天起就是为编译期可用设计的。再叠加 C20 的 consteval强制编译期求值的函数、consteval 变量你其实已经可以在编译期跑一段相当完整的算法流水线了。我在实际项目里验证过的结论是C20 的 ranges 算法中凡是只通过迭代器/哨兵读取数据、不涉及动态内存分配的几乎都能在编译期使用。而 C23 又补了一批包括排序和相关视图适配器。所以现在用 Clang 16 或 GCC 13已经能体会到把运行时代码直接搬到编译期的手感了。1.2 ranges 算法的无所有权设计是编译期友好的关键为什么偏偏 std::ranges 这么适合编译期这要从它的设计根子上看。ranges 的理念是算法不直接接受容器而是接受一个范围range它本质上是迭代器和哨兵的抽象。视图view是轻量级的、不持有底层数据的包装器移动、拷贝成本很低甚至很多视图就是一个迭代器对。这种设计天然规避了动态内存分配问题而动态内存分配恰恰是 constexpr 函数在 C20 里的大忌。拿 std::views::filter 来说它不生成新容器只是在遍历的时候跳过不满足谓词的元素。它的内部结构里没有堆分配的存储只有原始范围迭代器、谓词对象通常是个 lambda、以及必要的嵌套迭代器状态。这些在常量表达式求值期间都是可跟踪的编译器可以直接把它们展开成编译期的数据流。举个直观对比标准库容器 std::vector 在 C20 里不能在 constexpr 上下文中使用因为它的析构和扩容涉及 allocate/free。但 std::array、std::string_view、原生数组这些不拥有外部资源的类型完全可以在编译期和数据交互。ranges 算法就没有和容器绑死它操作范围所以跟 std::array、string_view 配合起来非常顺滑。1.3 C20 与 C23 两代标准下ranges 的 constexpr 家底对比表我得强调一个被很多人忽视的版本差异。下面这张表是按标准规格和实际编译器支持情况整理的建议存一下因为你可能在 C20 模式下编译会失败压根没想到是标准版本的问题算法/视图C20 是否 constexprC23 是否 constexpr我的实测备注std::ranges::find / find_if / find_if_not是是直接用没毛病std::ranges::count / count_if是是直接用没毛病std::ranges::all_of / any_of / none_of是是编译期断言常用std::ranges::for_each是是注意谓词要 constexpr 友好std::ranges::is_sorted是是编译期校验有序性很好用std::ranges::lower_bound / upper_bound / binary_search是是二分族在 C20 就能用std::ranges::sort / stable_sort否C20 不可用是C20 下会报not constexprstd::ranges::copy是是注意目标迭代器也要编译期合法std::views::filter / transform / take / drop / reverse是是迭代器操作是 constexpr 的std::views::split否C20 没有是C23 才加入 constexpr splitstd::ranges::fold_left否没有这个算法是C23 新算法编译期聚合很好用std::ranges::to否是C23 才支持编译期转容器有限制这张表说明了什么说明如果你想在 C20 下做编译期排序不能用 std::ranges::sort要么手写循环要么换用查找、统计这类算法。而一旦切到 C23 模式面前的局面就打开了。我用 GCC 13 / Clang 16 实测C23 模式下 ranges::sort 在 constexpr 上下文中可以正常工作只是编译时间会肉眼可见地增加这个后面详细说。2. 编译期能跑哪些算法一张实测可用的清单2.1 可以在 static_assert 里直接用的算法实际写代码时static_assert 是编译期计算最直观的出口。你算出一个 constexpr 变量然后用 static_assert 去校验它如果断言失败编译器直接给出编译错误。我分享几个已经跑通的例子。第一个是编译期判断数组有序并用二分查找定位元素#include algorithm #include array #include ranges #include cstdint constexpr std::arrayint, 6 data{ 1, 3, 5, 7, 9, 11 }; // 编译期断言data 必须是非递减有序的 static_assert(std::ranges::is_sorted(data)); // 编译期二分查找 constexpr auto it std::ranges::lower_bound(data, 7); static_assert(it ! data.end()); static_assert(*it 7);这里 lower_bound 返回的是一个迭代器而在 constexpr 上下文中迭代器之间的比较、解引用都是常量表达式允许的。编译器会在编译期完成整个二分查找过程直接计算出 it 指向的位置。第二个是编译期统计满足条件的元素个数constexpr std::arrayint, 8 nums{ 1, 2, 3, 4, 5, 6, 7, 8 }; // 编译期统计偶数个数 constexpr auto evenCount std::ranges::count_if(nums, [](int n) { return n % 2 0; }); static_assert(evenCount 4);C17 之后lambda 在满足条件时默认是 constexpr 的所以它可以直接作为编译期谓词。这里不需要显式加 constexpr 关键字只要捕获的变量和函数体里调用的操作在常量表达式中合法即可。第三个是编译期全量判定比如检查配置数组中所有值都落在某区间constexpr std::arrayint, 5 config{ 10, 20, 30, 40, 50 }; static_assert(std::ranges::all_of(config, [](int v) { return v 0 v 100; }));这些代码看起来和你平时写的运行时 ranges 代码几乎没有区别区别只是出现在 constexpr 函数或 static_assert 里。这是 std::ranges 编译期计算最大的吸引力——不需要重新学一套 DSL领域专用语言用你已经熟悉的算法语言就能做编译期计算。2.2 最容易翻车的一批排序和查找的版本差异我前面已经提过std::ranges::sort 在 C20 里不是 constexpr。这个坑我实际踩过当时项目还锁在 C20我试图在一个 constexpr 函数里对一个 std::array 排序然后 static_assert 排序结果编译直接报错。报错信息大概长这样error: call to non-constexpr function std::ranges::sort(...)所以提醒一下如果你们编译器标准还停留在 C20编译期排序要么自己写 constexpr 排序函数要么用查找/统计类算法绕过去。如果是 C23那么可以这样写consteval std::arrayint, 5 sort_compile_time() { std::arrayint, 5 arr{ 5, 3, 1, 4, 2 }; std::ranges::sort(arr); return arr; } constexpr auto sorted sort_compile_time(); static_assert(sorted[0] 1); static_assert(sorted[4] 5);consteval 会强制这个函数只在编译期执行如果任何地方试图在运行时调用它编译就是错误。这在我们希望某些校验绝对不能被带到运行时的场景下非常有用。还有一个小细节std::ranges::lower_bound、binary_search 这类二分家族在 C20 里就是 constexpr 了所以如果你只是查找不需要为排序发愁。但如果你既想排序又想查找在 C23 之前只能手动排序或者用 std::array 加 constexpr 自定义函数。2.3 视图适配器的 constexpr 边界视图适配器方面C20 里 std::views::filter、transform、take、drop、reverse 的迭代器操作已经是 constexpr 了。这意味着你可以在 constexpr 函数里构建管道constexpr std::arrayint, 6 nums{ 1, 2, 3, 4, 5, 6 }; constexpr int sumEvenSquares() { int sum 0; for (int v : nums | std::views::filter([](int n) { return n % 2 0; }) | std::views::transform([](int n) { return n * n; })) { sum v; } return sum; } static_assert(sumEvenSquares() 56); // 4 16 36注意一个细节视图管道本身是惰性的上面的 constexpr 函数里我用了 for 循环去消费它。如果不消费管道里的 lambda 可能不会被完整展开自然也就不会被编译器真正求值。这个坑我后面专门讲。还有一个使用陷阱某些视图适配器在编译期构建时要求传入的范围也必须能在常量表达式中求值。如果你把视图接收的参数设为一个普通函数的局部变量而不是 constexpr 表达式那当然没法在编译期展开。所以编译期使用视图通常需要把底层数据也设为 constexpr 或 consteval 的产物。3. 实例拆解用 std::ranges 在编译期校验和变换数据3.1 场景一编译期判断数组是否有序并做二分查找实际项目中我遇到最多的编译期计算需求是对配置数据做合法性校验。这些数据往往在编译期就确定了如果能在编译期就把非法配置拦截下来运行时就可以去掉一堆防御性检查。比如项目里有一个静态查找表它要求按 key 升序排列以便运行时做二分查找。以前的做法是写测试用例在运行时校验或者靠代码 review 保证有序。现在可以直接这样#include algorithm #include array #include ranges #include string_view struct Entry { std::string_view key; int value; }; constexpr std::arrayEntry, 4 table{{ {alpha, 1}, {beta, 2}, {gamma, 3}, {delta, 4}, }}; // 编译期检查key 必须有序 static_assert(std::ranges::is_sorted(table, {}, Entry::key));这里用到了 ranges 算法的投影projection特性。第三个参数传了一个成员指针 Entry::key它告诉算法比较的时候把每个 Entry 投影到它的 key 成员上去比较。这个特性在编译期同样有效。再拓展一点如果我还想编译期验证 key 都是唯一的constexpr bool allKeysUnique() { for (std::size_t i 1; i table.size(); i) { if (table[i].key table[i - 1].key) { return false; } } return true; } static_assert(allKeysUnique());运行时二分查找可以直接基于这张表进行因为有序性已经在编译期被证明过了。3.2 场景二用视图管道在编译期提取并聚合数据视图管道的真正价值在于它可以把筛选、变换、截断这些步骤串联起来而不用创建中间容器。编译期也一样编译器可以直接在编译期完成整个数据流推导。我在一个工具项目里需要生成一张偶数平方表并且只取前 3 个然后把这些数累加成一个编译期常量#include algorithm #include array #include ranges constexpr std::arrayint, 10 source{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 }; constexpr int sumFirstThreeEvenSquares() { int result 0; int count 0; for (int v : source | std::views::filter([](int n) { return n % 2 0; }) | std::views::transform([](int n) { return n * n; }) | std::views::take(3)) { result v; count; } return result; } static_assert(sumFirstThreeEvenSquares() 4 16 36);这个例子里管道执行了筛选偶数、平方、取前三个三步最后聚合。如果在 C23 下还能更省事用 fold_left 一步到位// C23 写法 constexpr int sumCpp23 std::ranges::fold_left( source | std::views::filter([](int n) { return n % 2 0; }) | std::views::transform([](int n) { return n * n; }) | std::views::take(3), 0, std::plus()); static_assert(sumCpp23 56);fold_left 在 C23 里是 constexpr 的而且算法签名也接受视图作为第一个参数。这种写法非常贴近把编译期计算写成管道表达式的直觉。3.3 场景三consteval 配合 ranges 解析编译期字符串字符串处理也是编译期计算的常见需求。string_view 本身支持 constexpr 构造你可以把字符串字面量直接转成 string_view然后交给 ranges 算法处理。我写过一个编译期校验版本号格式的函数版本号由三段数字组成形如 1.22.333中间用点分隔。我要求编译期验证这三个字段都是纯数字。C20 还没有 std::views::split所以我用 ranges::count_if 做基础校验#include algorithm #include ranges #include string_view consteval bool isDigit(char c) { return c 0 c 9; } consteval bool isValidVersion(std::string_view version) { // 非空 if (version.empty()) return false; // 第一个字符不能是 . if (version.front() .) return false; // 统计点个数必须是 2 个点 const auto dotCount std::ranges::count(version, .); if (dotCount ! 2) return false; // 除了点之外其他字符都必须是数字 return std::ranges::all_of(version, [](char c) { return c . || isDigit(c); }); } static_assert(isValidVersion(1.22.333)); static_assert(!isValidVersion(1.22.33a)); static_assert(!isValidVersion(1.22));这段代码的妙处在于consteval 函数强制所有调用都发生在编译期而 isValidVersion 内部使用的 count、all_of 都是 constexpr 算法。如果某个版本号写错了编译直接失败而不是等到运行时崩溃。C23 下可以升级为真正按点拆分并逐段校验// C23 写法 consteval bool isValidVersionCpp23(std::string_view version) { auto parts version | std::views::split(.) | std::views::transform([](auto part) { return std::string_view(part.begin(), part.end()); }); auto it parts.begin(); std::size_t fieldCount 0; for (auto part : parts) { if (part.empty()) return false; if (!std::ranges::all_of(part, isDigit)) return false; fieldCount; } return fieldCount 3; } static_assert(isValidVersionCpp23(1.22.333)); static_assert(!isValidVersionCpp23(1.22.33a));views::split 返回的子范围不能直接隐式转 string_view需要手动构造这是一个小坑我在代码里已经给出了处理方法。4. 惰性求值带来的编译期陷阱4.1 管道没有被消费谓词里的错误不会爆出来这是我在实际项目中真实遇到过的问题写了一个 constexpr 函数里面构建了一个 views::filter 管道但没有遍历它然后 static_assert 了一个跟管道无关的 true 表达式。结果谓词里写错了一个变量名编译器却没有报错。原因很简单视图是惰性的它只是包装了迭代器逻辑并不会在创建时执行谓词。常量的 constexpr 求值也是一样如果你没有消费这个视图编译器不会实例化谓词的函数体自然不会发现其中的错误。我第一次遇到时挺困惑的后来想明白了这跟模板惰性实例化是同一套逻辑。类模板成员函数只有被使用时才会被实例化视图的 constexpr 路径也只有被消费时才会完整展开。所以检查一下你的代码如果一个 constexpr 函数创建了管道但没有遍历那你最好主动消费它或者用 static_assert 强制某些结果成立。比如consteval bool testFilter() { auto view std::views::iota(1) | std::views::filter([](int x) { return x 0; }) | std::views::take(3); // 必须消费否则上面 filter 的 lambda 错误可能不会被发现 return *view.begin() 1; } static_assert(testFilter());写完之后可以故意把 lambda 里的 x 改成 x yy 未定义你会发现编译错误只有在消费视图时才爆出来。4.2 constexpr 上下文里不能随便捕获变量lambda 捕获在 constexpr 上下文里是有讲究的。最简单的情况是捕获编译期常量constexpr int threshold 3; constexpr auto view nums | std::views::filter([threshold](int n) { return n threshold; });这种情况下threshold 是 constexpr 变量捕获它没有问题。但如果捕获的是一个普通局部变量而这个变量不是在常量表达式中初始化的编译期求值就会失败constexpr int badExample() { int x 5; // 常量表达式求值中x 本身是允许的局部变量 auto view nums | std::views::filter([x](int n) { return n x; }); int sum 0; for (int v : view) sum v; return sum; }这个例子反而是合法的因为 constexpr 函数里的局部变量可以被 lambda 按值捕获只要整个函数在常量表达式求值时能一步步算清楚。真正不能捕获的是那些运行时才知道的值比如从 std::cin 读取的值、从外部传入的非 constexpr 参数。一旦被捕获进 lambda再传给 constexpr 函数编译器会直接报错。所以我的经验是编译期使用的 lambda 尽量用无捕获或者只捕获 constexpr 变量代码意图最清晰也最容易排查问题。4.3 视图不能当容器使取元素和长度的小心机视图看起来像容器但它没有 operator[]也没有 size()。这个在编译期尤其容易犯迷糊。比如我想取编译期视图中第三个元素不能直接写 view[2]。正确的是constexpr int thirdElement() { auto view nums | std::views::filter([](int n) { return n % 2 0; }) | std::views::take(3); auto it view.begin(); std::advance(it, 2); return *it; }在编译期求值里std::advance 会展开成迭代器递增操作。如果迭代器是随机访问迭代器它是 O(1) 的如果像 filter_view 这种不是随机访问的迭代器那它会逐个遍历。编译期不需要关心效率差异但要注意迭代器类型是否支持相应操作。还有一点视图可能没有稳定的 size()。你无法简单地通过 std::ranges::size(view) 拿到元素个数因为 filter 无法预知多少个元素会通过筛选。这在编译期也一样如果你试图对 filter 视图调用 size编译器大概率会报错。解决方法是自己遍历计数或者用 std::ranges::distanceconstexpr int countElements() { auto view nums | std::views::filter([](int n) { return n % 2 0; }); return std::ranges::distance(view.begin(), view.end()); } static_assert(countElements() 4);std::ranges::distance 对 filter 视图这种不知道大小的范围会通过遍历来计算长度在编译期同样可用。5. 编译期计算后的产物性能与编译时间5.1 static constexpr 变量到底会留下什么很多人担心编译期计算是不是会导致生成的二进制文件变大答案取决于你怎么使用计算结果。如果你只是把编译期计算结果用于 static_assert那么中间变量不会出现在最终二进制中。编译器在常量求值阶段算完丢掉临时值只保留断言是否通过。比如前面 sumEvenSquares 的例子静态断言通过后sum 这个值在运行时根本不存在。如果你用 static constexpr 变量保存结果那么它一般会被放进只读数据段.rodata。比如constexpr int kVersionSum sumFirstThreeEvenSquares();这个变量会在编译后存在于二进制中但因为是编译期常量不会被修改也不会有初始化代码相比运行时计算省掉了构造函数调用和初始化开销。如果你直接用 constexpr std::array 保存一张表比如把编译期计算出的查找表作为全局变量那么它会被嵌入二进制这是必要的开销。我做过对比同一个 1024 项查找表运行时初始化比编译期常量表二进制体积差不多但启动时间明显好于运行时初始化版本特别是在嵌入式环境。5.2 编译时间膨胀的控制策略编译期计算的代价是编译时间。如果你只是做 static_assert 做几个比较、count_if编译时间几乎可以忽略。但如果你在编译期排序一个很大的 std::array情况就不同了。我在 Ubuntu 上用 GCC 13 做了一次测试在 C23 模式下对一个 100 个元素的 std::array 做 constexpr std::ranges::sort编译时间明显增加大约增加了 0.3 到 0.5 秒。换到 1000 个元素编译时间会涨到数秒甚至更多具体取决于编译优化选项。这是因为编译期排序需要把整棵比较决策树展开到常量求值流程里。我个人的建议是编译期计算的数据规模控制在几百个元素以内超过这个量级要评估是否值得。优先使用查找、统计、变换类算法它们比排序类算法更轻量。对于需要编译期排序的场景可以把排序结果缓存到 constexpr 变量而不是每次调用 consteval 函数都重新排序。虽然 constexpr 求值结果通常有缓存但代码结构上明确写出只算一次更稳。把复杂的编译期计算逻辑放到单独的头文件里避免多个编译单元重复实例化。如果你发现某个 constexpr 计算在大型项目里拖慢了构建可以用 concept 做编译期分支或者考虑把一部分计算从编译期移到运行时用 static const 变量保存运行时首次计算的结果。工程上不一定所有计算都要编译期性价比最重要。5.3 适合用 ranges 做编译期计算的业务场景根据我这段时间的实践下面这些场景用 std::ranges 做编译期计算收益最大场景具体做法收益配置表合法性校验constexpr 数组 static_assert all_of/count_if/是_sorted非法配置在编译期拦截协议描述或枚举映射constexpr std::array 编译期查找运行时零查找开销生成小型查找表views::iota transform constexpr 数组消除运行时初始化字符串格式校验string_view ranges::all_of views::split(C23)提前暴露格式错误编译期选择配置分支if constexpr ranges 计算结果死代码自动折叠这些场景的共同点是数据在编译期已经确定运行时又需要高频访问或严格保证正确性。把它们搬到编译期可以让你的程序更早地暴露问题同时还能减少运行时校验代码一举两得。我还有一个体会如果你以前写过大量的 TMP 代码比如用 integer_sequence 类型递归做编译期数组变换换成 constexpr ranges 之后代码可读性的提升是革命性的。TMP 的调试体验极差编译器报错信息动辄几百行而 constexpr ranges 报错更像是普通算法在编译期出错了定位问题快得多。6. 我在项目中替换 TMP 的真实体会最后聊点实际的。我这边的项目里原来有一段 TMP 代码作用是编译期把一个枚举数组的每个值乘 2 并反转顺序生成另一张表。老代码用了 std::index_sequence配合模板递归特化大概 40 行模板代码很少有人敢去动它。换成 C23 的写法之后constexpr std::arrayint, 4 raw{ 1, 2, 3, 4 }; constexpr auto processed [] { std::arrayint, 4 result{}; std::ranges::copy( raw | std::views::reverse | std::views::transform([](int x) { return x * 2; }), result.begin()); return result; }(); static_assert(processed[0] 8 processed[3] 2);从 40 行模板递归变成十几行普通代码静态断言直接验证结果。编译时间没有明显变化因为没有涉及排序。要说有没有坑也有。比如我在一个 constexpr 函数里试图用 std::views::split 按逗号拆分字符串再用 std::ranges::to 转回 std::string结果发现 ranges::to 在编译期遇到过与容器的动态分配告警没法直接用。这类编译期生成/拥有容器的操作仍然受限。所以在 C23 里编译期的数据归宿主要还是 std::array、string_view 这类轻量类型如果你需要编译期生成一个未知长度的字符串列表还真得遵循手动递归和固定大小缓冲的老办法。另外一个小建议如果你打算在项目里大规模运用这套编译期计算请务必把编译器标准明确设为 C23至少是 C20并且使用较新的编译器版本。我踩过的坑里有一半是标准版本理解问题另一半是编译器实现 bug 或支持不完整。GCC 13、Clang 16 之后的版本对 ranges 和 constexpr 的支持已经比较稳定实在遇到诡异问题先检查是不是编译器太老。对于看到这里的朋友如果你们项目还在 C17也别急着失望。可以先尝试在一些小工具模块里把条件数组做成 constexpr 手写 constexpr for 循环把 C20 的先行经验积累起来等升级标准的时候std::ranges 的编译期大门会直接为你敞开。编译期计算从来不只是为了秀技巧它是把数据可靠性往前移、把运行时开销往后压的一把好用工具而 std::ranges 让这把工具第一次变得这么顺手。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门