跳到主要内容

复制、移动与特殊成员函数规则

本节目标

掌握 C++23 的复制、移动、特殊成员函数生成规则和移动后状态。

值语义让对象可以复制,也让资源可以通过移动转移。特殊成员函数把这些操作和对象生命周期连接起来;先辨认它们何时存在、何时不可用,再决定是否需要亲自声明。

六个特殊成员函数总览

六个特殊成员函数是默认构造函数、析构函数、复制构造函数、复制赋值运算符、移动构造函数和移动赋值运算符。它们的声明、定义和可用性由类自身以及其基类、成员的性质共同决定。下表采用常见的参数形状帮助分类,并不穷举语言允许的全部声明形式。

特殊成员典型来源作用时点资源责任
默认构造函数不从同类型源对象取值新对象开始生命周期建立初始不变量
析构函数当前对象对象生命周期结束释放该对象仍然拥有的资源
复制构造函数通常是 const Widget&从源对象初始化新对象建立独立的等价值
复制赋值运算符通常是 const Widget&替换已存在对象的状态安全处理旧状态,再复制源状态
移动构造函数通常是 Widget&&从可转移源初始化新对象取得可复用状态,并保持源对象有效
移动赋值运算符通常是 Widget&&替换已存在对象的状态清理目标旧状态、接管源状态,并维持双方不变量
Widget copy{source};

这里的 source 是具名左值,copy 则是刚开始生命周期的新对象;若来源改为 xvalue,同一个初始化位置才会给移动构造函数参与选择的机会。copy = source; 已是另一种操作,因为目标对象在赋值前就存在。移动构造和移动赋值也分别对应初始化与替换,它们不是 std::move 本身;std::move 只是把表达式转换成允许选择移动重载的 xvalue。

复制构造函数

复制构造沿着“现有源对象 → 新目标对象”的路径建立值:

TrackedString target{source};

target 在这条语句中才开始生命周期,所以不能用赋值运算符解释一个尚未存在的对象。复制构造函数应从同类型对象建立独立的等价值新对象;对值成员,逐成员复制通常已经满足值语义。若类直接独占一块动态资源,只复制地址会让源对象与副本共同承担同一份独占释放责任,两个析构路径随后可能重复释放。此时必须为副本取得自己的资源并复制内容,或者明确删除复制能力。

深复制还要把失败放在正确边界:取得副本资源或复制内容若抛出异常,尚未完成构造的目标不能作为有效对象暴露,源对象则应保持原值。用 std::string、容器或智能指针等 RAII 成员表达资源时,已经完成构造的成员会在外层构造失败时自行清理,也能让成员的复制合同组成外层类型的合同。

代表程序让 copyConstructionSource 初始化具名的 copyConstructionTarget。来源与目标都明确存在于代码中,又没有符合复制消除的临时返回路径,因此计数器只记录一次复制构造。

复制赋值运算符

复制赋值发生时,源对象和目标对象都已经存在:

copyAssignmentTarget = copyAssignmentSource;

目标的旧状态会被源对象的等价值替换。对直接管理独占资源的类型,这通常意味着先取得一份能够提交的深复制,再替换目标状态并释放旧资源;若一开始就释放旧资源,后续分配或复制失败便可能留下无效对象。具体实现可以选择并说明自己的异常保证,但失败后至少必须让目标仍可析构并满足类不变量;需要强保证时,应让取得替换状态的失败发生在提交之前。

自赋值 value = value 也必须安全。不能先销毁目标所拥有的资源,再通过其实就是同一对象的源参数读取资源。代表程序的 operator=(const TrackedString&)std::string 值成员执行复制,this != &other 使同一对象赋给自身时不重复执行成员赋值;main 只调用它一次,所以计数器记录一次复制赋值。这个身份检查处理了本实现的自赋值路径,却不是唯一的异常安全策略;无论采用身份检查、暂存替换状态还是后文的 copy-and-swap,都要分别审查旧资源、失败点和提交后的不变量。

移动构造函数

移动构造仍然是在建立新对象,只是来源允许转移可复用状态:

TrackedString target{std::move(source)};

target 从具名源对象初始化;真正取得状态的是随后选中的移动构造函数,而不是 std::move 这个转换。移动实现可以让成员接管资源表示,却不能把“源字符串一定为空”当作成功标志。

除非另有说明,标准库对象在移动后处于有效但未指定状态。它只能在满足各操作前置条件的情况下使用,不能依赖其具体内容。析构或重新赋值仍是有效用途;其他操作则要逐项确认其前置条件。代表程序没有读取移动后的旧字符串,而是先给 moveConstructionSource 重新赋值,再读取这个新值来验证对象可以继续使用。自定义类型也应明确记录自己的移动后不变量,不能把某次实现观察到的内容升级为接口保证。

移动赋值运算符

移动赋值面对的是一个已经拥有旧状态的目标:

moveAssignmentTarget = std::move(moveAssignmentSource);

实现要先让目标原有资源沿正确路径结束责任,再接管源对象可转移的状态。操作结束后,目标保存新的有效值,源对象也仍须满足该类型声明的移动后不变量;不能因为源对象“快销毁了”就跳过这项责任,也不能继续按移动前的旧值使用它。

代表程序使用彼此独立的具名 moveAssignmentSourcemoveAssignmentTarget。移动后只对源对象执行重新赋值,再读取新值;它没有探测未指定内容。示例中的移动赋值声明为 noexcept,因为其中实际执行的成员移动不会传播异常。自定义实现只有在目标清理、成员转移和状态修复都满足这份承诺时,才能写出同样的异常说明。

隐式生成、抑制与删除

特殊成员的“声明来自哪里”“函数体何时形成”和“最终能否调用”是三件事,可以按以下状态检查:

  • 隐式声明:类没有提供相应声明且满足该成员各自的条件时,编译器为它声明函数。隐式声明不等于已经生成函数体;在需要该函数定义时,编译器才会隐式定义它。
  • 显式默认Widget(Widget&&) = default; 仍是用户声明的移动构造函数,只是请求编译器按默认规则形成定义。首次声明处显式默认的函数不是 user-provided,却仍是 user-declared;在先前声明之后才显式默认的函数属于 user-provided。
  • 显式删除Widget(const Widget&) = delete; 明确禁止相应复制。被删除函数仍可进入名称查找和重载决议,若最终选中则调用不成立。
  • 用户提供:用户声明且没有在第一次声明时显式 = default= delete 的函数属于 user-provided;它的函数体和合同由该定义承担。
  • 默认后删除:隐式声明的特殊成员也可能被定义为 deleted。若完成外层操作所需的基类或成员构造、赋值或析构不可访问、被删除或不适用,相应的默认特殊成员就可能不可用;复制或移动赋值还要单独检查引用成员、某些 const 成员等删除条件。不同特殊成员的条件并不共用一张简化清单。

用户声明的析构函数、复制构造函数、复制赋值运算符、移动构造函数或移动赋值运算符会影响移动成员的隐式声明。隐式移动构造函数要求没有用户声明的复制构造、复制赋值、移动赋值或析构函数;隐式移动赋值运算符则要求没有用户声明的复制构造、复制赋值、移动构造或析构函数。声明移动构造函数或移动赋值运算符会使隐式声明的复制构造函数和复制赋值运算符被定义为 deleted。应按每个成员的精确规则检查,而不是用“编译器总会补齐”概括;把 = default 当作没有声明任何成员,同样会误判抑制关系。

规则零、三与五

若资源已经由成员类型正确管理,外层类型通常只需组合这些合同:

std::string value_;

规则零优先让成员类型管理资源;规则三适用于需要自定义析构、复制构造和复制赋值的资源所有者;规则五要求同时审视移动构造和移动赋值。

规则适用情形设计动作
规则零RAII 成员已经表达资源所有权与复制移动语义不自行声明特殊成员,让成员合同组合成外层合同
规则三类型直接管理资源,并且需要自定义析构与复制语义一起审视析构、复制构造和复制赋值;实现深复制或明确删除复制
规则五在规则三的责任上还需要决定资源能否高效转移再审视移动构造和移动赋值,按合同选择实现、默认或删除

这些规则是设计审查经验,不是语言强制的函数配额,也不要求机械地手写三个或五个函数。不能复制的唯一所有者可以明确删除复制;不变对象或共享语义也可能选择不同合同。危险做法是只写析构函数,却保留会浅复制独占资源并最终双重释放的默认复制。真正的问题始终是:资源所有权与每一种允许操作是否一致。

noexcept 移动与容器选择

noexcept 把移动是否会传播异常写进函数类型可见的合同。下面是两种互斥的声明选择,不是要同时声明两个重载:

// 实现能够证明不会传播异常。
TrackedString(TrackedString&&) noexcept;

// 实现可能传播异常。
TrackedString(TrackedString&&);

对这里的移动构造,只有基类与成员初始化以及源状态调整都不会传播异常时,才能选择第一种写法;移动赋值还要把目标旧状态的处理纳入同一证明。若异常逃出一个 noexcept 函数,程序会进入终止路径。为了“更快”而给可能抛出的移动加 noexcept,会把普通失败错误地变成终止。

noexcept 移动构造允许容器在重定位时优先移动;若移动可能抛出且复制可用,容器可以选择复制以维持更强的异常保证。这只是容器结合元素类型合同所能采用的策略,不是“每次扩容必然逐个移动”的保证。具体容器、具体操作、是否发生重分配及其异常保证都会影响实际路径,因此也不能从 noexcept 推出一个固定的总移动次数。先让移动实现正确,再用真实、可证明的不抛出语义表达 noexcept

copy-and-swap 与有效状态

自赋值必须保持对象可用;copy-and-swap 先取得副本,再交换状态。

Widget& operator=(Widget other) {
swap(*this, other);
return *this;
}

这段赋值可以按三个阶段审查:

  1. 取得临时状态:进入函数体之前,按值形参 other 已按实参值类别完成初始化。对同时提供相应复制与移动构造的 Widget,左值实参会复制构造按值形参 other,xvalue 实参会移动构造 other;同类型 prvalue 实参直接构造该形参对象,不会调用一次可计数的复制或移动构造。形参初始化若在此失败,原目标尚未改变。
  2. 交换并提交swap(*this, other) 把新状态交给目标,也把目标旧状态交给临时对象。只有交换合同确实不抛出时,这一步才天然形成不抛出的提交点。
  3. 清理旧状态:交换后,other 持有目标旧状态。形参 other 的销毁点由实现决定:可以在被调用函数退出时,也可以在包含该调用的完整表达式结束时;无论选择哪一处,other 最终都会清理交换后持有的目标旧状态。

复制自赋值时,临时对象先取得独立状态,再进入交换,所以不会先销毁自己仍要读取的来源。这也说明 this != &other 不是唯一异常安全策略。copy-and-swap 能把复制阶段的失败留在原对象之外,却可能多出一次副本或移动,并不总是性能最佳;若 swap 本身可能抛出,强保证也不会自动成立。无论选择这种惯用法还是直接赋值,都必须明确交换是否不抛出、资源释放发生在何处,以及失败后对象处于什么有效状态。

常见陷阱

复制和移动问题通常不是少写了一个关键字,而是对象责任与语言规则没有同时对齐:

  • 浅复制独占资源:只复制裸地址会制造多个自认为负责释放的对象。应深复制资源、把责任交给 RAII 成员,或删除复制能力。
  • 只声明部分相关成员:用户声明的析构、复制或移动成员会触发各自的抑制与删除规则。不要假定编译器会把其余操作按原意补齐,应逐项选择默认、删除或自定义实现。
  • std::move 当作动作:它只产生 xvalue;只有后续重载决议选中移动操作时,资源才可能转移。
  • 依赖移动后的具体值:有效不等于内容不变或必为空。先按类型合同确认操作前置条件;需要稳定新值时,先重新赋值。
  • 把复制消除次数当作合同:在允许但不强制复制消除的场景,例如符合条件的 NRVO,复制或移动是否发生并不是可移植固定次数;另一些 prvalue 场景则由语言直接构造结果对象,本来就不会调用一次可计数的复制或移动。没有先区分具体表达式的规则,就用返回对象或涉及临时量的按值传递锁定构造次数,会把不同路径混成同一份程序合同。

代表程序改用直接、具名的操作来固定观察边界:

TrackedString moveConstructionTarget{std::move(moveConstructionSource)};

本章固定次数示例只执行具名源对象和具名目标对象之间不可省略的复制或移动;它明确排除复制消除。程序的四个计数器都由 main 传入的局部状态管理,没有可变全局状态;移动后的源对象也只在重新赋值后才验证可用。

copy_move_report.cpp
#include <iostream>
#include <string>
#include <utility>

struct Counters {
int copyConstructed{0};
int copyAssigned{0};
int moveConstructed{0};
int moveAssigned{0};
};

class TrackedString {
public:
TrackedString(std::string value, Counters& counters)
: value_{std::move(value)}, counters_{counters} {}

TrackedString(const TrackedString& other) : value_{other.value_}, counters_{other.counters_} {
++counters_.copyConstructed;
}

TrackedString& operator=(const TrackedString& other) {
if (this != &other) value_ = other.value_;
++counters_.copyAssigned;
return *this;
}

TrackedString(TrackedString&& other) noexcept
: value_{std::move(other.value_)}, counters_{other.counters_} {
++counters_.moveConstructed;
}

TrackedString& operator=(TrackedString&& other) noexcept {
if (this != &other) value_ = std::move(other.value_);
++counters_.moveAssigned;
return *this;
}

void assign(std::string value) { value_ = std::move(value); }

[[nodiscard]] const std::string& value() const { return value_; }

private:
std::string value_;
Counters& counters_;
};

int main() {
Counters counters;

TrackedString copyConstructionSource{"copy source", counters};
TrackedString copyConstructionTarget{copyConstructionSource};

TrackedString copyAssignmentSource{"copy assignment source", counters};
TrackedString copyAssignmentTarget{"old copy target", counters};
copyAssignmentTarget = copyAssignmentSource;

TrackedString moveConstructionSource{"move source", counters};
TrackedString moveConstructionTarget{std::move(moveConstructionSource)};
moveConstructionSource.assign("reused after move construction");
const bool moveConstructionSourceReused =
moveConstructionSource.value() == "reused after move construction";

TrackedString moveAssignmentSource{"move assignment source", counters};
TrackedString moveAssignmentTarget{"old move target", counters};
moveAssignmentTarget = std::move(moveAssignmentSource);
moveAssignmentSource.assign("reused after move assignment");
const bool moveAssignmentSourceReused =
moveAssignmentSource.value() == "reused after move assignment";

static_cast<void>(copyConstructionTarget);
static_cast<void>(copyAssignmentTarget);
static_cast<void>(moveConstructionTarget);
static_cast<void>(moveAssignmentTarget);

std::cout << "copy_constructed=" << counters.copyConstructed << '\n';
std::cout << "copy_assigned=" << counters.copyAssigned << '\n';
std::cout << "move_constructed=" << counters.moveConstructed << '\n';
std::cout << "move_assigned=" << counters.moveAssigned << '\n';
std::cout << "source_valid="
<< (moveConstructionSourceReused && moveAssignmentSourceReused ? "true" : "false")
<< '\n';
}

输出:

copy_constructed=1
copy_assigned=1
move_constructed=1
move_assigned=1
source_valid=true

设计值类型时,先写下复制后双方应具备的独立性、移动后对象允许的操作、以及资源释放的唯一责任者。然后让这些不变量决定特殊成员声明,而不是从计数或微优化反推语义。