⚡C++ 语言基础

左值、右值与移动语义

面试回答

常见问法

  • 什么是左值、右值、将亡值(xvalue)?
  • 为什么移动语义能减少拷贝开销?
  • std::move 到底做了什么?它会真的“移动”吗?
  • 移动之后源对象还能不能用?
  • 右值引用、万能引用(转发引用)、完美转发有什么关系?
  • 返回局部对象时,到底是移动还是 RVO?

回答

面试里可以先抓住一句话:

左值强调“身份”和“可持续存在”,右值强调“值本身”或“即将销毁”,移动语义则是把“复制资源”改成“转移资源所有权”。

更准确地说:

  • 左值(lvalue):有身份、能取地址、通常可持续存在的对象 例如:普通变量、解引用结果、返回左值引用的表达式。
  • 纯右值(prvalue):纯粹的值、临时结果 例如:1a + bstd::string("abc") 这样的临时对象。
  • 将亡值(xvalue):有身份,但资源可以被安全复用的对象 例如:std::move(x) 的结果、函数返回的右值引用。

移动语义的核心价值在于:

对于管理资源的对象,拷贝通常意味着重新申请资源并复制内容;移动则是直接“接管”资源句柄,避免深拷贝。

所以它对下面这类类型收益最大:

  • 管理堆内存的对象:std::stringstd::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:返回值时到底是移动还是优化掉了?

这是面试很爱追问的点。

结论分两层:

  1. 返回局部对象时,编译器可能做 RVO/NRVO(返回值优化)
  2. 即便不做优化,也可能走移动构造

从 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. 值类别到底在区分什么?

值类别本质上回答两个问题:

  1. 这个表达式有没有“身份”(identity)?
  2. 这个对象的资源能不能被复用?

可以这样理解:

  • glvalue:有身份,能定位到某个对象

    • 包括 lvaluexvalue
  • 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);

流程是:

  1. s1 是左值
  2. std::move(s1) 把它转成 xvalue
  3. 编译器发现目标类型支持移动构造
  4. 调用移动构造,转移资源

但如果目标类型没有移动构造,或者移动不可用,那仍可能退化成拷贝。

所以面试里最好不要说“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::movex*pvec[0]“我后面还可能继续用它”
纯右值(prvalue)临时值、纯计算结果否/弱化身份通常不关心常可触发移动或直接省略构造1a+bstd::string("abc")“这是一个临时结果”
将亡值(xvalue)即将销毁但有身份可以最适合移动std::move(x)、返回 T&& 的表达式“资源可以安全接管”

std::movestd::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

面试速答版

左值右值本质是在区分表达式的值类别。左值有身份、可持续存在,右值通常是临时值;其中将亡值表示对象即将销毁,但资源可以被安全复用。移动语义就是利用右值引用,把原本“深拷贝资源”的过程改成“转移资源所有权”,所以对 stringvector、智能指针这类管理资源的对象特别有效。std::move 本身不会移动资源,它只是把对象转换成右值,真正是否移动要看类型有没有移动构造或移动赋值。移动后源对象仍然有效,但状态通常未指定,只保证可析构和可赋值。工程上要注意 const 会抑制移动,返回局部对象不要乱用 std::move,另外移动操作最好标 noexcept,这样容器扩容时更愿意用移动而不是拷贝。


面试加分版

如果让我系统说,左值右值我会从“值类别”和“资源管理”两层来讲。

第一层是值类别。左值强调对象有身份、能被定位,比如变量、解引用结果;纯右值更像临时计算结果;将亡值则是有身份但资源可以被复用的对象,比如 std::move(x)。这里要注意,左值右值是表达式属性,T&T&& 是类型属性,这两个维度不能混淆。尤其是命名后的右值引用变量,本身仍然是左值表达式。

第二层是移动语义。C++11 引入右值引用后,类型可以为“即将销毁的对象”提供移动构造和移动赋值。这样对于持有堆内存、文件句柄、socket 这类资源的对象,就不用深拷贝资源,而是直接接管资源句柄,所以性能收益很明显。比如 vectorstring 的移动,很多时候只是转移内部指针和元数据,而不是复制整块内存。

再往下说,std::move 只是一个类型转换,本质是把左值显式转成右值,让编译器有机会匹配移动重载。它本身不负责移动资源。如果类型没有移动构造,或者对象是 const,那 std::move 之后也可能还是拷贝。移动后源对象仍然有效,但一般只保证可析构、可重新赋值,不能依赖它原来的值。

工程实践里我会补充三点。第一,返回局部对象时不要习惯性写 std::move,因为很多场景下编译器能做 RVO/NRVO,甚至 C++17 起很多 prvalue 场景是强制省略拷贝/移动的。第二,移动构造最好标 noexcept,否则标准容器扩容时可能为了异常安全选择拷贝。第三,参数设计要结合场景:只读一般用 const T&,明确接管资源用 T&&,如果函数内部一定要持有副本,现代 C++ 里“按值传参再 std::move 到成员”也常常是很好的接口设计。

一句话总结:左值右值解决的是“对象此刻是什么身份”,移动语义解决的是“资源能不能从一个对象高效交接到另一个对象”。