写点什么

深入解析 C++ 的 auto 自动类型推导

作者:爱分享
  • 2024-04-11
    广东
  • 本文字数:7223 字

    阅读完需:约 24 分钟

深入解析C++的auto自动类型推导

关键字 auto 在 C++98 中的语义是定义一个自动生命周期的变量,但因为定义的变量默认就是自动变量,因此这个关键字几乎没有人使用。于是 C++标准委员会在 C++11 标准中改变了 auto 关键字的语义,使它变成一个类型占位符,允许在定义变量时不必明确写出确切的类型,让编译器在编译期间根据初始值自动推导出它的类型。这篇文章我们来解析 auto 自动类型推导的推导规则,以及使用 auto 有哪些优点,还有罗列出自 C++11 重新定义了 auto 的含义以后,在之后发布的 C++14、C++17、C++20 标准对 auto 的更新、增强的功能,以及 auto 有哪些使用限制。

推导规则

我们将以下面的形式来讨论:

auto var = expr;
复制代码

这时 auto 代表了变量 var 的类型,除此形式之外还可以再加上一些类型修饰词,如:

const auto var = expr;// 或者const auto& var = expr;
复制代码

这时变量 var 的类型是 const auto 或者 const auto&,const 也可以换成 volatile 修饰词,这两个称为 CV 修饰词,引用 &也可以换成指针*,如 const auto*,这时明确指出定义的是指针类型。

根据上面定义的形式,根据“=”左边 auto 的修饰情况分为三种情形:

  • 规则一:只有 auto 的情况,既非引用也非指针,表示按值初始化

如下的定义:

auto i = 1;	// i为intauto d = 1.0;	// d为double
复制代码

变量 i 将被推导为 int 类型,变量 d 将被推导为 double 类型,这时是根据“=”右边的表达式的值来推导出 auto 的类型,并将它们的值复制到左边的变量 i 和 d 中,因为是将右边 expr 表达式的值复制到左边变量中,所以右边表达式的 CV(const 和 volatile)属性将会被忽略掉,如下的代码:

const int ci = 1;auto i = ci;	// i为int
复制代码

尽管 ci 是有 const 修饰的常量,但是变量 i 的类型是 int 类型,而非 const int,因为此时 i 拷贝了 ci 的值,i 和 ci 是两个不相关的变量,分别有不同的存储空间,变量 ci 不可修改的属性不代表变量 i 也不可修改。

当使用 auto 在同一条语句中定义多个变量时,变量的初始值的类型必须要统一,否则将无法推导出类型而导致编译错误:

auto i = 1, j = 2;	// i和j都为intauto i = 1, j = 2.0;	// 编译错误,i为int,j为double
复制代码
  • 规则二:形式如 auto&或 auto*,表示定义引用或者指针

当定义变量时使用如 auto&或 auto*的类型修饰,表示定义的是一个引用类型或者指针类型,这时右边的 expr 的 CV 属性将不能被忽略,如下的定义:

int x = 1;const int cx = x;const int& rx = x;auto& i = x;	// (1) i为int&auto& ci = cx;	// (2) ci为const int&auto* pi = ℞	// (3) pi为const int*
复制代码

(1)语句中 auto 被推导为 int,因此 i 的类型为 int&。(2)语句中 auto 被推导为 const int,ci 的类型为 const int &,因为 ci 是对 cx 的引用,而 cx 是一个 const 修饰的常量,因此对它的引用也必须是常量引用。(3)语句中的 auto 被推导为 const int,pi 的类型为 const int*,rx 的 const 属性将得到保留。

除了下面即将要讲到的第三种情况外,auto 都不会推导出结果是引用的类型,如果要定义为引用类型,就要像上面那样明确地写出来,但是 auto 可以推导出来是指针类型,也就是说就算没有明确写出 auto*,如果 expr 的类型是指针类型的话,auto 则会被推导为指针类型,这时 expr 的 const 属性也会得到保留,如下的例子:

int i = 1;auto pi = &i;	// pi为int*const char word[] = "Hello world!";auto str = word;	// str为const char*
复制代码

pi 被推导出来的类型为 int*,而 str 被推导出来的类型为 const char*。

  • 规则三:形式如 auto&&,表示万能引用

当以 auto&&的形式出现时,它表示的是万能引用而非右值引用,这时将视 expr 的类型分为两种情况,如果 expr 是个左值,那么它推导出来的结果是一个左值引用,这也是 auto 被推导为引用类型的唯一情形。而如果 expr 是个右值,那么将依据上面的第一种情形的规则。如下的例子:

int x = 1;const int cx = x;auto&& ref1 = x;	// (1) ref1为int&auto&& ref2 = cx;	// (2) ref2为const int&auto&& ref3 = 2;	// (3) ref3为int&&
复制代码

(1)语句中 x 的类型是 int 且是左值,所以 ref1 的类型被推导为 int&。(2)语句中的 cx 类型是 const int 且是左值,因此 ref2 的类型被推导为 const int&。(3)语句中右侧的 2 是一个右值且类型为 int,所以 ref3 的类型被推导为 int&&。

上面根据“=”左侧的 auto 的形式归纳讨论了三种情形下的推导规则,接下来根据“=”右侧的 expr 的不同情况来讨论推导规则:

  • expr 是一个引用

如果 expr 是一个引用,那么它的引用属性将被忽略,因为我们使用的是它引用的对象,而非这个引用本身,然后再根据上面的三种推导规则来推导,如下的定义:

int x = 1;int &rx = x;const int &crx = x;auto i = rx;	// (1) i为intauto j = crx;	// (2) j为intauto& ri = crx;	// (3) ri为const int&
复制代码

(1)语句中 rx 虽然是个引用,但是这里是使用它引用的对象的值,所以根据上面的第一条规则,这里 i 被推导为 int 类型。(2)语句中的 crx 是个常量引用,它和(1)语句的情况一样,这里只是复制它所引用的对象的值,它的 const 属性跟变量 j 没有关系,所以变量 j 的类型为 int。(3)语句里的 ri 的类型修饰是 auto&,所以应用上面的第二条规则,它是一个引用类型,而且 crx 的 const 属性将得到保留,因此 ri 的类型推导为 const int&。

  • expr 是初始化列表

当 expr 是一个初始化列表时,分为两种情况而定:

auto var = {};	// (1)// 或者auto var{};	// (2)
复制代码

当使用第一种方式时,var 将被推导为 initializer_list<T>类型,这时无论花括号内是单个元素还是多个元素,都是推导为 initializer_list<T>类型,而且如果是多个元素,每个元素的类型都必须要相同,否则将编译错误,如下例子:

auto x1 = {1, 2, 3, 4};		// x1为initializer_list<int>auto x2 = {1, 2, 3, 4.0};	// 编译错误
复制代码

x1 的类型为 initializer_list<int>,这里将经过两次类型推导,第一次是将 x1 推导为 initializer_list<T>类型,第二次利用花括号内的元素推导出元素的类型 T 为 int 类型。x2 的定义将会引起编译错误,因为 x2 虽然推导为 initializer_list<T>类型,但是在推导 T 的类型时,里面的元素的类型不统一,导致无法推导出 T 的类型,引起编译错误。

当使用第二种方式时,var 的类型被推导为花括号内元素的类型,花括号内必须为单元素,如下:

auto x1{1};	// x1为intauto x2{1.0};	// x2为double
复制代码

x1 的类型推导为 int,x2 的类型推导为 double。这种形式下花括号内必须为单元素,如果有多个元素将会编译错误,如:

auto x3{1, 2};	// 编译错误
复制代码

这个将导致编译错误:error: initializer for variable 'x3' with type 'auto' contains multiple expressions。

  • expr 是数组或者函数

数组在某些情况会退化成一个指向数组首元素的指针,但其实数组类型和指针类型并不相同,如下的定义:

const char name[] = "My Name";const char* str = name;
复制代码

数组 name 的类型是 const char[8],而 str 的类型为 const char*,在某些语义下它们可以互换,如在第一种规则下,expr 是数组时,数组将退化为指针类型,如下:

const char name[] = "My Name";auto str = name;	// str为const char*
复制代码

str 被推导为 const char*类型,尽管 name 的类型为 const char[8]。

但如果定义变量的形式是引用的话,根据上面的第二种规则,它将被推导为数组原本的类型:

const char name[] = "My Name";auto& str = name;	// str为const char (&)[8]
复制代码

这时 auto 被推导为 const char [8],str 是一个指向数组的引用,类型为 const char (&)[8]。

当 expr 是函数时,它的规则和数组的情况类似,按值初始化时将退化为函数指针,如为引用时将为函数的引用,如下例子:

void func(int, double) {}auto f1 = func;		// f1为void (*)(int, double)auto& f2 = func;	// f2为void (&)(int, double)
复制代码

f1 的类型推导出来为 void (*)(int, double),f2 的类型推导出来为 void (&)(int, double)。

  • expr 是条件表达式语句

当 expr 是一个条件表达式语句时,条件表达式根据条件可能返回不同类型的值,这时编译器将会使用更大范围的类型来作为推导结果的类型,如:

auto i =  condition ? 1 : 2.0;	// i为double
复制代码

无论 condition 的结果是 true 还是 false,i 的类型都将被推导为 double 类型。

使用 auto 的好处

  • 强制初始化的作用

当你定义一个变量时,可以这样写:

int i;
复制代码

这样写编译是能够通过的,但是却有安全隐患,比如在局部代码中定义了这个变量,然后又接着使用它了,可能面临未初始化的风险。但如果你这样写:

auto i;
复制代码

这样是编译不通过的,因为变量 i 缺少初始值,你必须给 i 指定初始值,如下:

auto i = 0;
复制代码

必须给变量 i 初始值才能编译通过,这就避免了使用未初始化变量的风险。

  • 定义小范围内的局部变量时

在小范围的局部代码中定义一个临时变量,对理解整体代码不会造成困扰的,比如:

for (auto i = 1; i < size(); ++i) {}
复制代码

或者是基于范围的 for 循环的代码,只是想要遍历容器中的元素,对于元素的类型不关心,如:

std::vector<int> v = {};for (const auto& i : v) {}
复制代码
  • 减少冗余代码

当变量的类型非常长时,明确写出它的类型会使代码变得又臃肿又难懂,而实际上我们并不关心它的具体类型,如:

std::map<std::string, int> m;for (std::map<std::string, int>::iterator it = m.begin(); it != m.end(); ++it) {}
复制代码

上面的代码非常长,造成阅读代码的不便,对增加理解代码的逻辑也没有什么好处,实际上我们并不关心 it 的实际类型,这时使用 auto 就使代码变得简洁:

for (auto it = m.begin(); it != m.end(); ++it) {}
复制代码

再比如下面的例子:

std::unordered_multimap<int, int> m;std::pair<std::unordered_multimap<int, int>::iterator,		  std::unordered_multimap<int ,int>::iterator>	range = m.equal_range(k);
复制代码

对于上面的代码简直难懂,第一遍看还看不出来想代表的意思是什么,如果改为 auto 来写,则一目了然,一看就知道是在定义一个变量:

auto range = m.equal_range(k);
复制代码
  • 无法写出的类型

如果说上面的代码虽然难懂和难写,毕竟还可以写出来,但有时在某些情况下却无法写出来,比如用一个变量来存储 lambda 表达式时,我们无法写出 lambda 表达式的类型是什么,这时可以使用 auto 来自动推导:

auto compare = [](int p1, int p2) { return p1 < p2; }
复制代码
  • 避免对类型硬编码

除了上面提到的可以减少代码的冗余之外,使用 auto 也可以避免对类型的硬编码,也就是说不写死变量的类型,让编译器自动推导,如果我们要修改代码,就不用去修改相应的类型,比如我们将一种容器的类型改为另一种容器,迭代器的类型不需要修改,如:

std::map<std::string, int> m = { ... };auto it = m.begin();// 修改为无序容器时std::unordered_map<std::string, int> m = { ... };auto it = m.begin();
复制代码

C++标准库里的容器大部分的接口都是相同的,泛型算法也能应用于大部分的容器,所以对于容器的具体类型并不是很重要,当根据业务的需要更换不同的容器时,使用 auto 可以很方便的修改代码。

  • 跨平台可移植性

假如你的代码中定义了一个 vector,然后想要获取 vector 的元素的大小,这时你调用了成员函数 size 来获取,此时应该定义一个什么类型的变量来承接它的返回值?vector 的成员函数 size 的原型如下:

size_type size() const noexcept;
复制代码

size_type 是 vector 内定义的类型,标准库对它的解释是“an unsigned integral type that can represent any non-negative value of difference_type”,于是你认为用 unsigned 类型就可以了,于是写下如下代码:

std::vector<int> v;unsigned sz = v.size();
复制代码

这样写可能会导致安全隐患,比如在 32 位的系统上,unsigned 的大小是 4 个字节,size_type 的大小也是 4 个字节,但是在 64 位的系统上,unsigned 的大小是 4 个字节,而 size_type 的大小却是 8 个字节。这意味着原本在 32 位系统上运行良好的代码可能在 64 位的系统上运行异常,如果这里用 auto 来定义变量,则可以避免这种问题。

  • 避免写错类型

还有一种似是而非的问题,就是你的代码看起来没有问题,编译也没有问题,运行也正常,但是效率可能不如预期的高,比如有以下的代码:

std::unordered_map<std::string, int> m = { ... };for (const std::pair<std::string, int> &p : m) {}
复制代码

这段代码看起来完全没有问题,编译也没有任何警告,但是却暗藏隐患。原因是 std::unordered_map 容器的键值的类型是 const 的,所以 std::pair 的类型不是 std::pair<std::string, int>而是 std::pair<const std::string, int>。但是上面的代码中定义 p 的类型是前者,这会导致编译器想尽办法来将 m 中的元素(类型为 std::pair<const std::string, int>)转换成 std::pair<std::string, int>类型,因此编译器会拷贝 m 中的所有元素到临时对象,然后再让 p 引用到这些临时对象,每迭代一次,临时对象就被析构一次,这就导致了无故拷贝了那么多次对象和析构临时对象,效率上当然会大打折扣。如果你用 auto 来替代上面的定义,则完全可以避免这样的问题发生,如:

for (const auto& p : m) {}
复制代码

新标准新增功能

  • 自动推导函数的返回值类型(C++14)

C++14 标准支持了使用 auto 来推导函数的返回值类型,这样就不必明确写出函数返回值的类型,如下的代码:

template<typename T1, typename T2>auto add(T1 a, T2 b) {    return a + b;}
int main() { auto i = add(1, 2);}
复制代码

不用管传入给 add 函数的参数的类型是什么,编译器会自动推导出返回值的类型。

  • 使用 auto 声明 lambda 的形参(C++14)

C++14 标准还支持了可以使用 auto 来声明 lambda 表达式的形参,但普通函数的形参使用 auto 来声明需要 C++20 标准才支持,下面会提到。如下面的例子:

auto sum = [](auto p1, auto p2) { return p1 + p2; };
复制代码

这样定义的 lambda 式有点像是模板,调用 sum 时会根据传入的参数推导出类型,你可以传入 int 类型参数也可以传入 double 类型参数,甚至也可以传入自定义类型,如果自定义类型支持加法运算的话。

  • 非类型模板形参的占位符(C++17)

C++17 标准再次拓展了 auto 的功能,使得能够作为非类型模板形参的占位符,如下的例子:

template<auto N>void func() {    std::cout << N << std::endl;}
func<1>(); // N为int类型func<'c'>(); // N为chat类型
复制代码

但是要保证推导出来的类型是能够作为模板形参的,比如推导出来是 double 类型,但模板参数不能接受是 double 类型时,则会导致编译不通过。

  • 结构化绑定功能(C++17)

C++17 标准中 auto 还支持了结构化绑定的功能,这个功能有点类似 tuple 类型的 tie 函数,它可以分解结构化类型的数据,把多个变量绑定到结构化对象内部的对象上,在没有支持这个功能之前,要分解 tuple 里的数据需要这样写:

tuple x{1, "hello"s, 5.0};itn a;std::string b;double c;std::tie(a, b, c) = x;	// a=1, b="hello", c=5.0
复制代码

在 C++17 之后可以使用 auto 来这样写:

tuple x{1, "hello"s, 5.0};auto [a, b, c] = x;	// 作用如上std::cout << "a=" << a << ", b=" << b << ", c=" << c << std::endl;
复制代码

auto 的推导功能从以前对单个变量进行类型推导扩展到可以对一组变量的推导,这样可以让我们省略了需要先声明变量再处理结构化对象的麻烦,特别是在 for 循环中遍历容器时,如下:

std::map<std::string, int> m;for (auto& [k, v] : m) {    std::cout << k << " => " << v << std::endl;}
复制代码
  • 使用 auto 声明函数的形参(C++20)

之前提到无法在普通函数中使用 auto 来声明形参,这个功能在 C++20 中也得到了支持。你终于可以写下这样的代码了:

auto add (auto p1, auto p2) { return p1 + p2; };auto i = add(1, 2);auto d = add(5.0, 6.0);auto s = add("hello"s, "world"s);	// 必须要写上s,表示是string类型,默认是const char*,                                	// char*类型是不支持加法的
复制代码

这个看起来是不是和模板很像?但是写法要比模板要简单,通过查看生成的汇编代码,看到编译器的处理方式跟模板的处理方式是一样的,也就是说上面的三个函数调用分别产生出了三个函数实例:

auto add<int, int>(int, int);auto add<double, double>(double, double);auto add<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > >(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >);
复制代码

使用 auto 的限制

上面详细列出了使用 auto 的好处和使用场景,但在有些地方使用 auto 还存在限制,下面也一并罗列出来。

  • 类内初始化成员时不能使用 auto

在 C++11 标准中已经支持了在类内初始化数据成员,也就是说在定义类时,可以直接在类内声明数据成员的地方直接写上它们的初始值,但是在这个情况下不能使用 auto 来声明非静态数据成员,比如:

class Object {	auto a = 1;	// 编译错误。};
复制代码

上面的代码会出现编译错误:error: 'auto' not allowed in non-static class member。虽然不能支持声明非静态数据成员,但却可以支持声明静态数据成员,在 C++17 标准之前,使用 auto 声明静态数据成员需要加上 const 修饰词,这就给使用上造成了不便,因此在 C++17 标准中取消了这个限制:

class Object {	static inline auto a = 1;	// 需要写上inline修饰词};
复制代码
  • 函数无法返回 initializer_list 类型

虽然在 C++14 中支持了自动推导函数的返回值类型,但却不支持返回的类型是 initializer_list<T>类型,因此下面的代码将编译不通过:

auto createList() {    return {1, 2, 3};}
复制代码

编译错误信息:error: cannot deduce return type from initializer list。

  • lambda 式参数无法使用 initializer_list 类型

同样地,在 lambda 式使用 auto 来声明形参时,也不能给它传递 initializer_list<T>类型的参数,如下代码:

std::vector<int> v;auto resetV = [&v](const auto& newV) { v = newV; };resetV({1, 2, 3});
复制代码

上面的代码会编译错误,无法使用参数{1, 2, 3}来推导出 newV 的类型。


此篇文章同步发布于我的微信公众号:https://mp.weixin.qq.com/s/qa1Coxsx7oPfWRNNV8l1-A如果您感兴趣这方面的内容,请在微信上搜索公众号 iShare 爱分享或者微信号 iTechShare 并关注,或者扫描以下二维码关注,以便在内容更新时直接向您推送。


公众号:iShare爱分享


发布于: 刚刚阅读数: 5
用户头像

爱分享

关注

还未添加个人签名 2024-04-08 加入

还未添加个人简介

评论

发布
暂无评论
深入解析C++的auto自动类型推导_C++11_爱分享_InfoQ写作社区