在前一章中,我们介绍了函数模板,它允许我们将函数泛化,使其能够处理多种不同的数据类型。虽然这是迈向通用编程的良好开端,但它并不能解决我们所有的问题。让我们来看一个例子,看看模板还能为我们做些什么。
模板和容器类
在 “容器类”中,你学习了如何使用组合来实现包含其他类的多个实例的类。作为此类容器的一个例子,我们研究了 IntArray 类。以下是该类的一个简化示例:
IntArray.h
#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;
}
// We don't want to allow copies of IntArray to be created.
IntArray(const IntArray&) = delete;
IntArray& operator=(const IntArray&) = delete;
~IntArray()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// We need to make sure we set m_data to 0 here, otherwise it will
// be left pointing at deallocated memory!
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
main.cpp
#include "IntArray.h"
#include <iostream>
int main()
{
IntArray intArray{ length: 3 };
std::cout << intArray.getLength() << '\n';
intArray.erase();
std::cout << intArray.getLength() << '\n';
return 0;
}

虽然这个类提供了一种创建整数数组的简便方法,但如果我们想创建一个双精度浮点数数组呢?使用传统的编程方法,我们需要创建一个全新的类!这里有一个 DoubleArray 的例子,它就是一个用于存储双精度浮点数的数组类。
DoubleArray.h
#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;
// We need to make sure we set m_data to 0 here, otherwise it will
// be left pointing at deallocated memory!
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{};
public:
Array(int length)
{
assert(length > 0);
m_data = new T[length]{};
m_length = length;
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
~Array()
{
delete[] m_data;
}
T& operator[](int index);
void erase()
{
delete[] m_data;
m_data = nullptr;
m_length = 0;
}
int getLength() const { return m_length; }
};
template<typename T>
T& Array<T>::operator[](int index)
{
assert(index >= 0 && index < m_length);
return m_data[index];
}
#endif
如您所见,此版本与 IntArray 版本几乎完全相同,只是我们添加了模板声明,并将包含的数据类型从 int 更改为 T。
请注意,我们还在operator[]类声明之外定义了该函数。虽然这不是必需的,但新手程序员通常会因为语法问题而在第一次尝试时遇到困难,因此举例说明很有帮助。每个在类声明之外定义的模板成员函数都需要自己的模板声明。另外,请注意模板数组类的名称是 Array<T>,而不是 Array——除非在类内部使用 Array,否则 Array 指的是名为 Array 的类的非模板版本。例如,复制构造函数和复制赋值运算符使用的是 Array 而不是 Array<T>。当在类内部不使用模板参数使用类名时,参数与当前实例化的参数相同。
以下是一个使用上述模板化数组类的简短示例:
main.cpp
#include "Array.h"
#include <iostream>
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;
}
此示例会输出以下内容

模板类的实例化方式与模板函数相同——编译器会根据需要生成一个副本,并将模板参数替换为用户所需的实际数据类型,然后编译该副本。如果您从未使用过模板类,编译器甚至不会编译它。
模板类非常适合实现容器类,因为容器需要能够处理各种数据类型,而模板类可以让你无需编写重复代码就能做到这一点。尽管模板类的语法略显繁琐,错误信息也可能晦涩难懂,但它无疑是 C++ 最优秀、最实用的特性之一。
拆分模板类
模板不是类或函数,而是用于创建类或函数的模板。因此,它的工作方式与普通的函数或类并不完全相同。大多数情况下,这不会造成什么问题。然而,有一个方面常常会给开发人员带来麻烦。
对于非模板类,通常的做法是将类定义放在头文件中,将成员函数定义放在同名的代码文件中。这样,成员函数定义就会被编译成一个单独的项目文件。但是,对于模板类,这种方法行不通。请看以下示例:
Array.h:
#ifndef ARRAY_H
#define ARRAY_H
#include <cassert>
template <typename T> // added
class Array
{
private:
int m_length{};
T* m_data{}; // changed type to T
public:
Array(int length)
{
assert(length > 0);
m_data = new T[length]{}; // allocated an array of objects of type T
m_length = length;
}
Array(const Array&) = delete;
Array& operator=(const Array&) = delete;
~Array()
{
delete[] m_data;
}
void erase()
{
delete[] m_data;
// We need to make sure we set m_data to 0 here, otherwise it will
// be left pointing at deallocated memory!
m_data = nullptr;
m_length = 0;
}
// templated operator[] function defined below
T& operator[](int index); // now returns a T&
int getLength() const { return m_length; }
};
// Definition of Array<T>::operator[] moved into Array.cpp below
#endif
Array.cpp:
#include "Array.h"
// member functions defined outside the class need their own template declaration
template <typename T>
T& Array<T>::operator[](int index) // now returns a 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;
}
上述程序可以编译,但会导致链接器错误:

与函数模板类似,编译器只有在类模板在翻译单元中被使用(例如作为 intArray 等对象的类型)时才会实例化类模板。为了执行实例化,编译器必须同时看到完整的类模板定义(而不仅仅是声明)以及所需的特定模板类型。
还要记住,C++ 是逐个编译文件的。编译 main.cpp 时,Array.h 头文件的内容(包括模板类定义)会被复制到 main.cpp 中。当编译器发现我们需要两个模板实例 Array 和 Array 时,它会实例化这些实例,并将它们作为 main.cpp 翻译单元的一部分进行编译。由于operator[]成员函数有声明,编译器会接受对其的调用,并假定它在其他地方已经定义。
当单独编译 Array.cpp 时,Array.h 头文件的内容会被复制到 Array.cpp 中,但编译器在 Array.cpp 中找不到任何需要Array::operator[]实例化 Array 类模板或函数模板的代码——因此它不会实例化任何东西。
因此,当程序链接时,我们会得到一个链接器错误,因为 main.cpp 调用了某个Array::operator[]模板函数,但该模板函数从未被实例化!
有很多方法可以解决这个问题。
最简单的方法是将所有模板类代码直接放在头文件中(在本例中,将 Array.cpp 的内容放在 Array.h 中,位于类定义下方)。这样,当你使用 #include 头文件时,所有模板代码都会集中在一个地方。这种方法的优点是简单易行。缺点是,如果模板类在多个文件中使用,最终会生成许多模板类的本地实例,这可能会增加编译和链接时间(链接器应该会移除重复的定义,因此不会使可执行文件体积过大)。除非编译或链接时间过长成为问题,否则我们推荐使用这种方法。
如果您觉得将 Array.cpp 代码放入 Array.h 头文件中会导致头文件过长或过于混乱,另一种方法是将 Array.cpp 的内容移动到一个名为 Array.inl 的新文件中(.inl 代表内联),然后在 Array.h 头文件的末尾(头文件保护符内)包含 Array.inl。这样做与将所有代码都放在头文件中效果相同,但有助于保持代码的整洁有序。
提示
如果您使用 .inl 方法后遇到编译器关于重复定义的错误,很可能是因为您的编译器将 .inl 文件当作代码文件进行编译。这会导致 .inl 文件的内容被编译两次:一次是编译器编译 .inl 文件本身,另一次是编译包含 .inl 文件的 .cpp 文件。如果 .inl 文件包含任何非内联函数(或变量),则会违反“只能定义一个变量”的规则。如果发生这种情况,您需要将 .inl 文件从编译过程中排除。
通常可以通过在项目视图中右键单击 .inl 文件,然后选择“属性”来将 .inl 文件从生成过程中排除。相关设置应该就在那里。在 Visual Studio 中,将“从生成中排除”设置为“是”。在 Code::Blocks 中,取消选中“编译文件”和“链接文件”。
其他解决方案涉及 #include .cpp 文件,但我们不建议这样做,因为 #include 的用法不符合标准。
另一种方法是使用三文件架构。模板类定义放在头文件中。模板类成员函数放在代码文件中。然后添加第三个文件,其中包含所有需要实例化的类:
templates.cpp:
// Ensure the full Array template definition can be seen
#include "Array.h"
#include "Array.cpp" // we're breaking best practices here, but only in this one place
// #include other .h and .cpp template definitions you need here
template class Array<int>; // Explicitly instantiate template Array<int>
template class Array<double>; // Explicitly instantiate template Array<double>
// instantiate other templates here
“template class”命令会使编译器显式实例化模板类。在上述示例中,编译器会在 templates.cpp 文件中生成 Array 和 Array 的定义。其他需要使用这些类型的代码文件可以包含 Array.h 头文件(以满足编译器的要求),链接器会将 template.cpp 中的这些显式类型定义链接进去。
这种方法可能更高效(取决于编译器和链接器如何处理模板和重复定义),但需要为每个程序维护 templates.cpp 文件。



被折叠的 条评论
为什么被折叠?



