[C++] 模板进阶:特化与编译链接全解析

2024-08-07 16:45:55 浏览数 (1)

非类型模板

模板参数分为:类型形参和非类型形参

类型形参

类型形参,即在模板初阶中所用的例如class Atypename A此类参数类型,跟在classtypename后。 [C ] 模版初阶-CSDN博客

非类型模板参数

非类型模板参数,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用,定义方法如下:

代码语言:javascript复制
template<class T, size_t N = 10>

注意:

  1. 非类型模板参数只能是整型。
  2. 浮点数、类对象以及字符串是不允许作为非类型模板参数的。
  3. 非类型的模板参数必须在编译期就能确认结果(原因看下文)。

代码示例

代码语言:javascript复制
template<int N>
class Array {
public:
    int arr[N];
    // 其他方法
};
Array<5> myArray; // 创建一个包含5个元素的数组对象

模板的特化

为什么要有模板的特化

模板技术提供了强大的泛型编程能力,使得我们能够编写与数据类型无关的代码,从而提高代码的复用性和灵活性。然而,在实际应用中,有时需要对特定类型进行特殊处理,这时就需要用到模板特化

**注意:**一般情况下如果函数模板遇到不能处理或者处理有误的类型,为了实现简单通常都是将该函数直接给出

模板特化的出现是为了解决模板在处理某些特殊类型时可能遇到的问题。例如,一个通用的比较函数模板可以比较大多数类型的数据,但在遇到指针时,仅比较指针的地址而不是指向的内容,这就可能导致错误的结果。模板特化允许为特定类型提供定制的实现,以解决这些特殊情况下的需求。

代码语言:javascript复制
// 例如日期类中的函数模板的使用,在使用指针比较的时候就会出现错误,这时候就需要进行模板特化
template<class T>
bool Less(T left, T right)
{
    return left < right;
}

Date d1(2022, 7, 7);
Date d2(2022, 7, 8);
cout << Less(d1, d2) << endl; // 可以比较,结果正确

Date* p1 = &d1;
Date* p2 = &d2;
cout << Less(p1, p2) << endl; // 可以比较,结果错误

当使用指针进行比较的时候比较的就是指针指向的地址,而地址是从栈上向下申请,所以不会按照原本日期类希望的排序方法进行排序。 此时,就需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化与类模板特化

函数模板特化

函数模板特化用于为特定类型定制函数实现。它的典型用处是在普通模板无法满足某些类型需求时提供特定的功能。特化函数的签名必须与原模板函数完全一致。

函数模板的特化步骤:

  1. 必须要先有一个基础的函数模板;
  2. 关键字template后面接一对空的尖括号<>
  3. 函数名后跟一对尖括号,尖括号中指定需要特化的类型;
  4. 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇 怪的错误。
使用场景与示例

紧接上面的错误案例:假设我们有一个通用的比较函数模板Less,它比较两个对象的大小

代码语言:javascript复制
template<typename T>
bool Less(T left, T right) {
    return left < right;
}

上述模板在大多数情况下都能正常工作,但如果传入的是指针类型,那么比较的将是指针的地址而非指向的对象。为了正确比较指针指向的内容,我们需要对指针类型进行特化:

代码语言:javascript复制
template<>
bool Less<Date*>(Date* left, Date* right) {
    return *left < *right;
}

此特化版本用于比较指针指向的Date对象,确保比较逻辑正确。

函数模板特化的实现细节

在实现函数模板特化时,需要注意以下几点:

  • 特化声明:模板特化的声明需要紧随template<>,然后是函数签名,特化的类型需要放在尖括号中。
  • 参数一致性:特化函数的参数列表必须与原模板函数保持一致,不能增加或减少参数,也不能更改参数的顺序或类型。

**注意:**推荐直接写一个函数实现特殊处理,编译器在处理的时候会优先调用更匹配的。

类模板特化

类模板特化比函数模板特化更加复杂,主要分为全特化偏特化。类模板特化的主要作用是为特定类型提供定制的类定义和实现。

全特化

全特化是指将模板参数列表中的所有参数都具体化(全特化版本中的所有参数都必须指定具体类型)。

示例

假设我们有一个通用的数据存储类模板Data,它可以存储两个不同类型的对象:

代码语言:javascript复制
template<typename T1, typename T2>
class Data {
public:
    Data() { std::cout << "Data<T1, T2>" << std::endl; }
private:
    T1 _d1;
    T2 _d2;
};

我们可以为特定的类型组合如intchar进行全特化:

代码语言:javascript复制
template<>
class Data<int, char> {
public:
    Data() { std::cout << "Data<int, char>" << std::endl; }
private:
    int _d1;
    char _d2;
};

在这个全特化版本中,我们为Data类提供了一个int和一个char类型的特化实现。这意味着当我们创建Data<int, char>类型的对象时,将调用特化版本的构造函数。

偏特化

偏特化是部分特化的形式,可以仅对部分模板参数进行特化。偏特化比全特化更灵活,允许特化的同时保留一些模板参数。

偏特化中有两种表现方式:部分特化、通过限制参数进行特化

部分优化

部分特化允许开发者针对特定的模板参数进行特化,而其他模板参数保持泛型(需要在template中声明)。这样可以在不影响通用模板行为的情况下,为某些特定类型或类型组合提供专门的实现。

示例:

代码语言:javascript复制
template<typename T1, typename T2>
class Pair {
public:
    Pair(T1 first, T2 second) : first_(first), second_(second) {}
    T1 first() const { return first_; }
    T2 second() const { return second_; }

private:
    T1 first_;
    T2 second_;
};

上述模板类是通用的,可以存储任意类型的两个数据。然而,如果我们需要对第一类型是int的情况进行特化,可以使用部分特化:

代码语言:javascript复制
template<typename T2>
class Pair<int, T2> {
public:
    Pair(int first, T2 second) : first_(first), second_(second) {}
    int first() const { return first_; }
    T2 second() const { return second_; }

    void setFirst(int value) { first_ = value; } // 额外的特化方法

private:
    int first_;
    T2 second_;
};

在这个部分特化版本中,我们特化了Pair模板的第一个类型为int,第二个类型保持泛型。这样,当Pair<int, T2>的对象创建时,将调用这个特化版本,而不是通用版本。

通过进一步限制模板参数进行特化
偏特化为指针类型示例:

当需要模板参数为指针类型的时候,可以对其进行特化,以实现针对于指针的特定逻辑。这在需要对指针执行特定操作(如解引用、比较等)时尤为有用。

代码语言:javascript复制
// 两个参数偏特化为指针类型
template <typename T1, typename T2>
class Data<T1*, T2*> {
public:
    Data() { std::cout << "Data<T1*, T2*>" << std::endl; }
private:
    T1 _d1;
    T2 _d2;
};
  • 模板特化Data<T1*, T2*>,这个偏特化版本对模板的两个参数T1T2进行了特化,使得它们必须是指针类型。
  • 实现细节:在构造函数中打印了一条消息,标识这是指针特化的版本。
  • 成员变量:特化类中的成员变量依然是T1T2类型,不过它们实际上是指针指向的对象的类型。
偏特化为引用类型示例:

对于引用类型的参数,我们可以通过特化来处理那些需要传递引用的情况。这在需要修改外部对象或避免对象复制时非常有用。

代码语言:javascript复制
// 两个参数偏特化为引用类型
template <typename T1, typename T2>
class Data<T1&, T2&> {
public:
    Data(const T1& d1, const T2& d2)
        : _d1(d1), _d2(d2) {
        std::cout << "Data<T1&, T2&>" << std::endl;
    }
private:
    const T1& _d1;
    const T2& _d2;
};
  • 模板特化Data<T1&, T2&>,这个偏特化版本对模板的两个参数T1T2进行了特化,使得它们必须是引用类型。
  • 实现细节:在构造函数中接受了T1T2类型的引用,并初始化类的成员变量。
  • 成员变量:特化类中的成员变量是对传入对象的常量引用const T1&const T2&,这确保了数据不会被意外修改。

特化测试结果分析

代码语言:javascript复制
void test2() {
    Data<double, int> d1; // 调用全特化的模板
    Data<int, double> d2; // 调用基础的模板
    Data<int*, int*> d3; // 调用特化的指针版本
    Data<int&, int&> d4(1, 2); // 调用特化的引用版本
}
  • Data<double, int> d1;:调用了全特化模板,因为参数类型既不是指针也不是引用。
  • Data<int, double> d2;:同样调用全特化模板。
  • Data<int*, int*> d3;:调用了特化的指针版本,因为两个参数都是指针类型。
  • Data<int&, int&> d4(1, 2);:调用了特化的引用版本,因为两个参数是引用类型(注意,这里初始化引用类型参数时传递的是常量12,这些字面量会被隐式转换为合适的引用类型)。

模板特化中的注意事项

实例化时严格的匹配性

模板编程中,模板实例化时的匹配性要求非常严格,即使已经对模板进行了特化,在实例化时也必须精确匹配到最合适的模板版本。这种严格的匹配性体现在以下几个方面:

  • 全特化:指的是为特定类型组合提供一个完全定制化的实现。全特化要求在实例化时完全匹配所有模板参数类型,只有在参数完全匹配时,才会使用该特化版本。
  • 偏特化:允许对部分模板参数进行特化,同时保持其他参数的泛型性。在实例化时,编译器会优先选择最匹配的特化版本。如果没有找到完全匹配的特化版本,编译器才会退而求其次,选择更加通用的版本。
  • 模板匹配顺序:编译器在选择模板实例化时,会按照以下优先顺序进行匹配:
    • 完全匹配的全特化(优先级最高)
    • 最匹配的偏特化
    • 最通用的模板

指针特化时const的修饰问题

为什么在参数列表使用const

防止修改传入的参数:特化版本中的Date* const& leftDate* const& right,通过使用const,函数保证不会修改传入的指针变量本身的值,即指针的指向保持不变。这是一种安全措施,避免函数对外部数据的不必要的修改。

const与指针修饰关系
指针本身是常量 (const*之后)

const放在指针符号*之后时,它修饰的是指针本身,这意味着指针的值(即它指向的内存地址)不能被改变。但指针指向的对象的内容可以改变。

代码语言:javascript复制
Date* const pDate;

在这个例子中,pDate是一个常量指针,它指向一个Date类型的对象。pDate本身不能指向别处,但是pDate指向的Date对象的内容是可以修改的。

通过特化时将**const**放在在*****之后即可解决在特化中的修饰关系。

指向的内容是常量 (const*前面)

const放在*前面时,它修饰的是指针指向的对象,这意味着不能通过这个指针修改指向的对象的内容,但指针本身可以指向不同的对象。

代码语言:javascript复制
const Date* pDate;

在这个例子中,pDate是一个指向Date对象的指针。虽然pDate本身可以指向不同的Date对象,但不能通过pDate来修改它所指向的对象的内容。

指针特化时const修饰的应用

通用函数模板

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

该函数模板中的const修饰的是传入的leftright不会被改变。

特化函数模板

代码语言:javascript复制
template<>
bool LessFunc<Date*>(Date* const& left, Date* const& right)
{
	return *left < *right;
}

**Date* const& left****Date* const& right**:这两个参数都是指向Date对象的常量指针的引用。这意味着:

  • 指针本身不可改变:函数内部不能改变leftright指向的地址(与通用模板中的修饰目的相同)。

为了保持与通用模板中const效果相同,因此写为Date* const& left。通用模板是为了是传入的数据不被修改,而对于传入的指针来说,**const**放在*****之后,表示指针本身是常量。换句话说,指针本身的地址不能改变,也就是说,一旦初始化后,指针不能指向其他地址,也就是传入的指针不能被修改了,和通用模板实现的效果相同。 因此,Date* const& 的意思是“指向Date对象的常量指针的引用”。这个引用在函数内不会改变其所引用的指针对象,也不能通过引用修改指针本身的指向。

已经特化的类中T表示为什么?

在已经特化过的类中,不管特化时是将原类型特化为指针类型或者引用类型之类的,在类中使用T的时候一律会按照原类型进行使用,也就是说如果在类中要用原类型的指针类型的话,还是需要用T*

如此表示在const修饰传参时也有用处,例如上文所理解的LessFunc<Date*>(Date* const& left, Date* const& right),如果特化为指针的类中Date实际表示为Date*的话,那么在修饰的时候究竟要如何修饰呢。此时就会产生语法与习惯上的矛盾,所以将T直接作为原类型使用会更加方便与顺手。

模板的分离编译

分离编译模式简介

分离编译是软件工程中的一个基本概念,它指的是将源代码分割成多个模块,每个模块独立编译,最后通过链接器将这些模块组合成最终的可执行文件。这种方式提高了编译的并行性,同时也使得代码维护更加简单,因为修改一个模块通常不会影响到其他模块的编译。

模板的分离编译

分离编译测试

我们有一个模板函数Add,它的声明和定义被分别放在不同的文件中:

代码语言:javascript复制
// a.h
template<class T>
T Add(const T& left, const T& right);

// a.cpp
template<class T>
T Add(const T& left, const T& right)
{
    return left   right;
}

main.cpp中,我们使用了Add函数的两个实例:

代码语言:javascript复制
#include "a.h"

int main()
{
    Add(1, 2); // 整数实例
    Add(1.0, 2.0); // 浮点数实例
    return 0;
}

此时当运行的时候会出现链接错误。

原因解析
C/C 程序的编译链接原理

C/C 程序的构建过程通常分为四个阶段:预处理、编译、汇编和链接。

  1. 预处理:预处理器处理#include指令和其他预处理器指令,将头文件的内容插入到源文件中,同时处理宏定义等。
  2. 编译:编译器将预处理后的源代码转换成汇编代码。在这个阶段,编译器检查语法、词法和语义错误,并且如果一切正确,将代码转换成机器可以理解的指令集。
  3. 汇编:将汇编代码转换为机器代码的二进制形式。
  4. 链接:链接器将多个目标文件(.obj)和库文件链接起来,解决符号引用问题,生成最终的可执行文件。
为什么不能分离定义?

**原因:**模板实例化的代码并不是编译的时候在模板位置直接生成的,而是在需要实例化的时候才会生成特定的具体代码。

  • 实例化时机:模板的实例化发生在编译器遇到模板函数或类的使用时。如果模板的定义不在编译器当前正在处理的编译单元中,那么编译器无法知道如何实例化模板,因此不会生成相应的函数代码。
  • 地址问题:如你提到的例子,当在a.cpp中没有Add模板的具体实例化代码时,编译器不会生成对应的函数。而在main.obj中尝试使用Add<int>Add<double>时,链接器会在链接阶段寻找这些函数的地址,但因为它们在编译时没有被生成,所以链接器找不到这些地址,导致链接错误。
  • 单定义规则(One Definition Rule,ODR):C 的单定义规则要求每个非内联函数或变量在一个程序中只能有一个定义。模板的每次实例化都被视为一个独立的函数或类型定义,这意味着每次实例化都必须在同一个编译单元中完成,否则可能会违反ODR。
  • **推荐做法:**将模板的声明和定义放在同一个头文件中,确保在任何包含该头文件的编译单元中都可以进行正确的实例化。

0 人点赞