条款2:理解auto类型推导

优质
小牛编辑
125浏览
2023-12-01

如果你已经阅读了条款1关于模板相关的类型推导,你就已经知道了机会所有关于auto的类型推导,因为除了一个例外,auto类型推导就是模板类型推导。但是它怎么就会是模板类型推导呢?模板类型推导涉及模板和函数以及参数,但是auto和上面的这些没有任何的关系。

这是对的,但是没有关系。模板类型推导和auto类型推导是有一个直接的映射。有一个书面上的从一种情况转换成另外一种情况的算法。

在条款1,模板类型推导是使用下面的通用模板函数来解释的:

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

在这里通常调用:

f(expr);                    // 使用一些表达式来当做调用f的参数

在调用f的地方,编译器使用expr来推导TParamType的类型。

当一个变量被声明为autoauto相当于模板中的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>
void func_for_cx(const T param);    // 推导cx的概念上的模板

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

template<typename T>
void func_for_rx(const T& param);   // 推导rx概念上的模板

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

正如我所说,对auto的类型推导只存在一种情况的例外(这个后面就会讨论),其他的就和模板类型推导完全一样了。

条款1把模板类型推导划分成三部分,基于在通用的函数模板的ParamType的特性和param的类型声明。在一个用auto声明的变量上,类型声明代替了ParamType的作用,所以也有三种情况:

  • 情况1:类型声明是一个指针或者是一个引用,但不是一个通用的引用
  • 情况2:类型声明是一个通用引用
  • 情况3:类型声明既不是一个指针也不是一个引用

我们已经看了情况1和情况3的例子:

auto x = 27;                        // 情况3(x既不是指针也不是引用)

const auto cx = x;                  // 情况3(cx二者都不是)

const auto& rx = x;                 // 情况1(rx是一个非通用的引用)

情况2正如你期待的那样:

auto&& uref1 = x;                   // x是int并且是左值
                                    // 所以uref1的类型是int&

auto&& uref2 = cx;                  // cx是int并且是左值
                                    // 所以uref2的类型是const int&

auto&& uref3 = 27;                  // 27是int并且是右值
                                    // 所以uref3的类型是int&&

条款1讲解了在非引用类型声明里,数组和函数名称如何退化成指针。这在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& func2 = someFunc;             // func1的类型是
                                    // void (&)(int, double)

正如你所见,auto类型推导和模板类型推导工作很类似。它们就像一枚硬币的两面。

除了有一种情况是不一样的。我们从如果你想声明一个用27初始化的int, C++98你有两种语法选择:

int x1 = 27;
int x2(27);

C++11,通过标准支持的统一初始化(使用花括号初始化——译者注),可以添加下面的代码:

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

综上四种语法,都会生成一种结果:一个拥有27数值的int

但是正如条款5所解释的,使用auto来声明变量比使用固定的类型更好,所以在上述的声明中把int换成auto更好。最直白的写法就如下面的代码:

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

上面的所有声明都可以编译,但是他们和被替换的相对应的语句的意义并不一样。头两个的确是一样的,声明一个初始化值为27的int。然而后面两个,声明了一个类型为std::intializer_list<int>的变量,这个变量包含了一个单一的元素27!

auto x1 = 27;                       // 类型时int,值是27

auto x2(27);                        // 同上

auto x3 = { 27 };                   // 类型是std::intializer_list<int>
                                    // 值是{ 27 }

auto x4{ 27 };                      // 同上

这和auto的一种特殊类型推导有关系。当使用一对花括号来初始化一个auto类型的变量的时候,推导的类型是std::intializer_list。如果这种类型无法被推导(比如在花括号中的变量拥有不同的类型),代码会编译错误。

auto x5 = { 1, 2, 3.0 };            // 错误! 不能讲T推导成
                                    // std::intializer_list<T>

正如注释中所说的,在这种情况,类型推导会失败,但是认识到这里实际上是有两种类型推导是非常重要的。一种是auto: x5的类型被推导。因为x5的初始化是在花括号里面,x5必须被推导成std::intializer_list。但是std::intializer_list是一个模板。实例是对一些T实例化成std::intializer_list<T>,这就意味着T的类型必须被推导出来。类型推导就在第二种的推导的范围上失败了。在这个例子中,类型推导失败是因为在花括号里面的数值并不是单一类型的。

对待花括号初始化的行为是auto唯一和模板类型推导不一样的地方。当auto声明变量被使用一对花括号初始化,推导的类型是std::intializer_list的一个实例。但是如果相同的初始化递给相同的模板,类型推导会失败,代码不能编译。

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

template<typename T>                // 和x的声明等价的
void f(T param);                    // 模板

f({ 11, 23, 9 });                   // 错误的!没办法推导T的类型

但是,如果你明确模板的param的类型是一个不知道T类型的std::initializer_list<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声明一个变量并且使用花括号来初始化它,类型推导的就是std::initializer_list。你必须习惯这种花括号的初始化哲学——使用花括号里面的数值来初始化是理所当然的。在C++11编程里面的一个经典的错误就是误被声明成std::initializer_list,而其实你是想声明另外的一种类型。这个陷阱使得一些开发者仅仅在必要的时候才会在初始化数值周围加上花括号。(什么时候是必要的会在条款7里面讨论。)

对于C++11,这是一个完整的故事,但是对于C++14来说,故事还要继续。C++14允许auto表示推导的函数返回值(参看条款3),而且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,但是模板类型推导却不是这样
auto在函数返回值或者lambda参数里面执行模板的类型推导,而不是通常意义的auto类型推导