“模板不就是一个写通用代码的东西吗?把 T 换成 int、换成 string,一份代码到处用?那‘模板元编程’又是什么?”
这是我被问得最多的问题。说实话,在我写 C++ 的第八个年头、被一段二十层嵌套的模板报错折磨到凌晨两点之前,我也没认真琢磨过它。直到我发现,那套看着像“黑魔法”的技术,本质上就是 C++ 在编译期提供的一台微型计算机:你给它一段“程序”,它在编译阶段运行,吐出新的类型和常量。这就是 C++ 模板元编程——用模板在编译期做计算、做类型变换、做代码选择的编程方式。
这篇文章整理了从入门到能正常使用的完整路径。适合已经写了半年到几年 C++、却始终没把模板机制吃透的开发者阅读。你会弄明白模板元编程到底在解决什么问题、它的核心套路是什么、以及它在真实工程里(甚至面试八股题里)是怎么出现的。
1. 为什么说“编译期也是可以写程序的”
1.1 从一段“类型太多写不动重载”的经历说起
几年前我写一个通用序列化模块,最初的做法简单粗暴:每种类型写一个 Serialize 重载。先是 int、double、std::string,然后来了自定义结构体、容器、指针组合……重载数量迅速膨胀,代码里全是“复制粘贴改类型名”的痕迹。到后来,新增一种类型要改七八个地方,我甚至在代码评审里被同事调侃说“你这不是在写程序,是在做填空题”。
当时最需要的东西,是“根据类型特征自动决定该走哪条路”的能力。比如,如果某个类型自带 Serialize 成员函数,就调用它;如果是算术类型,就走数值转换;如果是容器,就遍历元素逐项序列化。这种判断必须在编译期完成,因为运行时已经来不及了——函数重载决议、分支选择都发生在类型确定的那一刻。
这正是模板元编程的用武之地。它不是让你“多写一份通用模板”,而是让你在类型系统里描述规则,让编译器替你完成“看类型、做判断、选路径”这三件事。
1.2 模板不仅仅是一个“类型容器”,它是一台图灵完备的编译期虚拟机
很多人对模板的理解停留在“泛型容器”:std::vector<T> 里的 T 是什么,容器就装什么。这个理解没有错,但严重低估了模板的能力。
模板机制实际上给了你三样东西:
- 类型参数:
template<typename T>,让类型成为可操作的“数据” - 非类型参数:
template<int N>或template<auto V>,让常量也能参与类型运算 - 特化与偏特化:编译器可以根据类型模式选择不同的“实现分支”
模板元编程有趣的地方在第四样:因为模板是按需实例化的,而实例化过程中会递归地触发更多实例化,所以整个模板机制等价于一台图灵完备的编译期计算机。这句话翻译成人话就是:在编译期,你理论上可以计算任何可计算的东西。比如编译期算斐波那契、编译期给类型列表排序,甚至编译期写一个解释器,这些在 C++ 里都成立。
我第一次意识到这件事是在读 Boost.MPL 源码的时候。看到 mpl::if_、mpl::and_、mpl::int_ 这些东西的时候,脑子里闪过的念头是:这不就是一门 Lisp 吗?只不过它在编译期运行,操作对象是类型和常量。这门“语言”没有运行时开销,它的代价全部发生在编译阶段。
1.3 运行期计算和编译期计算,本质差别在哪
很多初学者搞不明白“编译期”到底意味着什么。我用一个最直白的例子说明:
cpp复制// 运行期:程序跑起来之后算
int factorial_runtime(int n) {
return n <= 1 ? 1 : n * factorial_runtime(n - 1);
}
// 编译期:程序编译的过程中就算完了
template <unsigned N>
struct Factorial {
static constexpr unsigned value = N * Factorial<N - 1>::value;
};
template <>
struct Factorial<0> {
static constexpr unsigned value = 1;
};
int main() {
int a = factorial_runtime(5); // 运行时计算
int b = Factorial<5>::value; // 编译期计算,值为120
return a + b;
}
区别在哪?Factorial<5>::value 在编译阶段就已经折叠成 120,生成的目标代码里直接把这个常量塞进去,运行时零调用、零跳转、零函数栈。而 factorial_runtime(5) 编译后还是一段真实的递归函数代码,每次运行都走一遍。
编译期计算省掉的是运行时间,付出的是编译时间和模板实例化带来的内存开销。所以这不是“越底层越高级”的无脑追求,而是一种权衡。模板元编程的适用场景是:这个计算在运行时被调用非常频繁,而它的输入在编译期就能确定。比如嵌入式开发里没有浮点单元,某些数值计算刻意挪到编译期完成;比如游戏引擎里很多数学常量表希望在启动前就生成。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一个模板元程序:从编译期阶乘理解“递归实例化”的思维
2.1 用特化实现递归终止
模板元编程的第一个也是最核心的套路,就是“递归 + 特化终止”。上面那个 Factorial 就是最标准的例子。
有人可能会问:为什么模板元编程不用循环?因为模板实例化机制根本不提供“循环”这种结构。编译器在看到一个 Factorial<5> 的引用时,需要 Factorial<4> 的完整定义;看到 Factorial<4> 时,又需要 Factorial<3> 的完整定义……于是它被迫一路实例化下去,形成一条实例化链。这就是“编译期递归”的本质。
这条链怎么终止?答案是偏特化。当参数变成 0 时,编译器发现全特化 template<> struct Factorial<0> 比普通模板更匹配,于是走特化分支,不再生成下一层实例。这个过程也被称为“模板模式匹配”。
这个模式我会在后面的 TypeList 中反复用到。可以说,你只要掌握了“递归展开 + 偏特化终止 + 模式匹配”这三板斧,模板元编程的一半就通了。
2.2 非类型模板参数:把常量送进类型世界
template<unsigned N> 这种写法的奥妙在于,模板参数不只是类型,还可以是常量。这就像给编译器这台“类型机器”提供了一个常量输入口。你可以在编译期拿到一个数字,然后基于这个数字做计算、做选择,甚至基于它构造新类型。
一个常见应用是编译期数组形状组合:
cpp复制template <typename T, size_t Rows, size_t Cols>
struct Matrix {
static constexpr size_t kRows = Rows;
static constexpr size_t kCols = Cols;
T data[Rows * Cols];
};
注意这里 T data[Rows * Cols] 里的 Rows * Cols 是编译期常量表达式,所以它能用来定义数组尺寸。如果你试图把运行期的变量塞进去,编译器会立刻报错。这个例子看起来简单,但它展示了模板元编程最底层的连接点:类型参数和非类型参数组合在一起,可以在类型定义内部生成依赖编译期计算的布局。
2.3 从 Factorial 到 constexpr:现代 C++ 对模板元编程的“平替”
学模板元编程的人很容易陷入一个误区:认为所有“编译期计算”都必须写成模板递归。从 C++11 开始,constexpr 函数提供了更自然的写编译期计算的方式:
cpp复制constexpr unsigned factorial_constexpr(unsigned n) {
return n <= 1 ? 1 : n * factorial_constexpr(n - 1);
}
在 C++14 之后,constexpr 函数体内甚至允许 for 循环、局部变量等普通语句,这让编译期计算代码的写法趋于“普通函数”。
那模板元编程还有没有存在的必要?有,但场景变了。constexpr 擅长处理“值计算”——给定常量,返回常量。而模板元编程真正不可替代的领域是“类型计算”——给定类型,产出类型。比如判断 T 是否带 begin() 成员、从参数包中挑出某种类型、为类型生成一个带指定精度的哈希标签,这些都不是 constexpr 能直接搞定的,因为你在编译期操作的对象不是一个“值”,而是一个“类型”。
所以更准确的理解是:C++ 从 C++11 开始进入了“两条腿走路”阶段。纯值计算用 constexpr;类型变换、类型萃取、编译期决策仍然用模板递归和特化。二者经常配合使用。
3. 类型层面的编程:trait、特化与 SFINAE
3.1 类型萃取的本质:把类型视为数据
你第一次看到 std::is_same<T, int>::value、std::remove_reference<T>::type 这类代码的时候,可能没意识到一件事:这就是模板元编程。std::is_same 就是一个编译期判断类型是否相等的函数,std::remove_reference 就是类型世界里的“字符串替换”。
它们的工作原理完全一致。以 is_same 为例:
cpp复制template <typename T, typename U>
struct is_same : std::false_type {};
template <typename T>
struct is_same<T, T> : std::true_type {};
这里的关键是偏特化 is_same<T, T>。当两个模板实参完全相同、能匹配上这个偏特化模式时,编译器就会选择它;否则走主模板的 false_type。于是 is_same<int, int>::value 是 true,is_same<int, double>::value 是 false。整个过程没有任何运行时代码,纯粹是编译期的类型模式匹配。
你可能会问:为什么用继承 std::true_type / std::false_type 而不是简单写一个 static constexpr bool value?因为继承 true_type 的 trait 不仅自带 value,还能参与重载决议、搭配标签分派(tag dispatch)。比如:
cpp复制void dispatch(std::true_type) { /* 类型相等时的处理 */ }
void dispatch(std::false_type) { /* 类型不等时的处理 */ }
// 调用处
dispatch(is_same<MyType, int>{});
这个技巧在标准库和很多库代码里极常见。
3.2 SFINAE 的工作原理与 enable_if 的常规用法
SFINAE 全称 Substitution Failure Is Not An Error(替换失败不是错误),这是模板元编程里最容易让人头疼、也最强大的机制之一。
一句话解释:编译器在做模板实参替换时,如果某个模板候选因为替换导致代码非法(比如访问了不存在的成员),它不会直接导致编译失败,而是默默地把这个候选从“候选集”里剔除,继续找别的重载。你可以把这张牌想象成一场面试:候选人(模板函数)在展示自己的时候出了个丑,面试官不会直接把整场面试砸掉,而是面无表情地抽走这份简历,继续面下一位。
最有代表性的实现就是 std::enable_if 配合重载选择。举个例子,判断一个类型是否带 begin() 成员:
cpp复制template <typename T, typename = void>
struct HasBegin : std::false_type {};
template <typename T>
struct HasBegin<T, std::void_t<decltype(std::declval<T>().begin())>> : std::true_type {};
这里的 std::void_t<decltype(...)> 是一个“探测表达式”。如果 T 有 begin() 成员,decltype(std::declval<T>().begin()) 合法,那么 std::void_t<...> 就是 void,于是 HasBegin<T, void> 能匹配上偏特化,结果是 true_type。如果 T 没有 begin(),探测表达式替换失败,偏特化被 SFINAE 剔除,结果落到 false_type。
掌握这个套路之后,你就能写出大量的“类型能力探测”工具,比如判断一个类型是否可流式输出:
cpp复制template <typename T, typename = void>
struct IsOstreamPrintable : std::false_type {};
template <typename T>
struct IsOstreamPrintable<T, std::void_t<decltype(std::declval<std::ostream&>() << std::declval<T>())>> : std::true_type {};
这类代码在写通用库、做开发框架、实现代码生成工具时几乎是刚需。
3.3 一个完整例子:通用序列化重载选择
回到文章开头那个序列化场景。我现在要构造一个函数:如果类型自带 serialize(std::ostream&) 成员函数,就走成员函数;否则如果类型是算术类型,走 std::to_string;两种都没有,就编译报错。
传统写法是打开重载集合,用 SFINAE 剔除不符合条件的:
cpp复制#include <iostream>
#include <sstream>
#include <string>
#include <type_traits>
// 候选1:类型自带 serialize 成员函数
template <typename T>
auto MySerialize(const T& v, int)
-> decltype(std::declval<T>().serialize(std::declval<std::ostream&>()), std::string()) {
std::ostringstream oss;
v.serialize(oss);
return oss.str();
}
// 候选2:算术类型走 to_string
template <typename T>
std::string MySerialize(const T& v, long) {
static_assert(std::is_arithmetic<T>::value, "MySerialize only supports types with serialize() or arithmetic types");
return std::to_string(v);
}
// 入口:0 可以转为 int,所以优先选择候选1
template <typename T>
std::string MySerialize(const T& v) {
return MySerialize(v, 0);
}
注意这两个重载的参数一个是 int,一个是 long。调用时传 0,精确匹配 int 版本。如果 T 没有 serialize 成员,候选1在替换阶段失败,0 只能隐式转换成 long 去匹配候选2。如果两个都失败,那候选2的 static_assert 就会给出一个可读的编译信息,而不是让你去解谜一段深埋二十层的模板错误。
这个“用 int/long 做优先级控制”的小技巧在写通用重载时特别常用,它比 std::enable_if 更简洁,也更容易控制顺序。
4. 进阶:用模板元编程实现类型列表与编译期算法
4.1 从 type list 到类型计算
走到这一步,你已经完成了从“入门”到“会用”的跨越。但要说“精通”,还得会处理更复杂的类型组合问题。模板元编程里最经典的数据结构就是 TypeList:一个装类型的“列表容器”。
cpp复制template <typename... Ts>
struct TypeList {};
没错,就这么朴素。TypeList<int, double, std::string> 表示一个包含三个类型的列表。然后我可以在它上面实现各种“类型算法”。先看最基础的长度计算:
cpp复制template <typename List>
struct Length;
template <typename... Ts>
struct Length<TypeList<Ts...>> : std::integral_constant<size_t, sizeof...(Ts)> {};
这里用到了偏特化 Length<TypeList<Ts...>> 来展开参数包,然后通过 sizeof...(Ts) 取出类型个数。std::integral_constant<size_t, N> 是标准库提供的“携带整型常量”的工具,相比自己写 static constexpr size_t value = N 更规范,也能直接参与类型运算与重载决议。
再看取第 N 个类型:
cpp复制template <size_t N, typename List>
struct At;
template <size_t N, typename T, typename... Rest>
struct At<N, TypeList<T, Rest...>> : At<N - 1, TypeList<Rest...>> {};
template <typename T, typename... Rest>
struct At<0, TypeList<T, Rest...>> {
using type = T;
};
这个递归模式很典型:每次取出 TypeList 的第一个类型 T,把剩下的 Rest... 继续包装成 TypeList 传给下一层;当 N 减到 0 时,取到目标位置。整个过程和链表的前缀遍历一模一样,只不过把“节点”换成了“类型”。
4.2 类型过滤、查找与编译期算法
下一步,我把这些基础工具组合起来,实现一个“查找某个类型是否存在”的编译期判断:
cpp复制template <typename Target, typename List>
struct Contains;
template <typename Target>
struct Contains<Target, TypeList<>> : std::false_type {};
template <typename Target, typename... Rest>
struct Contains<Target, TypeList<Target, Rest...>> : std::true_type {};
template <typename Target, typename First, typename... Rest>
struct Contains<Target, TypeList<First, Rest...>> : Contains<Target, TypeList<Rest...>> {};
这里我用了个技巧:当第一个类型恰好等于 Target 时,Contains<Target, TypeList<Target, Rest...>> 这个偏特化比第三个偏特化更特化,编译器会优先选择它,于是提前终止递归,结果就是 true_type。否则,第三个偏特化被选中,继续对 Rest... 递归。
再来一个稍微复杂的:按谓词过滤类型列表。假定我想从 TypeList<int, std::string, double, char> 中挑出所有算术类型:
cpp复制template <template <typename> class Pred, typename List>
struct Filter;
template <template <typename> class Pred>
struct Filter<Pred, TypeList<>> {
using type = TypeList<>;
};
template <template <typename> class Pred, typename First, typename... Rest>
struct Filter<Pred, TypeList<First, Rest...>> {
using Tail = typename Filter<Pred, TypeList<Rest...>>::type;
using type = std::conditional_t<Pred<First>::value,
typename PushFront<Tail, First>::type,
Tail>;
};
template <typename List, typename T>
struct PushFront;
template <typename... Ts, typename T>
struct PushFront<TypeList<Ts...>, T> {
using type = TypeList<T, Ts...>;
};
如果让我用一个词评价这段代码的思维方式,那就是“分治”。每次只拿走列表第一个类型,判断它是否满足谓词;满足就插到结果列表头部,不满足就丢弃。递归处理剩下的部分。
写到这里,你应该已经感觉到了:模板元编程里的算法,和你在运行时写的算法,逻辑是一致的。只是它的操作对象是类型,语言设施只有“模式匹配 + 递归 + 偏特化”。一旦把这三样东西内化,任何运行时算法都理论上都能翻译成编译期版本。这就是“精通”的分界点。
4.3 你会在真实项目里看到它的地方
讲了这么多理论,有人可能会问:现实项目真的需要写 Filter 这种类型列表吗?
看场景。如果你只是写业务代码,八成一辈子不会直接写类型列表。但如果你用过这些库,你会看到它的影子:
- OpenCV 的
saturate_cast<T>:内部会根据目标类型是否为浮点、有符号整数、无符号整数,在编译期选择不同的截断算法。这是模板元编程在图像算法库里的典型应用。 - Eigen 的矩阵表达式模板:整个表达式模板体系依赖模板递归和类型变换,一个复杂的矩阵运算表达式在编译期会被拆解成类型树。
- Boost.MPL / Boost.Hana:全覆盖的元编程库,里的
mpl::find_if、hana::transform和我们上面手写的Filter思路一模一样。 - 很多工厂类框架:需要根据注册类型生成分发表时,类型列表 + 编译期遍历可以避免大量手写重复代码。
如果你没接触过这些库,建议找一两个读一读,不用全读,只看它们对类型做“模式匹配”的部分就够了。你会发现,我们前面推导的那些“丑代码”,其实就是这些库的地基。
5. 实战后的坑:实例化爆炸、代码膨胀与调试技巧
5.1 编译速度是如何被“慢慢”吃掉的
模板元编程的第一个隐藏成本是编译速度。模板是按需实例化的,但实例化结果会缓存。一个大型项目里,同样的模板以几十种不同类型被实例化是很正常的。每个实例都是一份完整代码,它们都要经过解析、语义分析、优化,最终生成目标代码。类型参数一变,实例就是全新的,编译器没有捷径可走。
我印象最深的一次是在一个模块里加了段递归模板,深度大约二十层,每个节点还展开了四个子节点。编译时间从三秒飙升到三分钟。我盯着那两百行模板错误信息,第一次怀疑人生。后来怎么解决的?把递归层次压扁,换成 if constexpr 分支合并同类路径,编译时间回到十几秒。
经验是:模板元编程适合“宽度小、深度可控”的场景。如果你的递归深度是运行时数据决定的(比如依赖某个编译期常量,而这个常量本身很大),你要小心了——编译器是物理有限机器,模板递归深度有上限,过度递归会直接编译失败。
5.2 让编译器说出它到底推导了什么:类型打印三板斧
调试模板元编程最大的痛点是“看不见中间状态”。普通程序可以打断点、打印变量,但模板元编程的中间结果是一堆类型,你没法直接输出。
我的三板斧如下:
第一板斧,类型名打印函数:
cpp复制template <typename T>
constexpr const char* type_name() {
#ifdef _MSC_VER
return __FUNCSIG__;
#else
return __PRETTY_FUNCTION__;
#endif
}
这个函数本身不报错,但它能让你在 IDE 里通过鼠标悬停看到推导出的完整类型名。如果你在代码里写 auto x = type_name<MyComplexType>();,很多 IDE 会在悬停时显示 const char* type_name<T>() [with T = ...],中间的就是完整类型。
第二板斧,故意制造编译错误让编译器“自报家门”:
cpp复制template <typename T>
struct TypePrinter; // 只有声明,没有定义
TypePrinter<MyComplexType> dummy; // 编译时报错:aggregate ‘TypePrinter<...> dummy’ has incomplete type
编译器在报不完整类型错误时,会把 MyComplexType 展开后的完整类型名打在错误信息里。这一招在模板层层套娃、类型被 remove_reference、decay 等变换得面目全非时特别管用。
第三板斧,用 static_assert 临时验证类型关系:
cpp复制static_assert(std::is_same_v<typename At<2, MyList>::type, double>,
"At<2> should be double");
这其实是从测试驱动借来的思路:在关键位置放一堆 static_assert,编译过了就说明推导结果符合预期,编译不过就立刻告诉你哪里不对。比对着报错信息猜快得多。
5.3 static_assert 的正确使用姿势
模板错误信息难读,很多是因为失败发生在“使用”端点,而不是“定义”端点。你想让调用者看懂错误,就得在入口处提前检查。
比如前面那个 MySerialize,候选2里的 static_assert 就是一种“约束前置”。它把错误信息从“无法将 Foo 转换为 long”这种八竿子打不着的报错,变成了一句人能看懂的话:“MySerialize 只支持带 serialize() 的类型或算术类型”。
再比如,写一个 CheckedCast 模板函数时:
cpp复制template <typename Target, typename Source>
Target CheckedCast(const Source& src) {
static_assert(std::is_arithmetic_v<Source>,
"CheckedCast: Source must be arithmetic");
static_assert(std::is_arithmetic_v<Target>,
"CheckedCast: Target must be arithmetic");
// 真正的转换逻辑
}
两个 static_assert 放在函数体最前面,任何一方不满足都能立刻指向具体约束,而不是让编译器深入到实现细节后报一段无关错误。这是我在代码评审里反复强调的习惯:模板库的使用者并不关心你的内部实现,他们只想知道“我的类型哪里用错了”。
6. C++17/20 之后:老模板元编程还有没有用
6.1 if constexpr:模板元编程的“降维武器”
C++17 引入的 if constexpr 可以说是传统模板元编程的一大“劲敌”。它允许你在函数体内直接写“编译期分支”,而不需要靠重载、SFINAE、标签分派这些绕圈子的手段。
还是拿序列化举例。传统做法要写两个重载加一个 int/long 优先级,用 if constexpr 可以收敛到一个函数里:
cpp复制template <typename T>
std::string MySerializeModern(const T& v) {
if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(v);
} else if constexpr (HasBegin<T>::value) {
return RangeSerialize(v.begin(), v.end());
} else {
static_assert(std::is_arithmetic_v<T> || HasBegin<T>::value,
"MySerializeModern only supports arithmetic types or ranges");
}
}
注意 if constexpr 的“剪枝”语义:条件为假的分支根本不会被实例化,只会做语法解析,所以分支里的代码甚至可以是“当前上下文里非法”的,只要语法是合法的。这从根本上解决了传统 SFINAE 代码读起来像天书的问题。
那传统模板元编程还有必要学吗?我的答案非常明确:有必要,而且是理解现代写法的前提。if constexpr 只是让“值分支”写起来更舒服,但类型变换、类型萃取、模式匹配这些底层能力,仍然是模板特化和偏特化提供的。遇见一个复杂的 concepts 匹配失败时,你依然得靠 is_same、remove_reference、偏特化机制去诊断问题。
6.2 concepts:让约束描述变得像说话一样自然
C++20 的 concepts 进一步改善了模板代码的可读性。它可以把散落各处的 enable_if、void_t 探测器收敛成一个具名的约束表达式:
cpp复制template <typename T>
concept Arithmetic = std::is_arithmetic_v<T>;
template <typename T>
concept HasBegin = requires(T t) {
t.begin();
};
auto MySerializeCpp20(Arithmetic auto v) {
return std::to_string(v);
}
这种写法的可读性比传统 SFINAE 高了不止一个数量级。但注意,concepts 的底层实现依然依赖模板替换机制,requires 表达式的本质还是“探测表达式是否合法”。所以会传统模板元编程的人,未必每一步都知道,但遇到库内部遮挡不住的问题时,能更快定位到根因。
另外,现实中大量现有代码库(包括 Boost、OpenCV、很多嵌入式 SDK)仍是传统写法。会传统模板元编程相当于带着一张地图去阅读这些旧代码,而不是每行都靠猜。
6.3 我的建议:新旧共融的技术路线
我自己的技术路线是:新代码优先用 if constexpr、concepts、constexpr 函数,尽量避免为了一个简单的分支重写满屏的模板结构。但对于类型变换和类型列表这类核心算法,我依然用传统的模板特化与偏特化,因为工具库需要最大兼容性,而偏特化的表达力在类型世界里目前仍是不可替代的。
如果让我给后来者三个具体的练习建议,我会说:
- 别急着读 Boost.MPL 源码,先自己手写
is_same、remove_reference、enable_if。标准库里处处都是模板元编程,读懂这三个就得一多半功力。 - 做一个小的
TypeList,实现Length、At、Contains、Filter四个操作。别怕写得丑,重点是体会“递归 + 偏特化 + 模式匹配”的编程范式。 - 把你之前写过的一个“用重载硬怼”的函数,改成 SFINAE 或者
if constexpr通用版本。真实场景永远比虚构练习更有记忆点。
模板元编程这东西,学的时候很容易被一堆尖括号吓退,但真当你把“类型就是数据、编译期就是一台虚拟机”这个视角建立起来之后,你会发现它其实没有多神秘。它只是 C++ 给追求极致性能和极强类型表达能力的人,留的那扇后门。我的经验是别贪多,每天只消化一个模板元编程小概念,配合一个小例子,过一两个月再回头看那些开源库的模板部分,你会觉得它们“顺眼”了很多。
