右值引用与std::move()
# 右值引用与std::move()
# 左值与右值
- 左值可以理解为一个有名字的变量,他能出现在等号的左边,也能出现在等号的右边
int x = 10; // x 是一个左值
int a = x; // 可以出现在等号右边
- 右值是一个没有名字的数值,他只能出现在等号的右边
int x = 10; // 这里 x 是左值,而 10 是右值
对于局部变量 x
, 他会被保存在栈空间里面,而对于右值,它只会被临时保存,故而存储在寄存器内,很快就被丢弃了。
# 左值引用
左值引用是 CPP 里一个比较基础的概念。
我们先定义一个变量 int a = 1;
同时再定义该变量的一个左值引用int &x = a;
那么在这里 x
就可以被理解为 a
的一个别名,当我们修改 x
时,对应的 a
也会发生改变,即 x
与 a
共享同一块地址空间。
# 右值引用
区别于左值引用,我们在定义右值引用时会用 &&
int &&x = 10; // x 是一个右值引用
右值引用延续了右值 10 的生命周期,我们用 x
将右值的状态保存了下来。
注意
右值引用 x
本身是一个左值,其引用了右值 10
。
与左值引用相同,本质上其实都是引用,因此无论是定义一个左值引用,还是一个右值引用,必须赋予其初值,同时还得注意左值引用只能用左值来初始化,右值引用只能用右值初始化。
int &a; // 错误,左值引用需要初始化
int &&b; // 错误,右值引用需要初始化
int x = 10; // 定义一个变量 x
int &c = x; // 正确
int &d = 10; // 错误,左值引用不能用右值初始化
int &&e = x; // 错误,右值引用不能用左值初始化
int &&f = 10; // 正确
int func() { ... } // 一个返回值类型为 int 的函数
int &&g = func(); // 正确,没有声明名字的 func() 的返回值是一个右值
# std::move()
对于右值引用的初始化,我们也许会思考,一个右值引用能不能用一个左值来初始化呢?
在这里我们就可以用到移动函数 std::move()
了,我们向其传递一个左值,而它会返回一个右值。
int x = 10; // 定义一个变量
int &&a = x; // 错误,右值引用不能用左值初始化
int &&b = std::move(x); // 正确,std::move(x) 返回了一个值与左值 x 相同的右值
注意
在调用了 std::move()
后,我们的移后源变量,也就是我们传入的左值会变成未知的状态,我们不能直接使用它,即使再次使用,其值也是未知的,这十分危险。我们可以再对其重新赋值后,再次使用。
# 常引用
常引用的定义方式和左值引用很像,就是多了一个 const,但与左值引用不同,我们可以用左值或者右值来初始化常引用。
int x = 10; // 定义一个变量
const int &a = x; // 正确
const int &b = 10; // 正确
常引用的应用场景:
常引用有一个非常有用的地方,我们经常会将常引用作为函数的形参类型,如此一来对于该函数,我们不仅能传递一个左值,同时还能传递一个右值。
void print(const int &x) {
std::cout << x << "\n";
}
// ...
print(10); // 正确
int x = 1;
print(x); // 正确
# 右值引用与std::move()
接下来我们来讲几个具体的应用场景。
1. 常引用与右值引用
作为函数的形参时,常引用不能对值进行修改,但是右值引用可以。
void print1(const int &x) {
x = 20; // 错误,常量不能修改
std::cout << x << "\n";
}
void print2(int &&x) {
x = 20; // 正确
std::cout << x << "\n";
}
以下几点注意事项需要给出:
在上面的两个函数中,
print2()
中可以修改x
的值,但print1()
中则不可以。对于两个同名的重载函数来说,右值引用作为形参的版本优先比常引用版本作为形参的版本更高。
对于
print2()
,其接受一个右值,如果我们想传递一个左值进去,可以通过std::move()
,即print2(std::move(x));
2. 移动语义
首先我们要知道几个概念,何为 拷贝 和 移动。而对于进一步的类里的一些概念在这里并不进一步阐述,如拷贝构造函数、移动构造函数、拷贝赋值函数、移动赋值函数、析构函数...
对于如下一个函数,以及其调用过程:
int f(int x) {
return x;
}
...
int a = 10;
std::cout << f(a) << "\n";
我们在给函数 f
传递参数 a
的时候实际上是进行了一次拷贝,我们将 a
的值拷贝给了一个临时的局部变量 x
,再进行函数的一系列操作。
那么函数的规模以及其调用次数更大的时候,拷贝所带来的性能上的降低是巨大的。
而这在类里面,当我们定了一个对象,我们想用这个对象去构造一个新的对象,而抛弃这个对象。
非常明显,对于拷贝操作来说,我们会先将旧对象的属性拷贝给新对象的属性,然后将旧对象的属性通过析构函数给销毁,显然这一步非常的冗余以及低效。
于是,我们便有了移动这一思想,我们可以将旧对象的属性移动给新对象,这样就避免的中间拷贝、销毁等一系列操作,大大提高了程序的运行效率。
而移动这一操作便是通过右值引用与std::move()来实现的
class A {
private:
int a = 5;
public:
A(const A &); // 拷贝构造函数
A &operator=(const A &); //拷贝移动函数
A(A &&) noexcept; // 移动构造函数
A &operator=(A &&) noexcept; //移动赋值函数
~A(); // 析构函数
};
在这里我们定义类A,并为其声明了五个相关函数。
一般情况下,构造A类的对象,或者将一个对象赋值给另一个对象,如果用移动控制函数会大大提高效率。
另外我们通过在移动控制函数的最后加上了 noexcept
,这是为了告诉编译器,我们的移动控制函数不会产生异常,需要优先使用移动控制函数。因为移动控制函数会改变移动源元素,如果移动过程中产生了异常,那么新对象和旧对象我们都无法正确使用了,而拷贝控制函数则不会有这烦恼,故编译器一般为了安全考虑,会优先使用拷贝控制函数。