左值、右值与移动语义
难度:⭐⭐ | 高频指数:🔥🔥🔥
一句话搞懂
左值 = 有名字、能取地址、还活着的对象;右值 = 临时的、马上要没的值;移动 = 把资源”偷”过来而不是复制一份。
核心概念
左值 vs 右值
int x = 42; // x 是左值(有名字,能 &x)
int y = x + 1; // x + 1 是右值(临时结果,没法 &(x+1))
std::string("abc"); // 临时对象,右值
判断方法很简单:能取地址的就是左值,不能的就是右值。
将亡值(xvalue)
就是”我标记了这个对象可以被偷资源”:
std::string s = "hello";
std::string t = std::move(s); // std::move(s) 是将亡值
// s 的资源被 t 偷走了
三者关系
表达式
/ \
glvalue rvalue
/ \ / \
lvalue xvalue prvalue
(左值) (将亡值) (纯右值)
面试记住:左值有身份能持续用,纯右值是临时结果,将亡值是”我允许你偷我资源”。
右值引用(T&&)到底怎么用
很多人看到 T&& 就懵,因为不知道它到底用在哪。其实右值引用在工程里只有三个用途,记住这三个就够了:
用途一:写移动构造 / 移动赋值(最核心)
右值引用存在的根本原因就是为了实现移动语义。没有 T&&,编译器无法区分”调用方还要用这个对象”和”调用方不要了,你可以偷资源”。
// 拷贝构造:参数是 const T&,表示"我只是看看,不动你的"
Buffer(const Buffer& other);
// 移动构造:参数是 T&&,表示"你不要了,我把资源偷走"
Buffer(Buffer&& other) noexcept;
结合 CrossShellNext:
class SshSession {
public:
// 移动构造:把 other 的 SSH 句柄偷过来
SshSession(SshSession&& other) noexcept
: handle_(other.handle_), host_(std::move(other.host_)) {
other.handle_ = nullptr; // 源对象置空
}
// 移动赋值:先释放自己的,再偷对方的
SshSession& operator=(SshSession&& other) noexcept {
if (this != &other) {
cleanup(); // 释放自己原来的资源
handle_ = other.handle_;
host_ = std::move(other.host_);
other.handle_ = nullptr;
}
return *this;
}
};
用途二:函数参数,表达”我要接管你的资源”
当函数需要消费调用方的对象时,参数写 T&&:
// SessionManager 要接管 session 的所有权
void SessionManager::addSession(int id, std::unique_ptr<Session>&& session) {
sessions_[id] = std::move(session); // 偷走,调用方不再拥有
}
// 调用方:
auto sess = std::make_unique<SshSession>("host", 22);
manager.addSession(1, std::move(sess)); // 明确放弃所有权
// sess 现在是 nullptr
对比 const T& 参数:
// 只读,不接管
void printSession(const Session& session); // 看看就好,不动你的
// 接管所有权
void addSession(std::unique_ptr<Session>&& session); // 我要拿走
用途三:模板中的转发引用(万能引用)
这是高级用法。当 T&& 出现在模板参数推导中时,它不是”右值引用”,而是”转发引用”,能同时接受左值和右值:
// 这里的 T&& 是转发引用,不是右值引用!
template<typename T>
void wrapper(T&& arg) {
// std::forward 保持 arg 原来的左值/右值身份
realFunction(std::forward<T>(arg));
}
// 线程池提交任务时的完美转发
template<typename F>
void ThreadPool::submit(F&& task) {
tasks_.emplace(std::forward<F>(task));
}
三个用途总结
| 用途 | 写法 | 含义 | 你项目里的例子 |
|---|---|---|---|
| 移动构造/赋值 | T(T&& other) | 偷走 other 的资源 | SshSession(SshSession&&) |
| 函数参数接管 | void f(T&& x) | 调用方放弃所有权 | addSession(unique_ptr&&) |
| 模板转发引用 | template<T> void f(T&& x) | 保持原始值类别转发 | ThreadPool::submit(F&&) |
什么时候不需要右值引用
int、double、裸指针 → 拷贝就几个字节,移动没意义- 只读访问 → 用
const T&就够了 - 对象很小(< 16 字节的 POD)→ 直接按值传递更快
移动语义
为什么需要移动
std::vector<int> a(1000000, 1); // 100万个元素
std::vector<int> b = a; // 拷贝:重新分配内存 + 复制100万个元素 → 慢
std::vector<int> c = std::move(a); // 移动:偷走 a 的内部指针 → 快(接近零成本)
移动就是把内部指针/句柄交接过去,不复制实际数据。
std::move 到底干了啥
它不移动任何东西! 它只是把左值强制转成右值,相当于贴了个标签说”这个对象的资源你可以拿走”。
// std::move 本质上就是:
static_cast<T&&>(obj)
真正的移动发生在移动构造函数/移动赋值运算符里。
移动构造长什么样
class Buffer {
char* data_;
size_t size_;
public:
// 拷贝构造:深拷贝,慢
Buffer(const Buffer& other)
: data_(new char[other.size_]), size_(other.size_) {
memcpy(data_, other.data_, size_);
}
// 移动构造:偷指针,快
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 源对象置空,防止 double free
other.size_ = 0;
}
};
核心区别:拷贝是 new + memcpy,移动是偷指针 + 置空源对象。
移动后源对象什么状态
有效但未指定。 只保证能析构、能重新赋值,不能假设它还有原来的值。
std::string s = "hello";
std::string t = std::move(s);
// s 现在是合法对象,但内容不确定(通常是空串,但标准不保证)
// 安全操作:s = "new value"; 或者让 s 析构
关键规则
0. 先搞清楚:变量的”类型” vs 表达式的”值类别”
这是理解右值引用最关键的前置知识。很多人懵就是因为把这两件事混在一起了。
类型(Type):声明变量时写的东西,描述”这个变量能绑定什么”。
值类别(Value Category):表达式在使用时的属性,描述”这个表达式此刻是左值还是右值”。
它们是两个独立维度:
int x = 42;
// ↑ 类型是 int
// x 这个表达式的值类别是:左值(有名字、能取地址)
int&& r = 42;
// ↑ 类型是 int&&(右值引用)
// r 这个表达式的值类别是:左值!(因为 r 有名字、能 &r)
核心规则:类型和值类别是独立的。类型是 T&& 不代表表达式就是右值。
用生活类比:
- 类型 = 你的身份证上写的”性别”,是固定的声明
- 值类别 = 你此刻在做什么(站着/坐着),是当下的状态
再看一组对比:
void f(std::string&& s) {
// s 的类型:std::string&&(右值引用)← 声明时决定的
// s 的值类别:左值 ← 因为 s 有名字,在函数体里还活着
// 类比:s 的"身份证"写着"右值引用",但它此刻"站着"(有名字、能用多次)
}
为什么 C++ 要这样设计?安全。
void f(std::string&& s) {
g(s); // 如果 s 自动是右值,g 可能偷走它的资源
h(s); // 那这里 s 就是空壳了 → bug!
}
所以 C++ 规定:有名字的变量永远是左值表达式,不管它的类型是什么。 你要放弃它的资源,必须显式 std::move(s) 表达意图。
快速判断表
| 表达式 | 类型 | 值类别 | 为什么 |
|---|---|---|---|
int x = 1; 中的 x | int | 左值 | 有名字 |
int&& r = 1; 中的 r | int&& | 左值 | 有名字 |
std::move(x) | int&& | 将亡值(xvalue) | move 转换了值类别 |
42 | int | 纯右值(prvalue) | 字面量,没名字 |
std::string("hi") | std::string | 纯右值 | 临时对象 |
f(std::string&& s) 中的 s | std::string&& | 左值 | 有名字 |
结合 CrossShellNext 理解
// SessionManager::addSession
void addSession(int id, std::unique_ptr<Session>&& session) {
// session 的类型:unique_ptr<Session>&&
// session 的值类别:左值(有名字)
sessions_[id] = session; // ❌ 编译错误!左值不能移动 unique_ptr
sessions_[id] = std::move(session); // ✅ 显式转成右值,触发移动赋值
}
一句话总结:类型是”声明时写的”,值类别是”用的时候是什么”。T&& 类型的变量,用的时候仍然是左值,因为它有名字。
1. 有名字的右值引用是左值
这是上面规则的直接推论,也是最经典的坑:
void f(std::string&& s) {
// s 的类型是 string&&,但 s 这个表达式是左值!
// 因为它有名字
g(s); // 传给 g 的是左值
g(std::move(s)); // 要再 move 一次才是右值
}
2. const 会抑制移动
const std::string s = "abc";
std::string t = std::move(s); // 实际走的是拷贝!
// 因为移动需要修改源对象,const 不让改
3. 返回局部对象别写 std::move
std::string f() {
std::string s = "abc";
return s; // ✅ 编译器会 RVO 或自动移动
// return std::move(s); // ❌ 反而可能妨碍 RVO 优化
}
4. 移动构造要标 noexcept
Buffer(Buffer&& other) noexcept; // ✅
不标的话,vector 扩容时为了异常安全可能选择拷贝而不是移动,白白浪费性能。
什么时候移动有意义
| 场景 | 有没有收益 | 原因 |
|---|---|---|
string、vector 等容器 | ✅ 收益大 | 内部有堆内存,移动只转指针 |
unique_ptr、文件句柄 | ✅ 必须移动 | 不可拷贝,只能移动 |
int、double、小 POD | ❌ 没意义 | 拷贝本身就很快,移动没区别 |
std::move vs std::forward
std::move | std::forward | |
|---|---|---|
| 作用 | 无条件转成右值 | 保留原来的左值/右值身份 |
| 用在哪 | 明确不再需要这个对象时 | 模板转发引用里 |
| 搭配 | 普通代码 | template<class T> void f(T&& x) |
// std::move:我不要了,你拿走
std::string s = "hi";
vec.push_back(std::move(s));
// std::forward:我不决定,调用者传什么我就转什么
template<class T>
void wrapper(T&& arg) {
target(std::forward<T>(arg));
}
传参怎么选
| 场景 | 写法 | 原因 |
|---|---|---|
| 只读大对象 | const T& | 不拷贝,能接左值和右值 |
| 要接管资源 | T&& 或按值 T | 明确表达消费语义 |
| 内部要保存副本 | T(按值)+ std::move | 接口简洁,左值右值都能处理 |
// 经典模式:按值接收 + move 到成员
void set_name(std::string name) {
name_ = std::move(name);
}
易错点
std::move不移动,只是类型转换- 有名字的
T&&变量本身是左值 const对象 move 后还是拷贝- 返回局部对象不要写
std::move(妨碍 RVO) - 移动后源对象不是”废了”,是”值不确定”
- 移动构造不标
noexcept会影响容器性能 - 小对象(int、指针)移动没意义
面试速答版
左值是有身份、能取地址的对象,右值是临时值。移动语义利用右值引用,把”深拷贝资源”改成”转移资源所有权”,对 string、vector、智能指针这类管理堆资源的对象收益很大。std::move 本身不移动,只是把左值转成右值让编译器匹配移动构造。移动后源对象仍有效但值未指定。工程上注意:const 抑制移动,返回局部对象别写 std::move,移动构造要标 noexcept。
面试加分版
左值右值我从两层来讲。第一层是值类别:左值有身份能持续使用,纯右值是临时结果,将亡值是有身份但资源可被复用的对象。注意值类别是表达式属性,T&& 是类型属性,命名后的右值引用变量本身仍是左值表达式。
第二层是移动语义。C++11 引入右值引用后,类型可以为即将销毁的对象提供移动构造,直接接管资源句柄而不是深拷贝。比如 vector 的移动只是转移内部指针和元数据,不复制整块内存。std::move 只是类型转换,把左值转成右值让编译器匹配移动重载,真正移动靠的是移动构造函数。
工程上三个要点:一,返回局部对象不要写 std::move,编译器能做 RVO 甚至 C++17 起强制省略构造;二,移动构造标 noexcept,否则容器扩容可能退回拷贝;三,参数设计上如果函数内部要保存副本,“按值传参再 move 到成员”往往比写两套重载更简洁。
一句话:左值右值解决”对象此刻是什么身份”,移动语义解决”资源能不能高效交接”。