左值 lvalue,右值 rvalue 和 移动语义 std::move

微信扫一扫,分享到朋友圈

左值 lvalue,右值 rvalue 和 移动语义 std::move

参考文章:

刷 Leetcode 时,时不时遇到如下 2 种遍历 STL 容器的写法:

int main()
{
vector<int> v = {1, 2, 3, 4};
for (auto &x: v)
cout<<x<<' ';
cout<<endl;
for (auto &&x: v)
cout<<x<<' ';
cout<<endl;
}

一个困扰我很久的问题是 auto &
auto &&
有什么区别?

左值、右值、纯右值、将亡值

首先要明确一个概念,值 (Value) 和变量 (Variable) 并不是同一个东西:

  • 值只有 类别(category)
    的划分,变量只有 类型(type)
    的划分。
  • 值不一定拥有 身份(identity)
    ,也不一定拥有变量名(例如 表达式中间结果 i + j + k
    )。

定义

左值(lvalue, left value),顾名思义就是赋值符号左边的值。准确来说, 左值是表达式(不一定是赋值表达式)后依然存在的持久对象。

右值(rvalue, right value),右边的值,是指表达式结束后就不再存在的临时对象。

C++11 中为了引入强大的右值引用,将右值的概念进行了进一步的划分,分为:纯右值和将亡值。

纯右值 (prvalue, pure rvalue),纯粹的右值,要么是纯粹的字面量,例如 10, true
; 要么是求值结果相当于字面量或匿名临时对象,例如 1+2
。非引用返回的临时变量、运算表达式产生的临时变量、原始字面量、Lambda 表达式都属于纯右值。

C++( 包括 C ) 中所有的表达式和变量要么是左值,要么是右值。通俗的左值的定义就是非临时对象,那些可以在多条语句中使用的对象。所有的变量都满足这个定义,在多条代码中都可以使用,都是左值。右值是指临时的对象,它们只在当前的语句中有效。

例子:

int i = 0; // ok, i is lvalue, 0 is rval
// 右值也可以出现在赋值表达式的左边, 但是不能作为赋值的对象,因为右值只在当前语句有效,赋值没有意义。
// 0 作为右值出现在了”=”的左边。但是赋值对象是 i 或者 j,都是左值。
(i > 0? i : j) = 233

总结:

  • 所有变量
    都是左值。
  • 右值都是临时的,表达式结束后不存在, 立即数、表达式中间结果
    都是右值。

特殊情况

需要注意的是,字符串字面量只有在类中才是右值,当其位于普通函数中是左值。例如:

class Foo
{
const char *&&right = "this is a rvalue"; // 此处字符串字面量为右值
// const char *&right = "hello world";    // error
public:
void bar()
{
right = "still rvalue"; // 此处字符串字面量为右值
}
};
int main()
{
const char *const &left = "this is an lvalue"; // 此处字符串字面量为左值
// left = "123"; // error
}

将亡值

将亡值 (xvalue, expiring value),是 C++11 为了引入右值引用而提出的概念 (因此在传统 C++ 中,纯右值和右值是同一个概念),也就是即将被销毁、却能够被移动的值。将亡值表达式,即:

move

先看一个例子:

vector<int> foo()
{
vector<int> v = {1,2,3,4,5};
return v;
}
auto v1 = foo();

按照传统 C++ 的方式(也是我们这些 C++ 菜鸟的理解),上述代码的执行方式为: foo()
在函数内部创建并返回一个临时对象 v
,然后执行 vector<int>
的拷贝构造函数,完成 v1
的初始化,最后对 foo
内的临时对象进行销毁。

那么,在某一时刻,就存在 2 份相同的 vector
数据。如果这个对象很大,就会造成大量额外的开销。

v1 = foo()
中, v1
是一个左值,可以被继续使用,但 foo()
就是一个纯右值, foo()
产生的那个返回值作为一个临时值,一 旦被 v1
复制后,将立即被销毁,无法获取、也不能修改。

而将亡值就定义了这样一种行为: 临时的值能够被识别、同时又能够被移动

在 C++11 之后,编译器为我们做了一些工作, foo()
内部的左值 v
会被进行 隐式右值转换
,等价于 static_cast<vector<int> &&>(v)
,进而此处的 v1
会将 foo
局部返回的值进行移动。也就是后面将会提到的移动语义 std::move()

个人的理解是,这种语法的引入是为了实现与 Java 中类似的对象引用系统。

左值引用与右值引用

区分左值引用与右值引用的例子

先看一段代码:

int a;
a = 2;  //a是左值,2是右值
a = 3;  //左值可以被更改,编译通过
2 = 3;  //右值不能被更改,错误
int b = 3;
int* pb = &b;  //pb是左值,&b是右值,因为它是由取址运算符返回的值
&b = 0;  //错误,右值不能被更改
// lvalues:
int i = 42;
i = 43; // ok, i is an lvalue
int* p = &i; // ok, i is an lvalue
int& foo();
foo() = 42; // ok, foo() is an lvalue
int* p1 = &foo(); // ok, foo() is an lvalue
// rvalues:
int foobar();
int j = 0;
j = foobar(); // ok, foobar() is an rvalue
int k = j + 2; // ok, j+2 is an rvalue
int* p2 = &foobar(); // error, cannot take the address of an rvalue
j = 42; // ok, 42 is an rvalue

那么问题来了:函数返回值是否只会是右值?当然不是。

vector<int> v(10, 0);
v[0] = 111;

显然, v[0]
会执行 []
的符号重载函数 int& operator[](const int x)
, 因此函数的返回值也是可能为左值的。

深入浅出

要拿到一个将亡值,就需要用到右值引用 T &&
,其中 T
是类型。右值引用的声明让这个临时值的生命周期得以延长,只要变量还活着,那么将亡值将继续存活。

C++11 提供了 std::move
这个方法 将左值参数无条件的转换为右值
,有了它我们就能够方便的获得一个右值临时对象,例如:

#include <iostream>
#include <string>
using namespace std;
void reference(string &str) { cout << "lvalue ref" << endl; }
void reference(string &&str) { cout << "rvalue ref" << endl; }
int main()
{
string lv1 = "string,"; // lv1 is lvalue
// string &&r1 = lv1;  // 非法,右值引用不能引用左值
string &&rv1 = std::move(lv1); // 合法,move 可将左值转移为右值
cout << rv1 << endl;
// string &lv2 = lv1 + lv1; // 非法,非常量引用的初始值必须为左值
const string &lv2 = lv1 + lv1; // 合法,常量左值引用能够延长临时变量的生命周期
cout << lv2 << endl;
string &&rv2 = lv1 + lv2; // 合法,右值引用延长临时对象生命周期(通过 rvalue reference 引用 rval)
rv2 += "Test";
cout << rv2 << endl;
reference(rv2); // 输出 "lvalue ref"
// rv2 虽然引用了一个右值,但由于它是一个引用,所以 rv2 依然是一个左值。
// 也就是说,T&& Doesn’t Always Mean “Rvalue Reference”, 它既可以绑定左值,也能绑定右值
}

为什么不允许非常量引用绑定到左值?

一种解释如下(C++ 真傻逼)。

这个问题相当于解释下面一段代码:

int i = 233;
int &r0 = i; // ok
double &r1 = i; // error
const double &r3 = i; // ok

因为 double &r1
类型与 int i
不匹配,所以不行,那为什么 const double &r3 = i
是可以的?因为它实际上相当于:

const double t = (double)i;
const double &r3 = t;

在 C++ 中,所有的临时变量都是 const
类型的,所以没有 const
就不行。

移动语义

先看一段代码,熟悉一下 move
做了些什么:

#include <iostream>
#include <string>
using namespace std;
int main()
{
string a = "sinkinben";
string b = move(a);
cout << "a = \"" << a << "\"" << endl;
cout << "b = \"" << b << "\"" << endl;
}
// Output
// a = ""
// b = "sinkinben"

然后看完下面一段代码,结束这一回合。

template <class T> swap(T& a, T& b){
T tmp(a);  //现有两份a的拷贝,tmp和a
a = b;     //现有两份b的拷贝,a和b
b = tmp;   //现有两份tmp的拷贝,b和tmp
}
//试试更好的方法,不会生成额外的拷贝
template <class T> swap(T& a, T& b){
T tmp(std::move(a)); //只有一份拷贝,tmp
a = std::move(b);    //只有一份拷贝,a
b = std::move(tmp);  //只有一份拷贝,b
}

个人感觉, b = move(a)
这一语义操作,是
把变量 b
绑定到数据 a
的内存区域上

,从而避免了无意义的数据拷贝操作。

下面这一段代码可以印证我的这个观点。

#include <iostream>
class A
{
public:
int *pointer;
A() : pointer(new int(1))
{
std::cout << "构造" << pointer << std::endl;
}
A(A &a) : pointer(new int(*a.pointer))
{
std::cout << "拷贝" << pointer << std::endl;
} // 无意义的对象拷贝
A(A &&a) : pointer(a.pointer)
{
a.pointer = nullptr;
std::cout << "移动" << pointer << std::endl;
}
~A()
{
std::cout << "析构" << pointer << std::endl;
delete pointer;
}
};
// 防止编译器优化
A return_rvalue(bool test)
{
A a, b;
if (test)
return a; // 等价于 static_cast<A&&>(a);
else
return b; // 等价于 static_cast<A&&>(b);
}
int main()
{
A obj = return_rvalue(false);
std::cout << "obj:" << std::endl;
std::cout << obj.pointer << std::endl;
std::cout << *obj.pointer << std::endl;
return 0;
}
/* Output
构造0x7f8477405800
构造0x7f8477405810
移动0x7f8477405810
析构0x0
析构0x7f8477405800
obj:
0x7f8477405810
1
析构0x7f8477405810
*/

对于 queue
或者 vector
,我们也可以通过 move
提高性能:

// q is a queue
auto x = std::move(q.front());
q.pop();
// v is a vertor
v.push_back(std::move(x));

如果 STL 中的元素「体积」都很大,这么做也能节省一点开销,提高性能。

完美转发

恕我直言,这个翻译是个辣鸡。英文名叫 Perfect Forwarding .

这是为了解决这样一个问题:实参被传入到函数中,当它被再传到另一个函数中,它依然是一个左值或右值。

template <class T>
void f2(T t){ cout<<"f2"<<endl; }
template <class T>
void f1(T t){
cout<<"f1"<<endl;
f2(t);
//如果t是右值,我们希望传入f2也是右值;如果t是左值,我们希望传入f2也是左值
}
//在main函数里:
int a = 2;
f1(3); //传入右值
f1(a); //传入左值

在引进:point_up_2:巴拉巴拉的这一套机制之前,即 C++11之前的情况是怎么样的呢?当我们从 f1
调用 f2
的时候,不管传入 f1
的是右值还是左值,因为 t
是一个变量名,传入 f2
的时候都变成了左值,这就会造成因为调用 T
的拷贝构造函数而生成不必要的拷贝浪费大量资源。

那么现在有一个叫 forward
的函数,就可以这样做:

template <class T>
void f2(T t){ cout<<"f2"<<endl; }
template <class T>
void f1(T&& t) {    //这是通用引用,而不是右值引用
cout<"f1"<<endl;
f2(std::forward<T>(t));  //std::forward<T>(t)用来把t转发为左值或右值,决定于T
}

这样, f1
调用 f2
的时候,调用的就是 移动构造函数
而不是拷贝构造函数,可以避免不必要的拷贝,这就叫「完美转发」。

完美转发,傻逼到家。

结语

本文开始提出的问题 auto &
auto &&
有什么区别?这个问题就更复杂了,涉及到 Universal Reference 这个概念,可以参考这 2 篇文章:

有空再说。

傻逼 C++ 。

微信扫一扫,分享到朋友圈

左值 lvalue,右值 rvalue 和 移动语义 std::move

《进击的巨人》作者故乡主角铜像揭幕 谏山创:进度已达98%

上一篇

蘑菇街:签约主播小甜心单场直播成交额破亿

下一篇

你也可能喜欢

左值 lvalue,右值 rvalue 和 移动语义 std::move

长按储存图像,分享给朋友