【C++】详解模板类型推导

文章目录

引言

如果说一个复杂系统的用户对于该系统的运作方式一无所知,然而却对其提供的服务表示相当满意,这就充分说明系统设计的好。从这样的角度来看,C++的模板类型推导毫无疑问取得了巨大的成功:成百上千的程序员都在向函数模板传递实参,并拿到了完全满意的结果,而其中鲜有人能够将类型推导的过程阐述出来。

模板类型推导

模板的类型推导是现代C++中auto的基础,如果你能接受模板推导的运作方式,那么自然也能愉快接受auto的运作方式,但是当模板类型推导规则作用于auto语境时,却不像作用于模板时那样符合直觉。

我们常见的函数模板如下:

c++ 复制代码
template<typename T>
void f(ParamType param){
   ...
}

调用形式形如:

c++ 复制代码
f(expr);

在编译期间,编译器会通过expr推导两个类型:一个是T的类型,另一个是ParamType的类型,这两个型别往往不一样,这通常是由于ParamType常常会包含一些修饰词所致,如:

c++ 复制代码
template<typename T>
void f(const T& param){
   ...
}

int x = 0;
f(x);//在此例中,T被推导成int,ParamType被推导成const int&

于是,我们会很自然的认为:T的推导结果和传递给函数的实参的类型是一致的。

在上例中,确实如此:x的类型是int,T的类型推导结果也是int。

但是这一点并不总是成立。实际上,T的推导结果不仅仅依赖expr的类型,还依赖ParamType的形式。具体要分三种情况讨论:

  • ParamType是个指针或引用类型,但不是个万能引用。
  • ParamType是一个万能引用。
  • ParamType既非指针也非引用。

接下来分别对这三种场景进行讨论。

场景1:ParamType是个指针或引用类型,但不是个万能引用

这是最简单的场景,在这种情况下,类型推导的实际运作过程如下:

  1. 若expr具有引用类型,则先将引用部分省略。
  2. 对expr的类型和ParamType的类型执行模式匹配从而决定T的类型。

例如:

c++ 复制代码
//函数模板如下:
template<typename T>
void f(T& param){
...
}
//接下来声明了如下变量
int x = 27;
const int cx  = x;
const int& rx = x;
//最后进行调用
f(x); //T的类型是int,param的类型是int&
f(cx); //T的类型是const int,param的类型是const int&
f(rx); //T的类型是const int,param的类型是const int&

在第二次和第三次调用过程中,由于cx和rx的值都被指明为const,所以T也被推导为const int,相对应的,形参的类型就成了const int &。这也是为何向持有T&类型的模板传入const 对象是安全的原因:该对象的常量性会成为T的型别推导结果的组成部分。

在第三次调用中,rx持有引用类型,但T并未被推导成一个引用,原因在于:rx的引用性会在类型推导的第一步被忽略。

尽管上述调用语句示例演示的都是左指引用形参,但是右值引用形参的类型推导运作方式与此完全相同。

而如果此时我们将形参中的T&修改为const T&,情况会有一些变化:由于parm已经具有const引用类型,因此T的推导结果中自然就不必包含const了。

如果parm是个指针或指向const对象的指针,而非引用,运作方式本质上并无不同。

场景2:ParamType是个万能引用

对于持有万能引用形参的模板而言,情况略微有些复杂,当传入的实参是左值时,其表现会有所不同,具体运作过程如下:

  • 如果expr是个左值,T和ParamType都会被推导为左值引用。这个结果具有双重的奇特之处:首先,这是在模板类型推导中,T被推导为引用类型的唯一情形。其次,尽管在声明时使用的是右值引用语法,它的类型推导结果却是左值引用。
  • 如果expr是个右值,则应用场景1中的规则。

例如:

c++ 复制代码
//函数模板如下:
template<typename T>
void f(T&& param){
...
}
//接下来声明了如下变量
int x = 27;
const int cx  = x;
const int& rx = x;
//最后进行调用
f(x); //x是个左值,所以T的类型是int&,param的类型也是int&
f(cx); //cx是个左值,所以T的类型是const int&,param的类型也是const int&
f(rx); //rx是个左值,所以T的类型是const int&,param的类型也是const int&
f(27); //27是个右值,所以T的类型是int,param的类型就成了int&&

场景3:ParamType既非指针也非引用

当ParamType既非指针也非引用时,我们面对的就是所谓的按值传递了:这就意味着无论传入的是什么,param都会是它的一个副本,也即一个全新的对象。"param会是个全新对象"这一事实促成了如何从expr推导出T的型别的规则:

  1. 若expr是引用类型,则忽略其引用部分。
  2. 若expr是个const/volatile对象,也忽略其const/volatile部分。
c++ 复制代码
//函数模板如下:
template<typename T>
void f(T param){
...
}
//接下来声明了如下变量
int x = 27;
const int cx  = x;
const int& rx = x;
//最后进行调用
f(x); //T和param的类型都是int
f(cx); //T和param的类型还都是int
f(rx); //T和param的类型仍都是int

请注意,即使cx和rx代表const值,param仍然不具有const类型。这是合理的。param是个完全独立于cx和rx存在的对象------是cx和rx的一个副本。从而cx和rx不可修改这一事实并不能说明param是否可以修改。正是由于这一原因,expr的const性和volatile性可以在推导param的型别时加以忽略:仅仅由于expr不可修改,并不能断定其副本也不可修改。

需要重点说明的是,const/volatile仅会在按值形参处被忽略。正如场景1中所见,若形参是const的引用或指针,expr的常量性会在类型推导过程中加以保留。但是,考虑到这种情况:当expr是个指向const对象的const指针,并且是按值传递时:

c++ 复制代码
template<typename T>
void f(T param);

const char a = 10;
const char* const ptr = &a;

f(ptr)

这里建议你先了解一下顶层const与底层const

*号右边的const指明这是个顶层const,即指针本身的指向不可以改变。函数调用时这个指针会按比特复制给param。换言之,ptr这个指针自己会按值传递。

依照按值传递形参的类型推导规则,此时ptr的const性会被忽略,param被推导成const char*,即一个指向可以改变的,指向一个const字符串的指针(或者说顶层const在值传递的过程中被消除),在推导过程中,ptr指向对象的常量性会被保留,但自身的常量性被忽略。

数组形参

以上已经基本讨论完模板推导的主流情况,但还有一个边缘情况值得了解。这种情况就是:数组类型有别于指针类型,尽管有时他们看起来可以互换。形成这种假象的主要原因是,在很多种语境下,数组会退化成指向首元素的指针。

那么如果是数组作为实参进行传递呢?

C++ 复制代码
const char name[] = "zjshhh";

template<typename T>
f(T param){
...
}

f(name);

既然数组声明可以按照指针声明方式进行处理,那就意味着这里会发生按值传递,将数组类型传入进去之后会被推导成指针类型,也就是说此处T会被推导成const char *;

难点来了。尽管函数无法声明真正的数组型别的形参,但却能将形参声明为数组的引用!所以,如果我们将函数模板改成按引用传递:

c++ 复制代码
const char name[] = "zjshhh";

template<typename T>
f(T& param){
...
}

f(name);

在这种情况下,T的类型会被推导成实际的数组类型!在此例中T被推导成const char [6],而形参则为const char (&)[13]。

没错,这种语法看起来又臭又长,但是如果了解到这个程度却会让你在面对极少数较真的人时,挣得好大一个面子。

我们可以利用这一能力做一些有意思的事情。比如,写一个函数用来推导数组含有元素的个数:

c++ 复制代码
template<typename T,std::size_t N>
constexpr std::size_t arraySize(T (&)[N] )noexcept
{
  return N;
}

有点像模板元编程。

函数实参

数组并非C++中唯一可退化成指针之物。函数类型也会退化成函数指针。并且我们针对数组类型推导所做的一切讨论都适合于函数及其向函数指针的退化。例如:

c++ 复制代码
void someFunc(int , double);

template<typename T>
void f1(T param);

template<typename T>
void f2(T& param);

f1(someFunc);//param被推导成函数指针,具体的型别是void(*)(int,double)
f2(someFunc);//param被推导成函数引用,具体的型别是void(&)(int,double)

在实践中,这些推导结果和前面的那些并没有什么不同。不过,既然你打算了解数组向指针的退化,那么就顺便也了解一下函数向指针的退化好了。

相关推荐
捕鲸叉3 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer3 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
Peter_chq3 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
青花瓷5 小时前
C++__XCode工程中Debug版本库向Release版本库的切换
c++·xcode
幺零九零零6 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
捕鲸叉6 小时前
MVC(Model-View-Controller)模式概述
开发语言·c++·设计模式
Dola_Pan7 小时前
C++算法和竞赛:哈希算法、动态规划DP算法、贪心算法、博弈算法
c++·算法·哈希算法
yanlou2337 小时前
KMP算法,next数组详解(c++)
开发语言·c++·kmp算法
小林熬夜学编程7 小时前
【Linux系统编程】第四十一弹---线程深度解析:从地址空间到多线程实践
linux·c语言·开发语言·c++·算法
阿洵Rain8 小时前
【C++】哈希
数据结构·c++·算法·list·哈希算法