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

时间:2023-03-09 00:42:36
左值 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); //传入左值

在引进巴拉巴拉的这一套机制之前,即 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++ 。