跳转至

第1章 类型推导

CHAPTER 1 Deducing Types

C++98有一套类型推导的规则:用于函数模板的规则。C++11修改了其中的一些规则并增加了两套规则,一套用于 auto,一套用于 decltype。C++14扩展了 autodecltype可能使用的范围。类型推导的广泛应用,让你从拼写那些或明显或冗杂的类型名的暴行中脱离出来。它让C++程序更具适应性,因为在源代码某处修改类型会通过类型推导自动传播到其它地方。但是类型推导也会让代码更复杂,因为由编译器进行的类型推导并不总是如我们期望的那样进行。

如果对于类型推导操作没有一个扎实的理解,要想写出有现代感的C++程序是不可能的。类型推导随处可见:在函数模板调用中,在大多数 auto出现的地方,在 decltype表达式出现的地方,以及C++14中令人费解的应用 decltype(auto)的地方。

这一章是每个C++程序员都应该掌握的知识。它解释了模板类型推导是如何工作的,auto是如何依赖类型推导的,以及 decltype是如何按照它自己那套独特的规则工作的。它甚至解释了你该如何强制编译器使类型推导的结果可视,这能让你确认编译器的类型推导是否按照你期望的那样进行。

条款一:理解模板类型推导

Item 1: Understand template type deduction

对于一个复杂系统的用户来说,很多时候他们最关心的是它做了什么而不是它怎么做的。在这一点上,C++中的模板类型推导表现得非常出色。数百万的程序员只需要向模板函数传递实参,就能通过编译器的类型推导获得令人满意的结果,尽管他们中的大多数在被逼无奈的情况下,对于传递给函数的那些实参是如何引导编译器进行类型推导的,也只能给出非常模糊的描述。

如果那些人中包括你,我有一个好消息和一个坏消息。好消息是现在C++最重要最吸引人的特性 auto是建立在模板类型推导的基础上的。如果你满意C++98的模板类型推导,那么你也会满意C++11的 auto类型推导。坏消息是当模板类型推导规则应用于 auto环境时,有时不如应用于template时那么直观。由于这个原因,真正理解 auto基于的模板类型推导的方方面面非常重要。这项条款便包含了你需要知道的东西。

如果你不介意浏览少许伪代码,我们可以考虑像这样一个函数模板:

template<typename T>
void f(ParamType param);

它的调用看起来像这样

f(expr);                        //使用表达式调用f

在编译期间,编译器使用 expr进行两个类型推导:一个是针对 T的,另一个是针对 ParamType的。这两个类型通常是不同的,因为 ParamType包含一些修饰,比如 const和引用修饰符。举个例子,如果模板这样声明:

template<typename T>
void f(const T& param);         //ParamType是const T&

然后这样进行调用

int x = 0;
f(x);                           //用一个int类型的变量调用f

T被推导为 intParamType却被推导为 const int&

我们可能很自然的期望 T和传递进函数的实参是相同的类型,也就是,Texpr的类型。在上面的例子中,事实就是那样:xintT被推导为 int。但有时情况并非总是如此,T的类型推导不仅取决于 expr的类型,也取决于 ParamType的类型。这里有三种情况:

  • ParamType是一个指针或引用,但不是通用引用(关于通用引用请参见Item24。在这里你只需要知道它存在,而且不同于左值引用和右值引用)
  • ParamType是一个通用引用
  • ParamType既不是指针也不是引用

我们下面将分成三个情景来讨论这三种情况,每个情景的都基于我们之前给出的模板:

template<typename T>
void f(ParamType param);

f(expr);                        //从expr中推导T和ParamType

情景一:ParamType是一个指针或引用,但不是通用引用

最简单的情况是 ParamType是一个指针或者引用,但非通用引用。在这种情况下,类型推导会这样进行:

  1. 如果 expr的类型是一个引用,忽略引用部分
  2. 然后 expr的类型与 ParamType进行模式匹配来决定 T

举个例子,如果这是我们的模板,

template<typename T>
void f(T& param);               //param是一个引用

我们声明这些变量,

int x=27;                       //x是int
const int cx=x;                 //cx是const int
const int& rx=x;                //rx是指向作为const int的x的引用

在不同的调用中,对 paramT推导的类型会是这样:

f(x);                           //T是int,param的类型是int&
f(cx);                          //T是const int,param的类型是const int&
f(rx);                          //T是const int,param的类型是const int&

在第二个和第三个调用中,注意因为 cxrx被指定为 const值,所以 T被推导为 const int,从而产生了 const int&的形参类型。这对于调用者来说很重要。当他们传递一个 const对象给一个引用类型的形参时,他们期望对象保持不可改变性,也就是说,形参是reference-to-const的。这也是为什么将一个 const对象传递给以 T&类型为形参的模板安全的:对象的常量性 constness会被保留为 T的一部分。

在第三个例子中,注意即使 rx的类型是一个引用,T也会被推导为一个非引用 ,这是因为 rx的引用性(reference-ness)在类型推导中会被忽略。

这些例子只展示了左值引用,但是类型推导会如左值引用一样对待右值引用。当然,右值只能传递给右值引用,但是在类型推导中这种限制将不复存在。

如果我们将 f的形参类型 T&改为 const T&,情况有所变化,但不会变得那么出人意料。cxrxconstness依然被遵守,但是因为现在我们假设 param是reference-to-constconst不再被推导为 T的一部分:

template<typename T>
void f(const T& param);         //param现在是reference-to-const

int x = 27;                     //如之前一样
const int cx = x;               //如之前一样
const int& rx = x;              //如之前一样

f(x);                           //T是int,param的类型是const int&
f(cx);                          //T是int,param的类型是const int&
f(rx);                          //T是int,param的类型是const int&

同之前一样,rx的reference-ness在类型推导中被忽略了。

如果 param是一个指针(或者指向 const的指针)而不是引用,情况本质上也一样:

template<typename T>
void f(T* param);               //param现在是指针

int x = 27;                     //同之前一样
const int *px = &x;             //px是指向作为const int的x的指针

f(&x);                          //T是int,param的类型是int*
f(px);                          //T是const int,param的类型是const int*

到现在为止,你会发现你自己打哈欠犯困,因为C++的类型推导规则对引用和指针形参如此自然,书面形式来看这些非常枯燥。所有事情都那么理所当然!那正是在类型推导系统中你所想要的。

情景二:ParamType是一个通用引用

模板使用通用引用形参的话,那事情就不那么明显了。这样的形参被声明为像右值引用一样(也就是,在函数模板中假设有一个类型形参 T,那么通用引用声明形式就是 T&&),它们的行为在传入左值实参时大不相同。完整的叙述请参见Item24,在这有些最必要的你还是需要知道:

  • 如果 expr是左值,TParamType都会被推导为左值引用。这非常不寻常,第一,这是模板类型推导中唯一一种 T被推导为引用的情况。第二,虽然 ParamType被声明为右值引用类型,但是最后推导的结果是左值引用。
  • 如果 expr是右值,就使用正常的(也就是情景一)推导规则

举个例子:

template<typename T>
void f(T&& param);              //param现在是一个通用引用类型

int x=27;                       //如之前一样
const int cx=x;                 //如之前一样
const int & rx=cx;              //如之前一样

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&&

Item24详细解释了为什么这些例子是像这样发生的。这里关键在于通用引用的类型推导规则是不同于普通的左值或者右值引用的。尤其是,当通用引用被使用时,类型推导会区分左值实参和右值实参,但是对非通用引用时不会区分。

情景三:ParamType既不是指针也不是引用

ParamType既不是指针也不是引用时,我们通过传值(pass-by-value)的方式处理:

template<typename T>
void f(T param);                //以传值的方式处理param

这意味着无论传递什么 param都会成为它的一份拷贝——一个完整的新对象。事实上 param成为一个新对象这一行为会影响 T如何从 expr中推导出结果。

  1. 和之前一样,如果 expr的类型是一个引用,忽略这个引用部分
  2. 如果忽略 expr的引用性(reference-ness)之后,expr是一个 const,那就再忽略 const。如果它是 volatile,也忽略 volatilevolatile对象不常见,它通常用于驱动程序的开发中。关于 volatile的细节请参见Item40

因此

int x=27;                       //如之前一样
const int cx=x;                 //如之前一样
const int & rx=cx;              //如之前一样

f(x);                           //T和param的类型都是int
f(cx);                          //T和param的类型都是int
f(rx);                          //T和param的类型都是int

注意即使 cxrx表示 const值,param也不是 const。这是有意义的。param是一个完全独立于 cxrx的对象——是 cxrx的一个拷贝。具有常量性的 cxrx不可修改并不代表 param也是一样。这就是为什么 expr的常量性 constness(或易变性 volatileness)在推导 param类型时会被忽略:因为 expr不可修改并不意味着它的拷贝也不能被修改。

认识到只有在传值给形参时才会忽略 const(和 volatile)这一点很重要,正如我们看到的,对于reference-to-const和pointer-to-const形参来说,expr的常量性 constness在推导时会被保留。但是考虑这样的情况,expr是一个 const指针,指向 const对象,expr通过传值传递给 param

template<typename T>
void f(T param);                //仍然以传值的方式处理param

const char* const ptr =         //ptr是一个常量指针,指向常量对象 
    "Fun with pointers";

f(ptr);                         //传递const char * const类型的实参

在这里,解引用符号(*)的右边的 const表示 ptr本身是一个 constptr不能被修改为指向其它地址,也不能被设置为null(解引用符号左边的 const表示 ptr指向一个字符串,这个字符串是 const,因此字符串不能被修改)。当 ptr作为实参传给 f,组成这个指针的每一比特都被拷贝进 param。像这种情况,ptr自身的值会被传给形参,根据类型推导的第三条规则,ptr自身的常量性 constness将会被省略,所以 paramconst char*,也就是一个可变指针指向 const字符串。在类型推导中,这个指针指向的数据的常量性 constness将会被保留,但是当拷贝 ptr来创造一个新指针 param时,ptr自身的常量性 constness将会被忽略。

数组实参

上面的内容几乎覆盖了模板类型推导的大部分内容,但这里还有一些小细节值得注意,比如数组类型不同于指针类型,虽然它们两个有时候是可互换的。关于这个错觉最常见的例子是,在很多上下文中数组会退化为指向它的第一个元素的指针。这样的退化允许像这样的代码可以被编译:

const char name[] = "J. P. Briggs";     //name的类型是const char[13]

const char * ptrToName = name;          //数组退化为指针

在这里 const char*指针 ptrToName会由 name初始化,而 name的类型为 const char[13],这两种类型(const char*const char[13])是不一样的,但是由于数组退化为指针的规则,编译器允许这样的代码。

但要是一个数组传值给一个模板会怎样?会发生什么?

template<typename T>
void f(T param);                        //传值形参的模板
f(name);                                //T和param会推导成什么类型?

我们从一个简单的例子开始,这里有一个函数的形参是数组,是的,这样的语法是合法的,

void myFunc(int param[]);

但是数组声明会被视作指针声明,这意味着 myFunc的声明和下面声明是等价的:

void myFunc(int* param);                //与上面相同的函数

数组与指针形参这样的等价是C语言的产物,C++又是建立在C语言的基础上,它让人产生了一种数组和指针是等价的的错觉。

因为数组形参会视作指针形参,所以传值给模板的一个数组类型会被推导为一个指针类型。这意味着在模板函数 f的调用中,它的类型形参 T会被推导为 const char*

f(name);                        //name是一个数组,但是T被推导为const char*

但是现在难题来了,虽然函数不能声明形参为真正的数组,但是可以接受指向数组的引用!所以我们修改 f为传引用:

template<typename T>
void f(T& param);                       //传引用形参的模板

我们这样进行调用,

f(name);                                //传数组给f

T被推导为了真正的数组!这个类型包括了数组的大小,在这个例子中 T被推导为 const char[13]f的形参(该数组的引用)的类型则为 const char (&)[13]。是的,这种语法看起来又臭又长,但是知道它将会让你在关心这些问题的人的提问中获得大神的称号。

有趣的是,可声明指向数组的引用的能力,使得我们可以创建一个模板函数来推导出数组的大小:

//在编译期间返回一个数组大小的常量值(//数组形参没有名字,
//因为我们只关心数组的大小)
template<typename T, std::size_t N>                     //关于
constexpr std::size_t arraySize(T (&)[N]) noexcept      //constexpr
{                                                       //和noexcept
    return N;                                           //的信息
}                                                       //请看下面

Item15提到将一个函数声明为 constexpr使得结果在编译期间可用。这使得我们可以用一个花括号声明一个数组,然后第二个数组可以使用第一个数组的大小作为它的大小,就像这样:

int keyVals[] = { 1, 3, 7, 9, 11, 22, 35 };             //keyVals有七个元素

int mappedVals[arraySize(keyVals)];                     //mappedVals也有七个

当然作为一个现代C++程序员,你自然应该想到使用 std::array而不是内置的数组:

std::array<int, arraySize(keyVals)> mappedVals;         //mappedVals的大小为7

至于 arraySize被声明为 noexcept,会使得编译器生成更好的代码,具体的细节请参见Item14

函数实参

在C++中不只是数组会退化为指针,函数类型也会退化为一个函数指针,我们对于数组类型推导的全部讨论都可以应用到函数类型推导和退化为函数指针上来。结果是:

void someFunc(int, double);         //someFunc是一个函数,
                                    //类型是void(int, double)

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

template<typename T>
void f2(T & param);                 //传引用给f2

f1(someFunc);                       //param被推导为指向函数的指针,
                                    //类型是void(*)(int, double)
f2(someFunc);                       //param被推导为指向函数的引用,
                                    //类型是void(&)(int, double)

这个实际上没有什么不同,但是如果你知道数组退化为指针,你也会知道函数退化为指针。

这里你需要知道:auto依赖于模板类型推导。正如我在开始谈论的,在大多数情况下它们的行为很直接。在通用引用中对于左值的特殊处理使得本来很直接的行为变得有些污点,然而,数组和函数退化为指针把这团水搅得更浑浊。有时你只需要编译器告诉你推导出的类型是什么。这种情况下,翻到item4,它会告诉你如何让编译器这么做。

请记住:

  • 在模板类型推导时,有引用的实参会被视为无引用,他们的引用会被忽略
  • 对于通用引用的推导,左值实参会被特殊对待
  • 对于传值类型推导,const和/或 volatile实参会被认为是non-const的和non-volatile
  • 在模板类型推导时,数组名或者函数名实参会退化为指针,除非它们被用于初始化引用

条款二:理解 auto类型推导

Item 2: Understand auto type deduction

如果你已经读过Item1的模板类型推导,那么你几乎已经知道了 auto类型推导的大部分内容,至于为什么不是全部是因为这里有一个 auto不同于模板类型推导的例外。但这怎么可能?模板类型推导包括模板,函数,形参,但 auto不处理这些东西啊。

你是对的,但没关系。auto类型推导和模板类型推导有一个直接的映射关系。它们之间可以通过一个非常规范非常系统化的转换流程来转换彼此。

Item1中,模板类型推导使用下面这个函数模板

template<typename T>
void f(ParmaType param);

和这个调用来解释:

f(expr);                        //使用一些表达式调用f

f的调用中,编译器使用 expr推导 TParamType的类型。

当一个变量使用 auto进行声明时,auto扮演了模板中 T的角色,变量的类型说明符扮演了 ParamType的角色。废话少说,这里便是更直观的代码描述,考虑这个例子:

auto x = 27;

这里 x的类型说明符是 auto自己,另一方面,在这个声明中:

const auto cx = x;

类型说明符是 const auto。另一个:

const auto& rx = x;

类型说明符是 const auto&。在这里例子中要推导 xcxrx的类型,编译器的行为看起来就像是认为这里每个声明都有一个模板,然后使用合适的初始化表达式进行调用:

template<typename T>            //概念化的模板用来推导x的类型
void func_for_x(T param);

func_for_x(27);                 //概念化调用:
                                //param的推导类型是x的类型

template<typename T>            //概念化的模板用来推导cx的类型
void func_for_cx(const T param);

func_for_cx(x);                 //概念化调用:
                                //param的推导类型是cx的类型

template<typename T>            //概念化的模板用来推导rx的类型
void func_for_rx(const T & param);

func_for_rx(x);                 //概念化调用:
                                //param的推导类型是rx的类型

正如我说的,auto类型推导除了一个例外(我们很快就会讨论),其他情况都和模板类型推导一样。

Item1基于 ParamType——在函数模板中 param的类型说明符——的不同特征,把模板类型推导分成三个部分来讨论。在使用 auto作为类型说明符的变量声明中,类型说明符代替了 ParamType,因此Item1描述的三个情景稍作修改就能适用于auto:

  • 情景一:类型说明符是一个指针或引用但不是通用引用
  • 情景二:类型说明符一个通用引用
  • 情景三:类型说明符既不是指针也不是引用

我们早已看过情景一和情景三的例子:

auto x = 27;                    //情景三(x既不是指针也不是引用)
const auto cx = x;              //情景三(cx也一样)
const auto & rx=cx;             //情景一(rx是非通用引用)

情景二像你期待的一样运作:

auto&& uref1 = x;               //x是int左值,
                                //所以uref1类型为int&
auto&& uref2 = cx;              //cx是const int左值,
                                //所以uref2类型为const int&
auto&& uref3 = 27;              //27是int右值,
                                //所以uref3类型为int&&

Item1讨论并总结了对于non-reference类型说明符,数组和函数名如何退化为指针。那些内容也同样适用于 auto类型推导:

const char name[] =             //name的类型是const char[13]
 "R. N. Briggs";

auto arr1 = name;               //arr1的类型是const char*
auto& arr2 = name;              //arr2的类型是const char (&)[13]

void someFunc(int, double);     //someFunc是一个函数,
                                //类型为void(int, double)

auto func1 = someFunc;          //func1的类型是void (*)(int, double)
auto& func2 = someFunc;         //func2的类型是void (&)(int, double)

就像你看到的那样,auto类型推导和模板类型推导几乎一样的工作,它们就像一个硬币的两面。

讨论完相同点接下来就是不同点,前面我们已经说到 auto类型推导和模板类型推导有一个例外使得它们的工作方式不同,接下来我们要讨论的就是那个例外。 我们从一个简单的例子开始,如果你想声明一个带有初始值27的 int,C++98提供两种语法选择:

int x1 = 27;
int x2(27);

C++11由于也添加了用于支持统一初始化(uniform initialization)的语法:

int x3 = { 27 };
int x4{ 27 };

总之,这四种不同的语法只会产生一个相同的结果:变量类型为 int值为27

但是Item5解释了使用 auto说明符代替指定类型说明符的好处,所以我们应该很乐意把上面声明中的 int替换为 auto,我们会得到这样的代码:

auto x1 = 27;
auto x2(27);
auto x3 = { 27 };
auto x4{ 27 };

这些声明都能通过编译,但是他们不像替换之前那样有相同的意义。前面两个语句确实声明了一个类型为 int值为27的变量,但是后面两个声明了一个存储一个元素27的 std::initializer_list<int>类型的变量。

auto x1 = 27;                   //类型是int,值是27
auto x2(27);                    //同上
auto x3 = { 27 };               //类型是std::initializer_list<int>,
                                //值是{ 27 }
auto x4{ 27 };                  //同上

这就造成了 auto类型推导不同于模板类型推导的特殊情况。当用 auto声明的变量使用花括号进行初始化,auto类型推导推出的类型则为 std::initializer_list。如果这样的一个类型不能被成功推导(比如花括号里面包含的是不同类型的变量),编译器会拒绝这样的代码:

auto x5 = { 1, 2, 3.0 };        //错误!无法推导std::initializer_list<T>中的T

就像注释说的那样,在这种情况下类型推导将会失败,但是对我们来说认识到这里确实发生了两种类型推导是很重要的。一种是由于 auto的使用:x5的类型不得不被推导。因为 x5使用花括号的方式进行初始化,x5必须被推导为 std::initializer_list。但是 std::initializer_list是一个模板。std::initializer_list<T>会被某种类型 T实例化,所以这意味着 T也会被推导。 推导落入了这里发生的第二种类型推导——模板类型推导的范围。在这个例子中推导之所以失败,是因为在花括号中的值并不是同一种类型。

对于花括号的处理是 auto类型推导和模板类型推导唯一不同的地方。当使用 auto声明的变量使用花括号的语法进行初始化的时候,会推导出 std::initializer_list<T>的实例化,但是对于模板类型推导这样就行不通:

auto x = { 11, 23, 9 };         //x的类型是std::initializer_list<int>

template<typename T>            //带有与x的声明等价的
void f(T param);                //形参声明的模板

f({ 11, 23, 9 });               //错误!不能推导出T

然而如果在模板中指定 Tstd::initializer_list<T>而留下未知 T,模板类型推导就能正常工作:

template<typename T>
void f(std::initializer_list<T> initList);

f({ 11, 23, 9 });               //T被推导为int,initList的类型为
                                //std::initializer_list<int>

因此 auto类型推导和模板类型推导的真正区别在于,auto类型推导假定花括号表示 std::initializer_list而模板类型推导不会这样(确切的说是不知道怎么办)。

你可能想知道为什么 auto类型推导和模板类型推导对于花括号有不同的处理方式。我也想知道。哎,我至今没找到一个令人信服的解释。但是规则就是规则,这意味着你必须记住如果你使用 auto声明一个变量,并用花括号进行初始化,auto类型推导总会得出 std::initializer_list的结果。如果你使用uniform initialization(花括号的方式进行初始化)用得很爽你就得记住这个例外以免犯错,在C++11编程中一个典型的错误就是偶然使用了 std::initializer_list<T>类型的变量,这个陷阱也导致了很多C++程序员抛弃花括号初始化,只有不得不使用的时候再做考虑。(在Item7讨论了必须使用时该怎么做)

对于C++11故事已经说完了。但是对于C++14故事还在继续,C++14允许 auto用于函数返回值并会被推导(参见Item3),而且C++14的lambda函数也允许在形参声明中使用 auto。但是在这些情况下 auto实际上使用模板类型推导的那一套规则在工作,而不是 auto类型推导,所以说下面这样的代码不会通过编译:

auto createInitList()
{
    return { 1, 2, 3 };         //错误!不能推导{ 1, 2, 3 }的类型
}

同样在C++14的lambda函数中这样使用auto也不能通过编译:

std::vector<int> v;

auto resetV = 
    [&v](const auto& newValue){ v = newValue; };        //C++14

resetV({ 1, 2, 3 });            //错误!不能推导{ 1, 2, 3 }的类型

请记住:

  • auto类型推导通常和模板类型推导相同,但是 auto类型推导假定花括号初始化代表 std::initializer_list,而模板类型推导不这样做
  • 在C++14中 auto允许出现在函数返回值或者lambda函数形参中,但是它的工作机制是模板类型推导那一套方案,而不是 auto类型推导

条款三:理解 decltype

Item 3: Understand decltype

decltype是一个奇怪的东西。给它一个名字或者表达式 decltype就会告诉你这个名字或者表达式的类型。通常,它会精确的告诉你你想要的结果。但有时候它得出的结果也会让你挠头半天,最后只能求助网上问答或参考资料寻求启示。

我们将从一个简单的情况开始,没有任何令人惊讶的情况。相比模板类型推导和 auto类型推导(参见Item1Item2),decltype只是简单的返回名字或者表达式的类型:

const int i = 0;                //decltype(i)是const int

bool f(const Widget& w);        //decltype(w)是const Widget&
                                //decltype(f)是bool(const Widget&)

struct Point{
    int x,y;                    //decltype(Point::x)是int
};                              //decltype(Point::y)是int

Widget w;                       //decltype(w)是Widget

if (f(w))                      //decltype(f(w))是bool

template<typename T>            //std::vector的简化版本
class vector{
public:
    
    T& operator[](std::size_t index);
    
};

vector<int> v;                  //decltype(v)是vector<int>

if (v[0] == 0)                 //decltype(v[0])是int&

看见了吧?没有任何奇怪的东西。

在C++11中,decltype最主要的用途就是用于声明函数模板,而这个函数返回类型依赖于形参类型。举个例子,假定我们写一个函数,一个形参为容器,一个形参为索引值,这个函数支持使用方括号的方式(也就是使用“[]”)访问容器中指定索引值的数据,然后在返回索引操作的结果前执行认证用户操作。函数的返回类型应该和索引操作返回的类型相同。

对一个 T类型的容器使用 operator[] 通常会返回一个 T&对象,比如 std::deque就是这样。但是 std::vector有一个例外,对于 std::vector<bool>operator[]不会返回 bool&,它会返回一个全新的对象(译注:MSVC的STL实现中返回的是 std::_Vb_reference<std::_Wrap_alloc<std::allocator<unsigned int>>>对象)。关于这个问题的详细讨论请参见Item6,这里重要的是我们可以看到对一个容器进行 operator[]操作返回的类型取决于容器本身。

使用 decltype使得我们很容易去实现它,这是我们写的第一个版本,使用 decltype计算返回类型,这个模板需要改良,我们把这个推迟到后面:

template<typename Container, typename Index>    //可以工作,
auto authAndAccess(Container& c, Index i)       //但是需要改良
    ->decltype(c[i])
{
    authenticateUser();
    return c[i];
}

函数名称前面的 auto不会做任何的类型推导工作。相反的,他只是暗示使用了C++11的尾置返回类型语法,即在函数形参列表后面使用一个”->“符号指出函数的返回类型,尾置返回类型的好处是我们可以在函数返回类型中使用函数形参相关的信息。在 authAndAccess函数中,我们使用 ci指定返回类型。如果我们按照传统语法把函数返回类型放在函数名称之前,ci就未被声明所以不能使用。

在这种声明中,authAndAccess函数返回 operator[]应用到容器中返回的对象的类型,这也正是我们期望的结果。

C++11允许自动推导单一语句的lambda表达式的返回类型, C++14扩展到允许自动推导所有的lambda表达式和函数,甚至它们内含多条语句。对于 authAndAccess来说这意味着在C++14标准下我们可以忽略尾置返回类型,只留下一个 auto。使用这种声明形式,auto标示这里会发生类型推导。更准确的说,编译器将会从函数实现中推导出函数的返回类型。

template<typename Container, typename Index>    //C++14版本,
auto authAndAccess(Container& c, Index i)       //不那么正确
{
    authenticateUser();
    return c[i];                                //从c[i]中推导返回类型
}

Item2解释了函数返回类型中使用 auto,编译器实际上是使用的模板类型推导的那套规则。如果那样的话这里就会有一些问题。正如我们之前讨论的,operator[]对于大多数 T类型的容器会返回一个 T&,但是Item1解释了在模板类型推导期间,表达式的引用性(reference-ness)会被忽略。基于这样的规则,考虑它会对下面用户的代码有哪些影响:

std::deque<int> d;

authAndAccess(d, 5) = 10;               //认证用户,返回d[5],
                                        //然后把10赋值给它
                                        //无法通过编译器!

在这里 d[5]本该返回一个 int&,但是模板类型推导会剥去引用的部分,因此产生了 int返回类型。函数返回的那个 int是一个右值,上面的代码尝试把10赋值给右值 int,C++11禁止这样做,所以代码无法编译。

要想让 authAndAccess像我们期待的那样工作,我们需要使用 decltype类型推导来推导它的返回值,即指定 authAndAccess应该返回一个和 c[i]表达式类型一样的类型。C++期望在某些情况下当类型被暗示时需要使用 decltype类型推导的规则,C++14通过使用 decltype(auto)说明符使得这成为可能。我们第一次看见 decltype(auto)可能觉得非常的矛盾(到底是 decltype还是 auto?),实际上我们可以这样解释它的意义:auto说明符表示这个类型将会被推导,decltype说明 decltype的规则将会被用到这个推导过程中。因此我们可以这样写 authAndAccess

template<typename Container, typename Index>    //C++14版本,
decltype(auto)                                  //可以工作,
authAndAccess(Container& c, Index i)            //但是还需要
{                                               //改良
    authenticateUser();
    return c[i];
}

现在 authAndAccess将会真正的返回 c[i]的类型。现在事情解决了,一般情况下 c[i]返回 T&authAndAccess也会返回 T&,特殊情况下 c[i]返回一个对象,authAndAccess也会返回一个对象。

decltype(auto)的使用不仅仅局限于函数返回类型,当你想对初始化表达式使用 decltype推导的规则,你也可以使用:

Widget w;

const Widget& cw = w;

auto myWidget1 = cw;                    //auto类型推导
                                        //myWidget1的类型为Widget
decltype(auto) myWidget2 = cw;          //decltype类型推导
                                        //myWidget2的类型是const Widget&

但是这里有两个问题困惑着你。一个是我之前提到的 authAndAccess的改良至今都没有描述。让我们现在加上它。

再看看C++14版本的 authAndAccess声明:

template<typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i);

容器通过传引用的方式传递非常量左值引用(lvalue-reference-to-non-const),因为返回一个引用允许用户可以修改容器。但是这意味着在不能给这个函数传递右值容器,右值不能被绑定到左值引用上(除非这个左值引用是一个const(lvalue-references-to-const),但是这里明显不是)。

公认的向 authAndAccess传递一个右值是一个edge case(译注:在极限操作情况下会发生的事情,类似于会发生但是概率较小的事情)。一个右值容器,是一个临时对象,通常会在 authAndAccess调用结束被销毁,这意味着 authAndAccess返回的引用将会成为一个悬置的(dangle)引用。但是使用向 authAndAccess传递一个临时变量也并不是没有意义,有时候用户可能只是想简单的获得临时容器中的一个元素的拷贝,比如这样:

std::deque<std::string> makeStringDeque();      //工厂函数

//从makeStringDeque中获得第五个元素的拷贝并返回
auto s = authAndAccess(makeStringDeque(), 5);

要想支持这样使用 authAndAccess我们就得修改一下当前的声明使得它支持左值和右值。重载是一个不错的选择(一个函数重载声明为左值引用,另一个声明为右值引用),但是我们就不得不维护两个重载函数。另一个方法是使 authAndAccess的引用可以绑定左值和右值,Item24解释了那正是通用引用能做的,所以我们这里可以使用通用引用进行声明:

template<typename Containter, typename Index>   //现在c是通用引用
decltype(auto) authAndAccess(Container&& c, Index i);

在这个模板中,我们不知道我们操纵的容器的类型是什么,那意味着我们同样不知道它使用的索引对象(index objects)的类型,对一个未知类型的对象使用传值通常会造成不必要的拷贝,对程序的性能有极大的影响,还会造成对象切片行为(参见item41),以及给同事落下笑柄。但是就容器索引来说,我们遵照标准模板库对于索引的处理是有理由的(比如 std::stringstd::vectorstd::dequeoperator[]),所以我们坚持传值调用。

然而,我们还需要更新一下模板的实现,让它能听从Item25的告诫应用 std::forward实现通用引用:

template<typename Container, typename Index>    //最终的C++14版本
decltype(auto)
authAndAccess(Container&& c, Index i)
{
    authenticateUser();
    return std::forward<Container>(c)[i];
}

这样就能对我们的期望交上一份满意的答卷,但是这要求编译器支持C++14。如果你没有这样的编译器,你还需要使用C++11版本的模板,它看起来和C++14版本的极为相似,除了你不得不指定函数返回类型之外:

template<typename Container, typename Index>    //最终的C++11版本
auto
authAndAccess(Container&& c, Index i)
->decltype(std::forward<Container>(c)[i])
{
    authenticateUser();
    return std::forward<Container>(c)[i];
}

另一个问题是就像我在条款的开始唠叨的那样,decltype通常会产生你期望的结果,但并不总是这样。在极少数情况下它产生的结果可能让你很惊讶。老实说如果你不是一个大型库的实现者你不太可能会遇到这些异常情况。

为了完全理解 decltype的行为,你需要熟悉一些特殊情况。它们大多数都太过晦涩以至于几乎没有书进行有过权威的讨论,这本书也不例外,但是其中的一个会让我们更加理解 decltype的使用。

decltype应用于变量名会产生该变量名的声明类型。虽然变量名都是左值表达式,但这不会影响 decltype的行为。(译者注:这里是说对于单纯的变量名,decltype只会返回变量的声明类型)然而,对于比单纯的变量名更复杂的左值表达式,decltype可以确保报告的类型始终是左值引用。也就是说,如果一个不是单纯变量名的左值表达式的类型是 T,那么 decltype会把这个表达式的类型报告为 T&。这几乎没有什么太大影响,因为大多数左值表达式的类型天生具备一个左值引用修饰符。例如,返回左值的函数总是返回左值引用。

这个行为暗含的意义值得我们注意,在:

int x = 0;

中,x是一个变量的名字,所以 decltype(x)int。但是如果用一个小括号包覆这个名字,比如这样 (x) ,就会产生一个比名字更复杂的表达式。对于名字来说,x是一个左值,C++11定义了表达式 (x)也是一个左值。因此 decltype((x))int&。用小括号覆盖一个名字可以改变 decltype对于名字产生的结果。

在C++11中这稍微有点奇怪,但是由于C++14允许了 decltype(auto)的使用,这意味着你在函数返回语句中细微的改变就可以影响类型的推导:

decltype(auto) f1()
{
    int x = 0;
    
    return x;                            //decltype(x)是int,所以f1返回int
}

decltype(auto) f2()
{
    int x = 0;
    return (x);                          //decltype((x))是int&,所以f2返回int&
}

注意不仅 f2的返回类型不同于 f1,而且它还引用了一个局部变量!这样的代码将会把你送上未定义行为的特快列车,一辆你绝对不想上第二次的车。

当使用 decltype(auto)的时候一定要加倍的小心,在表达式中看起来无足轻重的细节将会影响到 decltype(auto)的推导结果。为了确认类型推导是否产出了你想要的结果,请参见Item4描述的那些技术。

同时你也不应该忽略 decltype这块大蛋糕。没错,decltype(单独使用或者与 auto一起用)可能会偶尔产生一些令人惊讶的结果,但那毕竟是少数情况。通常,decltype都会产生你想要的结果,尤其是当你对一个变量使用 decltype时,因为在这种情况下,decltype只是做一件本分之事:它产出变量的声明类型。

请记住:

  • decltype总是不加修改的产生变量或者表达式的类型。
  • 对于 T类型的不是单纯的变量名的左值表达式,decltype总是产出 T的引用即 T&
  • C++14支持 decltype(auto),就像 auto一样,推导出类型,但是它使用 decltype的规则进行推导。

条款四:学会查看类型推导结果

Item 4: Know how to view deduced types

选择使用工具查看类型推导,取决于软件开发过程中你想在哪个阶段显示类型推导信息。我们探究三种方案:在你编辑代码的时候获得类型推导的结果,在编译期间获得结果,在运行时获得结果。

IDE编辑器

在IDE中的代码编辑器通常可以显示程序代码中变量,函数,参数的类型,你只需要简单的把鼠标移到它们的上面,举个例子,有这样的代码中:

const int theAnswer = 42;

auto x = theAnswer;
auto y = &theAnswer;

IDE编辑器可以直接显示 x推导的结果为 inty推导的结果为 const int*

为此,你的代码必须或多或少的处于可编译状态,因为IDE之所以能提供这些信息是因为一个C++编译器(或者至少是前端中的一个部分)运行于IDE中。如果这个编译器对你的代码不能做出有意义的分析或者推导,它就不会显示推导的结果。

对于像 int这样简单的推导,IDE产生的信息通常令人很满意。正如我们将看到的,如果更复杂的类型出现时,IDE提供的信息就几乎没有什么用了。

编译器诊断

另一个获得推导结果的方法是使用编译器出错时提供的错误消息。这些错误消息无形的提到了造成我们编译错误的类型是什么。

举个例子,假如我们想看到之前那段代码中 xy的类型,我们可以首先声明一个类模板但不定义。就像这样:

template<typename T>                //只对TD进行声明
class TD;                           //TD == "Type Displayer"

如果尝试实例化这个类模板就会引出一个错误消息,因为这里没有用来实例化的类模板定义。为了查看 xy的类型,只需要使用它们的类型去实例化 TD

TD<decltype(x)> xType;              //引出包含x和y
TD<decltype(y)> yType;              //的类型的错误消息

我使用variableNameType的结构来命名变量,因为这样它们产生的错误消息可以有助于我们查找。对于上面的代码,我的编译器产生了这样的错误信息,我取一部分贴到下面:

error: aggregate 'TD<int> xType' has incomplete type and 
        cannot be defined
error: aggregate 'TD<const int *> yType' has incomplete type and
        cannot be defined

另一个编译器也产生了一样的错误,只是格式稍微改变了一下:

error: 'xType' uses undefined class 'TD<int>'
error: 'yType' uses undefined class 'TD<const int *>'

除了格式不同外,几乎所有我测试过的编译器都产生了这样有用的错误消息。

运行时输出

使用 printf的方法使类型信息只有在运行时才会显示出来(尽管我不是非常建议你使用 printf),但是它提供了一种格式化输出的方法。现在唯一的问题是只需对于你关心的变量使用一种优雅的文本表示。“这有什么难的,“你这样想,”这正是 typeidstd::type_info::name的价值所在”。为了实现我们想要查看 xy的类型的需求,你可能会这样写:

std::cout << typeid(x).name() << '\n';  //显示x和y的类型
std::cout << typeid(y).name() << '\n';

这种方法对一个对象如 xy调用 typeid产生一个 std::type_info的对象,然后 std::type_info里面的成员函数 name()来产生一个C风格的字符串(即一个 const char*)表示变量的名字。

调用 std::type_info::name不保证返回任何有意义的东西,但是库的实现者尝试尽量使它们返回的结果有用。实现者们对于“有用”有不同的理解。举个例子,GNU和Clang环境下 x的类型会显示为”i“,y会显示为”PKi“,这样的输出你必须要问问编译器实现者们才能知道他们的意义:”i“表示”int“,”PK“表示”pointer to ~~konst~~ const“(指向常量的指针)。(这些编译器都提供一个工具 c++filt,解释这些“混乱的”类型)Microsoft的编译器输出得更直白一些:对于 x输出”int“对于 y输出”int const *

因为对于 xy来说这样的结果是正确的,你可能认为问题已经接近了,别急,考虑一个更复杂的例子:

template<typename T>                    //要调用的模板函数
void f(const T& param);

std::vector<Widget> createVec();        //工厂函数

const auto vw = createVec();            //使用工厂函数返回值初始化vw

if (!vw.empty()){
    f(&vw[0]);                          //调用f
    
}

在这段代码中包含了一个用户定义的类型 Widget,一个STL容器 std::vector和一个 auto变量 vw,这个更现实的情况是你可能在会遇到的并且想获得他们类型推导的结果,比如模板类型形参 T,比如函数 f形参 param

从这里中我们不难看出 typeid的问题所在。我们在 f中添加一些代码来显示类型:

template<typename T>
void f(const T& param)
{
    using std::cout;
    cout << "T =     " << typeid(T).name() << '\n';             //显示T

    cout << "param = " << typeid(param).name() << '\n';         //显示
                                                               //param
}                                                               //的类型

GNU和Clang执行这段代码将会输出这样的结果

T =     PK6Widget
param = PK6Widget

我们早就知道在这些编译器中 PK表示“pointer to const”,所以只有数字 6对我们来说是神奇的。其实数字是类名称(Widget)的字符串长度,所以这些编译器告诉我们 Tparam都是 const Widget*

Microsoft的编译器也同意上述言论:

T =     class Widget const *
param = class Widget const *

这三个独立的编译器产生了相同的信息并表示信息非常准确,当然看起来不是那么准确。在模板 f中,param的声明类型是 const T&。难道你们不觉得 Tparam类型相同很奇怪吗?比如 Tintparam的类型应该是 const int&而不是相同类型才对吧。

遗憾的是,事实就是这样,std::type_info::name的结果并不总是可信的,就像上面一样,三个编译器对 param的报告都是错误的。因为它们本质上可以不正确,因为 std::type_info::name规范批准像传值形参一样来对待这些类型。正如Item1提到的,如果传递的是一个引用,那么引用部分(reference-ness)将被忽略,如果忽略后还具有 const或者 volatile,那么常量性 constness或者易变性 volatileness也会被忽略。那就是为什么 param的类型 const Widget * const &会输出为 const Widget *,首先引用被忽略,然后这个指针自身的常量性 constness被忽略,剩下的就是指针指向一个常量对象。

同样遗憾的是,IDE编辑器显示的类型信息也不总是可靠的,或者说不总是有用的。还是一样的例子,一个IDE编辑器可能会把 T的类型显示为(我没有胡编乱造):

const
std::_Simple_types<std::_Wrap_alloc<std::_Vec_base_types<Widget,
std::allocator<Widget>>::_Alloc>::value_type>::value_type *

同样把 param的类型显示为

const std::_Simple_types<...>::value_type *const &

这个比起 T来说要简单一些,但是如果你不知道“...”表示编译器忽略 T的部分类型那么可能你还是会产生困惑。如果你运气好点你的IDE可能表现得比这个要好一些。

比起运气如果你更倾向于依赖库,那么你乐意被告知 std::type_info::name和IDE不怎么好,Boost TypeIndex库(通常写作Boost.TypeIndex)是更好的选择。这个库不是标准C++的一部分,也不是IDE或者 TD这样的模板。Boost库(可在boost.com获得)是跨平台,开源,有良好的开源协议的库,这意味着使用Boost和STL一样具有高度可移植性。

这里是如何使用Boost.TypeIndex得到 f的类型的代码

#include <boost/type_index.hpp>

template<typename T>
void f(const T& param)
{
    using std::cout;
    using boost::typeindex::type_id_with_cvr;

    //显示T
    cout << "T =     "
         << type_id_with_cvr<T>().pretty_name()
         << '\n';

    //显示param类型
    cout << "param = "
         << type_id_with_cvr<decltype(param)>().pretty_name()
         << '\n';
}

boost::typeindex::type_id_with_cvr获取一个类型实参(我们想获得相应信息的那个类型),它不消除实参的 constvolatile和引用修饰符(因此模板名中有“with_cvr”)。结果是一个 boost::typeindex::type_index对象,它的 pretty_name成员函数输出一个 std::string,包含我们能看懂的类型表示。 基于这个 f的实现版本,再次考虑那个使用 typeid时获取 param类型信息出错的调用:

std::vetor<Widget> createVec();         //工厂函数
const auto vw = createVec();            //使用工厂函数返回值初始化vw
if (!vw.empty()){
    f(&vw[0]);                          //调用f
    
}

在GNU和Clang的编译器环境下,使用Boost.TypeIndex版本的 f最后会产生下面的(准确的)输出:

T =     Widget const *
param = Widget const * const&

在Microsoft的编译器环境下,结果也是极其相似:

T =     class Widget const *
param = class Widget const * const &

这样近乎一致的结果是很不错的,但是请记住IDE,编译器错误诊断或者像Boost.TypeIndex这样的库只是用来帮助你理解编译器推导的类型是什么。它们是有用的,但是作为本章结束语我想说它们根本不能替代你对Item1-3提到的类型推导的理解。

请记住:

  • 类型推断可以从IDE看出,从编译器报错看出,从Boost TypeIndex库的使用看出
  • 这些工具可能既不准确也无帮助,所以理解C++类型推导规则才是最重要的