传参的类型
完美转发
我们知道,对于对象的传递可以是普通的引用X&
,常量引用const X&
,或者是右值引用X&&
。
如果不使用模板编程想要将参数转发给其他函数,并区分这三者,就需要重载三个函数来实现。
1 | void g(X&) { |
需要说明一点,在转发右值类型的f()
函数中,使用了std::move
,这是因为参数并不传递移动语义的信息,也就是说在f(X&& val)
函数内部,其实val是被看作一个左值的,为了保证类型的无损,所以需要使用std::move
显式指定我们要传递的类型是右值类型。
如果采用模板来写转发函数,就可以少写一些实现,但会遇到一个问题,无法无损地传递参数类型。
1 | template<typename T> |
上面这个转发函数就无法传递可移动的对象,正如上面说的,f函数内部的val参数是看作左值来使用的。
为了解决这个问题,C++引入了完美转发的特殊规则。
1 | template<typename T> |
完美转发std::forward
会根据模板参数T来推导其完整的类型,然后进行无损的转发。
T&& 和 X&&:
模板参数T&&和具体类型X&&是不一样的。
X&&是明确的类型X的右值引用,只能绑定到一个类型X的可移动对象上;
T&&是声明了一个转发引用(万能引用),其遵循C++的引用折叠规则,可以绑定到模板参数T类型的变量对象、常量对象(const修饰的)、或者可移动对象上。
值类别
C++11以后引入了右值引用来支持可移动对象,值的类别就变得稍微复杂了许多,表达式的值仍可以大体上分为左值和右值两类,但右值还可以进一步细分:
1 | 表达式 |
左值和纯右值的含义很明确,可以简单的通过是否有对象拥有其所有权来判断。将亡值的含义是指这个变量即将过期,并且它的值可以被重复利用,所以将亡值是左值,此时它是有着明确的内存和对象的所有权的,但是在过期后被再次使用时,它已经释放掉了所有权,变成了右值,例如函数的返回结果就是一个将亡值。如果有其他的变量会重复使用它的话,那么完全有理由不直接释放,而是再次延长它的生命周期,这样可以节省一次构造和析构的开销。这也是编译器对将亡值常做的优化,比如(RVO和NRVO)。
传值还是传引用,这是个问题
首先,不考虑性能影响的话,传值和传引用对模板来说的最大区别在于,是否允许模板参数的退化。传值是允许参数自动进行退化的,而传引用的情况是不会自动进行退化的,除非在内部显式地使用退化。
1 | template <typename T> |
为了说明参数的退化,这里有两个模板函数,foo是传值的,foo2是传引用的,它们都是简单的打印一下入参的字节大小,然后将一个三字节的数组作为入参,可以先猜一下它们分别打印出来。
公布结果:
1 | 8 |
按值传递的foo函数打印出来的是8,说明数组在传入时,将其处理成了指针,我们打印的是这个指针的大小,在64位操作系统上指针需要占用8字节的大小;而按引用传递的foo2函数打印出来的是3,也就是说,数组被正确地作为入参传入了,我们传入的是引用,实际上计算出来的大小是这个引用指向的对象的大小,也就是最开始定义的数组,占了3个字节。
这并没有好坏之分,只是需要在合适的场景使用合适的方法传递参数(当然,这明显增加了开发者的心智负担)。不过传值似乎更加符合一贯的认知(众所周知,数组是不能作为C的入参的,数组的入参一直会被转换成指针),因此在模棱两可的时候选择按值传递可能更简单点,书中推荐在以下场景中可以使用按引用传递:
- 参数无法拷贝。
- 参数用于返回数据。
- 模板保留原始实参的所有属性,只是转发参数到其他地方。
- 性能能获得明显的提升。
传递引用时,要尤其注意字符串字面量和原始数组的问题,正如刚提到的,传递引用不会将参数退化处理:
1 | template<typename T> |
这在编写时就会报错,因为"hi"是const char[3]类型,而"guys"是const char[5]类型。实际上,没有编辑器的检查或者没有足够小心,很难怀疑这是错误的写法,甚至它很符合一贯的用法。