在C++里,如果说STL是通用工具箱,那模板就是锻造这个工具箱的工业母机。作为泛型编程的核心利器,模板让类型成为参数,让代码在编译期完成大量原本需要运行期判断的工作。我第一份工作维护老项目时,天天复制粘贴int版数组处理函数,改成double版、float版、结构体版,直到有位前辈把函数改成函数模板,我才意识到之前的做法有多笨。这篇文章不按cppreference念定义,而是站在我实际写代码、调模板报错、做技术方案的角度,把函数模板、类模板、模板元编程和工程化经验串起来,给准备入门泛型编程,或者面试前想系统梳理C++模板的读者一份能直接用的参考。
1. 为什么需要模板?泛型编程解决的核心痛点
1.1 重复代码让我抓狂:从int版swap到泛型版本
很多教程喜欢用swap函数讲模板,因为这是最直观的痛点。我最早写C语言时,想交换两个int只需要三行临时变量,可一旦要交换double、交换自定义结构体,就得重新写一版。结构体一多,十几个swap变体躺在代码里,改一处逻辑要同步改十几处,稍不留神就会漏掉某个类型。C语言里有void*写法,把所有类型的指针都塞进void*参数里,但这等于把类型信息丢掉,无论传入什么都能编译,运行时类型错了根本没人提醒。
函数模板解决的就是这个“类型维度上的重复”。把要操作的类型抽象成模板参数T,一份代码就能覆盖所有类型:
template <typename T> void my_swap(T& a, T& b) { T tmp = a; a = b; b = tmp; }这里T不是一个具体类型,而是占位符。调用my_swap(x, y)时,编译器看到参数的实时类型,自动生成对应版本的代码。和void*最本质的区别在于,模板是类型安全的:如果你传入两个不兼容的类型,编译器会在调用点给出明确的错误信息,而不是等到运行时炸掉。这也是为什么C++标准库的std::sort、std::vector能服务成千上万种类型,整套泛型机制就是地基。
泛型编程的思路到这里已经成型:把“类型”本身作为参数,让算法和容器不再绑死在某个具体类型上。模板就是这种思路在C++里的落地载体。它不是某个语法糖,而是一套编译期代码生成机制,这也是它比Java泛型、Go泛型走得更远的原因之一。
1.2 编译期多态与运行期多态,怎么选?
面试时几乎必问“模板和虚函数有什么区别”,很多八股答案是“模板是编译期多态,虚函数是运行期多态”。这话听着抽象,但落到工程里非常实在。
模板的“多态”发生在编译期。my_swap<int>和my_swap<double>是两个独立的函数,调用哪个在编译期就永远固定了,不存在运行时的跳转表、虚表指针,也没有任何间接调用开销。整个类型系统的检查全部前移到编译期,所以编译器能跨函数内联、优化掉多余的分支。代价是“延迟编译”:“多态”的决策发生在每一个使用模板的地方,编译器需要在那个位置看到完整定义,才能生成对应的代码。
虚函数的多态则发生在运行期。同一个基类指针,指向不同的派生类对象时,调用虚函数会查虚表,拿到真正执行的地址。好处是运行期才决定,可以在运行时加载插件、通过配置文件装配对象;坏处是每次调用都有查表开销,而且很多时候编译器无法内联,性能敏感场景会肉疼。
工程选型时我会这样判断:如果类型集合在编译期就确定,比如一个算法要支持int、float、double,交给模板;如果类型集合要到运行期才确定,比如一个UI框架要响应不同控件对象,该用虚函数还是用虚函数。二者不冲突,实践中经常混用——模板负责编译期的类型展开,虚函数负责运行期的行为分派。理解了这个底层差别,再去读STL源码、看开源框架,就不会把模板当作“花活”了。
2. 从函数模板入手,最快掌握模板核心
2.1 3分钟写一个类型安全的max函数模板
函数模板是最容易上手的入口。我遇到团队新人,一般先丢给他一个任务:给max写一个泛型版本。第一版通常长这样:
template <typename T> T my_max(T a, T b) { return a > b ? a : b; }这能用,但有几个工程问题。第一,T按值传参,传入一个拷贝成本很高的对象时会白白复制两次;第二,返回类型如果写成T,返回的是副本,想要返回引用还得再写变体。比较稳妥的写法:
template <typename T> const T& my_max(const T& a, const T& b) { return a > b ? a : b; }用const T&接收参数,能避免不必要的拷贝,也允许传右值临时对象。返回const T&则保留原始数据的引用,避免二次复制。这里真正重要的是:模板参数T只要支持>运算,这段代码就能工作。int可以,double可以,自定义的Person只要重载了operator>也可以。这种“隐式接口”概念是模板和抽象类最大的不同——抽象类要求你显式继承并实现虚函数,模板只要求类型满足某些表达式合法即可。
实际写业务代码时,我一般会加一个细节:把比较规则的扩展留出来。比如要让my_max也能比较字符串长度,直接在原模板上改会把逻辑写死,不如拆成两个参数,把比较器也做成模板参数。标准库的std::max、std::sort都是这个套路。
2.2 函数模板的类型推导规则与避坑细节
类型推导是模板使用中最大的坑,八股文高频考点,也是日常写代码最容易被拌倒的地方。先说最常见的推导规则:如果模板参数T出现在普通参数位置,比如const T& a,编译器会忽略引用和顶层const,然后按实参类型推导出T。但有几个反直觉的细节必须记住。
字符串字面量是个经典坑。写my_max("abc", "bcd")时,字符串字面量实际上先被推导成const char[4]数组类型,数组和const char*之间又不完全等价。如果你把两个长度不同的字符串传进去,比如"abc"和"b",推导出的数组类型分别是const char[4]和const char[2],根本对不上,直接编译失败。我在项目里封装日志库时就踩过,最后要么显式指定my_max<std::string>(...),要么提前把字符串转成std::string,不能依赖隐式推导。
另一个易错点是多个参数共享同一个模板参数时的类型一致性。template<typename T> T add(T a, T b)要求两个参数必须是同一个T,你传int和double就会出现推导冲突。规避办法是把模板参数拆开:
template <typename U, typename V> auto add(U a, V b) -> decltype(a + b) { return a + b; }这里auto返回值加后置decltype,让返回类型自动推导为a + b的实际类型。这种写法在处理混合类型运算时非常实用,比如add(1, 2.5)返回double,不会因为模板参数统一而报错。
还有一类是“万能引用”相关的问题。模板参数写成T&&时,如果传入左值,T会被推导成左值引用类型,最终实参类型就是T&;如果传入右值,T就是普通类型。这个机制配合std::forward才能实现完美转发。日常写库代码时,凡是需要转发参数给下游函数的,几乎都长这样:
template <typename F, typename... Args> void call(F&& f, Args&&... args) { f(std::forward<Args>(args)...); }刚开始不要求深究每个引用折叠细节,但至少要知道T&&和普通T&行为完全不同。
2.3 函数模板重载与特化,哪个优先级更高?
函数模板可以和普通函数重载,也可以在模板之外专门写一个非模板函数,它们之间的匹配优先级经常让新人困惑。我梳理一下常见结论:编译器做重载决议时,普通函数优先于函数模板;如果多个模板都能匹配,则选择最“特化”的那个模板——也就是参数更具体、经过偏序推导后更精确的版本。
举个例子,我想让my_max在比较两个const char*时直接用strcmp,而不是比较指针地址:
template <typename T> const T& my_max(const T& a, const T& b); const char* my_max(const char* a, const char* b) { return std::strcmp(a, b) > 0 ? a : b; }当调用my_max("x", "y")时,非模板的const char*版本优先,行为就是我们期望的字典序比较。如果只写一个函数模板显式特化,比如template<> const char* my_max(const char*& a, const char*& b),行为会混乱得多,在重载决议中的选择优先级有时反而不如预期。所以我的长期经验是:函数模板需要特殊处理某个类型时,优先写普通重载函数,而不是写显式特化。显式特化更适合类模板定制场景,函数层面用重载维护性更好。
这条规则背后还有个坑:如果普通函数和模板函数声明在不同的头文件,可能因为只看到模板版本,导致走了错误的重载。所以写模板库时,我把非模板重载和模板放同一个头文件,并且保证用户include的顺序不会影响决议结果。
3. 类模板是怎么成为容器与设计模式的万能积木
3.1 类模板基本写法:成员函数、静态成员和友元
函数模板处理的是“算法”,类模板处理的是“数据类型”。std::vector、std::map都是类模板,本质是让容器容纳任意元素类型。定义一个类模板并不复杂:
template <typename T> class Stack { public: void push(const T& value); void pop(); const T& top() const; bool empty() const { return items_.empty(); } private: std::vector<T> items_; };类模板的成员函数如果要在类外定义,必须重新带上模板参数声明:
template <typename T> void Stack<T>::push(const T& value) { items_.push_back(value); } template <typename T> const T& Stack<T>::top() const { return items_.back(); }这里的语法细节很容易写错:类名后面必须跟<T>,Stack<T>::push才是一个具体的、属于“Stack这个类模板的某次实例化”的成员。写头文件时,类模板的成员函数定义天然放在类外时容易漏掉最前面的template <typename T>;漏掉后编译器会把它当成普通类的普通成员函数,然后就出一堆摸不着头脑的错误。
类模板里还有两个经典坑。第一个是静态成员。每个不同的模板实例都有自己的静态成员,Stack<int>::count_和Stack<double>::count_是两份独立变量。C++17以前,静态成员定义要放在头文件里被引用,而且容易违反单一定义规则(ODR);C++17之后用inline static或者static constexpr能省掉不少麻烦。第二个坑是友元,尤其友元函数本身也是模板时,需要额外声明模板参数才能正确对应。
3.2 类模板的全特化与偏特化:定制化场景
类模板相比函数模板多了一个杀手级能力:偏特化。全特化很容易理解——把模板参数全部固定为某个具体类型,专门给这个类型写一套实现。比如写一个Storage<T>,在T是bool时不想用完整字节存储,可以这样做:
template <typename T> class Storage { public: T value; }; template <> class Storage<bool> { public: uint8_t bit : 1; };全特化相当于在大量通用逻辑里开了一个小口子,专门针对某个类型做优化。偏特化更进一步:部分地限制模板参数。最常见的是针对指针类型的偏特化:
template <typename T> class Storage<T*> { public: size_t pointer_id; };这里T*的意思是“T是什么类型不重要,我只关心它是指针”。任何指针类型,如int*、std::string*,都会匹配到这个偏特化版本,得到一个只存指针编号的实现。这个能力非常适合做内存池、句柄表之类的场景。
需要注意:函数模板没有偏特化,只有全特化,因为函数重载已经能表达“部分限制”的意图。类模板则只能靠特化机制。我在设计库接口时会刻意利用这一点:如果想让某些特殊类型走不同路径,用类模板特化藏实现,外部用户看到的还是同一个模板名,使用体验上是统一的。
3.3 一个例子:用类模板写通用Stack容器
光讲语法不够,我演示一个真实可跑的类模板容器。一个轻量栈容器,我用它做过表达式求值模块,底层存储不同类型的数据:
template <typename T, size_t Capacity = 16> class FixedStack { public: void push(const T& value) { if (size_ >= Capacity) { throw std::overflow_error("stack overflow"); } buffer_[size_++] = value; } void pop() { if (size_ == 0) { throw std::underflow_error("stack underflow"); } --size_; } const T& top() const { if (size_ == 0) { throw std::underflow_error("stack underflow"); } return buffer_[size_ - 1]; } size_t size() const { return size_; } private: T buffer_[Capacity]; size_t size_ = 0; };模板参数一个表示元素类型T,一个表示栈容量Capacity。非类型模板参数让这个容器能在编译期确定缓冲区大小,和C数组一样分配在栈上,不需要动态内存,非常适合嵌入式或高频小规模数据场景。使用时直接声明FixedStack<int, 8>,编译器就会生成容量为8的int栈。如果需要换成double,只需改成FixedStack<double, 8>,其他逻辑一模一样。
类模板的价值从这里看出来:整个容器的数据布局、接口都跟类型解耦,真实使用时却能对具体类型做出精确的编译期定制。后端的编译器优化可以拿到全部类型信息,生成非常紧凑的代码。这也是为什么很多高性能库宁可写模板,也不愿意用运行时多态。
4. 模板进阶:可变参数模板和模板元编程,进入编译期“计算世界”
4.1 可变参数模板:用折叠表达式写通用打印函数
C++11引入的可变参数模板让模板真正进入了“任意参数个数”的时代。std::tuple、std::function的实现大量依托这个特性,我最近给日志模块写的通用打印函数也靠它。先看最简洁的C++17折叠表达式版本:
template <typename... Args> void print_all(Args... args) { (std::cout << ... << args) << '\n'; }这里Args...是一个参数包,展开后等价于std::cout << arg1 << arg2 << ...。无论传1个参数还是10个参数,编译器都能处理。配合sizeof...(Args)还可以在编译期拿到参数个数,用来做空包判断或静态断言:
template <typename... Args> void print_all(Args... args) { static_assert(sizeof...(Args) > 0, "need at least one argument"); (std::cout << ... << args) << '\n'; }如果没有C++17的折叠表达式,只能递归展开参数包,写法啰嗦得多:
void print_all() {} template <typename T, typename... Args> void print_all(const T& first, const Args&... rest) { std::cout << first << ' '; print_all(rest...); }递归展开是理解参数包的关键,虽然平时不用手写,但它在元编程里无处不在。我在维护老代码时就遇到过递归展开版本,能读懂比会写更重要。
可变参数模板不只是打印,“完美转发”配合参数包是工厂函数、线程封装最常见的实现。C++标准库的std::make_shared、std::thread构造都大量使用这种模式。给团队写通用库的时候,我需要一个能接收任意类型、任意数量参数并构造对象的函数,可变参数模板几乎是最佳选择。
4.2 模板元编程:阶乘计算和类型萃取
模板元编程听起来高大上,本质就是利用模板在编译期完成计算和类型操作。最经典的例子是编译期阶乘:
template <int N> struct Factorial { static constexpr int value = N * Factorial<N - 1>::value; }; template <> struct Factorial<0> { static constexpr int value = 1; };调用Factorial<5>::value时,编译器会递归实例化Factorial<4>、Factorial<3>,直到特化的Factorial<0>终止。整个计算全部发生在编译期,生成的代码里只有最终结果120,运行时没有任何循环或函数调用。这个例子虽然简单,但它揭示了元编程的核心:模板实例化是图灵完备的,理论上能在编译期完成任何计算。
不过我不建议在业务代码里拿模板写复杂逻辑,维护太难。更实用的方向是“类型萃取”,也就是在编译期判断类型特征,再结合std::enable_if或if constexpr选择不同实现。比如判断一个类型是否是指针:
template <typename T> void print_value(const T& v) { if constexpr (std::is_pointer_v<T>) { std::cout << *v; } else { std::cout << v; } }std::is_pointer_v<T>就是模板元编程在标准库里的体现。它不是一个运行时函数,而是一个编译期常量。使用if constexpr后,条件不成立的分支在编译期就被丢弃,不会产生无效代码,也不会因为类型不支持某个操作而报错。这是C++17以后写模板的利器,比SFINAE直观很多。
4.3 SFINAE与enable_if:让错误的模板参数静默消失
C++引入concept之前,SFINAE是模板重载筛选的主流手段。全称“替换失败不是错误”,意思是当模板实例化过程中某个替换导致无效代码时,编译器不会报错,而是把这个候选函数从重载集里剔除,继续找别的匹配。利用这个特性,可以按类型能力做精细化重载。
比如我想让整数类型走取模分支,浮点类型走四舍五入分支:
template <typename T> typename std::enable_if<std::is_integral<T>::value, bool>::type is_odd(T v) { return v % 2 != 0; } template <typename T> typename std::enable_if<!std::is_integral<T>::value, bool>::type is_odd(T v) { return std::fmod(v, 2.0) != 0; }std::enable_if的第一个参数是编译期布尔值,只有为true时才定义type成员。当编译器尝试用int去匹配第二个版本,发现enable_if<false, bool>::type不存在,就放弃这个候选,最后只能匹配第一个版本。这就是SFINAE的核心:通过“故意制造替换失败”来筛选重载。
SFINAE用多了有两个问题。一是可读性差,报错信息绕;二是语法冗长,写起来像在念咒。因此C++20引入concept之后,新项目我尽量用concept替代:
template <typename T> requires std::integral<T> bool is_odd(T v) { return v % 2 != 0; }效果一样,但语义更直白。
不过现有老代码里SFINAE和enable_if仍然大量存在,能看懂、能维护,依然是必要的。
5. 实战:用模板封装一个类型安全的事件总线
5.1 场景和设计
我维护的一个客户端项目里,多个模块需要互相通知:登录成功、界面切换、网络状态变化。以前的做法是写一个万能事件类,里面塞一个类型标识外加void*,订阅者收到后自己往下转。这种做法写了两年,问题一堆:类型转换错误要跑起来才出现;新增事件类型要改公共头文件;每个订阅函数都在做重复的“if (event.type == x) cast”判断。
我决定用模板封装一个类型安全的事件总线。设计目标有三个:第一,发布者调用publish时直接传具体事件对象,不需要事先转成通用类型;第二,订阅者只接收自己关心的具体事件类型,收到的一定是正确类型,不需要手动向下转型;第三,新增事件类型不需要改公共代码,只要定义一个新的结构体,声明总线支持这个类型就行。
最终方案是“一个事件类型对应一个独立的Channel通道”。Channel保存该类型所有订阅回调,发布时遍历回调。不同事件类型可以同时共存在一个EventBus里,而模板继承会让指定类型的事件自动路由到对应通道。
5.2 核心代码实现
第一步先写单独的事件通道:
#include <vector> #include <functional> #include <algorithm> template <typename Event> class Channel { public: using Handler = std::function<void(const Event&)>; void subscribe(Handler handler) { handlers_.push_back(std::move(handler)); } void publish(const Event& event) { for (const auto& handler : handlers_) { handler(event); } } void clear() { handlers_.clear(); } private: std::vector<Handler> handlers_; };std::function可以接收lambda、函数指针、绑定的成员函数,接口很灵活。这里为了示例简单,线程安全问题先不处理,后面扩展章节再补充。
第二步用可变参数模板做多事件总线的聚合:
template <typename... Events> class EventBus : public Channel<Events>... { public: template <typename E, typename Fn> void subscribe(Fn&& fn) { Channel<E>::subscribe(std::forward<Fn>(fn)); } template <typename E> void publish(const E& event) { Channel<E>::publish(event); } };核心是继承包展开。EventBus<LoginEvent, LogoutEvent>等价于继承了Channel<LoginEvent>和Channel<LogoutEvent>。调用bus.subscribe<LoginEvent>(lambda)时,模板参数E被推导为LoginEvent,于是只向Channel<LoginEvent>订阅。发布publish(LoginEvent{...})时,E同样从实参推导为LoginEvent,走对应通道,对其他事件毫无影响。
使用示例:
#include <iostream> struct LoginEvent { int uid; }; struct LogoutEvent { int uid; }; int main() { EventBus<LoginEvent, LogoutEvent> bus; bus.subscribe<LoginEvent>([](const LoginEvent& e) { std::cout << "login: " << e.uid << '\n'; }); bus.subscribe<LogoutEvent>([](const LogoutEvent& e) { std::cout << "logout: " << e.uid << '\n'; }); bus.publish(LoginEvent{1001}); bus.publish(LogoutEvent{1001}); return 0; }运行输出:
login: 1001 logout: 1001整个流程没有任何void*,没有类型标记,没有if-else链。类型错误在编译期就会被拦截:如果某个模块试图subscribe<LoginEvent>时传了一个不接受LoginEvent的lambda,编译器会直接提示类型不匹配,而不是运行时才崩溃。
5.3 扩展与线程安全
上面的事件总线在单线程够用,但项目里经常要在多个线程之间发事件。最简单的方式是给Channel加一个std::mutex,在subscribe和publish里加锁:
#include <mutex> template <typename Event> class Channel { public: void subscribe(Handler handler) { std::lock_guard<std::mutex> lock(mutex_); handlers_.push_back(std::move(handler)); } void publish(const Event& event) { std::lock_guard<std::mutex> lock(mutex_); for (const auto& handler : handlers_) { handler(event); } } private: std::vector<Handler> handlers_; std::mutex mutex_; };这里有个隐藏问题:如果某个handler内部又调用了publish,会尝试再次锁同一个std::mutex,造成死锁。一般解决办法是换成std::recursive_mutex,或者把publish改成先拷贝一份handler列表,在锁外执行回调。我实际做业务时更倾向后者,因为回调里经常产生新事件,死锁风险太高。多线程模板库的另一个坑是“同一个模板在不同线程同时首次实例化”的开销,编译期实例化本身有锁保护,但会增加编译负担,建议在大型项目里注意控制模板实例化数量。
如果不希望订阅者持有std::function,也可以把Handler换成函数指针,能减少内存占用,但灵活性降低。事件总线的优化方向还有很多,比如用std::any做类型擦除,或者引入优先级、异步队列等。核心价值已经清楚:模板让这个总线在类型安全、易用性、性能之间找到了一个相当好的平衡点。
6. 模板工程化的经验:编译报错、代码膨胀与调试技巧
6.1 模板编译报错怎么读
写模板最磨人的就是报错信息。一个简单的std::vector<int>::push_back("hello"),在旧编译器上可能吐出一百多行模板实例化栈。很多新手看到一堆error就慌了,其实只要抓住两个地方:报错第一行往往藏有真实的错误原因,比如“cannot convert from const char* to const int&”;后面那一长串是编译器尝试实例化不同模板的过程记录,大部分是辅助信息。我一般直接Ctrl+F搜索“error:”最早出现的那个,再从下往上找涉及自己代码的文件行号。
C++20的concept能显著改善这类体验。用concept约束模板参数后,不满足条件的调用会有直接提示,而不是把整个实例化过程喷一遍。如果是维护老代码无法升级,就用static_assert给用户一个友好提示。比如在函数模板第一行检查类型特性:
static_assert(std::is_default_constructible_v<T>, "T must be default constructible");这比让使用者在某个模板实例化深处看到神秘错误要好得多。
6.2 代码膨胀和编译速度控制
模板每一个类型实例都会生成一份独立代码,大量使用会导致二进制尺寸变大,也就是“代码膨胀”。我做过一个图形算法库,模板嵌套层数一堆,编译产物从2MB涨到8MB。后来梳理发现,很多实例是同一个模板的不同指针类型,比如Buffer<float*>和Buffer<double*>,逻辑完全一样,只是类型参数不同。这种场景可以做显式实例化:
// .h template <typename T> class Buffer { ... }; extern template class Buffer<float>; extern template class Buffer<double>; // .cpp template class Buffer<float>; template class Buffer<double>;显式实例化让这些类型的代码只在一个编译单元里生成,其他文件哪怕include了头文件,也不再重复实例化,编译速度和二进制尺寸都能改善。代价是只能支持已经实例化过的类型,新增类型要回.cpp里补充。所以显式实例化更适合类型集合稳定、使用者少的内部组件。
编译速度方面,模板头文件一改动,所有包含它的文件都要重新编译。为了不至于每次改个模板就全工程重编,我习惯把常用且稳定的模板实例集中放一个.cpp里,头文件只保留声明和少量内联定义。另一个偷懒办法是使用预编译头文件把模板库包含进去,对本地开发体验提升明显。
6.3 模板定义为什么必须放头文件
这个问题每届实习生都会问。函数模板或类模板编译时,编译器必须要能看到完整的模板定义,才能为具体类型生成实例。如果头文件里只有声明,.cpp里有定义,其他编译单元调用时看不到定义,编译器无从实例化,链接时就会报“undefined reference to ...”。这和普通函数不一样,普通函数只需要声明就能编译通过,链接时再去找符号。
举个常见错误:
// foo.h template <typename T> void foo(T value); // foo.cpp template <typename T> void foo(T value) {} // main.cpp #include "foo.h" int main() { foo(42); }这个代码链接时一定失败。因为main.cpp只看到声明,编译器不知道要生成foo<int>的实现,而foo.cpp里的定义又没有在一个编译单元中看到foo<int>的调用,所以也不会实例化。解决办法就是:模板定义直接放在头文件里,或者用上面说的显式实例化在foo.cpp中生成特定类型的实例。写模板库时,把定义写在.inl文件再include到头文件也是常见做法,主要是为了保持头文件整洁。
两阶段查找是另一个头文件相关的坑。模板在定义时,不依赖模板参数的名字会在第一阶段查询;依赖模板参数的名字要到实例化时第二阶段查询。如果在模板中使用了一个依赖类型,必须显式加typename;调用依赖模板参数的成员模板函数,要加template关键字。很多人遇到“dependent name is not a type”错误时一头雾水,其实就是这两个关键字漏了。
最后再分享一个我自己的习惯。写模板时多用if constexpr和static_assert,少玩“炫技”的SFINAE。模板的价值在于让代码在类型维度上抽象,而不是制造一堆让人头痛的编译魔法。能把“一份逻辑,适配多种类型”这件事做好,已经比绝大多数只会堆重复代码的程序员强了。C++模板确实有学习曲线,但一旦跨过那几道坎,你会发现它带来的类型安全、性能和设计自由度,都是其他泛型方案很难同时给到的东西。