模板与泛型
环境:Windows 11 x64,GCC 13.1.0,
g++ -std=c++17(concepts 部分用-std=c++20)。报错行数、拷贝计数都是本机实测。
一、模板错误的真相:行数不等于质量
同一个误用(给不支持 + 的类型调用 add),三种写法的报错:
| 写法 | 报错行数 | 错误停在哪里 |
|---|---|---|
无约束 template<class T> | 5 行 | 模板体内部:no match for 'operator+' |
SFINAE enable_if_t<is_arithmetic_v<T>> | 14 行 | 调用点:no matching function |
C++20 requires std::integral<T> | 16 行 | 调用点 + 明确点名哪个约束、哪个表达式求值为 false |
无约束版:
t_plain.cpp: In instantiation of 'T add(T, T) [with T = NoPlus]':
t_plain.cpp:4:45: required from here
t_plain.cpp:2:47: error: no match for 'operator+' (operand types are 'NoPlus' and 'NoPlus')
concepts 版(节选):
error: no matching function for call to 'add(NoPlus&, NoPlus&)'
note: candidate: 'template<class T> requires integral<T> T add(T, T)'
note: constraints not satisfied
note: the expression 'is_integral_v<_Tp> [with _Tp = NoPlus]' evaluated to 'false'
所以”concepts 能把二十行报错缩成一行”这个流行说法,在这个最小例子里是错的。 真正的差别是两点:
- 错误位置:无约束版是”先接受任何类型、实例化之后才在模板体里炸”,你看到的是模板内部的错误,得反推”是谁用什么类型实例化了它”(
In instantiation of ... required from here这条链在嵌套调用里会拖很长)。concepts 在重载决议阶段就把候选排除,错误停在你的调用点。 - 可解释性:
constraints not satisfied+is_integral_v<NoPlus> evaluated to false是声明级的信息,不用读懂模板体就知道为什么不行。
在真实项目里(多层嵌套模板、STL 容器、递归实例化)无约束版的报错会膨胀到几十上百行,而约束版的长度基本恒定——行数不随复杂度爆炸才是它的价值。这个最小例子恰好相反,说明”看行数”不是正确的评估方式。
二、两阶段名字查找与 typename / template
模板定义时先做一次检查(不依赖 T 的部分),实例化时再做一次(依赖 T 的部分)。这就是为什么有些错误在定义期就报、有些要等到调用:
template <class T>
void f(T c) {
T::size_type x; // ❌ 编译器默认把 :: 当"小于号"
typename T::size_type x; // ✅ 明确它是类型
c.template get<int>(); // ✅ 依赖成员的模板调用要写 template
}
规则只有两条:依赖 T 的类型名前面加 typename;依赖 T 的模板成员调用加 template。写错时的报错通常是一句 need 'typename' before ... because 'T' is dependent type,很好认。
三、特化:类可以做,函数不行
template <class T> struct Box { }; // 主模板
template <class T> struct Box<T*> { }; // ✅ 类模板偏特化
template <> struct Box<int> { }; // ✅ 类模板全特化
template <class T> int f(T); // 主模板
template <class T> int f<T*>(T *); // ❌ 函数模板不允许偏特化
error: non-class, non-variable partial specialization 'f<T*>' is not allowed
函数只能全特化,不能偏特化。替代方案有两个,优先级也写在下面:
- 加重载(首选):
int f(int*)就是一个普通重载,重载决议会正确处理。 - 委托给类:
template<class T> struct impl;做偏特化,函数模板只调用impl<T>::go()。这是标准库里到处用的技巧。
一个坑:函数模板的特化会改变重载决议结果,而且特化必须在调用点之前可见,否则调用绑定到主模板。这就是”std::swap 不要特化、要重载”这条建议的由来。
四、万能引用与完美转发:省掉的是全部拷贝
template <class T> void sink_bad(T v) { } // 按值
template <class T> void sink_fwd(T &&v) { } // 万能引用(转发引用)
C c;
for (int i = 0; i < 100; i++) sink_bad(c); // 左值
for (int i = 0; i < 100; i++) sink_fwd(c);
for (int i = 0; i < 100; i++) sink_fwd(C{}); // 右值
sink_bad(T v) 100 次调用:拷贝 100 移动 0
sink_fwd(T &&v) 100 次调用:拷贝 0 移动 0
sink_fwd(临时量) 100 次调用:拷贝 0 移动 0
机制:T &&v 在推导场景下是转发引用,传左值时 T 推导为 C&,C& && 折叠成 C& —— 参数直接绑到原对象,一次拷贝都不产生。按值传参则必须构造一个副本。
转发到另一个函数时要用 std::forward:
template <class... Args>
auto make(Args &&...args) { return T(std::forward<Args>(args)...); }
std::move 是”无条件变右值”,std::forward 是”保持原来的值类别”——转发必须用 forward,用 move 会把本该按引用传的左值强行变成右值,导致下游意外窃取。
转发引用的四个陷阱
- 只出现在
T &&且 T 正在被推导的位置。void f(std::vector<T> &&)、void f(S &&)(S 是已确定的类成员)都不是转发引用。 - 布尔参数会毁掉接口的可读性:
f(true)到底传的是什么?转发之外一律用具名类型。 {1,2,3}推不出来:花括号初始化列表不能推导T,遇到就编译失败。unique_ptr/右值字面量当实参要写std::forward<T>(x)而不是T(x),否则可能复制(unique_ptr又不可复制,直接编译错误)。
五、if constexpr:编译期分支,不实例化另一支
template <class T> auto describe(T v) {
if constexpr (std::is_integral_v<T>) return v * 2;
else if constexpr (std::is_floating_point_v<T>) return v + 0.5;
else { static_assert(sizeof(T) == 0, "不支持的类型"); return v; }
}
describe(3); // OK
describe(1.0); // OK
describe(Bad{}); // 触发 static_assert
error: static assertion failed: 不支持的类型
if constexpr 的分支不满足条件时根本不实例化,所以浮点类型走不到 v * 2 也不会报错(普通 if 会)。这是替代 SFINAE 和标签转发(tag dispatch)最省脑子的写法。注意 static_assert(sizeof(T) == 0) 这个惯用法:直接写 false 会在定义期就炸,必须绑到 T 上才延迟到实例化。
六、CTAD:C++17 的类模板实参推导
template <class T> struct Box { T value; explicit Box(T v) : value(v) {} };
Box b1(42); // Box<int>
Box b2{3.5}; // Box<double>
auto v = std::vector{1, 2, 3}; // vector<int>
auto p = std::make_unique<int>(7);
Box b3("abc");
b1=42 b2=3.5 v.size=3 *p=7
Box("abc") 推导出的类型大小=8
推导规则和 auto 完全一致,包括那条容易忽略的:数组到指针的退化——Box b3("abc") 里的 value 是 const char *(8 字节),不是 char[4]。想保留数组语义得自己写推导指引(template <class T, size_t N> Box(T(&)[N]) -> Box<std::array<T,N>>)。
没有显式推导指引时,编译器从构造函数隐式生成。所以构造函数模板化的类(如 std::pair、std::tuple)CTAD 行为可能反直觉,必要时手写指引。
七、变量模板与内联静态成员
template <class T> constexpr T pi = T(3.1415926535897932385); // C++14 变量模板
float r = pi<float>; // 3.14159f
double d = pi<double>; // 更高精度,同一个模板
C++17 起类内 static 成员可以写 inline static int count = 0;,不用再在 .cpp 里补一份定义——这是模板类和单例实现里最常见的样板消除。
八、这一节的自查清单
- 公共模板接口用
concepts约束,理由不是”报错短”,而是”错误停在调用点且可解释” - 依赖类型加
typename,依赖模板成员加template - 函数模板要”按类型分派”就加重载或委托类,不要试图偏特化
- 转发用
std::forward,不用std::move - 转发引用参数后面紧跟
T的使用点,别在中间转成值类型 - 编译期分支用
if constexpr,static_assert的条件要依赖模板参数 - CTAD 注意数组退化和花括号推不出来这两点
- 模板定义放头文件(否则实例化看不到定义会链接失败)
- 拿不准推导结果时用
static_assert(std::is_same_v<decltype(x), Expected>)或typeid+__cxa_demangle打印真实类型
下一节讲 STL 容器与迭代器:各容器的复杂度保证、迭代器失效规则表(哪些操作让哪些迭代器失效)、vector 的扩容策略实测,以及为什么 std::list 在真实机器上通常比理论慢。