模板类
本节阅读量:
此前,我们介绍了函数模板,它允许我们泛化函数,以处理许多不同的数据类型。虽然这是泛型编程的良好开端,但它并不能解决所有问题。让我们来看一个例子,看看模板还能为我们做些什么。
模板和容器类
容器类使用组合来包含其他类的多个实例。IntArray类就是这样一个例子。下面是该类的简化版本:
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
|
#ifndef INTARRAY_H
#define INTARRAY_H
#include <cassert>
class IntArray
{
private:
int m_length{};
int* m_data{};
public:
IntArray(int length)
{
assert(length > 0);
m_data = new int[length]{};
m_length = length;
}
// 不允许 IntArray 被拷贝
IntArray(const IntArray&) = delete;
IntArray& operator=(const IntArray&) = delete;
~IntArray()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// 确保 m_data 为nullptr
// 否则 它将指向被释放的空间
m_data = nullptr;
m_length = 0;
}
int& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
int getLength() const { return m_length; }
};
#endif
|
虽然这个类提供了一种创建整数数组的简单方法,但如果我们想创建一个double数组,该怎么办?使用传统的编程方法,我们必须创建一个全新的类!下面是DoubleArray的例子,它是一个用于保存双精度数的数组类:
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
|
#ifndef DOUBLEARRAY_H
#define DOUBLEARRAY_H
#include <cassert>
class DoubleArray
{
private:
int m_length{};
double* m_data{};
public:
DoubleArray(int length)
{
assert(length > 0);
m_data = new double[length]{};
m_length = length;
}
DoubleArray(const DoubleArray&) = delete;
DoubleArray& operator=(const DoubleArray&) = delete;
~DoubleArray()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// 确保 m_data 为nullptr
// 否则 它将指向被释放的空间
m_data = nullptr;
m_length = 0;
}
double& operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
int getLength() const { return m_length; }
};
#endif
|
尽管代码清单很长,但您会注意到这两个类几乎完全相同!事实上,唯一的实质性区别是它们保存的数据类型不同(int与double)。正如您可能已经猜到的,这也是模板非常适合发挥作用的场景,可以让我们不必创建绑定到特定数据类型的类。
创建模板类的方式与创建模板函数的方式几乎相同,所以我们继续通过示例说明。
下面是数组类的模板化版本:
Array.h:
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
|
#ifndef ARRAY_H
#define ARRAY_H
#include <cassert>
template <typename T> // 新增
class Array
{
private:
int m_length{};
T* m_data{}; // 修改类型为 T
public:
Array(int length)
{
assert(length > 0);
m_data = new T[length]{}; // 分配存放类型为 T 的对象的数组
m_length = length;
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
~Array()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// 确保 m_data 为nullptr
// 否则 它将指向被释放的空间
m_data = nullptr;
m_length = 0;
}
// operator[] 函数,在下面定义
T& operator[](int index); // 现在返回 T&
int getLength() const { return m_length; }
};
// 在类外定义的成员函数,因此需要带上模板声明
template <typename T>
T& Array<T>::operator[](int index) // 现在返回 T&
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
#endif
|
如您所见,这个版本几乎与IntArray版本相同,只是添加了模板声明,并将保存的数据类型从int改为T。
请注意,我们还在类声明外部定义了operator[]函数。这不是必需的,但由于语法细节较多,新程序员第一次尝试时通常容易出错,因此这里给出一个示例会很有帮助。在类声明外部定义的每个模板成员函数都需要自己的模板声明。此外,请注意模板数组类的名称是Array<T>,而不是Array。在类内部使用Array,在类外部使用Array<T>。例如,拷贝构造函数和赋值运算符使用Array,而不是Array<T>。
下面是一个使用上述模板数组类的简短示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
#include <iostream>
#include "Array.h"
int main()
{
const int length { 12 };
Array<int> intArray { length };
Array<double> doubleArray { length };
for (int count{ 0 }; count < length; ++count)
{
intArray[count] = count;
doubleArray[count] = count + 0.5;
}
for (int count{ length - 1 }; count >= 0; --count)
std::cout << intArray[count] << '\t' << doubleArray[count] << '\n';
return 0;
}
|
此示例会打印以下内容:
1
2
3
4
5
6
7
8
9
10
11
12
|
11 11.5
10 10.5
9 9.5
8 8.5
7 7.5
6 6.5
5 5.5
4 4.5
3 3.5
2 2.5
1 1.5
0 0.5
|
模板类的实例化方式与模板函数相同——编译器会根据需要生成副本,用用户需要的实际数据类型替换模板参数,然后编译该副本。如果模板类没有被使用,编译器甚至不会编译它。
模板类非常适合实现容器类,因为容器通常需要适用于各种数据类型,而模板允许您在不复制代码的情况下做到这一点。尽管语法不太美观,错误消息也可能晦涩难懂,但模板类确实是C++中最好用、最有价值的功能之一。
拆分模板类
模板不是类或函数——它是用于创建类或函数的模具。因此,它的工作方式与普通函数或类并不完全相同。在大多数情况下,这不是什么大问题。不过,有一个地方经常会给开发人员带来困扰。
对于非模板类,常见做法是将类定义放在头文件中,将成员函数定义放在同名的代码文件中。这样,成员函数定义会作为单独的项目文件被编译。然而,对模板来说,这种做法行不通。
请考虑以下内容:
Array.h:
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
|
#ifndef ARRAY_H
#define ARRAY_H
#include <cassert>
template <typename T> // 新增
class Array
{
private:
int m_length{};
T* m_data{}; // 修改类型为 T
public:
Array(int length)
{
assert(length > 0);
m_data = new T[length]{}; // 分配存放类型为 T 的对象的数组
m_length = length;
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
~Array()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// 确保 m_data 为nullptr
// 否则 它将指向被释放的空间
m_data = nullptr;
m_length = 0;
}
// operator[] 函数,在对应的cpp文件定义
T& operator[](int index); // 现在返回 T&
int getLength() const { return m_length; }
};
// Array<T>::operator[] 定义移动到 Array.cpp
#endif
|
Array.cpp:
1
2
3
4
5
6
7
8
9
|
#include "Array.h"
// 在类外定义的成员函数,因此需要带上模板声明
template <typename T>
T& Array<T>::operator[](int index) // 现在返回 T&
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
|
main.cpp:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
#include <iostream>
#include "Array.h"
int main()
{
const int length { 12 };
Array<int> intArray { length };
Array<double> doubleArray { length };
for (int count{ 0 }; count < length; ++count)
{
intArray[count] = count;
doubleArray[count] = count + 0.5;
}
for (int count{ length - 1 }; count >= 0; --count)
std::cout << intArray[count] << '\t' << doubleArray[count] << '\n';
return 0;
}
|
上述程序会导致链接错误:
1
|
undefined reference to `Array<int>::operator[](int)'
|
就像函数模板一样,如果在翻译单元中使用类模板(例如,将它作为intArray等对象的类型),编译器才会实例化类模板。为了完成实例化,编译器必须同时看到完整的类模板定义(不仅仅是声明)和所需的具体模板类型。
还要记住,C++会单独编译文件。编译main.cpp时,Array.h头文件的内容(包括模板类定义)会被复制到main.cpp中。当编译器发现我们需要两个模板实例Array<int>和Array<double>时,会实例化这些实例,并将它们编译为main.cpp翻译单元的一部分。由于 operator[] 成员函数已有声明,编译器会接受对它的调用,并假定它会在其他地方定义。
单独编译Array.cpp时,Array.h头文件的内容会被复制到Array.cpp中,但编译器在Array.cpp中找不到任何需要实例化Array类模板或Array<int>::operator[]函数模板的代码——因此它不会实例化任何内容。
因此,当程序链接时,我们会得到一个链接器错误,因为main.cpp调用了Array<int>::operator[],但该模板函数从未被实例化!
有许多方法可以解决这个问题。
最简单的方法是将所有模板类代码都放在头文件中(在本例中,就是将Array.cpp的内容放到Array.h中类定义的下方)。这样,当您引用头文件时,所有模板代码都在同一个位置。这种解决方案的优点是简单。缺点是,如果模板类在许多文件中使用,您会得到模板类的许多本地实例,这可能增加编译和链接时间(链接器通常会删除重复定义,因此不会让可执行文件膨胀)。除非编译或链接时间开始成为问题,否则这是我们的首选方案。
如果您认为将Array.cpp代码放入Array.h会让头文件过长或过于混乱,另一种方法是将Array.cpp的内容移动到名为Array.inl(.inl表示内联)的新文件中,然后在Array.h头文件的底部(头文件保护内)包含Array.inl。这与把所有代码放在头文件中效果相同,但有助于让组织结构更清晰。
其他解决方案会涉及include .cpp文件,但我们不建议这样做,因为include cpp的用法并不规范。
另一种替代方法是使用三文件方法。模板类定义在头文件中。模板类成员函数放在代码文件中。然后添加第三个文件,其中包含您需要的所有实例化类:
templates.cpp:
1
2
3
4
5
6
7
8
9
10
|
// 确保可以看到完整的Array模板定义
#include "Array.h"
#include "Array.cpp" // 我们在这里打破了最佳实践,但仅在这一处
// 此处include所需的其他.h和.cpp模板定义
template class Array<int>; // 显式实例化模板 Array<int>
template class Array<double>; // 显式实例化模板 Array<double>
// 在此处实例化其他模板
|
“template class”会让编译器显式实例化模板类。在上述情况下,编译器会在templates.cpp内实例化模板Array<int>和Array<double>的定义。想要使用这些类型的其他代码文件可以包含Array.h(以满足编译器要求),链接器则会从templates.cpp链接这些显式类型定义。
这种方法可能更高效(取决于编译器和链接器处理模板及重复定义的方式),但需要为每个程序维护templates.cpp文件。