一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第6天,点击查看活动详情。
概念
我们都知道,现代的编程语言,不管是动态语言(JavaScript、Python 等),还是静态语言(Go、Rust 等),大都支持自动类型推导(type deduction)。自动类型推导,通俗地讲就是定义一个变量的时候不需要明确指定类型,而是让编译器根据上下文进行推导。
对于C++语言,在 C++11 之前,就只有模板(template)代码就支持编译器自动类型推导。C++11 很重要的一个特性就是加强了编译器自动类型推导的能力,使之不限于模板 —— 与此相关的关键字有两个 auto 和 decltype 。通过这两个关键字不仅能方便地获取复杂的类型,而且还能简化书写,提高编码效率。但是有利也有弊,增加简洁可能就会需要增加学习度,如果不能深入理解类型推断背后的规则与机理,很可能就会出现用法错误。基于我们上一篇文章已经介绍了模板类型推导。本节我们先讲解 auto 关键字,下节再讲解 decltype 关键字。
auto用法
通常auto的用法有下列两种:
auto declaratorinitializer; // 普通变量声明定义
auto f = [](auto param1, auto param2) {}; // lambda表达式参数
基本类型
我们来看看 auto 关键字在 C++ 中的使用。 最简单的用法,定义变量的时候不指定类型,通过初始化的值让编译器自动推导。在C++11标准的语法中,auto被定义为自动推断变量的类型。不过C++11的auto关键字时有一个限定条件,那就是必须给申明的变量赋予一个初始值,否则编译器在编译阶段将会报错。
auto a; // 编译error,initializer required
auto b = 0; // b 是 int 类型
auto c = 0ull; // c 是 unsigned long long 类型
auto d = "Hello World"; // d 是 const char* 类型
auto e = std::string("Hello"); // e 是 std::string 类型
容器类型和迭代器
auto 和容器类型、迭代器一起配合使用,可以简化代码,代码也更简洁、清晰。
std::vector<int> v(10, 1);
auto it_begin = v.begin(); // std::vector<int>::iterator
auto it_end = v.end(); // std::vector<int>::iterator
auto sz = v.size(); // std::vector<int>::size_type
std::map<int,list<string>> m;
auto i = m.begin(); // std::map<int,list<string>>::iterator
引用和 cv 限定符
特别要注意,使用 auto 会删除引用、 const 限定符和 volatile 限定符。如果定义的对象不是指针或者引用,则const属性会被丢弃。 请看下面示例:
#include <iostream>
int main() {
int count = 10;
int& countRef = count;
auto myAuto = countRef;
countRef = 11;
std::cout << count << " ";
myAuto = 12;
std::cout << count << std::endl;
}
在上面的程序中,myAuto 是一个int,而不是引用int,因此,如果引用限定符尚未被auto删除,则输出11 11,而不是11 12。如果我们希望推导出的auto类型保留上层const或引用,我们需要明确指出。例如:
const int i = 2;
const auto cvAuto = i; // save const
auto& ri = i; // save reference
指针和引用
如果定义的对象是指针或者引用,则const属性被保留。 auto关键字与指针:设置类型为auto的指针,初始值的const属性仍然保留。
#include <iostream>
#include <string>
#include <typeinfo>
int main() {
int i=2;
const int ci=i;
auto a=&i; // a是整型指针(整数的地址就是指向整数的指针);
auto b=&ci; // b是指向整型常量的指针(对const对象取地址是一种底层const);
std::cout << typeid(a).name() << '\n'; // Pi
std::cout << typeid(b).name() << '\n'; // PKi
// 用auto声明指针类型时,用auto和auto *没有任何区别;
auto *ppi=&i;
auto pi=&i; // 两种方式没有差别;
std::cout << typeid(ppi).name() << '\n'; // Pi
std::cout << typeid(pi).name() << '\n'; // Pi
//用auto声明引用类型时则必须加&;
auto &rri=i; // rri为引用;
auto ri=i; // ri为int;
std::cout << typeid(rri).name() << '\n'; // i
std::cout << typeid(ri).name() << '\n'; // i
}
auto关键字与引用:在使用auto关键字,使用引用其实是使用引用的对象。特别是当引用被用作初始值时,真正参与初始化的其实是引用的对象的值,此时编译器以引用对象的类型作为auto的类型。另外设置类型为auto的引用,初始值的const属性仍然保留。例如:
#include <iostream>
#include <string>
#include <typeinfo>
int main() {
int x = 0, &rx = x; // rx是引用
auto a1 = rx; //使用引用其实是使用引用的对象,
//此时auto以引用对象的类型作为auto的类型,
//所以auto这里被推断为 int;
auto &a2 = rx; //此时auto被推断为int类型,a2本身就是int &类型;
const auto &a3 = rx; //auto被推断为int类型,a3对象本身是const int &类型;
//不能通过a3去修改rx引用的对象值;
std::cout << typeid(a1).name() << '\n'; // i
std::cout << typeid(a2).name() << '\n'; // i
std::cout << typeid(a3).name() << '\n'; // i
int i=2;
const int ci=i;
auto &a=ci; // 此时auto被推断为const int类型,a本身是const int &类型;
// auto &b=42; //错误,不能为非const引用绑定字面值;
const auto &c=43; //正确,可以为const引用绑定字面值;
std::cout << typeid(a).name() << '\n'; // i
std::cout << typeid(c).name() << '\n'; // i
}
数组和函数
在一些情况下,数组的操作实际上是指针的操作。意味着适用数组作为一个auto变量的初始值时,推断得到的类型是指针而非数组。例如:
#include <iostream>
#include <string>
#include <typeinfo>
int add(int a,int b){ return a+b; } // 函数声明;
int main() {
int ia[]={1,2,3,4,5};
auto ia2(ia); // 此时ia2是整型指针,指向ia的第一个元素,
// 相当于auto ia2(&ia[0]);
std::cout << typeid(ia2).name() << '\n'; // Pi
decltype(ia) ia3={0,1,2,3,4}; // 此时ia3是一个数组,下一篇我们介绍decltype;
std::cout << typeid(ia3).name() << '\n'; // A5_i
const char arr[] = "I Love China";
auto r1 = arr; // 如果将数组名赋值给auto变量,那么auto推断的结果是指针类型,
// 如果有const属性,则保留,auto推断的结果是const char *;
auto &r2 = arr; // 如果将数组名赋值给auto &变量,
// auto &变量的类型是一个数组引用类型,即为const char (&)[14];
std::cout << typeid(r1).name() << '\n'; // PKc
std::cout << typeid(r2).name() << '\n'; // A13_c
auto r3 = add; // r3为函数指针:int(*)(int, int);
auto &r4 = add; // r4为函数引用:int(&)(int, int);
std::cout << typeid(r3).name() << '\n'; // PFiiiE
std::cout << typeid(r4).name() << '\n'; // FiiiE
}
复合类型
下面的代码示例演示如何使用大括号初始化 auto 变量。 请注意 B 和 C 与 A 与 E 之间的差异。
#include <iostream>
int main(){
// auto初始化表达式可以采用多种形式:
auto a(1); // 直接初始化或构造函数样式的语法,int
auto b{ 2 }; // 通用初始化语法,int
auto c = 3; // 赋值语法,int
auto d = { 4 }; // 通用赋值语法,是一个列表:std::initializer_list<int>
std::cout << typeid(a).name() << '\n'; // i
std::cout << typeid(b).name() << '\n'; // i
std::cout << typeid(c).name() << '\n'; // i
std::cout << typeid(d).name() << '\n'; // St16initializer_listIiE
auto A = { 1, 2 }; // std::initializer_list<int>
auto B = { 3 }; // std::initializer_list<int>
auto C{ 4 }; // int
std::cout << typeid(A).name() << '\n'; // St16initializer_listIiE
std::cout << typeid(B).name() << '\n'; // St16initializer_listIiE
std::cout << typeid(C).name() << '\n'; // St16initializer_listIiE
// error: cannot deduce type for 'auto' from initializer list'
auto D = { 5, 6.3 }; // 不允许两种不同类型
// error in a direct-list-initialization context the type for 'auto'
// can only be deduced from a single initializer expression
auto E{ 8, 9 }; // 必须要用=号
}
关键字 auto 是声明具有复杂类型的变量的简单方法。 例如,可用于 auto 声明初始化表达式涉及模板、指向函数的指针或指向成员的指针的变量。
还可以用于 auto 向 lambda 表达式声明和初始化变量。 您不能自行声明变量的类型,因为仅编译器知道 lambda 表达式的类型。
这和auto的一种特殊类型推导有关系。当使用一对花括号来初始化一个auto类型的变量的时候,推导的类型是std::intializer_list。如果这种类型无法被推导(比如在花括号中的变量拥有不同的类型),代码会编译错误。
更多用法
以下代码片段声明变量 iter 的类型以及 elem 何时 for 启动范围 for 循环。
#include <vector>
using namespace std;
int main() {
vector<double> vtDoubleData(10, 0.1);
for (auto iter = vtDoubleData.begin(); iter != vtDoubleData.end(); ++iter)
{ /* ... */ }
// prefer range-for loops with the following information in mind
// (this applies to any range-for with auto, not just vector, deque, array...)
// COPIES elements, not much better than the previous examples
for (auto elem : vtDoubleData)
{ /* ... */ }
// observes and/or modifies elements IN-PLACE
for (auto& elem : vtDoubleData)
{ /* ... */ }
// observes elements IN-PLACE
for (const auto& elem : vtDoubleData)
{ /* ... */ }
}
以下代码片段使用 new 运算符和指针声明来声明指针:
#include <iostream>
#include <vector>
int main() {
{
double x = 12.34;
auto *y = new auto(x);
auto **z = new auto(&x);
std::cout << typeid(x).name() << '\n'; // d
std::cout << typeid(y).name() << '\n'; // Pd
std::cout << typeid(z).name() << '\n'; // PPd
}
{
int v1 = 300;
int v2 = 200;
auto x = v1 > v2 ? v1 : v2;
std::cout << typeid(x).name() << '\n'; // i
}
}
auto推导规则
首先,结论是auto使用的是模板实参推断(Template Argument Deduction)的机制,也就是我们上一篇文章介绍的。auto被一个虚构的模板类型参数T替代,然后进行推断,即相当于把变量设为一个函数参数,将其传递给模板并推断为实参,auto相当于利用了其中进行的实参推断,承担了模板参数T的作用。比如:
template<typename Container>
void useContainer(const Container& container) {
auto pos = container.begin(); // 1
while (pos != container.end()) {
auto& element = *pos++; // 2
// ... 对元素进行操作
}
}
上面第一个auto的初始化相当于下面这个模板传参时的情形,T就是为auto推断的类型:
// 模板声明定义
template<typename T>
void deducePos(T pos);
// auto pos = container.begin()中auto的推导等价于下列语句推导
deducePos(container.begin());
而auto类型变量不会是引用类型(int&, float&等),所以要用auto&,第二个auto推断对应于下面这个模板传参时的情形,同样T就是为auto推断的类型:
template<typename T>
void deduceElement(T& element);
deduceElement(*pos++);
我们上一篇文章把模板类型推导划分成三部分,基于在通用的函数模板的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是一个非通用的引用)
const auto* rx = &x; // 情况1(rx是一个非通用的指针)
情况2正如你期待的那样:
auto x = 27;
const auto cx = x;
auto&& uref1 = x; // x是int并且是左值,所以uref1的类型是int&
auto&& uref2 = cx; //cx是int并且是左值,所以uref2的类型是const int&
auto&& uref3 = 27; // 27是int并且是右值,所以uref3的类型是int&&
当然我们上一篇文章还介绍了数组以及函数这两种情形,这两种情形对于auto这里也是适用的,上面我们已经把这两种情形的用法介绍了,这里就不详细介绍它的推导了。
但是,唯一例外的是对初始化列表的推断,auto会将其视为std::initializer_list,而模板则不能对其推断
#include <iostream>
template<typename T>
void deduceX(T x) {}
int main(){
auto x = { 1, 2 }; // 不允许对auto用initializer_list直接初始化,必须用=
// auto x1 { 1, 2 }; // 错误
// 保留了单元素列表的直接初始化,但不会将其视为initializer_list,x2是int
auto x2 { 1 };
std::cout << typeid(x).name(); // class std::initializer_list<int>
std::cout << typeid(x2).name(); // C++14中为int
deduceX({ 1, 2 }); // 错误:不能推断T
}
但是,如果你明确模板的param的类型是一个不知道T类型的std::initializer_list<T>:
#include <iostream>
template<typename T>
void deduceX(std::initializer_list<T> initList) {}
int main(){
// void deduceX<int>(std::initializer_list<int>)
deduceX({ 1, 2 }); // 正确:T是int
}
所以auto和模板类型推导的本质区别就是auto假设花括号初始化代表的是std::initializer_list,但是模板类型推导却不是。
你们可能对为什么auto类型推导有一个对花括号初始化有一个特殊的规则而模板的类型推导却没有感兴趣。可惜我也没找到答案。这可能就是规则吧,这就意味着你必须记住如果使用auto声明一个变量并且使用花括号来初始化它,类型推导的就是std::initializer_list。在C++11编程里面的一个经典的错误就是误被声明成std::initializer_list,而其实你是想声明另外的一种类型。这个陷阱使得一些开发者仅仅在必要的时候才会在初始化数值周围加上花括号。
C++14还允许auto作为返回类型,但此时auto仍然使用的是模板实参推断的机制,因此返回类型为auto的函数如果返回一个初始化列表,则会出错:
auto newInitList() { return { 1,2 }; } // 错误
在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 来简化。例如:
for(std::vector<int>::iterator it = v.begin();it != v.end();++it) {}
如果使用auto可以直接写为 :
for(auto it = v.begin();it != v.end();++it) {}
- 当函数返回的值不确定时,可以用auto作为返回值类型,更加方便。编译器会根据返回值的类型推断 auto 的类型,这种语法是在 C++14 才出现的。例如:
auto func() { return 0; }
不适用的场景:
- 函数形参不能是auto类型,比如:
int add(auto a, auto b) { return a + b; } //是不允许的。
- 类的非static成员变量不可以是auto类型。类的static成员变量可以是auto类型的,但需要用const修饰,而且该变量需要类内初始化。例如:
class {
auto x = 6; // error
auto static const i=4;
}
- auto 不能直接用来声明数组。
- 实例化模板时不能使用auto作为模板参数。
总结
在C++11新标准中引进了auto类型说明符,使用它能够让编译器代替我们去分析表达式所属的类型。auto 自动类型推断发生在编译期,所以使用 auto 关键字不会降低程序的运行效率。
但是要注意,虽然auto 是一个很强大的工具,但任何工具都有它的两面性,不加选择地随意使用 auto,会带来代码可读性和维护性的严重下降。因此,在使用 auto 的时候,一定要权衡好它带来的“价值”和相应的“损失”。
总的来说, 我认为模板参数推导 (template argument deduction) 和 auto 其实是一回事, 是基于 C++ 类型系统的类型推导 (type inference) 的两个表现形式。
另外,decltype也是 C++11 新增的一个关键字,它和 auto 的功能一样,都是用来在编译时期进行自动类型推导。decltype比auto更确切地推断名称或表达式的类型(即原始的declared type),实现原理和auto类似,只是特殊情况不太一样,具体我们下一篇文章介绍。
参考
《Effective Modern C++》