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

来自:博客园
时间:2024-04-11
阅读:

关键字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为int
auto 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都为int
auto 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为int
auto j = crx;	// (2) j为int
auto& 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类型,这时无论花括号内是单个元素还是多个元素,都是推导为initializer_list类型,而且如果是多个元素,每个元素的类型都必须要相同,否则将编译错误,如下例子:

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

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

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

auto x1{1};	// x1为int
auto 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类型,因此下面的代码将编译不通过:

auto createList() {
    return {1, 2, 3};
}

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

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

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

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

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


此篇文章同步发布于我的微信公众号:深入解析C++的auto自动类型推导

返回顶部
顶部