现代 C++ 跨平台开发-内存篇:拷贝、移动、可重定位

本文是整个【现代 C++ 跨平台开发-内存篇】系列的第 4 篇,主要涉及:拷贝、移动、可重定位等内容。

拷贝语义

C 只有值语义,struct 赋值就是逐字段赋值(浅拷贝),如果要保证安全,必须手动 strcpy()memcpy()
C++ 引入拷贝语义,主要是为了封装深拷贝逻辑,让任意类型能像基本类型一样优雅地赋值拷贝。

拷贝构造/运算符

  • 拷贝构造函数:针对的是不存在的对象,直接在一行代码里声明对象后直接赋值;

  • 拷贝赋值运算符:针对的是已存在的对象,后续语句再次赋值;

  • Java 都是引用,赋值几乎没有开销;但 C++ 一句简单赋值就可能引入拷贝开销,对于大对象要格外注意。

T(const T& t); //拷贝构造函数
T& operator=(const T& t); //拷贝复制运算符

T t0{}; //构造
T t1 = t0; //拷贝构造
T t2{};
t2 = t1; //拷贝赋值*
  • 参数是引用类型:

    • 防止循环调用;
  • 参数是 const

    • 支持传入 constconst_cast() 可去掉 const,但无法加上 const);

如何安全拷贝

对于单一数值类(或其指针)字段,可以直接拷贝。
对于复杂类型,很多因素(字节填充方式、RTTI 及虚表实现方式等)会影响对象内存布局(本质上涉及 C++ ABI 一致性,不同编译器实现差异);如果不管三七二十一直接拷贝,属于 UB。

可平凡拷贝(Trivially Copyable)

意味着可以安全地用 memcpy()memmove() 复制,而不会破坏语义;
函数调用过程中,可通过寄存器高效传参,否则只能走栈:构造析构涉及到内存分配释放、异常处理等,不能简单通过寄存器传递,无法保证安全性;

  • 要求:

    • 至少有一个拷贝或移动操作(构造/赋值)是平凡的(未被用户自定义);

    • 所有拷贝/移动构造操作要么是平凡的,要么被删除(delete);

    • 析构函数是平凡的(不能是虚析构函数,也不能是用户自定义的非平凡析构);

    • 所有非静态成员和基类也必须是 trivially copyable。

  • 可用于某些底层操作(如序列化、共享内存)。

  • 可通过 std::is_trivially_copyable_v<T>() 检查;

  • 可平凡拷贝,仅代表可安全地手动拷贝,不代表赋值会被自动优化为按字节拷贝,以下因素会影响:

    • 存在字段填充:拷贝会带上填充位,影响对象唯一化表示(哈希/序列化),且某些ABI规定子类可以利用父类的填充空间;

    • 成员有初始化器:会让默认构造非平凡,影响编译器对类型和唯一表示的判断;

平凡类型(Trivial)

平凡类型可以静态初始化,且生命周期始于存储分配完成时(无需调用构造函数)。

  • 要求:

    • 首先得可平凡拷贝;

    • 默认构造函数也是平凡的;

    • 不能有虚函数或虚基类(虚表内存布局及实现涉及二进制兼容);

    • 基类和非静态成员都必须是平凡类型;

  • STL 中的容器类型都是非平凡类型;

  • 可通过 std::is_trivial_v<T>() 判断;

拷贝相关内置函数

memcpy()

  • 小内存:直接内联为通用寄存器操作(movlmovq 等),避免函数调用开销;

  • 大内存:

    • 依赖 SIMD 指令 SSE(movups)、AVX(vmovdqu)等;

    • 使用非临时存储(Non-Temporal Store)指令,如 movntdq(SSE), vmovntdqa(AVX-512):

      • 绕过 CPU 缓存,直接写入内存;

      • 避免污染缓存(大块数据很可能不会被立即重用);

  • 不能处理内存重叠,属于未定义行为。

memmove()

  • 能正确处理内存重叠。

std::copy()

  • 支持 STL 迭代器;

  • 迭代器有重叠也是未定义行为,建议改用 std::copy_n()

std::rotate()

  • 支持循环移动。

Guaranteed Copy Elision(C++17)

当使用 prvalue 直接初始化对象时,直接在目标位置构造,不存在中间对象拷贝。

  • 函数返回 prvalue;
T foo() { return T{}; }
T t = foo();
  • 用 prvalue 初始化变量:
T t1 = T{};
T t2{T{}};
  • prvalue 作为参数:
void foo(T t);
foo(T{});

移动语义(C++11)

左值/右值

右值本质上是为移动语义专门引入的概念,使编译器能区分”可安全移动的左值”(xvalue)和普通左值。

expression
├── glvalue (泛左值,有身份,可引用);
│ ├── lvalue (常规左值);
│ └── xvalue (将亡值,如 std::move() 返回值);
└── rvalue (右值,可移动);
├── xvalue(将亡值,既可引用,还能安全移动);
└── prvalue (纯右值,常量/临时对象/普通函数返回值);

移动语义

C 语言参数只有拷贝语义;C++ 加入移动语义是为了避免赋值时隐式调用拷贝构造函数产生的不必要的开销,而右值引用完善了移动构造函数。

  • 若外部传普通引用,在移动构造函数里:

    • 若不将原指针置空,可能因为重复引用导致问题;

    • 若将原指针置空,外部可能不知道,出现非法访问;

  • 强制外部传右值引用,外部就会知道移动后不能再访问。

std::move()

  • 本质只是类型转换,类似 static_cast<T&&>(),告诉编译器可以安全移动,触发移动构造;

  • 普通函数返回值、lambda 等本身就是右值,无需 move。

移动构造/运算符

  • 移动构造函数:针对的是不存在的对象,直接在一行代码里声明对象后直接赋值;

  • 移动赋值运算符:针对的是已存在的对象,后续语句再次赋值;对于移动,涉及到转移所有权,所以不能光构造,还要处理原对象;

Decoder::Decoder(Decoder &&other) noexcept : cjson(std::move(other.cjson)) {
}

Decoder &Decoder::operator=(Decoder &&other) noexcept {
    if (this != &other) [[likely]] {
        this->cjson = std::move(other.cjson);
        other.cjson.reset();
    }
    return *this;
}

T t0{}; //构造
T t1 = std::move(t0); //移动构造
T t2{};
t2 = std::move(t1); //移动赋值
  • 必须 check self;

  • 原对象必须置空,可使用 std::exchange()

  • 对于 STL 成员,可直接调用 std::move()

  • 必须声明 noexcept,否则也不会被调用。

相关 concept

#define AssertMove(T) \
    static_assert(std::is_move_constructible_v<T>, #T " MUST BE MOVE CONSTRUCTIBLE!"); \
    static_assert(std::is_move_assignable_v<T>, #T " MUST BE MOVE ASSIGNABLE!"); \
    static_assert(std::is_nothrow_move_constructible_v<T>, #T " MUST BE NOTHROW MOVE CONSTRUCTIBLE!"); \
    static_assert(std::is_nothrow_move_assignable_v<T>, #T " MUST BE NOTHROW MOVE ASSIGNABLE!")

可重定位语义(C++26)

背景:STL 扩容场景,逐个元素调用拷贝/移动构造的运行时开销太高,可优化为:内存拷贝 + 原地析构

本质 - 返璞归真:

  • 消除 C++ 各种构造函数抽象的开销,通过更原始的方式模拟实现批量移动;

  • 是对移动语义的特化优化(有限制条件),而移动是更通用的候选项。

  • 没有对应的构造函数和赋值运算符,只需定义类型时加 trivially_relocatable_if_eligible 标识,编译器会判断是否真的有资格:

    • 首先必须得可平凡复制,因为要 memcpy()

      • 拷贝/移动/析构都平凡(未定义/默认/为空);
    • 对析构放宽要求:

      • 可以不平凡,只要不释放资源,即允许 memcpy() + ~T() 模拟 move
    • 不能有虚函数;

    • 不能有引用成员;

    • 不能有不可重定位成员;

  • 标准库没有限制一定要用 memcpy(),但事实各家标准库几乎都是这样实现;

拷贝 or 移动?

六大函数

零法则

  • 尽量使用 RAII 和标准库容器,让编译器自动生成所有构造/析构函数;

编译器“潜规则”的坑

就算暂时未使用,也要显式声明所有函数 – 因为你无法预见外部对类型的使用方式,而编译器默认行为可能不符合预期。

  • 自动生成的拷贝构造函数,要特别注意:裸指针是简单浅拷贝;

  • 用户显式声明析构函数、拷贝构造函数/运算符:编译器不会生成默认的移动构造函数/运算符,这会显著影响性能;(指定为 default 也算显式声明)

  • 用户显式声明移动构造函数/运算符:拷贝构造函数/运算符会被编译器声明为 delete,设计初衷是为了避免隐式拷贝,但可能不符合我们的预期;

参数类型选择

  • 数值;

  • 对象值:

    • 优先尝试最优方式(move for rvalue, copy for lvalue),否则退而求其次;

    • 某些情况存在拷贝开销,但也有优势:

      • 统一了左值右值,不用写两套重载;

      • 函数拥有参数的完整副本,可安全存储或修改;

  • 指针:

    • const 限制是否可修改;

    • 需通过文档约定所有权,或改为智能指针:

      • unique_ptr 作为参数,表示接管所有权,因为它只能移动;
  • 左值引用:

    • const 可控制是否能修改;

    • 如果传右值,则必须有 const 修饰:

      • const 修饰:编译失败(右值无法绑定到非 const 左值);

      • const 修饰:绑定到左值,自动延长生命周期到函数作用域,不会触发拷贝/移动;

  • 右值引用:

    • 传右值:触发移动构造;

    • 传左值:编译失败(无法绑定左值到右值引用,必须明确通过 std::move() 放弃所有权);

  • 万能引用(T&&):

    • 用于模板函数传参场景;

    • 基于引用折叠规则,可同时兼容左值右值;

    • 内部可通过 std::forward() 将模板参数转发给其他模板函数。