原创文章,未经许可,禁止转载!

Item 2:理解 auto 的推导

阅读完 Item 1 关于模板的推导,你基本上已经学会了推导 auto 所需要的知识,因为 auto 只有一个例外,这个在后面会讲到。模版类型推导包含了模版,函数以及函数的参数, 但 auto 并不处理以上任何一种情况,模版推导和 auto 之间的知识点为什么是一样的呢?

是的,就是这样。这里可以映射一套规则将模版推导和 auto 推导联系起来。从字面上来看,这好像是一个数学变换公式,将一个规则变换成另一个。

在 Item 1,用的是函数模版和函数调用来解释模版的推导:

template <typename T>
void f(ParamType param);      // 使用函数模版来推导

f(expr);                      // 使用函数调用来推导

调用 f 时,编译器使用 expr 的类型来推导 T 和 ParamType。

当一个变量的声明使用的是 auto ,auto 使用了和模版 T 同样的规则,变量类型的指定就和 ParamType 一样,拿实际一点的例子来说会更直观,所以看下面这段代码:

auto x = 27;

这里,x 的类型其实一目了然,是 int,很容易自动推导出它的类型。再看另外一个定义,

const auto cx = x;  // 很明显 这里的 auto 是 int

再看下面 const auto& :

const auto& rx = x;  // 这里的 auto 也是 int

rx 的类型是 const auto& 。为了推导 x , cx 和 rx 类型,编译器表现的行为就像是每个定义都是一个模版同时用相应的初始化信息来传递模版参数。

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

func_for_x(27);                    // 函数调用:用函数参数类型的推导的方式来解释 x 的类型推导

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

func_for_cx(x);                    // 函数调用:用函数参数类型推导的方式来解释 cx 的类型推导

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

func_for_rx(x);                    // 函数调用:用函数参数类型推导的方式来解释 rx 的类型推导

就像前面说的,auto 的类型推导只有一个例外(很快就会说明),其他的和模版的推导是一样的。

Item 1 把模版类型的推导按照参数类型的不同分成了3部分,param 的类型说明是普通的函数模版规则。而将变量定义成 auto,就好像将 ParamType 的类型描述的替代品,因此这里也有3个 Case:

  • 如果类型是指针或者引用,但不是 Univeral Reference。
  • 类型是一个 Univeral Reference。
  • 类型既不是指针也不是引用。

我们已经看到这 3 个 Case 了:

auto x = 27;        // 符合 Case 3: x 既不是指针也不是引用
const auto cx = x;  // 符合 Case 3: cx 既不是指针也不是引用
const auto& rx = x; // 符合 Case 1: rx 是引用,但不是 Univeral Reference

2 的情况如下:

auto&& uref1 = x;  // x 是 int 并且是一个左值,所以 uref1 的类型是 int&
auto&& uref2 = cx; // cx 是 const int 并且是一个左值,所以 uref2 的类型是 const int &
auto&& uref3 = 27; // 27 是一个右值,所以 uref2 的类型是 int&&

Item 1 讲到了数组和函数如何退化为非引用型指针,这个规则同样适用于 auto 的推导规则。

const char name[] = "R. N. Briggs";  // name 的类型是 const char[13]
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 的推导和模版的推导差不多,本质上都是一样的。除了一点,现在开始看一下你是不是想定义一个初始值为 27 的 int 型变量,C++98 提供了两种语法:

int x1 = 27;
int x2(27);

而到了 C++11,通过支持统一初始化,如下:

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

所有的四个语法都在做一件事情,就是定义一个 int 变量并赋初值 27。

在 Item 5 中解释到,使用 auto 定义变量比直接使用类型定义有优势,所以最好把上面的 int 都换成 auto,简单的替换以后代码如下:

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

这些声明都可以编译通过,但是在他们换掉的那一刻意义并不相同。前两个声明,很明显,声明了一个初始值为 27 的变量。后面两个声明的类型是 std::initializer_list<int> ,里面装着一个值为 27 的元素。

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

这就导致 auto 推导过程中的一个特殊的规则,当 auto 变量的初始值被大括号包住,那么它的类型就会推导成 std::initializer_list。如果这种类型不能被推导出来(比如,大括号里面的多个值之间是不同的类型),代码就会报错,代码如下:

auto x5 = {1, 2, 3.0f};  // 错误!无法推导出元素的类型 T,导致 std::initializer_list<T> 无法被推导

就像代码注释里面描述到的,类型推导会失败,认识到这个里面有 2 种类型推导是很重要的。既然使用了 auto,那么 x5 的类型必须被推导出来,因为 x5 的初始化数据已经都在大括号内了。x5 肯定是被推导成了 std::initializer_list。但是 std::initializer_list 是一个模版。必须有明确的类型 T,才能实例化模版 std::initializer_list<T>,这就意味着 T 的类型必须可以确定。类似的推导失败在于有第二种类型的推导发生在这里的模版类型的推导。在这个例子中,推导的失败是因为括号内的初始化值不止包含一种类型。

处理大括号初始化是 auto 推导 和模版推导唯一不一样的地方。当一个使用 auto 定义的变量通过大括号来进行初始化,那么推导出来的类型就是一个模版实例化后的一个( T 的类型是确定的)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

所以,如果你指定了函数的参数 param 的类型是 std::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 的类型推导在用花括号来进行初始化的时候会有这样特殊的规则,但模版却没有。其实我也想知道,但是到目前为止我还没有找到一个合理的解释。但是规则就是规则,这也意味着你只能记住它,使用它。当你非常习惯使用花括号的方式去初始化的时候,切记这个规则非常重要。一个经典的 C++11 的错误就是声明了一个 std::initializer_list 变量但是你其实是想声明别的类型。产生这个问题的原因之一就是一些开发者只有在必须放置大括号初始化的地方放置大括号。(欲知详情,阅读 Item 7 )

在 C++ 11 里,这是个故事,而在 C++14中,这个传说还在继续。C++ 14 允许 auto 推导函数返回值的雷习惯。然而,这种推导采用的是模版推导的规则而不是 auto 的规则。因此,auto 返回值的函数是不能用花括号的方式,编译不会通过,见下方代码:

auto createInitList()
{
    return {1, 2, 3};     // 错误,这里无法推导{1, 2, 3}
}

当 auto 作为 lambdas 表达式的返回值时结果是一样的,见下方代码:

std::vector<int> v;
...
auto resetV = [&v](const auto& newValue) { v = newValue; };  // C++ 14 lambdas
...

resetV({1, 2, 3});   // 错误,无法推导返回类型 {1, 2, 3}

重点总结:

  • auto 的推导大多数情况下和模版类似,但是 auto 可以推导出成员初始化列表,但是模版不行。
  • auto 在函数的返回值或者 lambda 表达式,那么 auto 的推导符合模版推导的规则

发表评论