C++模板深入理解

C++ 模板进阶深度解析

1. 非类型模板参数详解

类型模板参数就是我们最常使用的模板方式,允许将类型作为“参数”传递,出现在模板参数列表中,跟在class或者typename之类的参数类型名称。,就是用一个常量作为类(函数)模板的一个参数,在类(函数)模板中可将该参数当成常量来使用。

非类型参数编译期具体的值作为模板参数传递,与宏定义类似。C++20之前只允许传递整型常量,C++20则可以传递其他类型的常量,

代码示例:

template<class T, size_t N = 10>  // N就是非类型模板参数
class array {
private:
    T _array[N];  // 这里N被当作编译期常量使用
    size_t _size;
};

对于上面代码,我们需要理解:

  • N 不是变量,而是编译期确定的常量
  • 编译器在实例化时会用具体数值替换 N
  • 模板实例化后,N的值就确定了,在编译期就会确定数组大小

2. 模板特化

2.1 什么是模板特化

我们在使用模板编程的时候,有时候会遇到特殊情况,我们所写的模板可能无法满足我们需要使用的类型的一些要求,无法实现相应功能,因此需要重写一个模板来实现相应功能。

如下所示:

template<class T>
bool Less(T left, T right) {
    return left < right;
}

Date* p1 = &d1, *p2 = &d2;
cout << Less(p1, p2); // 灾难!比较的是指针地址而非对象内容
cout << Less(*p1, *p2); //这是我们所期待的方式

此时,该函数无法实现比较大小的功能,他只会按照指针去比大小,而不是解引用取到值去比较大小,无法实现相应功能,**模板的通用性在这里变成了"错误的通用"——它对所有类型都一视同仁,但指针类型需要特殊处理。**因此需要重写一个模板来实现相应功能。

而重写模板费时费力,可能大部分功能和接口都相同的,只是个别功能出现问题,因此我们可以进行模板特化,在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。

特化分为函数模板特化和类模板特化。

具体特化方式及语法请看下文。

2.2 函数模板特化

特化语法详解

函数模板的要求:

  1. 必须要先有一个基础的函数模板
  2. 关键字template后面接一对空的尖括号<>
  3. 函数名后跟一对尖括号,尖括号中指定需要特化的类型
  4. 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误
// 基础模板 - 通用版本
template<class T>
bool Less(T left, T right) {
    return left < right;
}

// 特化版本 - 针对Date*类型的特殊处理
template<>  // 空尖括号表示这是特化
bool Less<int*>(int* left, int* right) {  // 明确指定特化类型
    return *left < *right;  // 解引用后比较实际对象
}

int main()
{
    int a = 3;
    int b = 4;
    Less(a, b);   //调用普通版本
    Less(&a, &b);  // 指针类型,调用特化版本,解引用后再比较大小
    return 0;
    
}

但是我们一般情况下不推荐函数模板特化,因为有更好的解决方案。

函数模板特化缺点
// 方式1:函数模板特化(不推荐)
template<>
bool Less<int*>(int* left, int* right);

// 方式2:普通函数重载(推荐)
bool Less(int* left, int* right);

函数模板特化相比起来直接重载函数,代码可读性低,不易维护,不易书写,所以说遇到特殊情况时,直接重载一个函数,更加方便。

2.3 类模板特化

2.3.1 全特化

全特化即是将模板参数列表中所有的参数都确定化。

为特定的类型组合提供完全不同的代码实现。

// 通用模板 - 处理大多数情况
template<class T1, class T2>
class Data {
public:
    Data() { cout << "通用版本" << endl; }
};

// 全特化 - 为<int, char>量身定制
template<>
class Data<int, char> {
public:
    Data() { cout << "int和char的特化版本" << endl; }
    // 可以有不同的成员变量、不同的方法实现
};

当某个特定类型组合需要完全不同的数据结构或算法时,我们就可会使用全特化来特殊处理。

2.3.2 偏特化

偏特化,也叫半特化,相比比全特化更灵活,它允许我们对模板参数施加条件限制。

部分特化

允许部分模板参数特化

// 当第二个参数是int时的特化版本
template <class T1>
class Data<T1, int> {  // 注意语法:在类名后的模板参数中指定特化类型
public:
    Data() { cout << "第二个参数为int的版本" << endl; }
};

类型限制特化

// 指针类型的特化
template <typename T1, typename T2>
class Data<T1*, T2*> {  // 两个参数都是指针类型
public:
    Data() { cout << "指针版本" << endl; }
};

// 引用类型的特化  
template <typename T1, typename T2>
class Data<T1&, T2&> {  // 两个参数都是引用类型
public:
    Data(const T1& d1, const T2& d2) : _d1(d1), _d2(d2) {
        cout << "引用版本" << endl;
    }
private:
    const T1& _d1;  // 引用成员
    const T2& _d2;
};

那要是同时存在全特化,偏特化,那么具体实例化哪一个呢?

Date<int, int> d1; //不符合全特化,也不符合偏特化,调用普通版本
Date<int, char> d2; //符合全特化,调用全特化
Date<char, int> d3; //符合偏特化第二个参数要求,调用偏特化
Date<char, char> d4; //不符合偏特化,也不符合偏特化,调用普通版本
Date<int*, char*> d5; //两个参数都是指针,符合指针类型限制的特化,调用该版本
Date<int&, char&> d6; //两个指针都是引用,符合引用类型限制的特化,调用该版本

总结:先检查时候符合全特化条件,符合的话调用全特化,然后检查是否符合偏特化条件,符合的话调用符合条件的偏特化,没有符合任意一个特化条件,则调用普通版本

2.3.3 实际应用

STL算法需要比较器,但通用比较器对指针类型失效。

namespace cmp
{
    template< class T >
    struct Less {
        bool operator()(const T& x, const T& y)
        {
            return x < y;
        }       
    };

    template<class T>
    struct Less<T*> {
        bool operator()(const T* x, const T* y)
        {
            return *x < *y;
        }
    }; 
}

int main()
{
    char a = 5;
    char b = 4;
    std::cout << cmp::Less<char>()(a, b) << std::endl;  //调用普通版本
    std::cout << cmp::Less<char*>()(&a, &b) << std::endl; //调用特化版本

    return 0;
}

有了指针类型的特化版本后,可以解引用的类型就可以通过指针去调用特化版本来比较大小

3. 模板分离编译的底层原理

3.1 编译链接过程

理解模板分离编译问题,需要先明白C++的编译过程:

  1. 预处理:头文件展开、替换宏定义等
  2. 编译:将每个.cpp文件单独编译成目标文件(.obj)
  3. 链接:将所有目标文件合并,解析符号引用

3.2 模板分离编译问题

我们生命和定义分离时,会有下列情况:

a.h(声明) → 被main.cpp和a.cpp包含
a.cpp(定义) → 编译成a.obj
main.cpp(使用) → 编译成main.obj

问题发生过程

  1. 编译a.cpp时:编译器看到 Add 模板的定义,但没看到具体的实例化调用,所以不会生成Add< int >或Add< double >的实际代码
  2. 编译main.cpp时:编译器看到Add(1, 2)调用,知道需要Add< int > 函数,但在当前文件中找不到定义,只能生成一个"未解析符号",放进符号表,等待链接时再寻找函数地址
  3. 链接时:由于没有实例化出具体代码,因此链接器在a.obj中找不到Add< int >的实现,报链接错误

3.3 解决方案

方案1:声明定义放在一起(推荐)
// xxx.h 或 xxx.hpp
template<class T>
T Add(const T& left, const T& right) {
    return left + right、;
}

当main.cpp包含头文件时,头文件展开后,就可以看到完整的模板定义,编译器在编译main.cpp看到集体函数调用后就能实例化出Add< int >等具体函数,直接获得函数地址,不存在链接时找不到定义

方案2:显式实例化(不推荐)
// a.cpp
template<class T>
T Add(const T& left, const T& right) {
    return left + right;
}

// 显式告诉编译器:请生成这些具体版本
template int Add<int>(const int&, const int&);
template double Add<double>(const double&, const double&);

缺点

  • 需要预知所有可能用到的类型
  • 维护困难,容易遗漏
  • 违背了模板的通用性原则

4. 模板按需实例化

模板的“按需实例化”,有时也被称为“隐式实例化”,是C++模板工作的核心机制。

大概意思就是:编译器不会为你的模板生成一整套完整的代码,而是只生成那些真正在程序中被使用到的部分。 这种机制深刻地影响了模板代码的编写、编译和最终生成的可执行文件。

4.1 按需实例化的行为

当你编写了一个模板类或模板函数时,它只是一个“蓝图”,编译器并不会立即为其生成任何实际的机器代码。只有当你在代码中明确使用了这个模板的某个特定版本时,编译器才会动手。例如,你定义了一个MyClass< int >对象,编译器才会开始将模板MyClass< T >中的T替换为 int,并生成MyClass< int >的二进制代码。而且,在这个生成的类中,它只会实例化那些被你调用了的成员函数。如果一个成员函数从未被调用,那么编译器就永远不会为它生成代码。

4.2 优点:控制代码膨胀

这是“按需实例化”最重要的价值所在。它是对抗模板可能引起的“代码膨胀”问题的第一道防线。想象一下,如果你有一个包含20个成员函数的模板类,而你只使用了其中的2个。如果没有“按需实例化”,编译器为每种类型生成全部20个函数,会造成巨大的浪费。而“按需实例化”确保了最终的可执行文件中只包含那2个真正被用到的函数,极大地节省了空间。

4.3 缺点:延迟错误暴露

这是一个非常关键且有时令人困惑的副作用。由于编译器只检查和处理被实例化的部分,这意味着模板代码中的错误(不包括没有分号,缺少花括号等一些“框架”错误,),只有在它被实例化时才会被编译器发现。你可以编写一个语法有误或者对某些类型无意义的模板,但只要你不使用有问题的那个部分,编译器就会安然放过它。这被称为“两阶段查找”:

  • 第一阶段(模板定义时):编译器检查不依赖于模板参数的语法,比如基本符号、括号匹配等。
  • 第二阶段(模板实例化时):编译器检查所有依赖于模板参数的代码是否有效。

举个例子来说明:

#pragma once
#include<iostream>
#include<vector>
#include<list>
namespace mystack{
    template<class T, class Container = std::vector<T>> //默认使用vecotr<T>, 然而在C++STL标准中默认使用的是dqueue
    class stack{
        private: 
        Container _st;
  namespace mystack {
    template<class T, class Container = std::vector<T>> //默认使用vecotr<T>, 然而在C++STL标准中默认使用的是dqueue
    class stack {
    private:
        Container _st;

    public:
        void push(const T& val)
        {
            _st.push_back(val);
        }
        void pop()
        {
            _st.pop_back();
        }
        const T& top()
        {
            return _st.back();
        }
        bool empty()
        {
            return _st.empty();
        }
        size_t size()
        {
            _st++;  //vector不支持++运算符,因此这是一个编译错误
            return _st.size();
        }
        void swap(stack& val)
        {
            _st.swap(val._st);
        }
    };
}

int main()
{
    mystack::stack<int> st1; //此时实例化stack类 
    st1.push(1); //调用时实例化push函数
    st1.top();  //调用时实例化top函数
    
    //st1.size(); //该函数中存在错误,但是不调用的话,编译器没有实例化,就不会发现该错误,程序可以正常运行,去掉注释后,调用该该函数了,就会报错
    //st1.swap();  //该函数如果一直不被调用,那么他将一直不实例化,其他未调用的模板类或者模板函数也是如此
    //……
    return 0;
}

上面代码中st1.size()调用就会报错,不调用则正常运行

调用:

在这里插入图片描述

不调用:

在这里插入图片描述

总结:“按需实例化”是C++模板设计哲学的一种体现——不为不需要的东西付出代价。它要求程序员以一种新的思维方式来对待代码:一个模板的正确性,不仅仅在于它的定义本身,更在于它被以何种方式实例化。理解这一点,对于编写高效、健壮的模板代码,以及解读那些“看似正确却编译失败”的复杂模板错误信息至关重要

5.模板的缺点

5.1 代码复用的代价

​ 模板所谓的“代码复用”并非传统意义上的一份代码被多处调用,其本质是一种编译期的“代码生成”机制;当我们为不同的类型使用同一个模板时,编译器会为每一种类型都实例化并编译出一份完全独立的代码副本。这种机制的代价是会导致代码膨胀,即最终的可执行文件体积显著增大,因为它包含了多份逻辑相同但类型不同的代码,这可能会影响程序的加载速度和内存占用。

5.2 灵活性的代价:

C++模板设计哲学的一种体现——不为不需要的东西付出代价。它要求程序员以一种新的思维方式来对待代码:一个模板的正确性,不仅仅在于它的定义本身,更在于它被以何种方式实例化。理解这一点,对于编写高效、健壮的模板代码,以及解读那些“看似正确却编译失败”的复杂模板错误信息至关重要

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值