三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

泛型编程的诞生(C++篇)

泛型编程的诞生(C++篇)

模板的深入理解

前言

模板非常的重要,它就是STL的根基。STL容器vector/list/set/map、算法sort全都是基于模板实现
我将从函数模板→类模板→模板实例化匹配→模板特化→分离编译,层层拆解,贯穿底层


一、为什么需要模板?泛型编程的诞生

1. 传统重载的痛点

相信大部分从C语言学过来的人都大概率实现过计算器的基本功能。写整数的加法、减法,我们会写一个Plus的函数,但是计算器的场景不可能只停留在整数,还可能涉及到小数,也就是我们的浮点数。而C语言完全不支持函数重载,C语言标准规定死了同名函数只能有一个,那就非常的麻烦。你得写个Plus1,Plus2…这类不同名的函数,只是为了实现一个简单的加法。非常难受。进入C++就好很多,你可以在类里封装一个Plus函数对它进行重载,传不同的参数,但是只是可以名字相同,你的工作量还是没有下来,你还是得人为的去写一大堆重载函数。比如你还想要一个通用交换函数,处理int、double、char:

voidSwap(int&left,int&right){inttemp=left;left=right;right=temp;}voidSwap(double&left,double&right){doubletemp=left;left=right;right=temp;}voidSwap(char&left,char&right){chartemp=left;left=right;right=temp;}

重载存在两大致命缺陷:

  1. 代码复用率极低:每新增一种类型,必须手写一份完全重复逻辑的函数;
  2. 维护成本极高:逻辑修改时,所有重载函数都要同步改动,一处出错全部出错。

2. 泛型编程思想

但是呢,我们祖师爷本.贾尼博士整了个模板,也就是从模板开始,C++有了质的变化。祖师爷呢就希望告诉编译器一套通用逻辑,让编译器根据传入的类型自动生成对应代码,不用人工重复写。

C++通过模板实现泛型编程,分为两大分支:

  • 函数模板:通用函数
  • 类模板:通用类(stack/vector底层全是类模板)

二、函数模板

2.1 函数模板基础语法

template<typenameT1,typenameT2,...,Tn>返回值类型 函数名(参数列表){//通用逻辑,和类型无关}

关键字说明

  1. template:模板声明标识,必须写在模板函数/类最前面;
  2. typename:定义模板参数,代表通用类型占位符;
    • 等价替换:class T,二者完全通用,不能用struct T
  3. T:类型占位符,编译阶段由编译器替换成真实类型。

通用交换函数模板示例

template<typenameT>voidSwap(T&left,T&right){T temp=left;left=right;right=temp;}

2.2 函数模板底层原理

模板本身不是可执行函数,只是一套代码模具。
编译阶段编译器会做两件事:

  1. 读取函数调用时传入的实参,推导真实类型
  2. 根据推导出来的类型,实例化一份专属该类型的函数代码。

示例:

inta=1,b=2;doublec=1.1,d=2.2;Swap(a,b);// 推导T=int,生成Swap<int>版本Swap(c,d);// 推导T=double,生成Swap<double>版本

模具模板 + 实参类型 → 编译器生成对应类型函数。

2.3 函数模板两种实例化方式

1)隐式实例化(最常用,自动推导)

编译器根据传入实参自动推导T,不用手动指定类型:

template<typenameT>TAdd(constT&left,constT&right){returnleft+right;}intmain(){inta1=10,a2=20;doubled1=10.0,d2=20.0;Add(a1,a2);// T=int 隐式实例化Add(d1,d2);// T=double 隐式实例化return0;}
隐式实例化限制:两边参数类型必须完全一致
Add(a1,d1);// 报错:第一个参数推导T=int,第二个推导T=double,冲突无法确定T

2)显式实例化(手动指定模板参数)

调用函数时,用<>强制写明真实类型,解决类型冲突问题:

Add<int>(a1,d1);// 强制T=int,double会隐式转换为intAdd<double>(a1,d1);// 强制T=double,int转double

2.4 模板与普通函数的匹配规则

  1. 普通函数优先级高于模板:存在同名普通函数+模板时,优先调用普通函数,不会实例化模板。人想偷懒所以搞了一套模板逻辑,把原本人要做的事情交给编译器来做,当然编译器发现人写了现有的函数它也会想着去偷懒,自己就不用生成了,直接用人写的函数,这是一个优先级的问题。
// 普通int加法函数intAdd(intleft,intright){returnleft+right;}// 通用模板template<typenameT>TAdd(T left,T right){returnleft+right;}voidTest(){Add(1,2);// 完全匹配普通int函数,优先调用普通函数Add<int>(1,2);// 显式指定模板,强制调用模板版本Add(1.0,2.0);// 无匹配普通函数,实例化模板double版本}
  1. 模板不支持自动类型转换;普通函数支持自动转换。
    普通函数int Add(int,int)double会自动截断;模板必须类型完全匹配或手动显式实例化。

三、类模板(STL容器底层核心)

vector、stack、list、string底层全部是类模板。

类模板:模具,template<…> class Data,未实例化,不是真实类;
模板类:成品,Data<int, int>,编译器根据模具生成的、可以定义对象的真实类。

3.1 类模板定义语法

template<classT1,classT2...>class类名{//成员变量、成员函数,可用T代表通用类型};

栈类模板实战

template<typenameT>classStack{public:Stack(size_t capacity=4){_array=newT[capacity];_capacity=capacity;_size=0;}voidPush(constT&data);private:T*_array;size_t _capacity;size_t _size;};

3.2 类模板实例化规则(和函数模板巨大区别)

类模板无法隐式推导,必须显式指定模板参数<类型>

intmain(){Stack<int>st1;// T=int,实例化int栈Stack<double>st2;// T=double,实例化double栈// Stack st3; 直接报错,必须写明<>内类型return0;}

类模板外部定义成员函数标准写法

当我们在类模板外去实现其成员函数时,必须重写template模板声明,同时类名带上模板参数:

template<typenameT>voidStack<T>::Push(constT&data){//扩容逻辑..._array[_size++]=data;}

3.3 类模板分离编译大坑(重中之重)

问题描述

如果把模板声明放在.h头文件,实现放在.cpp源文件:

// a.htemplate<typenameT>TAdd(constT&l,constT&r);// a.cpptemplate<typenameT>TAdd(constT&l,constT&r){returnl+r;}// main.cpp#include"a.h"intmain(){Add(1,2);// 链接报错LNK2019}

报错根本原因

C++编译是分文件独立编译

  1. 编译a.cpp:文件内没有任何Add<int>调用,编译器不会实例化任何版本模板,不生成函数实体;
  2. 编译main.cpp:只看见函数声明,看不到模板完整实现,无法实例化代码;
  3. 链接阶段找不到Add<int>实体,报未解析外部符号。

两种标准解决方案

  1. 推荐方案:将模板声明+实现全部放入.hpp头文件,所有使用文件直接include,编译时完整看到模板定义,正常实例化;
  2. 不推荐:在.cpp手动提前实例化需要的类型,拓展性极差。

四、模板进阶:模板特化(解决特殊类型逻辑差异)

4.1 什么是特化?

基础模板提供通用逻辑,部分特殊类型需要单独定制实现,我们针对该类型重写一套模板逻辑,这个过程叫模板特化。
分为两类:函数模板特化、类模板特化(全特化、偏特化)。

4.2 函数模板全特化

场景:通用比较模板Less,普通内置类型正常比较,但比较模板对于自定义Date类,会直接比较地址,但是我们想的时是去比较哪个日期大,逻辑明显错误,所以要单独特化这种场景。

步骤

  1. 先存在基础通用函数模板;
  2. template<>空模板参数列表代表全特化;
  3. 函数名后<指定特化类型>
  4. 函数参数全部替换为目标类型。
//基础通用模板template<typenameT>boolLess(constT&left,constT&right){returnleft<right;}//Date类函数模板全特化template<>boolLess<Date>(constDate&left,constDate&right){//自定义日期比较逻辑,不再比较地址returnleft.GetYear()<right.GetYear()||(left.GetYear()==right.GetYear()&&left.GetMonth()<right.GetMonth())||(left.GetYear()==right.GetYear()&&left.GetMonth()==right.GetMonth()&&left.GetDay()<right.GetDay());}

调用规则:传入Date对象时,编译器优先匹配特化版本,不会使用通用模板。

4.3 类模板特化(全特化 + 偏特化)

1)全特化:所有模板参数全部固定

原模板:template<class T1, class T2> class Data;
全特化固定所有参数为int、char

template<>classData<int,char>{//专属int+char的单独实现};

2)偏特化:只固定一部分模板参数,剩余仍为通用

场景1:部分参数固定
template<typenameT1>classData<T1,int>{//第二个参数固定为int,第一个任意类型};
场景2:参数类型限制(指针/引用偏特化)
//仅针对指针类型偏特化template<typenameT>classData<T*,T*>{//专门处理指针的逻辑,比如解引用比较};
逻辑脉络

首先你通用模板不能写死解引用(普通类型会崩溃),所以单独写指针偏特化版本;只要实例化时传入指针,编译器自动切换专属逻辑,内部完成解引用比较,普通类型走通用逻辑直接对比原值,互不冲突。

#include<iostream>usingnamespacestd;template<classT1,classT2>classData{public:T1 _d1;T2 _d2;boolIsLess(){// 错误:不分类型全部解引用return*_d1<*_d2;}};intmain(){Data<int,int>d1(1,2);d1.IsLess();// 编译报错:int无法解引用return0;}
#include<iostream>usingnamespacestd;// 通用模板:处理普通非指针类型template<classT1,classT2>classData{public:T1 _d1;T2 _d2;// 直接比较原值,不解引用boolIsLess(){return_d1<_d2;}};// 指针偏特化:仅匹配两个同类型指针 T*, T*template<typenameT>classData<T*,T*>{private:T*_p1;T*_p2;public:Data(T*a,T*b):_p1(a),_p2(b){}// 内部封装解引用,对比真实存储数据boolIsLess(){return*_p1<*_p2;}};

模板版本适用类型是否内部解引用用户是否手动写*
通用基础模板int/double/string普通类型不解引用,直接操作原值不需要
指针偏特化Data<T*,T*>两个同类型指针内部封装*解引用完全不需要

4.4 特化实战意义:STL算法底层支撑

std::sort+vector<Date*>举例:
容器存日期指针时,通用模板只会对比指针地址,排序错乱;对指针类型偏特化后,算法会解引用对比真实日期,排序逻辑正常。


五、非类型模板参数

5.1 概念

模板参数不只是类型(typename T),还可以传入常量整数/字符等编译期常量,称为非类型模板参数。

template<size_t N>//N是编译期常量,非类型参数classArray{private:char_arr[N];//编译期固定大小数组};//调用Array<10>arr1;//N=10Array<100>arr2;//N=100

限制

  1. 只能是整型、字符常量;浮点数、自定义类不能作为非类型参数;
  2. 参数必须是编译期可确定常量,不能传变量。

六、模板优缺点总结

优点

  1. 极致代码复用:一套逻辑适配所有类型,消除重复重载;
  2. 提升代码灵活性,STL整套容器/算法基于模板搭建;
  3. 编译期多份类型代码,运行无性能损耗。

缺陷

  1. 代码膨胀(代码爆炸):每种实例化类型都会生成一份独立代码,可执行文件体积变大,编译时间变长;
  2. 报错晦涩难懂:模板推导失败时编译器报错极长,新手难以定位错误;
  3. 最重要的分离编译限制,必须头文件包含全部实现。

七、问题总结

  1. 函数模板隐式实例化为什么不能两边不同类型?
    答:编译器需要统一推导T,两个参数类型冲突,推导失败,需要显式实例化强制转换。(或者你去写两个模板参数typename T1,typename T2)
  2. 普通函数和同名模板同时存在,调用优先级?
    答:完全匹配普通函数时优先普通函数;无普通函数匹配才实例化模板。
  3. 类模板和函数模板实例化区别?
    答:函数模板支持隐式推导;类模板必须手动<>指定类型,无法自动推导。
  4. 模板分离编译报错原因与解决方案?
    答:分文件编译不会主动实例化模板,链接找不到实体;解决方案:.h头文件放完整实现(也就是不对类模板进行声明定义分离)或者在.cpp里对其手动实例化。(不推荐)
  5. 什么是模板特化?全特化和偏特化区别?
    答:针对特殊类型定制模板逻辑;全特化固定所有模板参数,偏特化仅固定部分参数。
  6. 模板代码膨胀是什么?
    答:每一种实例化类型都会生成独立函数/类代码,程序体积增大。

补充

模板是编译期特性,所有推导、实例化全部发生在编译阶段,运行时没有模板相关逻辑,看不懂的时候可以多看编译器生成的对应类型代码,理解模具生成逻辑。

← 返回列表