C++11 ---- 右值引用、值类型

一、右值引用

1.1 宏观理解右值引用

在C++98中,就有引用的语法,而C++11中新增了右值引用的语法特性,对于我们在C++98中学习的引用叫做左值引用。但是无论是左值引用还是右值引用,都是给对象取别名

1.2 左值和右值

在学习右值引用之前,我们必须要先理解左值和右值是什么?如果对它们都不理解,就无法学好右值引用,更无法合理地使用左值引用和右值引用。

1.2.1 什么是左值

左值是一个表示数据的表达式,一般是持久状态,存储在内存中,我们可以获取它的地址,左值可以出现在赋值符号的左边,也可以出现在赋值符号的右边。定义左值时,如果采用来const修饰左值,那么它的值只有在定义时初始化,不能再去修改,但是可以取它的地址。

cpp 复制代码
// 以下的p、b、c、*p、s、s[0]就是常见的左值​
int* p = new int(0);
int b = 1;
// 左值出现在右边的情况
const int c = b; // b 是左值
*p = 10;
string s("111111");
s[0] = 'x';
cout << &c << endl;
// s[0] 返回的是字符引用,对其取地址得到的地址类型
// 是char*,会被 cout 当作字符串输出,所以要强转为void*
cout << (void*)&s[0] << endl;

1.2.2 什么是右值

右值也是一个表示数据的表达式,不持久化状态,在内存中存储时间很短或者未在内存中,直接临时存储在CPU的寄存器中,不可以获取它的地址。右值可以出现在赋值符号的右边,也可以出现赋值符号的左边(情况非常少)。常见的右值:字面值常量,表达式求值过程中创建的临时对象或中间运算值,匿名对象等。

cpp 复制代码
double x = 1.1, y = 2.2;
// 以下几个10、x + y、fmin(x, y)、string("11111")都是常见的右值​
10; // 字面值常量
x + y; // 中间运算值 x + y 的结果会被放到寄存器中
string s("1234");
s + "1"; // 临时对象 先在栈上开辟一个临时对象的空间,s + "1" 的结果会被放到栈上的临时对象中
string("11111"); // 匿名对象

// 编译出错
//cout << &10 << endl;
//cout << &(x+y) << endl;
//cout << &(s + "1") << endl;
//cout << &string("11111") << endl;

补充:对于表达式求值产生的对象,如果对象占用的空间 ,会直接存储在寄存器 中,如果对象占用的空间 ,会先在栈上开辟一个临时对象的空间,拷贝构造临时对象。

特例:右值出现在赋值符号左边的情况

cpp 复制代码
string("temp") = "new value"; 
vector<int>({1,2,3})[0] = 99;

对于内置类型,赋值运算符的左操作数必须是左值,对于自定义类型,可以借助赋值运算符重载使得右值出现在赋值符号左边,以上特例只是告诉大家,不要根据赋值符号来判断一个值是右值还是左值。

1.2.3 左值和右值的本质区别

左值的英文简写为lvalue,右值的英文简写rvalue。传统认为它们分别是left value、right value的缩写。但现代C++中,lvalue 被解释为locate value,可意味着存储在内存中、有明确存储地址可以取地址的对象,rvalue被解释为read value,可意味着只提供读取数据值,不可以寻址的对象。所以左值和右值的本质区别就是一个值是否能被取地址

1.3 左值引用和右值引用

左值引用语法:Type& r1 = x;右值引用语法:Type&& rr1 = y;

1.3.1 什么是左值引用

左值引用就是给左值取别名,不能直接给右值取别名,需要用const左值引用才能给右值取别名。

cpp 复制代码
	int a = 1;
	// 左值引用
	int& ra = a;
	// const左值引用
	const int rb = a;
	const int rc = 5;

1.3.2 什么是右值引用

右值引用就是给右值取别名,不能直接给左值取别名,需要move(左值)才能给左值取别名。

补充:move()是库函数里面的一个函数模板,本质是进行强制类型转换,还会涉及一些引用折叠的知识,这些知识后面讲。

cpp 复制代码
	// 右值引用
	int&& rra = 5;
	string&& s = string();
	int b = 1;
	int&& rrb = move(b);
    // const右值引用
    const int&& rrc = 10;

先抛出一个结论:**右值引用变量的属性是左值属性,持久化存储在内存中,可以被取地址,引用内容也可以被修改,**这一点看似很怪,但这就是右值引用的价值,后面的例子会一一体现。

由于右值引用变量本身的属性是左值,对右值引用变量进行引用,只能是左值引用或者采用move进行右值引用。

cpp 复制代码
	int&& rra = 5;
	int& rb = rra;
	// 编译出错
	// int&& rrc = rra;
	int&& rrd = move(rra);

1.4 左值和右值的参数匹配

在C++98中,我们实现一个const左值引用作为参数的函数,那么实参传递左值和右值都可以匹配。

C++11以后,分别重载左值引用、const左值引用、右值引用作为形参的f函数,那么实参是左值会
匹配f(左值引用),实参是const左值会匹配f(const 左值引用),实参是右值会匹配f(右值引用)

cpp 复制代码
#include <iostream>
using namespace std;
void f(int &x)
{
	std::cout << "左值引用重载 f(" << x << ")\n";
}
void f(const int &x)
{
	std::cout << "到 const 的左值引用重载 f(" << x << ")\n";
}
void f(int &&x)
{
	std::cout << "右值引用重载 f(" << x << ")\n";
}
int main()
{
	int i = 1;
	const int ci = 2;
	f(i);			 // 调用 f(int&)​
	f(ci);			 // 调用 f(const int&)​
	f(3);			 // 调用 f(int&&),如果没有 f(int&&) 重载则会调用 f(const int&)​
	f(std::move(i)); // 调用 f(int&&)​
	// 右值引用变量的属性是左值
	int &&x = 1;
	f(x);			 // 调用 f(int& x)​
	f(std::move(x)); // 调用 f(int&& x)​
	return 0;
}

​1.5 引用延长生命周期

右值引用可用于为临时对象延长生命周期,const左值引用也能延长临时对象生存周期。const左值引用引用临时的对象不可以修改,右值引用引用的临时对象可以修改,const右值引用也不能修改。引用之后,临时对象的生命周期随引用变量的生命周期

cpp 复制代码
std::string s1 = "Test";

// s1 + s1 产生的临时对象生命周期延长到 const 的左值引用的生命存期​
const std::string& r2 = s1 + s1; 
// 编译错误,不能通过 const 的左值引用修改
// r2 += "Test"; ​
// 右值引用延长临时对象生命周期​
std::string&& r3 = s1 + s1; 
// 能通过非 const 的右值引用修改​
r3 += "Test"; 
const std::string&& r4 = s1 + s1;
// 编译错误,不能通过 const 的右值引用修改
// r4 += "Test";

1.6 右值引用和移动语义结合使用的场景

1.6.1 左值引用主要使用场景回顾

左值引用主要使用场景是在函数中左值引用传参左值引用传返回值减少拷贝 ,同时还可以修改实参和修改返回对象的价值。虽然左值引用已经解决大多数场景的拷贝效率问题,但是有些场景不能使用传左值引用返回。

cpp 复制代码
string addStrings(string num1, string num2) 
{
    string ret;
    int next = 0;
    int end1 = num1.size()-1;
    int end2 = num2.size()-1;
    while(end1 >= 0 || end2 >= 0)
    {
        int val1 = end1 >= 0 ? num1[end1] - '0' : 0;
        int val2 = end2 >= 0 ? num2[end2] - '0' : 0;
        --end1;
        --end2;
        int sum = val1 + val2 +next;
        next = sum / 10;
        ret.push_back(sum%10 + '0');
    }
    if(next == 1)
        ret.push_back('1');
    reverse(ret.begin(),ret.end());
    return ret;
}

对于形似addStrings函数,就无法通过左值引用返回,因为 ret 定义到了addStrings栈帧内部,函数调用结束后,ret 所在的函数栈帧销毁,ret 就析构销毁了,此时返回左值引用,就相当于返回了野指针,运行出现错误,那么可以通过右值引用解决吗?显然不可能,因为 ret 是左值,无法右值引用返回,那么返回 move(ret) 是不是就能解决问题了呢? 还是不能解决问题,因为 ret 所在的函数栈帧销毁,ret 就析构销毁了,所以对于当前函数中的局部变量就不能以引用返回。接下来就让我们看一下基于右值引用的使用方法 ---- 移动语义。

值得一提:C++98中对上述问题的解决方案,通过输出型参数来解决。

cpp 复制代码
void addStrings(string num1, string num2, string& ret) 
{
    string ret;
    int next = 0;
    int end1 = num1.size()-1;
    int end2 = num2.size()-1;
    while(end1 >= 0 || end2 >= 0)
    {
        int val1 = end1 >= 0 ? num1[end1] - '0' : 0;
        int val2 = end2 >= 0 ? num2[end2] - '0' : 0;
        --end1;
        --end2;
        int sum = val1 + val2 +next;
        next = sum / 10;
        ret.push_back(sum%10 + '0');
    }
    if(next == 1)
        ret.push_back('1');
    reverse(ret.begin(),ret.end());
}

1.6.2 移动语义

移动语义是指把一个对象的资源直接转移给另一个对象。主要用于转移临时对象中深拷贝的资源。

本质是:浅拷贝 + 源对象置空。它采用浅拷贝的方式直接复制指针本身,然后将源对象的指针置空,这样既避免了深拷贝带来的性能消耗,又解决了重复释放的问题。

核心实现:移动构造函数和移动赋值运算符重载

1.6.3 移动构造函数和移动赋值运算符重载

作用:用一个即将销毁的临时对象来初始化或赋值新对象,接管其资源。

cpp 复制代码
class MyString 
{
public:
    // 移动构造函数
    MyString(MyString&& other)
        : _str(other._str), _size(other._size), _capacity(other._capacity)
    {
        // 关键:把源对象的资源置空,防止析构时重复释放
        other._str = nullptr;
        other._size = 0;
        other._capacity = 0;
    }
    MyString& operator=(MyString&& other) 
    {
        // 防止自赋值
        if (this != &other) 
        { 
            // 先释放当前对象的旧资源
            delete[] _str;

            // 接管源对象的资源
            _str = other._str;
            _size = other._size;
            _capacity = other._capacity;

            // 源对象置空
            other._str = nullptr;
            other._size = 0;
            other._capacity = 0;
        }
        return *this;
    }

private:
    char* _str;
    size_t _size;
    size_t _capacity;
};

注意:从上面的实现方式可以看出,只有对于string/vector这样深拷贝的类 或者包含深拷贝的成员变量的类移动构造和移动赋值才有意义,因为移动构造和移动赋值的第一个参数都是右值引用的自身类型,它的本质是要"窃取"引用的右值对象的资源,从而提高效率,而不是像拷贝构造和拷贝赋值那样去深拷贝资源。

1.6.4 右值引用和移动语义解决传值返回问题

cpp 复制代码
string addStrings(string& num1, string& num2) 
{
    string ret;
    int next = 0;
    int end1 = num1.size()-1;
    int end2 = num2.size()-1;
    while(end1 >= 0 || end2 >= 0)
    {
        int val1 = end1 >= 0 ? num1[end1] - '0' : 0;
        int val2 = end2 >= 0 ? num2[end2] - '0' : 0;
        --end1;
        --end2;
        int sum = val1 + val2 +next;
        next = sum / 10;
        ret.push_back(sum%10 + '0');
    }
    if(next == 1)
        ret.push_back('1');
    reverse(ret.begin(),ret.end());
    return ret;
}
场景一:右值对象构造,只有拷贝构造,没有移动构造
cpp 复制代码
string s1("1234");
string s2("8954");

string s3 = addStrings(s1, s2);

语法层面上:先在函数内部构造 ret ,ret 返回时,ret 先拷贝构造临时对象,临时对象再拷贝构造 s3。

构造 + 拷贝构造 + 拷贝构造

场景二:右值对象构造,有拷贝构造,也有移动构造
cpp 复制代码
string s1("1234");
string s2("8954");

string s3 = addStrings(s1, s2);

语法层面上:先在函数内部构造 ret,返回ret时,ret 先移动构造临时对象,临时对象再移动构造s3。

构造 + 移动构造 + 移动构造

对于场景一和场景二,现代编译器的优化:在main栈帧里,为s3分配好栈空间(即构造s3对象),然后编译器会把s3的地址作为一个隐藏的参数,传给addStrings函数,在addStrings函数内部,不是在自己的栈帧里构造ret,而是直接用s3的地址,在s3的地址上构造对象。函数结束时,只是销毁自己的栈帧,不需要任何拷贝。---- 构造
经过编译器的优化后,拷贝构造与移动构造的差距被抹平,两者都可以做到零拷贝。但这种优化不是C++标准强制的,也不是所有编译器都能保证实现。因此,依赖编译器优化消除拷贝风险是不可靠的,而移动语义是C++标准层面的特性,所有C++编译器都必须支持,它能为代码效率的提供可移植性的保障。

场景三:右值对象赋值,只有拷贝构造和拷贝赋值,没有移动构造和移动赋值
cpp 复制代码
string s1("1234");
string s2("8954");

string s3;
s3 = addStrings(s1, s2);

语法层面上:先在函数内部构造 ret,返回ret时,ret 先拷贝构造临时对象,临时对象再拷贝赋值s3。

构造 + 拷贝构造 + 拷贝赋值

场景四:右值对象赋值,既有拷贝构造和拷贝赋值,也有移动构造和移动赋值

语法层面上:先在函数内部构造 ret,返回ret时,ret 先移动构造临时对象,临时对象再移动赋值s3。

构造 + 移动构造 + 移动赋值

对于场景三和场景四,现代编译器的优化:在main栈帧里,为临时对象分配好栈空间(即构造临时对象),然后编译器会把临时对象的地址(下面"补充"中会解释)作为一个隐藏的参数,传给addStrings函数,在addStrings函数内部,不是在自己的栈帧里构造ret,而是直接用临时对象的地址,在临时对象的地址上构造对象。函数结束时,只需要将临时对象拷贝赋值/移动赋值给s3。---- 构造 + 拷贝赋值/移动赋值

补充:除了在寄存器上的右值,其余右值在进程的虚拟地址空间上均是有地址的,也有物理地址,只是语法层面不允许获取它们的地址,但不代表它们没有地址。

经过编译器的优化后,移动赋值和拷贝赋值的差距没有被抹平,所以移动赋值的价值是远大于拷贝复制的

值得提一句:编译器对于语言性能的优化只能是锦上添花,而不是雪中送碳

1.6 左值引用和右值引用的底层理解

1.6.1 左值引用的底层理解

cpp 复制代码
	int a = 1;
00007FF625231E8E  mov         dword ptr [a],1  
	int& ra = a;
00007FF625231E95  lea         rax,[a]  
00007FF625231E99  mov         qword ptr [ra],rax

语法层面上,左值引用不会开辟空间,左值引用的地址就是引用对象的地址,在底层角度上,左值引用其实就是一个指向引用对象的指针。

1.6.2 右值引用的底层理解

cpp 复制代码
	int&& b = 1;
00007FF625231E9D  mov         dword ptr [rbp+64h],1  
00007FF625231EA4  lea         rax,[rbp+64h]  
00007FF625231EA8  mov         qword ptr [b],rax  

语法层面上,右值引用不会开辟空间,右值引用的地址就是引用对象的地址,在底层的角度上,右值引用其实也就是一个指向引用对象的指针。

对于右值引用的底层理解,问题接踵而至。

1. 右值引用的引用对象是右值,由于右值不能取地址,那右值有地址吗,为什么右值不能取地址?
  • 纯寄存器优化的右值(如 13.14):这类基础类型的字面量,在编译后可能直接被优化到 CPU 寄存器中,不占用内存空间,因此没有虚拟地址和物理地址。

  • 栈上临时对象(如 string("abc")、匿名类对象):这类右值会被分配在 ** 栈段(stack)** 上,因此拥有合法的虚拟地址和物理地址。

  • 只读段中的常量(如 "hello" 字符串字面量):这类右值存储在 .rodata 只读数据段中,也有固定的地址,但用户层无法通过 & 直接获取。

  • 寄存器优化的右值,本身就没有内存地址对这类值(如 &1)取地址毫无意义,直接被语法禁止。

  • 只读段中的常量,地址无法被用户层安全访问整型、浮点、字符串字面量等存储在 .rodata 段,是只读的,用户代码不能修改,也无法通过 & 获取其地址。语言层面直接禁止了这种操作,避免用户通过指针修改只读内存。

  • 栈上临时对象的地址不稳定,存在悬空风险类类型的临时对象,生命周期只存在于当前表达式结束前,之后就会被析构。如果允许你直接拿到它的地址,很容易写出 T* p = &T(); 这种危险代码,导致访问已销毁的对象。因此语言干脆禁止了这种操作。

2. 右值引用的指令明明比左值指令多了一行,那一行的作用是干什么的呢?

为右值创建一个栈上的临时变量,让它变成一个有地址的实体。

3. 为什么必须要多这一步?

语法上,引用必须绑定一个有地址的对象。

左值本身就有地址,直接绑定即可。

有些右值没有地址或者右值的地址在只读区域上,且右值引用的属性是左值,所以编译器必须额外在栈上创建临时变量,使它绑定一个有地址的对象。对于匿名对象和临时对象这样有栈上地址的右值,编译器不需要再额外创建临时变量,延长引用对象的生命周期,直接绑定即可。

二、值类型

2.1 类型分类

标准版本 分类方式
C++98 左值(lvalue)、右值(rvalue)
C++11+ 引入「值分类」体系,右值进一步分为:纯右值(prvalue)、亡值(xvalue),并新增「泛左值(glvalue)」概念

2.2 什么是纯右值

纯右值是指那些字面值常量或求值结果相当于字面值或是一个不具名的临时对象。如:42、true、nullptr 或者类似str.substr()、str1 + str2 传值返回函数调用,或者整形a、b,a++,a+b等。C++11中的纯右值概念划分等价于C++98中的右值。

2.3 什么是亡值

亡值是指返回右值引用的函数的调用表达式和转换为右值引用的转换函数的调用表达。如

move(x)、static_cast<X&&>(x)。

2.4 什么是泛左值

泛左值(generalized value,简称glvalue),泛左值包含亡值和左值。

2.5 值分类的意义

C++11 的值分类的意义是为了区分 "有地址 / 无地址""可移动 / 不可移动" 的值,为移动语义和完美转发提供类型基础。

参考文献:

引用声明 - cppreference.comhttps://zh.cppreference.com/cpp/language/reference

值类别 - cppreference.comhttps://zh.cppreference.com/w/cpp/language/value_category

相关推荐
少司府1 小时前
C++进阶:多态
c语言·开发语言·c++·多态·抽象类·虚函数·虚表指针
并不喜欢吃鱼1 小时前
从零开始 C++----- 十三【C++ 数据结构】哈希表从原理到手撕实现(开放定址 + 链地址全覆盖)
数据结构·c++·散列表
:1211 小时前
Java泛型
java·开发语言
愿天垂怜1 小时前
【C++脚手架】etcd 的介绍与使用
java·linux·服务器·c语言·c++·中间件·etcd
喵了几个咪1 小时前
Headless 后端实践:基于Go的企业级多栈管理系统脚手架
开发语言·vue.js·后端·golang·reactjs·gowind
小则又沐风a1 小时前
进程篇: 进程概念的补充(了解环境变量和虚拟地址空间)
linux·运维·服务器·c++
枫叶丹41 小时前
【HarmonyOS 6.0】Map Kit瓦片图层深度解析:本地加载方式与瓦片数据缓存能力
开发语言·缓存·华为·harmonyos
郝学胜-神的一滴1 小时前
[简化版 GAMES 101] 计算机图形学 11:频域·卷积·抗锯齿
c++·unity·图形渲染·opengl·three·unreal
小小龙学IT1 小时前
Go 并发模式深度解析:Fan-out/Fan-in 高效处理大规模数据流
开发语言·后端·golang