C++模板类:泛型编程与容器实现

在前一章节中,我们介绍了函数模板,它能让函数泛化以适应多种数据类型。虽然这是迈向泛型编程的良好开端,但并未解决所有问题。让我们通过一个示例审视此类问题,并进一步了解模板还能为我们做些什么。

模板与容器类

你已学会如何利用组合实现包含多个其它类实例的容器类。作为此类容器的示例,我们曾考察 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 时,将实例化它们,并作为 main.cpp 翻译单元的一部分编译。由于 operator[] 成员函数已有声明,编译器接受对其调用,并假定其定义位于别处。

当 Array.cpp 独立编译时,Array.h 头文件内容被复制到 Array.cpp,但编译器在该文件中找不到任何要求实例化 Array 类模板或 Array::operator[] 函数模板的代码——故不会实例化任何内容。

于是,程序链接时将报错,因 main.cpp 调用了 Array::operator[],而该模板函数从未实例化!

解决此问题的方法众多。

最简单的方法是将所有模板类代码直接置于头文件(即将 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 与 Array 的定义。其它欲使用这些类型的源码文件只需包含 Array.h(以满足编译器),链接器将从 templates.cpp 链接这些显式类型定义。

该方法可能更高效(取决于编译器与链接器如何处理模板与重复定义),但需为每个程序维护 templates.cpp 文件。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"