C++从零开始(十):C++进阶(上)模板
🌟《C++从零开始》 系列,开始更新中…
八、模板
模板(Template)是C++中的泛型编程的基础。
就像类是一种模板,可以创建不同的对象。模板(Template),也可以创建不同的“对象”,只不过这个“对象”是函数或类。
类为同一类型的不同对象,提供了一份“通用的代码”。而产生所需的不同对象,只需实例化类生成对象时通过:
-
分配内存:为不同对象分配不同内存,这决定了它们物理意义上的不同;
-
构造函数初始化:调用指定构造函数和参数进行初始化,可以让对象产生值意义上的不同。
模板类似,根据类模板/函数模板可以实例化出不同的类/函数。这些类之间或函数之间的差异性,是在实例化时:
- 编译器会为每个实例出来的类/函数分配不同空间;
- 另一方面,是实例化时我们可以指定不同的数据类型。
模板最大的好处便体现在第2点,让我们编写的类或函数和数据类型无关,从而实现了通用编程。
本文主要探讨以下内容:
- 函数模板、类模板的应用场景及基本使用;
- 模板参数;
- 模板特化应用场景及使用,包含全特化和偏特化。
8.1 模板初识
8.1.1 函数模板
我们可能实现过这么一个mySwap
函数:
1 | void mySwap(int&a , int& b) |
这虽然简单到都不需要进行注释,但是如果哪一天我们希望交换的数类型是long
、char
、std::string
等,这肯定就让你发愁了。
总不然定义茫茫多的、仅仅数据类型不同的函数吧?还好你知道函数模板:
1 | // template允许我们的函数不再受限于数据类型 |
为你的机智点赞。现在我们探讨一点高深的问题:
- 函数模板实例化生成的“对象”在哪里?(1)、(2)两处会分别声明两个对象吗?
- 我可以将模板的声明(.h)和定义(.cpp)分开吗?
函数对象生成
模板实例化生成的函数,由编译器在编译阶段完成。(1)、(2)两处只会生成一个具体函数:
注意到上图红框(1)、(2)处,都是执行函数模板生成的同一个函数。
函数模板的声明和定义无法分开
模板的声明和定义不可以分开,如果你尝试这么做:
1 | // swap.h |
1 | // swap.cpp |
1 | // test.cpp |
编译时会发生错误:
1 | [root@roy-cpp test]# g++ -std=c++11 test.cpp swap.cpp -o test.out |
上述显示链接时找不到函数 mySwap<int>(int&, int&)
的定义。
理一理整个编译过程:
-
预编译:源文件
test.cpp
、swap.cpp
进行头文件替换等; -
编译:
test.cpp
、swap.cpp
分别单独编译生成可执行文件test.o
、swap.o
。-
test.cpp
编译到mySwap<int>(a,b)
发现一个函数调用,不过没关系,先在函数位置生成符号标记为未定义,在链接时再寻找这个函数; -
swap.cpp
进行编译,期望swap.o
生成我们所需的函数符号,但是很意外,没有任何符号被生成。
-
-
所以汇编完成后,链接时无法找到
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 | // swap.h |
1 | // test.cpp |
再次编译一切正常:
这个解决方案同样适合类模板。
什么是类模板?奥我们还没开始讲呢,看官老爷请看下文。
8.1.2 类模板
认识类模板
类模板同样使得我们编写的类不用再和数据类型相关,更加通用。
下面这个模板类StaticArray
,允许我们创建不同成员数据类型的静态数组类。
1 | template <typename T, int size> |
简单使用一下:
1 | int main() |
感觉还不错。
也许你注意到,我们进行模板定义时好像有点“奇怪”:
1 | template <typename T, int size> |
这里还使用了参数 int
, C++称之为“非模板类型参数”,而不是只使用“模板类型参数”,即typename
。
模板参数
“模板类型参数” ,是一个占位符类型,用于替代作为参数传入的类型。
“非模板类型参数”,则是预定义的类型,它允许以下类型:
- 整数类型(浮点类型在C++20起开始支持)
- 枚举类型
- 指向类对象/函数/类成员函数的指针/引用
std::nullptr_t
回顾我们之前的代码:
1 | StaticArray<int, 4> myArray1{}; |
实例化类模板StaticArray
时,编译器会将 T
替换为int
,size
替换为 4
。此时,int
是一个类型参数,而 4
是一个非类型参数。
最终m_array
是 int[4]
类型。
最后注意,非模板类型参数只能用表达式 初始化,非表达式 是不被允许的。
1 | int x = 4; |
8.2 模板特化
模板使得我们不用再担心仅仅因为数据类型不同,而去定义多个极为类相似的函数或类。
因为我们的类模板/函数模板,可以处理不同数据类型。也就是对于不同数据类型,我们都可以使用同一套模板代码。
这会带来另一个问题:同一套代码,对特定的数据类型也许并不能很好地处理。
这就依旧需要我们针对特定数据类型,准备特定模板。
之前的例子:
1 | template <typename T, int size> // 类模板 |
现在有一个成员函数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 | /* tmp.cpp */ |
注意到:
- 全特化通过尖括号
<int,char>
,对模板两个类型参数都进行了限制,T1、T2分别限定为 int 、char; - 偏特化
<T1,char>
,只对T2进行了限制为char,T1没有限制(部分限制)。
为什么函数模板不允许偏特化?
函数可以进行重载,函数模板也不例外,也可以进行重载。
但因为函数重载就可以实现偏特化,所以偏特化没有必要。
1 | /* tmp.cpp */ |
到目前为止,函数模板的偏特化还没有得到C++标准的支持,不排除它在将来会被纳入标准的可能。
函数模板重载可以实现偏特化,那也应该能实现全特化吧?
1 | // 函数模板1 |
1、2两处代码被视为是等价的,全特化就是模板1的重载。
那为啥全特化被允许使用?不明白,有机会厘清一下。
不过请注意,全特化的函数模板已经不能称之为模板。
全特化接管了编译器的工作,实例化出了函数。查看tmp.cpp
的符号表,也发现确实已有符号生成:
而模板(及偏特化)只有在编译到调用代码时才会进行实例化,生成具体函数符号。
成员函数模板全特化
继续前面的例子,我们采用函数模板全特化解决。注意,此时print
是成员函数。
1 |
|
注意到,template <>
模板参数已为空,代码26行处StaticArray<char, 13>
将原本参数T
被替换为char
,size
被替换为13
。
1 | int main() |
再次编译输出正常:
1 | [root@roy-cpp test]# ./test.out |
但这个全特化例子,对模板所有类型的参数都限定死了,包括size
。所以,我们只能处理特定长度为13
的字符串。
虽然我们很想使用偏特化,只对T
进行限制为char
,对size
不进行限制:
1 | template <int size> // 函数模板偏特化?error |
C++不允许呀!那就试试函数重载吧。
但是成员函数print()
根本没办法重载,因为它没有任何参数来对T
进行限制。除非它是一个普通函数:
1 | template <typename T, int size> |
不过我们可以迂回一点:函数模板不能偏特化,不代表类模板不能偏特化(类没有重载,不会受到限制)。我们可以偏特化出一个类模板,专门让成员函数print
处理不同长度的char
类型数据。
这就是类模板的偏特化。
8.2.2 类模板特化
类模板全、偏特化
我们使用类模板偏特化,让成员函数print
可以处理不同长度的char
类型数据。
1 | // template <typename T, int size> |
试一试:
1 | int main() |
输出:
类模板全特化使用类似,这里仅做一个简单对比:
1 | // 类模板 |
指针偏特化
先观察下面这个例子:
1 | // 类模板1 |
或许让你有点惊讶,类模板2依旧被视为是类模板1的偏特化版本。即使我们没有准确地指定底层类型,只是告诉编译器它用于指针类型。
下面是具体实例应用。
1 |
|
模板相关介绍over,下章我们开始介绍C++标准库STL。
更新记录
- 第一次更新
参考资料
- 1.C++ 模板偏特化与全特化 – 珂酱 (kejiang.co) ↩
- 2.C++模板template用法总结:https://blog.csdn.net/qq_35637562/article/details/55194097 ↩