11、移动语义与容器:std::vector 的移动插入与扩容优化、std::map 的移动节点、std::string 的 COW 与移动

聊到移动语义,很多人第一反应是「哦,就是那个 && 嘛」。但说实话,真正把移动语义和容器结合好,才是 C++11 之后写出高性能代码的关键。我见过不少项目,明明用了 C++17,结果 vector 插入还在老老实实拷贝——那移动语义不是白用了吗?

这一章,我们就来深入看看三个最常用的容器:std::vectorstd::mapstd::string。它们各自在移动语义下有什么「小动作」,又有哪些坑等着你踩。

11.1 std::vector:移动插入与扩容优化

先说说 vector。这是大家最熟悉的容器,但它的移动优化其实藏得挺深。

11.1.1 push_back 与 emplace_back 的移动版本

C++11 之后,push_back 有了右值引用重载:

std::vector<std::string> vec;
std::string s = "hello";
vec.push_back(s);               // 拷贝
vec.push_back(std::move(s));    // 移动
vec.push_back("hello");         // 先构造临时对象,再移动

我个人习惯是:如果对象已经存在,就用 std::move 明确告诉编译器「我不再需要它了」。如果是临时构造,直接传值就好,编译器会自动选择移动路径。

但这里有个细节——emplace_back 呢?它直接原地构造,按理说连移动都省了。没错,但前提是你传的是构造参数,而不是一个现成的对象。如果你写:

vec.emplace_back(std::move(s));  // 还是调用了移动构造

嗯,这其实和 push_back(std::move(s)) 没区别。所以别迷信 emplace_back,它只在传构造参数时才有优势。

11.1.2 扩容时的移动优化

vector 扩容时,会把旧元素搬到新内存。C++11 之前,这全是拷贝。C++11 之后,如果元素类型是 noexcept 可移动构造的,vector 就会用移动代替拷贝。

为什么会这样?因为 vector 需要保证强异常安全。如果移动过程中抛异常,旧数据已经损坏了,回滚都来不及。所以标准库规定:只有 noexcept 的移动构造函数,才能在扩容时使用。

关键点:如果你的类有移动构造函数,记得加上 noexcept。否则 vector 扩容时还是会老老实实拷贝——你想想看,那移动语义不是白写了?

我在项目中遇到过这样一个案例:一个自定义类,移动构造里做了内存转移,但忘了加 noexcept。结果压测时发现性能瓶颈在 vector 扩容上,排查了半天才发现是拷贝在作祟。加上 noexcept 后,性能直接翻倍。

class MyData {
public:
    MyData(MyData&& other) noexcept 
        : ptr_(other.ptr_), size_(other.size_) {
        other.ptr_ = nullptr;
        other.size_ = 0;
    }
    // ...
};

11.1.3 shrink_to_fit 与移动

还有一个冷门用法:用移动语义来收缩 vector 容量。比如:

std::vector<int> vec(1000);
// ... 只用了 10 个元素
std::vector<int>(std::move(vec)).swap(vec);

这行代码干了什么?先构造一个临时 vector,用移动语义接管 vec 的数据,然后 swap 回来。临时 vector 析构时,会把多余的内存释放掉。说白了,就是手动触发一次「移动 + 交换」来收缩容量。

小技巧:C++11 之后,shrink_to_fit 也可能会利用移动语义来优化,但标准不强制。如果你需要确定性的收缩,上面的「移动 + swap」手法更可靠。

11.2 std::map:移动节点

map 和 vector 不同,它是基于节点的容器。移动语义在 map 上的体现,更多是在节点操作上。

11.2.1 insert 与 emplace 的移动版本

map 的 insert 同样支持移动:

std::map<int, std::string> m;
std::string val = "world";
m.insert({1, std::move(val)});   // 移动 value
m.emplace(2, "hello");           // 原地构造

但注意,map 的 key 是 const 的。你不能移动 key,只能移动 value。这其实是个设计上的取舍——如果 key 被移动了,那它在树中的位置就乱了。

11.2.2 extract 与节点句柄

C++17 引入了一个非常强大的功能:extract。它可以从 map 中「摘出」一个节点,返回一个节点句柄。这个句柄可以独立存在,也可以插入到另一个 map 中。

std::map<int, std::string> src{{1, "one"}, {2, "two"}};
std::map<int, std::string> dst;

auto node = src.extract(1);      // 摘出 key=1 的节点
node.key() = 10;                 // 修改 key(注意:非 const 引用!)
dst.insert(std::move(node));     // 插入到 dst

这里有个神奇的地方:node.key() 返回的是非 const 引用。这意味着你可以修改 key!但前提是修改后不破坏 map 的排序。如果你乱改,那后果自负——map 不会重新平衡。

警告:修改 extract 出来的 key 时,一定要保证新 key 在目标 map 中的位置是正确的。否则插入后 map 的排序就坏了,查找会出问题。我曾经在代码 review 时看到有人把 key 改成随机数,差点出事。

节点句柄的移动是 O(1) 的,因为它只是指针的交换。这在需要把元素从一个 map 搬到另一个 map 时,比先 erase 再 insert 高效得多。

11.3 std::string:COW 与移动

string 的情况比较特殊。它历史上经历过 COW(写时复制)和 SSO(小字符串优化)两种实现。移动语义的出现,让 COW 彻底退出了历史舞台。

11.3.1 COW 为什么被抛弃

COW 的核心思想是:多个 string 共享同一块内存,只有在写入时才复制。这在单线程下很美好,但多线程下就麻烦了——每次读取都要加锁,性能反而下降。

C++11 之后,移动语义提供了更优雅的解决方案。移动 string 只是交换指针,O(1) 搞定,比 COW 的引用计数还快。而且没有线程安全问题。

我记得 GCC 的 libstdc++ 在 C++11 之前用的是 COW,C++11 之后改成了 SSO + 移动语义。这个转变花了好几年,因为要保证 ABI 兼容。嗯,历史包袱嘛,大家都懂。

11.3.2 string 的移动构造与移动赋值

现代 string 的移动构造,说白了就是「偷指针」:

std::string a = "hello world, this is a long string";
std::string b = std::move(a);   // b 偷走了 a 的堆内存
// a 现在处于有效但未指定的状态

移动之后,a 变成了空字符串(大多数实现如此),但标准只要求它「有效」。你不能假设它一定是空的,但可以安全地析构或赋值。

对于短字符串(SSO 优化),移动构造可能只是拷贝——因为数据在栈上,移动和拷贝没区别。但这也无所谓,反正 SSO 的字符串本来就很短,拷贝开销可以忽略。

11.3.3 实际项目中的 string 移动

我在项目中经常用 string 移动来避免不必要的拷贝。比如:

std::vector<std::string> lines;
// ... 读取大量行
std::string last = std::move(lines.back());
lines.pop_back();

这样 last 直接接管了 vector 中最后一个元素的堆内存,避免了拷贝。如果你用 last = lines.back(),那就是一次深拷贝,对于大字符串来说很伤。

建议:如果你要从容器中取出一个元素并继续使用,优先用 std::move + pop_backextract。这比拷贝再删除高效得多。

11.4 知识体系总览

下面这张图总结了本章的核心内容,帮你理清三个容器的移动优化路径:

移动语义与容器:核心知识体系 std::vector std::map std::string 移动插入 扩容优化 (noexcept) 移动插入 extract 节点句柄 移动构造/赋值 COW → SSO 核心要点 • vector:移动插入 + noexcept 扩容优化 • map:extract 节点句柄实现 O(1) 节点转移 • string:移动语义取代 COW,SSO 短字符串优化 • 共同原则:noexcept 是移动优化的前提

11.5 避坑指南

最后,分享几个我踩过的坑:

  • 忘记 noexcept:自定义类的移动构造函数不加 noexcept,vector 扩容时就不会用移动。性能直接打回原形。
  • 移动后继续使用:移动后的对象处于有效但未指定状态。别假设它是空的,也别用它做任何有意义的操作——除了析构和赋值。
  • map 的 key 修改:extract 出来的节点可以改 key,但改完后要保证排序正确。否则 map 就废了。
  • string 的 SSO 陷阱:短字符串的移动可能退化为拷贝。别指望移动短 string 一定比拷贝快——它本来就不慢。

我曾经在重构一个旧项目时,把所有的 push_back 都改成了 emplace_back,以为性能会提升。结果发现 vector 里存的是自定义对象,移动构造函数没加 noexcept,扩容时还是拷贝。嗯,那次教训让我记住了:移动优化不是自动的,你得给编译器创造条件。

好了,这一章的内容就到这里。记住一句话:移动语义在容器中的优化,核心在于「避免不必要的拷贝」。不管是 vector 的扩容、map 的节点转移,还是 string 的指针交换,目标都是一样的——能偷懒就偷懒,能 O(1) 就别 O(n)。


公众号:蓝海资料掘金营,微信 deep3321