在 C++11 之前,对象的传递和赋值往往伴随着深拷贝的代价。例如,一个包含百万级数据的 std::vector,每次拷贝都需要重新分配内存并逐个复制元素,这就像搬运一栋房子里的所有家具,耗时且浪费资源。

C++98/03 时代,我们只能通过 “拷贝” 传递对象 ,哪怕对象是临时的、用完就扔,也要完整复制一份数据,既浪费内存又消耗 CPU。直到 C++11 引入移动语义,这个问题才被彻底解决

看看下面这个例子

String(const String& other) {   data = new char[strlen(other.data) + 1];  // 重新分配内存  strcpy(data, other.data);                 // 逐个字节拷贝数据}

这是一个自定义String类的复制构造函数,调用时需完整复制底层字符数组,进行深拷贝。对于1MB大小的字符串,就意味着1次内存分配和100万次数据拷贝。更严重的是拷贝时data临时对象的"复制-销毁"循环:函数返回大对象时,临时对象先接收拷贝,再拷贝给目标对象,最后立即销毁,意味着两次拷贝都是无效开销

左值和右值的基本概念

在学习移动语义前,必须先理清两个基础概念:左值(lvalue) 和右值(rvalue) ,它们是移动语义的 “基石”。

用最简单的说法就是:

左值指的是能取地址、有名字的 “持久化” 对象,比如变量、数组元素、函数返回的引用等,可以放在=赋值运算符左边

int a = 10;       // a是左值(有名字、能取地址&a)std::string s = "hello"; // s是左值int arr[5] = {1}; // arr[0]是左值

右值指的是不能取地址、临时存在的 “一次性” 对象,比如字面量、临时对象、函数返回的非引用对象等,只能放在=赋值运算符右边

10;               // 字面量,右值(不能取&10)s + "world";      // 临时字符串对象,右值(没有名字)std::string("hi");// 临时对象,右值

冗余拷贝的痛点

C++11之前,当我们要传递右值(比如临时对象)时,编译器会调用拷贝构造函数,把临时对象的数据完整复制一份 。但临时对象用完就被销毁了,这份拷贝完全是 “无用功”。

举个例子:用自定义String类存储字符串,当返回临时对象时,拷贝构造会重复分配内存、复制数据:

// 简单的String类(C++98风格,只有拷贝)class String {private:    char* data; // 指向字符串的指针    size_t len; // 字符串长度public:    // 构造函数:分配内存    String(const char* str) {        len = std::strlen(str);        data = new char[len + 1]; // 多存一个'\0'        std::strcpy(data, str);        std::cout << "构造函数:分配" << len + 1 << "字节\n";    }    // 拷贝构造函数:深拷贝(复制数据)    String(const String& other) {        len = other.len;        data = new char[len + 1]; // 重新分配内存        std::strcpy(data, other.data); // 复制数据        std::cout << "拷贝构造:复制" << len + 1 << "字节\n";    }    // 析构函数:释放内存    ~String() {        if (data) {            delete[] data;            std::cout << "析构函数:释放" << len + 1 << "字节\n";        }    }    // 获取字符串(方便测试)    const char* c_str() const { return data; }};
// 函数返回临时String对象String createString() {    return String("I am a long string..."); // 临时对象(右值)}
int main() {    String s = createString(); // 用临时对象初始化s    return 0;}
注意,这里要关闭编译器的拷贝省略优化,才能看到下面的效果

运行结果:

图片

可以看到这里调用了两次拷贝构造,一次是创建临时对象的拷贝,一次是创建s对象的拷贝,而且临时对象的内存刚分配,就被拷贝了一份,然后自己被销毁了,所以说这12 字节的分配、复制、释放完全是冗余的!

如果字符串更长(比如 1MB),性能损耗会非常明显。虽然现代编译器可以对以上情况进行优化,从而避免调用拷贝构造,但这并不意味着所有程序员都能意识到这段代码本身是存在效率问题的,也不是所有场景下编译器都能帮我们做优化

右值引用:移动语义的 “钥匙”

C++11 为了解决这个问题,引入了右值引用(用&&表示)—— 它专门用来绑定右值(临时对象),允许我们 “拿走” 右值的资源,而不是 “复制”。

右值引用的核心特点:

  • 只能绑定右值,不能绑定左值(除非用std::move转换);

  • 绑定后,右值的生命周期会延长到与引用相同。

比如:

// 正确:右值引用绑定临时对象(右值)String&& temp = String("hello"); // 错误:不能绑定左值(s是左值)// String&& temp2 = s; 

有了右值引用,我们就能实现 “移动” 而非 “拷贝”—— 这就是移动语义的核心。

移动语义的本质是资源控制权的转移:新对象获得资源完全控制权,原对象放弃所有权并置空资源指针。这区别于浅拷贝的"共享所有权",避免了悬垂指针风险。

移动构造函数与移动赋值运算符

移动语义主要通过两个特殊成员函数实现:移动构造函数移动赋值运算符。它们的参数都是 “右值引用”,作用是 “窃取” 源对象(右值)的资源,而非复制。

  • 移动构造函数:“偷” 资源的构造

定义格式

移动构造函数的参数是 “非 const 的右值引用”(因为要修改源对象,把它的资源置空):

给前面的String类添加移动构造函数,对比拷贝和移动的区别:

class String {private:    char* data;    size_t len;public:    // 构造函数    String(const char* str) {        len = std::strlen(str);        data = new char[len + 1];        std::strcpy(data, str);        std::cout << "构造函数:分配" << len + 1 << "字节\n";    }    // 拷贝构造函数(深拷贝)    String(const String& other) {        len = other.len;        data = new char[len + 1];        std::strcpy(data, other.data);        std::cout << "拷贝构造:复制" << len + 1 << "字节\n";    }    // 移动构造函数:窃取资源    String(String&& other) { // 右值引用参数        // 1. 窃取other的资源        this->len = other.len;        this->data = other.data; // 直接指向other的内存,不分配新内存        // 2. 把other的资源置空(关键!避免析构时重复释放)        other.len = 0;        other.data = nullptr;        std::cout << "移动构造:转移" << this->len + 1 << "字节\n";    }    // 析构函数()    ~String() {        if (data) {            delete[] data;            std::cout << "析构函数:释放" << len + 1 << "字节\n";        } else {            std::cout << "析构函数:空对象,无需释放\n";        }    }
};
int main() {    String s = createString(); // 用临时对象初始化s    std::cout << "s的内容:" << s.c_str() << "\n";    return 0;}

运行结果:

图片

看看,没有再调用拷贝构造函数了,而是调用的移动构造,无需再多次拷贝了,这样就可极大的提升效率

要特别注意的是,旧对象的资源控制权被转移给新对象后,一定要把旧对象的资源置空,以免析构时重复释放。

  • 移动赋值运算符:“偷” 资源的赋值

移动构造是 “初始化新对象” 时用的,而移动赋值运算符是 “给已存在的对象赋值” 时用的。

定义格式

同样需要右值引用参数,且要处理 “自我赋值” 和 “释放当前资源”:

还是结合之前的例子,给String类添加移动赋值运算符函数

class String {    // ... 其他成员(构造、拷贝构造、析构)省略 ...
    String(): data(nullptr), len(0){}; // 默认构造函数     //移动赋值运算符    String& operator=(String&& other) {        std::cout << "移动赋值:开始窃取资源\n";        if (this != &other) { // 避免自我赋值            // 释放当前对象的旧资源            delete[] this->data;            // 窃取other的资源            this->len = other.len;            this->data = other.data;            // 置空other            other.len = 0;            other.data = nullptr;        }        return *this;    }};

测试移动赋值
int main() {    String s;  //创建一个空对象s    s = String("new value"); //用临时对象给s移动赋值    return 0;}

运行结果:

图片


可以看到:s 的旧资源(空,无需释放)被处理后,直接 “偷” 了临时对象的资源,没有调用拷贝函数。

  • 关键工具:std::move—— 把左值 “伪装” 成右值

右值引用只能绑定右值,但如果我们想 “移动” 一个左值(比如一个不再使用的变量),该怎么办?C++ 提供了std::move函数(定义在<utility>头文件中),它的作用很简单:把左值转换为右值引用,让编译器误以为它是右值,从而调用移动语义。

注意:std::move 不 “移动”,只 “转换类型”

std::move本身不会移动任何资源,它只是告诉编译器:“这个左值我不用了,你可以把它当右值处理,偷它的资源吧”。

代码示例:用 std::move 移动左值
#include <utility> // 包含std::moveint main() {    String s1("I will be moved"); // 左值s1    std::cout << "s1的内容:" << s1.c_str() << "\n";    // 用std::move把s1转为右值引用,调用移动构造    String s2 = std::move(s1);     // 注意:s1此时已被“掏空”,不能再使用它的资源!    std::cout << "s2的内容:" << s2.c_str() << "\n";    // std::cout << s1.c_str(); // 错误!s1的data是nullptr,会崩溃    return 0;}

运行结果:


图片

注意:移动后的源对象状态

移动后,源对象(比如上面的 s1)会变成 “空对象”(资源指针为 nullptr),此时可以做的是:

  • 可以调用它的析构函数(安全,因为析构会判断是否为空);

  • 可以重新赋值(比如s1 = String("new data"));

但是绝对不能访问它的资源,比如调用c_str()),否则会导致未定义行为(崩溃、乱码等)。

什么时候该用移动语义?

移动语义不是所有场合都需要用的,它主要适用于有 “可转移资源” 的类(比如包含指针、动态数组、文件句柄、网络连接等资源的类)。以下是几个典型使用场景:

场景 1:函数返回大对象

当函数返回一个大对象(比如 vector、string、自定义的大数据类)时,移动语义可以避免拷贝。这个之前已经举例提过了

但是这里额外再提一下,现代编译器(C++11 及以后)会使用"返回值优化(RVO/NRVO)"和拷贝省略(Copy Elision)技术,对返回的临时对象不再进行拷贝。但我们不能完全依赖于编译器的优化。要知道移动语义是 “保底”技术—— 当优化不生效时,依然能避免拷贝。

场景 2:STL 容器存储大对象

STL 容器(vector、list、map 等)在插入、扩容时,会调用对象的构造 / 赋值函数。如果存储的是大对象,用移动语义能显著提升性能。

场景 3:智能指针的所有权转移

C++11 的智能指针中,unique_ptr是 “独占所有权” 的 ,它不能被拷贝,只能通过移动语义转移所有权。

如下面例子

#include <memory> // 包含unique_ptrint main() {    // 创建unique_ptr,指向一个int    std::unique_ptr<int> p1 = std::make_unique<int>(10);         std::unique_ptr<int> p2 = p1;  // 错误!unique_ptr不能拷贝
     // 正确:用std::move移动所有权    std::unique_ptr<int> p2 = std::move(p1);     // p1已空,p2拥有资源    std::cout << *p2 << "\n"; // 输出10    std::cout << *p1; // 错误!p1为空    return 0;}

这是移动语义的经典应用 ,通过 “转移所有权”,保证资源不被重复释放,同时避免拷贝。

使用移动语义的注意事项

  • 对移动构造 / 赋值加 noexcept

限定符noexcept表示函数不会抛出异常。STL 容器(比如 vector)在扩容时,如果移动构造函数是noexcept修饰的,会选择移动;如果不是,会退化为拷贝(因为移动过程中抛异常会导致资源泄漏,而拷贝更安全)。

所以,最好在移动构造和移动赋值时加上noexcept,对STL容器操作时则必须加上noexcept

// 推荐:加noexceptString(String&& other) noexcept { ... }String& operator=(String&& other) noexcept { ... }
  • 不要滥用 std::move

对局部变量返回时,不用加std::move, 编译器会自动优化(RVO),加了反而可能阻止优化:

// 错误:多余的std::move,可能阻止RVOString createString() {    String s("hello");    return std::move(s); // 无需加,直接return s即可}

另外对还会使用的左值,不要用std::move,否则会 “掏空” 它,导致后续使用时出错。

总结

移动语义是 C++11 最核心的特性之一,它的本质是 “转移资源所有权”,而非 “复制数据”,从而解决了冗余拷贝的性能问题。

  • 核心工具

    右值引用(&&)、移动构造函数、移动赋值运算符、std::move;

  • 适用场景

    有动态资源的类(指针、容器、智能指针等)、函数返回大对象、STL 容器插入;

  • 关键原则

    移动后源对象要置空、加noexcept、不滥用std::move。

掌握移动语义,能让你的 C++ 代码更高效、更优雅。通过移动语义,C++实现了从"复制一切"到"高效转移"的跨越,在保证安全的同时将性能提升数倍。因此要好好学习

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐