本文聊聊C++中的模板类型推导和auto。两者其实是一样的,前者推导T
的类型,后者推导auto
的类型。本文初创于公司内部博客,更适合于有基础的同学参考。
模板类型推导
对于模板函数来说,编译器需要根据实际传入的参数来推导模板类型T。例如,假设我们有下面这个模板函数:
template<typename T>
void f(T& param); // param is a reference
同时声明了这些变量:
int x = 27; // x is an int
const int cx = x; // cx is a const int
const int& rx = x; // rx is a reference to x as a const int
那么调用模板函数时,编译器推导出的模板类型分别为:
f(x); // T is int, param's type is int&
f(cx); // T is const int, param's type is const int&
f(rx); // T is const int, param's type is const int&
可以发现,同样是整型变量,带不带const
、是不是reference
,类型推导的结果是不一样的。回到刚开始声明的模板函数,我们声明了参数类型为T&
,如果声明为T
,或者是T&&
,又会得到怎样的结果呢?我们用表格来总结一下:
![](https://img.haomeiwen.com/i1186132/9c7b005758e9f844.png)
是不是有点眼花缭乱?死记硬背是记不住的,我们找一找其中的规律。
- 一般情况下,
param
的类型是最完整的类型,继承了形参中声明的cr(const和reference)和实参中带过来的cr。但有两个特例:- 特例一:当形参是通用引用(
T&&
作为模板参数时称为通用引用)时,param
根据具体的实参类型,推导为左值引用或者右值引用。 - 特例二:当形参不是引用时,实参到形参为值传递,去除所有cr修饰符。
- 特例一:当形参是通用引用(
-
T
中是否包含cr修饰符,取决于param
的修饰符是否已在形参中声明过。也就是说,T
中修饰符不会与形参中已声明的修饰符重复。
为什么我们需要知道这些规则呢?这是因为,有时候需要根据传入的param
的类型构造与之相关联的其它类型的对象。比如我们想要在函数内部构造一个与param
同类型但去除cr修饰符的对象,那么就应该把形参声明为const T& param
,然后声明T obj
这个对象。这是因为上表中第4行表明了,无论实参包含了什么修饰符,无论是左值还是右值,T
都是不带任何修饰符的单纯类型。当然,声明为T param
也是可以的,但这种情况下参数就是值传递了。所以说,根据实际情况选择合适的模板参数类型是很重要的。
最后,为了更容易实践,我们总结出下面三个推荐用法:
- 想要按值传递,将模板函数参数声明为
T param
。 - 想要按引用传递,但不考虑右值时,将模板函数参数声明为
const T& param
。 - 想要按引用传递,但要区分左值和右值时,将模板函数参数声明为
T&& param
。
auto类型推导
理解了上一节的模板类型推导后,auto类型推导就很简单了。除了极个别情况,auto类型推导与模板类型推导是完全一致的。比如,上一节表格中的四种模板函数调用都可以写出对应的auto语句(只写出对实参x调用的那一列):
auto& param = x; // auto is int, param's type is int&
const auto& param = x; // auto is int, param's type is const int&
auto&& param = x; // auto is int&, param's type is int&
auto param = x; // auto is int, param's type is int
decltype
decltype
是一个运算符,用来获取一个变量或表达式的实际类型。之所以需要这个运算符,是因为在某些情况下,特别是模板函数中,我们事先根本不知道应该创建一个什么类型的变量。举个简单的例子,下面这个模板函数用来获取任意一个容器对象的第i个元素:
auto get(Container& c, Index i) -> decltype(c[i])
{
return c[i];
}
函数声明用到了C++11中的trailing return type语法,即返回类型后置。该语法支持在原本书写返回类型的地方用auto占位,然后在参数列表后面加上“→返回类型”。这里之所以用到了这个语法,是因为c[i]必须在其声明的位置后面才可以访问到。
你可能会想,是否能够进一步简化呢?让编译器自动推导返回值类型不是更方便吗。正是为此,C++14提供了如下的写法:
template<typename Container, typename Index>
auto get(Container& c, Index i)
{
return c[i];
}
返回值直接由c[i]
的类型自动推导。但我们不能高兴地太早,根据auto类型推导的规则,现在这种写法相当于:
auto element = get(Container& c, Index i);
这是有问题的。这种形式对应于第1节表格的最后一行,返回值会按值传递。如果c[i]
的实际类型是引用,函数返回类型会自动去掉引用这一修饰符,这在有些情况下不是我们想要的结果。我们想要的结果是,函数返回类型与c[i]
完全一致,如果c[i]
是引用,那么返回类型也是引用,如果c[i]
不是引用,那么返回类型也不是引用。遗憾的是,第1节表格中提供的四种声明方式都不能满足我们的需求,auto&
和auto&&
会把非引用变成引用,const auto&
会添加额外的const
,auto
会把引用变成非引用。
现在,唯一的方法就是借助decltype
,将函数改写如下:
template<typename Container, typename Index>
decltype(auto) get(Container& c, Index i)
{
return c[i];
}
现在,函数返回类型一定与c[i]
的类型完全一致了。但别急,到这里还没完,因为这个函数仍有可改进的空间。注意到,我们把Container
的参数设为了引用,这就注定它不能接收右值对象,比如临时创建的Container
对象。另写一个重载函数可以解决该问题,但不优雅。借助通用引用和完美转发可以解决这个问题,请看最终版的代码:
decltype(auto) get(Container&& c, Index i)
{
return std::forward<Container>(c)[i];
}
std::forward
会根据实参的实际类型将c
转换为左值引用或右值引用。关于通用引用和完美转发,可以参考《右值引用那些事儿》。这里,我们同时给出C++11版本的实现,以供对比:
template<typename Container, typename Index>
auto get(Container&& c, Index i) -> decltype(std::forward<Container>(c)[i])
{
return std::forward<Container>(c)[i];
}
如何查看类型推导结果?
编程过程中,我们经常需要知道模板类T
或者auto
的推导结果到底是什么,是否符合我的预期。这里介绍四种方式。
-
使用IDE的自动提示。像Clion这种IDE,把鼠标放上去就会有自动提示的。但不可尽信,因为IDE内置的语法分析并不总是那么可靠。
-
让编译器告诉我们。编译器一定是最准的,但让编译器输出
T
或auto
的类型却并不容易。我们需要手动构造一个编译错误,它才会把错误部分的类型打印出来。比如,想要查看x
的类型,需要先声明一个空的模板类
template<typename T>
class TD; // TD means "Type Displayer"
然后实例化对象
int y = 0;
const int& x = y;
TD<decltype(x)> xType; // Compiler will report error at this line, which contains x's type
在我的计算机上,输出的错误信息是
error: aggregate 'TD<const int&> xType' has incomplete type and cannot be defined
这样就可以看到,x
的实际类型是const int&
。
- 运行时输出。这是我们通常最容易想到的方法。C++提供了typeid操作符,用法如下:
std::cout << typeid(x).name() << std::endl;
在我的计算机上,输出结果为i
。
i
是typeinfo
类中定义的int
的简称。很显然,这里的结果是错误的,因为x
的实际类型是const int&
,而typeinfo
却认为它是int
。之所以会出现这种情况,是因为x
传入typeid
时采用了值传递,所有cr修饰符都被丢弃了。所以,typeinfo
只适合检查变量的基础类型,不能用来查看其完整类型。
- 使用boost库中的typeindex。这种方式需要引入第三方库,但可以保证结果绝对准确,代码示例如下:
std::cout << boost::typeindex::type_id_with_cvr<decltype(x)>().pretty_name() << std::endl;
type_id_with_cvr
中的cvr指的是const
、volatile
和reference
三种修饰符。使用前先#include <boost/type_index.hpp>
。在我的计算机上,输出结果为int const&
,完全正确。
这四种用法各有其适用场景,总结一下:
- 最方便快捷:IDE提示和
typeid
操作符。 - 最准确:编译器提示和
boost::typeindex
。
用auto代替显式类型声明
auto
有显而易见的优点,比如,它可以使你的代码更简洁,避免手写类型出错等等。不过你可能并不觉得这是多大的优点,但看看下面这个例子,就会发现不用auto
的坏处了。
std::unordered_map<std::string, int> m;
// ...
for (const std::pair<std::string, int>& p : m)
{
// do something with p
}
在range-for循环中,我们用const std::pair<std::string, int>&
类型的变量绑定m
中的每个键值对,看起来天衣无缝。但事实是,m
中的每个键值对并非该类型,正确的类型应该是const std::pair<const std::string, int>&
,键是const
类型的。上面的写法导致创建了临时变量以及隐式类型转换,不知不觉中就造成了性能损失。如果使用auto
,这种问题就能完全避免。
再来举一个例子,下面展示了两种保存lambda对象的方式:
auto my_function1 = [](const std::vector<int>& v1, const std::vector<int>& v2) {return v1.size() + v2.size();};
std::function<std::vector<int>::size_type(const std::vector<int>&, const std::vector<int>&)> my_function2 = [](const std::vector<int>& v1, const std::vector<int>& v2) {return v1.size() + v2.size();};
前者直接保存为lambda对象,后者相当于转换成了std::function
对象后再保存。虽然用的时候并无区别,但如果我们看一下这两个对象的大小,就会发现前者比后者小的多:
std::cout << sizeof(my_function1) << std::endl; // output: 1
std::cout << sizeof(my_function2) << std::endl; // output: 32
这是因为,std::function
是一个功能完善的标准化类,提供了额外的功能,而lambda表达式生成的对象仅仅包含一个operator()
,连数据成员都不需要,所以占用空间非常小。因此在这个例子中,auto
无论是在简洁性上,还是在性能上,都完全优于std::function
。
当auto出错时,使用static_cast显式指定类型
最后一节来说说auto
不行的场景。有时候,自动推导出的类型反而不是我们想要的类型,这种情况在invisible proxy这种设计模式中可能遇到。举个最常见的例子,我们经常用Eigen这个矩阵库,比如下面的代码:
Eigen::MatrixXd m1, m2;
// ...
Eigen::MatrixXd m = m1 + m2;
如果把第二句换成
auto m = m1 + m2;
这是会出问题的。因为m1 + m2
得到的并不是一个Eigen::MatrixXd
类型的对象,而是const CwiseBinaryOp< sum <Scalar>, const Derived, const OtherDerived>
类型,见Eigen文档Eigen::MatrixBase::operator+。Eigen使用了lazy-evaluation技术,只有当把一个表达式最终赋值给另一个Matrix
对象时,才会真正计算表达式的值。也就是说,在调用Eigen::Matrix::operator=
时,才会真正执行加法运算。一旦我们用auto
替代了Eigen::MatrixXd
,这个表达式就仍然保持其表达式的状态,而不进行运算。关于Eigen的内部实现机制,可以参考What happens inside Eigen, on a simple example,保证干货满满。
还有一种情况,如果我们想要进行隐式类型转换,比如:
float float_number = getDoubleNumber();
这种情况也是无法用auto
替代float
的。但此处有更好的解决方式。像上面这样的隐式类型转换是不推荐的,因为这句代码没有体现出开发者的主观意愿,不知道开发者是忘记了等号两边类型不一致,还是知道但故意这样写。更好的做法是:
auto float_number = static_cast<float>(getDoubleNumber());
这就没有任何歧义了。
网友评论