【C++篇】引领C++模板初体验:泛型编程的力量与妙用

2024-10-09 19:59:40 浏览数 (5)

C 模板编程

前言

C 作为一门强大的编程语言,以其丰富的功能和灵活的设计著称。模板编程是C 中非常重要的一个特性,通过模板可以实现泛型编程,编写与数据类型无关的代码,极大地提高了代码的复用性和可维护性。本文将从泛型编程、函数模板、类模板等几个方面详细讲解C 模板的使用,并结合实际的代码示例进行分析,帮助大家全面掌握模板编程的知识。

本篇文章将包含以下几个部分:

  1. 泛型编程的基本概念
  2. 函数模板的定义与使用
  3. 类模板的实现
  4. 模板的匹配原则

通过阅读本文,你将能够掌握C 模板编程的基础知识,理解其背后的工作原理,并学会如何在实际项目中应用这些技术。


第一章: 初始模板与函数模版
1.1 什么是泛型编程?

泛型编程(Generic Programming)是C 中的一种编程范式,旨在编写与数据类型无关的通用代码。这意味着你可以编写一次代码,并通过不同的数据类型进行复用。C 通过模板(Template)来实现泛型编程,模板是泛型编程的核心工具。

1.1.1 为什么要有泛型编程?
  • 问题提出:如何实现一个通用的交换函数呢?
代码语言:javascript复制
void Swap(int& left, int& right)
{
    int temp = left;
    left = right;
    right = temp;
}
void Swap(double& left, double& right)
{
    double temp = left;
    left = right;
    right = temp;
}
void Swap(char& left, char& right)
{
    char temp = left;
    left = right;
    right = temp;
}
//....等等

使用函数重载虽然可以实现,但是有一下几个不好的地方:

  1. 重载的函数仅仅是类型不同,代码复用率比较低,只要有新类型出现时,就需要用户自己增加对应的函数
  2. 代码的可维护性比较低,一个出错可能所有的重载均出错

那能否告诉编译器一个模子,让编译器根据不同的类型利用该模子来生成代码呢?

如果在C 中,也能够存在这样一个模具,通过给这个模具中填充不同材料(类型),来获得不同 材料的铸件(即生成具体类型的代码),那将会节省许多头发。巧的是前人早已将树栽好,我们只需在此乘凉。这就是泛型编程

泛型编程:编写与类型无关的通用代码,是代码复用的一种手段。模板是泛型编程的基础。

1.1.1 泛型编程的优势

泛型编程的主要优势包括:

  • 代码复用性强:通过模板,你可以避免为每个数据类型单独编写相同功能的代码。
  • 提高代码的可维护性:代码只需编写一次,减少了冗余代码,后续如果需要修改或修复,只需在一处进行。
  • 减少编写错误:重复编写代码时容易出错,而模板可以让编译器自动生成所需代码,减少人为失误。
1.2 函数模板的基础
1.2.1 什么是函数模板?

函数模板(Function Template)是一个与类型无关的函数“蓝图”。通过模板参数,编译器在编译期间会根据实际的数据类型生成相应的函数版本。

1.2.2 函数模板的定义格式

我们可以通过以下格式来定义一个函数模板:

代码语言:javascript复制
template<typename T> 
返回类型 函数名(参数列表) {
    // 函数体
}
  • template:告诉编译器接下来的内容是模板。
  • typename T:定义一个模板参数T,可以用来表示任何类型。
  • 返回类型参数列表可以使用T作为数据类型。

注意:typename是用来定义模板参数关键字,也可以使用class(切记:不能使用struct代替 class),下文会讲到

1.2.3 示例:通用的交换函数

那我们就可以使用模板来编写交换函数,它可以交换任意类型的数据:

代码语言:javascript复制
/**
 * @brief 通用的交换函数
 * @tparam T 通用的类型参数,由编译器根据实参推断
 * @param left 左侧变量
 * @param right 右侧变量
 */
template<typename T>
void Swap(T& left, T& right) {
    T temp = left;
    left = right;
    right = temp;
}

int main() {
    int a = 10, b = 20;
    double x = 1.1, y = 2.2;
    char c1 = 'A', c2 = 'B';

    // 使用模板函数进行交换
    Swap(a, b);    
    Swap(x, y);    
    Swap(c1, c2);  

    std::cout << "交换后的整数: " << a << " " << b << std::endl;
    std::cout << "交换后的浮点数: " << x << " " << y << std::endl;
    std::cout << "交换后的字符: " << c1 << " " << c2 << std::endl;

    return 0;
}
输出示例:
代码语言:javascript复制
交换后的整数: 20 10
交换后的浮点数: 2.2 1.1
交换后的字符: B A
1.2.4 模板中的typenameclass

在定义模板时,typenameclass是可以互换的。你可以选择以下两种方式:

代码语言:javascript复制
template<typename T>  // 使用 typename
template<class T>     // 使用 class

虽然两者功能相同,但推荐使用typename,因为它能够更好地表达该参数是一个类型参数,避免与类的定义产生混淆。


1.3 函数模板的原理

函数模板的核心在于它不是一个真正的函数,而是一个编译器用来生成特定类型函数的蓝图。编译器根据模板的使用情况推导出具体的类型,并生成相应的代码。这一过程称为模板的实例化

1.3.1 函数模板的实例化

当我们调用模板函数时,编译器会根据实际的参数类型生成对应的函数版本。比如:

代码语言:javascript复制
template<class T>
T Add(const T& left, const T& right) {
    return left   right;
}

int main() {
    int a = 10, b = 20;
    double x = 1.1, y = 2.2;

    Add(a, b);  // 生成 Add<int> 版本
    Add(x, y);  // 生成 Add<double> 版本

    return 0;
}

在编译器编译阶段,对于模板函数的使用,编译器需要根据传入的实参类型来推演生成对应 类型的函数以供调用。比如:当用double类型使用函数模板时,编译器通过对实参类型的推演, 将T确定为double类型,然后产生一份专门处理double类型的代码,对于字符类型也是如此。

1.3.2 隐式实例化与显式实例化
代码语言:javascript复制
template<class T>
    T Add(const T& left, const T& right)
{
    return left   right;
}
int main()
{
    int a1 = 10, a2 = 20;
    double d1 = 10.0, d2 = 20.0;
    Add(a1, a2);
    Add(d1, d2);

    Add(a1, d1);
    /*
该语句不能通过编译,因为在编译期间,当编译器看到该实例化时,需要推演其实参类型
通过实参a1将T推演为int,通过实参d1将T推演为double类型,但模板参数列表中只有
一个T,
编译器无法确定此处到底该将T确定为int 或者 double类型而报错
注意:在模板中,编译器一般不会进行类型转换操作,因为一旦转化出问题,编译器就需要
背黑锅

*/
    // 此时有两种处理方式:1. 用户自己来强制转化 2. 使用显式实例化
    Add(a1, (int)d1);//自己强制转换
    return 0;
}

模板实例化分为两种:

  1. 隐式实例化:编译器根据实参推导出模板参数,并自动生成函数。例如上面的Add函数就是隐式实例化。
  2. 显式实例化:如果想要强制指定模板参数,可以使用显式实例化:

就是第二种处理方式

代码语言:javascript复制
int main(void)
{
    int a = 10;
    double b = 20.0;
    // 显式实例化
    Add<int>(a, b);
    return 0;
};

第二章: 类模板
2.1 类模板概念

类模板(Class Template)是用于定义与类型无关的类,它允许我们在类的定义中使用模板参数,编译时再根据实际类型进行类的实例化。类模板与函数模板类似,只不过它是用来生成类的。

2.1.1 类模板的定义格式

定义类模板的语法格式如下:

代码语言:javascript复制
template<class T>
class 类名 {
    // 类的成员变量和方法
};
  • template<class T>:告诉编译器这是一个模板类,T是一个类型参数。
  • 类的成员和方法可以使用T作为数据类型,编译时由用户提供的类型来替代T
2.1.2 示例:简单的类模板

下面是一个简单的栈(Stack)类模板,用于存储任意类型的数据:

代码语言:javascript复制
#include<iostream>

using namespace std;

/**
 * @brief 栈的类模板
 * @tparam T 通用的类型参数
 */
template<class T>
class Stack {
public:
    Stack(size_t capacity = 4) {
        _array = new T[capacity];
        _capacity = capacity;
        _size = 0;
    }

    ~Stack() {
        delete[] _array;
    }

    /// @brief 将元素压入栈中
    void Push(const T& data) {
        if (_size == _capacity) {
            Expand();  // 扩容
        }
        _array[_size  ] = data;
    }

    /// @brief 弹出栈顶元素
    void Pop() {
        if (_size > 0) {
            --_size;
        }
    }

    /// @brief 返回栈顶元素
    T& Top() {
        if (_size > 0) {
            return _array[_size - 1];
        }
        throw out_of_range("栈为空!");
    }

    /// @brief 检查栈是否为空
    bool IsEmpty() const {
        return _size == 0;
    }

    /// @brief 获取栈中元素的数量
    size_t Size() const {
        return _size;
    }

private:
    T* _array;
    size_t _capacity;
    size_t _size;

    /// @brief 扩展栈的容量
    void Expand() {
        size_t newCapacity = _capacity * 2;
        T* newArray = new T[newCapacity];
        for (size_t i = 0; i < _size;   i) {
            newArray[i] = _array[i];
        }
        delete[] _array;
        _array = newArray;
        _capacity = newCapacity;
    }
};

int main() {
    Stack<int> st;  // 创建存储整数的栈
    st.Push(10);
    st.Push(20);
    st.Push(30);

    cout << "栈顶元素: " << st.Top() << endl;
    st.Pop();
    cout << "弹出后栈顶元素: " << st.Top() << endl;

    return 0;
}
输出示例:
代码语言:javascript复制
栈顶元素: 30
弹出后栈顶元素: 20

在这个类模板中,T是一个通用类型参数。Stack<int>Stack类模板的一个实例化,表示它是一个存储int类型数据的栈。编译器会根据实际使用的类型自动生成相应的类。

2.2 类模板的实例化

与函数模板不同,类模板在使用时必须显示地提供类型参数。实例化类模板时,必须在类名后面的尖括号<>中指定实际的类型参数。例如:

代码语言:javascript复制
Stack<int> st1;    // 实例化为处理 int 类型的栈
Stack<double> st2; // 实例化为处理 double 类型的栈

这里的Stack<int>Stack<double>分别表示不同的类型,即不同的模板实例。编译器会根据模板参数生成相应的类代码。

2.3 类模板中的成员函数定义

对于类模板,成员函数可以在类定义内或定义外实现。类模板的成员函数定义外置时,需要在函数名之前加上模板声明和模板参数。例如:

代码语言:javascript复制
template<class T>
void Stack<T>::Push(const T& data) {
    if (_size == _capacity) {
        Expand();  // 扩容
    }
    _array[_size  ] = data;
}
2.4 为什么不建议类模板的定义和声明分离?

在C 中,类模板的实现与普通类有一个显著的区别:模板是在编译时根据实际类型实例化的,而不是像普通的类那样在编译期和链接期处理。这导致了一个很重要的问题:如果将类模板的声明和定义分离到不同的文件中,可能会导致链接错误。以下是详细原因:

2.4.1 模板的编译时行为

类模板的本质是一个“蓝图”,它并不是一个完整的类,而是一个在需要时根据实际类型生成代码的模式。因此,模板只有在实际使用(实例化)时,编译器才会生成对应的类型的代码。编译器无法预先知道你会使用哪些类型来实例化模板,因此它不会为模板生成实际的代码。

2.4.2 链接器无法找到定义

当你将类模板的声明放在头文件中,而把定义放在.cpp文件中时,模板实例化的过程可能发生在不同的编译单元中。因为模板只有在编译期被实例化,链接器在链接时无法看到模板的定义,除非在编译时所有模板的实例化代码都可见。如果定义在.cpp文件中,其他使用模板的编译单元无法找到这个定义,导致链接器报错。

2.4.3 无法预编译模板

与普通类不同,类模板无法被预编译或只在一个编译单元中定义然后供其他单元使用。普通的类在编译过程中,编译器会生成目标代码并储存在.obj文件中,链接时其他编译单元可以引用这些已生成的代码。而类模板无法这样做,因为它需要知道使用时的类型才能生成实际的代码。

2.4.4 解决方案:将声明和定义放在同一个头文件中

为了避免上述问题,C 的惯用方法是将类模板的声明和定义都放在同一个头文件中。这使得每个使用模板的编译单元在实例化模板时,编译器能够访问到模板的定义,并根据需要生成实际的代码。这种方式确保了编译器能够在编译期处理模板的实例化,而不会在链接时出现找不到定义的问题。

错误用法:

代码语言:javascript复制
// Stack.h
template<typename T>
class Stack {
public:
    void Push(const T& value);
    // 声明在头文件中
};

// Stack.cpp
template<typename T>
void Stack<T>::Push(const T& value) {
    // 定义在.cpp文件中
}

在这种情况下,如果不同编译单元使用了Stack<int>Stack<double>,链接器可能会报错,因为它无法找到模板的定义。

正确用法:

代码语言:javascript复制
// Stack.h
template<typename T>
class Stack {
public:
    void Push(const T& value);
};

template<typename T>
void Stack<T>::Push(const T& value) {
    // 声明和定义都在头文件中
}

这种方法确保每个编译单元都能访问到模板的完整定义,避免链接时的错误。

总结:

类模板的代码只有在实例化时才生成,因此类模板的定义必须在每个使用它的编译单元中可见。将模板的声明和定义放在同一个头文件中,可以确保模板实例化时能够访问到其定义,避免链接错误。这也是为什么大多数C 开发者在编写模板时会将模板的实现放在头文件中的原因。

注意:

函数模板与类模板不同,当代大多数编译器支持函数模板的声明和定义分离,这是因为函数模板的实例化往往只涉及函数的具体调用,不像类模板这么复杂,具体之后的博客会更详细的讲解此处的内容,敬请期待哦

0 人点赞