Skip to content

vector 扩容:到底是拷贝还是移动? ​

这是 STL 里最容易被"背答案"背错的高频考点。很多人答"扩容就是把元素 move 过去",不全对——标准规定:扩容时旧元素是移动还是拷贝,由 std::move_if_noexcept 决定。

一、结论先行(TL;DR) ​

vector 触发 reallocation(容量不足、需要搬迁到新内存)时,旧元素的搬迁方式:

条件搬迁方式
T 的移动构造函数是 noexcept(is_nothrow_move_constructible_v<T> 为真)移动
T 不可拷贝(is_copy_constructible_v<T> 为假)移动(异常后果 unspecified)
其余情况(可拷贝 且 移动可能抛异常)拷贝
T 是平凡可拷贝(trivially copyable)位块搬移(memmove/memcpy),既不调拷贝也不调移动

一句话记忆:"移动安全(noexcept)就移动,不安全但有拷贝就用拷贝,连拷贝都没有才硬移动"。这正是 std::move_if_noexcept 的语义。

二、决策流程(先判什么,再判什么) ​

判定优先级:① 能不能位块搬(平凡)→ ② 移动安不安全(noexcept)→ ③ 有没有拷贝兜底。

text
扩容搬迁旧元素
  │
  ├─ Trivially Copyable? ── 是 ─▶ memmove 位块搬移         🚀 极快
  └─ 否
     ├─ move 是 noexcept? ── 是 ─▶ 移动构造                🛵 良好
     └─ 否
        ├─ 可拷贝? ── 是 ─▶ 拷贝构造(保强异常保证)       🐌 缓慢
        └─ 否 ───────────▶ 只能移动(effects unspecified)  ⚡ 别无选择

决策在编译期由标准库(vector 实现)用类型萃取完成,无运行时开销;入口是 std::move_if_noexcept。 注意:move 非 noexcept 时不是无条件拷贝——还要看是否可拷贝;不可拷贝的类型只能移动(标准允许,异常后果 unspecified)。

类型特征扩容行为性能为什么
is_trivially_copyablememmove 位块搬移🚀 极快纯内存复制,不调构造/析构
非 trivial,move 是 noexcept移动构造循环🛵 良好所有权转移(指针交换),抛不出、安全
非 trivial,move 可能抛 且可拷贝拷贝构造循环🐌 缓慢源对象不敢动,保证扩容失败可回滚
非 trivial,move 可能抛 且不可拷贝移动构造循环⚡ 别无选择无拷贝可用,只能移动

三、标准原文(出处) ​

1) std::move_if_noexcept 的定义 —— 见 C++ draft [utility]:

cpp
template<class T>
constexpr conditional_t<
    !is_nothrow_move_constructible_v<T> && is_copy_constructible_v<T>,
    const T&, T&&>
move_if_noexcept(T& x) noexcept;

即:当「移动可能抛异常 且 类型可拷贝」时返回 const T&(走拷贝),否则返回 T&&(走移动)。

2) vector 单个元素插入在异常下的保证 —— 见 C++ draft [vector.modifiers](§2):

If an exception is thrown while inserting a single element at the end and T is Cpp17CopyInsertable or is_nothrow_move_constructible_v<T> is true, there are no effects. Otherwise, if an exception is thrown by the move constructor of a non-Cpp17CopyInsertable T, the effects are unspecified.

翻译与解读:

  • 插入(含扩容搬迁)过程中抛异常时,只要 T 可拷贝(能回滚)或 move 是 noexcept(不会抛),就保证 "no effects"(强异常保证,如同什么都没发生);
  • 只有当 T 既不可拷贝、move 又可能抛时,才出现 unspecified(不可回滚),此时实现只能移动。

四、为什么要这么设计(核心在"异常安全") ​

扩容搬迁是个**"全有或全无"**的操作:新内存已分配、旧元素要逐个搬过去。若搬到一半抛异常:

  • 用拷贝:源对象保持完好 → 可以回滚,保证 reallocation 的强异常保证(strong exception safety);
  • 用移动:已被搬过的源对象处于"有效但未指定"(被掏空)状态 → 无法回滚,强保证被破坏。

所以标准层面对 vector 的取舍是:

  1. 能安全移动就移动——移动通常远比拷贝便宜(如 string、vector、unique_ptr),性能优先;
  2. 移动可能抛 且 可拷贝 → 退化为拷贝——用一点点性能换取异常安全;
  3. 不可拷贝则只能移动——此时放弃强保证(标准写明 effects unspecified),因为别无选择。

⚠️ 实操坑:一个"移动很便宜、但忘了标 noexcept"的类型,在扩容时会被拷贝。给移动构造/移动赋值加 noexcept(在确实不抛的前提下)是实打实的性能优化。

五、Trivial(平凡类型)扮演什么角色 ​

  • 平凡类型的移动必然是 noexcept:trivial move constructor 一定 noexcept,所以它天然落在"移动"分支。
  • 实现还会再进一步优化:对 is_trivially_copyable 的类型,libstdc++/libc++ 在搬迁时走位块搬移(memmove/std::uninitialized_copy 的 trivial 快路径),既不算拷贝构造也不算移动构造。
  • 这是因为平凡类型拷贝与移动的位模式完全相同,"搬到新地址"就是"复制字节"。
  • 相关概念 "trivially relocatable"(可平凡重定位)目前仍是非标准提案(P1144),但各大实现已在按 is_trivially_copyable 做优化。

判断口诀:is_trivially_copyable_v<T> 为真 → 扩容时基本等价于 memmove,开销最小。

六、怎么判断:一段可运行的验证代码 ​

cpp
#include <cstdio>
#include <type_traits>
#include <utility>
#include <vector>

struct Plain { int a[16]; };                    // 平凡类型

struct CopyOnly {                               // 可拷贝、move 非 noexcept
    int v;
    CopyOnly(int x=0):v(x){}
    CopyOnly(const CopyOnly& o):v(o.v){ std::puts("COPY ctor"); }
    CopyOnly(CopyOnly&& o):v(o.v){ std::puts("MOVE ctor"); }   // 未标 noexcept
};

struct Movable {                                // 可拷贝、move 是 noexcept
    int v;
    Movable(int x=0):v(x){}
    Movable(const Movable& o):v(o.v){ std::puts("COPY ctor"); }
    Movable(Movable&& o) noexcept :v(o.v){ std::puts("MOVE ctor"); }
};

struct NoCopy {                                 // 不可拷贝、move 非 noexcept
    int v;
    NoCopy(int x=0):v(x){}
    NoCopy(const NoCopy&) = delete;
    NoCopy(NoCopy&& o):v(o.v){ std::puts("MOVE ctor"); }
};

int main() {
    // 静态断言:直接看 move_if_noexcept 的推导结果类型
    static_assert(std::is_same_v<
        decltype(std::move_if_noexcept(std::declval<CopyOnly&>())), const CopyOnly&>);
    static_assert(std::is_same_v<
        decltype(std::move_if_noexcept(std::declval<Movable&>())),  Movable&&>);
    static_assert(std::is_same_v<
        decltype(std::move_if_noexcept(std::declval<NoCopy&>())),   NoCopy&&>);

    std::vector<CopyOnly> a; a.reserve(1);
    a.emplace_back(0); std::puts("-- grow CopyOnly --");
    a.emplace_back(1);   // 扩容:预期打印 COPY ctor

    std::vector<Movable> b; b.reserve(1);
    b.emplace_back(0); std::puts("-- grow Movable --");
    b.emplace_back(1);   // 扩容:预期打印 MOVE ctor
}

预期输出(gcc/clang):

  • CopyOnly 扩容 → COPY ctor(因为 move 非 noexcept 且有拷贝)
  • Movable 扩容 → MOVE ctor
  • std::vector<Plain> 扩容 → 什么都不打印(trivial 位块搬移)

七、扩容策略与相关考点(顺带) ​

  • 增长因子:libstdc++ 用 2×,MSVC 用 1.5×(1.5 更易复用此前释放的内存块,减少虚拟地址碎片;2× 增长更快、拷贝更少)。无论哪个,push_back 均摊复杂度都是 O(1)。
  • 搬迁代价:reallocation 的复杂度是线性(要搬 N 个元素),但平摊到每次 push_back 是常数。
  • reserve / resize / shrink_to_fit:reserve 一次到位避免多次扩容;shrink_to_fit 是非强制请求。
  • 迭代器/引用失效:reallocation 会使整个容器的迭代器、指针、引用失效(含 end());不发生 reallocation 的插入只使插入点之后失效。
  • 哪个容器"搬元素":只有 vector(及 string 的扩容)会在扩容时搬迁元素;deque/map/unordered_map 扩容(rehash)时元素节点原地不动(只改指针/桶),不涉及该规则。

八、高频问答 ​

Q1. vector 扩容时,元素到底是拷贝还是移动?怎么判断?

答案

由 std::move_if_noexcept 决定:move 构造是 noexcept,或类型不可拷贝 → 移动;可拷贝但 move 可能抛 → 拷贝。 即 !is_nothrow_move_constructible_v<T> && is_copy_constructible_v<T> 时走拷贝,否则走移动。

Q2. 为什么这么设计?和 noexcept / 异常处理有关吗?

答案

有关,核心是强异常保证。搬迁要"全有或全无":拷贝时源对象不变、可回滚;移动时源已被掏空、无法回滚。所以"移动可能抛且能拷贝"就退化为拷贝以保安全;"move 是 noexcept"则可放心移动;"不可拷贝"只能移动、放弃强保证(标准写 unspecified)。

Q3. Trivial 类型扮演什么角色?

答案

平凡类型的 move 必 noexcept,天然走移动;更进一步,trivially copyable 的类型由实现用 memmove/memcpy 位块搬移,连构造都不调用("trivially relocatable" 优化,标准尚未正式命名)。

Q4. 我明明写了移动构造函数,为什么扩容还是调用拷贝?

答案

大概率是移动构造没标 noexcept。只要它可能抛异常、且类型又可拷贝,move_if_noexcept 就返回 const T& 走拷贝。给它(在确实不抛时)加 noexcept 即可让扩容走移动。

Q5. std::move_if_noexcept 的定义是什么?

答案
cpp
template<class T>
constexpr conditional_t<
  !is_nothrow_move_constructible_v<T> && is_copy_constructible_v<T>,
  const T&, T&&>
move_if_noexcept(T& x) noexcept;

"移动可能抛 且 可拷贝" → 返回 const T&(拷贝),否则返回 T&&(移动)。

Q6. deque / unordered_map 扩容会移动元素吗?

答案

不会(就这条规则而言)。deque 分段存储、扩容只是增加中控 map 的段指针;unordered_map rehash 只重排桶指针,节点原地不动。真正在扩容时"搬元素"的典型是 vector(和 string 的扩容)。

Q7. 扩容因子为什么常见 2 或 1.5?

答案

都要保证 push_back 均摊 O(1)。1.5×(MSVC)更易复用之前释放的内存块、减少地址空间碎片;2×(libstdc++)增长更快、搬迁次数更少。固定增量则是每次 O(N),退化成均摊 O(N)。

Q8. 怎么让自己的类型在扩容时走移动、且不破坏异常安全?

答案

在移动构造/移动赋值确实不抛的前提下标 noexcept(并遵守 Rule of Five/Zero)。这样既拿到移动的性能,又让 vector 的强异常保证生效。

九、八股速答(面试问法 → 一句话) ​

面试问法一句话答案
vector 扩容是拷贝还是移动?看 move_if_noexcept:move 是 noexcept 或不可拷贝 → 移动,否则拷贝
在哪一步决定?编译期由库用类型萃取决定,入口 std::move_if_noexcept
为什么不是一律移动?移动可能抛异常时无法回滚,会破坏强异常保证
我写了移动构造为什么还拷贝?移动构造没标 noexcept(且类型可拷贝)
平凡类型扩容做了什么?memmove 位块搬移,不调构造/析构
怎么让扩容走移动?移动构造/移动赋值标 noexcept
不可拷贝类型扩容呢?只能移动,异常后果 unspecified
扩容的时间复杂度?单次线性,均摊到 push_back 是 O(1)
string 也遵循吗?是的,扩容搬迁同理(SSO 时不涉及堆搬迁)
is_trivially_copyable 的条件?拷贝/移动构造、赋值、析构全平凡,且无虚函数/虚基类

相关 ​

参考 ​

糌粑的面经笔记 · VitePress 版