右值引用与移动语义
左值与右值
在传统的C++中,按照属性把值分为两种类别:左值和右值。
左值指的是那些实际存储于内存或寄存器中的值。它往往是可以修改的,所以常出现于表达式的左边,比如一个整形变量n,我们可以对其赋值:n = 16。
而右值一般不可被修改,所以常位于表达式的右边,如立即数3、布尔值true、空指针nullptr,我们无法对其赋值:3 = 10。
但并非不可被修改的值就是右值,如:
int const n = 3;
n = 16; // n is lvalue
所以,一般的做法是通过取地址来区分左值与右值,左值可以拿到地址,而右值拥有着临时的属性,一般来说无法取得地址。
那么左值位于右边会发生什么呢?如int x = y;
此时会经历一个由左值至右值的隐式转换,先将y转换为一个右值,再赋值给x。
到了C++11,值类别发生了变化,因为要支持移动语义,所以出现了右值引用。现在值类型被分的更加细致,分别是三种核心类型和两种复合类型。
核心类型为:左值、纯右值、临终值。
复合类型由这三种类型组合,分为左值与右值。其中左值与右值又由:
- 左值:左值、临终值
- 右值:纯右值、临终值
组成。
此时,左值专指那些在内存上拥有实体的值,它可以用来确定标识一个对象,一个函数,如int n,Point p等等。
而纯右值专指那些除字符串的字面常量,是的,字符串字面常量并非右值,而是左值。纯右值可以用来初始化一个对象,或是用来进行四则运算,如10、true、false、nullptr。注意,lambda表达式同样属于纯右值。
最后来说说「临终值」,这主要是新增的细分类型,也是支持右值引用的那么右值。之所以既把它归于左值,又归于右值,是因为其拥有左值与右值共有的属性。它指那些可以用来标明一个对象可以被重复使用,但该对象并不会存在太久,稍后便消亡的值。既有左值标识对象的属性,又有右值那临时的属性,如一个被强制转换为右值引用的对象,或是调用std::move的对象。
可以来看一个例子:
class Point {};
// 接受任意的值类型
void g(Point const&)
{
std::cout << "Point const&\n";
}
// 接受纯右值和消亡值类型
void g(Point&&)
{
std::cout << "Point&&\n";
}
void foo()
{
Point p;
Point const cp;
// 可修改的左值
g(p);
// 不可修改的右值
g(cp);
// 纯右值
g(Point());
// 消亡值
g(std::move(p));
}
输出如下:
Point const&
Point const&
Point&&
Point&&
除了此种方法,还可以使用decltype来确定一个值所属的类型:
int n = 3;
int& a = n;
int&& b = std::move(a);
// 左值
bool ret = std::is_same_v<decltype(n), int>;
assert(ret);
// 左值
ret = std::is_same_v<decltype(a), int&>;
assert(ret);
// 消亡值
ret = std::is_same_v<decltype(b), int&&>;
assert(ret);
// 纯右值
ret = std::is_same_v<decltype(5), int>;
assert(ret);
移动语义
千头万绪从何说起?还是让我们以一个例子来说明为何需要移动语义。
class Point {
public:
Point(double const& x)
{
std::cout << "constructor\n";
}
Point(Point const& p)
{
std::cout << "copy constructor\n";
}
Point const& operator=(Point const& p)
{
std::cout << "operator=\n";
}
~Point()
{
std::cout << "deconstructor\n";
}
private:
double m_x;
};
Point GetPoint()
{
Point p(3.1); // p创建,一次构造操作
return p; // 右值返回值创建,一次拷贝构造操作
} // p析构,一次析构操作
int main()
{
Point p = GetPoint(); // p创建,一次拷贝构造操作
//右值返回值析构,一次析构操作
std::cin.get();
return 0;
} // p析构,一次析构操作
可以看到,此时进行了三次构造操作和三次析构操作,然而其中两次的构造与析构其实并不需要,因为它们是临时的,若构造成本过高,十分影响效率。
此处没有给出输出结果,是因为编译器会对程序代码做出部分转化,以优化我们的程序。这种优化被称为NRV(Named Return Value)优化,基本上所有的编译器都会进行这种优化。
比如,优化后的GetPoint可能会是这样的:
void GetPoint(Point &__result)
{
// 调用构造函数初始化
__result.Point::Point(3.1);
return;
}
那么此时,就只会有一次构造操作和一次拷贝构造,以及一次析构操作,VS2017就是这样给我输出的:
constructor
copy constructor
deconstructor
而g++给出的输出是:
constructor
deconstructor
它连那一次拷贝操作也给优化掉了。
这是编译器对于程序代码的优化,已经做的非常好了,而右值引用解决的是那些编译器优化不了的操作,此时使用移动语义能提高我们的程序效率。
比如上面的赋值拷贝运算符重载,它的实现可能是这样的:
Point const& operator=(Point const& p)
{
if(this == &p) return *this;
// 假设m_x是个需要动态分配内存的对象
delete m_x;
m_x = new double;
m_x = p.m_x;
return *this;
}
若传入的是一个右值,那么还是需要重新分配一次内存,因为右值是临时的,后面可能根本就不需要再次被使用,所以此处就造成了浪费。
由于有了右值引用,所以就可以避免这次内存分配,移动版本的赋值构造可能实现如下:
Point const& operator=(Point&& p)
{
if(this = &p) return *this;
m_x = p.m_x;
p.m_x = nullptr; // 右值置空,避免析构
return *this;
}
完美转发
最后,稍微提一下std::move与std::forward。
move()用于把一个函数强制转换为右值,它所做的事可以看看下面这个实现:
template<typename T>
decltype(auto) move(T&& param)
{
using ReturnType = typename remove_reference_t<T>&&;
return static_cast<ReturnType>(param);
}
move对于任何传进来的值都会进行无条件转换为右值,这意味着随后你不能再使用传进来的那个值,move这个名称就标识着转移所有权。
而有时你期望按照传进来的值来决定该值类型,是右值就使用右值,是左值就使用左值,而非全部强制,此时就要使用forward()来进行转发,它会完美的进行类型匹配。
forward一般用于进行模板选择,例子请参考今天第一篇文章。
更多内容,后期会再有一篇更深入的来解析,这篇其实已经可以使你很好地使用右值了,就先到这儿~