模板与泛型编程

概念

模板(template)可以理解为:

生成类或函数的蓝图

模板本身不是具体的类或函数,而是告诉编译器:

  • 如果用户给我某种类型/某个值
  • 我就按这个模板生成对应的类或函数

泛型编程的核心思想

泛型编程强调:

写一份通用代码,适用于多种类型

例如:

  • vector<int>
  • vector<string>
  • find
  • sort

这些都依赖模板机制。

模板的实例化

当我们真正使用模板时,编译器会把模板“变成”具体版本,这个过程叫:

实例化(instantiation)

例如:

1
vector<int> v;

会实例化出 vector<int> 这个具体类。

1
compare(1, 2);

会实例化出一个针对 intcompare 函数。

函数模板

函数模板是用来生成一组函数的公式。

基本定义

你原文里 template 拼错了,正确写法是:

1
2
3
4
5
6
template <typename T>
int compare(const T& v1, const T& v2) {
if (v1 > v2) return 1;
if (v1 < v2) return -1;
return 0;
}

含义:

  • T 是模板参数
  • 实际调用时,T 会被替换成具体类型

模板参数列表

模板定义一般写成:

1
template <typename T>

或:

1
template <class T>

这里 typenameclass 在模板参数列表中含义相同。

函数模板的使用

调用时,编译器通常会自动推断模板实参:

1
cout << compare(1, 0) << endl;   // T 推断为 int

这会实例化出大致等价于:

1
int compare(const int&, const int&);

再例如:

1
2
vector<int> v1{1,2,3}, v2{4,5,6};
cout << compare(v1, v2) << endl;

会实例化出:

1
int compare(const vector<int>&, const vector<int>&);

前提是 vector<int> 支持 <> 比较。

模板并不是“万能的”

模板能否实例化成功,取决于模板中对类型的要求是否满足。

例如这个 compare 模板要求 T 支持:

  • >
  • <

如果类型 T 不支持这些运算,就会编译失败。

所以复习时记住:

模板只在被使用时才检查具体类型是否满足要求

模板参数

模板参数分为两大类:

  • 类型参数
  • 非类型参数

类型模板参数

类型模板参数表示一个“类型占位符”。

基本写法

1
template <typename T>

1
template <class T>

多个类型参数

你原文这里也有小错误,正确写法应是:

1
2
template <typename T, typename U>
T calc(const T&, const U&);

不能写成:

1
template <typename T, U>   // 错误

因为每个类型参数前都要写 typenameclass

类型参数的用法

类型参数本质上可以当作“类型名”使用:

  • 返回类型
  • 形参类型
  • 局部变量类型
  • 类型转换目标类型

例如:

1
2
3
4
template <typename T>
T mymax(T a, T b) {
return a > b ? a : b;
}

非类型模板参数

非类型模板参数表示的不是“类型”,而是“值”。

基本例子

1
2
template <unsigned N>
int arr[N];

或者更常见地写成:

1
2
3
4
5
template <unsigned N>
void print(const char (&a)[N]) {
for (unsigned i = 0; i != N; ++i)
cout << a[i];
}

这里 N 是一个值,而不是类型。

非类型模板参数的要求

非类型模板参数必须是编译期常量,通常要求是常量表达式。

常见可作为非类型模板参数的有:

  • 整型常量
  • 枚举值
  • 指针
  • 左值引用
  • nullptr

复习时重点记一句:

非类型模板实参必须是编译时能确定的常量表达式

为什么有用

因为它可以让模板在编译期根据“值”生成不同版本。

例如数组大小:

1
2
3
4
template <size_t N>
struct Buffer {
char data[N];
};

inline 和 constexpr 的模板

函数模板也可以是:

  • inline
  • constexpr

写法:

1
2
3
4
5
6
7
template <typename T>
inline T mymin(const T&, const T&);

template <typename T>
constexpr T myabs(T x) {
return x < 0 ? -x : x;
}

注意位置:

  • template<...> 在前
  • inline / constexpr 在后
  • 再写返回类型

模板的声明与定义

模板可以先声明,后定义:

1
2
template <typename T>
int compare(const T&, const T&);

然后再定义:

1
2
3
4
5
6
template <typename Type>
int compare(const Type& a, const Type& b) {
if (a < b) return -1;
if (b < a) return 1;
return 0;
}

模板参数名可以不同,只要:

  • 参数个数相同
  • 参数种类相同(类型参数/非类型参数)

即可。

模板代码通常放在头文件中

这是模板最重要的实践规则之一。

原因:

模板只有在实例化时才生成代码
编译器必须看到模板定义,才能实例化

所以通常:

  • 模板声明放头文件
  • 模板定义也放头文件

不像普通函数那样常放到 .cpp

类模板

类模板是生成类的蓝图。

基本定义

1
2
3
4
5
6
7
8
template <typename T>
class MyClass {
public:
MyClass(T v = T()) : val(v) {}
T get() const { return val; }
private:
T val;
};

使用类模板

1
2
MyClass<int> a(10);
MyClass<string> b("hello");

编译器会分别实例化出不同的类:

  • MyClass<int>
  • MyClass<string>

每个实例是不同类型

这一点非常重要:

类模板的每个实例都是一个独立类型

例如:

1
2
MyClass<int> a;
MyClass<double> b;

ab 类型不同,互不相同。

类模板与函数模板的区别

复习时最容易混的一点:

函数模板通常可自动推断

1
compare(1, 2);

编译器能推断 T=int

类模板通常必须显式写模板实参

1
MyClass<int> obj;

因为类模板一般不能像函数模板那样方便地从“构造参数”自动推断
(C++17 有类模板实参推断 CTAD,但基础复习里通常先不展开)。

使用 typename 表示“这是一个类型”

这是模板里非常重要的语法点。

典型场景

当模板参数类型内部还有一个类型成员时,编译器默认不确定它是不是类型。

例如:

1
2
3
4
5
6
7
template <typename T>
typename T::value_type top(const T& c) {
if (!c.empty())
return c.back();
else
return typename T::value_type();
}

这里:

1
T::value_type

前必须加 typename

为什么要加 typename

因为在模板中,像 T::value_type 这样的名字依赖于模板参数,
编译器默认无法确定它是:

  • 一个类型名
  • 还是一个静态成员

所以要显式告诉编译器:

这是一个类型

记忆法

模板中出现“依赖于模板参数的嵌套类型名”时,前面通常要加 typename

默认模板实参

模板参数也可以有默认值。

类模板默认实参

1
2
3
4
5
6
7
template <class T = int>
class Numbers {
public:
Numbers(T v = 0) : val(v) {}
private:
T val;
};

使用:

1
2
Numbers<long double> x;
Numbers<> y; // 使用默认类型 int

函数模板默认实参

1
2
3
4
5
6
template <typename T, typename F = less<T>>
int compare(const T& v1, const T& v2, F f = F()) {
if (f(v1, v2)) return -1;
if (f(v2, v1)) return 1;
return 0;
}

这表示:

  • 默认比较器是 less<T>
  • 默认函数实参是 F()

成员模板

一个类可以有成员函数模板,这叫:

成员模板

注意:

成员模板不能是虚函数

因为虚函数依赖运行时多态,模板依赖编译时实例化,两套机制不同。

普通类中的成员模板

1
2
3
4
5
6
7
class DebugDelete {
public:
template <typename T>
void operator()(T* p) const {
delete p;
}
};

这里 operator() 是成员模板。

类模板中的成员模板

类模板的成员本身也可以再是模板。

类模板参数和成员模板参数彼此独立。

模板参数推断

函数模板调用时,编译器通常会根据实参推断模板参数。

最基本规则

1
2
template <typename T>
void f(T x);

调用:

1
f(42);

则推断 T=int

引用和 const 会影响推断

例如:

1
2
template <typename T>
void f(const T& x);

调用:

1
2
int a = 10;
f(a);

T 推断为 int,参数类型变成 const int&

推断失败的常见情况

如果模板参数不能从函数实参中唯一确定,就会失败。

例如:

1
2
template <typename T>
int compare(const T&, const T&);

调用:

1
compare(1, 3.14);   // 通常推断失败

因为一个参数像 int,一个像 doubleT 无法统一。

这时可:

  • 显式指定模板实参
  • 或改写模板参数列表

显式模板实参

1
compare<int>(1, 2);

表示手动指定 T=int

此时允许后续函数参数进行普通类型转换。

模板参数推断、引用折叠、转发


std::move

std::move 的作用不是“移动对象”,而是:

把一个左值显式转换成右值引用,从而允许移动语义发生

例如:

1
2
string s1 = "abc";
string s2 = std::move(s1);

这里 std::move(s1) 表示:

  • 允许把 s1 的资源“挪走”
  • s1 之后仍然有效,但值通常不再指定

std::forward

std::forward 用于:

在模板中保持实参原本的值类别(左值/右值)

它通常和右值引用参数完美转发一起使用。

典型形式

1
2
3
4
template <typename T>
void wrapper(T&& arg) {
func(std::forward<T>(arg));
}

这里:

  • 如果传入左值,forward 后仍是左值
  • 如果传入右值,forward 后仍是右值

为什么需要它

如果不用 forward,模板里的 arg 一旦有名字,就总是左值。

所以:

std::forward 是完美转发的核心工具

可变参数模板

可变参数模板可以接受任意个数任意类型的模板参数。

这是现代 C++ 非常重要的内容。

参数包

有两种包:

  • 模板参数包
  • 函数参数包

例如:

1
2
template <typename T, typename... Args>
void foo(const T& t, const Args&... rest);

其中:

  • Args 是模板参数包
  • rest 是函数参数包

例子理解

1
foo(i, s, 42, d);

则:

  • T 对应第一个参数类型
  • Args... 对应后面剩余参数类型

空包也是合法的

1
foo("hi");

这时 Args... 可以为空。

所以参数包表示:

零个或多个参数

sizeof...

可变参数模板中常用:

1
2
sizeof...(Args)
sizeof...(args)

作用:

返回参数包中的元素个数

例如:

1
2
3
4
5
template <typename... Args>
void g(Args... args) {
cout << sizeof...(Args) << endl;
cout << sizeof...(args) << endl;
}

包扩展

对参数包能做的最核心操作就是:

展开(扩展)

写法通常是:

1
pattern...

例如:

1
print(args...);

表示把 args 包中的每个参数依次展开。

转发参数包

可变参数模板常与 forward 结合,形成“完美转发”:

1
2
3
4
template <typename T, typename... Args>
shared_ptr<T> my_make_shared(Args&&... args) {
return shared_ptr<T>(new T(std::forward<Args>(args)...));
}

这里:

  • Args&&... 接收任意参数
  • forward<Args>(args)... 保持每个参数原有左值/右值属性

这是 emplacemake_shared 一类函数的核心思路。

控制实例化

模板通常在使用时自动实例化。
但在大型工程中,相同模板实例可能在多个源文件中重复生成,增加编译开销。

因此 C++ 提供了:

  • 显式实例化声明
  • 显式实例化定义

显式实例化声明

1
extern template class Blob<string>;

含义:

本文件不要生成这个实例,别处会有定义

显式实例化定义

1
template int compare(const int&, const int&);

含义:

在这里生成这个模板实例

规则

  • 一个实例可以有多个 extern 声明
  • 但程序中应只有一个显式实例化定义

复习时知道用途即可:

减少重复实例化,降低编译开销

模板特例化(特化)

有时通用模板对某些类型不合适,
这时可以为特定类型提供“特殊版本”。

这叫:

模板特例化 / 特化(specialization)

为什么需要特化

因为通用版本可能:

  • 编译不过
  • 效率不高
  • 语义不合适

函数模板特化示意

例如通用比较:

1
2
template <typename T>
int compare(const T& a, const T& b);

对于 const char*,如果直接比较,比较的是指针地址,不是字符串内容。
因此可为它提供特化版本。

类模板特化也很常见

比如对某个特定类型做特殊处理。

复习时记一句:

特化 = 给某些特殊类型/值提供专门实现

模板的设计原则

对类型要求尽量少

模板写得越通用越好。

例如:

  • 如果只需要 <,就不要额外要求 >==
  • 如果只需要拷贝,就不要额外要求移动或默认构造

错误通常在实例化时暴露

模板定义时未必报错,
但某个具体类型一用,才会发现问题。

所以调模板代码常见现象是:

错误信息在使用点爆发

优先写清晰,再追求复杂技巧

模板语法本来就复杂,复习时先抓:

  • 会定义
  • 会使用
  • 会推断
  • 知道 forward / 可变参数模板的核心用途

常见易错点总结

template 不能拼错

不是 templete

模板参数列表不能为空

必须写至少一个模板参数。

每个类型参数前都要写 typenameclass

1
template <typename T, typename U>   // 对

模板通常放在头文件中

因为实例化时必须看到定义。

类模板的不同实例是不同类型

1
vector<int>` 和 `vector<string>`

不是同一种类型。

依赖类型名前通常要加 typename

1
typename T::value_type

成员模板不能是虚函数

函数模板不一定总能推断成功

类型不统一时常会失败。

非类型模板参数必须是编译期常量

std::move 不移动,只是转换为右值引用

真正是否移动,取决于后续是否调用移动构造/移动赋值。

std::forward 用于保留值类别

常和 T&& 及可变参数模板一起出现。


结论速记

模板是什么

模板是生成类或函数的蓝图

实例化是什么

编译器把模板变成具体类/具体函数的过程

模板参数有什么

  • 类型参数
  • 非类型参数

typename 的两种常见作用

  1. 声明类型模板参数
1
template <typename T>
  1. 说明依赖名是类型
1
typename T::value_type

可变参数模板是什么

能接受任意个参数的模板

moveforward

  • move:把左值转成右值引用
  • forward:按原值类别转发参数

特化是什么

给特殊类型/值单独提供实现


总结

模板让我们写出与类型无关的通用代码,编译器在使用时进行实例化;可变参数模板、move/forward 和特化则让模板更灵活、更高效。