auto 与 decltype

本文将介绍C++11中两个与类型有关的关键字,autodecltype ,以及C++11中新提出的类型推导的概念。

1 auto

说到C++11的新特性,从使用方便的角度,不得不提的就是auto。auto在C++11中与C++98中完全是两种概念。在C++98中是一种存储类型指示符(storage-class-specifier,如static、extern、thread_local等), 而在C++11中是一种新的类型指示符(type-specifier, 如int, float, char等)。只是,auto声明变量的类型必须由编译器在编译时期推导而得。另一个与类型有关的则是 decltype,下面分别介绍这两个关键字的用法。

1.1 auto类型推导

在编程语言中,C/C++常被冠以“静态类型”,的称号,而像Python类的语言则被称为“动态类型”的。通常情况下,“静”和“动”的区别非常直观。先看看如下的Python代码:

1
2
name = 'world\n'
print 'hello, %s' % name

这是Python的一个”hello world”实现。代码使用了一个变量name,在使用前并没有进行过任何声明,而当使用时,可以直接拿来就用。

1.1.1 静态类型和动态类型

这种变量的使用方式非常随性,而在C/C++程序员眼中,每个变量使用前都必须声明(或定义的同时声明)几乎是天经地义的事,这样通常被视为编程语言的 “静态类型” 的体现。而对于Python、Perl、JavaScript等语言中变量不需要声明,而几乎“拿来就用” 的变量方式,则被视为是编程语言中的 “动态类型” 的体现。不过从技术上严格地讲,静态类型和动态类型的主要区别在于对变量进行类型检查的时间点。对于静态类型,类型检查主要发生在编译阶段;而对于动态类型,类型检查主要发生在运行阶段。形如Python等语言中变量“拿来就用”的特性,则需要归功于一个技术,即类型推导

事实上,类型推导也可以用与静态类型的语言中。比如在上面的 Python 代码中,即使没有对 name 进行事先的类型声明,如果按照 C/C++ 程序员的思考方式,”world\n”表达式应返回一个临时字符串,所以即使name没有进行类型声明,也能轻松推导出name的类型应该是一个字符串类型。在C++11中,重定义了auto关键字,实现了类型的推导,另一个实现是 decltype,将在后面详细叙述。

可以使用C++11的方式实现刚才的Python代码:

1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;

int main()
{

auto name = "world\n";
cout << "hello, " << name;
}
// 编译选项: g++ -std=c++11 auto-test-1.cpp

这里使用了auto关键字来要求编译器对变量name的类型进行自动推导。编译器根据它的初始化表达式的类型,推导出name的类型为 char*

auto类型推导的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{

double foo();
auto x = 1; // x的类型为 int
auto y = foo(); // y的类型为 double
struct m { int i; }str;
suto str1 = str; // str1的类型是 struct m

auto z; //无法推导,无法通过编译
z = x;
}
// 编译选项: g++ -std=c++11 auto-test-2.cpp

变量x被初始化为1,因为字面常量1的类型为 const int,所以编译器推导出x的类型为int(这里const类型限制符被去掉了,后面会解释)。同理y被推导为double类型,str1被推导为struct m。而变量z,我们使用auto “声明” z,但不立即对其进行定义,此时编译器会报错。表明auto声明的变量必须被初始化,以使编译器能够从初始化表达式中推导出其类型。从这个意义上讲auto并非一种类型声明,而是一个类型声明时的占位符,编译器在编译时期会将auto替换为变量实际的类型。

1.2 auto的优势

1.2.1 简化复杂类型变量的声明

假如我们定义了这样的一个函数:

1
2
3
4
5
6
void loopover(std::vector<std::string> &vs)
{

std::vector<std::string>::iterator i = vs.begin();
for(; i != vs.end(); i++)
// do something
}

我们以 vs.begin() 初始化迭代器i的值,然而在定义i的时候不得不写出 std::vector< std::string >::iterator 这样的声明。这样冗长的代码可读性自然很低,就算使用了 using namespace std,情况也好不到那里去, 如果使用auto的话,代码可读性可以成倍增加。

1
2
3
4
5
void loopover(std::vector<std::string> &vs)
{

for( auto i = vs.begin(); i != vs.end(); i++)
// do something
}

在for循环中,i 的类型将由vs.begin()推导出,这样的代码更清晰可读。

1.2.2 免除类型声明时的麻烦,避免类型声明时的错误

先看这样的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PI{
public:
double operator* (float v)
{
return (double)val * v; // 这里扩展了精度
}
const float val = 3.1415927f;
};
int main()
{

float radius = 1.7e10;
PI pi;
auto circumference = 2 * (pi * radius);
}

这里定义了 float 类型的变量 radius (半径)以及一个自定义类型的 PI 变量 pi ,使用 auto 类型定义变量 circumference ,在 pi 与 radius 相乘时,其返回值是 double。而 PI 的定义有可能在其他地方, main 函数作者也许为避免数据上溢或者精度降低而使用 double,这样不会有问题,而使用了 float 类型声明就可能享受不了 PI 作者细心设计带来的好处,将 circumference声明为auto则毫无问题。

1.2.3 自适应,支持泛型编程

当 auto 应用于模板的定义中,其“自适应”性会得到更加充分的体现。看看下面的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template<typename T1, typename T2>
double Sum(T1 & t1, T2 & t2){
auto s = t1 + t2; // s 的类型会在模板实例化是被推导出来
return s;
}

int main()
{

int a = 1;
long long b = 2LL;
double c = 3.0f;
double d = 4.0;

auto e = Sum<int, long long>(a, b); // e 的类型被推导为long
auto f = Sum<double, double>(c, d); // f 的类型被推导为double
}

由于T1、T2的类型要在模板实例化时才能确定,所以在Sum中将s声明为auto类型。

1.3 auto使用细则

1.3.1 auto类型指示符与指针和引用之间的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
int x;
int * y = &x;
double foo();
int & bar();

auto * a= &x; // a: int *
auto & b = x; // b: int &
auto c = y; // c: int *
auto * d = y; // d: int *
auto * e = &foo(); // 指针不能指向临时变量
auto & f = foo(); // nonconst 的左值引用不能绑定一个临时变量
auto g = bar(); // g: int
auto & h = bar(); // h: int &

可以看出 auto* 与 auto 并没有什么区别,如果要使得auto声明的变量是另一个变量的引用,则必须使用 auto&。

1.3.2 auto 与 volatile 和 const 之间的关系

volatile 和 const 代表两种不同的属性:易失性和常量性,在C++标准中,它们常被一起叫做cv限制符(cv-qulifier)。

1
2
3
4
5
6
7
8
9
10
11
double foo();
float * bar();

const auto a = foo(); // a: const double
const auto & b = foo(); // b: cosnt double &
volatile auto * c = bar(); // c: volatile float *

auto d = a; // d: double
auto & e = a; // e: const double &
auto f = c; // f: float *
volatile auto & g = c; // g: volatile float * &

可以看出,auto可以和cv限制符一起使用,可以通过非cv限制的类型初始化一个cv限制的类型。不过通过auto声明的变量d,f却无法带走a和f的常量性和易失性。这里的例外还是引用,可以看出,声明为引用的变量e,g都保持了其引用对象的属性。

1.3.3 auto的其他规则

此外,同一赋值语句中,auto可以用来声明多个变量的类型,不过这些变量的类型必须相同。如果类型不同,则会编译错误。事实上,用auto来声明多个变量是,只有第一个变量用于类型推导,然后推导出来的数据类型被作用于其他的变量。所以不允许这些变量的类型不同。其实每个 auto 都写一行是最好的做法,可读性也好。

1
2
3
4
5
6
7
8
auto i = 1, j = 2;  // i,j均为int

auto x = 1, y = 1.2f; // 编译失败

auto o = 1, &p = o, *q = &p; // 从左向右推导

// m是指向const int的指针,n是int型变量
const auto* m = &x, n = 1;

1.3.4 auto不能推导的情况

不过 auto 也不是万能的,受制于语法的二义性,或者是实现的困难性,auto往往也会有使用上的限制。这些例外写在下面的代码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <vector>
using namespace std;

void fun(auto x = 1) {} // 1: auto 函数参数,编译失败

struct str{
auto var = 10; // 2: auto 非静态成员,编译失败
};

int main()
{

char x[3];
auto y = x;
auto z[3] = x; // 3: auto 数组,编译失败

// 4: auto 模板参数实例化时,编译失败
vector<auto> v(2, 0);
}

以上四种情况是auto不能推导的情况。

2 decltype

与auto类似,decltype 也能进行类型推导,不过这两者的使用方式却有一定的区别。先看如下这个简单的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <typeinfo>
using namespace std;

int main()
{

int i;
decltype(i) j = 0;
cout << typeid(j).name() << endl; // 打印出"i", g++中表示int

float a;
double b;
decltype(a+b) c;
cout << typeid(c).name() << endl; // 打印出"d", g++中表示double
}

我们看到变量j的类型由decltype(i)进行声明,表示j的类型跟i相同。而c的类型跟a+b这个表达式返回的类型相同。由于a+b返回double类型,则c也是double类型。

2.1 decltype的应用

与 typedef/using 合用,常看到这样的代码:

1
2
3
using size_t = decltype(sizeof(0));
using ptrdiff_t = decltype((int*)0 - (int*)0);
using nullptr_t = decltype(nullptr);

这里 size_t 以及 ptrdiff_t 还有 nullptr_t 都是由 decltype 推导出的类型。

除此之外,decltype 在某些场景下,可以极大地增加代码的可读性。

1
2
3
4
5
6
vector<int> vec;
typedef decltype(vec.begin()) vec_iter_type;
for(vec_iter_type i = vec.begin(); i != vec.end(); i++)
// do something
for(decltype(vec)::iterator i = vec.begin(); i != vec.end(); i++)
// do something

可以看到 decltype(vec)::iterator 这样灵活的用法,和 auto 非常类似,也类似一种“占位符”式的替代。

另外,在C++中,有时会遇到匿名的类型,而拥有了 decltype 这个利器之后,重用匿名类型也非难事。看看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
enum class {K1, K2, K3} anon_e;   // enum class 为C++11新特性

union {
decltype(anon_e) key;
char* name;
}anon_u; // 匿名的union联合体

struct {
int d;
decltype(anon_u) id;
}anon_s[100]; // 匿名的 struct 数组

int main()
{

decltype(anon_s) as;
as[0].id.key = decltype(anon_e)::K1; // 引用匿名强类型枚举中的值
}
// 编译选项: g++ -std=c++11

进一步地,有了 decltype,可以适当扩大模板泛型的能力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// s的类型被声明为 decltype(t1 + t2)
template<typename T1, typename T2>
void Sum(T1 & t1, T2 & t2, decltype(t1+t2) &s){
s = t1 + t2;
}

int main()
{
int a = 3;
long long b = 5;
float c = 1.0f, d = 2.4f;

long long e;
float f;
Sum(a, b, e); // s的类型被推导为long log
Sum(c, d, f); // s的类型被推导为float
}
// 编译选项: g++ -std=c++11

这样一来,Sum的适用范围增加,不过很明显,如果 t1 和 t2 是两个数组,t1+t2 不会是合法的表达式。因此应该为这些特殊情况提供其他的版本。

2.2 decltype 推导四规则

作为 auto 的伙伴,decltype 在C++11中也非常重要。不过跟auto一样,也有很多细则条款需要注意。最典型的例子如下:

1
2
3
4
int i;
decltype(i) a; // a: int
decltype((i)) b; // b: int &, 无法编译通过
// 编译选项: g++ -std=c++11

这里为什么编译器提示说 b 是 int & 类型?因为没有初始化所以编译出错。而 a 则被正确推导为 int 类型。decltype(e) 进行类型推导时,编译器将依序判断以下四规则:

  • 1)如果e是一个没有带括号的标记符表达式或者类成员访问表达式,那么 decltype(e) 就是 e 所命名的实体的类型。此外,如果 e 是一个被重载的函数,则会导致编译时错误。
  • 2)否则,假设 e 的类型是T,如果 e 是一个将亡值(xvalue),那么 decltype(e) 为T&&
  • 3)否则,假设 e 的类型是T,如果 e 是一个左值,则 decltype(e) 为T&
  • 4)否则,假设 e 的类型是T,则 decltype(e) 为T

再回到以上代码,并结合 decltype 的推导规则,就可以知道,decltype((i)) b; 中,由于 (i) 不是一个标记符表达式,但却是一个左值表达式,因此,按照 decltype 推导规则3,其类型是一个 int 的引用。

总的来说, decltype 算得上是C++11中类型推导使用方式上最灵活的一种。虽然看起来推导规则有些复杂,有的时候跟 auto 推导结果还略不相同,但大多数时候,deltype 还是自然而亲切的。一些细则的区别,可以在使用时遇到问题再返回查验。

追踪返回类型的函数定义中,将融合 auto 与 decltype, 将C++11中的泛型能力提升到更高的水平。