右值引用与移动语义

左值与右值

在传统的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一般用于进行模板选择,例子请参考今天第一篇文章。

更多内容,后期会再有一篇更深入的来解析,这篇其实已经可以使你很好地使用右值了,就先到这儿~

posted @ 2019-07-06 20:13  cpluspluser  阅读(549)  评论(1编辑  收藏  举报