理解Decltype

本文最后更新于:April 20, 2022 am

本系列博客主要讨论C++的类型推导系统

本节着重讨论decltype关键字

decltype的推导规则

decltype是一个奇怪的造物,给它一个变量名或者表达式,它可以反过来告诉你其相应的类型信息。通常情况下,它返回的类型信息正如你所期待的那样,但在极少数情况下,它推断出来的类型会让开发者抓狂

我们从最普遍的情况开始讨论——不存在任何“意外”的情况。同模板类型推导和auto不一样的是,decltype通常会机械地将参数的类型反射回来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const int i = 0;
//decltype(i) <-> const int

bool f(const Widget& w);
//decltype(w) <-> const Widget&
//decltype(f) <-> bool(const Widget&)

struct Point{
int x,y;
//decltype(Point::x) <-> int
//decltype(Point::y) <-> int
};

Widget w;
//decltype(w) <-> Widget

f(w);
//decltype(f(w)) <-> bool

std::vector<int>v{1,2};
//decltype(v[0]) <-> int&

在C++11中,decltype最初被使用的场景,大概是当一个函数模板的返回值取决于此函数的参数。假设我们的函数模板接收一个标准库容器和一个索引index,比方说std::vector,然后对用户做校验,最后返回std::vector[index],那函数模板的返回值类型应当和std::vector::operator[](std::size_t)函数的返回类型相同,此时就需要使用decltype

不少容器的operator[]函数都返回T&类型,比方说std::deque,而std::vector几乎总是这样,除了std::vector<bool>,因为它返回的是全新的对象,总之,此函数模板的返回值应该由容器的operator[]函数的返回值确定

C++11能够自动推导出只有一个return语句的lambda表达式的返回值,C++14则把这个特性推导到了所有lambda和函数,包括有多个return语句的

1
2
3
4
5
6
//C++14:
template<typename Container, typename Index>
auto authAndAccess(Container& c, Index i){
authenticateUser();
return c[i];
}

如果这么写,就出问题了,上一节提到,auto用作推导函数返回值时,采用模板类型推导的原则,如果operator[]返回的是T&类型,则索引符号会被直接忽略掉

1
2
3
4
std::deque<int>d;
......
authAndAcess(d, 5) = 10;
//校验用户,然后返回d[5]的索引,加以更改

如果函数真的使用的auto关键字作为返回值声明,那么这一段代码肯定不能编译,因为即使d[5]int&类型,但是函数authAndAcess的返回值被编译器推导为了int类型(忽略所有的引用),因此,第三行是在对右值形式的int赋值,编译器报错

此时需要使用decltype来指定函数authAndAccess的返回值应当同operator[]一致。委员会还考虑到,在某些比较极端的情况下,需要使用decltype的类型推导规则来返回某个被推导的类型,因此decltype(auto)这种用法也是合理的。

decltype(auto)?乍一听没什么,但是按普遍理性而论,是不是有点矛盾?其实这是完全合理的,auto关键字用于提示编译器,此处的类型需要被推导(很多时候是出于懒得写类型的原因),decltype用于告诉编译器推导时应使用decltype的规则,而非auto关键字自己的规则,因此,我们可以这样来改写函数authAndAccess:

1
2
3
4
5
6
7
//C++14
//能正常编译运行,但仍有优化空间
template <typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i){
authenticateUser();
return c[i];
}

如此一来,authAndAccess就会老实地返回operator[]函数的返回值类型了

值得一提的是,decltype(auto)这种用法不止可以用来声明函数的返回值,当希望用使用decltype的推导规则来声明变量时,同样可以使用之:

1
2
3
4
5
6
Widget w;
const Widget& cw = w;
auto myWidget1 = cw;
//使用auto的推导规则,myWidget1 <-> Widget
decltype(auto) myWidget2 = cw;
//使用decltype的推导规则,myWidget2 <-> const Widget&

在上上一段代码段中,第二行注释提到了那一段代码仍有优化空间,接下来我们加以讨论:

1
2
3
4
5
6
7
//C++14
//能正常编译运行,但仍有优化空间
template <typename Container, typename Index>
decltype(auto) authAndAccess(Container& c, Index i){
authenticateUser();
return c[i];
}

函数authAndAccess的第一个参数c是通过左值引用进行传递的,如果用户希望传入右值形式的容器并且仅仅想获得一个c[i]的拷贝,这份函数就无法处理参数

1
auto copyOfC_i = authAndAccess(makeStringDeque(),5);

因此,我们还需要为authAndAccess函数添加一个接收右值容器的版本。许多开发者的第一反应是通过重载来解决,这是可行的,但引入了额外的函数维护成本,比较好的方法是使用万能引用

此外,由于使用了万能引用,在对容器取索引时,也需要进一步使用完美转发 来配套

1
2
3
4
5
template<typename Container, typename Index>
decltype(auto) authAndAccess(Container&& c, Index i){
authenticateUser();
return std::forward<Container>(c)[i];
}

现在就大功告成哩,这份函数将会完美地执行我们所期望的任务

不过如果使用的是C++11的编译器,需要这样写

1
2
3
4
5
6
7
template<typename Container, typename Index>
auto authAndAccess(Container&& c, Index i)
-> decltype(std::forward<Container>(c)[i])
{
authenticateUser();
return std::forward<Container>(c)[i];
}

此处authAndAccess的第二个参数使用的值传递的方式,这样有引起额外的性能开销的风险,具体的内容在这篇博客里已经做过讨论,不过考虑到如果容器都像stl标准库容器一样,拷贝一份索引变量的开销应该不大。

意外情况

另一个问题是,在开篇就提到,decltype几乎总是返回你所期望的类型,那有没有什么例外呢?事实上,如果你不是一位库文件编写和维护人员,那么几乎不会遇到这些例外,这些特殊情况太过于晦涩,没有必要在本博客中一一列举,不过可以透过一个例子来更加深入地了解decltype

对某个变量名施加decltype,将会返回声明此变量时所指定的类型,变量名都是左值,但这并不影响decltype的作用。对于比变量名更复杂一些的左值表达式,decltype则保证它总是会返回左值引用,这意味着,如果某个左值表达式expression,而且它不是简单的一个变量名,有类型Tdecltype(expression)会给出T&类型。

这几乎不会有什么影响,因为大多数左值表达式都会天然地包含一个左值引用限定符。比如,返回左值的函数,总是会返回左值引用

知道这个特点之后,有一点非常值得注意

1
2
3
int x = 0;
//decltype(x) <-> int
//decltype((x)) <-> int&!

x本身作为一个表达式,它是一个很简单的变量名,因此decltype(x)将会返回int,这很好理解。如果我们在x两侧多加一对括号会发生什么呢?(x)——根据C++的定义,这是一个表达式,并且已经脱离了变量名的范畴,但也是左值,因此decltype((x))将会返回int&,在变量名周围多加一对括号就改变了decltype的返回类型!

这在C++11中或许不算什么,当C++14对decltype(auto)的支持之后,在写法上的一个看起来很微小的差别就会引起函数返回值类型的推导结果

1
2
3
4
5
6
7
8
9
10
11
12
13
decltype(auto) f1(){
int x = 0;
......
return x;
// decltype(x) <-> int,函数返回值是int类型
}

decltype(auto) f2(){
int x = 0;
......
return (x);
// decltype((x)) <-> int&,函数返回值是int&类型
}

f2不仅意外地返回了引用类型,更要命的是它返回了函数局部变量的引用,这样几乎肯定会引起未定义行为

从这里学到的教训是,当使用decltype(auto)时,我们必须要多多留心,源代码中看起来毫不起眼的区别将会导致decltype返回的类型与我们所期待的大相径庭

但同时,也不要丢了西瓜捡芝麻,在绝大多数情况下,decltype总是会返回我们期待的类型,特别是对变量名施加decltype

总结

decltype几乎总是完好地返回变量或者表达式的类型

对于非变量名的具有类型T的左值表达式,decltype总是返回T&

C++14支持decltype(auto),同样可以推导类型,但是使用decltype的规则


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!