《EffectiveModernC++》第一章:类型推导

第一章 类型推导

item1:理解模板类型推导

考虑这样一个函数模板:

1
2
3
4
5
template<typename T>
void f(ParamType param);

//调用:
f(expr) //从expr中推导T和ParamType

我们可以给T加上一些修饰,如下:

1
2
3
4
5
6
template<typename T>
void f(const T& param); //ParamType是const T&

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

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

我们期望传入参数的类型和T是一致的(就像上面的传入的int参数,T也会被推导出int),但有时情况并非总是如此,T的类型推导不仅取决于expr的类型,也取决于ParamType的类型。这里有三种情况:

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

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

  1. 如果expr的类型是一个引用,忽略引用部分
  2. 然后expr的类型与ParamType进行模式匹配来决定T
1
2
3
4
5
6
7
8
9
10
11
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的引用

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

后两个的调用中,变量cx是一个const valuerxa reference to a const value,也就是都不允许改变变量的值,这两者T推导出来的信息也包含const,都是const int,这是十分有道理的,考虑一下当你传入一个const value的值, 这个值/对象也是期望不可以被改变,那么T理应推导出常量性的信息,即也包含constness常量性。

这也是为什么将一个const对象传递给以T&类型为形参的模板安全的:对象的常量性constness会被保留为T的一部分。

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

如果我们将f的形参类型T&改为const T&

1
2
3
4
5
6
7
8
9
10
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&

cxrxconstness依然被遵守(param变量依然是一个constness的)。但是因为现在我们假设param是reference-to-constconst不再被推导为T的一部分.

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

1
2
3
4
5
6
7
8
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*
  • 情景二:ParamType是一个通用引用
  1. 如果expr是左值,TParamType都会被推导为左值引用。
  2. 如果expr是右值,就使用正常的(也就是情景一)推导规则。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)的方式处理

1
2
3
4
5
6
7
8
9
10
template<typename T>
void f(T param); //以传值的方式处理param

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

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

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

考虑这样的一个情况:

expr是一个const指针,指向const对象,expr通过传值传递给param

1
2
3
4
5
6
7
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。

ptr作为实参传给f,组成这个指针的每一比特都被拷贝进param。像这种情况,ptr自身的值会被传给形参,根据类型推导的第三条规则,ptr自身的常量性constness将会被省略,所以paramconst char*,也就是一个可变指针指向const字符串。 这一条看似有些奇怪,但是我们观察第三条中的

expr是一个const,那就再忽略const

这里强调的是expr的const,这里expr是一个指针 ,忽略的是指针的const,即忽略指针的常量性,指针指向的内容的常量性不应该被忽略 。

现在我们再来讨论数组实参

上面的内容几乎覆盖了模板类型推导的大部分内容,但这里还有一些小细节值得注意,比如数组类型不同于指针类型,虽然它们两个有时候是可互换的。

在很多上下文中数组会退化为指向它的第一个元素的指针。这样的退化允许像这样的代码可以被编译:

1
2
3
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])是不一样的,但是由于数组退化为指针的规则,编译器允许这样的代码。

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

1
2
3
4
5
template<typename T>
void f(T param); //传值形参的模板

const char name[] = "J. P. Briggs";
f(name); //name是一个数组,但是T被推导为const char*

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

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

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

T被推导为了真正的数组!这个类型包括了数组的大小,在这个例子中T被推导为const char[13]f的形参(对这个数组的引用)的类型则为const char (&)[13]

这也是一个小trick知识。

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

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

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

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

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

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

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

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

再来讨论函数实参

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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依赖于模板类型推导。正如我在开始谈论的,在大多数情况下它们的行为很直接。在通用引用中对于左值的特殊处理使得本来很直接的行为变得有些污点,然而,数组和函数退化为指针把这团水搅得更浑浊。

请记住:

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

item2:理解auto类型推导

auto在绝大多数情况是和模板一致的,T和auto等价。

1
auto x = 27;

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

1
const auto cx = x;

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

1
const auto & rx=cx;

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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描述的三个情景稍作修改就能适用于auto:

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

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

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

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

1
2
3
4
5
6
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类型推导:

1
2
3
4
5
6
7
8
9
10
11
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提供两种语法选择:

1
2
int x1 = 27;
int x2(27);

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

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

把上面声明中的int替换为auto,我们会得到这样的代码:

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

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

如果这样的一个类型不能被成功推导(比如花括号里面包含的是不同类型的变量),编译器会拒绝这样的代码:

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

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

1
2
3
4
5
6
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,模板类型推导就能正常工作:

1
2
3
4
5
template<typename T>
void f(std::initializer_list<T> initList);

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

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

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

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

1
2
3
4
5
6
std::vector<int> v;

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

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

item3:理解decltype

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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&

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

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

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

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

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

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

1
2
3
4
5
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

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

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

1
2
3
4
5
6
7
8
Widget w;

const Widget& cw = w;

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

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

1
2
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)引用。但有时候用户可能只是想简单的获得临时容器中的一个元素的拷贝,比如这样:

1
2
3
4
std::deque<std::string> makeStringDeque();      //工厂函数

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

要想支持这样使用authAndAccess我们就得修改一下当前的声明使得它支持左值和右值。

1.重载是一个不错的选择(一个函数重载声明为左值引用,另一个声明为右值引用),但是我们就不得不维护两个重载函数。

2.另一个方法是使authAndAccess的引用可以绑定左值和右值,Item24解释了那正是通用引用能做的,所以我们这里可以使用通用引用进行声明:

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

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

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

1
2
3
4
5
6
7
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版本的极为相似,除了你不得不指定函数返回类型之外:

1
2
3
4
5
6
7
8
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还有很多奇怪的规则,比如:

1
2
3
4
5
6
7
8
9
10
11
12
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,而且它还引用了一个局部变量!这样的代码将会称为未定义行为,这是一种非常不好的情况。

其实本质是 (x)会被认为是一个表达式,而x会被认为是一个变量,更多推导规则可以看:

https://chillstepp.github.io/2022/03/11/%E7%B1%BB%E5%9E%8B%E6%8E%A8%E5%AF%BC%EF%BC%9Aauto%E5%92%8Cdecltype/#decltype

我们这里只是像教给你怎么用decltype写一个合理的用法。

请记住:

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

item4:学会查看类型推导结果

  • 第一种方法就是通过IDE来查看

  • 第二种通过编译器报错来查看:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    using FunType = int(const int&,int);
    int fun(const int& x,int y)
    {
    cout<<x<<" "<<y<<endl;
    return x+y;
    };

    template<typename T>
    class TD;

    int main()
    {
    TD<decltype(fun)>xtype;
    return 0;
    }
    Copy

    报错:error: aggregate 'TD<int(const int&, int)> xtype' has incomplete type and cannot be defined|,可以得知:funint(const int&, int)类型。