【C++面试】右值引用与移动语义:std::move到底做了什么

一、左值、右值和右值引用到底是什么

学习移动语义之前,先要知道:

复制代码
左值
右值

大概是什么意思。

先看:

复制代码
int a = 10;

这里:

复制代码
a

是一个:

复制代码
左值

因为它:

复制代码
有名字
有明确的存储位置
可以多次访问

例如:

复制代码
a = 20;
std::cout << a << std::endl;

都没有问题。

而:

复制代码
10

通常可以看成一个右值。

因为:

复制代码
它只是一个临时值
一般不会长期存在

再比如:

复制代码
int a = 10;
int b = 20;

int c = a + b;

这里:

复制代码
a
b
c

都是左值。

而:

复制代码
a + b

产生的临时结果:

复制代码
30

可以看成右值。

所以初学时可以先简单理解:

复制代码
左值
   ↓
有名字、能够找到地址、生命周期较稳定

右值
   ↓
临时值、即将被销毁

左值引用我们已经非常熟悉:

复制代码
int a = 10;

int &ref = a;

这里:

复制代码
int&

就是:

复制代码
左值引用

但是不能这样:

复制代码
int &ref = 10;

因为:

复制代码
普通左值引用
不能直接绑定到临时右值

C++11 引入了:

复制代码
&&

也就是:

复制代码
右值引用

例如:

复制代码
int &&ref = 10;

这里:

复制代码
ref

就是一个右值引用。

再例如:

复制代码
int &&ref = 10 + 20;

也可以。

所以:

复制代码
T&

主要用于绑定:

复制代码
左值

而:

复制代码
T&&

主要用于绑定:

复制代码
右值

例如:

复制代码
void func(int &x) {
    std::cout << "左值引用" << std::endl;
}

void func(int &&x) {
    std::cout << "右值引用" << std::endl;
}

调用:

复制代码
int a = 10;

func(a);
func(20);

输出:

复制代码
左值引用
右值引用

为什么要专门区分右值?

因为右值通常具有一个很重要的特点:

复制代码
它马上就要被销毁了

既然一个对象马上就没用了,那么它内部的资源:

复制代码
是不是可以直接拿走?

这就是移动语义出现的原因。

二、为什么需要移动构造

先看一个简单的类:

复制代码
#include <cstring>
#include <iostream>

class String {
private:
    char *data_;

public:
    explicit String(const char *str = "") {
        size_t len = std::strlen(str);

        data_ = new char[len + 1];

        std::strcpy(data_, str);
    }

    ~String() {
        delete[] data_;
    }
};

这个类内部保存:

复制代码
char *data_;

也就是说,它真正的数据放在:

复制代码
堆内存

里面。

现在如果需要拷贝一个对象:

复制代码
String s1("hello");

String s2 = s1;

如果正确实现深拷贝,拷贝构造大概需要:

复制代码
String(const String &other) {
    size_t len = std::strlen(other.data_);

    data_ = new char[len + 1];

    std::strcpy(data_, other.data_);
}

过程就是:

复制代码
s1

data_
 ↓
"hello"

拷贝:

复制代码
重新申请内存
      ↓
复制hello
      ↓
s2拥有新的内存

最终:

复制代码
s1 ───→ "hello"

s2 ───→ "hello"

虽然内容一样,但是:

复制代码
两块不同的内存

如果字符串非常大,例如:

复制代码
100MB

那么每次拷贝都需要:

复制代码
申请100MB
+
复制100MB

成本就比较高。

但是现在有一个临时对象:

复制代码
String createString() {
    return String("hello");
}

调用:

复制代码
String s = createString();

假设临时对象马上就要销毁。

那么完全没有必要:

复制代码
重新申请一块内存
+
重新复制数据

因为临时对象内部的:

复制代码
data_

马上也没用了。

所以可以:

复制代码
直接把它的data_拿过来

这就是:

复制代码
移动

移动构造可以写成:

复制代码
String(String &&other) noexcept : data_(other.data_) {
    other.data_ = nullptr;
}

原来:

复制代码
临时对象

other.data_
     ↓
  "hello"

执行:

复制代码
data_ = other.data_;

以后:

复制代码
当前对象 ───→ "hello"

临时对象 ───→ "hello"

这时两个对象还指向同一块内存。

所以必须:

复制代码
other.data_ = nullptr;

最终:

复制代码
当前对象
    ↓
 "hello"

临时对象
    ↓
 nullptr

这样临时对象析构时:

复制代码
delete[] nullptr;

是安全的。

而真正的:

复制代码
"hello"

已经交给新的对象管理。

这就是移动构造的核心:

复制代码
不复制资源
而是直接接管资源

所以:

复制代码
拷贝

可以理解成:

复制代码
复制一份资源

而:

复制代码
移动

可以理解成:

复制代码
把原来的资源直接交给别人

三、移动构造和移动赋值怎么写

先来看完整一点的类:

复制代码
#include <cstring>
#include <iostream>
#include <utility>

class String {
private:
    char *data_;

public:
    explicit String(const char *str = "") {
        size_t len = std::strlen(str);

        data_ = new char[len + 1];

        std::strcpy(data_, str);
    }

    ~String() {
        delete[] data_;
    }

    // 拷贝构造
    String(const String &other) {
        size_t len = std::strlen(other.data_);

        data_ = new char[len + 1];

        std::strcpy(data_, other.data_);
    }

    // 移动构造
    String(String &&other) noexcept : data_(other.data_) {
        other.data_ = nullptr;
    }

    void show() const {
        if (data_) {
            std::cout << data_ << std::endl;
        }
    }
};

现在:

复制代码
String s1("hello");

然后:

复制代码
String s2 = s1;

调用的是:

复制代码
拷贝构造

因为:

复制代码
s1

是一个左值。

如果:

复制代码
String s3 = std::move(s1);

就会调用:

复制代码
移动构造

移动之前:

复制代码
s1
 ↓
"hello"

移动以后:

复制代码
s1
 ↓
nullptr

s3
 ↓
"hello"

所以移动以后:

复制代码
s1仍然是一个合法对象

但是:

复制代码
它内部的资源已经被转移

因此一般不应该再依赖它原来的值。


除了移动构造,还需要:

复制代码
移动赋值

例如:

复制代码
String s1("hello");
String s2("world");

s2 = std::move(s1);

赋值之前:

复制代码
s1 → "hello"

s2 → "world"

如果直接:

复制代码
s2.data_ = s1.data_;

那么原来:

复制代码
"world"

这块内存地址就丢失了。

会造成:

复制代码
内存泄漏

所以移动赋值应该先释放自己原来的资源。

复制代码
String &operator=(String &&other) noexcept {
    if (this != &other) {
        delete[] data_;

        data_ = other.data_;
        other.data_ = nullptr;
    }

    return *this;
}

整个过程:

复制代码
s2原来拥有"world"
       ↓
delete[] data_
       ↓
释放world
       ↓
接管s1的"hello"
       ↓
s1.data_ = nullptr

最终:

复制代码
s1 → nullptr

s2 → "hello"

所以可以把移动构造和移动赋值分别记成:

复制代码
移动构造

当前对象本来没有资源
      ↓
直接接管other

移动赋值

当前对象可能已经有资源
      ↓
先释放自己的资源
      ↓
再接管other

完整版本:

复制代码
#include <cstring>
#include <iostream>
#include <utility>

class String {
private:
    char *data_;

public:
    explicit String(const char *str = "") {
        size_t len = std::strlen(str);

        data_ = new char[len + 1];

        std::strcpy(data_, str);

        std::cout << "普通构造" << std::endl;
    }

    ~String() {
        delete[] data_;
    }

    // 拷贝构造
    String(const String &other) {
        size_t len = std::strlen(other.data_);

        data_ = new char[len + 1];

        std::strcpy(data_, other.data_);

        std::cout << "拷贝构造" << std::endl;
    }

    // 移动构造
    String(String &&other) noexcept : data_(other.data_) {
        other.data_ = nullptr;

        std::cout << "移动构造" << std::endl;
    }

    // 拷贝赋值
    String &operator=(const String &other) {
        if (this != &other) {
            char *newData = new char[std::strlen(other.data_) + 1];

            std::strcpy(newData, other.data_);

            delete[] data_;

            data_ = newData;
        }

        return *this;
    }

    // 移动赋值
    String &operator=(String &&other) noexcept {
        if (this != &other) {
            delete[] data_;

            data_ = other.data_;
            other.data_ = nullptr;
        }

        return *this;
    }

    void show() const {
        if (data_) {
            std::cout << data_ << std::endl;
        } else {
            std::cout << "nullptr" << std::endl;
        }
    }
};

int main() {
    String s1("hello");

    // 拷贝
    String s2 = s1;

    // 移动
    String s3 = std::move(s1);

    s2.show();
    s3.show();
    s1.show();

    return 0;
}

这实际上已经涉及:

复制代码
拷贝构造
拷贝赋值
移动构造
移动赋值
析构函数

也就是经常提到的:

复制代码
Rule of Five
五法则

如果一个类自己管理资源,一般需要认真考虑这几个特殊成员函数应该如何实现。

四、std::move到底移动了什么

这是面试中非常容易说错的地方。

很多人会说:

std::move 把对象移动了。

实际上:

std::move 本身并不会移动任何数据。

例如:

复制代码
String s1("hello");

String s2 = std::move(s1);

真正完成资源转移的是:

复制代码
String(String &&other)

也就是:

复制代码
移动构造函数

而:

复制代码
std::move(s1)

真正做的事情,可以先简单理解成:

复制代码
把s1强制转换成一个右值

也就是告诉编译器:

这个对象后面的资源可以被转移。

它的核心思想类似:

复制代码
static_cast<T &&>(obj);

所以:

复制代码
std::move(s1)

本身不会执行:

复制代码
复制
释放
修改内存

它只是完成:

复制代码
类型转换

让后续函数能够匹配:

复制代码
T &&

版本。

例如:

复制代码
void func(const String &s) {
    std::cout << "拷贝版本" << std::endl;
}

void func(String &&s) {
    std::cout << "移动版本" << std::endl;
}

调用:

复制代码
String s("hello");

func(s);

因为:

复制代码
s是左值

所以匹配:

复制代码
const String &

而:

复制代码
func(std::move(s));

std::move(s) 把表达式转换为右值类别,因此更容易匹配:

复制代码
String &&

所以执行:

复制代码
移动版本

因此面试时一定不要说:

复制代码
std::move负责搬内存

更准确的回答是:

std::move 本质上是一个类型转换工具,它把对象转换为可以绑定到右值引用的表达式,本身并不会真正移动资源。真正的资源转移发生在移动构造函数或移动赋值运算符中。

这也是为什么:

复制代码
std::move(x);

单独写一句:

复制代码
std::move(x);

基本不会对:

复制代码
x

产生什么实际效果。

必须有后续操作:

复制代码
T y = std::move(x);

或者:

复制代码
func(std::move(x));

才可能真正触发移动逻辑。

另外要注意:

复制代码
std::move之后
原对象不是"不能再用"

而是:

复制代码
仍然处于合法状态
但具体值通常不应该再依赖

例如:

复制代码
std::string s1 = "hello";
std::string s2 = std::move(s1);

之后 s1 仍然必须满足:

复制代码
可以析构
可以重新赋值

但:

复制代码
s1里面具体还剩什么内容

一般不应该依赖。

所以更准确的表述是:

复制代码
moved-from对象
仍然有效
但状态通常未指定

五、移动语义在实际开发中有什么用

理解移动语义以后,再回头看很多 C++ 代码,就会突然顺很多。

例如前面手写的:

复制代码
unique_ptr

为什么:

复制代码
std::unique_ptr<int> p1(new int(10));
std::unique_ptr<int> p2 = p1;

不允许?

因为:

复制代码
unique_ptr是独占所有权

不能拷贝。

但是可以:

复制代码
std::unique_ptr<int> p2 = std::move(p1);

因为:

复制代码
不是复制资源
而是转移所有权

最终:

复制代码
p1 → nullptr

p2 → int对象

所以:

复制代码
unique_ptr

本身就是移动语义非常典型的应用。


再看前面写过的:

复制代码
vector扩容

vector 空间不足时需要:

复制代码
申请新内存
   ↓
把旧元素搬到新空间

如果对象支持:

复制代码
T(T &&other) noexcept;

那么 vector 可以考虑直接移动元素。

例如:

复制代码
旧vector

Object1
Object2
Object3

扩容:

复制代码
新空间
   ↓
移动Object1
移动Object2
移动Object3

如果对象内部保存大量动态资源:

复制代码
移动指针

会比:

复制代码
重新申请
+
完整复制

高效很多。

这也是为什么移动构造经常写:

复制代码
noexcept

例如:

复制代码
String(String &&other) noexcept;

因为标准容器扩容时需要考虑:

复制代码
异常安全

如果移动构造可能抛异常,而对象又支持拷贝,标准库实现可能更倾向使用:

复制代码
拷贝

以便在异常发生时更容易保证原容器状态。

所以:

复制代码
移动构造 + noexcept

经常一起出现。


最后可以把整个知识链整理成:

复制代码
左值
   ↓
有名字、可长期访问

右值
   ↓
临时对象、即将销毁

右值引用 T&&
   ↓
绑定右值
   ↓
允许我们识别"资源可以被拿走"的对象

移动构造
   ↓
直接接管资源

移动赋值
   ↓
释放自己的旧资源
   ↓
接管对方资源

std::move
   ↓
把表达式转换成适合触发移动语义的右值类别

如果面试官问:

什么是移动语义?

可以这样回答:

移动语义主要用于避免不必要的深拷贝。对于拥有动态资源的对象,如果源对象即将销毁,就可以通过移动构造或移动赋值直接接管它内部的资源,而不是重新申请并复制一份。移动完成后需要把源对象置于合法但通常不再依赖其原值的状态。

如果继续问:

std::move 到底做了什么?

可以回答:

std::move 本身并不移动数据,它本质上是一个类型转换,把对象转换为可以绑定到右值引用的表达式,从而让编译器有机会调用移动构造或者移动赋值。真正的资源转移发生在移动构造函数和移动赋值函数中。

如果继续问:

移动构造和拷贝构造有什么区别?

可以回答:

拷贝构造会为新对象复制一份独立资源,源对象仍然保持原来的资源;移动构造则直接接管源对象的资源,并把源对象对应的资源指针置空或调整到合法状态,因此对于拥有大量动态资源的对象通常更加高效。

所以右值引用与移动语义真正需要理解的不是:

复制代码
&&

这个符号本身,而是:

复制代码
对象马上不用了
      ↓
资源能不能别复制
      ↓
直接转移给另一个对象

这就是现代 C++ 移动语义最核心的思想。

0voice · GitHub

相关推荐
TheITSea42 分钟前
JavaScript Web APIs
开发语言·前端·javascript
空心木偶☜1 小时前
A2A2A(多智能体协作)
开发语言·python·ai·ai编程
红豆诗人1 小时前
C++二叉搜索树:查找、插入和删除为什么这样写
数据结构·c++·算法
Cc.Y1 小时前
Java零基础入门:类与对象深度掌握 —— 从“零散变量“到“打包管理“
java·开发语言·python
\光辉岁月/1 小时前
2.java变量
java·开发语言·变量
夏天的清晨1 小时前
C++入门学习
开发语言·c++·学习
新鲜势力呀1 小时前
PHP 文件上传系统实战:从上传卡顿到分片上传 + 秒传 + 云存储架构完整优化方案
开发语言·架构·php
汉克老师1 小时前
GESP2026年9月认证C++七级( 第二部分判断题(1~10题)精讲
c++·gesp·小学生·学c++编程
Dream it possible!1 小时前
蓝桥杯_翻转_C++
c++·算法·蓝桥杯