C++中的左值与右值以及右值引用到底是什么?

左值与右值

通常来说你确实可以使用一个值在表达式中的位置信息来判断这个值的左右值类型,比如说在下面这段代码中:

int x = 10;
int y = 20;
int sum = x + y;

最后一行当中,位于 " = " 左侧的sum就是一个左值,而右侧的 " x + y " 就是一个右值。

除此之外,还可以用另外一种常用的方式来判断:

  • 可以取地址的,有名字的就是左值
  • 反之不能取地址,没有名字的就是右值

比如在刚刚的代码的最后一行中,我们就可以对sum进行取地址操作,而不能对等号右侧的x+y进行取地址操作。

C++11当中的右值

在C++11当中,右值被分为了xvalueprvalue两种类型,它们两者对应的单词分别是 Expiring ValuePure Right Value ,对应的中文名词可以翻译为将亡值纯右值

  • 其中纯右值就是我们指的最常见的那种右值类型:比如说
  1. 函数返回的临时变量值
  2. 字面量值
  3. lambda表达式

这些都是无法取地址的临时值类型,当然,你可以把它们赋值给一个左值然后继续使用。

  • 而将亡值就是在C++11中新引入的根右值引用相关的表达式类型,这样的表达式通常是要被移动的对象

右值引用,顾名思义就是对一个右值的引用,由于右值本身并不具有名字,所以我们只能通过右值引用来关联到它们的存在,在C++中,我们可以使用两个 "按位与" 符号(&&)来表示一个右值引用类型,举个例子:

int &&x = 10;

这里的变量x就是代表一个整型临时值的右值引用类型。

那么右值引用应该用在哪些地方呢?一般来说你可以通过右值引用来完成对一个将亡值的语义转移过程,通常来讲,在C++中我们会通过拷贝构造函数来完成类对象之间的赋值过程,下面看一段代码案例:

#include <iostream>

class A
{
public:
    A(size_t size) : size(size), array((int*) malloc(size))
    {
        std::cout << "constructor called." << std::endl;
    }
    ~A()
    {
        free(array);
    }
    A(const A &a) : size(a.size) 
    {
        array = (int*) malloc(a.size);
        std::cout << "normal copied, memory at:" << array << std::endl;
    }
    size_t size;
    int *array;
};

int main(int argc, char **argv)
{
    auto x = A(100);
    auto y = x;
    return 0;
}

在这段代码中,临时值赋值给变量x,以及x被赋值给变量y时,会分别执行两次在类A中定义的拷贝构造函数,而在这次拷贝构造函数中,每一次都会对类对象内的array数组重新进行内存分配的过程。而为了完成一个语义转换的过程,我们就需要使用C++中另外一个名字为移动构造函数的成员函数类型,那么一段完整的C++代码是这样的:

#include <iostream>

class A
{
public:
	A(size_t size) : size(size), array((int*)malloc(size))
	{
		std::cout << "constructor called." << std::endl;
	}
	~A()
	{
		free(array);
	}
	A(A &&a) : array(a.array), size(a.size)
	{
		a.array = nullptr;
		std::cout << "xvalue copied, memory at: " << array << std::endl;
	}
	A(const A &a) : size(a.size)
	{
		array = (int*)malloc(a.size);
		std::cout << "normal copied, memory at: " << array << std::endl;
	}
	size_t size;
	int *array;
};

int main(int argc, char **argv)
{
	auto getTempA = [](size_t size = 100) -> A
	{
		auto tmp = A(size);
		std::cout << "Memory at: " << tmp.array << std::endl;
		return tmp;
	};
	std::cout << std::is_rvalue_reference<decltype(getTempA()) && >::value << std::endl; // true

	auto x = getTempA(1000);
	auto y = x;
	return 0;
}

我们先来分析一下主函数:

首先我们编写了一个用于返回临时值的lambda函数getTempA,通过这个函数可以返回一个类A的临时值对象,也就是一个右值。接下来我们使用了宏函数is_rvalue_reference来判断前面的lambda函数返回的值是否是一个右值引用。这里证实了我们的结论,这个宏函数返回了true,在接下来的两行代码中,我们分别把之前的右值赋值给了变量x,然后又把左值x赋值给了变量y。

此时我们再来回过头关注一下类A内部当中新增加的移动构造函数,这里我们单独提取出这段代码来分析:

...
A(A &&a) : array(a.array), size(a.size)
{
	a.array = nullptr;
	std::cout << "xvalue copied, memory at: " << array << std::endl;
}
...

可以看出在这个函数中,我们一共做了两件事情:

  • 第一件事是直接把临时值对象,也就是右值引用对象A内部的成员变量array的值直接赋值给了新生成的对象array变量,这使得新对象可以直接使用临时值内部已经分配好的这块内存空间,而不再像拷贝构造函数一样,需要取重新分配内存空间。
  • 而第二件事情,就是将临时值对象成员变量array的值置为nullptr,也就是空指针,这样做的目的是为了防止临时值对象的析构函数在执行时,将这块已经分配的内存区域清除,因为这块内存区域实际上已经被新对象成员中的成员变量array直接使用了

而之前所说的转移语义就可以理解成为:将临时对象内已经分配好的内存区域,直接偷过来使用这样一个过程。

总结

总体来讲,使用基于右值引用的语义转移过程可以使我们在复制具有大块内存空间的对象时,可以直接使用原对象已经分配好的内存空间,进而省去重新分配内存空间的过程。因此在某种程度上来讲,可以在一定条件下提升应用的整体运行效率。

在C++中,左值一般是指位于运算符左侧的值,并且这个值可以被进行取地址操作,而右值通常是指位于运算符右侧的值,但是这个值无法被取地址。

在C++11中,右值又被细分为纯右值和将亡值,比如常见的字面量值,表达式产生的临时变量值等均是纯右值。而将亡值则表示资源能够被重新使用的对象,常见的如std::move()函数的返回值,或者返回值被标记为右值引用(&&)的函数等。

对于纯右值和将亡值,在实际编码中,我们并不需要细致的区分,在大多数情况下,将它们统一看成右值来使用即可!

std::move

std::move是C++11当中新引入的一个标准库函数,std::move并不能移动任何东西,它唯一的功能是将一个左值强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义。确切地说:它使一个值易于移动。从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);

接下来我们来看一下std::move的用武之地,通常我们需要交换两个变量的值时,我们会这样编写函数:

template<class T>
void swap(T left, T right)
{
	T temp = left;
	left = right;
	right = temp;
}

通过这种方法会存在三次复制的操作,当然如果数据类型较小的情况下,这种方法是可取的,但是倘若数据量很大的情况下,这种交换操作的代价是非常昂贵的,因此我们采用了std::move的方法:

// std::swap函数的实现
template<class T>
void swap(T &left, T& right)
{
	T temp = std::move(left);    // left为空
	left = std::move(right);     // right为空
	right = std::move(temp);     // temp为空
}

使用这种方法的好处是,我们不必再为中间对象temp重新分配内存空间,而是直接将left申请好的内存直接偷过来用,从而避免了大量内存分配的高昂代价。

问题:为什么拷贝构造函数的参数会使用常量左值引用类型(const T&)?或者说使用这个类型有什么样的好处呢?

首先来说明一下为什么拷贝构造函数的参数必须为引用类型:

  • 这是由于,倘若拷贝构造函数的参数不是一个引用类型,而是诸如(const T)或(T)这样的传值类型,那么我们在调用拷贝构造函数的时刻,会采用传值(pass-by-value)的方式将实参的值传递给形参,而传值的方式又会调用该类的拷贝构造函数,从而造成无穷的递归调用拷贝构造函数,进而导致堆栈溢出,因此拷贝构造函数的参数必须为引用类型。

我们再来进一步分析为什么为常量类型:

  • 这是由于,我们拷贝构造函数设计的初衷即:根据被拷贝对象的内容,来初始化对象本身。使用常量类型是为了防止在拷贝构造函数内部,通过引用形参修改被拷贝对象本身的内容。
发布了37 篇原创文章 · 获赞 42 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/weixin_42570248/article/details/100748302