ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++模板元编程实战:编译期排序算法与TypeList设计

C++模板元编程实战:编译期排序算法与TypeList设计 最近我在维护一个内部通信框架时遇到了一个很实际的问题事件回调的注册表需要在启动前把所有处理器按优先级排好而“优先级”来自类型的固有属性。如果放在运行时排序每次启动都要多跑一段循环还要忍受动态分配和额外的依赖如果写死顺序每加一个类型就得手改一坨代码。最后我把目光放到了模板编译期排序算法上——它能让编译器在编译现场把类型列表排好编出来的程序直接带着一个已经定序的清单进入运行阶段。这篇文章想聊的就是这件事什么是编译期排序我需要排序时如何用模板写出稳定可用的算法以及编译期排序在真实项目里该不该用、怎么用。内容适合正在接触模板元编程的人也适合那些已经会在 C11/14/17 里写点 traits但没真正把“类型作为数据”玩起来的读者。1. 为什么要把排序搬进编译器1.1 一个真正出现过的场景我之前维护过一个模拟的“消息分发中枢”消息类型有一百多种每种类型带一个优先级标签。我需要做一张表把类型按优先级从高到低排好让分发函数按这个顺序去匹配处理器。优先级是编译期就能确定的只是当时的工程里没人把这个顺序编译出来所有人都在运行时通过std::sort排序一个“类型指纹 优先级”的数组。这套做法的问题不在排序本身而在“顺序”是运行期才产生的。数据要放进内存排序要执行比较函数初始化路径上会多出一层对象构造和函数调用如果这个模块会被频繁地动态加载那每次加载都要重新算一遍。更难受的是按优先级排好的顺序本来是整个模块稳定性的基础你却不希望在运行时有任何机会发生变化。于是我把“类型列表”抽象出来一个TypeListint, string, char然后在编译期调用某个排序模板得到一个排好序的TypeList。整个过程没有任何循环在运行期执行也没有任何对象被构造。最终生成的分发表只需要按这个类型清单从上往下展开即可。1.2 编译期排序和运行期排序的分界线很多人一听到“编译期排序”就会想到 CPU 指令和算法复杂度。实际上在模板元编程里复杂度度量的是“模板实例化的次数”和“模板递归的深度”而不是纳秒或毫秒。比如插入排序的递归深度大致等于元素数量而实例化数量大概是 O(n²)归并排序深度是 O(log n)但模板数量和符号数量会显著增加。选择算法时要考虑的因素和运行期排序完全不同不是为了省几个时钟周期而是为了控制编译器的负担和编译失败的概率。所以一句话总结如果你排序的是运行时才产生的值用运行期排序如果你排序的是类型本身或者类型的固有属性并且这个顺序不需要在运行时改变那就可以考虑编译期排序。C20 出现后还有第三种做法用 constexpr 函数在编译期给一组整数或std::array排序再把结果喂给模板。这条路线我放到后面单开一节聊。2. TypeList 与排序谓词编译期算法的两块地基2.1 用变参模板定义类型列表模板元编程里最重要的数据结构不是数组不是 vector而是一个可以含任意数量类型的“类型列表”。最简单的形式是#include cstddef template typename... Ts struct TypeList { static constexpr size_t size sizeof...(Ts); };它看起来像一个空壳但排序算法正是在这个空壳里堆积模板特化。你用TypeListint, char, long表示一个三个元素的序列编译器在处理这个类型时其实已经把三个类型打包进了一个包parameter pack。元编程排序要做的就是把这个包重新排列成另一个包最终再实例化出一个新的TypeList。C 元编程里类型列表往往不是最终目的而是中间手段。它的价值在于你可以在编译期遍历它、过滤它、排序它然后用它去生成函数表、注册表、组合类型序列等。而排序就是这种处理中最常见的一步。2.2 排序标准不能只靠sizeof运行期排序的标准是“比较函数”编译期排序的标准是“元函数”。最常见的写法是像std::less一样定义一个模板类型template typename T struct Rank; template struct Rankchar { static constexpr int value 1; }; template struct Rankshort { static constexpr int value 2; }; template struct Rankint { static constexpr int value 3; }; template struct Ranklong { static constexpr int value 4; }; template typename A, typename B struct RankLess { static constexpr bool value (RankA::value RankB::value); };我的一个建议是不要直接用sizeof(A) sizeof(B)作为默认比较标准因为类型大小在不同平台上并不稳定而且很多类型会有相同的大小。如果你排序的是“概念上的优先级”最好的方式就是定义显式的Rank数值。这样既稳定又让意图直接出现在代码里。如果将来要调整某个类型的优先级也只需改一个特化。2.3 把“递归”当循环来理解模板元编程中几乎一切操作都由“特化 递归”完成。我习惯这样思考一个算法处理TypeListHead, Tail...时先对Tail...递归调用同样结构的模板拿到一个中间结果再和Head组合。这非常像一个函数式程序里对列表做 fold 或者 map 的操作。就排序而言你可以用递归把问题拆成“排序一个更小的列表”“插入一个元素”“合并两段有序列表”这样基础的操作。这也是后面我实现插入排序时用的思路不是直接照搬运行期for循环而是把一个元素递归地塞到已经排好的列表里。3. 从零实现编译期插入排序3.1 整体思路把头部插到排好序的尾部插入排序在运行期非常好理解从第二个元素开始每次把当前元素插入到前面已经有序的序列里。模板元编程版也是一样的只不过“序列”是TypeList“插入”是一个模板特化。我先定义一个Prepend用来把一个类型放到列表头部template typename T, typename List struct Prepend; template typename T, typename... Ts struct PrependT, TypeListTs... { using type TypeListT, Ts...; };然后定义Insert把一个值插入到一个已经有序的TypeList中template typename List, typename Value, template typename, typename class Cmp struct Insert; template typename Value, template typename, typename class Cmp struct InsertTypeList, Value, Cmp { using type TypeListValue; }; template typename Head, typename... Tail, typename Value, template typename, typename class Cmp struct InsertTypeListHead, Tail..., Value, Cmp { using tail_insert typename InsertTypeListTail..., Value, Cmp::type; using type std::conditional_t CmpValue, Head::value, TypeListValue, Head, Tail..., typename PrependHead, tail_insert::type ; };这里的核心分支是如果Value应该排在Head前面就直接把Value放到整个有序列表头部否则让Value去和后面的Tail...继续比较然后把Head接到结果前面。这样递归地跑下去最终得到一个完全有序的新列表。3.2 Sort 模板本身递归吃掉一个元素有了Insert之后排序外壳几乎可以直接“抄”下来template typename List, template typename, typename class Cmp struct Sort; template template typename, typename class Cmp struct SortTypeList, Cmp { using type TypeList; }; template typename Head, typename... Tail, template typename, typename class Cmp struct SortTypeListHead, Tail..., Cmp { using sorted_tail typename SortTypeListTail..., Cmp::type; using type typename Insertsorted_tail, Head, Cmp::type; };你可能会问我为什么不是“把后面的元素插入到前面的有序前缀中”其实两种方向都行。这里选择“先排好尾巴再把头插进去”是函数式列表处理中最顺手的写法头永远是单个元素尾是递归入口。写成这样之后语义很清晰SortTypeListHead, Tail... InsertSortTail..., Head。3.3 验证排序结果模板写出来不代表它真的对最好用静态断言把小样例钉死在代码里。我习惯加一段这样的测试using input_list TypeListlong, int, char, short; using sorted_list Sortinput_list, RankLess::type; static_assert(std::is_samesorted_list, TypeListchar, short, int, long::value, RankLess should sort by Rank value);这段代码能编译通过说明long、int、char、short在编译期被正确重排为char short int long。遇到顺序不稳定或者谓词写反的时候静态断言会直接告诉你“sort failed”不用等到运行期。3.4 插入排序为什么只适合小集合我实际用的规则是类型数量在 32 以内插入排序非常舒服超过 64就要开始盯编译时间了超过两三百通常我会考虑别的方法或者改用库。原因不是算法本身错了而是每插入一个新元素都可能触发一批新的std::conditional_t实例化数量近似 O(n²)。当 n 到几百实例化数量就是几万甚至几十万编译器会变得非常吃力。插入排序的优点在于实现短、思路简单、不容易写错。在小规模类型列表上它几乎总是一个足够好的选择。如果你列表里的类型数量真的很大那就应该正视归并排序或快速排序这类分治算法了。4. 归并排序与快速排序模板能搬多重的排序4.1 二路归并在编译期的代价归并排序在运行期几乎是稳定高效的代名词但在模板元编程里它并不显得优雅。拆成两半需要按索引把TypeList切开这一步在参数包里并不直接通常要先实现类似Take和Drop的元函数然后递归排序左右两段最后再实现Merge把两个有序TypeList按谓词合并。模板代码大致会长得像这样TakeN, List取出列表前 N 个类型生成一个新的TypeListDropN, List去掉列表前 N 个类型返回剩余部分MergeListA, ListB, Cmp比较两个列表头把头部较小的那一个并入结果继续合并剩余部分SortND...递归调用Sort于左右两半然后再Merge功能是可以实现的但代码量几乎是指数级增长。而且有两个特别需要注意的点其一递归深度虽然只有 O(log n)但每一次递归都会同时展开左右两个分支编译器要维护的“实例化栈”其实不止一个维度其二因为元编程没有真正的运行时函数调用归并中有些本可以“共用的中间结果”在模板实例化层面会被重复生成导致编译器符号数量暴涨。4.2 快速排序基准点和筛选快速排序的元编程版也更像“筛选 拼接”而不是常规意义上的“原地交换”。你选取一个基准类型 pivot然后把剩余类型分成两组一组是“比 pivot 小”的一组是“比 pivot 大”的再递归排序这两组最后拼成Less pivot Greater。模板里没有一个可以直接复用的“分区”循环通常还是要写递归去遍历整个列表。而且基准点如果选得不好例如总是取第一个元素而输入恰好是接近有序的列表那么快排会退化成 O(n²)模板实例化数量也会跟着恶化。在运行期我们可以随机选基准点来规避最坏情况可在编译期随机不是个自然概念。因此元编程快排完全不比归并更“快”它只是思路更贴近常见教科书写起来同样繁琐。4.3 三种算法的复杂度对照我把三者的关键特性整理成了一张表方便你在设计时快速判断算法模板递归深度实例化数量级对输入顺序的敏感度实现难度插入排序O(n)O(n²)低很低归并排序O(log n)O(n log n) 但常数大低较高快速排序平均 O(log n)最坏 O(n)平均 O(n log n) 但基准选择影响大高高我的经验是在模板元编程里除非你面对的是几百上千个类型否则没有必要为了“更优复杂度”去忍受更长的代码和更难查的编译错误。插入排序写出来 20 行归并排序可能要写一百行而收益却要等类型列表足够大时才能体现出来。这个权衡和运行期是不一样的编译器编译模板的过程不会像 CPU 执行指令那样“流水线化”每多一层实例化都可能是实打实的编译秒数。5. 实例化深度、编译时间和“灾难性”错误消息5.1 绕不开的-ftemplate-depth一旦你开始递归这些模板你很快就会遇到一个经典错误template instantiation depth exceeds maximum of 900。GCC 和 Clang 默认模板递归深度大约是 900插入排序对一个 900 个类型的列表排序时光递归深度就触顶了。你可以用-ftemplate-depth2048或者更高把它抬上去但这只是把限制往后推不是消除问题。我在实际项目里见过有人为了排 1000 个类型把深度直接调到 10000结果编译内存涨了几 GB单次编译动辄几分钟。更理智的做法是如果列表在几百以内优先考虑用库或者 C20 constexpr 方案如果不能换方案就通过显式分桶把一个大列表拆成多个小列表再进行排序或者让递归深度保持在线性范围但减少每个递归层里产生的嵌套模板数量5.2 实例化数量与编译器内存模板递归深度只是“栈有多深”真正让编译变慢的是“总共生成了多少个类模板实例”。插入排序的实例化数量大概相当于 n²/2 量级因为每插入一个新元素它都要和已排序列表里的元素逐个比较。一个 500 个类型的列表在最坏情况下会有十几万个类的符号被编译器记住。这不是运行时的std::sort每多一个符号IDE、静态分析工具和链接器都会受到影响。所以元编程排序里真正要优化的指标不是比较次数而是“避免创建不必要的模板实例”。常见的优化包括用using别名而不是用一个空壳结构体去包装中间结果把不需要对外暴露的特化写进私有细节命名空间尽量少用std::conditional_t一层套一层的方式组合结果因为它也会递归实例化出很多内部节点。5.3 把编译错误拆成可以理解的最小件编译期排序最劝退人的一点是错误信息能把一个 30 行的模板报出一整屏的实例化栈。我自己调试时只有一个心得把所有能拆的步骤都拆成独立命名模板设置最小的测试输入。比如先只测Insert再测Prepend最后才测整个Sort。不要让编译器一口气展开三层递归。如果一段静态断言失败我会在注释里留下“当前应该得到什么类型”的说明然后一点一点缩短测试列表。很多时候错误不在排序算法本身而是比较谓词在某个类型上实例化失败了比如Rank没有对应特化。把谓词单独拿出来用static_assert(RankLesschar, int::value)验证通常几秒钟就能发现问题。6. C20 的 constexpr 排序另一条编译期排序路线6.1 一个可直接跑的 constexpr 插入排序C20 之后我越来越常看到团队不再写递归模板而是用 constexpr 函数在编译期对值排序再驱动类型重排。这更接近“编译期算出来一个顺序然后让模板按顺序拼装”比直接在模板里处理参数包要直观得多。最简单的示例是排序一个std::arrayint, N#include array #include cstddef template std::size_t N constexpr std::arrayint, N compile_time_sort(std::arrayint, N input) { for (std::size_t i 1; i N; i) { int key input[i]; std::size_t j i; while (j 0 input[j - 1] key) { input[j] input[j - 1]; --j; } input[j] key; } return input; } constexpr std::arrayint, 5 input{5, 3, 1, 4, 2}; static_assert(compile_time_sort(input)[0] 1);这段代码很普通但它在编译期完成不会生成任何运行期代码。你甚至可以把它变成constexpr std::arraystd::size_t, N order compute_order(...)然后利用...展开按order从std::tuple里取出对应元素得到一个重新排序后的std::tuple或TypeList。6.2 用排序结果驱动类型重排如果我要对一组类型按Rank排序运行在 C20 下我会把类型的索引放进 constexpr 数组用一段普通的 constexpr 排序计算出索引顺序再用std::index_sequence把该顺序映射回类型template typename... Ts struct TypeList { }; template typename RankFunc, typename... Ts constexpr std::arraysize_t, sizeof...(Ts) sorted_rank_indices() { // 把 Ts... 对应的 Rank 放进数组用普通排序得到升序索引 } template std::size_t... I, typename List auto reorder_by_index(std::index_sequenceI..., List); // 最终把 TypeList... 按 constexpr 排序后的索引重新组装起来。这种“值驱动类型”的思路比直接在模板里写归并要容易理解得多但也不是没有代价你需要同时维护“值侧”和“类型侧”两套逻辑。而且constexpr 排序结果的静态检查能力有时候不如模板直接断言强比如无法轻易在编译期“遍历”一个数组并逐个比较相邻元素类型顺序。不过对于大多数应用场景它已经绰绰有余。6.3 constexpr 方案与模板方案的取舍我现在的判断标准大概是这样的如果排序对象本身就是类型且要参与模板重载、特化或生成类型列表优先用模板元编程排序因为结果直接就是类型。如果排序对象是可映射为值的属性比如优先级、大小、字母序索引并且后面主要用索引去tuple或数组取数据那 constexpr 方案更省事编译速度也更快。如果项目已经用了 C17 甚至 C20大部分新代码我都会尝试用 constexpr 函数先算一个“顺序”再手动映射到类型因为至少错误信息好懂一大截。7. 生产里更省心的选择与我的实践建议7.1 直接使用现成库Boost.MPL 与 Boost.Hana如果你在真实项目里并不想维护一套自己的元编程排序算法我的第一个建议永远是“先看看 Boost”。Boost.MPL 里有mpl::sort可以在类型序列上排序只是它基于较老的 MPL 世界观接口相对生涩。Boost.Hana 是更现代化的编译期算法库它提供了hana::sort可以排序 tuple-like 结构直接表达“编译期排序”的意图。举个例子如果项目能接受 Boost我的排序代码往往就是一两行#include boost/hana.hpp namespace hana boost::hana; using my_tuple decltype(hana::sort(hana::make_tuple( hana::type_clong, hana::type_cchar, hana::type_cint )));它的输出也是一个 tuple-like 编译期容器你可以继续用hana::integral_constant等机制取元素。好处是库作者已经处理了各种枯燥的边缘情况坏处是模板实例化深度和编译时间一样会体现在你的构建系统里。但站在工程角度用现成方案永远比自己造一个半成品更稳。7.2 真实可用的编译期排序需求我在实际项目中接触到的编译期排序需求通常不是“纯粹为了好玩”而是这类场景事件/回调注册表按优先级把类型顺序固定进编译期产物运行时不排序反射与序列化需要稳定输出字段顺序避免不同的编译器或平台产生不可预期顺序数据库表行装配按类型映射到列索引再用编译期排序索引生成访问代码动态多态替代类型列表排序后再逐一生成if constexpr或者策略类组合这些场景有个共同特点一旦顺序被编译期确定整个模块的行为就会变得可预测也能被编译器和优化器更彻底地内联。我很少在项目里处理超过几十个类型的排序但如果真的遇到上千个类型我一定会选择 C20 constexpr 方案或 Boost.Hana而不会自己手搓一个深度上千的归并。7.3 判断要不要自己写排序模板最后聊聊我的个人判断标准。如果一个团队里没有几个人熟悉模板元编程我通常不建议自己写排序模板因为代码一旦进入深水区后续维护成本会非常高。比较好的做法是先确定排序数据到底在“类型侧”还是“值侧”再决定用库、用 constexpr还是用自制模板。我自己在实践中最大的体会是编译期排序算法最有价值的产出往往不是“让编译更快”而是“让顺序在被编译之后就固定下来”不再依赖初始化上下文也不再被运行时环境影响。模板元编程里的插入、归并、快排本质上是在帮编译器建立一个关于类型顺序的“事实数据库”。当你需要把类型列表转换成一组可索引的策略、一张稳定的函数表、或一段可预测的反射元数据时这个事实数据库能帮你省掉大量运行时防御性代码。如果非要给一条直接可用的建议小列表用插入排序模板中列表用 Boost.Hana 或 constexpr 方案大列表先把数据转化为索引序列再排序千万不要在模板递归深度上逞强。这个顺序我踩过几次坑之后才确定下来也是我目前觉得最省心的做法。
返回列表