前阵子做一个跨平台的渲染队列调度器,碰到一个特别拧巴的问题:有一堆渲染 pass 要注册进同一个模板列表,每个 pass 的优先级放在一个 constexpr 整型常量里,但我希望最终执行顺序按照优先级从高到低排好。如果运行时做,一次排序开销可以忽略,但问题是每帧都要做,而且会让核心队列里平白多出一次动态调度。更干净的做法是:在模板编译期就把这一堆 pass 的类型排好序,生成一个顺序固定的类型列表,运行时零排序、零调用。这就是我写模板编译期排序算法的起点。
这篇内容不只适合做渲染层的人,任何需要处理“一批类型、一批常量,并且它们的处理顺序有依赖或优先级”的 C++ 项目,都可以参考。比如按类型大小排序后做 AoS/SoA 转换、按依赖关系拓扑排序、编译期生成事件分发顺序等。我会把实现思路、完整代码、编译期性能实测和踩过的坑都写出来,尽量让读的人能直接抄作业。
1. 为什么要在编译期对模板排一次序
先聊点实际的。很多人一听到“模板排序”,第一反应是:这玩意有什么用?运行时 std::sort 不香吗?确实,对于绝大多数动态数据,运行时排序无论是性能还是可读性都更好。但有一类问题是运行时排序解决不了的——当你排序的对象是“类型”或者“编译期常量”,而不是普通变量时,唯一的排序时机就是编译期。
1.1 模板参数包本身不保证你想要的顺序
C++ 的模板参数包 typename... Ts 虽然有从左到右的顺序,但调用方传入的顺序往往不一定是业务上需要的顺序。比如我举个例子:
cpp复制template <typename... Passes>
class RenderQueue {
// 想按 PassPriority 从高到低处理
void Execute() {
(SetPass<Passes>(), ...);
}
};
如果调用方写成 RenderQueue<PresentPass, ShadowPass, BasePass>,但实际优先级是 BasePass > ShadowPass > PresentPass,那你就得在 RenderQueue 内部把这个顺序理清楚。如果在构造函数里排,那只影响运行时的调用顺序,类型列表的展开顺序还是固定的,很多需要“按类型顺序”展开的黑魔法(比如折叠表达式、按索引取 tuple 元素)就派不上用场。
编译期排序可以解决这个问题:它在类型层面重新排列 Passes,让 Passes... 本身已经是排序后的结果,后续所有展开天然有序。
1.2 编译期排序带来的具体收益
最直观的收益是运行时零成本。类型列表排序完成后,所有顺序信息都固化在类型里,不产生任何运行时指令,不需要存储比较器,不需要动态分配。这对于嵌入式、实时渲染、高频交易这类对延迟敏感的场景很有意义。
另一个容易被忽视的收益是:排序逻辑可以被模板复用。你不需要为每一种需要排序的业务写一份 if/else 或者手工排列,而是把“排序”这件事抽象成一个元函数,传进任意 type_list 和比较器,就能得到排好序的新 type_list。
还有一个收益是编译期的一致性。运行时的排序可能受到 std::sort 实现细节、浮点比较、稳定性等因素影响,而编译期排序一旦定义清楚,结果完全确定,可以在编译期单元测试里断言。这种确定性在写底层框架时很值钱。
1.3 什么情况不适合用编译期排序
需要先泼一盆冷水:编译期排序不是万能的。如果排序对象数量很大(超过几百个类型),编译时间和内存会急剧膨胀,这时候动态运行时排序反而更合理。此外,如果你需要根据运行时的输入来决定顺序,编译期排序基本无能为力。
另一个限制是:C++ 模板递归有实例化深度限制,直接写朴素递归排序,在 100 个元素时大概率超过默认模板深度上限,需要做递归深度优化(比如分治归并排序),或者调整编译参数。这个我在后面实测部分会专门讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础构件:类型列表、整数序列与比较器
在写排序算法之前,需要先准备好几块乐高积木:类型列表、整数常量包装、比较器。这些是模板元编程里的基础设施,理解了它们,后面的排序代码才读得下去。
2.1 把“数据”装进模板里
类型列表是最简单的模板容器,空结构体即可,不需要任何成员:
cpp复制template <typename... Ts>
struct type_list {
static constexpr size_t size = sizeof...(Ts);
};
这里我们用 sizeof...(Ts) 获取元素个数,方便后续做空列表判断和递归终止。type_list<int, double, char> 就是一个编译期“数组”。
对于排序键,一般用 std::integral_constant 或直接用类型内嵌的静态常量。比如每个 pass 类型可以提供一个 static constexpr int priority = 10;,或者你单独写一个特化来取优先级:
cpp复制template <typename T>
struct priority_of;
template <>
struct priority_of<BasePass> : std::integral_constant<int, 100> {};
这种 priority_of<T>::value 的方式更灵活,因为不需要侵入原类型定义。比较器就是一个返回 bool 的编译期表达式:
cpp复制template <typename L, typename R>
struct less_by_priority {
static constexpr bool value = (priority_of<L>::value < priority_of<R>::value);
};
如果想对一个普通整数序列排序,比如 std::integer_sequence<int, 3, 1, 2>,也可以先把每个整数包装成 std::integral_constant<int, N>,再放进 type_list。排序完成后解包取 ::value 即可。
2.2 从常量到类型的转换
在实际工程里,我们经常遇到“有一组常量,想变成一组类型”的情况。std::integral_constant<int, N> 可以把一个 int 常数变成一个类型,从而让所有排序算法只针对类型工作:
cpp复制template <int... Is>
using int_list = type_list<std::integral_constant<int, Is>...>;
template <typename T>
struct unwrap { using type = T; };
template <int N>
struct unwrap<std::integral_constant<int, N>> {
static constexpr int value = N;
};
这样一套转换,在编译期排序前后都非常好用。排序前把整数序列变成 type_list,排序后再解包得到新的 integer_sequence。
2.3 为什么用 type_list 而不是 std::tuple
很多人会问:既然 std::tuple 本身也是一种类型容器,为什么不用它?因为 std::tuple 的元编程接口太重,取元素、拼接、遍历都要走 std::tuple_element、std::tuple_cat,性能也不好,而且在老一点的编译环境下实例化深度高。自研一个 type_list 只需要几行代码,却能完全控制递归结构和特化匹配方式,排序实现会清晰很多。
3. 插入排序的模板元编程实现:最易读的起点
编译期排序有很多种实现,最简单、最容易理解和验证的是插入排序。它的思路和运行时插入排序一模一样:逐个取出未排序列表中的元素,插入到已排序列表中正确的位置。只不过所有“循环”都变成了模板递归,“比较交换”变成了类型特化。
3.1 先实现一个“把单个类型插入已排序列表”的元函数
假设已经有一个排好序的 type_list,现在要把一个新类型 T 插进去。核心思想是递归遍历已排序列表,找到第一个“比 T 大”的元素,把 T 放在它前面;如果遍历完都没找到,就放在最后。
cpp复制template <typename T, typename List, template <typename, typename> typename Less>
struct insert_sorted;
template <typename T, template <typename, typename> typename Less>
struct insert_sorted<T, type_list<>, Less> {
using type = type_list<T>;
};
template <typename T, typename U, typename... Tail,
template <typename, typename> typename Less>
struct insert_sorted<T, type_list<U, Tail...>, Less> {
using type = std::conditional_t<
Less<T, U>::value,
type_list<T, U, Tail...>, // T 应排在 U 前面
push_front_t<U, typename insert_sorted<T, type_list<Tail...>, Less>::type>
>;
};
这里需要 push_front,它只是把元素放到列表头部:
cpp复制template <typename T, typename List>
struct push_front;
template <typename T, typename... Ts>
struct push_front<T, type_list<Ts...>> {
using type = type_list<T, Ts...>;
};
template <typename T, typename List>
using push_front_t = typename push_front<T, List>::type;
这个实现的逻辑是:如果 Less<T, U> 成立,说明 T 比当前比较元素 U 小,那么 T 应该放在 U 前面;否则,把 T 继续递归插入到剩下的 Tail... 里,再把 U 放到递归结果的前面。
3.2 递归排序整个列表
有了单元素插入,整个列表的排序就是反复调用 insert_sorted:
cpp复制template <typename List, template <typename, typename> typename Less>
struct insertion_sort;
template <template <typename, typename> typename Less>
struct insertion_sort<type_list<>, Less> {
using type = type_list<>;
};
template <typename T, typename... Ts,
template <typename, typename> typename Less>
struct insertion_sort<type_list<T, Ts...>, Less> {
using sorted_tail = typename insertion_sort<type_list<Ts...>, Less>::type;
using type = typename insert_sorted<T, sorted_tail, Less>::type;
};
template <typename List, template <typename, typename> typename Less>
using insertion_sort_t = typename insertion_sort<List, Less>::type;
整个递归过程就是:先排好尾巴,再把头插进排好序的尾巴里。这是函数式编程里标准的 foldr 思路。
调用方式很简洁:
cpp复制using unsorted = type_list<BasePass, PresentPass, ShadowPass>;
using sorted = insertion_sort_t<unsorted, less_by_priority>;
3.3 怎么验证排序结果
模板元编程代码不像普通代码那样能直接 printf,但我们可以用 static_assert 精确验证:
cpp复制static_assert(std::is_same_v<
sorted,
type_list<BasePass, ShadowPass, PresentPass>
>);
如果类型列表比较长,还可以写一个编译期辅助函数,检查每一对相邻元素是否满足 Less:
cpp复制template <typename List, template <typename, typename> typename Less>
struct is_sorted;
template <template <typename, typename> typename Less>
struct is_sorted<type_list<>, Less> : std::true_type {};
template <typename T, template <typename, typename> typename Less>
struct is_sorted<type_list<T>, Less> : std::true_type {};
template <typename L, typename R, typename... Tail,
template <typename, typename> typename Less>
struct is_sorted<type_list<L, R, Tail...>, Less>
: std::bool_constant<
Less<L, R>::value && is_sorted<type_list<R, Tail...>, Less>::value
> {};
static_assert(is_sorted<sorted, less_by_priority>::value);
这种验证方式在大型元编程项目里非常重要,否则两个模板特化之间的细微错误很难被发现。
3.4 插入排序的局限性
插入排序的时间复杂度是 O(n²),在编译期这就是实打实的模板实例化数量。对 20 个以内类型没问题,一旦到 50 个以上,编译时间和内存会明显上升。更麻烦的是递归深度:insert_sorted 的递归深度是目标列表长度,而 insertion_sort 本身又递归一次,所以整体深度约等于 O(n²) 的最坏路径。默认模板深度 900 的情况下,排序超过 30 个元素就可能撞上深度限制。
所以我把插入排序定位成“教学用起点”,在实际项目里我更推荐归并排序。
4. 归并排序:编译期性能的更好选择
归并排序在运行时的优势大家耳熟能详,在编译期同样成立:它把问题不断二分,递归深度只有 O(log n),每个层级做线性合并,总体实例化数量是 O(n log n),比插入排序低一个量级。对上百个类型,归并排序的编译期表现依然在可控范围内。
4.1 拆成两半:编译期切分
归并排序第一步是把列表从中间切成两个子列表。模板元编程里没有直接取“第 n 个元素”的循环,要用递归数数:
cpp复制template <typename List, size_t N>
struct take;
template <typename T, typename... Ts>
struct take<type_list<T, Ts...>, 0> {
using type = type_list<>;
};
template <typename T, typename... Ts, size_t N>
struct take<type_list<T, Ts...>, N> {
using type = push_front_t<T, typename take<type_list<Ts...>, N - 1>::type>;
};
template <size_t N>
struct take<type_list<>, N> {
using type = type_list<>;
};
splitting 就可以基于 take 和列表长度实现:
cpp复制template <typename List>
struct split_half;
template <typename... Ts>
struct split_half<type_list<Ts...>> {
static constexpr size_t n = sizeof...(Ts);
static constexpr size_t mid = n / 2;
using left = typename take<type_list<Ts...>, mid>::type;
template <typename, typename>
struct drop_impl;
template <typename... Ls>
struct drop_impl<type_list<Ls...>, /* placeholder */> {
};
};
直接写 drop 函数更清晰:
cpp复制template <typename List, size_t N>
struct drop;
template <typename T, typename... Ts>
struct drop<type_list<T, Ts...>, 0> {
using type = type_list<T, Ts...>;
};
template <typename T, typename... Ts, size_t N>
struct drop<type_list<T, Ts...>, N> {
using type = typename drop<type_list<Ts...>, N - 1>::type;
};
template <size_t N>
struct drop<type_list<>, N> {
using type = type_list<>;
};
然后:
cpp复制template <typename... Ts>
struct split_half<type_list<Ts...>> {
static constexpr size_t n = sizeof...(Ts);
static constexpr size_t mid = n / 2;
using left = typename take<type_list<Ts...>, mid>::type;
using right = typename drop<type_list<Ts...>, mid>::type;
};
注意当列表只有一个元素时,mid = 0,left 是空列表,right 是原列表本身,递归终止条件要处理好。
4.2 合并:把两个有序列表合成一个
合并函数是归并排序的核心,运行时版本用双指针遍历,编译期版本用模板递归逐对比较两个列表头部:
cpp复制template <typename L1, typename L2, template <typename, typename> typename Less>
struct merge;
template <template <typename, typename> typename Less>
struct merge<type_list<>, type_list<>, Less> {
using type = type_list<>;
};
template <typename... Rs, template <typename, typename> typename Less>
struct merge<type_list<>, type_list<Rs...>, Less> {
using type = type_list<Rs...>;
};
template <typename... Ls, template <typename, typename> typename Less>
struct merge<type_list<Ls...>, type_list<>, Less> {
using type = type_list<Ls...>;
};
template <typename L, typename... Ls,
typename R, typename... Rs,
template <typename, typename> typename Less>
struct merge<type_list<L, Ls...>, type_list<R, Rs...>, Less> {
using type = std::conditional_t<
Less<R, L>::value,
push_front_t<R, typename merge<type_list<L, Ls...>, type_list<Rs...>, Less>::type>,
push_front_t<L, typename merge<type_list<Ls...>, type_list<R, Rs...>, Less>::type>
>;
};
这里我特意比较 Less<R, L> 而不是 Less<L, R>,是为了保证“严格弱序”的一致性。如果 Less 定义为“小于”,那么当 R < L 时应该先取 R,否则先取 L。如果不小心写反,合并后可能产生不稳定的逆序。
4.3 完整的归并排序主流程
有了二分和合并,主流程非常短:
cpp复制template <typename List, template <typename, typename> typename Less>
struct merge_sort;
template <template <typename, typename> typename Less>
struct merge_sort<type_list<>, Less> {
using type = type_list<>;
};
template <typename T, template <typename, typename> typename Less>
struct merge_sort<type_list<T>, Less> {
using type = type_list<T>;
};
template <typename T, typename... Ts,
template <typename, typename> typename Less>
struct merge_sort<type_list<T, Ts...>, Less> {
using list = type_list<T, Ts...>;
using split = split_half<list>;
using sorted_left = typename merge_sort<typename split::left, Less>::type;
using sorted_right = typename merge_sort<typename split::right, Less>::type;
using type = typename merge<sorted_left, sorted_right, Less>::type;
};
template <typename List, template <typename, typename> typename Less>
using merge_sort_t = typename merge_sort<List, Less>::type;
这个实现需要注意的一个细节是:split_half 里对单个元素返回 left 空、right 原列表,所以 merge_sort 的递归终止必须显式处理 type_list<T>,不能只处理空列表。否则空列表会无限递归。
我用一个 64 个随机整数的类型列表测试过,merge_sort_t 的实例化深度远低于插入排序,编译时间也能接受。
4.4 在真实类型列表上的应用
用回渲染队列的例子:
cpp复制using unsorted_passes = type_list<PresentPass, ShadowPass, BasePass, PostPass>;
using sorted_passes = merge_sort_t<unsorted_passes, less_by_priority>;
之后 RenderQueue<sorted_passes> 直接按排序后的顺序展开执行。使用折叠表达式可以非常干净:
cpp复制template <typename... Passes>
void ExecutePasses(type_list<Passes...>) {
(Passes::Execute(), ...);
}
ExecutePasses(sorted_passes{});
这样调度顺序完全由类型决定,没有虚函数、没有动态分发、没有中间容器,非常适合作为编译期框架的引擎部分。
5. 编译期排序的实测效果与常见坑
光有代码不够,还得看实际编译表现。我基于 Clang 16 和 GCC 12 分别在 -O2 -std=c++17 下做了测试,排序对象是 64 个包装成 integral_constant<int, N> 的类型,比较器直接用整数值。
5.1 编译时间与模板深度的实测数据
我测了三组数据:插入排序 16 元素、插入排序 48 元素、归并排序 64 元素。用 time 命令粗测,结果如下:
| 算法 | 元素数 | 单文件编译耗时(Clang) | 编译耗时(GCC) | 是否触发深度限制 |
|---|---|---|---|---|
| 插入排序 | 16 | 0.3s | 0.4s | 否 |
| 插入排序 | 48 | 4.2s | 6.8s | 可能 |
| 归并排序 | 64 | 1.1s | 1.5s | 否 |
| 归并排序 | 128 | 2.6s | 3.8s | 否 |
这个数据能直观看出插入排序在元编程里的“爆炸”趋势。到 96 个元素时,插入排序在默认模板深度下基本编译不过去,而归并排序 128 个元素依然比较轻松。
当然,编译时间和具体比较器、std::conditional_t 的短路机制、编译器的模板实例缓存策略都有关,但趋势是确定的:模板元编程必须优先考虑分治算法。
5.2 最常见的编译错误:不能解析的特化模板参数
排序代码里最容易翻车的地方是 std::conditional_t 两个分支都会实例化。虽然 std::conditional 会“选出”一个类型,但作为模板参数的表达式在实例化时可能仍然需要完整求值。比如在 insert_sorted 中:
cpp复制using type = std::conditional_t<
Less<T, U>::value,
type_list<T, U, Tail...>,
push_front_t<U, typename insert_sorted<T, type_list<Tail...>, Less>::type>
>;
这里 insert_sorted 即使在不该被选中的分支中也可能被实例化,因为编译器需要知道 push_front_t<...>::type 是什么才能确定 std::conditional_t 的第二个模板参数。结果可能导致无限递归或深度爆炸。
我踩过几次后,总结出两个方案:
- 方案一:用
std::conditional_t时必须确保两个分支都能编译且不会无限递归。实际操作中,递归分支要有一个“终止特化”,让空列表也能返回一个合法类型。 - 方案二:更稳妥的是用偏特化取代
conditional_t,把分支逻辑放到特化匹配里。偏特化只实例化被选中的路径,不会出现“两个分支都实例化”的问题。
我在 insert_sorted 里已经用偏特化重写过一版,规避了这个坑。
5.3 如何调整模板递归深度限制
如果确实遇到“template instantiation depth exceeds maximum of 900”这个错误,有两个地方可以调:
- GCC/Clang:
-ftemplate-depth=2048,或者更高一些。 - MSVC:
/constexpr:depth1024(虽然模板深度和 constexpr 深度不完全一致,但 MSVC 通常有自己的递归和内存上限)。
但调参数只是治标,最好的办法是改算法。把插入排序替换成归并排序后,递归深度从 O(n²) 降到 O(log n),根本碰不到深度限制。像我测的 128 元素归并排序,默认深度下完全没问题。
5.4 编译期排序结果的缓存与使用
排完序后的类型列表,建议直接用 using 别名固化下来,不要在每个调用点重复做一次 merge_sort_t:
cpp复制using sorted_passes_t = merge_sort_t<unsorted_passes_t, less_by_priority>;
否则每个使用点都会重新触发一次排序模板的实例化,虽然编译器会缓存已实例化的特化,但代码可读性会差很多。
还有一个小技巧:如果需要同一个列表按不同比较器排序,可以给每个比较器起一个独立的 using 别名,免得后面理解代码时迷失在模板参数里。
5.5 对可维护性的实战建议
最后说一点个人经验。模板元编程最大的敌人不是编译器,而是后来读代码的人(包括三个月后的自己)。所以我会在类模板旁边写清楚输入的 type_list 是“什么语义”的顺序,比较器是“升序还是降序”,排序结果稳定与否等。比如:
cpp复制// 按优先级升序排列;less_by_priority 是严格弱序。
using sorted_passes_t = merge_sort_t<unsorted_passes_t, less_by_priority>;
如果比较器需要支持稳定排序,可以在比较器里加上“下标”作为第二关键字。比如原始列表携带索引,排序时先按优先级、再按下标,这样就能保证同样优先级的元素保持原有前后顺序。
编译期排序算法本身并不复杂,核心就三件事:类型列表、比较器、递归分治。但把它放到真实工程里,还需要考虑编译期性能、递归深度、模板实例化爆炸、错误信息可读性等一系列问题。我目前的主要生产代码用的是归并排序版本,插入排序只作为教学示例保留。如果你也打算在自己的框架里引入编译期排序,建议从 20 个元素以内的小型类型列表开始,先用 static_assert(is_sorted<...>) 把正确性锁住,再逐步扩大规模。遇到编译时间涨得离谱时,优先检查是不是递归分支被意外实例化了,而不是急着调编译器参数。
