【C++】左值引用、右值引用
目录
一、右值引用的意义
在 C++ 的编程世界中,对象的拷贝与资源管理一直是性能优化的核心痛点。在 C++11 标准之前,我们编写的代码中隐藏着大量临时对象的深拷贝开销:比如函数返回一个非引用类型的对象、容器插入临时对象时,都会触发拷贝构造函数,完成堆内存的重新分配与数据复制,而临时对象在表达式结束后就会被销毁,这就造成了完全无意义的内存操作与性能浪费。
举个最简单的例子:std::string str = "hello" + " world";,表达式会生成一个临时的 string 对象,再通过拷贝构造把数据复制给 str,随后临时对象被销毁。如果字符串很长,这个深拷贝的开销是完全可以避免的。
而 C++11 引入的右值引用,正是为了解决这个问题。它不仅重新定义了 C++ 的资源管理哲学,让我们可以对即将销毁的临时对象的资源进行转移而非拷贝,还支撑了泛型编程中的完美转发,成为现代 C++ 不可或缺的核心特性。
二、基础:理解左值与右值
1. 左值(Lvalue,Locator Value)
左值的核心定义是:可以通过&取地址运算符获取其内存地址、拥有持久生命周期的表达式。
简单来说,左值就是一个 “有名字、有固定内存地址” 的对象,你可以找到它的存储位置,除了 const 修饰的左值外,都可以修改其内容。左值既可以放在赋值号的左边,也可以放在赋值号的右边(作为右值使用)。
常见的左值场景:
普通变量、const 修饰的变量(const 左值不可修改,但依然可以取地址,属于左值)对指针解引用的结果*ptr数组的元素arr[index]结构体 / 类的非静态成员变量返回左值引用的函数调用前缀自增 / 自减表达式++i、--i
int main() { int i = 0; i = 1; // i是变量,可修改左值,能放在赋值号左侧 const int ci = 5; // ci = 6; // 报错:const左值不可修改,但&ci可获取地址,仍是左值 int* pi = &i; *pi = 1; // *pi对指针解引用,左值 int arr[5] = {0}; arr[0] = 1; // 数组元素,左值 struct { int m_a; } st, *pst = &st; st.m_a = 1; // 结构体成员,左值 pst->m_a = 2; return 0; } 2. 右值(Rvalue,Read Value)
C++11 之后,右值被细分为纯右值(prvalue,Pure Rvalue) 和将亡值(xvalue,eXpiring Value) 两类,二者共同构成了右值。
右值的核心定义是:无法通过&取地址、生命周期短暂的临时表达式结果,它代表的是一个值,而非值所在的存储位置。右值只能放在赋值号的右侧,不能放在左侧。
2.1 纯右值(prvalue)
纯右值是 C++98 标准中传统意义上的右值,核心是 “字面量、临时计算结果”,没有持久的存储地址。
常见的纯右值场景:
除字符串字面量外的字面常量,如42、'a'、true(字符串字面量是 const char 数组类型,属于左值)算术、逻辑、关系运算符的求值结果,如1+2、a>10、a&&b非引用类型返回值的函数调用结果后置自增 / 自减表达式i++、i--lambda 表达式
2.2 将亡值(xvalue)
将亡值是 C++11 为了配合右值引用新增的概念,核心是 “资源即将被转移、生命周期即将结束的对象”,它是连接左值和右值的桥梁。
将亡值虽然可以通过特殊方式获取地址,但它的核心使命是完成资源转移,使用后就会被销毁,因此归属于右值范畴。
常见的将亡值场景:
std::move()的返回结果返回右值引用类型的函数调用结果类型转换为右值引用的表达式结果int getNum() { return 100; } int main() { int i = 0, c = 0; i = 42; // 字面值42,纯右值 c = 'a'; // 字面值'a',纯右值 i = 1 + 2; // 算术表达式结果,纯右值 i = (c != 'a'); // 逻辑表达式结果,纯右值 i = getNum(); // 非引用返回值,纯右值 int&& rri = std::move(i);// std::move(i)的结果,将亡值,右值 return 0; } 3. 左值与右值的核心区别
| 核心特性 | 左值 | 右值(纯右值 + 将亡值) |
|---|---|---|
| 内存地址 | 可通过&获取稳定的内存地址 | 无法直接通过&获取地址 |
| 生命周期 | 持久,随变量 / 对象的作用域存在 | 临时,表达式结束后即销毁(除非被引用绑定) |
| 可修改性 | 非 const 左值可修改,const 左值不可修改 | 不可直接修改 |
| 赋值位置 | 可放在赋值号左侧、右侧 | 只能放在赋值号右侧 |
| 引用绑定 | 可被左值引用、const 左值引用绑定 | 只能被 const 左值引用、右值引用绑定 |
三、右值引用的定义与语法规则
1. 左值引用的回顾与局限
在 C++11 之前,我们使用的引用都是左值引用,用&符号声明,本质是给变量起一个 “别名”,底层通过指针实现,必须在定义时初始化,且初始化后无法重新绑定到其他对象。
左值引用的核心局限:非 const 左值引用只能绑定到左值,无法绑定到右值。只有 const 左值引用是个例外,它可以同时绑定左值和右值,但 const 修饰决定了我们无法通过它修改绑定的对象。
int main() { int a = 10; int& ra = a; // 正确:非const左值引用绑定左值 // int& rb = 10; // 报错:非const左值引用无法绑定右值 const int& rca = a; // 正确:const左值引用绑定左值 const int& rcb = 10; // 正确:const左值引用绑定右值 // rcb = 20; // 报错:const引用无法修改绑定对象 return 0; } 这个局限带来了两个核心问题:
无法对临时的右值对象进行修改和资源复用,只能通过拷贝使用泛型编程中,无法区分传入的参数是左值还是右值,无法实现精准的参数转发
而右值引用的出现,完美解决了这两个问题。
2. 右值引用的语法与本质
右值引用是 C++11 新增的引用类型,用&&符号声明,核心是专门绑定到右值(临时对象、将亡值)的引用。基础语法:
类型&& 引用名 = 右值表达式; 右值引用的核心规则:
必须在定义时初始化,且初始化后无法重新绑定到其他对象只能直接绑定到右值,无法直接绑定到左值绑定到右值后,会延长该临时右值的生命周期,生命周期与右值引用变量一致可以通过右值引用修改绑定的右值对象右值引用变量本身是左值(有名字、可通过&取地址),这是初学者最容易踩的坑#include <iostream> using namespace std; int getNum() { return 100; } void func(int&& rri) { rri = 0; } int main() { // 1. 右值引用必须绑定右值 int&& ri0 = 42; // 正确:绑定字面量纯右值 int&& ri1 = 1 + 2; // 正确:绑定表达式结果纯右值 int&& ri2 = getNum(); // 正确:绑定函数返回值纯右值 // int&& ri3 = ri0; // 报错:ri0是右值引用变量,本身是左值,无法绑定 // 2. 可通过右值引用修改绑定的对象 ri0 = 100; cout << "ri0 = " << ri0 << endl; // 输出:ri0 = 100 // 3. 可获取右值引用的地址(证明它本身是左值) int* pi = &ri0; *pi = 200; cout << "*pi = " << *pi << ", ri0 = " << ri0 << endl; // 输出:*pi = 200, ri0 = 200 // 4. 右值引用作为函数参数,接收右值 func(1 + 2); return 0; } 3. std::move ()做了什么?
std::move()实现了资源的移动?这是错误的。
std::move()的本质:不做任何资源移动、不生成任何机器码,只是无条件地将一个左值强制转换为右值引用类型(将亡值),仅此而已。它的唯一作用,就是让左值可以被右值引用绑定,从而为后续的资源转移提供可能。真正的资源转移,是在类的移动构造函数、移动赋值运算符中完成的,std::move()只是打开了 “资源转移” 的入口。#include <iostream> #include <string> using namespace std; int main() { string str = "Hello World!"; cout << "move前:str = " << str << endl; // std::move将str左值转为右值,触发string的移动构造函数 string tmp = std::move(str); cout << "move后:str = " << str << endl; // 结果未定义,标准仅保证str可析构 cout << "move后:tmp = " << tmp << endl; return 0; } 注:
std::move()之后的原对象,其资源已经被转移,处于 “有效但未定义” 的状态,只能对其进行析构或重新赋值,绝对不能再访问其内部资源,否则会出现未定义行为。4. 左值引用 vs 右值引用
| 特性 | 左值引用(Type&) | 右值引用(Type&&) |
|---|---|---|
| 声明符号 | & | && |
| 初始化要求 | 必须初始化,绑定后不可重新绑定 | 必须初始化,绑定后不可重新绑定 |
| 直接绑定对象 | 非 const 左值引用:仅左值const 左值引用:左值 + 右值 | 仅右值(纯右值 + 将亡值),左值需通过 std::move 转换后绑定 |
| 绑定对象的修改性 | 非 const 左值引用:可修改const 左值引用:不可修改 | 可修改绑定的右值对象(const 右值引用除外) |
| 本身属性 | 左值 | 有名字的右值引用变量是左值,匿名右值引用是右值 |
| 核心应用场景 | 函数参数传递、避免拷贝、返回左值对象 | 移动语义、完美转发、资源所有权转移 |
| 引用折叠 | 与任何引用折叠均为左值引用 | 仅与右值引用折叠为右值引用,其余均为左值引用 |
| 能否为 nullptr | 不能,必须绑定合法对象 | 不能,必须绑定合法对象 |
四、右值引用的两大核心应用场景
1. 移动语义:告别无意义的深拷贝,实现资源高效转移
移动语义是右值引用最核心的价值,它的核心思想是:对于即将销毁的对象,不做资源的深拷贝,而是直接将其资源的所有权 “转移” 到新对象中,避免了内存分配、数据复制和内存释放的巨大开销。
移动语义的实现,依赖于移动构造函数和移动赋值运算符,二者的参数都是右值引用类型。
1.1 无移动语义的痛点:深拷贝的性能浪费
先实现一个简单的字符串类,看看没有移动语义时,临时对象带来的性能开销:
#include <iostream> #include <cstring> using namespace std; class MyString { public: char* data; size_t len; // 普通构造函数 MyString(const char* str) { len = strlen(str); data = new char[len + 1]; strcpy(data, str); cout << "构造函数:分配内存,地址=" << (void*)data << ",内容=" << data << endl; } // 拷贝构造函数(深拷贝) MyString(const MyString& other) { len = other.len; data = new char[len + 1]; strcpy(data, other.data); cout << "拷贝构造函数:深拷贝内存,新地址=" << (void*)data << ",内容=" << data << endl; } // 拷贝赋值运算符(深拷贝) MyString& operator=(const MyString& other) { if (this == &other) { return *this; } // 释放自身原有资源 delete[] data; // 深拷贝源对象资源 len = other.len; data = new char[len + 1]; strcpy(data, other.data); cout << "拷贝赋值运算符:深拷贝内存,新地址=" << (void*)data << ",内容=" << data << endl; return *this; } // 析构函数 ~MyString() { if (data) { cout << "析构函数:释放内存,地址=" << (void*)data << ",内容=" << data << endl; delete[] data; } else { cout << "析构函数:资源已转移,无需释放" << endl; } } }; MyString createString() { MyString str("Hello C++"); return str; // 返回临时对象,触发拷贝构造 } int main() { MyString str1("Hello World"); MyString str2 = str1; // 触发拷贝构造 MyString str3 = createString(); // 临时对象触发拷贝构造,随后销毁 return 0; } 此处,每一次对象拷贝都会触发深拷贝,重新分配内存、复制数据,临时对象创建后很快被销毁,造成了巨大的性能浪费。
1.2 基于右值引用实现移动语义
我们给 MyString 类添加移动构造函数和移动赋值运算符,通过右值引用实现资源转移:
#include <iostream> #include <cstring> using namespace std; class MyString { public: char* data; size_t len; // 普通构造函数 MyString(const char* str) { len = strlen(str); data = new char[len + 1]; strcpy(data, str); cout << "构造函数:分配内存,地址=" << (void*)data << ",内容=" << data << endl; } // 拷贝构造函数(深拷贝) MyString(const MyString& other) { len = other.len; data = new char[len + 1]; strcpy(data, other.data); cout << "拷贝构造函数:深拷贝内存,新地址=" << (void*)data << ",内容=" << data << endl; } // 移动构造函数(右值引用参数,资源转移) // noexcept关键字:告诉编译器该函数不会抛出异常,STL容器会优先调用 MyString(MyString&& other) noexcept { // 直接转移源对象的资源,无需深拷贝 data = other.data; len = other.len; // 源对象指针置空,避免析构时释放资源 other.data = nullptr; other.len = 0; cout << "移动构造函数:转移资源,目标地址=" << (void*)data << endl; } // 拷贝赋值运算符(深拷贝) MyString& operator=(const MyString& other) { if (this == &other) { return *this; } delete[] data; len = other.len; data = new char[len + 1]; strcpy(data, other.data); cout << "拷贝赋值运算符:深拷贝内存,新地址=" << (void*)data << ",内容=" << data << endl; return *this; } // 移动赋值运算符(右值引用参数,资源转移) MyString& operator=(MyString&& other) noexcept { if (this == &other) { return *this; } // 释放自身原有资源 delete[] data; // 转移源对象资源 data = other.data; len = other.len; // 源对象指针置空 other.data = nullptr; other.len = 0; cout << "移动赋值运算符:转移资源,目标地址=" << (void*)data << endl; return *this; } // 析构函数 ~MyString() { if (data) { cout << "析构函数:释放内存,地址=" << (void*)data << ",内容=" << data << endl; delete[] data; } else { cout << "析构函数:资源已转移,无需释放" << endl; } } }; MyString createString() { MyString str("Hello C++"); return str; } int main() { MyString str1("Hello World"); MyString str2 = std::move(str1); // 触发移动构造,转移str1的资源 MyString str3 = createString(); // 临时对象是右值,触发移动构造 str3 = MyString("Hello Move"); // 临时对象触发移动赋值 return 0; } 此处,移动构造 / 移动赋值没有做任何深拷贝,只是转移了指针的指向,没有新的内存分配,性能得到了提升。
注:
移动构造 / 移动赋值的参数必须是非 const 右值引用,因为我们需要修改源对象的指针,将其置空强烈建议给移动构造 / 移动赋值添加noexcept关键字,STL 容器(如 vector)在扩容时,只有移动构造被声明为 noexcept,才会优先调用移动构造,否则会保守地调用拷贝构造函数当类没有自定义拷贝构造、拷贝赋值、析构函数时,编译器会自动生成默认的移动构造和移动赋值函数;如果自定义了上述函数,编译器不会自动生成,需要手动实现1.3 移动语义的常见场景
STL 容器的优化:C++11 之后,所有 STL 容器都实现了移动构造和移动赋值,使用std::move()转移容器可以避免全量拷贝,比如vector<int> vec2 = std::move(vec1);时间复杂度从 O (n) 降到 O (1)emplace 系列函数:vector::emplace_back()、map::emplace()等函数,通过完美转发直接在容器内存中构造对象,避免了临时对象的拷贝和移动,性能优于push_back()/insert()智能指针的资源转移:std::unique_ptr无法拷贝,只能通过移动语义转移所有权,正是基于右值引用实现函数返回大对象:返回大容器、大对象时,编译器会优先进行 RVO/NRVO 返回值优化,优化失效时会自动调用移动构造,无需手动加std::move()
2. 完美转发
完美转发是右值引用的第二大核心应用,它的核心目标是:在模板函数中,将参数原封不动地转发给另一个函数,保留参数的左值 / 右值属性、const/volatile 修饰符。
简单来说,传入的是左值,转发后还是左值;传入的是右值,转发后还是右值,不会因为转发而改变参数的类型属性,这就是 “完美” 的含义。
2.1 完美转发的底层支撑
完美转发的实现,依赖三个 C++11 的核心特性:
右值引用万能引用(转发引用)引用折叠规则std::forward()
2.1.1 万能引用(转发引用)
万能引用是指模板函数中,形如T&&的参数,它既可以绑定左值,也可以绑定右值,同时保留参数的所有类型属性。
注意:只有发生模板参数推导时,T&&才是万能引用;如果是具体类型的Type&&,只是普通的右值引用,只能绑定右值。
// 模板参数推导,T&&是万能引用 template<typename T> void func(T&& arg) { } // 具体类型,int&&是普通右值引用 void func2(int&& arg) { } int main() { int a = 10; func(a); // 正确:万能引用绑定左值 func(10); // 正确:万能引用绑定右值 // func2(a); // 报错:普通右值引用无法绑定左值 func2(10); // 正确:普通右值引用绑定右值 return 0; } 2.1.2 引用折叠规则
C++ 不允许引用的引用,但在模板参数推导时,会出现引用嵌套的情况,此时编译器会执行引用折叠,规则如下:
左值引用&与 任何引用(&/&&)折叠,最终结果都是左值引用&右值引用&&与 右值引用&&折叠,最终结果才是右值引用&&
| 嵌套类型 | 折叠结果 |
|---|---|
| T& & | T& |
| T& && | T& |
| T&& & | T& |
| T&& && | T&& |
这个规则,让万能引用可以同时适配左值和右值:
当传入左值int&时,T 被推导为int&,T&& =int& &&,折叠为int&,左值引用当传入右值int&&时,T 被推导为int,T&& =int&&,右值引用
2.1.3 std::forward () 的作用
std::forward()是实现完美转发的核心函数,它的作用是有条件地进行类型转换:
当传入的是左值时,不做任何转换,依然返回左值当传入的是右值时,转换为右值引用,返回右值
和std::move()的无条件转右值不同,std::forward()是 “按需转换”,完美保留参数的原始属性。
2.2 完美转发的示例
我们通过一个完整的例子,看看完美转发的效果,以及不使用完美转发的问题:
#include <iostream> #include <utility> using namespace std; // 重载函数,区分左值和右值参数 void process(int& x) { cout << "左值引用版本被调用,值=" << x << endl; } void process(int&& x) { cout << "右值引用版本被调用,值=" << x << endl; } // 不使用完美转发的模板函数 template<typename T> void noForward(T&& arg) { cout << "无完美转发:"; process(arg); // arg是有名字的变量,本身是左值,永远调用左值版本 } // 使用完美转发的模板函数 template<typename T> void withForward(T&& arg) { cout << "有完美转发:"; process(std::forward<T>(arg)); // 完美转发,保留原始左值/右值属性 } int main() { int a = 5; cout << "===== 传入左值a =====" << endl; noForward(a); withForward(a); cout << "\n===== 传入右值10 =====" << endl; noForward(10); withForward(10); return 0; } 运行结果:
===== 传入左值a ===== 无完美转发:左值引用版本被调用,值=5 有完美转发:左值引用版本被调用,值=5 ===== 传入右值10 ===== 无完美转发:左值引用版本被调用,值=10 有完美转发:右值引用版本被调用,值=10 可以看到,不使用std::forward()时,无论传入左值还是右值,都会调用左值版本的 process 函数,因为函数内部的 arg 是有名字的变量,本身是左值。而使用完美转发后,参数的左值 / 右值属性被完美保留,正确调用了对应的重载函数。
2.3 完美转发的常见场景
STL 容器的 emplace 系列函数:直接在容器内存中构造对象,将用户传入的参数完美转发给对象的构造函数,保留所有类型属性std::bind/std::function:实现函数对象的封装和参数转发,底层依赖完美转发线程库 std::thread:将线程函数的参数完美转发到线程执行上下文泛型编程中的中间转发函数:需要将参数透传给其他函数的模板场景
五、高频踩坑点与实践建议
1. 高频踩坑点
坑 1:std::move () 之后继续使用原对象
std::move()之后,原对象的资源已经被转移,处于有效但未定义的状态,此时访问其内部资源(如 string 的字符、vector 的元素)会触发未定义行为,只能对其进行析构或重新赋值。坑 2:对函数返回值使用 std::move ()
很多人会写return std::move(obj);,这是典型的错误写法。编译器默认会对返回的局部对象执行 RVO/NRVO 返回值优化,直接在返回值地址构造对象,零拷贝。而加了std::move()之后,会阻止编译器的优化,强制调用移动构造,反而降低性能。
坑 3:误以为右值引用变量本身是右值
有名字的右值引用变量,本身是左值,无法直接绑定到右值引用参数。如果需要在函数中转发右值引用形参,必须使用std::move()或std::forward()。
void func(int&& x) { } void test(int&& arg) { // func(arg); // 报错:arg是右值引用变量,本身是左值 func(std::move(arg)); // 正确 } 坑 4:给移动构造 / 移动赋值加 const 修饰
移动构造 / 移动赋值需要修改源对象的指针,将其置空,加了 const 之后无法修改源对象,只能做深拷贝,完全失去了移动语义的意义。const 右值引用几乎没有使用场景,不如直接用 const 左值引用。
坑 5:把普通右值引用当成万能引用
只有在模板参数推导场景下的T&&才是万能引用,类成员函数的模板参数如果没有推导过程,也不是万能引用。class Test { public: // 这里的T&&不是万能引用,因为类实例化时T已经确定,没有参数推导 template<typename T> void func(T&& arg); }; 2. 实践建议
只在需要转移资源的时候使用std::move(),且确保 move 之后的对象不再使用实现移动构造 / 移动赋值时,必须添加noexcept关键字,确保 STL 容器能优先调用函数返回局部对象时,不要加std::move(),交给编译器做返回值优化泛型编程中,需要转发参数时,使用std::forward()实现完美转发,不要用std::move()遵循 “三五法则”:如果自定义了析构函数、拷贝构造函数、拷贝赋值运算符中的任意一个,就应该同时实现或禁用移动构造、移动赋值运算符对于不需要拷贝、只需要转移所有权的对象(如 unique_ptr、文件句柄),禁用拷贝构造和拷贝赋值,只实现移动构造和移动赋值
感谢阅读,本文如有错漏之处,烦请斧正。