c++值类别、右值引用和移动语义

值类别

值类别区分了一个表达式是一个能够持久存在的对象,还是一个临时的值。

值类别最基础可以分为左值(lvalue)、将亡值(xvalue)、纯右值(prvalue),左值和将亡值组成了泛左值(glvalue),将亡值和纯右值又组成了右值(rvalue)。

bash 复制代码
         expression (表达式)
              /    \
         glvalue    rvalue
        (泛左值)     (右值)
         /    \      /  \
     lvalue    xvalue    prvalue
    (左值)     (将亡值)   (纯右值)
  • lvalue:有身份,不可移动(传统左值,如变量名、函数名)。

  • xvalue:有身份,但可移动(即将被销毁的对象的资源可以被窃取)。

  • prvalue:无身份,纯值(临时对象、字面量、运算结果)。

  • glvalue (generalized lvalue) = lvalue + xvalue(有身份)

  • rvalue = xvalue + prvalue(可移动)

左值和右值的区分

先从简单的左值 (lvalue) 和右值 (rvalue) 来理解值类别这一概念。左值表示一个持久存在的对象,其保存在内存中,可以通过地址来访问;右值则是一个临时的值,没有持久存在的内存地址。

左值一般是占据内存中一个可识别区域的对象,其在表达式结束后依然存在,且可以通过取地址运算符 "&" 寻址。

右值一般是字面量和函数返回的非引用对象,仅仅在创建它的表达式中可以访问,表达式结束后会消失,寻址运算符 "&" 也不能直接对右值使用。

可以使用赋值表达式区分左值和右值。

一般情况:

cpp 复制代码
int a(5);
a = 7;

对于 a = 7 这样一个赋值表达式,左侧为a,右侧为7。

cpp 复制代码
int b(0);
b = a;

对于 b = a 这个赋值表达式,左侧为b,右侧为a。

在赋值表达式中,右侧只需要它的值,而左侧需要确定这个对象存在的地址。

左值右值的分类可以借助赋值语句来判断。左值可以放在赋值表达式左侧,也可以放在右侧;右值则只能放在赋值表达式右侧,没有办法放在左侧。

其他情况:

普通的字面量只能做右值。不过字符串字面量比如 "ABC" ,其确实存在于内存只读区域中,在程序运行结束前都会存在,并且可以对这个对象使用取地址运算符寻址。字符串字面量可以看作是一个没有名字的全局常量数组变量,算是左值。

函数的返回值是否是左值?对于下面的函数,显然不可以放在赋值语句的左侧,所以一般的函数返回值是右值。

cpp 复制代码
int f() {
    return 1;
}

//main
f() = 5;    //×

而函数返回值如果是返回引用,则可以把函数的返回值作为左值。

cpp 复制代码
int g_value = 0;
int &f2 () {
    return g_value;
}

//main
    f2() = 7;    //√

const修饰的变量,虽然是只读变量,无法在赋值语句 "=" 左侧,但是在内存中有持续存在的地址,可以在程序中多次调用,可以寻址。所以仍分类为左值。

将亡值xvalue

将亡值这个概念是实现现代c++移动语义的基础。将亡值表示一个身份即将过期,可以被移动的对象。

对于一个自定义的String对象,如果想要在另一个函数中普通的引用,则会发生复制操作。如果对象非常大或者在这个函数之后对象不再使用,则会浪费宝贵的内存空间。

cpp 复制代码
class String {
public:
	// 构造函数
	String() : data(nullptr), capacity(0) {}

	String(const char* str) : data(nullptr), capacity(0) {
		capacity = std::strlen(str) + 1; // 包含字符串结尾的'\0'字符
		data = new char[capacity];
		std::copy(str, str + capacity, data);
	}
	// 复制构造函数
	String(const String& str) : data(nullptr), capacity(0) {
		data = new char[str.capacity];
		std::copy(str.data, str.data + str.capacity, data);
		capacity = str.capacity;
	}

	~String() {
		delete[] data;
	}

	void print() const {
		std::cout << data << std::endl;
	}
private:
	char* data;
	size_t capacity;
};
cpp 复制代码
void func(String s) {
	s.print();
}

int main() {
	String s1; // 默认构造函数
	func(s1);  // func(s1)在接收时调用了复制构造函数,s1被复制到s中
}

如果在函数中使用参数的const引用,则无法修改参数的值。如果使用普通的引用为参数,则无法直接让右值作为实参传递。

cpp 复制代码
void func2(const String& s) {
	s.print();
}

void func3(String& s) {
	s.print();
}

int main() {
	String s1 = "String literal"; // 默认构造函数
	func2(s1); // 函数无法修改s1,因为传递了const引用
	func3(s1); // √ 可以修改s1,也没有发生复制
	func3("String literal");	// × func3参数为引用,无法接收字符串字面量
}

c++引入移动语义,可以把对象s1的内容交给另外一个变量,并且不发生复制,且移动后这个变量中的内容可以更改。在这种移动操作中,被std::move()移动的对象s1就成了将亡值xvalue。

cpp 复制代码
	auto s2 = std::move(s1); 
	func(std::move(s1)); 

std::move怎样实现移动语义?这就需要先了解右值引用的概念。

右值引用和移动语义

普通的引用符号为&,只能绑定左值,这是因为引用实际是取变量的地址,只有左值才可以被取地址,而右值没有地址。右值引用符号为 &&,是对右值的引用,可以指向右值但不能指向左值。

cpp 复制代码
int i = 0;    // i是一个左值
int &r = i;   // √ 左值引用
int &n = 1;   // × 左值引用不能绑定右值
int &&k = i;  // × 右值引用只能绑定右值

const类型的引用(const T& )

特别的情况是,const类型的左值引用可以指向右值,因为const类型的引用保证不会修改引用的对象。而在这种情况下,原本临时的右值对象生命周期会被自动延长到const类型对象离开作用域自动销毁。

cpp 复制代码
const int &x = 2;

在没有引入右值引用前,const类型的引用是实现高效参数传递的唯一途径,虽然这样无法修改参数的值,但是这种方法可以在不拷贝的前提下进行参数传递,还能够同时接收左值和右值。

右值引用

右值引用是针对右值 (rvalue) 的引用,主要是为了实现移动语义,充分利用右值的资源,避免消耗资源的深拷贝。右值引用可以绑定右值而不能绑定左值。

cpp 复制代码
int a = 10;
int&& r1 = 10; // 正确:10 是右值
int&& r2 = a;  // 错误!a 是左值,编译失败

如果一个对象被右值引用绑定,代表程序可以安全的将它的内部指针赋值给新的对象,并将原对象的指针置空。

右值引用的变量本身是一个左值,因为这个引用变量有具名的标识符,可以被取地址,且变量会在作用域结束后销毁。右值引用变量要绑定右值。这里的例子是绑定了纯右值(prvalue)。

cpp 复制代码
int&& r = 10; 
// r 本身是一个左值(因为它有名字 r),它的类型是 int&&

std::move

移动语义是把一个对象底层的内容、内存的所有权转交给了一个新的对象,并且不发生任何复制操作。std::move是实现移动语义的基础,但是只有std::move并不能完成移动语义。

使用std::move的左值需要用右值引用类型来接收。

cpp 复制代码
void func(String&& s) {
	s.print();
}
int main() {
	String s1 = "String literal";
	String&& s2 = std::move(s1);	
    // 右值引用接收s1,s1被视为右值(将亡值),s2成为s1的右值引用
	func(std::move(s1));			
    // func参数为右值引用,std::move(s1)将s1转换为右值,func接收s1的资源并打印内容
}

这是因为std::move本质上只是将左值强制转换成了右值(其实是右值中的将亡值),所以右值引用类型才可以接收std::move的左值类型。std::move是为了实现移动语义,但是并不会发生真正的移动,移动语义的实现需要更多的操作。

另外,右值引用类型是否为有名字的变量也会影响其是右值还是左值。std::move 的返回值为右值引用类型,而这个返回值没有具名的标识符,也就是没有名称,无法寻址,所以是一个右值。而这个右值需要被右值引用变量绑定,前面提到右值引用变量有名称,可以寻址,是一个左值。

cpp 复制代码
	func(static_cast<String&&>(s1));	
	// std::move(s1)本质和static_cast<String&&>(s1)一样,都是将s1转换为右值

对于内建类型的变量,std::move将变量转换并赋值给新的右值引用变量后,并不会真的移动什么,也不会自动将原变量置空。

cpp 复制代码
	int a = 5;
	int&& b = std::move(a);	
// 右值引用b接收a,a被视为右值(将亡值),b成为a的右值引用
	std::cout << "a: " << a << std::endl; 
// a已经被移动,打印结果为5,因为int是内置类型,移动后a的值仍然是5
	std::cout << "b: " << b << std::endl; 
// b接收了a的值,打印结果为5

移动语义

使用将亡值创建一个新的右值引用的自定义对象,需要通过移动构造函数实现。

而使用std::move后的对象并不会自动置空,如果只是把原来对象的内容用新的右值引用对象接收,则只实现了浅拷贝。

如果想要用移动构造函数完成移动语义,则需要通过在构造函数中将指针置空或者通过其他操作来实现。

cpp 复制代码
// 移动构造函数
String::String(String&& str) : data(str.data), capacity(str.capacity) {
    //将源对象的内容置空,实现资源的转移,避免源对象析构时释放资源。这样才实现了完整的移动语义
	str.data = nullptr;
	str.capacity = 0;
}
String::print() const {
	if (data)
		std::cout << data << std::endl;
	else
		std::cout << "(empty)" << std::endl;
}
//main
	String s2 = std::move(s1);
	// 是移动构造函数的语法糖,等价于String s2(std::move(s1)),s1变为将亡值,s2接收s1的资源
	s1.print();
	// s1已经被移动,数据被s2接收,s1的data指针被置为nullptr,capacity被置为0,打印结果为空

本文为学习笔记,尽量将值类别、右值引用和移动语义记录全了,可能会有些重复或纰漏。

相关推荐
zhangjw341 小时前
第11篇:Java Map集合详解,HashMap底层原理、哈希冲突、JDK1.8优化、遍历方式彻底吃透
java·开发语言·哈希算法
jrrz08282 小时前
Apollo MPC Controller
c++·自动驾驶·apollo·mpc·横向控制·lateral control
benpaodeDD2 小时前
视频10,11,12,13——java程序的加载与执行,安装jdk
java·开发语言
一颗牙牙3 小时前
安装mmcv
开发语言·python·深度学习
大空大地20263 小时前
C#高级语法总结
开发语言·c#
ytttr8733 小时前
DSP 28335 CAN总线通信程序
开发语言·stm32·单片机
XiYang-DING3 小时前
【Java SE】JVM
java·开发语言·jvm
小短腿的代码世界3 小时前
Qt进程间通信全体系深度解析:从QSharedMemory到本地Socket的七层武器
开发语言·qt
小陶来咯3 小时前
小智接入懒人说书MCP
java·开发语言