【C++】模板进阶--详解

一. 非类型模板参数

模板参数分两种

模板参数分为类型形参与非类型形参。

类型形参 :就是跟在 classtypename 后面的那个参数,它代表一个类型。简单说就是"这个参数是一个类型",具体是什么类型等实例化的时候再确定。

cpp 复制代码
template <class T> class A;    // T 是类型形参,T 代表一个类型

非类型形参:就是用一个常量作为模板的参数,在模板里面可以把这个参数当成常量来用。简单说就是"这个参数是一个值",不是一个类型。

cpp 复制代码
template <class T, size_t N> class A;   // T 是类型形参,N 是非类型形参

1.1非类型模板参数基本用法

非类型形参支持整型、枚举、指针、引用等类型:

cpp 复制代码
template<int N, size_t M, bool Flag> class A;        // 整型常量
template<char C> class B;                            // 字符常量
template<size_t N = 10> class C;                    // 可以给默认值

实例化的时候,传的必须是编译期能确定的常量表达式:

cpp 复制代码
A<10, 20, true> a1;        // 传常量
const int n = 5;
A<n, 30, false> a2;        // const 变量也算常量表达式
int m = 5;
A<m, 10, true> a3;         //  错误,m 是变量,不是常量表达式

比如我开个栈:

cpp 复制代码
template<size_t N = 10, bool flag = false>
class Stack
{
private:
    int _a[N];      // N 作为数组大小,编译期确定
    int _top;
};

实例化方式:

cpp 复制代码
Stack<> s0;        // 使用默认值,N = 10
Stack<5> s1;       // N = 5
Stack<10, true> s2; // N = 10,flag = true

非类型模板参数也支持默认值,和类型参数一样,不管哪种模板参数,都可以给缺省值


1.2非类型模板参数的限制

C++20 之前,非类型模板参数只支持:

整型(int、char、size_t、bool 等)

枚举类型

指针、引用

C++20 开始,浮点数也可以作为非类型模板参数:

cpp 复制代码
// C++20 才支持
template<double D>
class A
{
    // ...
};

注意: 非类型模板参数的值必须是编译期常量,不能是运行期变量。

1.3非类型模板参数与数组

1.三种数组/容器的存储位置

cpp 复制代码
// 栈上分配
array<int, 10> a1;     // 40 字节,栈上
array<int, 100> a2;    // 400 字节,栈上
int a3[10];            // 40 字节,栈上

// 堆上分配
vector<int> v(100, 1); // 对象本身在栈上(24字节),数据在堆上(400字节)

2.原生数组的越界问题

cpp 复制代码
int a3[10];

// 越界读:不检查
cout << a3[10] << endl;   // 越界了,但程序不会报错,输出内存中的随机值

// 越界写:抽查,不一定会报错
a3[12] = 10;   // 越界了,但可能侥幸没崩溃
a3[20] = 10;   // 越界更多,可能破坏其他数据或直接崩溃

问题来了,为什么是抽查?

原生数组越界属于未定义行为,编译器不做检查。有时候越界写的是未使用的内存,程序继续跑;有时候写到了关键数据,程序就崩了。这就是抽查------一般运气好没事,运气不好就挂。

3.array 的越界检查

cpp 复制代码
array<int, 10> a1;

a1[10];      // debug 下会检查,越界直接断言报错
a1[12] = 10; // debug 下同样检查

4.array 和 vector 的内存占用

cpp 复制代码
cout << sizeof(a2) << endl;   // 400 字节(100 个 int × 4 字节)
cout << sizeof(v) << endl;    // 24 字节(对象本身的大小)

array 的 size 就是 N × sizeof(T),数据直接放在对象里。vector 的对象本身很小(通常 24 字节),数据在堆上单独分配。

5.栈的生长方向

cpp 复制代码
void func()
{
    int a = 1;
    cout << &a << endl;   // 打印 func 中变量的地址
}

int main()
{
    int a = 0;
    cout << &a << endl;   // 打印 main 中变量的地址
    func();
    return 0;
}

输出:

地址越来越小,说明栈是向下生长的(从高地址向低地址生长)。
注意:

  1. 浮点数、类对象以及字符串是不允许作为非类

  2. 非类型的模板参数必须在编译期就能确认结果。


二、模板的特化

2.1 概念

通常情况下,使用模板可以实现一些与类型无关的代码 ,但对于一些特殊类型的可能会得到一些错误的结果,需要特殊处理,比如:实现了一个专门用来进行小于比较的函数模板

cpp 复制代码
// 函数模板 -- 参数匹配
template<class T>
bool Less(T left, T right)
{
	return left < right;
}
int main()
{
	cout << Less(1, 2) << endl;   // 可以比较,结果正确
	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;  // 可以比较,结果错误
	return 0;
}

可以看到,Less绝对多数情况下都可以正常比较,但是在特殊场景下就得到错误的结果。上述示例中,p1指向的d1显然小于p2指向的d2对象,但是Less内部并没有比较p1和p2指向的对象内容,而比较的是p1和p2指针的地址,这就无法达到预期而错误。

此时,就需要对模板进行特化。即:在原模板类的基础上,针对特殊类型所进行特殊化的实现方式。模板特化中分为函数模板特化类模板特化

2.2函数模板特化

函数模板的特化步骤:

  1. 必须要先有一个基础的函数模板

  2. 关键字template后面接一对空的尖括号<>

  3. 函数名后跟一对尖括号,尖括号中指定需要特化的类型

  4. 函数形参表: 必须要和模板函数的基础参数类型完全相同,如果不同编译器可能会报一些奇怪的错误。

cpp 复制代码
// 函数模板 -- 参数匹配
template<class T>
//bool LessFunc(T const & left, T const & right)
bool LessFunc(const T& left, const T& right)
{
	return left < right;
}

// 特化
//template<>
//bool LessFunc<const Date*>(const Date* const& left, const Date* const& right)
//{
//	return *left < *right;
//}
//
//template<>
//bool LessFunc<Date*>(Date* const& left, Date* const& right)
//{
//	return *left < *right;
//}

// 推荐
bool LessFunc(const Date* left, const Date* right)
{
	return *left < *right;
}

bool LessFunc(Date* left, Date* right)
{
	return *left < *right;
}

int main()
{
	cout << LessFunc(1, 2) << endl; // 可以比较,结果正确

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

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

	const Date* p3 = &d1;
	const Date* p4 = &d2;
	cout << LessFunc(p3, p4) << endl; // 可以比较,结果错误

	const int i = 0;
	int const j = 0;
	const int& rx = i;
	int const& ry = i;

	return 0;
}

1.函数模板匹配的局限性

函数模板可以对不同类型进行统一处理,但有些类型用默认的比较方式会出错:

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

当比较 Date*(指针)时,LessFunc 比较的是指针地址的大小,而不是 Date 对象本身的大小:

cpp 复制代码
Date d1(2022, 7, 7);
Date d2(2022, 7, 8);
Date* p1 = &d1;
Date* p2 = &d2;

cout << LessFunc(p1, p2) << endl;  // 比较的是 p1 和 p2 的地址值,结果可能是错误的

2.解决方法:模板特化

就是针对特定类型,给模板提供一个专门的版本,这叫模板特化

cpp 复制代码
// 特化:专门为 Date* 类型提供实现
template<>
bool LessFunc<Date*>(Date* const& left, Date* const& right)
{
    return *left < *right;   // 比较 Date 对象本身
}

然后现在调用 LessFunc(p1, p2) 就会走特化版本,比较的是两个 Date 对象的大小。


3.特化的写法分析

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

template<> 空的尖括号,表示这是一个全特化;

LessFunc<Date*> 指明特化的模板参数是 Date*;

Date* const& 参数类型,是主模板 const T& 在 T = Date* 时的展开形式

4.const 的位置问题

cpp 复制代码
// 特化版本1:匹配 T = Date*
template<>
bool LessFunc<Date*>(Date* const& left, Date* const& right)

// 特化版本2:匹配 T = const Date*不是同一个特化
template<>
bool LessFunc<const Date*>(const Date* const& left, const Date* const& right)

Date*const Date* 是两种不同的类型,因为特化时要保持一致。

const 的位置:

cpp 复制代码
// const 在 * 右边 ---> 修饰指针本身,指针不能改,但指向的数据能改
// 这是主模板 const T& 在 T = Date* 时的自然展开
Date* const& left

// const 在 * 左边 ---> 修饰指向的数据,数据不能改
// 这匹配的是 T = const Date*,而不是 T = Date*
const Date*& left

6.因此不推荐特化,推荐重载

特化的语法容易搞混 const 的位置,更推荐直接用函数重载

cpp 复制代码
// 重载1:处理 const Date* 类型
bool LessFunc(const Date* left, const Date* right)
{
    return *left < *right;
}

// 重载2:处理 Date* 类型
bool LessFunc(Date* left, Date* right)
{
    return *left < *right;
}

举例:用重载处理指针比较

cpp 复制代码
int main()
{
    // 一些比较基础类型比较,走主模板版本
    cout << LessFunc(1, 2) << endl;          // true,没问题
    
    // 比较 Date 对象,走主模板版本
    Date d1(2022, 7, 7);
    Date d2(2022, 7, 8);
    cout << LessFunc(d1, d2) << endl;        // true,没问题
    
    // 比较 Date*,走重载版本
    Date* p1 = &d1;
    Date* p2 = &d2;
    cout << LessFunc(p1, p2) << endl;        // true,比较的是 Date 对象
    
    // 比较 const Date*,走重载版本
    const Date* p3 = &d1;
    const Date* p4 = &d2;
    cout << LessFunc(p3, p4) << endl;        // true,比较的是 Date 对象
    
    // const 的位置:等价写法
    const int i = 0;
    int const j = 0;      // 和 const int 等价
    const int& rx = i;
    int const& ry = i;    // 和 const int& 等价
    
    return 0;
}

【补充】

const 与指针的位置关系

给一个口诀方便记忆:const 在 * 左边,修饰的是指向的数据;const 在 * 右边,修饰的是指针本身。

表格理解

写法 读法 const 修饰谁 数据能否改 指针本身能否改 说明
const Date* p 指向 const Date 的指针 数据(*p 不能 指向的数据是只读的,指针本身可以指向别处
Date const* p 同上 数据(*p 不能 const Date* 完全等价,写法不同而已
Date* const p 指向 Date 的 const 指针 指针本身(p 不能 指针地址固定,但指向的数据可以修改
const Date* const p 指向 const Date 的 const 指针 数据 + 指针 不能 不能 数据和指针都不能改,完全只读
Date const* const p 同上 数据 + 指针 不能 不能 和上面等价
const Date*& p 指向 const Date 的指针的引用 数据(*p 不能 能(通过引用可以改指针指向) 引用本身不可改,但可以改变引用指向的指针(引用的特性)
Date* const& p 指向 Date 的 const 指针的引用 指针本身(p 不能(通过引用也不能改指针) 相当于给 Date* const 起了一个别名

2.3 类模板特化

类模板特化步骤:

  • 必须要先有一个基础的类模板。
  • 关键字 template 后面接一对空的尖括号 <>
  • 类名后跟一对尖括号 <>,尖括号中指定需要特化的类型
    类模板的特化分为:全特化偏特化

2.3.1 全特化

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

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

// 全特化
template<>
class Data<int, char>
{
public:
	Data() { cout << "Data<int, char>" << endl; }
};

void TestVector()
{
Data<int, int> d1;//基础类模板
Data<int, char> d2;
}

2.3.2 偏特化

偏特化:任何针对模版参数进一步进行条件限制设计的特化版本。比如对于以下模板类:

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

偏特化有以下两种表现方式:

  • 部分特化

将模板参数类表中的一部分参数特化。

cpp 复制代码
// 将第二个参数特化为int
template <class T1>
class Data<T1, int>
{
public:
   Data() {cout<<"Data<T1, int>" <<endl;}
private:
   T1 _d1;
   int _d2;
};
  • 参数更进一步的限制

偏特化并不仅仅是指特化部分参数,而是针对模板参数更进一步的条件限制所设计出来的一

个特化版本。

cpp 复制代码
//两个参数偏特化为指针类型
template <typename T1, typename T2>
class Data <T1*, T2*>
{
public:
   Data() {cout<<"Data<T1*, T2*>" <<endl;}
   
private:
   T1 _d1;
   T2 _d2;
};
//两个参数偏特化为引用类型
template <typename T1, typename T2>
class Data <T1&, T2&>
{
public:
   Data(const T1& d1, const T2& d2)
 
: _d1(d1)
 
, _d2(d2)
  {
           cout<<"Data<T1&, T2&>" <<endl;
  }
   
private:
   const T1 & _d1;
   const T2 & _d2;    
};
void test2 ()
{
    Data<double , int> d1;      // 调用特化的int版本
    Data<int , double> d2;      // 调用基础的模板    
    Data<int *, int*> d3;       // 调用特化的指针版本
    Data<int&, int&> d4(1, 2);  // 调用特化的指针版本
}

2.3.3 类模板特化应用示例

有如下专门用来按照小于比较的类模板Less:

cpp 复制代码
#include<vector>
#include<algorithm>
template<class T>
struct Less
{
    bool operator()(const T& x, const T& y) const
    {
        return x < y;
    }
};
int main()
{
    Date d1(2022, 7, 7);
    Date d2(2022, 7, 6);
    Date d3(2022, 7, 8);
    vector<Date> v1;
    v1.push_back(d1);
    v1.push_back(d2);
    v1.push_back(d3);
    // 可以直接排序,结果是日期升序
    sort(v1.begin(), v1.end(), Less<Date>());
    vector<Date*> v2;
    v2.push_back(&d1);
    v2.push_back(&d2);
    v2.push_back(&d3);
// 可以直接排序,结果错误日期还不是升序,而v2中放的地址是升序
// 此处需要在排序过程中,让sort比较v2中存放地址指向的日期对象
// 但是走Less模板,sort在排序时实际比较的是v2中指针的地址,因此无法达到预期
    sort(v2.begin(), v2.end(), Less<Date*>());
return 0;
}

通过观察上述程序的结果发现,对于日期对象可以直接排序,并且结果是正确的。但是如果待排序元素是指针,结果就不一定正确。因为:sort最终按照Less模板中方式比较,所以只会比较指针,而不是比较指针指向空间中内容,此时可以使用类版本特化来处理上述问题:

cpp 复制代码
// 对Less类模板按照指针方式特化
template<>
struct Less<Date*>
{
    bool operator()(Date* x, Date* y) const
    {
    return *x < *y;
    }
}

完整代码如下:

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

class Date
{
public:
    Date(int year = 1, int month = 1, int day = 1)
        : _year(year), _month(month), _day(day)
    {}

    // 重载 operator<
    bool operator<(const Date& d) const
    {
        if (_year != d._year) return _year < d._year;
        if (_month != d._month) return _month < d._month;
        return _day < d._day;
    }

    /成员函数:打印
    void print(ostream& out = cout) const
    {
        out << _year << "-" << _month << "-" << _day;
    }

private:
    int _year;
    int _month;
    int _day;
};

// Less 仿函数 
template<class T>
struct Less
{
    bool operator()(const T& x, const T& y) const
    {
        return x < y;
    }
};

// 特化版本:针对 Date*
template<>
struct Less<Date*>
{
    bool operator()(Date* x, Date* y) const
    {
        return *x < *y;
    }
};

//  测试
int main()
{
    Date d1(2022, 7, 7);
    Date d2(2022, 7, 6);
    Date d3(2022, 7, 8);

    // 测试 vector<Date*>
    vector<Date*> v2;
    v2.push_back(&d1);
    v2.push_back(&d2);
    v2.push_back(&d3);

    cout << "排序前: ";
    for (auto p : v2)
    {
        p->print();        // 调用 print
        cout << " ";
    }
    cout << endl;

    sort(v2.begin(), v2.end(), Less<Date*>());

    cout << "排序后: ";
    for (auto p : v2)
    {
        p->print();        // 调用 print
        cout << " ";
    }
    cout << endl;

    return 0;
}

输出打印:

**仿函数就是一个类,里面重载了 (),让对象能像函数一样被调用,**传给 sortset 这些容器,告诉它们怎么比大小。比普通函数好用,因为可以带状态。长得像函数,其实是个对象


三. 模板分离编译

3.1 什么是分离编译

一个程序(项目)由若干个源文件共同实现,而每个源文件单独编译生成目标文件,最后将所有目标文件链接起来形成单一的可执行文件的过程称为分离编译模式。


模板不能把声明放 .h,定义放 .cpp。 因为编译的时候各编各的,.cpp 不知道你要用啥类型,就不生成代码;.h 那边要用但找不到定义,最后链接就报错。

解决办法:直接把定义也写在 .h 里,别分开。

3.2 模板的分离编译

假如有以下场景,模板的声明与定义分离开,在头文件中进行声明,源文件中完成定义:

cpp 复制代码
// 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
#include"a.h"
int main()
{
    Add(1, 2);
    Add(1.0, 2.0);
    return 0;
}

板分离编译总结

1.编译链接的四个步骤

cpp 复制代码
.cpp -->预处理 --> .i --> 编译 -->.s → 汇编 --> .o --> 链接 --> .exe
步骤 干啥的
预处理 头文件展开、宏替换、去注释
编译 检查语法,生成汇编代码
汇编 汇编代码转成二进制机器码
链接 多个 .o 文件拼成可执行文件,把函数地址对上

2.模板分离编译为啥会报错?

模板的声明放 .h,定义放 .cpp 时:

1.实例化需要完整的定义,光有声明不够。

cpp 复制代码
// a.h 里只有这个:
template<class T>
T Add(const T& left, const T& right);   // 只有声明

// main.cpp 里调用:
Add(1, 2);
// 编译器看到这个调用,知道需要实例化 Add<int>
// 但它找不到 Add<int> 的定义(函数体)
// 所以没法实例化,只能留个标记让链接器去找

2.编译链接全过程(你的情况)

编译 a.cpp

编译器看到模板定义(如果写在了 a.cpp 里)

但没人调用它,所以不生成任何机器码

a.obj 是空的(没有 Add 相关的代码)

编译 main.cpp

看到 Add(1, 2),需要 Add<int>

但只有声明,找不到定义

编译器无法实例化,在 main.obj 留了个标记我需要 Add<int>

链接时

链接器去 a.obj 里找 Add<int>

a.obj 是空的 报错:找不到符号

3.3 解决方法

  1. 将声明和定义放到一个文件 "xxx.hpp" 里面或者xxx.h其实也是可以的。推荐使用这种。

  2. 模板定义的位置显式实例化。这种方法不实用,不推荐使用。

【分离编译扩展阅读】

为什么C++编译器不能支持对模板的分离式编译-CSDN博客


四. 模板总结

【优点】

  1. 模板复用了代码,节省资源,更快的迭代开发,C++的标准模板库(STL)因此而产生

  2. 增强了代码的灵活性

【缺陷】

  1. 模板会导致代码膨胀问题,也会导致编译时间变长

  2. 出现模板编译错误时,错误信息非常凌乱,不易定位错误