一文搞懂左值与右值兼左值引用和右值引用(附右值引用的移动语义)

左值与右值兼左值引用和右值引用

前言

​ 在C++编程中,理解左值、右值以及左值引用和右值引用的概念是至关重要的。这些概念不仅有助于我们编写出更高效的代码,而且也是理解C++中一些高级特性(如移动语义和完美转发)的基础。本文将循序渐进的结合案例阐释概念,同时解释为什么、怎么样的问题,尤其着重与对不同使用场景下用途的剖析。


一、理解左值和右值

​ 左值和右值是在C语言中已经存在的概念,网上用来区分左值和右值的众多精炼解释:“左值可以被修改,右值不可修改”。然而,这句说法并不全面,下面我随意选取两个案例来进行辩证:

示例1:(常变量)

void test1()
{
	const int a = 10;	// 此处 a 不可修改,但属于左值
}

示例2:(返回值为非常量引用的函数表达式)

int& func()
{
	static int x = 1;
	return x;
}
void test2()
{
	func() = 5;		// foo()是一个右值,因为它是函数调用表达式,但是它可以被修改
	cout << func() << endl;
}

所以,通过自身可否被修改来判断是左值还是右值是不靠谱的,准确的说法应该是按照取地址来划分:

左值可以取地址,右值不可取地址 --> 前加“&”

首先,我们偏激地从两个不常被人注意的例子引入案例来验证这句话的准确性:

案例1:

int& func()		// 注意返回非常量引用
{
	static int x = 1;
	return x;
}
void test3()
{
	auto p = &func;		// 编译未报错
	cout << p << endl;
}

在这里插入图片描述

案例2:

void test4()
{
	int a = 10;
	auto p1 = &(a++);	// 后置++返回值类型,属于右值,此处取地址编译报错
	auto p2 = &(++a);	// 前置++返回非常量(no_const)引用,属于左值
}

编译报错:

在这里插入图片描述

至此,我们经过严谨的分析举例论证了 “取地址法” 用于判断左值和右值的科学性。

二、深入探讨左值引用

左值引用的定义

在C++中,左值引用是一种特殊类型的引用,左值只能绑定到左值引用。

特别注意:const 修饰的左值引用可以绑定到右值。

void test5()
{
	int a = 0;
	int* p = &a;

	// 下面的都是左值引用
	int& b1 = a;
	const int& b2 = a;
	int*& pb = p;
	int& b = *p;
}

注意上面的 “const int& b2 = a;” 其中的 b2 也属于左值引用,因为其引用的对象为左值,但是其值不可被修改。

那什么情况下属于 const 修饰的左值引用给右值起别名呢?

const int& b3 = 100;	// 左值引用绑定右值(将亡值)

这种情况属于左值引用的特例,但是与上面的描述并不冲突,考虑所有情况的统一而言:左值只能被左值引用绑定,无论是否带 const ;而右值也可以被左值引用绑定,但是有个前提,左值引用需要带 const ,也就是所说的常量引用(const reference)

左值引用的用途

左值引用主要用于函数参数返回类型,以实现引用传递和返回引用。

做函数参数:

void setDouble(int& x)
{
	x *= 2;
}
void test6()
{
	int a = 10;
	setDouble(a);
	cout << "a = " << a << endl;
}

在这里插入图片描述

做函数返回值:

int& get_nTimes()
{
	static int count = 0;
	return ++count;
}
void test7()
{
	for (int i = 0; i < 5; ++i)
	{
		get_nTimes();
	}
	int call_count = get_nTimes();
	cout << "call count number: " << call_count << endl;
}

特别注意:我们十分不建议用局部变量作函数引用类型返回值,而这里的 count 变量是静态成员,存储在全局区,函数栈帧调用结束后并不会随之消除,所以不会发生悬空引用的问题。

在这里插入图片描述

三、深入探讨右值引用

右值引用的定义

右值引用是C++11引入的新特性,它可以绑定到一个将要销毁的对象(即右值)。

// 右值引用
int min(int a, int b)
{
	return a < b ? a : b;
}
void test8()
{
	int x = 3, y = 5;
	// 以下都是右值引用
	int&& a = 20;
	int&& b = x + y;
	int&& c = min(x, y);
}

上面我们介绍过前置++和后置++的区别,前置++返回类型为非常量引用,后置++返回的类型为值类型,所以右值引用只能绑定到后置++,而不能绑定到前置++。

int&& d1 = ++x;		// 编译报错
int&& d2 = x++;

在这里插入图片描述

特别注意:右值引用还可绑定 move() 后的左值。

首先我们来简单看一下官方对 move() 函数的定义:

在这里插入图片描述

尤其通过上面图片中最后一行,我们可以清晰的了解到 move() 函数的返回值类型是右值引用。

所以通过 move() 函数可以实现右值引用对左值的绑定:

int&& e1 = move(y);
int&& e2 = y;		// 编译报错

在这里插入图片描述

右值引用的用途

为什么已经有了左值引用,还需要右值引用呢?

我们需要提到 左值引用 解决了哪些问题:

  1. 解决传参拷贝的问题

    void func(const T& val);
    
  2. 解决部分返回对象拷贝的问题

    (出了函数作用域, 返回对象未被销毁(如堆区存储的变量和全局区存储的变量等))利用左值引用返回,减少了拷贝消耗

没有解决的问题:函数返回对象是局部对象,出作用域后生命周期结束,只能传址返回,从而不可避免的存在拷贝,对程序的性能消耗很大。

所以在C++11中右值引用应运而生,用它的目的与左值引用一致:就是为了减少拷贝

右值引用做函数参数

首先我们来看右值引用作函数参数时的情况:

这里给出函数 f() 的重载版本,其参数分别为常量左值引用和右值引用:

void f(const int& a)
{
	cout << "void f(const int& a)" << endl;
}
void f(int&& a)
{
	cout << "void f(int&& a)" << endl;
}
void test9()
{
	int a = 100;
	f(a);
	f(100);
}

在这里插入图片描述

我们看到当常量100作为参数传递给 f() 函数时,优先适配的是右值引用,虽然 const左值引用也能对100(右值)接收。

C++11对右值的概念进行了细分:

  • 纯右值内置类型的右值) 如:“a+b”, "100"等
  • 将亡值自定义类型的右值) 如:匿名对象、传值返回函数、自定义类型对象后置++ … …

接着我们通过与拷贝构造函数对比,给出利用右值引用做参数的构造函数:移动构造函数

这里就不妨以自己模拟实现的String类为例,同时给出拷贝构造函数和移动构造函数,测试新定义对象时传入参数利用 move() 转换成右值,观察移动复制是否符合预期:

String类的拷贝构造和移动构造函数:

// 拷贝构造函数
String::String(const String& s)
{
	cout << "String::String(const String& s)" << endl;
	m_size = s.m_size;
	m_capacity = s.m_capacity;
	m_str = new char[m_capacity + 1];
	strcpy(m_str, s.m_str);
}
// 移动构造函数
String::String(String&& s)
{
	cout << "String::String(String&& s)" << endl;
	swap(s);
}

测试函数代码:

// 测试移动构造函数
String s1("hawwwcrwwwwcrw");
String s2(move(s1));
cout << s2 << endl;

在这里插入图片描述

通过执行结果我们看到移动构造函数被成功调用!

转而实现重载赋值运算符:

String& String::operator=(String&& s)
{
	cout << "String& String::operator=(String&& s)" << endl;
	swap(s);
	return *this;
}
隐式 move() 实现左值为转换右值

下面利用预先实现的值返回函数 to_String() 来观察移动函数的调用情况:

to_String() 函数:

String to_String(int n)
{
	bool flag = true;
	if (n < 0)
	{
		flag = false;
		n = 0 - n;	// 负数转为正数
	}

	String result;
	while (n)
	{
		int x = n % 10;
		n /= 10;
		result += ('0' + x);	// 数字转字符
	}

	if (!flag)
	{
		result.push_back('-');
	}
	// 反转
	reverse(result.begin(), result.end());
	return result;
}

测试案例代码:

void test9()
{
	// 测试添加移动构造和移动赋值函数后,值返回函数 to_String()
	String s;
	s = to_String(-10086);
	cout << s << endl;
}

给出g++和VS2022两种编译运行打印结果:

在这里插入图片描述

需要注意这里存在move()隐式转换

在这里插入图片描述

右值引用具有左值属性

上面的例子引出了存在隐式move()将左值转移为右值的行为,下面的案例将展现在何种情况下编译器会把指向右值的右值右值引用转为左值:

void test10()
{
	int&& a = 10;
	++a;		// a可被修改
	auto p = &a;	// a可被取地址 --> 左值属性
}

显而易见,此处右值引用变量具有左值属性,为什么已经是对右值的右值引用还要具有左值属性呢?它(右值引用)所引用的对象是纯右值(或将亡值),能够取地址有什么必要性呢?

来看下面的案例:

// 移动构造函数
String::String(String&& s)
{
	cout << "String::String(String&& s)" << endl;
	swap(s); // 注意这里对右值引用变量s操作
}

我们在移动构造函数中将自身与传入的右值引用变量互换,从而使得 s 在声明周期结束时,会自动带走 *this 的资源,虽然这里作用是初始化( *this 的资源为空),但是 s 的资源被转移到 *this。

右值不能改变,那怎么转移你的资源呢?那为什么这里 s 的资源可以被转移呢?

右值被右值引用绑定后,右值引用的属性是左值,可以被改变,这样才能实现资源的转移

为了证明这个观点,我们不妨引出更深层次的案例:

首先给出自定义的 List 类部分功能实现:

template<typename T>
class List
{
	void push_back(const T& val)	// 左值右值均可传入
	{
		insert(m_head, val);
	}

	void push_back(T&& val)		// 仅接受右值参数
	{
		insert(m_head, val);
	}

	iterator insert(iterator pos, const T& val)
	{
		Node* n = new Node(val);

		iterator pos_pre = pos.node->m_prev;
		n->m_prev = pos_pre.node;
		n->m_next = pos.node;
		pos.node->m_prev = n;
		pos_pre.node->m_next = n;

		return n;
	}
    iterator insert(iterator pos, T&& val)
	{
		Node* n = new Node(val);

		iterator pos_pre = pos.node->m_prev;
		n->m_prev = pos_pre.node;
		n->m_next = pos.node;
		pos.node->m_prev = n;
		pos_pre.node->m_next = n;

		return n;
	}
private:	// 成员变量
	Node* m_head;
};

template<typename T>
class List<T>::ListNode
{
public:
	ListNode* m_prev;
	ListNode* m_next;
	T m_val;
public:
	ListNode(const T& val = T()) : m_prev(nullptr), m_next(nullptr), m_val(val) {}
    // 右值引用做参数
	ListNode(T&& val) : m_prev(nullptr), m_next(nullptr), m_val(val) {}
};

接着我们利用上面的成员函数功能测试 String 类对象的插入情况:

#include "String.hpp"
void test_String()
{
	List<String> lt;
	cout << "***********************" << endl;
	String s = "vocal";
	lt.push_back(s);	// 参数为左值
	cout << "***********************" << endl;
	lt.push_back(String("hahaha"));		// 参数为右值(将亡值)
	cout << "***********************" << endl;
	lt.push_back("zundujiadu");		// 隐式类型转换为右值(将亡值)
	cout << "***********************" << endl;
}

运行结果:

在这里插入图片描述

我们看到已然实现的 String 类的移动构造函数为什么没有被调用?没错,正是因为右值引用具有左值属性,所以下图中的红色箭头调用并没有按照理想的方向传递,最终导致 String 移动构造函数并未被调用,因为 String 对象构造时参数类型为左值,所以调用了拷贝构造函数!

在这里插入图片描述

其实从第一步( push_back() --> insert() )就已经将右值引用 (注意属性是左值属性) 传递给 insert() 函数了,所以后续都调用了参数为 const T& val 常量左值引用的函数。

那么如何解决这个问题呢?使用 move() 移动语义,将右值引用属性转为右值

template<typename T>
class List
{
	void push_back(const T& val)	// 左值右值均可传入
	{
		insert(m_head, val);
	}

	void push_back(T&& val)		// 仅接受右值参数
	{
		insert(m_head, std::move(val));
	}

	iterator insert(iterator pos, const T& val)
	{
		Node* n = new Node(val);
		//...
	}
    iterator insert(iterator pos, T&& val)
	{
		Node* n = new Node(std::move(val));
		//...
	}
private:	// 成员变量
	Node* m_head;
};

template<typename T>
class List<T>::ListNode
{
public:
	ListNode* m_prev;
	ListNode* m_next;
	T m_val;
public:
	ListNode(const T& val = T()) : m_prev(nullptr), m_next(nullptr), m_val(val) {}
    // 右值引用做参数
	ListNode(T&& val) : m_prev(nullptr), m_next(nullptr), m_val(std::move(val)) {}
};

运行结果:

在这里插入图片描述

至此,我们通过右值引用的移动语义成功实现了移动构造函数的可调用性。

(注:完美转发等右值引用相关内容会在新文阐述)


总结

​ 通过本文,我们详细解析了左值、右值以及左值引用和右值引用的概念,并通过实例进行了深入的探讨。正确地理解和使用这些概念,可以帮助我们编写出更高效、更优雅的C++代码。同时,这些概念也是理解C++中一些高级特性(如移动语义和完美转发)的基础。

  • 18
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

螺蛳粉只吃炸蛋的走风

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值