前言
模板的入门内容通常是「写一个template <class T>的函数,让同一个算法适配多种类型」。真正写库的时候才会发现,入门那点知识不够用:参数可以不是类型,类可以针对某类类型单独实现,名字查找分两个阶段,模板的定义位置还会影响链接。这些内容构成了模板的「进阶」部分。
这一阶段最常见的三个误解是:以为模板参数只能是类型;以为函数模板也能像类模板一样做偏特化;以为模板代码写在.cpp里和普通函数一样。前两个会导致写法上的死胡同,第三个会导致莫名其妙的「undefined reference」链接错误,而编译器在编译阶段一声不吭。
本文按四条线索展开:模板参数的种类、特化与偏特化、依赖名与两阶段查找、可变参数与编译期分支。最后给一个把这几项串起来的完整示例。所有代码以 C++17 为基准,没有在本机编译或运行过;凡涉及标准行为的地方,按标准语义描述。
一、模板参数不止是类型
模板参数分三类:类型参数、非类型参数、模板模板参数。入门只用了第一类。
非类型参数在编译期必须是一个常量表达式。常见的形态是整型、枚举、指针、左值引用和nullptr。C++17 起可以用auto让编译器推导非类型参数的类型,这让写法简洁不少。
// 适用于 C++17
#include <cstddef>
template <class T, std::size_t N>
struct FixedArray {
T data[N];
constexpr std::size_t size() const noexcept { return N; }
T& operator[](std::size_t i) { return data[i]; }
const T& operator[](std::size_t i) const { return data[i]; }
};
template <auto N>
struct Constant {
static constexpr auto value = N;
};
constexpr int kAnswer = Constant<42>::value;
constexpr char kLetter = Constant<'x'>::value;FixedArray<int, 8>的大小在编译期就确定,不需要堆分配。非类型参数必须是编译期常量,传一个运行时变量会直接编译失败,这是它和函数参数最本质的区别。C++20 起非类型参数还允许浮点数和满足条件的类类型,但支持程度依赖编译器版本,使用前要确认工具链。
模板模板参数接收的是「一个模板」而不是「一个类型」。它在需要泛化容器时很有用。
// 适用于 C++17
#include <cstddef>
#include <vector>
template <template <class...> class Container, class T>
Container<T> make_filled(std::size_t n, const T& value)
{
return Container<T>(n, value);
}
int main()
{
auto v = make_filled<std::vector>(3, 7);
return static_cast<int>(v.size());
}这里用template <class...> class Container而不是template <class> class,是因为std::vector实际有两个模板参数(元素类型和分配器),第二个有默认值。写成template <class> class时,std::vector无法匹配,这是模板模板参数最常见的报错来源。C++17 之前必须把分配器参数显式写出来,C++17 的匹配规则放宽了一些,但用可变参数包是最稳妥的写法。
| 参数种类 | 写法示例 | 编译期要求 |
|---|
| 类型参数 | template <class T> | 无 |
| 整型非类型参数 | template <int N> | 必须是常量表达式 |
auto非类型参数 | template <auto N> | C++17 起可用 |
| 模板模板参数 | template <template <class...> class C> | 实参必须是模板 |
| 默认模板实参 | template <class T = int> | 可省略实参 |
二、特化与偏特化
泛型实现未必对所有类型都合适。std::vector<bool>就是一个著名例子:标准为它提供了压缩存储的特化,代价是operator[]返回代理对象而不是bool&。这正是特化要解决的问题:给某个具体类型,或者某一类类型,换一套实现。
全特化把所有的模板参数都固定下来。偏特化只固定一部分,或者对参数的形式提出限制(比如「所有指针类型」)。只有类模板和变量模板可以偏特化,函数模板只能全特化。
// 适用于 C++17
#include <iostream>
#include <string>
template <class T>
struct TypeName {
static const char* get() { return "unknown"; }
};
// 全特化
template <>
struct TypeName<int> {
static const char* get() { return "int"; }
};
// 偏特化:所有指针类型
template <class T>
struct TypeName<T*> {
static const char* get() { return "pointer"; }
};
// 偏特化:带 const 的类型
template <class T>
struct TypeName<const T> {
static const char* get() { return "const-qualified"; }
};
int main()
{
std::cout << TypeName<int>::get() << '\n';
std::cout << TypeName<double*>::get() << '\n';
std::cout << TypeName<const long>::get() << '\n';
return 0;
}匹配规则是「最特殊者胜」。TypeName<const int>同时匹配const T和int两条路径,编译器会选择更特殊的全特化。如果两个偏特化同样特殊且都能匹配,就是歧义,编译失败。设计特化时要把「谁更特殊」想清楚,否则错误信息会非常难读。
函数模板不能偏特化,但通常可以用重载达到同样效果。重载参与正常的重载决议,往往比特化更直观,也更不容易出现「特化没被选中」的意外。
// 适用于 C++17
#include <iostream>
#include <string>
template <class T>
void show(const T&)
{
std::cout << "generic\n";
}
// 全特化:允许,但通常不如重载直观
template <>
void show<int>(const int&)
{
std::cout << "int\n";
}
// 重载:处理所有指针
template <class T>
void show(T* const&)
{
std::cout << "pointer\n";
}有一个流传很广的陷阱:函数模板的特化不参与重载决议,只有在主模板被选中之后才会考虑特化。因此「看起来更匹配」的特化可能永远不会被调用。除非有明确理由,函数模板请优先用重载而不是特化。
三、依赖名、两阶段查找与消歧关键字
模板里的名字查找分两个阶段。第一阶段在模板定义处进行,处理所有不依赖模板参数的名字;第二阶段在实例化时进行,处理依赖模板参数的名字,此时还会启用参数依赖查找(argument-dependent lookup, ADL)。理解这一点,才能解释为什么有些名字在定义处找不到、实例化时却能找到,反过来也成立。
依赖名带来两个语法歧义,需要显式消歧。
第一,当T::value_type这类名字依赖模板参数时,编译器默认把它当成静态数据成员,除非你写typename告诉它这是一个类型。
// 适用于 C++17
#include <vector>
template <class Container>
typename Container::value_type first_or_zero(const Container& c)
{
typename Container::value_type result{};
if (!c.empty()) {
result = c.front();
}
return result;
}返回类型处的typename和函数体里的typename都不能省。少了第一个,编译器在解析返回类型时还不知道value_type是类型,直接报错;少了第二个,局部变量声明会被当成表达式。
第二,当通过依赖类型的对象调用成员模板时,需要template关键字。
// 适用于 C++17
#include <vector>
template <class T>
void append_one(T& container, typename T::value_type value)
{
container.template push_back<typename T::value_type>(value);
}push_back并不是成员模板,这里只是演示语法位置;真实场景中常见于obj.template get<int>()这类调用。规则是:如果成员名后面紧跟<,而这个成员所属的对象类型依赖模板参数,就必须写template,否则<会被解析成小于号。
还有一条历史细节:C++11 之前,嵌套模板的右尖括号必须写成> >,因为>>会被词法分析成右移运算符。C++11 起这个限制取消,std::vector<std::vector<int>>可以直接写。看到老代码里的空格不必惊讶,但在新代码里不需要模仿。
四、可变参数、折叠表达式与 if constexpr
可变参数模板让函数接受任意个类型和值。C++17 的折叠表达式又把「递归展开」这种样板写法压缩成了一行。
// 适用于 C++17
#include <iostream>
#include <string>
#include <utility>
template <class... Ts>
auto sum(Ts... xs)
{
return (xs + ... + 0);
}
template <class... Ts>
void print_all(const Ts&... xs)
{
((std::cout << xs << ' '), ...);
std::cout << '\n';
}
template <class T, class... Rest>
bool all_positive(T first, Rest... rest)
{
return (first > 0) && ((rest > 0) && ...);
}
int main()
{
std::cout << sum(1, 2, 3) << '\n';
std::cout << sum() << '\n';
print_all(std::string("a"), 1, 2.5);
std::cout << std::boolalpha << all_positive(1, 2, 3) << '\n';
return 0;
}折叠表达式有四种形式:一元右折叠(pack op ...)、一元左折叠(... op pack)、二元右折叠(pack op ... op init)、二元左折叠(init op ... op pack)。sum用的是二元左折叠,初值0保证了空参数包也能返回0;print_all用的是逗号运算符的一元左折叠,按顺序展开每个表达式。
all_positive里混用了普通参数和参数包:first保证至少有一个参数,rest可能为空,((rest > 0) && ...)在空包时按标准展开为true,逻辑上正好正确。如果全部写成参数包,空包时还需要额外处理,否则(pack && ...)的空包值是true,语义上未必是你要的。
C++17 的if constexpr让编译期分支不再需要 tag dispatch 或者 SFINAE 重载。条件必须是编译期常量;条件为假的分支不会被实例化,但仍然必须语法正确。
// 适用于 C++17
#include <string>
#include <type_traits>
template <class T>
std::string stringify(const T& value)
{
if constexpr (std::is_same_v<T, std::string>) {
return value;
} else if constexpr (std::is_arithmetic_v<T>) {
return std::to_string(value);
} else {
return "unsupported";
}
}被丢弃的分支不会实例化,所以std::to_string(value)在T是std::string时不会造成编译错误。但要注意:如果分支里有语法错误或者依赖了不存在的名字,即使不会被实例化,某些编译器仍可能在解析阶段报错,因为解析必须先通过。这与「模板只在实例化时报错」的直觉略有出入。
完整示例:一个编译期类型列表
把非类型参数、偏特化、折叠表达式和if constexpr放在一起,写一个用于查询类型集合的小工具。它在编译期回答「列表里有几个类型」「是否包含某个类型」,并能在运行期打印一个 tuple。
// 适用于 C++17
#include <cstddef>
#include <iostream>
#include <string>
#include <tuple>
#include <type_traits>
template <class... Ts>
struct TypeList {
static constexpr std::size_t size = sizeof...(Ts);
};
template <class T, class List>
struct Contains;
// 偏特化:把参数包拆开,用折叠表达式做逐项比较
template <class T, class... Ts>
struct Contains<T, TypeList<Ts...>>
: std::bool_constant<(std::is_same_v<T, Ts> || ...)> {};
template <class... Ts>
void print_tuple(const std::tuple<Ts...>& t)
{
std::apply(
[](const auto&... xs) {
((std::cout << xs << ' '), ...);
},
t);
}
int main()
{
using List = TypeList<int, double, std::string>;
static_assert(List::size == 3);
static_assert(Contains<int, List>::value);
static_assert(!Contains<char, List>::value);
print_tuple(std::make_tuple(1, 2.5, std::string("hi")));
return 0;
}Contains的主模板只有声明,所有实际匹配都发生在偏特化上;如果传入的第二个参数不是TypeList,就是不完整类型,使用时会编译失败,这正是我们想要的效果。折叠表达式在空包时展开为false,所以空列表不包含任何类型,语义正确。print_tuple借助std::apply把 tuple 拆成参数包,再用逗号折叠依次输出,元素类型不同也没问题。
常见坑点
- ❌ 试图对函数模板做偏特化,例如
template <class T> void f(std::vector<T>)。
✅ 函数模板只能全特化;需要按类型族定制时改用重载,重载会正常参与重载决议。
- ❌ 在依赖类型里直接写
T::value_type x;。
✅ 写typename T::value_type x;,否则编译器会把value_type当成静态成员。
- ❌ 调用依赖对象的成员模板时写
obj.get<int>()。
✅ 写obj.template get<int>(),否则<可能被解析成小于号。
- ❌ 把模板函数的定义放在
.cpp里,只在头文件留声明。
✅ 模板定义通常必须对使用点可见,放到头文件;或者在同一翻译单元显式实例化,否则链接期报未定义引用。
- ❌ 用
template <class> class Container接收std::vector。
✅ 用template <class...> class Container,把分配器等额外参数包住。
- ❌ 特化写在头文件里却没有加
inline,被多个翻译单元包含。
✅ 类模板特化本身不受影响;函数模板的全特化是普通函数,需要inline或放到单一源文件,避免重复定义。
- ❌ 以为
if constexpr的假分支完全不参与语法检查。
✅ 假分支不实例化,但仍要能通过解析;不要在里面写依赖不存在名字的无效语句。
- ❌ 折叠表达式处理空参数包时忽略初值,导致结果与预期不符。
✅ 明确需要空包语义时使用二元折叠并给出初值,如(xs + ... + 0)。
总结
| 主题 | 关键点 |
|---|
| 参数种类 | 类型、非类型(编译期常量)、模板模板参数,C++17 可用auto |
| 特化 | 类模板支持全特化和偏特化;函数模板只支持全特化,优先用重载 |
| 名字查找 | 两阶段查找;依赖类型加typename,依赖成员模板加template |
| 可变参数 | 参数包 + 折叠表达式,注意空包时的初值 |
| 编译期分支 | if constexpr丢弃分支不实例化,但仍需语法正确 |
| 定义位置 | 模板定义通常放头文件,或显式实例化 |
模板进阶的门槛不在于语法数量,而在于「编译期发生了什么」这个心智模型:哪些名字在定义时确定,哪些在实例化时确定,哪些代码根本不会被生成。把这三件事想清楚,读长报错时就能从最后一行的「no matching function」往前找到真正的约束冲突,而不是在几百行实例化栈里迷路。