左值、右值与移动语义
面试回答
常见问法
- 什么是左值、右值、将亡值(xvalue)?
- 为什么移动语义能减少拷贝开销?
std::move到底做了什么?它会真的“移动”吗?- 移动之后源对象还能不能用?
- 右值引用、万能引用(转发引用)、完美转发有什么关系?
- 返回局部对象时,到底是移动还是 RVO?
回答
面试里可以先抓住一句话:
左值强调“身份”和“可持续存在”,右值强调“值本身”或“即将销毁”,移动语义则是把“复制资源”改成“转移资源所有权”。
更准确地说:
- 左值(lvalue):有身份、能取地址、通常可持续存在的对象 例如:普通变量、解引用结果、返回左值引用的表达式。
- 纯右值(prvalue):纯粹的值、临时结果
例如:
1、a + b、std::string("abc")这样的临时对象。 - 将亡值(xvalue):有身份,但资源可以被安全复用的对象
例如:
std::move(x)的结果、函数返回的右值引用。
移动语义的核心价值在于:
对于管理资源的对象,拷贝通常意味着重新申请资源并复制内容;移动则是直接“接管”资源句柄,避免深拷贝。
所以它对下面这类类型收益最大:
- 管理堆内存的对象:
std::string、std::vector - RAII 资源对象:文件句柄、socket、锁、智能指针等
- 大对象、容器元素搬运、函数返回临时对象等场景
比如一个 std::vector 拷贝时,往往要重新分配内存并逐个复制元素;而移动时,通常只需要把内部指针、容量、大小这些元数据转交给新对象,代价接近常数级。
还要补一句非常重要的话:
std::move本身不移动任何资源,它只是把一个表达式强制转换成右值,以便触发移动构造/移动赋值重载。真正是否发生移动,要看类型是否支持移动。
追问
追问 1:左值 / 右值 和 引用类型是什么关系?
它们不是一个维度的概念。
- 左值/右值:说的是表达式的值类别
T&/T&&:说的是类型
比如:
int&& rr = 10;
rr; // 注意:rr 这个表达式本身是左值
也就是说,“类型是右值引用”不等于“表达式是右值”。
这也是为什么在模板里命名后的 T&& param 传给别人时,通常要用 std::forward<T>(param),而不是直接当右值用。
追问 2:移动语义什么时候最有价值?
最有价值的场景,是对象内部持有独占资源,并且拷贝代价高。
典型例子:
- 容器扩容时搬迁元素
- 函数返回大对象
push_back(std::move(x))- 工厂函数返回资源对象
unique_ptr这类天然不可拷贝、只能移动的类型
如果对象本身很小、可平凡拷贝,比如 int、小 POD,移动和拷贝的收益可能非常有限。
追问 3:移动有没有代价和风险?
有,而且这是工程里必须说清楚的点。
-
移动后源对象仍然有效,但通常只保证:
- 可以析构
- 可以重新赋值
- 能满足类型定义的基本不变式
-
不要假设移动后对象还有原值
-
如果类设计不好,移动后对象可能进入“难以继续使用但又没彻底坏”的尴尬状态
-
对某些类型,移动不一定比拷贝便宜
-
如果移动构造不是
noexcept,标准容器在某些场景下可能更倾向于拷贝而不是移动
追问 4:返回值时到底是移动还是优化掉了?
这是面试很爱追问的点。
结论分两层:
- 返回局部对象时,编译器可能做 RVO/NRVO(返回值优化)
- 即便不做优化,也可能走移动构造
从 C++17 开始,很多场景下 prvalue 的构造是强制省略拷贝/移动的,所以:
std::string make() {
return std::string("abc");
}
这类代码很多时候既不是拷贝,也不是移动,而是直接构造到目标位置。
所以要区分:
- 移动语义:语言提供“资源转移”的机制
- 拷贝省略 / RVO:编译器直接不生成那个中间对象
两者相关,但不是一回事。
追问 5:const 对移动有什么影响?
这是高频坑点。
const std::string s = "abc";
std::string t = std::move(s); // 通常不会调用真正的移动构造,而是拷贝
原因是:移动通常需要“修改源对象状态”,而 const 对象不允许被修改。
所以:
const基本上会“抑制移动”。
这也是为什么函数参数、返回值设计时,不要动不动就把一切都写成 const。
原理展开
1. 值类别到底在区分什么?
值类别本质上回答两个问题:
- 这个表达式有没有“身份”(identity)?
- 这个对象的资源能不能被复用?
可以这样理解:
-
glvalue:有身份,能定位到某个对象
- 包括 lvalue 和 xvalue
-
prvalue:更像“纯值”“临时结果”
-
xvalue:有身份,但资源可被转移
一个非常实用的面试记法:
- lvalue:我还能稳定地继续用它
- prvalue:这是个临时结果
- xvalue:这个对象还认得出来是谁,但它快“退场”了,资源可以搬走
例如:
int a = 10; // a 是左值
int b = a + 1; // a + 1 是纯右值
int&& r = 1; // r 的类型是右值引用
r; // 但 r 这个表达式本身是左值
std::move(r); // 这是将亡值
2. 为什么右值引用能支持移动语义?
C++11 引入 T&&,本质上是给类型设计者一个信号:
“如果你拿到的是一个即将被销毁、资源可复用的对象,那你可以不拷贝,直接接管资源。”
于是类可以提供:
- 移动构造函数
- 移动赋值运算符
典型写法:
#include <cstring>
#include <utility>
class Buffer {
private:
char* data_;
std::size_t size_;
public:
Buffer(std::size_t n)
: data_(new char[n]), size_(n) {}
~Buffer() {
delete[] data_;
}
// 拷贝构造:深拷贝
Buffer(const Buffer& other)
: data_(new char[other.size_]), size_(other.size_) {
std::memcpy(data_, other.data_, size_);
}
// 拷贝赋值:深拷贝
Buffer& operator=(const Buffer& other) {
if (this == &other) return *this;
char* new_data = new char[other.size_];
std::memcpy(new_data, other.data_, other.size_);
delete[] data_;
data_ = new_data;
size_ = other.size_;
return *this;
}
// 移动构造:接管资源
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
// 移动赋值:释放旧资源,再接管新资源
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
return *this;
}
};
这里最核心的差别是:
- 拷贝:重新分配 + 复制内容
- 移动:偷指针 + 置空源对象
3. std::move 的本质是什么?
它本质是一个类型转换,大致等价于:
static_cast<T&&>(obj)
所以它做的不是“搬运资源”,而是:
把一个本来是左值的表达式,显式地标记成“你可以把它当成右值处理”。
例如:
std::string s1 = "hello";
std::string s2 = std::move(s1);
流程是:
s1是左值std::move(s1)把它转成 xvalue- 编译器发现目标类型支持移动构造
- 调用移动构造,转移资源
但如果目标类型没有移动构造,或者移动不可用,那仍可能退化成拷贝。
所以面试里最好不要说“std::move 会移动对象”,更准确的说法是:
std::move只是创造了“可移动的机会”。
4. 移动之后,源对象到底是什么状态?
标准通常只要求:有效但未指定(valid but unspecified)。
这句话建议这样解释,最容易拿分:
- 有效:对象还能析构,还能重新赋值,通常还能调用部分满足前置条件的成员函数
- 未指定:不能假设它还是原来的值,也不能依赖某个固定内容
例如:
std::string s1 = "hello";
std::string s2 = std::move(s1);
这之后:
s2拿到了原资源s1依旧是一个合法的std::string- 但不能假设
s1 == "",虽然很多实现里看起来像空串
工程建议:
- 被
std::move之后的对象,最好只做两件事:销毁,或者重新赋值 - 不要继续依赖它原有业务含义
5. 为什么 noexcept 很重要?
这是很有工程味道的追问点。
标准容器(尤其是 std::vector)在扩容搬迁元素时,会倾向使用移动构造。
但如果移动构造可能抛异常,就会影响异常安全保证。
因此很多容器实现会遵循这样的策略:
- 移动构造是
noexcept:优先移动 - 移动构造可能抛异常:为了强异常安全,可能退回拷贝
所以对于资源管理类:
如果移动操作确实不会失败,尽量显式标注
noexcept。
这是一个典型的性能和异常安全结合点。
6. 什么时候会触发移动?什么时候不会?
常见会触发移动的场景:
std::string a = "abc";
std::string b = std::move(a); // 移动构造
std::vector<std::string> v;
std::string s = "hello";
v.push_back(std::move(s)); // 移动插入
常见不会按你想象触发移动的场景:
情况一:对象是 const
const std::string s = "abc";
std::string t = std::move(s); // 往往还是拷贝
情况二:类型没有定义移动操作
class A {
public:
A(const A&) = default;
// 没有移动构造
};
这时即使你 std::move(a),也未必真能移动。
情况三:你把该优化的返回值写坏了
std::string f() {
std::string s = "abc";
return std::move(s); // 通常不推荐,可能妨碍返回值优化
}
在返回局部对象时,通常直接:
return s;
更好,让编译器自己做 RVO/移动选择。
7. 移动语义和完美转发是什么关系?
很多面试官会从“移动语义”继续追到“完美转发”。
先分清两个目标:
- 移动语义:我知道这个对象可以被转移资源,于是主动移动它
- 完美转发:我自己不决定左值右值,而是把调用者传进来的值类别原封不动地传下去
示例:
#include <utility>
void process(const std::string& s) {
// 处理左值/可复用对象
}
void process(std::string&& s) {
// 处理右值/可移动对象
}
template <typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg)); // 保留原始值类别
}
这里:
T&&在模板推导场景下是转发引用arg虽然类型上可能是T&&,但作为命名变量,它本身是左值- 所以必须用
std::forward<T>(arg)恢复原始值类别
这是比“左值右值”更深一层的面试点。
8. 工程里怎么选:传值、传左值引用、传右值引用?
这是实际编码中最常见的判断题。
传 const T&
适合:
- 只读参数
- 不持有参数
- 避免不必要拷贝
优点:
- 通用,左值右值都能接
- 不产生额外构造成本
缺点:
- 如果你最终要把它存起来,可能还得再拷贝一次
传 T&&
适合:
- 明确表示“我要接管资源”
- 只接受可移动对象
- 常用于移动构造、移动赋值、部分工厂/容器接口
优点:
- 意图明确
- 性能好
缺点:
- 调用方必须显式
std::move - 接口更“挑剔”
传值 T
适合:
- 你本来就要拷贝/持有一份
- 参数可能来自左值也可能来自右值
- 希望接口简洁
典型写法:
class Person {
private:
std::string name_;
public:
void set_name(std::string name) {
name_ = std::move(name);
}
};
好处是:
- 调用左值时:发生一次拷贝到形参,再移动到成员
- 调用右值时:直接移动到形参,再移动到成员
- 接口统一,代码简洁
这在现代 C++ 里是非常常见的工程写法。
对比总结
值类别对比
| 概念 | 核心特征 | 是否有身份 | 是否可取地址 | 是否适合被移动 | 典型例子 | 适用理解 |
|---|---|---|---|---|---|---|
| 左值(lvalue) | 可持续存在、可定位 | 是 | 通常可以 | 不能默认直接移动,需显式 std::move | x、*p、vec[0] | “我后面还可能继续用它” |
| 纯右值(prvalue) | 临时值、纯计算结果 | 否/弱化身份 | 通常不关心 | 常可触发移动或直接省略构造 | 1、a+b、std::string("abc") | “这是一个临时结果” |
| 将亡值(xvalue) | 即将销毁但有身份 | 是 | 可以 | 最适合移动 | std::move(x)、返回 T&& 的表达式 | “资源可以安全接管” |
std::move、std::forward、拷贝的区别
| 概念 | 本质 | 作用 | 适用场景 | 优点 | 风险/误区 |
|---|---|---|---|---|---|
std::move | 强制转成右值 | 给出“可以移动”的信号 | 明确不再需要原对象 | 能触发移动重载 | 不是实际移动;用完后源对象状态不可依赖 |
std::forward | 条件转发 | 保留原始值类别 | 模板转发、完美转发 | 避免误把左值当右值 | 只能配合转发引用正确使用 |
| 拷贝 | 复制内容/资源 | 保留源对象完整语义 | 还要继续使用源对象 | 语义稳妥 | 代价可能大,尤其是深拷贝 |
传参方式对比
| 方式 | 适用场景 | 优点 | 缺点 | 工程建议 |
|---|---|---|---|---|
const T& | 只读、不持有 | 通用、稳定、零额外语义负担 | 如果内部要保存,后面可能还要拷贝 | 默认首选只读参数方式 |
T&& | 明确接管资源 | 性能好、意图强 | 调用方式受限、接口更复杂 | 用在移动构造/移动赋值/接管型接口 |
T(传值) | 内部要持有一份副本 | 接口简洁,左值右值都好用 | 左值调用时可能多一次拷贝 | 对“要保存到成员”的场景常很合适 |
移动语义 vs 返回值优化
| 概念 | 本质 | 谁在主导 | 是否依赖类型支持移动 | 面试回答重点 |
|---|---|---|---|---|
| 移动语义 | 转移资源所有权 | 语言机制 + 类型实现 | 是 | “避免深拷贝,把资源交接出去” |
| RVO / NRVO | 省略中间对象构造 | 编译器优化/语言规则 | 否,不一定需要 | “直接构造到目标位置,连移动都省了” |
易错点
std::move不等于真的移动。 它只是把表达式转成右值,能不能移动取决于重载解析和类型能力。- 命名变量永远是左值表达式。 即使它的类型是
T&&,表达式本身仍是左值。 - 移动后对象不是“失效对象”。 它仍然合法,只是值未指定。
- 不要默认认为移动后一定是空。 比如字符串、容器常见实现里看似变空,但标准未要求必须为空。
const对象通常无法真正移动。- 返回局部对象时不要滥用
std::move。 很多情况下会妨碍返回值优化。 - 移动构造/移动赋值最好写
noexcept。 否则容器扩容时可能退回拷贝。 - 有资源管理就要关注 Rule of Five。 如果你自定义了析构、拷贝、移动中的一些操作,通常要整体考虑五大函数的一致性。
- 不是所有类型都值得写移动语义。 对 trivially copyable 的小对象,收益可能非常有限。
- 转发引用和右值引用不能混为一谈。
T&&只有在模板类型推导场景下才可能是转发引用。
记忆技巧
- 左值看身份,右值看临时。 左值像“有身份证的人”,右值像“短暂停留的快递单”。
- 移动不是复制,是“交接钥匙”。 资源没被复制,而是把控制权交给新对象。
std::move是“许可单”,不是搬家公司。 它只是在说:这个对象你可以尝试拿走资源。- 命名后的
T&&也会“落地成左值”。 所以模板转发要靠std::forward恢复身份。 - 返回值优先想 RVO,不要上来就
std::move。
面试速答版
左值右值本质是在区分表达式的值类别。左值有身份、可持续存在,右值通常是临时值;其中将亡值表示对象即将销毁,但资源可以被安全复用。移动语义就是利用右值引用,把原本“深拷贝资源”的过程改成“转移资源所有权”,所以对 string、vector、智能指针这类管理资源的对象特别有效。std::move 本身不会移动资源,它只是把对象转换成右值,真正是否移动要看类型有没有移动构造或移动赋值。移动后源对象仍然有效,但状态通常未指定,只保证可析构和可赋值。工程上要注意 const 会抑制移动,返回局部对象不要乱用 std::move,另外移动操作最好标 noexcept,这样容器扩容时更愿意用移动而不是拷贝。
面试加分版
如果让我系统说,左值右值我会从“值类别”和“资源管理”两层来讲。
第一层是值类别。左值强调对象有身份、能被定位,比如变量、解引用结果;纯右值更像临时计算结果;将亡值则是有身份但资源可以被复用的对象,比如 std::move(x)。这里要注意,左值右值是表达式属性,T& 和 T&& 是类型属性,这两个维度不能混淆。尤其是命名后的右值引用变量,本身仍然是左值表达式。
第二层是移动语义。C++11 引入右值引用后,类型可以为“即将销毁的对象”提供移动构造和移动赋值。这样对于持有堆内存、文件句柄、socket 这类资源的对象,就不用深拷贝资源,而是直接接管资源句柄,所以性能收益很明显。比如 vector 或 string 的移动,很多时候只是转移内部指针和元数据,而不是复制整块内存。
再往下说,std::move 只是一个类型转换,本质是把左值显式转成右值,让编译器有机会匹配移动重载。它本身不负责移动资源。如果类型没有移动构造,或者对象是 const,那 std::move 之后也可能还是拷贝。移动后源对象仍然有效,但一般只保证可析构、可重新赋值,不能依赖它原来的值。
工程实践里我会补充三点。第一,返回局部对象时不要习惯性写 std::move,因为很多场景下编译器能做 RVO/NRVO,甚至 C++17 起很多 prvalue 场景是强制省略拷贝/移动的。第二,移动构造最好标 noexcept,否则标准容器扩容时可能为了异常安全选择拷贝。第三,参数设计要结合场景:只读一般用 const T&,明确接管资源用 T&&,如果函数内部一定要持有副本,现代 C++ 里“按值传参再 std::move 到成员”也常常是很好的接口设计。
一句话总结:左值右值解决的是“对象此刻是什么身份”,移动语义解决的是“资源能不能从一个对象高效交接到另一个对象”。