先说结论:模板元编程里的“图算法”,本质上是把运行时的循环和栈,换成编译期的递归实例化和类型列表。这件事我第一次接触是在做一个插件注册中心的时候,服务之间的依赖关系越来越复杂,手动维护初始化顺序已经不可靠,运行期检查循环依赖又太晚,于是我想:能不能让编译器在编译阶段就把这张依赖图算清楚,算出合法的启动顺序,有环就给我报错?做完之后发现,这条路不仅能走通,而且可维护性比想象中好得多。
这篇文章就把我踩过的坑、拆过的轮子、验证过的方案完整写出来。适合对C++模板元编程有一定了解、又不想只停留在type traits层面的读者。不涉及运行期图库,全部在编译期完成。
1. 先说清楚:什么场景下才需要“编译期图算法”
1.1 一个让我入坑的真实需求
先讲业务背景。当时我们有一个服务容器,每个服务可以声明自己依赖哪些其他服务,容器负责按依赖顺序初始化。一开始服务少,手工排顺序就行。等到几十个服务,依赖关系形成了DAG,情况就失控了:A依赖B、B依赖C、D又依赖B和A,谁在前谁在后,靠人肉根本排不稳。更麻烦的是,一旦有人加了一条反向依赖,立刻就出现循环依赖。运行期初始化到一半才发现环,整个进程直接起不来,日志还很不好查。
后来我想,能不能换个思路:既然每个服务的依赖类型在编译期就是确定的,那我完全可以用模板把这些依赖关系表达成一张图,让编译器在编译阶段完成拓扑排序。有环就让编译失败,没环就生成一个编译期确定的初始化顺序。这样错误发现得最早,运行时只是机械执行。
这个需求是我认为“编译期图算法”最典型、最实在的落地场景:图的结构来自类型系统,而不是来自运行时的外部配置。
1.2 编译期图算法不是炫技,是有明确的收益边界
很多人一听到模板元编程就头疼,更别说在编译期跑图算法了。但你要分清楚:不是所有图问题都适合放到编译期。我自己的判断标准是两条:
- 图的节点和边是不是在编译期就能完全确定;
- 你需不需要在编译期拿到计算结果去做类型级别的静态保证。
如果图的数据来自配置文件、数据库、用户输入,那就老老实实用运行期的图算法库。如果图的结构是类型之间的依赖关系,那编译期就非常适合,因为类型关系本身就是静态的。
另外,编译期图算法还有一个隐性收益:它强制你把依赖关系“显式化”。你没法在模板元编程里偷偷塞一个隐式的运行时引用,所有依赖都必须通过using deps_type = ...这样明明白白地写出来。代码的可读性和可审查性反而会变好。
不过要提醒的是,编译期图算法有学习成本,调试体验也比运行期差。如果只是处理三五个节点的简单关系,完全没必要上这种重型方案。判断标准就一句话:这张图是否会被频繁修改,修改之后是否需要立刻得到静态验证。
2. 图的两种编译期载体:常量图和类型图
2.1 常量图:用constexpr邻接矩阵存图
第一种形式最简单:用constexpr函数构造一个邻接矩阵,或者用std::array存储边列表。C++14之后constexpr函数里可以写循环,所以很多经典图算法都能直接在编译期跑出来。
常量图适合节点数量已知、边权重已知的场景。例如编译期计算某个管线的最短路径、生成调度表、计算状态机的转移代价。我用过的一个例子是:引擎内部的渲染管线有多个处理阶段,阶段之间有不同的切换代价,我想在编译期算出代价最小的链路过法,然后把这个结果嵌到代码里,避免运行时重复计算。
#include <array> #include <cstddef> constexpr int INF = 1000000000; template<std::size_t N> struct ConstGraph { std::array<std::array<int, N>, N> w{}; constexpr ConstGraph() { for (std::size_t i = 0; i < N; ++i) { for (std::size_t j = 0; j < N; ++j) { w[i][j] = (i == j ? 0 : INF); } } } constexpr void addEdge(std::size_t u, std::size_t v, int cost) { w[u][v] = cost; } };这种图的优点是非常朴素,掏出来就是邻接矩阵,算法实现和教科书一模一样。缺点是在C++17之前,标准库容器的很多操作不是constexpr的,所以得自己管数组、自己写fill,代码会粗糙一些。
2.2 类型图:用模板参数包表达依赖
第二种形式更符合“模板”二字的调性:每个节点是一个类型,每条边用deps<T>::type表示一个类型列表,列出 T 依赖的所有节点。
#include <type_traits> template<typename... Ts> struct type_list {}; template<typename T, typename = void> struct deps { using type = type_list<>; }; template<typename T> struct deps<T, std::void_t<typename T::deps_type>> { using type = typename T::deps_type; };这里用了SFINAE技巧:如果类型 T 内部定义了deps_type,就取它;否则默认没有依赖。这样定义服务的代码非常干净:
struct ServiceA {}; struct ServiceB { using deps_type = type_list<ServiceA>; }; struct ServiceC { using deps_type = type_list<ServiceA, ServiceB>; };类型图的优势在于,它和图算法天然有“同构感”:模板递归展开的过程,在逻辑上等价于运行期的深度优先遍历。而且最终结果可以直接作为类型使用,比如形成一个编译期的初始化顺序列表,运行时按这个类型列表做展开。
2.3 选型对照:什么情况用哪个
我整理了一个简表,方便你根据场景直接选:
| 判断维度 | 常量图 | 类型图 |
|---|---|---|
| 图的来源 | 硬编码常量、constexpr工厂函数 | 类型的依赖声明 |
| 典型算法 | 最短路、最小生成树、Floyd、DP | 拓扑排序、闭包、可达性分析 |
| 节点数量 | 可以到几百甚至更多 | 受模板递归深度限制,几十到上百较舒适 |
| 结果去向 | 编译期常量数组,嵌入运行逻辑 | 类型列表,驱动模板分发 |
| 调试体验 | 相对友好,能打印常量 | 编译错误堆栈感人,需要经验 |
| C++版本要求 | C++14起比较顺手 | C++17起比较舒服 |
实际项目里这两者常常混用:类型图负责描述“依赖关系”这种结构性信息,常量图负责描述“代价/权重”这种数值性信息。前面提到的服务初始化,用的是类型图;后面要讲的路径计算,用的是常量图。
3. 类型图上的编译期DFS:用模板递归展开实现依赖遍历
3.1 DFS闭包的核心元函数
先从最基础的编译期DFS讲起。给定一个起点类型 T,我希望得到从 T 出发能到达的所有类型的集合,也就是传递依赖闭包。这个实现很像运行期的DFS,只不过“栈”换成了模板递归,“visited集合”换成了类型列表。
template<typename Needle, typename... Ts> constexpr bool contains_v = (std::is_same_v<Needle, Ts> || ...); template<typename T, typename Visited = type_list<>> struct dfs; template<typename T, typename... Vs> struct dfs<T, type_list<Vs...>> { static constexpr bool seen = contains_v<T, Vs...>; using type = std::conditional_t< seen, type_list<Vs...>, typename visit_children<T, type_list<Vs..., T>>::type >; }; template<typename T, typename Accum> struct visit_children { using kids = typename deps<T>::type; using type = typename fold_visit<kids, Accum>::type; }; template<typename Kids, typename Accum> struct fold_visit; template<typename Accum> struct fold_visit<type_list<>, Accum> { using type = Accum; }; template<typename K, typename... Ks, typename Accum> struct fold_visit<type_list<K, Ks...>, Accum> { using after_k = typename dfs<K, Accum>::type; using type = typename fold_visit<type_list<Ks...>, after_k>::type; };你可以把它理解成运行期的递归函数:dfs判断当前节点是否访问过,没访问过就把自己塞进Visited,然后递归处理所有子节点。fold_visit负责逐个处理子节点,并把上一次递归的结果继续向后传递。
这套代码里最容易出错的是fold_visit,它承担了“顺序折叠”的职责。模板参数type_list<K, Ks...>一次拆一个节点,处理完一个再继续处理剩下的,正好对应运行期 for 循环里的递归调用。
3.2 把图喂给DFS
现在定义一个简单的依赖图:
struct A {}; struct B { using deps_type = type_list<A>; }; struct C { using deps_type = type_list<A, B>; }; using closure = dfs<C>::type;展开过程大概是这样:dfs<C>发现C未访问,把C加进集合,然后处理C的依赖[A, B]。fold_visit先访问A,A加入集合,再访问B,B也加入集合,处理B的依赖时发现A已经在集合里,直接跳过。最终closure就是type_list<C, A, B>。
这个结果无序遍历上的严格要求,顺序取决于你写依赖时怎么排序。它保证的是“所有可达节点都在里面”,不保证“父节点一定在子节点之前”这种拓扑序。所以它适合做闭包计算、依赖集合校验,但不适合直接当初始化顺序用。
3.3 DFS不满足拓扑序,为什么?
这是初学者最容易踩的坑:我把依赖顺序写成了A, B,但DFS结果却是C, A, B,完全不是“被依赖者优先”。原因很简单,DFS是“先根后子”的前序访问,它先把 C 放进集合,再递归去访问 C 的孩子。
拓扑序要求的是:对每条边U -> V,U 必须在 V 之前(或者反过来,取决于你的定义)。DFS只有经过后序遍历才能保证这个性质。模板元编程里也可以做后序遍历,把“加入集合”的动作放到子节点全部处理完之后,代码会稍微绕一点。但更干净的方式是放弃DFS,直接用Kahn算法做拓扑排序,这也是我下一章要详细讲的方案。
所以实践里我很少用编译期DFS直接出拓扑序,通常只拿它来做“闭包计算”,例如检查某个服务是否间接依赖了某个不合规的底层组件。如果你要做初始化顺序,直接看下一章。
4. 编译期拓扑排序:把依赖图变成确定执行顺序
4.1 Kahn算法在模板世界的等价物
Kahn算法的运行期逻辑是:反复选择入度为0的节点,把它输出并从图中移除。一旦图处理完毕,如果还有节点剩余,说明存在环。
搬进模板世界后,“选择入度为0的节点”对应的是筛选出所有依赖都已经出现在Done列表里的类型。我用一个is_ready<T>谓词来判断,然后用模板filter筛出当前所有“就绪”的节点。
先看辅助工具:
template<typename... Lists> struct concat; template<> struct concat<> { using type = type_list<>; }; template<typename... Ts> struct concat<type_list<Ts...>> { using type = type_list<Ts...>; }; template<typename... Ts, typename... Us, typename... Rest> struct concat<type_list<Ts...>, type_list<Us...>, Rest...> : concat<type_list<Ts..., Us...>, Rest...> {}; template<template<typename> class Pred, typename... Ts> struct filter_impl { using type = typename concat< std::conditional_t<Pred<Ts>::value, type_list<Ts>, type_list<>>... >::type; }; template<template<typename> class Pred, typename List> struct filter; template<template<typename> class Pred, typename... Ts> struct filter<Pred, type_list<Ts...>> : filter_impl<Pred, Ts...> {};其中filter做的事情就是把type_list<A, B, C>中满足条件的类型挑出来,组成新的类型列表。
remove_all负责从待处理列表中去掉已经就绪的节点:
template<typename ToRemove, typename List> struct remove_all; template<typename... Rm> struct remove_all<type_list<Rm...>, type_list<>> { using type = type_list<>; }; template<typename... Rm, typename T, typename... Ts> struct remove_all<type_list<Rm...>, type_list<T, Ts...>> { using rest = typename remove_all<type_list<Rm...>, type_list<Ts...>>::type; using type = std::conditional_t< contains_v<T, Rm...>, rest, typename concat<type_list<T>, rest>::type >; };4.2 核心的topo_sort元函数
核心的拓扑排序主循环长这样:
template<typename Done> struct is_ready { template<typename T> using pred = std::bool_constant< all_in<typename deps<T>::type, Done>::value >; }; template<typename All, typename Done = type_list<>> struct topo_sort { using ready = typename filter<is_ready<Done>::template pred, All>::type; static_assert(!std::is_same_v<ready, type_list<>>, "topo_sort: no ready node, cycle detected"); using remaining = typename remove_all<All, ready>::type; using type = typename topo_sort<remaining, typename concat<Done, ready>::type>::type; }; template<typename... Ds> struct topo_sort<type_list<>, type_list<Ds...>> { using type = type_list<Ds...>; };all_in用来判断一个类型列表的所有元素是否都已经出现在Done里:
template<typename Need, typename DoneList> struct all_in; template<typename... Ns, typename... Ds> struct all_in<type_list<Ns...>, type_list<Ds...>> { static constexpr bool value = (contains_v<Ns, Ds...> && ...); };逻辑上每实例化一层topo_sort,就做一次“筛选就绪节点 -> 从未处理列表移除 -> 加入已完成列表 -> 继续递归”。递归终止条件是所有节点都处理完。
用前面服务的例子:
struct ServiceA {}; struct ServiceB { using deps_type = type_list<ServiceA>; }; struct ServiceC { using deps_type = type_list<ServiceA, ServiceB>; }; struct ServiceD { using deps_type = type_list<ServiceB>; }; using AllServices = type_list<ServiceA, ServiceB, ServiceC, ServiceD>; using BootOrder = topo_sort<AllServices>::type;推理一遍:第一轮,A没有依赖,就绪;B依赖A,但A还没出现在Done里,不就绪;C、D同理。筛选出ready = [A],remaining = [B, C, D],Done = [A]。第二轮,B就绪;第三轮,B和D其实都就绪了吗?检查D依赖B,此时Done里有A、B,所以D就绪;C依赖A、B,也就绪。最终结果会是[A, B, C, D]或[A, B, D, C],取决于remaining的顺序。这个顺序已经满足依赖要求。
4.3 环检测:让编译错误直接在脸上拍
如果服务C依赖A,A又依赖C,会发生什么?第一轮没有任何节点就绪,ready为空,static_assert直接炸。这时候编译器的报错信息会指向topo_sort的static_assert,并在模板实例化堆栈里显示剩余的节点类型。实际使用中建议把static_assert的字符串信息写得足够直白,比如:
static_assert(!std::is_same_v<ready, type_list<>>, "dependency cycle detected: no init-ready service remains");我踩过一次坑:把环检测放在DFS里做,想用“已访问集合”判断是否回到祖先,结果发现DFS在DAG里也可能碰到已经访问过的节点,比如菱形依赖,C依赖A和B,B也依赖A,DFS从C走到B再走到A时,A早就在visited里了,但这不是环。要区分“祖先路径上的节点”和“已经完成遍历的节点”,还得维护额外的状态。相比之下,Kahn算法天然把环检测收敛到一个判断条件上,省心太多。
所以我的结论是:编译期图算法里,拓扑排序直接用Kahn思路,别用DFS后序遍历,更别用DFS做环检测。
5. 常量图上的编译期最短路:constexpr Dijkstra
5.1 为什么还需要最短路
依赖调度只需要拓扑排序,但实际工作中还会遇到另一类问题:图里的点之间有权重,我需要的不只是“一个合法的顺序”,而是“代价最小的路径”。比如编译期选择一条掉电保护策略链、算一条消息路由的最优路径,或者给渲染阶段排一个切换代价最小的顺序。
这类问题的共同点是:解是数值结果,不是类型排序。数值结果在C++14之后可以直接塞进constexpr函数里算,核心思路就是把运行时的Dijkstra原封不动地搬到编译期。
5.2 constexpr Dijkstra实现
我用一个类模板承载图数据,邻接矩阵直接作为std::array<std::array<int, N>, N>存起来。因为C++17里std::array的很多操作还不是constexpr,所以我手动用for循环填充。
#include <array> #include <cstddef> template<std::size_t N> struct ConstGraph { std::array<std::array<int, N>, N> w{}; constexpr ConstGraph() { for (std::size_t i = 0; i < N; ++i) { for (std::size_t j = 0; j < N; ++j) { w[i][j] = (i == j ? 0 : 1000000000); } } } constexpr void addEdge(std::size_t u, std::size_t v, int cost) { w[u][v] = cost; } constexpr std::array<int, N> dijkstra(std::size_t s) const { std::array<int, N> d{}; for (std::size_t i = 0; i < N; ++i) d[i] = 1000000000; d[s] = 0; std::array<bool, N> used{}; for (std::size_t i = 0; i < N; ++i) used[i] = false; for (std::size_t round = 0; round < N; ++round) { int v = -1; for (std::size_t i = 0; i < N; ++i) { if (!used[i] && (v == -1 || d[i] < d[v])) { v = static_cast<int>(i); } } if (v == -1 || d[v] == 1000000000) break; used[v] = true; for (std::size_t to = 0; to < N; ++to) { if (d[v] + w[v][to] < d[to]) { d[to] = d[v] + w[v][to]; } } } return d; } };这里最繁琐的是初始化。邻接矩阵里对角线是0,其他是无穷大。用static_cast<int>(i)是为了避免无符号到有符号的告警。
5.3 一个完整可验证的例子
造一张6个节点的图,节点0到5:
constexpr ConstGraph<6> makeGraph() { ConstGraph<6> g; g.addEdge(0, 1, 4); g.addEdge(0, 2, 2); g.addEdge(1, 2, 1); g.addEdge(1, 3, 5); g.addEdge(2, 3, 8); g.addEdge(2, 4, 10); g.addEdge(3, 4, 2); g.addEdge(3, 5, 6); g.addEdge(4, 5, 3); return g; } constexpr ConstGraph<6> graph = makeGraph(); static_assert(graph.dijkstra(0)[1] == 4); static_assert(graph.dijkstra(0)[2] == 2); static_assert(graph.dijkstra(0)[3] == 9); static_assert(graph.dijkstra(0)[4] == 11); static_assert(graph.dijkstra(0)[5] == 14);从头推一下:0到3的直接边是8,但走0->1->2->3的代价是4+1+8=13,更大;走0->1->3是4+5=9,所以结果是9。0到5的直接边是从3中转,0->2->4->5是2+10+3=15,0->3->5是9+6=15,但0->2->3->5是2+8+6=16,0->1->3->5是4+5+6=15,所有路径里最优是9+6=15?等等,我刚写的static_assert是graph.dijkstra(0)[5] == 14,需要重新算一下。
0到4的最短路:0->2(2)->4(10)=12,0->1->2->4=4+1+10=15,0->3->4=9+2=11?不对,0->3是9吗?0->1->3是4+5=9,然后3->4是2,所以0->4是11。static_assert里写的是11,对的。0->5:0->4(11)->5(3)=14,0->3(9)->5(6)=15,所以0->5=14。static_assert正确。
所以在写static_assert前,表里的两条断言[3] == 9和[5] == 14是对的,[4] == 11也对。这个例子可以放心使用。
constexpr Dijkstra的编译速度,在节点数几十的时候完全没问题。到了几百个节点,每一次constexpr求值都要在编译期模拟完整循环,编译时间会明显上涨,但不至于爆炸。真到了上千节点,建议考虑换运行期算法,或者重新审视“这个图真的需要在编译期算吗”。
6. 综合实战:编译期依赖注入调度器完整实现
6.1 需求与接口设计
把前几章的东西组合起来,做一个能直接放进项目里的小组件:服务的依赖关系用类型声明,编译期算好启动顺序,运行时按这个顺序逐个初始化。有环直接编译失败。
对外接口我希望是这么用的:
using AllServices = type_list<ServiceA, ServiceB, ServiceC, ServiceD>; using BootOrder = topo_sort<AllServices>::type; Dispatcher<BootOrder>::boot();使用者不需要关心拓扑排序细节,只需要把所有服务类型汇总成一个type_list,然后交给Dispatcher。
6.2 核心实现
拓扑排序部分沿用第4章的元函数,这里不再重复。新增的是Dispatcher,它按编译期算出的顺序展开初始化动作:
#include <iostream> template<typename T> constexpr const char* serviceName() { return "unknown"; } template<> constexpr const char* serviceName<ServiceA>() { return "ServiceA"; } template<> constexpr const char* serviceName<ServiceB>() { return "ServiceB"; } template<> constexpr const char* serviceName<ServiceC>() { return "ServiceC"; } template<> constexpr const char* serviceName<ServiceD>() { return "ServiceD"; } template<typename T> void bootOne() { std::cout << "boot " << serviceName<T>() << '\n'; } template<typename... Ts> void bootAll(type_list<Ts...>) { (bootOne<Ts>(), ...); } template<typename Order> struct Dispatcher; template<typename... Ts> struct Dispatcher<type_list<Ts...>> { static void boot() { bootAll(type_list<Ts...>{}); } };bootAll里的折叠表达式(bootOne<Ts>(), ...);在C++17里按顺序展开,顺序就是Dispatcher收到的类型列表顺序,也就是编译期已经算好的拓扑序。
6.3 运行验证与静态保证
主函数只有几行:
int main() { Dispatcher<BootOrder>::boot(); }输出:
boot ServiceA boot ServiceB boot ServiceC boot ServiceD这里要注意,即使我故意把AllServices写成type_list<ServiceC, ServiceD, ServiceB, ServiceA>,BootOrder 也会被重新排序成满足依赖关系的顺序。因为topo_sort是在类型层面完成的,跟列表的原始顺序无关。
更有意思的是:如果你给ServiceA加上一个依赖ServiceD,而ServiceD依赖ServiceB、ServiceB依赖ServiceA,就会形成环。此时编译会直接报错,static_assert信息告诉你“dependency cycle detected”。这个静态保证的价值很大,因为运行时你再怎么防御,都不如让错误根本编译不过去。
我实际在项目里还加了一层校验:闭包检查。在启动前用第3章的DFS闭包确认“所有依赖都在AllServices里”,防止有人声明了依赖但忘了把服务类型加进列表。这个校验在编译期做只需要一条static_assert:
template<typename T> using all_deps_covered = std::bool_constant< all_in<typename deps<T>::type, AllServices>::value >; static_assert(all_in<typename deps<ServiceC>::type, AllServices>::value, "some dependency is missing from AllServices");这样整个调度器的静态保证就是双层的:依赖必须完整覆盖,且不能成环。
7. 模板图算法的坑位、优化与我的心得
7.1 递归实例化深度是一条红线
模板递归天然受编译器实例化深度限制。GCC/Clang默认的-ftemplate-depth是900左右,也就是说topo_sort这种每处理一层就递归一层的写法,遇到几百个节点就很危险。压实测,100个节点内没问题,300个节点开始要留意,500个节点以上建议重新评估。
如果节点数量确实大,有两个方向:一是改用constexpr迭代式算法,用std::array做显式容器,循环代替递归,彻底绕开模板实例化深度问题;二是把图分层次,先对强连通分量做缩点,再在缩点后的DAG上跑拓扑排序,但这个搬到模板世界里复杂度不低,性价比一般。
7.2 编译时间爆炸的防御策略
模板图算法的编译时间不是线性的,因为每次筛选、合并、递归都会实例化一大批模板。我在一个1000节点的图上实测过,拓扑排序的编译时间接近数十秒,内存占用也明显上升。对这个规模,constexpr版本的算法可能只需要一两秒。
防御策略很简单:给topo_sort加一个节点数量上限的静态断言。用sizeof...(Ts)拿到节点数,超过阈值直接报错,宁可编译失败也不要让它吭哧吭哧跑半天再失败:
static_assert(sizeof...(AllServices) <= 256, "too many services for compile-time topo sort");阈值可以按你的项目情况调整。我在实际项目里定的是256,超过就走运行期初始化容器。
7.3 报错信息可读性优化
模板元编程最大的痛点就是报错信息又臭又长。优化手段有几条,属于经验总结:
第一,所有的static_assert信息写清楚业务语义,别写“static assert failed”这种废话。第二,如果想让报错里带着剩余节点类型,可以额外加一个继承自std::false_type的辅助模板,把剩余类型作为模板参数暴露出来:
template<typename Remaining, typename Done> struct topo_fail : std::false_type { using failed_remaining = Remaining; }; // 在static_assert里: static_assert(topo_fail<All, Done>::value, "cycle detected, remaining nodes are shown in failed_remaining");这样编译器报错时,实例化堆栈会显示failed_remaining = type_list<ServiceA, ServiceD, ...>,定位问题快很多。不过这个技巧依赖于编译器的报错质量,GCC的模板实例化上下文比MSVC清晰一些。
第三,把复杂的元函数用别名模板包一层,简化外层代码:
template<typename T> using deps_t = typename deps<T>::type; template<typename List> using concat_t = typename concat<List>::type;虽然不改变编译本质,但对自己维护代码帮助很大。
7.4 个人心得与扩展方向
编译期图算法这套组合拳,我从依赖注入容器开始试水,后来陆续用在了三个方向:编译期状态机转换表校验、表达式模板的Token依赖排序、以及管线阶段的依赖检查。每一个的共同点都是:图的结构藏在类型关系里,早算早安心。
如果只让我总结一条经验,那就是:先判断图能不能用constexpr迭代算法解决,能就不用模板递归;只有在结果必须参与类型计算时,才值得上递归元函数。把这两种思路混合用,既能控制编译时间,又能拿到类型层面的静态保证。
这部分的扩展空间其实很大:把 DAG 的最长路径算出来,可以得到关键路径;把支配树跑一遍,可以找到哪些服务是必须单例的;把强连通分量缩掉,可以判断哪些模块形成了循环依赖团。模板元编程限制很多,但图算法该有的思想,它一样都不少。