
模板编译期排序算法听起来有点玄乎说白了就是让 C 在编译阶段把“顺序问题”一次性算完。过去一年我在给内部模块化框架做启动依赖解析时反复用到这个能力框架要在 main 函数之前确定所有服务节点的初始化顺序依赖关系跨了十几个模块如果把这些依赖排序放在编译期做排序结果会直接变成类型或常量的一部分运行期拿到的就已经是排好序的结果连防御性校验代码都能省掉大半。这篇文章不打算堆概念而是把我实际用过的两条实现路线——模板元编程式的 TypeList 快排和 C14 之后更流行的 constexpr 函数式排序——连同踩过的坑一起写出来。适合正在写模板库、想做编译期配置排序、或者单纯想理解“模板递归到底怎么工作”的 C 开发者。1. 编译期排序算法到底解决什么问题1.1 三个关键词拆开看“模板编译期排序算法”这个短语可以拆成三块模板、编译期、排序算法。模板在 C 里承担的是类型级函数角色。普通函数接收运行时变量、返回运行时对象模板接收的是类型或常量表达式输出的是另一个类型、常量或者类型序列。比如std::vectorT传进去一个 int得到的是std::vectorint这个“计算”发生在编译期。编译期指的是这一切都发生在编译器生成目标代码之前。模板实例化、constexpr 函数求值、static_assert 检查都属于编译期行为。排序算法则是很经典的那几个思路快排、选择排序、插入排序、归并排序。把这三块组合起来就是在编译器解析代码期间对一串类型或者一串编译期常量执行排序并把排序结果固化到类型系统或常量表里。用装修来类比运行期排序是家具已经运到现场工人现场搬来搬去搬得再快也是入住之后的事。编译期排序则是在设计图纸阶段就把每个家具摆在哪、按什么顺序进场写死交付时装修顺序已经是确定的。省的不只是现场搬家的时间还消除了“搬家顺序出错”的可能。1.2 实际场景和适用边界我整理了一下实际能用上的典型场景场景排序对象收益TypeList 类型列表排序类型本身后续元函数拿到的顺序确定运行期零开销编译期配置项排序整型常量、enum 值启动阶段直接用有序常量表省掉一次运行期 sorttuple / 结构体成员重排类型减少 padding 带来的内存浪费依赖关系 / 初始化顺序类型或优先级常量依赖错误在编译期暴露而不是运行期崩溃这些场景的共同点是顺序本身是固定的、已知的、不会在运行期变化的。如果顺序依赖用户输入或者外部配置文件那就老老实实去运行期排别硬套编译期。但编译期排序也有非常明显的边界。模板递归展开的实例化数量往往比运行期排序高一个甚至几个数量级类型数量一大编译时间和内存占用就非常难看。我的经验是几十个类型以内的排序用编译期很舒服上百个除非有硬性需求否则建议先跑一次编译期计时再做决定。真正做模板库的人会把这套东西封装在内普通业务代码不会直接跟几十个上百个类型列表打交道。2. 两条路线先搞清楚再动手2.1 模板元编程和 constexpr 函数的分工要做编译期排序有两条思路完全不同的路线。第一条是纯模板元编程C11 时代就有。核心手段是模板递归加局部特化写的像一堆struct之间互相嵌套引用最终通过using type ...把排序结果吐出来。这条路线最大的优势是“直接操作类型”可以用它对 TypeList 里的类型重新排序不限于数字。缺点是代码很不直观一个排序算法能被递归模板拆得面目全非排错也痛苦。第二条是 constexpr 函数C14 放宽了 constexpr 的限制之后开始流行。它长得跟普通排序函数一模一样循环、局部变量、数组下标赋值全部可用只要所有操作路径都能在常量表达式里求值就行。这条路线最大的优势是“可读性好”可以直接在编译期对一个constexpr数组排序。缺点是它天然面向值不直接面向类型想对类型列表重排需要先把类型映射成某种可比较的键排完再映射回去中间桥接代码照样要走模板。两条路线不是互斥的。我现在的习惯是排数值、排 enum、排常量用 constexpr 函数排类型、排元函数依赖链用模板元编程。两者各管一半谁也不抢谁的活。2.2 先准备这些基础工具不管走哪条路线下面几个基础工具都会反复用到。#include type_traits #include utility #include cstddef // 最简单的类型列表 templatetypename... Ts struct TypeList {}; // std::integral_constant把一个编译期常量打包成类型 templatetypename T, T v using integral_constant std::integral_constantT, v; // C11 可用的选择类型元函数替代 std::conditional_t templatebool B, typename T, typename F struct If { using type T; }; templatetypename T, typename F struct Iffalse, T, F { using type F; }; templatebool B, typename T, typename F using If_t typename IfB, T, F::type;TypeList是所有类型操作的容器。std::integral_constant则常用来做排序比较器的返回值lessA, B::value要么是 true 要么是 false正好对应模板分支选择。后面所有模板递归本质上都在不停判断这个 true / false再决定把类型放进哪一边。3. 路线一TypeList 上的模板元编程快排3.1 基本功Prepend、Concat、Partition模板元编程版排序第一步不是实现排序而是实现几个最基础的列表操作。Prepend把新类型插到类型列表头部。Concat把两个类型列表拼成一个。Partition则负责快排里的分区给定一个基准类型 pivot把列表里的元素按比较结果分成两支一支在 pivot 前面一支在 pivot 后面。// 前插 templatetypename T, typename List struct Prepend; templatetypename T, typename... Ts struct PrependT, TypeListTs... { using type TypeListT, Ts...; }; templatetypename T, typename List using Prepend_t typename PrependT, List::type; // 拼接 templatetypename L1, typename L2 struct Concat; templatetypename... Ts, typename... Us struct ConcatTypeListTs..., TypeListUs... { using type TypeListTs..., Us...; }; templatetypename L1, typename L2 using Concat_t typename ConcatL1, L2::type; // 分区按 Compare 把元素分成 “小” 和 “大” 两组 templatetypename Pivot, typename List, templatetypename, typename class Compare struct Partition; templatetypename Pivot, templatetypename, typename class Compare struct PartitionPivot, TypeList, Compare { using Left TypeList; using Right TypeList; }; templatetypename Pivot, typename Head, typename... Tail, templatetypename, typename class Compare struct PartitionPivot, TypeListHead, Tail..., Compare { using Rest PartitionPivot, TypeListTail..., Compare; using RestLeft typename Rest::Left; using RestRight typename Rest::Right; using Left If_tCompareHead, Pivot::value, Prepend_tHead, RestLeft, RestLeft; using Right If_tCompareHead, Pivot::value, RestRight, Prepend_tHead, RestRight; };这里我故意没有用std::conditional_t而是用自己写的If_t。原因很实际这套写法可以完整在 C11 下编译不依赖 C14 的 trait 别名。实际工程里如果编译器统一支持 C17直接换成std::conditional_t也没问题。Partition的递归逻辑是这类模板的样板先处理剩余的Tail...得到Rest再处理当前Head。每次把Head前插到对应分组里。注意它每处理一个元素就实例化一层Partition所以分区这一段的模板实例化深度是 O(N) 的N 是列表长度。3.2 把快排写成类型元函数有了Prepend、Concat、Partition快排本身就比较直白了取第一个元素当 pivot对剩余元素分区然后递归排序左右两组最后拼起来。// 默认比较器按类型大小升序 templatetypename L, typename R struct size_less : std::integral_constantbool, (sizeof(L) sizeof(R)) {}; templatetypename List, templatetypename, typename class Compare size_less struct QuickSort; templatetemplatetypename, typename class Compare struct QuickSortTypeList, Compare { using type TypeList; }; templatetypename Head, typename... Tail, templatetypename, typename class Compare struct QuickSortTypeListHead, Tail..., Compare { using Part PartitionHead, TypeListTail..., Compare; using LeftSorted typename QuickSorttypename Part::Left, Compare::type; using RightSorted typename QuickSorttypename Part::Right, Compare::type; using type Concat_tLeftSorted, Prepend_tHead, RightSorted; }; templatetypename List, templatetypename, typename class Compare size_less using QuickSort_t typename QuickSortList, Compare::type;整个过程可以理解为Head是基准Partition把剩下元素拆成Left和Right然后递归对两边排序最后把LeftSorted、pivot、RightSorted拼接。这里Prepend_tHead, RightSorted等价于把 pivot 放到右侧排序结果前面再整体拼到左边后面。验证一下using Sorted QuickSort_tTypeListdouble, char, int; static_assert(std::is_same_vSorted, TypeListchar, int, double);在size_less这个默认比较器下结果是char, int, double跟运行期快排的预期一致。这个static_assert就是编译期排序最迷人的地方排序结果在编译期就已经被验证根本轮不到运行期出错。3.3 模板深度的坑和性能预期模板元编程版快排有个绕不开的痛点模板实例化深度和数量都不是广播意义上的“快排 O(N log N)”。分区递归深度是 O(N)排序递归深度最好情况和运行期快排一样是期望 O(log N)但最坏情况下同样会退化到 O(N)。所以对超过编译器默认递归上限的列表很容易直接报错。GCC 默认模板深度上限一般是 900Clang 是 1024MSVC 旧版本也有类似限制。如果你一定要排很长的列表可以调g -ftemplate-depth3000但调高上限只是治标。模板深度每加一层编译器内存占用都在涨真到了几千层的级别编译时间可能从秒级变成分钟级。我的建议是模板元编程排序只处理你能完全控制长度的列表比如几十个类型以内的 type list。想排几百个编译期常量请走 constexpr 路线。再补充一个性能认知Prepend每次操作都会重新铺开一遍类型参数包Partition处理 N 个元素时左右分组通过前插不断构造新列表最终实例化数量会达到 O(N²) 级别。这是模板元编程的固有代价不要试图用一个漂亮的O(N log N)复杂度去套它。真优化可以引入惰性包装把递归结果延迟实例化但后端代码会复杂很多小规模场景完全不值得。4. 路线二用 constexpr 函数做编译期排序4.1 为什么 C14 之后这条路越来越香C11 时代的 constexpr 函数限制非常死函数体只能是一条 return 语句不能声明变量不能写循环不能改局部状态。想在里面实现排序只能靠递归嵌套表达式写出来的东西比模板递归还难读。C14 放开了一大截限制。constexpr 函数里允许声明局部变量、允许 for 循环、允许修改局部对象。这等于说你可以用普通排序函数的写法直接跑在编译期。对业务代码来说这比模板元编程友好太多。到了 C20 之后标准库里的很多算法都被标记为 constexpr连std::sort都可以在常量表达式里调用。所以现在做编译期值排序优先考虑 constexpr 函数几乎是唯一合理的选择。4.2 实现一个可编译的 constexpr 选择排序为了避免std::array在某些标准版本下operator[]不是 constexpr 的坑我习惯用一个极简的封装结构templatetypename T, std::size_t N struct CArr { T data[N]; }; templatetypename T, std::size_t N constexpr CArrT, N constexpr_sort(CArrT, N arr) { for (std::size_t i 0; i N; i) { std::size_t k i; for (std::size_t j i 1; j N; j) { if (arr.data[j] arr.data[k]) { k j; } } if (k ! i) { T tmp arr.data[i]; arr.data[i] arr.data[k]; arr.data[k] tmp; } } return arr; } constexpr CArrint, 5 kInput{{5, 3, 4, 1, 2}}; constexpr auto kSorted constexpr_sort(kInput); static_assert(kSorted.data[0] 1); static_assert(kSorted.data[1] 2); static_assert(kSorted.data[2] 3); static_assert(kSorted.data[3] 4); static_assert(kSorted.data[4] 5);上面这段代码要求 C14 以上编译但没有任何第三方依赖。它就是一个普通的双层选择排序区别只在于所有变量和数组都是常量表达式整个函数在编译期完成求值。static_assert验证的就是排序结果。如果你已经统一 C17可以换成std::array代码更简洁。但要注意 C17 之前std::array的下标访问不是 constexpr这是很多人一编译就报错的原因。4.3 从整型序列到 C20 的标准算法constexpr 路线最常打交道的对象是std::integer_sequence。它是标准库提供的编译期整数包非常适合做索引映射和启动顺序表。templatetypename T, T... Ints constexpr auto sorted_int_seq(std::integer_sequenceT, Ints...) { return constexpr_sort(CArrT, sizeof...(Ints){{Ints...}}); } constexpr auto kSeq sorted_int_seq(std::integer_sequenceint, 5, 1, 3, 2{}); static_assert(kSeq.data[0] 1); static_assert(kSeq.data[1] 2); static_assert(kSeq.data[2] 3); static_assert(kSeq.data[3] 5);这比模板递归版看着舒服多了。你要做的就是定义好输入包调用排序函数拿到CArr常量数组。不管底层排的是选择排序还是别的编译器都会在常量求值阶段把结果算出来。如果你是 C20 用户甚至可以直接写#include algorithm #include array constexpr std::arrayint, 4 sort_me(std::arrayint, 4 arr) { std::sort(arr.begin(), arr.end()); return arr; } constexpr auto kArr sort_me({4, 1, 3, 2}); static_assert(kArr[0] 1 kArr[3] 4);C20 让std::sort在常量表达式里可用这在标准库层面省去了自己写排序循环的必要。不过要注意编译期的std::sort展开同样需要时间和空间不比手写排序快多少它的价值在于代码维护成本低不用为了一个编译期排序去单独维护一套算法代码。5. 实战案例把编译期排序用起来5.1 重排 tuple 字段减少结构体 padding这是一个我实际验证过的场景。std::tuple在主流实现里会按照模板参数顺序存储成员而成员顺序不对会导致 padding 浪费。以std::tuplechar, double, int为例在 x86-64 GCC 12 下大小是 24 字节。char 之后为了对齐 double 要补 7 字节int 排完还要再补尾。如果把成员按对齐大小降序重排成std::tupledouble, int, char大小是 16 字节直接省下 8 字节。用模板编译期排序这件事可以自动完成templatetypename L, typename R struct size_desc : std::integral_constantbool, (sizeof(L) sizeof(R)) {}; templatetypename List struct TypeListToTuple; templatetypename... Ts struct TypeListToTupleTypeListTs... { using type std::tupleTs...; }; using RawTuple std::tuplechar, double, int; using SortedTuple typename TypeListToTuple QuickSort_tTypeListchar, double, int, size_desc ::type; static_assert(sizeof(SortedTuple) sizeof(RawTuple));这里我只是把QuickSort_t按size_desc大小降序排了 TypeList再把排好的类型列表转换成std::tuple。static_assert直接证明重排后更小。必须强调std::tuple的内存布局标准并不保证这只是主流 libstdc / libc 的实际行为。如果你要的是跨编译器的硬保证那应该用结构体手动排列成员。但作为编译期排序的实战演示它足够直观。5.2 生成编译期启动顺序表另一个我经常用的场景生成启动服务的优先级表。假设每个服务有一个编译期优先级 enum你希望在编译期就把启动顺序算好运行期直接按表初始化。#include array #include cstddef enum class Service : int { A 3, B 1, C 2, }; constexpr CArrint, 3 kInitOrder{{ static_castint(Service::C), static_castint(Service::A), static_castint(Service::B), }}; constexpr auto kOrder constexpr_sort(kInitOrder); static_assert(kOrder.data[0] 1); static_assert(kOrder.data[1] 2); static_assert(kOrder.data[2] 3);运行期kOrder.data[0]、kOrder.data[1]、kOrder.data[2]就已经按 1、2、3 的顺序排好了。这样做的好处是如果未来有人往配置里加了低优先级项编译器会重新计算这个常量数组不会出现运行期顺序漏更新的问题。只要排序后的结果是被整个程序共享的固定顺序这个模式就适用。注意别拿它处理用户输入决定的顺序那属于运行期问题。5.3 进阶依赖拓扑排序的思路TypeList 快排能覆盖很多场景但依赖拓扑排序稍微特别一点它不一定是一维的大小或优先级比较而是“A 依赖 BB 依赖 C”这样一张图。模板元编程同样能把拓扑排序编译期化。思路是每个节点类型内部定义一个依赖列表比如using Deps TypeListB, C;然后通过 DFS 遍历依赖图用“访问完所有依赖之后再记录当前节点”的方式生成顺序。因为模板递归本身就是深度优先展开所以这套逻辑天然适合写成元函数。真要写起来比快排复杂不少核心是维护Visited集合和Stack集合还得处理循环依赖。我的建议是先别上来写模板递归先在纸上把 DFS 的步骤拆清楚再用类型列表模拟栈。编译期循环依赖的检测是一个非常好的模板元编程练手题排在“给类型排序”之上。另外提一句如果项目里已经依赖 BoostBoost.Hana提供了现成的hana::tuple_t和hana::sort处理类型排序比你手写 QuickSort 省力得多。小项目不想引入 Boost 再自己造轮子手写模板方案还是有价值的大项目里能少维护一段元编程就少维护一段。6. 踩坑记录与调试技巧速查6.1 模板实例化深度超限这是模板元编程排序最常见的报错。GCC 报错里一般会出现template instantiation depth exceeds maximum of 900Clang 则是recursive template instantiation exceeded maximum depth of 1024。原因很简单列表过长或者递归路径太深。先不要急着调-ftemplate-depth应该检查列表长度和快排的 pivot 选择。如果输入列表本身就有几百个类型即使调高了也没意义考虑改用 constexpr 路线或者重新设计问题规模。如果确实需要调GCC 用-ftemplate-depth3000Clang 用-ftemplate-depth 3000。调完之后优先跑一次小用例看编译时间别让表面能编译骗过去。6.2 编译时间和核心体积同步飙升模板元编程排序的实例化数量不是广播意义的O(N log N)实际展开的模板类数量很容易到O(N²)。列表稍微长一点-ftime-report里就能看到template instantiation耗时明显偏高。我排查这类问题时通常先做两件事第一把Partition、QuickSort的模板参数减少去掉不必要的默认模板参数第二把列表分块排序再合并。不过分块合并本身也是元编程复杂度上去了收益未必明显。最有效的优化永远是缩小 N。另外模板元编程中每个分支都会实例化。即使If_tfalse, Prepend_tHead, RestLeft, RestLeft最终选的是RestLeft上面的Prepend_tHead, RestLeft在模板实参里已经实例化了。想让未选中的分支不实例化得把分支包成懒加载类型这就是元编程里“惰性实例化”的玩法。小项目用不到先了解根因就好。6.3 类型打印与断言技巧编译期排序写错了编译器给的类型往往又长又绕。我调试时最爱用的是这个经典技巧templatetypename T struct TD;然后用一个故意的报错让它把类型吐出来using Sorted QuickSort_tTypeListdouble, char, int; TDSorted type_dump_here; // 编译器错误信息里会显示完整类型这比逐层 inspect 直观得多。配合static_assert(std::is_same_v...)可以逐步验证每一步排序结果。另一个常用技巧是给比较器加一个“可传导性”测试。编译期排序要求比较器满足严格弱序很多自定义比较器写了但忘了处理相等情况会导致排序结果不稳定甚至递归形态异常。比如比较sizeof时两个类型相等大小less和greater返回 false它俩的相对顺序就取决于分区前插方向这就是为什么我说模板元编程版快排默认不保证稳定。6.4 编译器差异和常见问题速查表最后把我实际踩过的一些问题整理成速查表方便你对照排查现象可能原因解决方案template instantiation depth exceeds maximum列表过长或递归退化缩小 N、调整 pivot、调大 depth 限制C14 下std::array下标访问报非 constexprstd::array::operator[]在 C14 并非 constexpr换 C17或用自己的 CArr 包裹原生数组编译期排序结果和预期顺序不一致比较器不满足严格弱序补全相等情况定义明确的方向排序后等价键相对顺序乱掉前插操作导致分组内逆序改用 append 或接受不稳定排序编译时间激增实例化数量到达 O(N²)拆分列表、延迟实例化、减小规模std::sort在 constexpr 里报错使用了低于 C20 的标准升级标准或手写 constexpr 排序编译器差异上GCC 和 Clang 对模板元编程的报错可读性都一般但 Clang 的彩色诊断通常更友好。MSVC 在某些版本对可变参数模板的实例化深度处理不一样同一段代码可能在 GCC 上编译过、MSVC 上崩。如果你需要跨平台建议在 CI 里同时跑两个编译器专门把编译期排序的代码单独编一遍。最后说一个我自己的习惯编译期排序模块要单独放一个头文件里面只保留最小依赖不掺运行时逻辑。这样任何项目引入它编译期负担集中在额外开销最小的位置排查问题也能直接进元函数内部。排序规模超过 50 的时候我会顺手写一个“编译耗时”的注释提醒自己这地方不是免费的午餐。