模板

函数模板

普通函数其参数是特定类型

1
void foo(int, double);

利用函数模板我们可以让编译器根据实参推断参数类型

1
2
3
// typename 关键字换 class 也行
template <typename T, typename R>
void foo(T, R);

比如写一个把传进参数+1的函数,我们可以对任意类型调用(如果可以+1的话)

1
2
3
4
template <typename T>
T add_one(T x) {
return x + 1;
}
  • 编译期看到调用点,才根据实参 自动生成 对应函数。
  • 如果 T 不支持 +1,编译直接报错,而不是运行期崩溃。

我们试着调用add_one

1
2
3
4
5
6
7
// 打印2,3,3
std::cout << add_one(1) << std::endl;
std::cout << add_one(2.0f) << std::endl;
std::cout << add_one(2.0) << std::endl;

// 编译报错,string不支持+int
std::cout << add_one(std::string("hello")) << std::endl;

类模板

和函数模板如出一辙,可以泛化类型,让我们写一个能构造任意类型数组的一个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
// 两个模板参数:类型 + 大小
template<typename T, std::size_t N>
class MyArray
{
public:
MyArray() { std::cout << "MyArray<" << typeid(T).name() << "," << N << ">\n"; }
T data[N];
};

int main()
{
MyArray<int, 5> a; // 生成一份 int[5] 的代码
MyArray<double, 3> b; // 再生成一份 double[3] 的代码
}

模板参数包

模板参数包是接受 任意参数类型和任意参数个数 的一个特性,使用的时候涉及到打包和解包两个操作

函数模板+参数包

我们写一个log函数,打印接收到的所有参数

1
2
3
4
5
6
7
8
9
10
11
12
#include <iostream>
template<typename... Args> // 打包
void log(Args... args) // 解包
{
((std::cout << args << ' '), ...); // fold expression, since c++17
std::cout << '\n';
}

int main()
{
log(2025, "price=", 19.9, '$'); // 任意个数、任意类型
}

上面的 右折表达式 会在编译期把参数包内容展开成前面操作的形式

1
2
3
4
5
6
7
8
9
10
// 一元右折表达式展开
((std::cout << arg1 << ' '),
((std::cout << arg2 << ' '),
((std::cout << arg3 << ' '), ...)));

// 等价于
std::cout << a1 << ' ';
std::cout << a2 << ' ';
std::cout << a3 << ' ';
...

接下来我们使用递归调用来设计功能一样的log

需要注意的事,设计递归,就需要终止条件,而递归函数模板也不例外

1
2
3
4
5
6
7
8
9
10
// 终止条件
void log() {
std::cout << "[log end]" << std::endl;
}

template<typename Head, typename... Args>
void log(const Head& h, Args... args) {
std::cout << h << std::endl;
log(args...);
}

终止条件必须写在递归函数模板之前,必须让编译器先看到递归终止条件,在递归调用到0参数时,才会调用终止条件的函数;把终止条件写在后面和不写的效果是一样的,都会编译报错,因为编译器找不到0参数函数实例,就会照着模板生成一个,然后发现模板至少需要一个参数(typename Head限制至少需要一个参数),就会生成实例失败

另外,如果存在多个可推导模板,比如上面两个log,一个用折叠表达式,一个用递归,编译器是看哪个模板更特殊决定使用哪个模板的,更特殊大致的意思是“约束更严格、匹配范围更小”,这里选用的是后面的递归形式,因为它限制了至少要有一个参数

类模板+参数包

我们来写一个简单的tuple类,用类模板递归把参数包拆成“头+尾”,这也是标准库std::tuple的核心思想,把数据一层层存储

需要注意的是,设计递归,就需要终止条件,而递归类模板也不例外(重要的事情说两遍)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>

// 需要此前向声明,因为Tuple内部还使用了Tuple<Tail...>
template<typename... Ts>
class Tuple;

// 递归的终止条件(全特化),拆包结束
template<> // 0 个参数时的特化
class Tuple<> {
public:
void print() const {}
};

// 创建tuple类
template<typename Head, typename... Tail>
class Tuple<Head, Tail...> // 至少 1 个参数
{
Head value; // 存为头部数据
Tuple<Tail...> tail; // 剩下的再打包一层
public:
Tuple(Head h, Tail... t) // 构造:把参数包一层层剥开
: value(h), tail(t...) {}

void print() const // 递归打印
{
std::cout << value << ' ';
tail.print();
}
};

int main()
{
Tuple<int, double, char> t(42, 3.14, 'X');
t.print(); // 42 3.14 X
}

完美转发

完美转发即把收到的实参原汁原味地转给另一段代码,既不掉引用,也不掉 const/volatile`,更不掉右值性

我们先看看普通的传参

1
2
3
4
5
template<class T>
void wrapper(T arg) // 按值传
{
foo(arg); // 这里 arg 永远是左值
}

如果我们foo函数只有一个接收右值的定义,上面的代码就会编译报错了

1
void foo(int&&) {  }  // 右值引用

使用std::forward进行完美转发,保留值性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
#include <utility> // std::forward

// 三个重载
void foo(int& x) { std::cout << "lvalue int& : " << x << '\n'; }
void foo(int&& x) { std::cout << "rvalue int&& : " << x << '\n'; }
void foo(const int& x) { std::cout << "const lvalue const int& : " << x << '\n'; }

// 完美转发包装器
template<class T>
void wrapper(T&& arg) { // 万能引用,仅模板推导+&&才是万能,这里有个引用折叠的知识点就不多说了
foo(std::forward<T>(arg));
}

int main() {
int a = 1;
const int b = 2;

wrapper(a); // lvalue
wrapper(b); // const lvalue
wrapper(3); // rvalue
wrapper(std::move(a)); // rvalue (forced transform)
}

典型应用:工厂函数

1
2
3
4
5
template<class T, class... Args>
T* create(Args&&... args)
{
return new T(std::forward<Args>(args)...); // 完美转发给构造函数
}

函数模板全特化

全特化和偏特化的概念很简单,前者是将全部模板参数定义为特定类型,后者是将部分定义为特定类型

有人可能会好奇,编译器不是会自动推断吗?干嘛还要特化这种东西

其实特化主要是针对特定类型,我们能做的事情不同,意思就是针对特定类型,函数的定义选为另一套,参数类型推导可不会改函数定义,所以这两个是完全不同的东西,别混淆了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <iostream>

template <typename T>
void foo(T) {
std::cout << "could calculate" << std::endl;
}

template <>
void foo(bool) {
std::cout << "could judgement" << std::endl;
}

template <>
void foo(const char*) {
std::cout << "could read" << std::endl;
}

int main()
{
int a { 1 };
float b { 2.0f};
double c { 3.0 };
bool e { false };
const char* d { "hello" };

foo(a); // could calculate
foo(b); // could calculate
foo(c); // could calculate
foo(d); // could read
foo(e); // could judgement
}

先提及两个注意点:

  • 全特化不参与重载,只是给已经生成的模板实例打补丁。
  • 如果把它写成重载函数而不是全特化,行为会不同(重载优先级更高)。

这也是 “模板全特化 vs 重载” 经典面试题。

在上面的代码中加上一个重载函数,那么最终调用的是这个重载函数,打印出”hello”

1
2
3
void foo(const char* s) {
std::cout << s << std::endl;
}

如果你喜欢先看看目录再看文章,可能发现了函数模板只提到了全特化,没错,因为函数模板不允许偏特化,这和函数重载决议有关系

说白了c++压根没考虑过两个完全一样的签名在重载决议时出现,我们的通用模板和偏特化模板可能会实例化出完全一样的函数签名,这时候就没法抉择了

而全特化的函数模板,在编译器眼里就是一个普通函数,编译器给他改了函数签名让他不参与重载决议,所以函数模板全特化是允许的,在调用点处,编译器会先看有没有普通函数可以用,再用通用模板匹配,匹配推导类型成功后再看有没有全特化的函数,这样全特化函数即使改了签名还是能被调用到

类模板全特化与偏特化

类模板的全特化写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

template<class T, class U>
class Foo { // 主模板:两个参数
public:
void print() { std::cout << "Universal\n"; }
};

template<> // 全特化:两个参数全部给出具体类型
class Foo<int, double> {
public:
void print() { std::cout << "int+double Specialized\n"; }
};

int main() {
Foo<int, char> f1;
Foo<int, double> f2;

f1.print();
f2.print();

return 0;
}

类模板的偏特化写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>

template<class T, class U>
class Foo { // 主模板:两个参数
public:
void print() { std::cout << "Universal\n"; }
};

// 1. 只指定第一个,第二个保持开放
template<class U> // 注意:这里还有一个模板参数 U
class Foo<int, U> { // 模式:只要第一实参是 int 就命中
public:
void print() { std::cout << "int+? Specialized\n"; }
};

// 2. 指定第二个为指针,第一个保持开放
template<class T> // 仍有模板参数 T
class Foo<T, T*> {
public:
void print() { std::cout << "?+T* Specialized\n"; }
};

int main() {
Foo<int, char> f1;
Foo<int, char*> f2;
Foo<float, float*> f3;
// Foo<int, int*> f4;

f1.print();
f2.print();
f3.print();

return 0;
}

上面注释了一个f4,如果取消注释就会发现有编译错误,原因是两个偏特化都能推导定义并且编译器无法判定谁更特殊,就不知道用哪个模板生成了,解决方案也很多,去掉一个偏特化或者加一个全特化之类的,这里只是说明一下问题希望用模板时多多思考,规则有点多