本文最早发表于公司内部博客,禁止转载
在c++11中,左值(L-Value),L应表示Location,表示可寻址,左值指可以取地址的一个值。右值(R-Value), 右值指的是不能取地址的值(关于左值右值,新老解释并不一致,但是我们没必要深究,此处定义更方便我们理解)。区分左值和右值的简单方法:看能不能对表达式取地址,如果能, 则为左值, 否则为右值。所有具有名称的变量或者对象都是左值, 而匿名变量则是右值。
c++11中又将右值分为纯右值和将亡值。纯右值就是C++98中右值的概念,如返回非引用值的函数的返回的临时变量;一些写死的值, 如"hello world"、true、1、‘c’,这些值都不能被取地址。
将亡值的产生,是由C++11中右值引用的产生而引起的。 它是和右值引用相关的表达式。这样的表达式通常是将要移动的对象,它通常为:返回右值引用的函数的条用表达式,如std::move,或者转换为右值引用的转换函数的调用表达式。
实际上我们不用太过去在意这些晦涩的语法糖。我们只需记住,左值是允许我们取地址的值或者表达式, 而右值就是不是左值的值。下面一个简单的列子:
int i = 10;
i = 11;//正确,i是个左值
int* p = &i;//正确,i是个左值, 可以取地址
int & func_L();
func_L() = 42;//正确,func_L()是一个左值
//右值
int func_R();
int j = 0, *p2 = 0;
j = func_R();//正确,func_R() 是个右值
int* p2 = &func_R();//错误,右值无法取地址
j = 22;//正确,22是一个右值
&j = p2;//错误,&j是个右值,表达是必须是可修改的左值
C++中引用很常见, 就是给变量取一个别名,因为在c++11中增加了右值引用(rvalue reference), 所以我们将之前的引用称作左值引用(lvalue reference)。右值引用使用的符号是&&。顾名思义左值引用、右值引用, 分别对应的是左值和右值。下面是一例子:
int a = 1;
int& refA = a;//refA是a别名,它是一个左值引用,修改refA就是修改a
int& b = 1;//编译出错,1是一个右值,不能赋值给左值引用
int&& c = 2;//正确,给了一个匿名变量2赋值给右值引用
int&& d = a;//错误,a是左值
int&& e = a * 10;//正确, a * 10 是个右值
const int& f = a * 10;//正确,注意可以将一个const左值引用绑定到一个右值
class CA
{
public:
int x1 = 0;
};
CA GetAObj()
{
return CA();
}
CA&& objA = GetAObj();//正确,GetAObj()返回的是右值(临时变量)
CA&& objA2 = objA;//错误,注意,objA是一个右值引用类型变量, 但是它是一个左值
如上,GetAObj()返回的是一个临时变量(右值),在表达式结束后, 它的生命周期也应该结束,通过右值引用,这个右值又延续了生命,他的生命周期跟右值引用变量objA一样。看上述代码,左值引用只能绑定左值, 右值引用只能绑定右值, 不过常量左值引用属列外,它能既能绑定左值,又能绑定右值,不过它只能读, 不能写。
结合上面的代码示例,我们会发现,左值都是有一段生命周期的,而右值要么是写死的字面值,要么是表达式返回的临时对象,即左值持久,右值短暂。所以基于右值引用只能绑定到临时对象, 我们可以得出结论:1)右值引用所引用的对象将要被销毁;2)该对象没有其他拥有或者使用者。这个特征意味着, 使用右值引用可以自由接管所引用右值的资源。
当我刚开始接触C++11中的右值引用, 我也疑惑它到底有什么用处。右值引用可以减少不必要的内存分配和拷贝,它解决了两个问题,实现移动语义和完美转发。c++11之前的老代码,如果用支持c++11的编译器重新编译,性能也会得到一定的提高,原因就在于标准库中使用新增的移动语义。
看下面代码,X是一个类,它类似vector, 它包含资源的指针m_pResource。m_pResource是数组指针,它里面包含一些对象,(代码都经过测试,但是只是为了示例,并不完善,能看懂表达意思即可)。
template
class X
{
public:
X(int capacity = DEFAULT_CAPACITY) explicit
{
m_pResource = nullptr;
m_nSize = capacity;
}
X(const X& rhs)
{
//TODO:释放m_pResource,给m_pResource重新分配空间,将rhs中资源复制拷贝给m_pResource
//此处代码类似下面 = 号重载函数, 省略...
}
X& operator = (const X& rhs)
{
if (this == &rhs) return *this;
//释放 m_pResource
if (m_pResource != nullptr) delete[] m_pResource;
//复制 rhs.m_pResource, 此处默认T为简单类型,可以直接内存复制,复杂对象的话需要一个个赋值
m_nSize = rhs.m_nSize;
m_pResource = new T[m_nSize];
memcpy(m_pResource, rhs.m_pResource, sizeof(T) * m_nSize);
}
~X()
{
delete[] m_pResource;
}
protected:
T* m_pResource;
int m_nSize;
};
假设X模板类像下面这样使用
X GetObj();//函数可以获取一个对象
X x1;
//中间X1可能还有其他用处...
x1 = GetObj();
这段代码并没有问题,GetObj()返回一个临时对象,即上文说右值。将这个临时对象赋值给x1,首先我们会释放x1拥有的资源,然后将临时对象资源克隆一份给x1对象(见上述代码operator = 重载),最后赋值操作完成后,临时对象会释放。
很显然,如果我们直接在x1和临时对象之间直接交换资源指针,然后让临时对象的析构函数销毁x1的原始资源,这是更高效的做法。换句话说,在特殊情况下, 如果赋值的右侧是一个右值, 我们希望赋值运算符的行为如下:
// do something...
// swap m_pReource and rhs.m_pResource
// do something...
这就叫做Move Semantics,所谓移动语义。在c++11中, 这种行为可以通过函数的重载来实现,伪代码如下:
X& operator=( rhs)
{
// [...]
// swap this->m_pResource and rhs.m_pResource
// [...]
}
由于我们重载了赋值运算符,上述中“unknown type”必然需要是一个引用类型,我们当然希望通过引用将右侧的值传给我们。此外,我们期望这个“unknown type”具有这样的行为:当在两个重载之间进行选择时,其中一个重载是普通引用, 另一个重载是“unknown type”,那么右值必须选择这个“unknown type”,而左值则使用普通引用。
如果认真看了上面rvalue reference描述,此时很容易就想到了“unknown type ”就是右值引用了。
要实现移动语义, 我们在类中必须增加两个函数:移动构造函数和移动赋值运算符函数。给上述类X加上这两个函数如下:
//移动赋值函数 = 号重载,注意没有const修饰
X& operator = (X&& rhs) noexcept
{
if (this == &rhs) return *this;
//此处可以直接交换rhs.m_pResource和m_pResource
//我们选择直接交换指针资源,对比上述普通左值引用的赋值运算符函数,此处单纯交换指针,效率提高了
std::swap(m_pResource, rhs.m_pResource);
std::swap(m_nSize, rhs.m_nSize);
#if 0 //也可以直接把m_pResource释放,直接将rhs.m_pResource赋值给它,然后rhs.m_pResource置为null
if (m_pResource != nullptr) delete[] m_pResource;
m_pResource = rhs.m_pResource;
#endif
return *this;
}
//移动构造函数,类似移动赋值函数, 注意没有const修饰
X(X&& rhs) noexcept
:m_pResource(rhs.m_pResource)
{
m_nSize = rhs.m_nSize;
rhs.m_pResource = nullptr;//置为空,避免在临时右值对象中再次释放
}
此时再来看看下面这些调用:
X x1;//默认构造函数
//中间X1可能还有其他用处...
//do something...
x1 = GetObj(); //调用移动赋值运算符函数,指针交换,减少一次资源拷贝
X x2(x1);//调用拷贝构造函数
X x3 = x2;//调用普通的赋值运算符函数
/*
注意:理论上下面这一行会调用移动构造函数,但是编译器进行了
RVO(Return Value Optimization)。移动构造本来就是为了减少复制,
GetObj构造的对象地址直接给x4复用,省略了复制和移动,vs2017下测试
也是这样,这句代码只在GetObj中调用一次默认构造函数,并没有拷贝发生。
*/
X x4(GetObj());
结合上文,可以看到,移动构造函数和拷贝构造函数区别,拷贝构造函数参数是const X& rhs,移动构造函数参数是X&& rhs,这是个右值引用。当构造函数参数是一个右值时,优先进入移动构造函数而不是拷贝构造函数。移动构造函数中,它并没有重新分配新的空间,也无资源拷贝发生,它是直接“窃取”了右值引用参数中的资源指针,然后将参数中的指针置为nullptr,置为nullptr防止在右值中释放这个资源,因为这个资源已经被当前对象接管。移动赋值运算符也基本类似,它同样的也是“窃取”资源。
为什么能“窃取”右值中的资源呢?对于一个临时对象,它的生命周期很短暂,一般在执行完当前这条表达式之后,他就释放了,所以充分利用资源,即高效又合理。
我们仍然有一个问题。基于右值短暂, 左值持久的结论,实际上大部分情况下我们遇到的都是左值。在某些情况下,左值都是一个局部变量,或者左值只使用一次,它的生命周期也很短暂。那么能不能对这样的左值只移动而不拷贝呢?C+11中提供一个std::move()方法,它表示对象能被移出,允许将资源转移到另一个对象,特别是它能生成一个右值表达式,实际这个函数等效于static_cast到一个右值引用类型。std::move能让你在左值上也使用移动语义。看下面的例子:
x1 = GetObj(); //调用移动赋值运算符函数,减少了一次资源拷贝
X x2(x1);//调用拷贝构造函数
X x5(std::move(x1));//此时调用了移动构造函数,x1中资源被移出,理论上x1可被重新赋值使用,但是我们最好不这样做以避免混淆
再举一个列子,标准库中的std::swap,我们已经为类M重载赋值运算符和拷贝构造函数:
template
void swap(T& a, T& b)
{
T tmp(a);
a = b;
b = tmp;
}
M a, b;
swap(a, b);
swap函数中没有右值, 所以函数中三行代码都没有使用到移动语义, 但是显而易见, 我们已经知道移动语义会更高效:不管一个变量是作为被拷贝的对象还是赋值运算的目标值,这个变量要么根本不会再使用,要么只作为赋值的目标。
在c++11中我们使用移动语义,重写std::swap,如下:
template
void swap(T& a, T& b)
{
T tmp(std::move(a));
a = std::move(b);
b = std::move(tmp);
}
Ma, b;
swap(a, b);
现在swap函数中三行代码都使用了移动语义。需要注意的是,对于那些没有实现移动语义的类型, 调用这个swap,它仍然跟之前的旧swap函数一样调用。再看下从标准库拷贝出的swap实现:
template inline
void swap(_Ty& _Left, _Ty& _Right)
_NOEXCEPT_OP(is_nothrow_move_constructible<_Ty>::value
&& is_nothrow_move_assignable<_Ty>::value)
{ // exchange values stored at _Left and _Right
_Ty _Tmp = _STD move(_Left);
_Left = _STD move(_Right);
_Right = _STD move(_Tmp);
}
跟我上述实现的swap是一样的。我们继续思考一个问题:swap函数中使用了std::move,我们知道std::move是返回的是一个右值引用,对于没有实现移动语义的类型,swap中构造和赋值实际调用的是左值拷贝构造函数和左值赋值运算符,其参数都是一个左值引用。 这是将一个右值引用传递给一个参数为左值引用函数的问题,这涉及到引用叠加。下文会讲到这个问题。
假设类M已经支持了移动语义,现在考虑一下下面的调用:
void foo(M&& m)
{
M m_ = m;//等同于M m_(m);
// TODO...
}
现在有一个问题:到底M的哪一个复制构造函数会被调用呢?m是一个右值引用参数,即指向一个右值的一个引用。因此,我们很可能预期m本身也应该像一个右值一样可以绑定,也就是说会调用:
M(M&& rhs);
这个移动构造函数。换句话说,可能会期望任何声明为右值引用的东西就是右值。但是右值引用设计者却设计了一个更加求巧妙的解决方案:被声明为右值引用的东西既可以是左值也可以是右值,区别的标准是:如果有名称,则为左值,否则,它是一个右值。
所以上面例子中,声明为右值引用的变量有一个名字(m),它是一个左值:
void ttt(M&& m)
{
M m_ = m;//此处调用M(const M& rhs);
// TODO...
}
M&& sss();
M m = sss();//调用M(M&& rhs),因为右边表达式返回值无名字
假如允许将移动语义应用于一个有名字的表达式或变量,我们刚从该表达式或变量中移出东西(即被窃取的资源),在后续代码中确仍然可以访问,这是很容易混淆并容易出错的。移动语义的全部要点就在于“无关紧要”的地方应用它,从某种意义上说,我们移动的东西,会在移动之后立即消失。因此规则为,如果有名称,则为左值,没名字就是右值。根据这个规则, 我们很能大概猜测出std::move()函数的实现方式。std::move()通过引用传递参数,并不做其它事情,std::move结果是一个右值引用,并且没有名字,它是一个右值。std::move()能将他的参数转换成一个右值,即使该参数不是,原理就是通过隐藏参数的名字。
有名字为左值,没名字为右值。下面这个列子会让你意识到这条规则的重要性。假设你编写了一个Base父类,并且通过重载了拷贝构造函数和赋值运算符实现了移动语义:
Base(Base const & rhs); // 普通拷贝构造函数
Base(Base&& rhs); // 移动构造函数
现在你写了一个雷Derived,它继承Base类, 为了确保移动语义应用于Derived对象的Base部分, 你也重载了Derived的拷贝构造函数和赋值运算符。让我们看下左值拷贝构造函数:
Derived(Derived const & rhs)
: Base(rhs)
{
//左值版本很简单,并没什么问题
}
我们再看下移动构造函数:
Derived(Derived&& rhs)
: Base(rhs) // 错误rhs是一个左值
{
// do something
}
如果这样编码的话,会调用到Base的左值拷贝构造函数,因为rhs有个名字,它是个左值。而我们实际想做的是调用Base的移动构造函数,只有参数为右值才能调用到移动构造函数,我们应该这样写:
Derived(Derived&& rhs)
: Base(std::move(rhs)) // OK 调用Base(Base&& rhs)
{
// do something
}
当右值引用和模板结合的时候,T&&并不一定表示右值引用,它可能是一个左值引用也可能是一个右值引用。如下面这段代码:
template
return_type func(T&& parem)
{
//do something
}
假如上面的函数模板中参数只表示右值引用的话,那么我们是不能传递左值的,但是实际上却是可以。T&&在此处是一个未定的引用类型,当它作为参数时,它是左值引用还是右值引用取决于它的初始化,如果它被一个左值初始化就是左值引用,如果被一个右值初始化,它就是右值引用。
需要注意的是,只有在进行类型推导时(如函数模板的自动推导、auto关键字),T&&才是universal reference。没有发生类型推导就没有universal reference。universal references只能以T&&出现。decltype也会发生类型推导,其可能能引发引用折叠(下文会讲)的发生,但是并不能说decltype(expression)&&就是一个universal reference。看下面例子:
template
void func(T&& param) {//T的类型需要推导,&&是一个universal references
//do something
}
template
class CTest1
{
CTest1(Test&& rhs);//CTest1类型确定,不需要推导,所以&&表示rvalue reference
};
template
class MyVector
{
void push_back(T&& param);//调用时已存在MyVector对象,T类型已经确定,没发生类型推导,&&是rvalue reference
};
//注意universal references只能以T&&出现
template
void func2(const T&& param); //含const,这只是一个rvalue reference
template
void func3(std::vector&& param); //std::vector&&非T&&形式, 且函数调用前std::vector类型已确定,无需推导,&&是一个rvalue reference;
std::string s1, s2;
auto&& t1 = s1;// t1为auto,需编译时推导类型,&&表示universal reference,它使用左值初始化,所以t1变成可一个左值引用
auto&& t2 = string("hello");// 同上,&&表示universal reference,=右侧为右值,auto&&转换为string&&
decltype(s1) && z1 = s2;//decltype(s1) 也需推导类型,但是此处并不是一个universal reference,此处类型是string&&, 赋值一个左值,编译出错
decltype(s1) && z1 = std::move(s2);//这样写是对的
universal references常出现于函数模板和auto声明的变量中。我们再来看一个例子,std::vector中的emplace_back函数,它功能跟push_back类似,但是某些情况下效率会更高,先看下标准库中实现:
template >
class vector
: public _Vector_alloc<_Vec_base_types<_Ty, _Alloc> >
{ // varying size array of values
//省略其它函数实现
template
void emplace_back(_Valty&&... _Val)
{ // insert by moving into element at end
if (this->_Mylast() == this->_Myend())
_Reserve(1);
_Orphan_range(this->_Mylast(), this->_Mylast());
this->_Getal().construct(_Unfancy(this->_Mylast()),
_STD forward<_Valty>(_Val)...);
++this->_Mylast();
}
}
windows这种风格看着乱七八糟, 我们稍微翻译下emplace_back实现:
template >
class vector {
public:
...
//class... 这是模板变长参数 别在意细节~
template
void emplace_back(Args&&... args); // 需要 ⇒ 类型推导;
... // && ≡ universal references
};
不要让emplace_back中可变数量参数迷惑了我们,函数的模板参数Args独立于类模板参数T,因此假设我们知道类为std::vector
,我们也不会知道emplace_back参数所采用的类型。所以即使知道是类std::vector
,编译器仍然需要推断传给emplace_back的参数类型。emplace_back的参数是一个universal reference。所以使用emplace_back ,在参数为右值的情况下,内部会使用移动语义, 减少无谓的拷贝。
实际上reference collapsing正是导致上文所述universal references产生的根本原因。C++11后,有左值引用T&和右值引用T&&两种类型,所谓引用折叠就是两种引用排列组合使用,其有四种情况,遵循下述规则:
(1)左值-左值 T& &,变成T&
(2)左值-右值 T& &&,变成T&
(3)右值-左值 T&& &,变成T&
(4)右值-右值 T&& &&,变成T&&
然而C++中并不允许使用对引用的引用,看下面代码:
int a = 0;
int &ra = a;
int & &rra = ra; // 编译报错:不允许使用对引用的引用
既然不允许使用对引用的引用,那么引用折叠有什么意义呢?其实看了上面universal references,我们已经知道对于一个函数模板, 参数为T&&的时候,如果参数被左值初始化就是左值引用, 如果被右值初始化就是有值引用,很明显这遵循了上述引用叠加规则的3、4两条。universal references实际上就是利用了模板推导和引用折叠的规则,生成不同实例化模板来接受传进的参数。
所以编译器不允许我们写int& &&
这样的代码,但是它自己却能推导出这样的int& &&
这样的代码出来,理由就是编译器可以利用引用折叠规则int& &&
可转化为int&
,最终版本并没有reference to reference(对引用的引用)。
有了万能引用, 当我们需要即需要接收左值类型又需要接收右值类型的时候,我们不用再写两个重载函数了。
在讲解完美转发之前,我们先来了解下一个函数模板的参数推导规则,对于函数模板,在参数为T&&右值引用的时候:
template
void foo(T&&);
T的推断规则如下:
(1)当foo调用传入的是一个左值A,那么T将被推导为A&,因此根据引用叠加规则,参数最终类型为A&
(2)当foo调用传入的是一个右值A,那么T将被推导为A,因此参数最值类型为A&&
这个规则很重要。
所谓完美转发,就是将一个函数的参数继续转交给另一个函数进行处理,这个参数可能是左值或者右值,如果能不改变参数的特征,那么它就是完美的。
这时我们可能会想到上面所说的universal references。不过上面讨论支持universal references的函数模板时, 我们并没考虑到在函数中继续转发参数的情况,我们来用代码测试下:
//接首左值
template
void print_(T&& param)
{
std::cout << "print_(T&&)" << std::endl;
}
//重载,接收右值
template
void print_(T& param)
{
std::cout << "print_(T&)" << std::endl;
}
//func_forward中T&&是universal reference
template
void func_forward(T&& param)
{
//此时此时param虽然是右值引用,但是它有name,是左值
//左值会进入print_的左值引用重载函数
//print_(T& &&) = print_(T&)
//右值引用变成了左值引用
print_(param);
}
void test_forwarding()
{
int a = 0;
func_forward(a);//传入左值
func_forward(10);//传入右值
}
运行test_forwarding,打印的是:
output:
print_(T&)
print_(T&)
可以看出不会进入print_的右值重载函数中,这个问题我们前面也探讨过。在func_forward函数内部,不管我们参数是什么类型,它都是有名字的, 有名字是一个左值。
此处在参数转发过程中就是不完美转发了。C++中提供了std::forwarding()模板函数解决这个问题。将func_forward函数简单改写下:
template
void func_forward(T&& param)
{
//对比之前的print_(param)
//此处完美转发了,注意是std::forward不是std::forward
print_(std::forward(param));
}
现在看输出
output:
print_(T&)
print_(T&&)
完美转发了 so easy!
我们来看下std::forwarding()函数实现,标准库中它有两个重载, 一个参数是左值引用的一个是右值引用的。很明显在上述函数中参数转发时,参数是一个左值, 所以我们只看左值引用版本的实现:
template inline
constexpr _Ty&& forward(
typename remove_reference<_Ty>::type& _Arg) _NOEXCEPT
{ // forward an lvalue as either an lvalue or an rvalue
return (static_cast<_Ty&&>(_Arg));
}
再精简下这个代码:
template
T&& forward(T& param)
{
return (static_cast(param));
}
分析下这段代码:T不管是什么类型,经过折叠引用, 其最终还是一个T&的左值引用类型,forward是以左值引用的方式接收param,然后通过static_cast
我们再来分析下func_forward(T&& param)是如何完美转发的:
1)传入参数是int的左值类型
//本节开头所说函数模板参数类型推导规则,T被推断为int&
void func_forward(T && param){ print_(std::forward(param));}
//再来分析std::forward(param) ,将T=int&代入
int& && std::forward(int& & param)
{
return static_cast(param);
}
//引用折叠下,最终返回一个左值引用int&
int& std::forward(int& param)
{
return static_cast(param);
}
最终forward返回一个左值引用,保留实参的左值属性,转发正确!
2)传入参数是int右值类型
//本节开头所说函数模板参数类型推导规则,T被推断为int
void func_forward(T && param){ print_(std::forward(param));}
//再来分析std::forward(param) ,将T=int代入,很明显返回的是个int&&,右值引用
int && std::forward(int& param)
{
return static_cast(param);
}
最终forward返回一个右值引用,保留实参的左值属性,转发正确!
到此,完美转发也介绍完毕了!
在写文章的过程中,我查询了不少资料,一开始我也以为自己熟知右值引用相关知识栈,然而在写此文的过程中, 随着不断查询资料,我才知道我所了解的也只是冰山一角。直至现在我仍不敢说我已窥其全貌。但是,我已经尽力去描述了右值引用及相关特性,如有疏漏或者不足,烦请指正!
附相关参考资料:
https://isocpp.org/blog/2012/11/universal-references-in-c11-scott-meyers
http://thbecker.net/articles/rvalue_references/section_01.html
https://en.cppreference.com/w/cpp/11
《Effective Modern c++》