🌟《C++从零开始》 系列,开始更新中…

八、模板

模板(Template)是C++中的泛型编程的基础。

就像类是一种模板,可以创建不同的对象。模板(Template),也可以创建不同的“对象”,只不过这个“对象”是函数或类

类为同一类型的不同对象,提供了一份“通用的代码”。而产生所需的不同对象,只需实例化类生成对象时通过:

  1. 分配内存:为不同对象分配不同内存,这决定了它们物理意义上的不同;

  2. 构造函数初始化:调用指定构造函数和参数进行初始化,可以让对象产生值意义上的不同。

模板类似,根据类模板/函数模板可以实例化出不同的类/函数。这些类之间或函数之间的差异性,是在实例化时:

  1. 编译器会为每个实例出来的类/函数分配不同空间;
  2. 另一方面,是实例化时我们可以指定不同的数据类型

模板最大的好处便体现在第2点,让我们编写的类或函数和数据类型无关,从而实现了通用编程

本文主要探讨以下内容:

  • 函数模板、类模板的应用场景及基本使用;
  • 模板参数;
  • 模板特化应用场景及使用,包含全特化和偏特化。

8.1 模板初识

8.1.1 函数模板

我们可能实现过这么一个mySwap 函数:

1
2
3
4
5
6
void mySwap(int&a , int& b) 
{
int temp = a;
a = b;
b = temp;
}

这虽然简单到都不需要进行注释,但是如果哪一天我们希望交换的数类型是longcharstd::string 等,这肯定就让你发愁了。

总不然定义茫茫多的、仅仅数据类型不同的函数吧?还好你知道函数模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// template允许我们的函数不再受限于数据类型
template<typename T>
void mySwap(T& a, T& b)
{
T temp = a;
a = b;
b = temp;
}

int main()
{
int a = 0, b = 1;
int a1 = 0, b1 = 1;
long a2 = 0, b2 = 1;

mySwap<int>(a,b); // (1)实例化模板指定数据类型

mySwap<int>(a1,b1); // (2)

mySwap<long>(a2,b2); // (3)
}

为你的机智点赞。现在我们探讨一点高深的问题:

  • 函数模板实例化生成的“对象”在哪里?(1)、(2)两处会分别声明两个对象吗?
  • 我可以将模板的声明(.h)和定义(.cpp)分开吗?
函数对象生成

模板实例化生成的函数,由编译器在编译阶段完成。(1)、(2)两处只会生成一个具体函数:

image-20220210222030037

注意到上图红框(1)、(2)处,都是执行函数模板生成的同一个函数。

函数模板的声明和定义无法分开

模板的声明和定义不可以分开,如果你尝试这么做:

1
2
3
// swap.h
template<typename T>
void mySwap(T& t1, T& t2);
1
2
3
4
5
6
7
8
// swap.cpp
template<typename T>
void mySwap(T& a, T& b)
{
T temp = a;
a = b;
b = temp;
}
1
2
3
4
5
6
7
8
// test.cpp
# include "swap.h"

int main()
{
int a = 0, b = 1;
mySwap<int>(a,b);
}

编译时会发生错误:

1
2
3
4
[root@roy-cpp test]# g++ -std=c++11 test.cpp swap.cpp -o test.out
/tmp/ccrmjFSC.o: In function `main':
test.cpp:(.text+0x25): undefined reference to `void mySwap<int>(int&, int&)'
collect2: error: ld returned 1 exit status

上述显示链接时找不到函数 mySwap<int>(int&, int&)的定义。

理一理整个编译过程:

  1. 预编译:源文件test.cppswap.cpp进行头文件替换等;

  2. 编译:test.cppswap.cpp 分别单独编译生成可执行文件test.o swap.o

    • test.cpp 编译到 mySwap<int>(a,b) 发现一个函数调用,不过没关系,先在函数位置生成符号标记为未定义,在链接时再寻找这个函数;

      image-20220210233031798

    • swap.cpp 进行编译,期望swap.o 生成我们所需的函数符号,但是很意外,没有任何符号被生成。

    image-20220210230529700

  3. 所以汇编完成后,链接时无法找到mySwap任何相关定义,链接报错。

为什么swap.o 没有生成具体函数

这是因为模板分两次编译:

  • 第一次编译发生在 swap.cpp中(实例化前),仅对模板进行一些语法检查等,不生成具体函数,所以 swap.o 符号表没有生成任何符号;
  • 第二次编译是在test.cpp中(实例化时),编译到调用代码mySwap<int>(a,b)时,才会去编译 swap.cpp中模板mySwap生成具体函数,因为这个时候编译器才知道需要的类型是int,知道生成什么参数类型的函数

但是test.cpp 的头文件 swap.h 仅包含函数模板mySwap 的声明,无法得知其定义,所以编译器无法根据其生成具体函数。

最终导致链接时发生错误。

解决办法也很简单,模板的定义和声明都放在头文件中即可,这样生成函数对象的时候就可以看到函数模板完整定义

试一试。

注意到我们还在swap.h 设置了宏开关,避免模板定义出现在头文件中时,而swap.h被多个文件引用,导致mySwap 出现重复定义。

1
2
3
4
5
6
7
8
9
10
11
// swap.h
#ifndef mySwap_H
#define mySwap_H
template<typename T>
void mySwap(T& a, T& b) // 定义和声明在一块儿
{
T temp = a;
a = b;
b = temp;
}
#endif
1
2
3
4
5
6
7
8
// test.cpp
# include "swap.h"

int main()
{
int a = 0, b = 1;
mySwap<int>(a,b);
}

再次编译一切正常:

image-20220210234622994

这个解决方案同样适合类模板。

什么是类模板?奥我们还没开始讲呢,看官老爷请看下文。

8.1.2 类模板

认识类模板

类模板同样使得我们编写的类不用再和数据类型相关,更加通用。

下面这个模板类StaticArray,允许我们创建不同成员数据类型的静态数组类。

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T, int size> 
class StaticArray
{
private:
T m_array[size]{};

public:
T* getArray() { return m_array; }
T& operator[](int index)
{
return m_array[index];
}
};

简单使用一下:

1
2
3
4
5
6
int main()
{
StaticArray<int, 4> myArray1{};
StaticArray<long, 10> myArray2{};
return 0;
}

感觉还不错。

也许你注意到,我们进行模板定义时好像有点“奇怪”:

1
2
template <typename T, int size> 
class StaticArray

这里还使用了参数 int , C++称之为“非模板类型参数”,而不是只使用“模板类型参数”,即typename

模板参数

“模板类型参数” ,是一个占位符类型,用于替代作为参数传入的类型。

“非模板类型参数”,则是预定义的类型,它允许以下类型:

  • 整数类型(浮点类型在C++20起开始支持)
  • 枚举类型
  • 指向类对象/函数/类成员函数的指针/引用
  • std::nullptr_t

回顾我们之前的代码:

1
StaticArray<int, 4> myArray1{};

实例化类模板StaticArray 时,编译器会将 T替换为intsize 替换为 4 。此时,int 是一个类型参数,而 4 是一个非类型参数。

最终m_arrayint[4] 类型。

最后注意,非模板类型参数只能用表达式 初始化,非表达式 是不被允许的。

1
2
int x = 4;
StaticArray<int, x> myArray1{}; // error

8.2 模板特化

模板使得我们不用再担心仅仅因为数据类型不同,而去定义多个极为类相似的函数或类。

因为我们的类模板/函数模板,可以处理不同数据类型。也就是对于不同数据类型,我们都可以使用同一套模板代码

这会带来另一个问题:同一套代码,对特定的数据类型也许并不能很好地处理

这就依旧需要我们针对特定数据类型,准备特定模板。

之前的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template <typename T, int size> // 类模板
class StaticArray
{
private:
T m_array[size]{};

public:
T* getArray() { return m_array; }
T& operator[](int index)
{
return m_array[index];
}
void print()
{
for (int i{ 0 }; i < size; ++i)
std::cout << m_array[i] << ' ';
std::cout << '\n';
}
};

现在有一个成员函数print期望可以打印StaticArray 的不同数据类型成员m_array

注意,这个函数为数组m_array 每个元素都设置了空格 ' '

这看起来没什么毛病:

  • 对于非字符类型,在每个数组元素之间放置一个空格是有意义的:

    1
    1 2 3 4 5
  • 但对于字符类型数组,char数组 ,你肯定不想打印出来的结果是这样的:

    1
    h e l l o  w o r l d !

所以我们希望函数模板(成员函数print此时也是模板,作为类模板的成员),针对特定的数据类型char,能进行特别处理。

这就是模板特化的思想, 模板特化具体又分为模板全特化和偏特化:

  • 全特化就是限定死模板实现的模板类型参数;
  • 偏特化就是如果这个模板有多个类型参数,那么只限定其中的一部分。

8.2.1 函数模板特化

函数模板特化初识

先从普通函数模板特化说起,再谈论成员函数模板特化。

函数模板有两个重要的概念:

  • 对于函数模板,只有全特化,不能偏特化;

  • 编译器优先匹配:全特化>偏特化>函数模板。

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
/* tmp.cpp */ 
// 函数模板
template<typename T1, typename T2>
void fun(T1 a , T2 b)
{
}

// 全特化
template<>
void fun<int ,char>(int a, char b) // 声明时<>指定参数类型
{
}

// 函数不存在偏特化:下面的代码是错误的
/*
template<typename T1>
void fun<T1,char>( T1 a , char b) // 偏特化,error
{
}
*/

int main()
{
fun<int,double>(1,2); // 匹配模板函数,使用时<>指定参数类型
fun<int,char>(1,2); // 匹配全特化
}

注意到:

  • 全特化通过尖括号<int,char>,对模板两个类型参数都进行了限制,T1、T2分别限定为 int 、char;
  • 偏特化<T1,char>,只对T2进行了限制为char,T1没有限制(部分限制)。

为什么函数模板不允许偏特化

函数可以进行重载,函数模板也不例外,也可以进行重载。

但因为函数重载就可以实现偏特化,所以偏特化没有必要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* tmp.cpp */ 
// 函数模板1
template<typename T1, typename T2>
void fun(T1 a , T2 b);

/*
// 函数不存在偏特化:下面的代码是错误的
template<typename T1>
void fun<T1,char>( T1 a , char b); // 偏特化,error
*/

// 函数模板2,重载了模板1
// 实现了偏特化
template<typename T1>
void fun(T1 a , char b); // 不是偏特化,注意没有<>

到目前为止,函数模板的偏特化还没有得到C++标准的支持,不排除它在将来会被纳入标准的可能。

函数模板重载可以实现偏特化,那也应该能实现全特化吧?

1
2
3
4
5
6
7
8
9
10
11
// 函数模板1
template<typename T1, typename T2>
void fun(T1 a , T2 b);

// 1.全特化
template<>
void fun<int ,char>(int a, char b); // <>指定参数类型

// 2.模板2,重载了函数模板1
template<>
void fun(int a, char b);

1、2两处代码被视为是等价的,全特化就是模板1的重载

那为啥全特化被允许使用?不明白,有机会厘清一下。

不过请注意,全特化的函数模板已经不能称之为模板

全特化接管了编译器的工作,实例化出了函数。查看tmp.cpp的符号表,也发现确实已有符号生成:

image-20220211115932016

而模板(及偏特化)只有在编译到调用代码时才会进行实例化,生成具体函数符号。

成员函数模板全特化

继续前面的例子,我们采用函数模板全特化解决。注意,此时print是成员函数。

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
#include<cstring>
#include<iostream>

template <typename T, int size> // 类模板
class StaticArray
{
private:
T m_array[size]{};

public:
T* getArray() { return m_array; }
T& operator[](int index)
{
return m_array[index];
}

void print()
{
for (int i{ 0 }; i < size; ++i)
std::cout << m_array[i] << ' ';
std::cout << '\n';
}
};

template <> // 成员函数模板全特化
void StaticArray<char, 13>::print()
{
for (int count{ 0 }; count < 13; ++count)
std::cout << m_array[count]; // 去除了字符之间的空格
}

注意到,template <> 模板参数已为空,代码26行处StaticArray<char, 13>将原本参数T 被替换为charsize 被替换为13

1
2
3
4
5
6
7
int main()
{
StaticArray<char, 13> char13{};
std::strcpy(char13.getArray(), "Hello world!"); // 数组不能作为左值,用strcpy复制
char13.print();
return 0;
}

再次编译输出正常:

1
2
[root@roy-cpp test]# ./test.out 
Hello world!

但这个全特化例子,对模板所有类型的参数都限定死了,包括size。所以,我们只能处理特定长度为13的字符串。

虽然我们很想使用偏特化,只对T进行限制为char ,对size 不进行限制:

1
2
3
4
5
6
template <int size> // 函数模板偏特化?error
void StaticArray<char, size>::print()
{
for (int count{ 0 }; count < 13; ++count)
std::cout << m_array[count];
}

C++不允许呀!那就试试函数重载吧。

但是成员函数print()根本没办法重载,因为它没有任何参数来对T 进行限制。除非它是一个普通函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
template <typename T, int size>
void print(StaticArray<T, size>& array) // 非成员函数,有一个StaticArray类型参数
{
for (int count{ 0 }; count < size; ++count)
std::cout << array[count] << ' ';
}

template <int size>
void print(StaticArray<char, size>& array) // 函数重载,此时ok
{
for (int count{ 0 }; count < size; ++count)
std::cout << array[count];
}

不过我们可以迂回一点:函数模板不能偏特化,不代表类模板不能偏特化(类没有重载,不会受到限制)。我们可以偏特化出一个类模板,专门让成员函数print处理不同长度的char 类型数据

这就是类模板的偏特化。

8.2.2 类模板特化

类模板全、偏特化

我们使用类模板偏特化,让成员函数print 可以处理不同长度的char类型数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// template <typename T, int size>
template <int size>
class StaticArray<char,size> // 类模板偏特化
{
private:
char m_array[size]{};

public:
char* getArray() { return m_array; }
char& operator[](int index)
{
return m_array[index];
}

void print()
{
for (int i{ 0 }; i < size; ++i)
std::cout << m_array[i];
std::cout << '\n';
}
};

试一试:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
StaticArray<char, 7> char7{}; // 长度为7
std::strcpy(char7.getArray(), "Hello");

StaticArray<char, 13> char13{}; // 长度为13
std::strcpy(char13.getArray(), "Hello world!");

char7.print(); // ok
char13.print(); // ok
return 0;
}

输出:

image-20220211140112484

类模板全特化使用类似,这里仅做一个简单对比:

1
2
3
4
5
6
7
8
9
10
11
// 类模板
template <typename T, int size>
class StaticArray;

// 类模板偏特化
template <int size>
class StaticArray<char,size>; // 尖括号指定参数

// 类模板全特化
template <>
class StaticArray<char,14>; // 尖括号指定参数
指针偏特化

先观察下面这个例子:

1
2
3
4
5
6
7
8
9
10
11
// 类模板1
template <typename T>
class Storage;

// 模板1的全特化版本
template <>
class Storage<char*>;

// 类模板2,模板1的偏特化版本
template <typename T>
class Storage<T*>;

或许让你有点惊讶,类模板2依旧被视为是类模板1的偏特化版本。即使我们没有准确地指定底层类型,只是告诉编译器它用于指针类型

下面是具体实例应用。

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <iostream>
#include <cstring>

// 模板1:只适合存储(单个)非指针类型成员
template <typename T>
class Storage
{
private:
T m_value;
public:
Storage(T value): m_value { value } // 只能初始化非指针成员(单个),值复制方式
{
}

~Storage()
{
}
};

// 模板2:模板1的偏特化版本,可以存储(单个)指针类型成员
template <typename T>
class Storage<T*>
{
private:
T* m_value;
public:
Storage(T* value): m_value { new T { *value } } // 适合初始化(单个)指针成员,new分配
{
}

~Storage()
{
delete m_value;
}
};

// 模板1的全特化版本:适合存储char*指针数组
template <>
class Storage<char*>
{
private:
char* m_value;
public:
Storage(char* value)
{
int length { 0 };
while (value[length] != '\0')
++length;
++length;
m_value = new char[length];
for (int count = 0; count < length; ++count)
m_value[count] = value[count];
}
~Storage()
{
delete m_value;
}
};

int main()
{
Storage<int> my_int { 5 };

int x { 7 };
Storage<int*> my_int_ptr(&x);

char *name { new char[40]{ "royhuang" } };
Storage< char*> my_name(name);
delete[] name;
}

模板相关介绍over,下章我们开始介绍C++标准库STL。

更新记录

2022-02-11 :更新笔记

  1. 第一次更新

参考资料


  1. 1.C++ 模板偏特化与全特化 – 珂酱 (kejiang.co)
  2. 2.C++模板template用法总结:https://blog.csdn.net/qq_35637562/article/details/55194097