CC++ & Algorithm

移动语义与emplace系列操作

困难5
语言版本:通用
概述:学习如何用“搬东西”的方式优化代码,避免不必要的复制,以及直接在容器中构造对象,让程序更快更高效。

搬新家啦!C++移动语义和emplace系列让程序跑得更快

先说说这是什么东西、能干什么

你有没有遇到过这样的烦恼:写了一个程序,往一个大容器(比如 vector)里不断添加东西,程序跑着跑着就变慢了,甚至卡住?有时候明明只是换个地方放东西,却要复制一份一模一样的出来,浪费时间和内存。这就是C++中拷贝操作带来的性能损失。

移动语义emplace系列操作 就是专门用来解决这个问题的两个“法宝”:

  • 移动语义:好比搬家时直接把整箱书搬到新书房,而不是把书一本本复印一遍再搬。C++允许我们把一个对象“偷来”的资源(比如动态分配的内存、打开的文件句柄)直接转移给另一个对象,省去拷贝开销。
  • emplace系列:好比你直接在书房里组装新家具,而不需要在客厅组装好再搬进去。STL容器提供的 emplace_backemplace 等函数,允许你直接在容器内存中构造对象,连临时对象都省了。

这两个功能在C++11中引入,是信息学竞赛和高效编程中提升性能的利器。今天我们就来把它们彻底搞明白。


为什么要用移动语义?—— 生活中的“搬家”类比

想象一下你要从书桌上把一本厚厚的词典放进书包带去学校。通常有两种方式:

方式现实类比C++对应
拷贝你先把整本词典一页一页复印出来,然后把复印本放进书包,再把原词典留在书桌上(最后可能扔掉)拷贝构造函数分配新内存,复制所有数据
移动你直接拿起原词典放进书包,书桌上就不再有词典了(原位置空了)移动构造函数把指针“偷”过来,原对象置空

哪个更快? 当然是移动!移动只需要改几个指针(就像把书从一个地方举到另一个地方),而拷贝需要把整个词典的内容重新写一遍(如果词典有一千页,拷贝就要写一千页)。

在C++里,像 std::stringstd::vector 这类拥有动态内存的对象,拷贝起来非常耗时。移动语义就是让我们能“小偷”般地把资源夺过来,让程序跑得更快。


移动语义的工作原理

1. 左值和右值 —— 分清楚“有名有姓”和“临时过客”

C++中每个表达式都有个“身份”:

  • 左值(lvalue):有名字、可以取地址的表达式。比如 int a = 10; 中的 a 是左值,你可以用 &a 拿到它的地址。
  • 右值(rvalue):没有名字、即将被销毁的临时对象或字面量。比如 10a + b 的结果、函数返回的非引用临时对象。你不能对 10 取地址(&10 是错的)。

移动语义的核心就是 右值引用,用 && 表示。右值引用只能绑定到右值上,也就是说,它专门用来“抓住”那些即将销毁的临时对象。

2. std::move 并不是真的移动

很多人误以为 std::move 会移动对象——其实它什么也不移动,只是把一个左值“伪装”成右值引用,告诉编译器:“这个对象我可以偷走它的资源,因为它以后不再需要了。” 比如:

std::string str = "hello";
std::string str2 = std::move(str);  // 把str伪装成右值,触发移动构造函数
// 现在str变成了空字符串(但处于有效状态)

std::move 就像给原对象贴了一张“可丢弃”的标签,让后续操作可以“抢劫”它的资源。

3. 移动构造函数和移动赋值运算符 —— 具体的“偷资源”方法

C++11引入了带有右值引用参数的构造函数和赋值运算符:

class MyString {
    // 移动构造函数:参数是右值引用
    MyString(MyString&& other) noexcept {
        data = other.data;   // 偷指针
        size = other.size;
        other.data = nullptr; // 原对象置空
        other.size = 0;
    }
    // 移动赋值运算符
    MyString& operator=(MyString&& other) noexcept {
        if (this != &other) {
            delete[] data;         // 释放自己的旧资源
            data = other.data;     // 偷资源
            size = other.size;
            other.data = nullptr;  // 原对象置空
            other.size = 0;
        }
        return *this;
    }
};

注意:noexcept 关键字告诉编译器这个函数不会抛出异常,这样STL容器(比如 vector)会优先使用移动而不是拷贝。


emplace系列操作 —— 直接在容器里“组装”对象

我们已经知道移动可以避免拷贝临时对象,但有时候我们连临时对象都不想创建。比如往 vector 里放一个 std::pair<int, std::string>,传统做法:

std::vector<std::pair<int, std::string>> v;
v.push_back(std::make_pair(1, "hello"));
// 流程:先构造一个临时pair -> 拷贝/移动到vector -> 销毁临时pair

emplace_back 允许你直接传入构造函数的参数,在 vector 内部的内存中构造对象,不需要临时pair:

v.emplace_back(1, "hello");  // 直接在vector末尾构造pair

不只是 emplace_back,还有 emplace(在指定位置构造)、emplace_front(在链表头部构造)等。它们通常比对应的 push_backinsertpush_front 更高效。

什么时候用 emplace?
任何时候你打算往容器里放一个临时构造的对象,都可以考虑用 emplace。例如:

// 不好:先构造临时 string,再拷贝/移动到 vector
std::vector<std::string> words;
words.push_back(std::string("apple"));

// 好:直接在 vector 内部构造,省掉临时对象
words.emplace_back("apple");

新手容易犯的错误

  1. 移动后继续使用原对象
    移动后的对象处于“有效但未指定”的状态,你不能假设它的值是什么。比如:

    std::string a = "hello";
    std::string b = std::move(a);
    std::cout << a;  // 危险!a可能是空字符串,也可能是其他值
    

    正确做法:移动后不应再访问原对象(除非重新给它赋值)。

  2. 忘记实现 noexcept
    移动构造函数和移动赋值运算符如果可能抛出异常,STL容器(比如 vector 在扩容时)可能选择使用拷贝来保证安全性。加上 noexcept 能触发更高效的移动。

  3. emplace 参数写错
    emplace_back 的参数必须与容器内类型的某个构造函数匹配。例如 vector<pair<int,string>> 需要传两个参数(int 和 string),如果传一个 pair 对象反而会调用拷贝构造:

    v.emplace_back(1, "ok");       // 正确:直接构造pair
    v.emplace_back(make_pair(1, "ok")); // 错误!这会先构造临时pair,再拷贝/移动
    

    注意:如果传一个现成的对象,emplace 就会退化为拷贝/移动,失去优势。

  4. 对基本类型使用 std::move
    基本类型(int, char, double等)移动和拷贝没有区别,因为不涉及动态资源。对 int a = 10; int b = std::move(a); 仍然是拷贝,没有性能提升。

  5. 忘记包含头文件
    std::move<utility> 中,emplace_back 等方法在容器头文件(如 <vector>)中。记得包含对应头文件。


完整可运行的代码示例

下面是一个完整的 C++ 程序,演示了 MyString 类如何实现移动语义,以及 emplace_backpush_back 的对比。代码中每行变量定义都加了中文注释。

#include <iostream>
#include <vector>
#include <cstring>
#include <utility>  // for std::move

class MyString {
private:
    char* data;      // 指向动态分配的字符数组
    size_t size;     // 字符串长度(不含结尾'\0')
public:
    // 构造函数:从C风格字符串创建
    MyString(const char* str) {
        size = strlen(str);
        data = new char[size + 1];
        strcpy(data, str);
        std::cout << "构造: " << data << std::endl;
    }

    // 拷贝构造函数:深拷贝
    MyString(const MyString& other) {
        size = other.size;
        data = new char[size + 1];
        strcpy(data, other.data);
        std::cout << "拷贝构造: " << data << std::endl;
    }

    // 移动构造函数:偷走 other 的资源
    MyString(MyString&& other) noexcept {
        data = other.data;          // 直接偷指针
        size = other.size;
        other.data = nullptr;      // 原对象置空
        other.size = 0;
        std::cout << "移动构造: " << (data ? data : "(空)") << std::endl;
    }

    // 拷贝赋值运算符
    MyString& operator=(const MyString& other) {
        if (this != &other) {
            delete[] data;              // 释放自己原有的内存
            size = other.size;
            data = new char[size + 1];
            strcpy(data, other.data);
            std::cout << "拷贝赋值: " << data << std::endl;
        }
        return *this;
    }

    // 移动赋值运算符
    MyString& operator=(MyString&& other) noexcept {
        if (this != &other) {
            delete[] data;              // 释放自己的旧资源
            data = other.data;          // 偷资源
            size = other.size;
            other.data = nullptr;       // 原对象置空
            other.size = 0;
            std::cout << "移动赋值: " << (data ? data : "(空)") << std::endl;
        }
        return *this;
    }

    // 析构函数
    ~MyString() {
        delete[] data;
    }

    void print() const {
        if (data) std::cout << data;
        else std::cout << "(空)";
    }
};

int main() {
    std::vector<MyString> v;  // 存储 MyString 的 vector

    std::cout << "--- 使用 push_back 传入临时对象 ---" << std::endl;
    v.push_back(MyString("Hello"));
    // 输出:构造: Hello → 移动构造: Hello → 析构临时对象
    // 注意:这里调用了移动构造(因为 MyString("Hello") 是右值)

    std::cout << "\n--- 使用 emplace_back 直接在容器内构造 ---" << std::endl;
    v.emplace_back("World");
    // 输出:构造: World  (只有一次构造,没有临时对象)

    std::cout << "\n--- 使用 push_back 并显式 std::move 左值 ---" << std::endl;
    MyString str("MoveMe");
    v.push_back(std::move(str));  // 把左值转成右值,触发移动构造
    std::cout << "移动后 str 是: ";
    str.print();
    std::cout << std::endl;
    // 输出:构造: MoveMe → 移动构造: MoveMe → 移动后 str 是: (空)

    std::cout << "\n--- 验证 vector 中的内容 ---" << std::endl;
    for (const auto& s : v) {
        s.print();
        std::cout << " ";
    }
    std::cout << std::endl;

    return 0;
}

运行结果分析

  • push_back(MyString("Hello")) 先构造临时对象,然后移动构造到 vector,最后析构临时对象(两次构造函数+一次析构)。
  • emplace_back("World") 只调用一次构造函数,直接在 vector 内部构造,没有临时对象。
  • std::move(str) 将左值变为右值,触发移动构造,转移资源后 str 变为空。

通过观察输出可以直观感受到 emplace_back 的性能优势。


相关知识点指引

如果你已经掌握了移动语义和 emplace,接下来可以继续学习:

  • 右值引用与完美转发std::forward 和模板中的 T&&,用于编写泛型函数时保持参数的值类别。
  • 引用折叠T& &T& && 等规则,是理解完美转发的基础。
  • RAII(资源获取即初始化):移动语义是 RAII 的重要扩展,确保资源正确释放。
  • 拷贝省略(Copy Elision):C++17 保证的返回值优化(RVO),也能避免拷贝,但和移动语义是不同机制。
  • STL 容器的性能优化:比如 vectorreserveemplace_back 配合,大幅减少扩容次数。

在信息学竞赛中,很多时候直接用 emplace_back 替换 push_back 就能获得可观的性能提升,尤其是存储 pairstring 等复杂对象时。理解并善用这些特性,让你的程序既快又优雅。

例题精讲

1单选题

在C++中,如果一个类同时定义了拷贝构造函数和移动构造函数,使用std::move()将一个左值对象传递给push_back时,容器会调用哪个函数?

A拷贝构造函数
B移动构造函数
C先拷贝后移动
D默认构造函数
2单选题

关于std::vector的emplace_back和push_back,以下说法正确的是?

Apush_back总是比emplace_back高效
Bemplace_back可以直接在容器内存中构造对象,无需移动或拷贝临时对象
Cemplace_back只能用于包含简单类型的容器
Dpush_back和emplace_back的功能完全相同
3判断题

移动语义仅适用于动态分配的资源(如堆内存),对于栈上的数据(如int、double)没有意义。

4填空题
实现一个简化版MyString类的移动构造函数,请补充横线处的代码。

class MyString {
private:
    char* data_;
    size_t size_;
public:
    MyString(MyString&& other) noexcept
        : data_(other.data_), size_(other.size_)
    {
        other.data_ = ___;
        other.size_ = 0;
    }
};
5填空题
使用std::vector存储自定义类型Obj,Obj有构造函数Obj(int, double)。请用一行代码直接在vector末尾构造一个Obj对象,参数为42和3.14。

std::vector<Obj> vec;
vec.___(42, 3.14);