在前一章节中,我们介绍了函数模板,它能让函数泛化以适应多种数据类型。虽然这是迈向泛型编程的良好开端,但并未解决所有问题。让我们通过一个示例审视此类问题,并进一步了解模板还能为我们做些什么。
模板与容器类
你已学会如何利用组合实现包含多个其它类实例的容器类。作为此类容器的示例,我们曾考察 IntArray 类。以下为其简化版本:
#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 置为 0,否则将悬空指向已释放的内存!
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
尽管该类能方便地创建整型数组,但若需要创建双精度浮点型数组呢?在传统编程方式下,我们不得不重新编写一个全新类。以下即为 DoubleArray——用于存储 doubles 的数组类示例:
#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 置为 0,否则将悬空指向已释放的内存!
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:
#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 置为 0,否则将悬空指向已释放的内存!
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
如你所见,该版本除添加模板声明并将数据类型由 int 改为 T 外,与 IntArray 版本几乎一致。
注意,我们将 operator[] 函数定义于类声明之外。这并非必须,但初学者首次尝试时常因语法受阻,故给出示例以示说明。凡在类外定义的模板成员函数,均需自身模板声明。此外,模板数组类的名称为 Array<T>
,而非 Array
—— 除非在类内部使用,否则 Array
指代非模板类。例如,复制构造函数与复制赋值运算符在类内使用时写作 Array
,而非 Array<T>
。当在类内部使用不带模板参数的类名时,其模板实参与当前实例化保持一致。
以下是使用上述模板数组类的简短示例:
#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;
}
该程序输出如下:
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:
#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 置为 0,否则将悬空指向已释放的内存!
m_data = nullptr;
m_length = 0;
}
// 模板化的 operator[] 函数,定义移至 Array.cpp
T& operator[](int index); // 现返回 T&
int getLength() const { return m_length; }
};
// Array<T>::operator[] 定义移至 Array.cpp,见下
#endif
Array.cpp:
#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:
#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;
}
上述程序可编译,但会引发链接错误:
undefined reference to `Array<int>::operator[](int)'
与函数模板类似,仅当类模板被使用(例如在翻译单元中用作对象类型如 intArray)时,编译器才会实例化类模板。为了执行实例化,编译器必须同时看到完整的类模板定义(而非仅声明)以及所需的具体模板类型。
同时请牢记,C++ 按文件分别编译。编译 main.cpp 时,Array.h 头文件内容(含模板类定义)被复制到 main.cpp。当编译器发现我们需要两个模板实例 Array
当 Array.cpp 独立编译时,Array.h 头文件内容被复制到 Array.cpp,但编译器在该文件中找不到任何要求实例化 Array 类模板或 Array
于是,程序链接时将报错,因 main.cpp 调用了 Array
解决此问题的方法众多。
最简单的方法是将所有模板类代码直接置于头文件(即将 Array.cpp 内容移至 Array.h 中类定义之后)。如此,当 #include
头文件时,所有模板代码集中于一处。此方案优点在于简单;缺点在于若模板类在多处使用,将产生多个模板实例,可能增加编译与链接时间(链接器应剔除重复定义,故不会膨胀可执行文件)。除非编译或链接时间成问题,否则我们推荐此方案。
若将 Array.cpp 代码并入 Array.h 使头文件过长或凌乱,可改将 Array.cpp 内容移至新文件 Array.inl(.inl 意为 inline),然后在 Array.h 头文件头文件保护内底部包含 Array.inl。效果与全部置于头文件相同,但更有条理。
提示
若采用 .inl 法后编译器报重复定义错误,极可能是编译器将 .inl 文件当作项目源码文件编译,导致 .inl 内容被编译两次:一次为编译器直接编译 .inl,一次为包含 .inl 的 .cpp 文件被编译。若 .inl 文件含任何非 inline 函数(或变量),将违反一次定义原则。此时,需将 .inl 文件排除出构建。
排除 .inl 文件通常可在项目视图中右键点击该文件,选择属性,然后设置相应选项。Visual Studio 中,将 “Exclude From Build” 设为 “Yes”。Code::Blocks 中,取消勾选 “Compile file” 与 “Link file”。
其它方案包括 #include
.cpp 文件,但我们不推荐,因不符合 #include 的标准用法。
另一替代方案是采用三文件法:模板类定义置于头文件;模板类成员函数置于源码文件;再添加第三文件,显式实例化所需全部类:
templates.cpp:
// 确保能看到完整 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
该方法可能更高效(取决于编译器与链接器如何处理模板与重复定义),但需为每个程序维护 templates.cpp 文件。