参考文章:
- [1] 基础篇:lvalue,rvalue和move
- [2] 深入浅出 C++ 右值引用
- [3] Modern CPP Tutorial
- [4] 右值引用与转移语义
刷 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
)。
定义
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++ 。