左值引用和右值引用
为了避免值传递的开销,我们可能希望对一个对象使用引用。在C++中,在向函数传引用的时候,有两种方式,一种是传左值引用,像这样:
void foo(int&);
对于上面的foo函数,传值的时候可以传任意的左值,例如假设有int y=1;
,foo(y)
是OK的。
但是如果仅仅有左值引用,在一些情况下是有问题的,对于foo(42)
来说,无法通过编译,因为42
是一个字面量,它没有一个变量名去绑定(或者说,你没办法对它取指针),因此它没法传值给foo(int&)
这个函数。
于是C++中就诞生了右值引用,规定42
,或者3+29
这样由表达式得到的值,为类型的右值引用,可以调用下面的函数:
void foo(int &&);
这样的话,foo(42)
,或者foo(3+29)
都是合法的了。
常量引用
但是为了做到传引用这件事,C++要分别处理左值引用和右值引用的办法明显会显得非常麻烦。C++中提供了一个常量引用的机制,就是说,如果你对传进来的引用类型不进行修改的话,那么就允许右值进行绑定,也就是说,假设代码中只有一个接收常量引用的函数:
void foo(const int&);
对于上面的函数而言,foo(42)
和int y=29; foo(y)
都是合法的。
但是这种方式要求的是,foo函数中是不能修改x的值的,因为它是const引用。
右值引用于常量引用的使用关系
不过这样带来一个问题,假设代码中同时存在foo(int&&)
和foo(const int&)
时,在调用foo(42)
时,会走哪一个呢?
答案是,会走foo(int&&)
,因为42本身更加符合int&&
的定义,如果代码中是存在foo(int&&)
,那么就会优先走这个接收右值引用的函数。
但是,假设我们的代码是这样:
void foo(int&&);
void foo(const int&);
int && t = 42;
foo(t);
这个时候,会调用哪一个foo呢?
答案是,会调用foo(const int&)
,而不是foo(int&&)
。
引用折叠
为了要解释清楚上面的代码中,为什么foo(t)
调用的是foo(const int &)
而不是foo(int&&)
,我们就需要弄清楚C++在传引用的时候到底做了什么。这里的规则是,传值时,任何的具名变量都是左值,任何的非具名变量都是右值。
int x;
func(x); // func(int&);
func(x+1); // func(int&&);
但是有意思的问题是,如果上面的x是一个引用类型呢?也就是说,如果x本身是一个int&
,那么func(x)
传入的参数类型到底是什么?
这个问题非常地细节,大多数人都会忽略掉,但是要理解C++的转发机制,就必须知道这个细节。实际上,func(x)
的参数类型是int&
的引用,也就是说,它是一个左值引用的左值引用。
而C++中有一个引用折叠机制,这种左值引用的左值引用会被折叠成左值引用,正因为这种折叠机制的存在,导致这个问题被很多人忽略掉了,人们想当然地认为x是一个int&
,那么func(x)
调用的是func(int&)
。实际上这里多了一个引用折叠机制。
然后进一步讨论,如果像先前的代码那样,x的类型是一个int&&
,右值引用,那么func(x)
调用的是什么?
要讨论的是func(x)
里面的x
的类型是什么,跟前面一样,这里面的x
的类型是int&&
的引用,也就是说,它是右值引用的左值引用。
接着根据C++中的引用折叠机制,这种int&&
的左值引用实际上会被折叠成int&
。
所以即使x本身的类型是int&&
,在func(x)
的时候,依然调用的是func(int&)
,而并非func(int&&)
。
以下是C++中的折叠规则:
&
和&
折叠为&
&&
和&
折叠为&
&
和&&
折叠为&
&&
和&&
折叠为&&
所以,回到前面一小节的问题,这里面,尽管t
的类型是一个右值引用,这意味着int && t = y;
这样的语句是完全不合法的,但是t是具名的,因此在函数调用的是否,调用的是左值引用的函数,调用的是foo(const int&)
,而不是foo(int&&)
。
移动语义
如果仅仅是针对前面的例子,假设我需要去调用foo(int&&)
,那么是需要对这个t
使用std::move
的。
void foo(int&&);
void foo(const int&);
int && t = 42;
foo(std::move(t));
std::move
本身会强制类型转换为右值,而在传参的时候,由于std::move得到的结果本身不具名,因此传参的时候的参数类型是一个右值引用的右值引用,通过引用折叠之后得到一个右值引用,因此对于上面的代码来说,就会使用foo(int&&)
了。
forward reference
对于前面的讨论,可能会觉得很无聊,因为日常的开发过程中,很少有人会使用int && t = 42;
这样的语句,但是实际上上面的代码仅仅是一种简化的讨论,真正的问题在于函数的调用链。
从上面的代码中,我们已经知道,如果要写一个函数foo
,既能够接受左值int&
,又能够接受右值int&&
,我们就得写两个函数,一个接收左值foo(int&)
,一个接收右值foo(int&&)
。如果我们只想写一个函数,如果这个值保证在函数内不变的话,可以使用常量引用foo(const int&)
,这里的问题是,如果我们没法保证这个值在函数内是不变的,又想只写一个函数,怎么办?
那我们可能会想让模板来进行推断,这个就是forward reference,转发引用,或者说是万能引用。这样写:
template<typename T>
void foo(T && x);
对于这个函数来说,foo(42)
和foo(y)
都是可以实例化的,但是在使用的过程中,会遇到一个问题。就是有时你可能需要弄清楚这个x的类型。
前面我们已经提到,在C++中,任何具名的变量在函数传递的时候都是左值,这样一来就会遇到一个问题,假设我有两个函数:
void bar(int&&);
void bar(const int&);
这两个函数接收了不同的引用类型,内部的逻辑也不太相同,然后它们被foo调用:
template<typename T>
void foo(T && x) {
bar(x);
}
这里可能会遇到问题,根据前面的描述:任何具名的变量在函数传递的时候都是左值,无论x本身的类型是左值还是右值。换而言之:
foo(42); // call bar(const int&);
foo(y); // call bar(int &&);
尝试运行下面的代码,可以展示上面描述的问题:
#include <utility>
#include <iostream>
void foo(const int & x) {
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
void foo(int && x) {
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
template <typename T>
void bar(T&& x) {
foo(x);
return;
}
int main() {
int t = 42;
bar(42);
bar(t);
return 0;
}
但这很可能违背了我们的期望,我们有的时候可能希望函数的参数传进来的是什么类型,就要调用什么类型的函数。
对于上面的bar(42)
,在函数内部是int&&
类型,我们希望它调用foo(int&&)
,但是因为具名的关系,调用的是foo(const int&)
。
我们很显然不能无脑使用move,因为对于第二种情况bar(t)
来说,我们又确实希望它去调用foo(const int&)
。
此时就需要使用std::forward
,完美转发机制。
(不要认为这个事情是无意义的,在某些领域的开发中,例如在一些EDSL的开发中,左值和右值可能会因为涉及到寄存器分配的问题,因此需要区别实现。)
std::forward
对于前面的讨论
我们把前面的bar函数,修改成下面的形式,就可以了:
template <typename T>
void bar(T&& x) {
foo(std::forward<T>(x));
return;
}
forward的原理
前面讲述的都是用法,要彻底弄明白完美转发到底是怎么一回事,我们就必须对前面提到的代码进行一个详细的思考。
看这个代码:
template <typename T>
void bar(T&& x);
我们有一个bar函数,使用了这种forward reference,那么问题是对于函数调用bar(42)
和bar(y)
两个函数调用而言,T
和x
的类型到底是什么。
T
的类型很好知道,直接运行就可以了:
template <typename T>
void bar(T&& x) {
std::cout << __PRETTY_FUNCTION__ << std::endl;
}
运行一下就知道,bar(42)
会告诉你此时T = int
,而bar(y)
会告诉你T = int&
。
但这是T
的类型,问题是x
的类型。我从两个方面解释这个问题。
一个方面是forward reference有的时候被翻译成“万能引用”或者说“通用引用”,也就是说,这里面,x的类型,是传入什么类型,就是是什么类型,也就是说,对于bar(42)
来说,x的类型就是int&&
,对于bar(y)
来说,x的类型就是int&
。
另一个方面就是前面提到的引用折叠机制。
也就是说,当T = int
时,T &&
当然就是int &&
,当T = int&
时,T &&
就是int& &&
,会被折叠成int&
,这个就是x的类型的由来。
回到之前代码里的问题,对于函数:
template <typename T>
void bar(T&& x) {
foo(std::forward<T>(x));
return;
}
然后使用了bar(42)
和bar(y)
。对于内部的两次调用而言,foo的参数分别是std::forward<int>(x)
,和std::forward<int&>(x)
。然后我们可以知道std::forward<int>(x)
应该是把x
的类型转换为了int&&
,而std::forward<int&>(x)
则是把x
的类型转换为了int&
。
知道这一层之后,我们就可以来看forward的原理代码了:
template<typename T>
T&& forward(typename std::remove_reference<T>::type& x) noexcept {
return static_cast<T&&>(x);
}
它的基本意思,就相当于利用前面所说的引用折叠的规则,forward reference的函数中对于bar(42)
,它的T类型是T = int
,加上一个&&
之后就变成了int&&
,因此在调用foo
的时候就会调用foo(int&&)
,而对于bar(x)
,它的T类型是T = int&
,加上一个&&
就变成了int& &&
,通过引用折叠就变成了int&
,所以调用foo
的时候调用foo(const int&)
。
额外注意一下上面forward的参数:typename std::remove_reference<T>::type& x
,这里确保即使T
是引用类型,参数x
也是一个普通的引用(左值引用)。