MordernC++之 auto 和 decltype

在C++11标准中,auto作为关键字被引入,可以用来自动推导变量类型,auto可以用于定义变量,函数返回值,lambda表达式等,在定义变量时可以使用auto来代替具体类型,编译器根据变量初始化表达式推导出变量的类型。同时还引入了decltype关键字,用于推导表达式的类型,decltype与auto的不同之处在于,decltype关键字推导出的类型和表达式的类型完全一致,包括const与引用等信息。decltype关键字可以用于推导变量、表达式和函数返回值的类型。在推导变量类型时,可以使用decltype关键字加上变量名或表达式作为参数,编译器会根据变量或表达式的类型推导出变量的类型。

模板类型推导

C++中的auto是建立在模板类型推导的基础之上,如下所示一个函数模板:

template<typename T>
void func(ParemType param);

其调用方式如下:

func(expr);

编译器在编译的时候会根据调用的表达式进行推导,主要推导两个部分:第一部分是T的类型,第二个是ParamType的类型。一般情况下,这两个类型是不同的,因为ParamType不仅包含了T,还包括了const和引用等修饰符,一个简单的例子如下:

template<typename T>
void func(const T& param);

然后进行如下的函数调用:

int a = 0
func(a);

上述的调用中,T会被推导成为int,而ParamType被推导成为const int&,这两个类型是不一样的。在推导的时候,我们期望推导出来的T的类型和传入的参数的类型是一致的,即a是int型,推导出来的T就为int,这在大部分情况下是成立的,但是实际上T的类型推导不仅仅取决于expr的类型,还取决于ParamType的类型。可以总结为以下三种情形:

  1. ParamType 是一个指针或者引用(&),但是不是通用引用(&&)
  2. ParamType是一个通用引用(&&)
  3. ParamType既不是指针,也不是引用(&)或者通用引用(&&)

1. ParamType是一个指针或者引用(&),但是不是通用引用(&&)

如果ParamType是一个指针或者引用(&),但是不是通用引用(&&),这种情形如以下的模板:

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

则推导的规则是:

  • 如果expr是一个引用,则忽略其引用部分,然后用剩下的部分来推导T,然后再推导得到ParamType

用以下的一些变量来进行测试:

int a = 0;		// a为int
const int b = a;	// b为const int
const int& c = b;	// c为const int&

func(a);		// T推导为int,ParamType推导为int&
func(b);		// T推导为const int, ParamType推导为const int&
func(c);		// T推导为const int, ParamType推导为const int&

可以看到b和c的推导结果是一样的,c的引用被忽略了,这是因为传入函数的参数已经是引用形式,因此c在传入时真正引用的是b,因此忽略了c的引用,因为引用的引用还是引用。b和c的T被推导为const int, ParamType推导为const int&,因此当传入一个const对象给一个引用类型的参数时,const属性被保留,因此是安全的。

2. ParamType是一个通用引用(&&)

通用引用即右值引用,对应的函数模板如下:

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

对于通用引用的推导规则为:

  • 如果expr是左值,则T和ParamType都会被推导为左值引用,即使ParamType对应的是右值引用
  • 如果expr是右值,则按照情形1推导

具体的测试如下:

int a = 0;		// a为int
const int b = a;	// b为const int
const int& c = b;	// c为const int&

func(a);		// a为左值,T推导为int&,ParamType推导为int&
func(b);		// b为左值,T推导为const int&, ParamType推导为const int&
func(c);		// c为左值,T推导为const int&, ParamType推导为const int&
func(5);		// 5为右值,T推导为int,ParamType推导为int&&

关于通用引用与右值引用的内容会在其他文章进行详细介绍。

3. ParamType既不是指针,也不是引用(&)或者通用引用(&&)

如果一个参数既不是指针,也不是引用或者通用引用,则是通过传值进行处理,对应的函数模板为:

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

这种情形无论传入的param是什么,都会进行拷贝,由于是拷贝的新对象,因此会影响推导的规则,具体的如下:

  • 如果expr是引用,则忽略引用。如果是const,则再忽略const,如果是volatile,也会忽略volatile

具体的测试如下:

int a = 0;		// a为int
const int b = a;	// b为const int
const int& c = b;	// c为const int&

func(a);		// T推导为int, ParamType推导为int
func(b);		// T推导为int, ParamType推导为int
func(c);		// T推导为int, ParamType推导为int

可以看到即使b和c的const属性,c的引用属性都被忽略了。这是因为,在函数中按值进行传参,那么param是一个独立且完整的对象,原始的b和c等变量const不可修改,并不代表param对象不可修改,volatile属性被忽略也是一样的原因。但是有一种情况需要特别注意,那就是传入的参数是const指针类型,考虑以下的调用示例:

const char* const ptr = "Hello World";

func(str);		// T被推导为const char*,ParamType被推导为const char*

这种情形下T和paramType都被推导成为了const char *,具体的原因是:这里解引用符号右边的const表示ptr指针本身是一个const,无法修改其指向其他地址,解引用符号左边的const表示指针ptr指向的对象是const,无法通过ptr对其指向的对象进行修改。当ptr当作实参传递给函数的时候,首先会拷贝一个ptr,叫做copyPtr,copyPtr的类型是const char*,由于是新的指针,因此指针本身的const属性被忽略了,但是其指向的对象仍然是const无法修改。因此最终推导的结果T为const char*。

顶层const与底层const
在C++中,const可以分为底层const和顶层const两种类型,底层const指的是const修饰的变量或指针所指向的对象是常量,即该对象的值不能被修改。底层const可以用于任何类型,包括基本数据类型和自定义类型。顶层const指的是变量或指针本身是常量,即该变量或指针不能被赋值。顶层const只能用于指针类型.对于指针类型,底层const和顶层const可以同时存在,如上述ptr指针。

* 数组实参

在C++中,有些情形下数组和指针是完全等价的,常见的是在上下文数组会退化为指向第一个元素的指针,如:

const char str[] = "Hello World";
const char* ptrToStr = str;

这里通过str来初始化ptrToStr指针类型,其中str的类型为const char[12],ptrToStr的类型是const char*,这两种是不一样的类型,但是由于数组会退化成为指针,因此可以进行上述的赋值操作。这种退化会给模板推导带来影响。如非引用模板

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

进行如下调用

func(name);		// T被推导成为const char*

上述的调用过程中,当数组传入的时候会被当作指针来处理,最终会推导成为一个指针类型,也就是T被推导成为了const char*,即使str本身是一个数组。那么对于数组来说,如何才能真正的推导为正确的数组类型呢,答案是采用引用参数模板:

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

进行如下调用

func(name);		// T被推导成为const char[12]

当模板参数为引用时,T被正确的推导成为了数组const char[12],还包括了数组的大小等信息,paramType被推导成为了const char(&)[12].通过以下的模板我们可以从模板函数中推导出数组的大小:

template<typename T,std::size_t N>
constexpr std:size_t arraySize(T (&)[N]) noexcept
{
	return N;
}

* 函数实参

在C++中除了数组会退化为指针,函数类型也会退化为一个函数指针。具体的情形如下:

void function(int,float);

template<typename T>
void func1(T param);		// 传值

template<typename T>
void func2(T& param);		// 引用

func1(function);		// T被推导为指向函数的指针,类型为void(*)(int,float)
func2(function);		// T被推导为指向函数的引用,类型为void(&)(int,float)

auto类型推导

上述关于模板类型推导的内容,已经涵盖了大部分auto类型推导的内容。auto类型推导与模板类型推导有一个直接的映射关系。如下所示,为一个标准的函数模板:

// 定义
template<typename T>
void func(ParamType param);

// 调用
func(expr);

编译器通过函数调用的expr来对T和ParamType进行推导,那么映射到auto时,当一个变量使用auto进行声明的时候,auto扮演了模板的角色,变量的类型说明符扮演了ParamType的角色,考虑以下的代码:

auto a = 0;		// a的类型说明符是auto 
const auto b = a;	// b的类型说明符是const auto
const auto& c = b;	// c的类型说明符是const auto&

编译器在进行上述推导时,看起来像是每个声明都对应一个模板,然后用初始的赋值作为表达式进行推导:

// 用于推导a的模板
template<typename T>
void func_a(T param);
// 对a进行推导
func_a(0);

// 用于推导b的模板
template<typename T>
void func_b(const T param);
// 对a进行推导
func_b(a);

// 用于推导c的模板
template<typename T>
void func_c(const T& param);
// 对a进行推导
func_c(b);

从上述的映射可以看出,在使用auto作为类型说明符的变量声明中,类型说明符代替了ParamType,auto可以看到模板类型T,则上述模板推导的规则仍然可以适用于auto类型推导。同时,数组和函数会退化为指针同样也适用于auto类型推导。

const char strs[] = "Hello World";

auto str1 = strs;	// auto被推导为const char*
auto& str2 = strs;	// auto被推导为const char[12],str2类型为const char(&)[12]

void function(int,float);

auto func1 = function;	//auto被推导为void(*)(int,float)
auto& func2 = function;	//auto 被推导为void()(int,float),func2为void(&)(int,float)

但是并不是所有的情况下,auto和模板推导的结果都是一样,一个例外就是auto对统一初始化的推导。
C++ 11支持统一初始化(uniform initialization):

int a0 = 5;	// C++98
int a1(5);	// C++98 
int a2 = {5};	//C++11统一初始化
int a3{5};	//C++11统一初始化

上述的变量赋值之后结果都为5,类型为int。但是当把类型说明符使用auto代替时,就会出现不同的结果:

auto a0 = 5;	// auto被推导为int,a0值为5
auto a1(5);	// auto 被推导为int,a1值为5
auto a2 = {5};	// auto被推导为 std::initializer_list<int>,a2的值为{27}
auto a3{5};	// auto被推导为 std::initializer_list<int>,a3的值为{27}

但是如果花括号内包含的是不同类型的变量,则不符合std::initializer_list的要求,那么编译器便无法进行推导。

auto a4 = {1,2, 3.0};	//错误!无法进行auto推导

因此,auto类型推导和模板类型推导的真正区别在于:auto类型推导会对花括号推导出std::initializer_list,而模板类型推导无法对花括号进行推导

* 优先考虑使用auto而不是显示类型声明

在使用现代c++编程时,优先采用auto来进行变量类型的推导,一些局部未初始化变量可能会导致程序未知的行为,使用auto定义的变量必须进行初始化操作,因此可以规避风险。同时可以避免一些移植性和效率性的问题,可以更加方便的重构,同时还能少打一些字。在使用的同时,需要对于auto所推导的类型了解清楚,避免出现上述推导类型与期望类型不匹配的问题。

decltype类型推导

decltype和auto的作用相似,通过传入一个名字或者表达式即可以得到该名字或者表达式的类型,通常decltype可以得到比auto更为精确的结果。其使用方法如下:

const int a = 0;	// decltype(a)为const int
bool func(const A& a);	// decltype(a)为const A&,decltype(func)为bool(const A&)
std::vector<int> vec;	// decltype(vec)为std::vector<int>, decltype(vec[0])为int&

decltype在C++ 11中最主要的用途是 函数模板的返回类型,且返回类型依赖形参。以STL中的容器为例,一个函数有两个参数,一个参数是容器,另一个参数是索引,此函数可以通过[]来访问容器中指定索引的值,然后返回该值。因此,函数的返回类型应该和该[]索引获取的值的类型相同。对于T类型的容器,使用operator[]通常会返回一个T&类型的对象,比如std::queue。但是对于std::vector,operator[]并不会返回bool&,而且返回std::Vb_refrence<std::Wrap_alloc<std::allocator>>,这说明operator[]具体返回什么类型取决于容器本身。

如果想要函数针对所有容器都返回正确的类型,则可以采用decltype推导返回值类型,如下:

template<typename Container,typename Index>
auto GetContainerIndex(Container& c,Index i) -> decltype(c[i])
{
	return c[i];
}

这里需要尾置返回类型语法,这是因为对于GetContainerIndex函数中,返回类型的推导依赖于c和i,如果放到前置,则c和i就是未声明变量,无法使用。在尾置返回类型中,根据c[i]的类型进行推导,就可以得到正确的返回值类型。
上述的用法是在C++11中,在C++14中,我们可以直接忽略尾置的decltype推导,直接使用

template<typename Container,typename Index>
auto GetContainerIndex(Container& c,Index i) // C++ 14
{
	return c[i];
}

此时auto不是进行auto类型推导,而是表示编译器会从函数的实现中自动推导函数的返回类型。但是前述中提到过,auto类型推导会忽略表达式参数的引用属性,即operator[]的返回类型是T&,但是auto推导的结果是T,因此下面的代码就会出现编译错误:

std::queue<int> queue;
GetContainerIndex(queue,5) = 5; // 编译错误

在上述代码中,GetContainerIndex(queue,5)应该返回一个int&,但是auto类型推导忽略了引用部分,返回类型变成了int,因此返回值是一个右值,给一个右值进行赋值在C++11是无法通过编译的。C++14中采用decltype(auto)来解决这个问题。

template<typename Container,typename Index>
decltype(auto) GetContainerIndex(Container& c,Index i) // C++ 14
{
    return c[i];
}

这里的decltype(auto)可以这么理解:auto表示类型会被自动推导,而decltype表示decltype的规则会作用在此推导过程中。这样函数就会真正的返回int&了。

decltype(auto)不仅仅可以使用在函数返回类型上,也可以使用在变量的初始化上,这样会在auto的变量初始化应用decltype的规则。如下:

int a = 0;
const int& b = a;
auto c = b;		// c忽略了b的引用属性,推导为int
decltype(auto) d = b;	// d的推导相当于decltype(b),d被推导为const int&

在通常的使用场景中,decltype都会推导出期望的类型结果,但是依然存在一些特殊情况:

int a = 0;
decltype(a);	// 推导结果为int
decltype((a));	// 推导结果为int&

通过一个小括号即可以改变decltype推导的结果。这种特殊的情况只需要了解即可。

总结

本文主要介绍了C++11中的auto和decltype关键字,介绍了auto和decltype背后的类型推导的思想,auto在不同的情况下会出现不同的推导行为,而decltype相比auto而言,可以得到更加精确的推导结果。
本文主要来自于《EffectiveMordernC++》。

posted @ 2023-04-04 22:10  zutterhao  阅读(63)  评论(0编辑  收藏  举报