C++11,14,17中auto和decltype相关知识及拓展

前言

这篇文章是上篇文章《C++prvalue,xvalue和lvalue的相关知识》的续作,上次我们已经把prvalue,xvalue和lvalue说清楚了,本篇文章就来探讨一下prvalue,xvalue和lvalue与decltype之间的联系。顺便咱们也把auto类型说明符也都拓展一下。

从初始化器和表达式中推导( Deduction from Initializers and Expressions)

C++11包括声明一个类型是从其初始化器推导出变量类型的能力(auto)。它还提供了一种机制来表示已命名对象(变量或函数)或表达式的类型(decltype)。这些设施原来非常方便,而C++14和C++17在这个主题上增加了额外的变体。

auto类型说明符

编程时常常需要把表达式的值赋给变量,这就要求在声明变量的时候清楚地知道表达式的类型。c++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。

1
2
//由val1和val2相加的结果可以推断出item的类型
auto item = val1 + val2; // item初始化为val1和val2相加的结果
此处编译器将根据val1val2相加的结果来推断item的类型。如果val1val2是类sales_item的对象,则item的类型就是sales_item;如果这两个变量的类型是double,则item的类型就是double,以此类推。

复合类型,常量和auto

编译器推断出来的auto类型有时候和初始值的类型并不完全一样,编译器会适当地改变结果类型使其更符合初始化规则。

首先,正如我们所熟知的,使用引用其实是使用引用的对象,特别是当引用被用作初始值时,真正参与初始化的其实是引用对象的值。此时编译器以引用对象的类型作为auto的类型:

1
2
3
int i = 0,&r = i;
auto a = r;
// a的类型为int(r是i的别名,而i是一个整数)
其次,auto一般会忽略掉顶层const,同时底层const则会保留下来,比如当初始值是一个指向常量的指针时:
1
2
3
4
5
const int ci = i, &cr = ci;
auto b = ci;// b的类型为int(ci的顶层const 特性被忽略掉了)
auto c = cr;// c的类型为int( cr是ci的别名,ci本身是一个顶层const )
auto d = &i;// d是一个整型指针(int *)
auto e = &ci;// e是一个指向整数常量的指针(const int *)(对常量对象取完地址后,常量对象的顶层const在auto处转变为底层const)
如果希望推断出的auto类型是一个顶层const,需要明确指出:
1
const auto f = ci;		// ci的推演类型是int,f是const int
还可以将引用的类型设为auto,此时原来的初始化规则仍然适用:
1
2
3
auto &g = ci;		// g是一个整型常量引用,绑定到ci
auto &h = 42; //错误:不能为非常量引用绑定字面值(42的类型为int,左值引用不能绑定到右值)
const auto &j=42; //正确:可以为常量引用绑定字面值(42发生临时物化,产生一个xvalue的临时对象让常量引用绑定)
设置一个类型为auto的引用时,初始值中的顶层常量属性仍然保留。和往常一样,如果我们给初始值绑定一个引用(auto &g = ci;),则此时的const就不是顶层const了(g的类型为const int &,此处的const为底层const)。
要在一条语句中定义多个变量,切记,符号&*只从属于某个声明符,而非基本数据类型的一部分,因此初始值必须是同一种类型:
1
2
3
auto k = ci, &l = i;// k是int,l是int&
auto &m = ci,*p = &ci;// m是对整型常量的引用(const int &),p是指向整型常量的指针(const int *)。
auto &n= i, *p2 = &ci;//错误:i的基本数据类型是int而&ci的基本数据类型是const int
以上部分为auto类型说明符的基础部分,大部分例子为《c++primer》p61页的,我在其基础上添加了注释以及对其进行部分修改,以便读者能快速领悟其含义。其他基础部分若有不懂请自行补充,不再赘述。

进一步探讨auto类型说明符

auto类型说明符可用于许多地方(主要是namespace作用域和local作用域),以从其初始化器推导变量的类型。在这种情况下,auto被称为占位符类型(a placeholder type)(另一种占位符类型deltype(auto),文章后面将会描述)。例如,您可以使用:

1
2
3
4
5
6
7
8
9
10
template<typename Container> 
void useContainer(Container const& container)
{
auto pos = container.begin(); //auto示例一
while (pos != >container.end())
{
auto& element = *pos++; //auto示例二
// operate on the element
}
}
上面示例中auto的两种使用消除了编写两种长且可能复杂的类型,即容器的迭代器类型和迭代器的值类型:
1
2
3
typename Container::const_iterator pos = container.begin();

typename std::iterator_traits<typename Container::iterator>::reference element = *pos++;
自动推导使用与模板参数推导相同的机制。auto类型说明符可以被一个虚构的模板类型参数T取代,然后推导继续进行,就好像该变量是一个函数形参,它的初始化器相当于函数实参。
对于第一个auto示例,它对应于以下情况:
1
2
3
template<typename T> 
void deducePos(T pos);
deducePos(container.begin());
这里T看作auto,是要被推导出的类型。这样做的直接后果之一是,auto类型的变量永远不会是引用类型。
在第二个auto示例中使用auto&说明了如何生成一个引用类型的推导。其推导相当于以下函数模板和调用:
1
2
template<typename T> 	deduceElement(T& element);
deduceElement(*pos++);
在这里,element总是引用类型,并且它的初始化器不能生成一个临时对象。

auto与右值引用

也可以将auto与右值引用组合起来,但这样做使其行为像一个转发的引用(a forwarding reference),例如:
auto&& fr = …;
我们还是基于函数模板来看待它:

1
template<typename T> void f(T&& fr);// auto replaced by template parameter T
解释如下:
1
2
3
4
5
int x; 
auto&& rr = 42; // OK: rvalue reference binds to an rvalue
个(auto = int)
auto&& lr = x; // Also OK:reference collapsing makes. lr an lvalue reference
个(auto = int&)
这种技术经常用于代码中绑定函数或操作符调用的结果对象,且不知道结果对象的值类别(lvalue vs.rvarue),进而不必复制该结果对象。
例如,它通常是在基于范围的循环中声明迭代值的首选方法:
1
2
3
4
5
template<typename Container> 
void g(Container c)
{
for (auto&& x: c) { … }
}
这里我们不知道容器迭代接口的签名( the signatures of the container’s iteration interfaces),但是通过使用auto&&,我们可以确信我们正在遍历的值没有产生额外的副本。如果需要完美转发绑定值,则可以像往常一样在变量上调用std::forward<T>()。这使得一种“延迟”的完美转发成为可能。有关示例,请参见《c++ template 2nd》p167。
除了引用之外,还可以组合auto说明符来创建const对象、指针、成员指针等等,但auto必须声明成“main”类型说明符(基本数据类型)。它不能嵌套在模板参数中或跟在基本数据类型后面的声明部分中( part of the declarator that follows the type specifier)。具体请看下面的示例:
1
2
3
4
5
6
7
8
template<typename T> struct X { T const m; }; 
auto const N = 400u; // OK: constant of type

auto* gp = (void*)nullptr; // OK: gp has type void*

X<auto> xa = X<int>(); // ERROR: auto in template

int const auto::*pm2 = &X<int>::m; // ERROR: auto is >part of the “declarator”
最后两个例子不让通过的原因在于C++委员会认为,额外的实施成本和滥用潜力超过了好处 ☹️。

推导返回类型 c++14

C++14增加了另一种情况,其中可推导auto占位符类型可以出现在函数返回类型中。例如:

1
auto f() { return 42; }
定义一个返回类型为int(42)的函数。这也可以使用后置返回类型的语法来表示:
1
auto f() -> auto { return 42; }
在后一种情况下,第一个auto宣告有尾置返回类型,而第二个auto是要推导出的占位符类型。但是直接这么写会显得很冗长。
默认情况下,lambda也存在相同的机制:如果没有明确指定返回类型,则lambda的返回类型会像auto一样被推导出来:
1
2
auto lm = [] (int x) { return f(x); }; 
// same as:auto lm = [] (int x) -> auto { return f(x); };
函数可以单独声明。对于返回类型是被推导出来的函数也一样:
1
2
auto f(); // forward declaration 
auto f() { return 42; }
但是,在这样的情况下,forward declaration的用途非常有限,因为该定义必须在使用该函数的任何地方都可见。也许令人惊讶的是,提供带有“resolved”返回类型的forward declaration是无效的。例如:
1
2
int known(); 
auto known() { return 42; } //ERROR: incompatible return type
大多数情况下,提前声明一个具有推导返回类型的函数,只有在能够将成员函数定义移动到类定义之外时才有用:
1
2
3
4
struct S { 
auto f(); // the definition will follow the class definition
};
auto S::f() { return 42; }

可推导的非类型参数(Deducible Nontype Parameter)until c++17

在C++17之前,非类型模板参数必须用特定的类型来声明。但是,该类型可以是一个模板参数类型。例如:

1
2
template<typename T, T V> struct S; 
S<int, 42>* ps;
在本例中,必须指定非类型模板参数的类型,即在42之外指定int,可能非常繁琐。因此,C++17增加了声明非类型模板参数的能力,这些参数的实际类型是从相应的模板参数推导出来的。它们声明如下:
1
2
template<auto V> struct S;
S<42>* ps;//这样的话就简洁多了
这里S<42>的V类型被推断为int,因为42的类型为int。如果我们写的是S<42u>V的类型就会被推导为无符号int(《c++ template 2nd》p294)。
请注意,对非类型模板参数的类型的一般约束仍然有效。例如:
1
S<3.14>* pd;// ERROR: floating-point nontype argument
最后auto只在c++17中被允许用在模板参数中,c++20开始被移除,原因是弊大于利,滥用它会让程序变得更难读懂。

用decltype表示表达式的类型(Expressing the Type of an Expression with decltype)

虽然auto避免了写出变量的类型的需要,但它不容易允许人们使用该变量的类型(不能确定为某一指定类型)。deltype关键字解决了这个问题:它允许程序员表达表达式或声明的精确类型。但是,程序员应该小心decltype类型产生的细微差别,这取决于传递的参数是一个声明定义出的实体(eg:int a;//这里a属于被定义出的实体)还是一个表达式(eg:1+1;//这里1+1这个整体是一个表达式):
如果e是实体的名称(如变量、函数签名、枚举器或数据成员)或类成员访问过程,则delltype(e)生成该实体的声明类型或表示类成员的类型。因此,decltype类型可以用来检查变量的类型。
当希望精确匹配现有声明的类型时,这一点很有用。例如,请考虑以下变量y1y2

1
2
3
auto x = …; 
auto y1 = x + 1;
decltype(x) y2 = x + 1;
根据x的初始化器,y1可能具有也可能不具有与x相同的类型:它取决于+的行为。如果x被推导出为inty1也会是int。如果x被推导为char,那么y1将是int,因为char1的和是int。在y2类型中使用deltype(x)确保它始终具有与x相同的类型。

prvalue,xvalue和lvalue与decltype的关系:

  • 如果e是任何其他表达式,则deltype(e)将生成一个反映该表达式的类型(type)和值类别(value category),如下所示:
    If e is an lvalue of type T, decltype(e) produces T&. If e is an xvalue of type T, decltype(e) produces T&&. If e is a prvalue of type T, decltype(e) produces T. 参考《c++ template 2nd》的附录B可以了解更多value category的知识。

这种细微差别可以通过以下例子来证明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void g (std::string&& s)
{
// check the type of s:
std::is_lvalue_reference<decltype(s)>::value; // false
std::is_rvalue_reference<decltype(s)>::value; // true (s as declared)
std::is_same<decltype(s),std::string&>::value; // false
std::is_same<decltype(s),std::string&&>::value; // true

// check the value category of s used as expression:
std::is_lvalue_reference<decltype((s))>::value; // true (s is an lvalue)
std::is_rvalue_reference<decltype((s))>::value; // false
std::is_same<decltype((s)),std::string&>::value; // true (T& signals an lvalue)
std::is_same<decltype((s)),std::string&&>::value; // false
}
在前四个表达式中,为变量s调用delctype:
1
decltype(s) //declared type of entity e designated by s
这意味着deltype产生了s的声明类型std::sting&&。在最后四个表达式中,decltype类型构造的操作数不仅仅是一个名称,因为在每种情况下,该表达式都是(s),它是一个用括号表示的名称。在这种情况下,该类型将反映(s)的值类别:
1
decltype((s)) //check the value category of (s)
我们的表达式指的是一个变量,因此是一个lvalue:根据上面的规则,这意味着decltype((s))是对std::string的普通引用(lvalue reference)。这是C++中为数不多的用括号表示表达式会改变程序含义的地方之一,而不是影响操作符的关联性。
decltype类型计算任意表达式e的类型,这一事实在不同的地方都有帮助。具体来说,deltype(e)保留了关于表达式的足够信息,可以使它描述返回表达式e本身的函数的返回类型:
deltype计算该表达式的类型,但它也将表达式的值类别传播给函数的调用者。例如,考虑一个简单的转发函数g(),它返回调用f()的结果:
1
2
3
4
??? f(); 
decltype(f()) g() {
return f();
}
g()的返回类型取决于f()的返回类型。如果f()返回int&,那么计算g()的返回类型将首先确定表达式f()具有类型int。这个表达式是一个lvalue,因为f()返回一个左值引用,因此声明的返回类型变为int&。类似地,如果f()的返回类型是右值引用类型,则调用f()将是xvalue,decltype将生成与f()返回的类型完全匹配的右值引用类型。本质上,这种形式的decltype类型采用了任意表达式的主要特征——它的类型和值类别——并以一种能够完美转发返回值的方式在类型系统中对它们进行编码。
当 value-producing auto的推导不足时,decltype也可能很有用。例如,假设我们有一个未知迭代器类型的变量pos,并且我们希望创建一个变量元素来引用pos存储的元素。我们可以使用:
1
2
auto element = *pos;//这将始终有元素的副本产生。
auto& element = *pos;//引用pos存储的元素!
我们将总是会收到一个对元素的引用,但是如果迭代器的操作符 *返回一个值,那么程序将会失败。为了解决这个问题,我们可以使用decltype类型,以保持迭代器的运算符*的值或引用性:
1
decltype(*pos) element = *pos;
是使用内置类型解引用pos使用引用(*pos表达式的结果为左值,则decltype推断出来的类型为左值引用!),是迭代器的操作符*返回一个值时复制值。它的主要缺陷是它需要将初始化器表达式写入两次:一次在decltype中(不计算它),另一次在实际的初始化器中。C++14引入了decltype(auto)来解决这个问题,我们接下来将讨论这个问题。

decltype(auto) c++14

C++14增加了一个功能,它是auto和decltype的组合:decltype(auto)。与auto类型说明符一样,它是一个占位符类型,并且变量、返回类型或模板参数的类型是由相关表达式的类型(initializer, return value, or template argument)确定的。但是,与仅使用auto不同,它使用模板参数推断的规则来确定感兴趣的类型,实际的类型是通过将decltype直接应用于表达式来确定的。举例说明了这一点:

1
2
3
4
int i = 42; // i has type int
int const& ref = i; // ref has type int const& and refers to i
auto x = ref; // x has type int and is a new independent object
decltype(auto) y = ref; // y has type int const& and also refers to i
y的类型是通过将decltype应用到初始化器表达式中得到的,这里是ref,它是int const&。相比之下,auto类型推导的规则产生的则是类型int
另一个示例显示了索引std::vector(索引出来的是一个lvalue《c++primer》p121)时的差异:
1
2
3
std::vector<int> v = { 42 };
auto x = v[0]; // x denotes a new object of type int
decltype(auto) y = v[0]; // y is a reference (type int&)(Because [ ] operator produces an lvalue)
这很好地解决了前面示例中的冗余写法:
1
2
decltype(*pos) element = *pos;//Redundant writing
decltype(auto) element = *pos;//which can now be rewritten as
它通常用在返回类型上。参考以下示例:
1
2
3
4
5
6
7
8
template<typename C> class Adapt
{
C container;

decltype(auto) operator[] (std::size_t idx) {
return container[idx];
}
};
如果container[idx]生成一个lvalue,我们希望将该左值传递给调用者(他可能希望获取其地址或修改它):这需要一个左值引用类型,这正是decltype(auto)解析到的类型。如果生成出来的是prvalue(内置下表运算符只可能产生lvaluexvalue,这里说的是如果,原因在《C++prvalue,xvalue和lvalue的相关知识》中已阐述),则引用类型将导致悬空引用(dangling references),但幸运的是,deltype(auto)将生成这种情况下的对象类型(而不是引用类型)。

在递归模板中延迟返回类型推断(Delaying return type deduction in recursive templates

当模板的返回类型被指定为decltype(iter(Int<i-1>{}))而不是decltype(auto)时,在模板实例化过程中会遇到无限递归。

1
2
3
4
5
6
7
8
9
10
11
12
template<int i> 
struct Int {};

constexpr auto iter(Int<0>) -> Int<0>;//递归模板结束地方的声明

template<int i>//非类型参数(Nontype Parameter)《c++primer》p580
constexpr auto iter(Int<i>) -> decltype(auto) {
return iter(Int<i-1>{});
}
int main(){
decltype(iter(Int<10>{})) a;
}
这里使用decltype(auto)来延迟返回类型模板实例化的推断,从而解决返回类型实例化过早引发无限递归的问题。

与auto不同,delltype(auto)不允许修改其类型的说明符或声明符操作符。例如:

1
2
3
decltype(auto)* p = (void*)nullptr; // invalid
int const N = 100;
decltype(auto) const NN = N*N; // invalid
还要注意,初始化器中的圆括号可能很重要:
1
2
3
int x;
decltype(auto) z = x; // object of type int
decltype(auto) r = (x); // reference of type int&
这尤其意味着括号可能会严重影响return statements的有效性:
1
2
3
4
5
6
int g();

decltype(auto) f() {
int r = g();
return (r); // run-time ERROR: returns reference to temporary
}
until C++17,decltype(auto)也可以用于可推导的非类型参数,由于后面c++20将其剔除,所以这里就不继续深入探讨了,原因是一样,弊大于利,滥用将导致程序难以理解。

最后问大家一个小问题,auto会不会计算出表达式结果的值?,decltype会不会计算出表达式结果的值?,decltype(auto)呢?相信大家看完本篇文章后,这些问题就会不攻自破了 🙂 。如果本篇文章帮您解决了理论上难以理解的问题,记得点个赞哦!