C++模板入门从函数模板到可变参数
ServerRack
8 min read
模板让"同一套逻辑适配多种类型"在编译期完成,零运行期开销——代价是报错信息和编译耗时都更重。理解模板的关键,是接受"类型也是一种参数",编译器会为你用到的每种类型各生成一份代码。这套机制支撑了标准库的容器、算法和智能指针,是泛型编程的基石。
函数模板的基本形态
用 template <typename T> 把类型参数化,编译器按调用处的实参推导 T:
template <typename T>
T max_of(T a, T b) {
return a > b ? a : b;
}
int x = max_of(3, 7); // T = int
double y = max_of(1.2, 5.4); // T = double
推导失败时会报一长串候选,根因往往是实参类型不一致(如 max_of(3, 5.4) 两个参数 T 推不出统一类型)或该类型不支持 >。给多个类型参数、或显式 max_of<double>(3, 5.4) 可绕过推导。
模板为什么在编译期展开
每个被实际用到的 T 组合,编译器都会生成一份对应代码。这意味着 max_of<int> 和 max_of<double> 是两个独立函数,没有运行期类型查询。好处是内联和优化的空间大(编译器能看到具体类型做针对性优化),坏处是模板代码全写在头文件、编译单元变大,改一处所有用到它的翻译单元都要重编。
类模板与成员
template <typename T>
class Box {
T v;
public:
Box(T val) : v(val) {}
T get() const { return v; }
};
Box<int> b(10);
类模板的成员函数定义若放在类外,要重复 template <typename T> 且用 Box<T>:: 限定。类模板本身不能推导(C++17 起对构造函数有有限推导),通常要显式写 <int>。
可变参数模板
template <typename... Args> 里的 Args 是"类型包",Args... 展开成逗号分隔的多个类型。典型入口是递归展开:
void print() {} // 终止递归的空实现
template <typename Head, typename... Tail>
void print(Head head, Tail... tail) {
std::cout << head << " ";
print(tail...); // 每次少一个参数,直到匹配空版本
}
print(1, "hi", 3.14); // 1 hi 3.14
这里 print(tail...) 把包拆掉第一个后继续展开,直到 Tail 为空,落到空 print() 终止。顺序很重要:必须有一个非可变参数的终止函数,否则无限递归。这种"递归 + 终止"是 C++11 可变参数的经典写法。
C++17 起可用折叠表达式,省掉手写递归:
template <typename... Args>
auto sum(Args... args) {
return (args + ...); // 一元左折叠,展开为 ((a+b)+c)+...
}
折叠表达式还能带初始值 (0 + ... + args),处理空包也不会缺操作数。右折叠 (args + ...) 则按从右到左结合,数值上和加法无关、和减法、除法有关。
可变参数的实际用途
std::make_shared、std::tuple、std::thread 的构造函数都靠可变参数把任意数量的参数原样转发给目标。自己写工厂或包装器时,配合 std::forward 能保留每个实参的值类别:
template <typename T, typename... Args>
std::unique_ptr<T> build(Args&&... args) {
return std::make_unique<T>(std::forward<Args>(args)...);
}
这让 build<Buffer>(1024) 把 1024 按原样传给 Buffer 的构造函数,不会因为模板中转而退化成拷贝。
边界与坑
推导歧义:函数模板与重载的非模板函数并存时,重载决议优先选非模板(若匹配度相当),再选更特化的模板。理解这点能解释很多"为什么没走我预期的模板"。
typename 与 class 在模板参数里等价,但访问依赖类型名的成员必须用 typename 前缀(typename T::value_type),否则编译器无法确认那是类型还是静态成员,会报语法错而非你以为的逻辑错。
可变参数包的展开位置有限:只能在 ... 出现在函数参数、基类列表、初始化列表等允许"逗号序列"的上下文。想对包里每个元素做复杂操作,仍需借助折叠表达式或递归,不能随便写 for (auto x : pack)。
非类型模板参数(如 template <size_t N>)能表达编译期常量,但实参必须是编译期可确定的常量表达式,运行期变量传不进去。这类参数常用于定长数组、编译期循环展开。
模板报错常是"实例化深处才暴露"。定位办法是顺着报错栈找到最底层那行真实代码,而不是被最外层的一长串 in instantiation of 吓住。开启编译器的 Concepts(C++20)能把这些报错前置成清晰约束信息,大幅改善体验。
模板能力提升的是泛型表达力,但可读性随特化、SFINAE、concept 叠加而陡降。能用重载或普通多态解决、又不损失性能的地方,不必硬上模板——可维护性也是成本。模板该用在"确实要一组类型共享同一逻辑"的场合,而不是为了炫技把简单函数泛型化。
模板的编译成本与去重
模板把"类型作为参数"在编译期实例化,代价是代码体积和编译时间。每个被用到的 T 组合都生成一份机器码,所以 vector<int> 与 vector<string> 是两份独立代码,二进制会胀。显式实例化(template class vector<int>;)能把生成挪到单一翻译单元、减少重复,但常规项目不必过早优化这点。
去重靠"非类型相关的代码抽成普通函数":模板里只保留真正依赖 T 的部分,公共逻辑写成接受 void* 或迭代器的非模板辅助函数,让多份实例共享同一份底层实现。标准库的 std::sort 就大量用这种技巧,避免每种类型一套完整快排。
C++20 的 Concepts 让约束从"SFINAE 黑魔法"变成可读的一等公民:template <typename T> requires Integral<T> 比一堆 enable_if 清晰得多,报错也从前缀一堆候选变成一句"不满足 Integral"。新项目优先用 Concepts 表达约束,旧代码里的 std::enable_if 与 void_t 套路理解即可,不必新写。
模板递归展开有编译期深度上限,过深的可变参数递归会触到"模板实例化深度"限制——用折叠表达式(C++17 起)替代手写递归,既能跑更深的包,代码也更短。
1 view
0 likes
0 comments