文章目录
12. 模板篇
这一篇我们要来了解了解C++中的另一种程序设计模式——泛型程序设计,而C++利用了模板这一特性来完成泛型程序设计的操作。
(1). 泛型程序设计的思想
#1.什么是泛型
之前,我们不止一次提到了自己写一个Vector这件事,那你有想过这个问题吗?我们很轻松地实现了对于int的vector:
class IntVector
{
// 这里是轻松的实现
};
有一天,我觉得存double也需要一个Vector,那我可能要依照上面的代码写一个:
class DoubleVector
{
// 除了类型其它都一样的轻松的实现
};
两个也就算了,但是C++里还有float、long、long long、unsigned int、unsigned long long,还有无穷无尽的自定义类,难不成,我们都要一个个写一遍?? 这就太夸张了,我们不可能穷尽所有的类型。
有一个点值得关注,其实大部分类型在实现这个Vector的时候核心代码都是一样的,只有类型不同,或许我们实现对类型的抽象,例如在数据结构中我们经常提到ADT(Abstract Data Type)—抽象数据类型,一个数据结构本身往往与数据类型无关,我们只要在乎它的结构就好了。
而对于这种对于类型的抽象,我们就把它称为泛型程序设计,在实现一些函数或类的时候,我们不再去考虑具体的类型,而更加多地专注于一个通用方法的实现。
#2.C++中的泛型实现
在C++中,我们通过模板来实现泛型程序设计,例如你在用STL的vector时,需要往里传入一个类型参数,例如:
std::vector<int> v1;
std::vector<Complex> v2;
std::vector<std::vector<int>> v3;
在类后的尖括号内声明类型,就可以完成后面的各种操作了,这就是模板,模板的参数也可以不止一个,例如STL中的array,就需要传入类型和大小两个参数,例如:
std::array<int, 10> a1;
std::array<double, 20> a2;
而且到这里你也发现了,好像我们举的例子中泛型和类有着很紧密的联系,不过实际上,这二者只是并不对立,模板完全可以应用于函数中:
template<typename T>
void swap(T& a, T& b)
{
T temp = a;
a = b;
b = temp;
}
这里就实现了一个通用的swap函数,对于任意两个相同类型的对象,只要定义了赋值运算符,就可以完成交换。所以模板并非类或者函数的附属,它可以辅助类与函数更好地完成工作,至于为什么说模板不是附属,这在后面的模板元编程中会再讲到。
(2). 类模板的实现
那我们就先看看类模板如何实现吧,我们依旧以自制Vector为例:
template<typename T>
class Vector
{
...
};
在正常的类声明之前,我们要先加一行:
template<typename T>
typename T表示T是一个模板参数,它的参数值是一个类型名,这里typename可以替换为class,即:
template<class T>
typename和class是等价的,后面的字母T因为是形式参数,所以只要满足变量命名规则都是可以的;当然,如果你的模板中不需要类型参数,你也可以往里面写你需要的东西,例如:
template<size_t _size>
template<int _capacity>
template<typename T, size_t _size>
就如上面的代码,如果有多个模板参数,可以直接用逗号分隔,就如同函数的参数表一样。而一旦声明完了模板参数,在类内就可以直接使用模板参数完成操作了,例如:
template<typename T>
class Vector
{
private:
T* _data;
size_t _capacity;
size_t _size;
public:
Vector()
: _data(nullptr), _capacity(0), _size(0) {}
Vector(size_t _capa)
: _data(new T[_capa]), _capacity(_capa), _size(0) {}
Vector(const Vector<T>& vec)
: _data(new T[vec._capacity])
, _capacity(vec._capacity)
, _size(vec._size)
{
for (size_t i = 0; i < _size; i++) {
_data[i] = vec._data[i];
}
}
~Vector()
{
delete[] _data;
_data = nullptr;
}
T& operator[](size_t i)
{
if (i < _size) return _data[i];
else throw std::out_of_range("Index out of range!");
}
...
};
在后续编写代码的时候,可以完全采用T来代替类型,这样可就太方便了,我们不再需要对于每个类型都写一模一样的代码了!这里没有用到其他的类型,如果你要用到也是没问题的,例如:
template<typename T, typename U>
class Pair
{
public:
T first;
U second;
};
例如这样,就可以完成多个模板参数的调用,是不是还挺简单的?之后在调用这个类模板的时候,我们需要显式使用<>来传入对应的模板参数,例如:
Vector<int> a;
Pair<std::string, Vector<int>> b;
这样就好了,还有一个要注意的点是,模板参数一定要是编译期常量,例如我们先前用到的这个例子:
template<typename T, size_t _Size>
class Array
{
private:
T _arr[_Size];
size_t _size;
public:
Array() : _size(_Size) {}
};
在调用时一定要这么写:
// 这是正确示范
Array<int, 10> a;
constexpr int c = 100;
Array<int, c> b;
// 下面写法会报错
int d = 20;
Array<int, d> e;
因为模板代码的实例化是在编译期完成的,一旦出现了运行期确定的变量,则模板的实例化无法在编译期完成,从而就没有办法生成代码了,关于它的具体实现细节,我们会在第四小节中讲到。
(3). 函数模板的实现
函数模板的声明和类模板其实差不多:
template<typename T, ...>
RetType FuncName(T arg1, ...)
{
...
}
函数部分和正常函数一致,模板声明部分和类模板是一致的,因此声明和定义函数模板的时候没有什么太大的问题,关键在于调用。函数模板的调用不像类模板一样要显式声明模板参数,例如用到之前说的swap函数:
template<typename T>
void swap(T& a, T& b)
{
T temp = a;
a = b;
b = temp;
}
你可以这样去调用:
int a = 10, b = 20;
swap(a, b); // 不显式声明模板参数
swap<>(a, b); // 同样也是不显式声明模板参数,由C++自动推断
swap<int>(a, b); // 显式声明模板参数
这个用起来看起来可是要比类模板方便不少呢!对吧?
(4). 模板的工作原理
说了模板怎么用,我们还是得聊聊模板的工作原理,模板这个名字起的非常传神,它的工作真的就是:程序员写一个标准的模板代码,编译器在编译过程中根据模板代码和传入的模板参数生成调用到的代码。我们把编译器根据模板生成代码的过程叫做实例化,也很好理解,就是照着模板写了个实例出来,和用类生成一个对象的过程是类似的。
有这么一个事情我们需要注意:不是只要有了一个模板参数,就会把对应的类模板中所有的函数全部生成一次的,只有调用到的函数会进行生成,例如:
#include <iostream>
template<typename T>
class Vector
{
private:
T* _data;
size_t _capacity;
size_t _size;
public:
Vector()
: _data(nullptr), _capacity(0), _size(0) {}
Vector(int _capa)
: _data(new T[_capa]), _capacity(_capa), _size(0) {}
Vector(const Vector<T>& vec)
: _data(new T[vec._capacity])
, _capacity(vec._capacity)
, _size(vec._size)
{
for (size_t i = 0; i < _size; i++) {
_data[i] = vec._data[i];
}
}
~Vector()
{
delete[] _data;
_data = nullptr;
}
T& operator[](size_t i)
{
if (i < _size) return _data[i];
else throw std::out_of_range("Index out of range!");
}
};
int main()
{
Vector<int> a;
Vector<double> b;
return 0;
}
我们使用g++编译,并用objdump查看生成的目标代码文件中的符号表,结果(节选)如下:
因为我们只用到了对于int和double的默认构造函数以及析构函数,因此我们只能看到对应的四个函数。
_ZN6Vector说明函数在Vector的命名空间中,IiE和IdE分别表示模板参数为int和double,C表示Constructor,D表示Destructor,最后的E表示命名空间嵌套结束,v为参数类型void,对于其他类型的构造函数,编译器一个都没有生成,我们可以验证一下,把b的定义改一改:
Vector<double> b(10);
再次编译查看符号表可以发现:
这回虽然依旧只有四个函数,但是第三个函数变了,从_ZN6VectorIdEC2Ev变成了_ZN6VectorIdEC2Ei,也就是说参数类型从void变成了int,这就对了,模板代码生成时只会生成真正用到的代码,没有用到的代码即便有声明和定义最终也不会用于生成代码。
下面一个问题是,模板代码的生成是什么时候完成的?编译时加上–save-temps指令可以保存临时文件,我们先看看.ii文件(编译预处理后的文件),由于编译预处理会粘贴iostream的代码,这里我只把最后一部分的内容放出来:
完 全 一 致,这段代码变都不带变的,所以模板代码生成肯定不是在预处理阶段完成的,再来看看.s文件,这是在编译期生成的汇编代码文件:
.LEHB0:
call _ZN6VectorIdEC1Ei
.LEHE0:
movl $0, %ebx
leaq -48(%rbp), %rax
movq %rax, %rdi
call _ZN6VectorIdED1Ev
leaq -80(%rbp), %rax
movq %rax, %rdi
call _ZN6VectorIiED1Ev
movl %ebx, %eax
movq -24(%rbp), %rdx
subq %fs:40, %rdx
je .L4
jmp .L6
很明显,这里就出现了我们熟悉的符号:_ZN6VectorIdEC1Ei、_ZN6VectorIiED1Ev,说明模板代码生成是在预处理后、编译过程中、链接前完成的,你一定要很清楚模板代码的生成时间,因为这在第七小节中会帮助你更好地理解一些其他的东西。
(5). 模板的特化
泛型的泛,也有更宽和更窄的说法,例如:
template<typename T>
class A
{
...
};
template<typename T>
class A<T*>
{
...
};
template<>
class A<void*>
{
...
};
显然从上到下的三个类模板,第一个是最宽泛的,可以接收一切参数,第二个是在有T的前提下,对于一切指针类型进行操作,第三种则是最有限的,它只对void*生效,而后面两个类的声明,就是我们说的模板的特化。
在已经声明了没有特化的类模板后,我们才可以声明特化的类模板,声明的方式即同名的类后增加显式模板声明,例如第二种当中的class A<T*>,就是对于指针类型的一种A的特化。
那么如果同时有这三种声明,C++会如何选择呢?答案是:C++会选择符合当前参数表的,更加特例化的模板,例如:
A<int> a; // class A<T>
A<int*> b; // class A<T*>
A<void*> c; // class A<void*>
第一种和第三种都好说,第二种在寻找模板时,会同时遇到class A<T>和class A<T*>两种情况,因为class A<T*>对于int*更加特化,所以会选择class A<T*>作为模板进行代码的生成。
同理,函数的特化也是一样的,例如:
template<typename T>
bool less(T a, T b)
{
return a < b;
}
template<>
bool less(const char* a, const char* b)
{
return strcmp(a, b) < 0;
}
这样就好了,所以模板的特化也是挺好理解的对吧?
(6). concept关键字(C++20)
#1.模板的参数好像有点太自由了
下面一个问题是,模板的T太自由了,是什么都可以,但是理论上讲,有些情况下我们不能让它什么类型都是,比如这个例子:
template<typename T>
class Complex
{
private:
T _re;
T _im;
public:
...
};
复数类的实部和虚部可以是各种数字类型,如double、int等等都可以,但很明显不能是字符串,但是现在我们的复数类没有说T就不能是字符串了,如果真的传进去,那可就出大麻烦了,怎么办呢?我们在这里介绍一个C++20中引入的concept关键字来解决这个问题。
#2.concept关键字的使用
concept顾名思义概念,实际上我们是通过一些语句声明一个概念,然后在模板代码生成的时候根据这个概念来判断模板参数是否符合要求,我们有两种方式利用concept:①、将模板参数的T直接限定为concept,②、使用requires字句,我们分别来看看这两种用法:
template<typename T>
concept VALID = (
std::is_same_v<T, int> ||
std::is_same_v<T, long> ||
std::is_same_v<T, long long> ||
std::is_same_v<T, float> ||
std::is_same_v<T, double> ||
);
template<VALID T>
class Complex
{
private:
T _re;
T _im;
public:
...
};
这里我们使用了std::is_same_v<T, U>,它可以判断T和U的类型是否相同,如果相同则是true,不同则是false,如果你用的是C++17之前的标准,那我们需要用std::is_same_v<T, U>::value来判断,不过concept都是C++20的内容了,C++17的标准肯定已经支持了。
那么这里我们创建了一个概念VALID,在这里传入的T只有与上述五个类型相同的情况下才会为true,只有concept保证为true了,我们才能成功通过编译,否则编译器会直接报错,这很好啊!我们能够对模板参数进行一些限制了,同理,我们也可以在concept里面写一些其他的东西,比如:
template<size_t s>
concept VALID = (
s <= 100
);
这种情况下,我们需要使用requires字句完成操作:
template<typename T, size_t s>
requires VALID<s>
class Array
{
...
};
首先确定了s是一个size_t类型的模板参数,之后这个模板参数s需要满足VALID<size_t s>的要求,即s的值要小于等于100,之后再进行正常的类声明即可。
requires字句同样可以用于声明概念,例如:
template<typename T>
concept Comparable = requires(T a, T b) {
{ a == b } -> bool;
{ a != b } -> bool;
};
这段代码中要求T类型定义了==和!=两个比较运算符,否则不能通过编译,所以这么一来,你大概就明白concept关键字和requires子句的使用了,利用好这两个特性能够明显增强你对于模板类型的控制。
(7). 包含模板的代码的组织方式
之前写一般的类的时候,我们会习惯性地把成员函数的声明和定义分在两个文件中写,因为这样的话,.h文件中会比较简洁,毕竟类是概念的抽象,具体实现不应该那么详细地表现出来。
所以我们也习惯性地把类模板的分离和定义分开,结果发现,编译的时候没出错,链接的时候出错了,理论上讲,如果有语法错误,应该就是在编译的时候出错才对,怎么会这样呢?
这就要回到我们之前说的模板的工作原理上来了,首先当我们分离类的定义和声明的时候,我们的编译方式是:首先把所有.cpp文件编译成.o文件,最后通过链接器得到可执行文件,编译成.o文件会形成对应不同函数的符号表,在链接器中,根据最终调用的情况将需要用到的函数的符号链接起来完成工作,这就完成了编译的过程。
如果我们对类模板/函数模板也这么做,那么首先经过编译预处理,我们把代码的声明拷贝到对应对应的文件中,链接之前,编译器只检查代码的语法是否有问题,如果我们的代码本身没有问题,在这一步是不会出错的,要执行的源文件中的函数模板会等着之后链接器来链接,而类模板/函数模板的定义的源文件中会等着之后传入参数再进行实例化,结果就是:编译期模板没有实例化出对应的代码,源文件中在等待链接,这么一来,链接器根本找不到源文件中需要的函数符号,所以链接就出问题了。
所以我们的类/函数模板不能分为.h和.cpp文件进行编写,这一点一定要注意,因为这个问题是链接出错而不是编译出错,想要发现问题可能比编译出错要困难得多。
(8). 为什么我的友元函数跑不起来了?
好了,你已经掌握了模板编程的基本知识了,你决定自己写一个Vector,STL的Vector不能通过std::cout输出,你不服气,我的Vector要比它的更好用,于是你写了下面的代码:
template<typename T>
class Vector
{
...
public:
...
friend std::ostream& operator<<(std::ostream& out, const Vector<T>& vec);
};
template<typename T>
std::ostream& operator<<(std::ostream& out, const Vector<T>& vec)
{
for (size_t i = 0; i < vec._size; i++) {
out << "," + !i << vec[i];
}
return out;
}
“,” + !i的写法我在C语言教程中介绍过,因为",“是一个指针,仅在i不等于0的时候,”," + !i指向的才是’,',其他情况下都是到后面一位零字符,所以可以实现需求。
看起来很不错,然后一编译发现:根本过不了,为什么会这样,我这写的看起来也没错啊?其实呢,有个很大的问题我们要注意:友元函数不属于任何一个类,也就是说,在这里我们声明的这个友元函数,很明显用到了模板,但是它没有模板声明,因此这个const Vector<T>&中的T,编译器并不知道到底是个什么玩意儿,说到这里你肯定已经想到了解决方法了:
template<typename U>
friend std::ostream& operator<<(std::ostream& out, const Vector<U>& vec);
我们只要重新声明一次模板就好了,当然,你也可以用下面这种写法:
friend std::ostream& operator<< <>(std::ostream& out, const Vector<T>& vec);
在函数名后加上<>表示实例化,这样就可以使用类模板声明时用到的T了,不过如果你选择在声明友元的同时直接做定义,这个函数就不会在链接的时候报错了,因为这个时候友元函数的符号已经出现了,它在之后不会出现链接的问题了。
(9). 我真的不能分文件写模板吗?
还挺叛逆,没错,我也叛逆,我也想过我们真的不能分文件写模板吗?我们再来好好思考一下模板编译的过程:首先进行预处理,完成包含头文件的粘贴工作,之后再进入编译过程,这个步骤会依照使用到的模板进行展开,之后再完成链接工作。
等等,展开模板前有一个步骤是编译预处理,诶?我是不是可以在编译预处理的时候做点手脚??我们来试试看:
// Filename : T.h
#pragma once
template<typename T>
class A
{
private:
T* a;
size_t _size;
public:
A() : a(nullptr) {}
A(size_t size) : a(new T[size]), _size(size) {}
~A();
bool insert(const T& c);
};
#include "t_def.h"
// Filename : T_def.h
#pragma once
#ifndef T_DEF_HAS_INCLUDED
#define T_DEF_HAS_INCLUDED
template<typename T>
A<T>::~A()
{
delete[] a;
a = nullptr;
}
template<typename T>
bool A<T>::insert(const T& c)
{
a[0] = c;
std::cout << "Insert !" << std::endl;
return true;
}
#endif
// Filename : main.cpp
#include <iostream>
#include "t.h"
using namespace std;
int main()
{
A<int> a(100);
auto b{a.insert(1230)};
return 0;
}
我们把模板的定义写在一个头文件里,然后在声明模板的头文件末尾包含这个定义的头文件,再在主代码源文件中正常使用,来看看结果:
赢!我们成功了!这种写法其实是非常少见的,我并不是很推荐你这么写,因为可能会带来一些潜在的风险,不过有一点可以肯定,就是你一定要比较熟悉编译的流程,你才能理解这样的写法到底为什么可以实现。
(10). 模板元编程
这个部分就简单提一提,C++的模板是图灵完备的,这样一来,我们就可以用模板完成很多以前在运行期才能解决的问题,一个比较简单的例子是求阶乘:
#include <iostream>
template<unsigned int n>
struct factorial
{
enum { value = n * factorial<n - 1>::value };
};
template<>
struct factorial<0>
{
enum { value = 1 };
};
int main()
{
std::cout << factorial<10>::value << std::endl;
return 0;
}
其实还是相对比较好理解的,我们依旧是以递归的思路完成这份代码,运行的时候首先展开factorial<10>,之后依旧展开9,8,7,…,最终到1,有了确定值1,从而得到了10!的结果。
这就是模板元编程,这段代码的结果值完全是在编译期计算出来的,节选一段汇编代码:
.LFB2056:
.cfi_startproc
endbr64
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $3628800, %esi
leaq _ZSt4cout(%rip), %rax
你看10!的值直接被写在这里了,也就是说这段代码直接把10!算出来了,之后直接进行了打印,模板元编程会显著增加编译时间,它将运行期的计算工作放到了编译期执行。
当然,我就仅仅是简单介绍一下这个部分,你可以去网上了解到更多有关于模板元编程的资料。
小结
模板这个部分是相当灵活的,因为我们成功地实现了对于类型的抽象,不过也是因为模板的自由性,我们需要相当注意模板当中可能出现的问题,因为这些问题大部分不是在编译期出错,而是在链接期出错,链接器报的错误可要比编译器的难懂多了。
下一节我们要再来讲讲C++中的IO,这次的IO篇包含了标准输入输出、文件输入输出和对于字符串的输入输出操作,敬请期待。