左值引用和右值引用
一、基本概念
-
左值(lvalue)和右值(rvalue)
-
左值指的是有确定存储位置(地址)的对象,通常可以出现在赋值语句左侧。例如:变量名、解引用指针得到的对象、数组元素等都属于左值。
-
右值一般指临时对象或字面常量,通常没有固定的存储地址,只能出现在赋值语句右侧。例如:字面量(
42
、"hello"
)、表达式求值产生的临时结果(a + b
、函数返回的非引用类型)等都属于右值。
-
-
左值引用(lvalue reference)
T&
-
语法:
T&
-
含义:引用一个左值,必须绑定到一个具有名字且可寻址的对象上。
-
作用:可以通过引用直接操作原对象,不产生拷贝;常用于函数参数(接收可修改的实参)或延长临时对象的生命周期(使用
const T&
)。
-
-
右值引用(rvalue reference)
T&&
(C++11 引入)-
语法:
T&&
-
含义:引用一个右值,只能绑定到临时对象(比如函数返回的非引用类型对象、字面量或者
std::move
之后的结果)。 -
作用:为“移动语义(move semantics)”和“完美转发(perfect forwarding)”提供基础,通过接收将要被销毁的临时对象,可以“窃取”其内部资源而不是拷贝。
-
二、左值引用与右值引用的区别
特性 | 左值引用 T& | 右值引用 T&& |
---|---|---|
可绑定的对象 | 只能绑定到 左值(命名变量等) | 只能绑定到 右值(临时对象、字面常量、std::move 产生的中间值) |
是否可修改 | 可修改所引用的对象 | 可以修改所引用的临时对象(临时对象本来就要销毁) |
延长生命周期 | const T& 可延长临时对象生命周期T& 不能绑定临时对象 | 绑定临时对象后,可操作临时,直到其生命周期结束 |
主要用途 | 传递可修改的已有对象,避免拷贝 | 实现移动语义、完美转发,减少不必要的深度拷贝 |
-
左值引用
T&
-
只能引用已有的命名对象。
-
用途:
-
函数参数接收时,可以直接修改传入的实参(例如
void foo(int& x)
)。 -
避免拷贝开销(例如
void print(const std::string& s)
)。 -
const T&
可以绑定到右值,用于只读访问且延长临时对象生命周期。
-
-
-
右值引用
T&&
-
只能引用临时对象(右值)。
-
用途:
-
移动构造/移动赋值:从临时对象“窃取”内部资源,而不做深拷贝。
-
完美转发:在模板中,通过
T&&
(与std::forward<T>(…)
)保持函数参数的值类别(左值/右值)不变。 -
禁止绑定左值:直接传递命名对象到
T&&
参数会编译错误,除非显式使用std::move
。
-
-
三、典型示例
下面通过几个示例来直观说明它们的使用场景和区别。
1. 直接绑定示例
#include <iostream>
#include <string>int main() {int a = 10; // a 是左值int& lref = a; // 左值引用只能绑定到左值// int& lref2 = 20; // 错误:不能把 int& 绑定到字面量 20(右值)上int&& rref = 20; // 右值引用只能绑定到右值// int&& rref2 = a; // 错误:不能把 int&& 绑定到左值 a 上std::string s = "Hello";std::string& ls = s; // 合法,引用已有 std::string 对象const std::string& lsc = "World"; // const std::string& 可以绑定到右值 "World",临时字符串的生命周期延长至 lsc 作用域结束std::string&& rs = std::string("Temp"); // 右值引用绑定到了临时 std::string("Temp"),可在后续对 rs 进行修改std::cout << "a: " << a << "\n"; // a: 10std::cout << "lref: " << lref << "\n"; // lref: 10std::cout << "rref: " << rref << "\n"; // rref: 20std::cout << "ls: " << ls << "\n"; // ls: Hellostd::cout << "lsc: " << lsc << "\n"; // lsc: Worldstd::cout << "rs: " << rs << "\n"; // rs: Tempreturn 0;
}
-
int& lref = a;
:左值引用lref
绑定到左值a
。 -
int&& rref = 20;
:右值引用rref
绑定到字面量20
(右值)。 -
const std::string& lsc = "World";
:const T&
可以绑定到右值(临时字符串),并将其生命周期延长至lsc
的作用域结束。 -
std::string&& rs = std::string("Temp");
:右值引用rs
绑定到临时字符串对象,此时可以对该临时对象进行修改(不过它最终会析构)。
2. 作为函数参数的区别
通常我们会看到如下重载示例,用以区分传入的是左值还是右值:
#include <iostream>// 重载 1:接受左值引用
void process(int& x) {std::cout << "process(int&): 接收左值引用, x = " << x << "\n";
}// 重载 2:接受右值引用
void process(int&& x) {std::cout << "process(int&&): 接收右值引用, x = " << x << "\n";
}int main() {int a = 5;process(a); // 调用 process(int&), 因为 a 是左值process(10); // 调用 process(int&&), 因为 10 是右值process(std::move(a)); // std::move(a) 将 a 转换成右值, 因此调用 process(int&&)return 0;
}
-
当实参是一个命名变量(
a
),它是左值,因此会匹配void process(int&)
。 -
当实参是一个字面量或临时表达式(如
10
或std::move(a)
),它们是右值,因此会匹配void process(int&&)
。
3. 移动构造 vs 拷贝构造
以一个简单的自定义类 MyString
为例,演示移动构造函数与拷贝构造函数的区别。
#include <iostream>
#include <cstring>class MyString {
public:char* data;// 构造函数:从 C 风格字符串构造MyString(const char* s) {size_t len = std::strlen(s);data = new char[len + 1];std::strcpy(data, s);std::cout << "构造 MyString(\"" << data << "\")\n";}// 拷贝构造:深拷贝MyString(const MyString& other) {size_t len = std::strlen(other.data);data = new char[len + 1];std::strcpy(data, other.data);std::cout << "调用 拷贝构造, 源 = \"" << other.data << "\"\n";}// 移动构造:窃取指针,避免拷贝MyString(MyString&& other) noexcept {data = other.data; // 直接窃取资源指针other.data = nullptr; // 将源置空,避免析构时释放两次std::cout << "调用 移动构造\n";}// 析构函数~MyString() {if (data) {std::cout << "析构 MyString(\"" << data << "\")\n";delete[] data;} else {std::cout << "析构 MyString(nullptr)\n";}}
};MyString makeString() {MyString temp("临时");return temp; // C++11 以后可进行移动构造而非拷贝
}int main() {MyString s1("Hello"); // 普通构造MyString s2 = s1; // 拷贝构造MyString s3 = makeString(); // 由于 makeString 返回临时对象,可触发移动构造(或编译器优化)// 可以强制使用移动构造:MyString s4 = std::move(s1);return 0;
}
-
调用
MyString s2 = s1;
时,实参s1
是一个左值,因此调用的是 拷贝构造,会分配新内存并拷贝字符串内容。 -
调用
MyString s3 = makeString();
时,makeString()
返回的临时对象是一个右值,因此会优先调用 移动构造(如果编译器没有做完全优化)。移动构造只会“窃取”临时对象的内部char*
指针,而不会再做深拷贝。 -
对
s1
应用std::move(s1)
,即把左值s1
强制转换为右值,再传递给MyString s4 = std::move(s1);
,也会调用移动构造,将s1
的内部指针转移给s4
,此时s1.data
置为nullptr
。
4. 完美转发(Perfect Forwarding)示例
在模板中,如果想让函数“如实”地将传入参数的值类别(左值/右值)传给另一个函数,可以使用右值引用和 std::forward
。示例:
#include <iostream>
#include <utility> // std::forward, std::movevoid target(int& x) {std::cout << "target(int&) 被调用,x = " << x << "\n";
}void target(int&& x) {std::cout << "target(int&&) 被调用,x = " << x << "\n";
}// 通用转发函数模板
template <typename T>
void wrapper(T&& param) {// 直接传给 target,但保留 param 本身的值类别// 如果原参数是左值,就调用 target(int&); 如果是右值,就调用 target(int&&)target(std::forward<T>(param));
}int main() {int a = 100;wrapper(a); // a 是左值 -> 调用 target(int&)wrapper(200); // 200 是右值 -> 调用 target(int&&)int&& rr = 300;wrapper(rr); // rr 本身虽然声明为右值引用,但 rr 名称是左值 -> 调用 target(int&)wrapper(std::move(rr)); // std::move(rr) 是右值 -> 调用 target(int&&)return 0;
}
-
wrapper(a)
:a
是左值,模板参数T
被推断为int&
,T&&
变为int& &&
,折叠后仍为int&
,所以param
是左值引用,std::forward<T>(param)
仍是左值,调用target(int&)
。 -
wrapper(200)
:200
是右值,T
被推断为int
,T&&
为int&&
,param
是右值引用。std::forward<int>(param)
是右值,调用target(int&&)
。 -
int&& rr = 300;
声明了一个右值引用rr
,但rr
本身是一个命名变量(左值)。所以:-
wrapper(rr)
:传入rr
(是左值),和wrapper(a)
类似,依然调用target(int&)
。 -
wrapper(std::move(rr))
:std::move(rr)
将rr
强制转换为右值,调用target(int&&)
。
-
四、总结
-
绑定限制
-
T&
只能绑定到左值,不能绑定到右值(除非加上const
,即const T&
可以绑定到右值,用于只读)。 -
T&&
只能绑定到右值,不能直接绑定到左值(除非对左值使用std::move
或std::forward
强制转换为右值)。
-
-
主要用途
-
左值引用
T&
:主要用于接收已有对象并进行读取/修改,避免拷贝开销。 -
右值引用
T&&
:主要用于实现“移动语义”,在可以销毁的临时对象上直接窃取资源;同时在模板中用于“完美转发”以保留参数的值类别。
-
-
性能意义
-
使用
T&&
实现移动构造/移动赋值,可以显著减少对大对象(如容器、字符串等)的深拷贝,从而提升性能。 -
std::forward<T>
与T&&
配合可以让模板函数在转发参数时不丢失值类别,避免不必要的拷贝或移动。
-
-
注意事项
-
虽然
T&&
只能绑定右值,但当T
已推断为引用类型(如int&
)时,T&&
会出现“引用折叠”(reference collapsing)现象:-
如果
T
是U&
,那么T&&
等价于U&
。 -
如果
T
是U
,那么T&&
就是U&&
。
-
-
在函数重载中,若同时存在
void f(int&)
和void f(int&&)
,传入的实参的值类别会直接影响调用哪个重载。
-