一个类能成功申请内存,也能在析构时释放内存,看起来似乎已经完整。真正的问题往往要到复制之后才出现:两个对象保存了同一个地址,修改其中一个会影响另一个,最后还会对同一地址释放两次。
拷贝控制处理的正是对象状态的延续方式。我们需要回答三类问题:创建副本时得到独立值还是共同身份,已有对象被覆盖时怎样处理旧状态,以及一个临时值能否把资源低成本地转交出去。
这不是五个特殊成员函数的语法清单。它是一套语义设计:对象拥有什么,副本意味着什么,转移后还允许做什么,清理又由谁完成。
只写析构函数,常常会让资源类从“泄漏”变成“重复释放”。只要类直接拥有资源,就要把复制、赋值、移动和销毁放在一起审查。
判断调用哪个函数,先看左侧对象是否已经存在。
因此,下面两行虽然都有等号,含义并不相同:
Trace copy = original; // copy 正在创建:拷贝构造
copy = original; // copy 已经存在:拷贝赋值最直接的观察方法,是让每个特殊成员打印一条轨迹。下面的程序不依赖返回值优化,输出顺序可以清楚对应每一行代码。
#include <iostream>
#include <string>
#include <utility>
struct Trace {
std::string name;
explicit Trace(std::string value) : name(std::move(value)) {
std::cout << "construct " << name << '\n';
}
Trace(const Trace& other) : name(other.name + ".copy") {
std::cout << "copy construct from " << other.name << '\n';
}
Trace& operator=(const Trace& other) {
std::cout << "copy assign " << name << " <- " << other.name << '\n';
name = other.name;
return *this;
}
Trace(Trace&& other) noexcept : name(std::move(other.name)) {
std::cout << "move construct taking " << name << '\n';
other.name = "moved-from";
}
Trace& operator=(Trace&& other) noexcept {
std::cout << "move assign " << name << " <- " << other.name << '\n';
name = std::move(other.name);
other.name = "moved-from";
return *this;
}
~Trace() {
std::cout << "destroy " << name << '\n';
}
};
int main() {
Trace a{"A"};
Trace b = a;
Trace c{"C"};
c = b;
Trace d = std::move(a);
c = std::move(d);
std::cout << "leave main\n";
}使用 C++17 编译运行后,输出为:
construct A
copy construct from A
construct C
copy assign C <- A.copy
move construct taking A
move assign A.copy <- A
leave main
destroy moved-from
destroy A
destroy A.copy
destroy moved-from局部对象按构造完成的逆序销毁,所以最后依次销毁 d、c、b、a。被移动的对象没有消失;它仍是一个活对象,最后仍会析构。
常见触发点可以归纳为下表:
按值传参会初始化形参,按值返回会初始化结果,因此也可能触发复制或移动。传 const T& 只建立借用,不创建 T 的副本。
临时对象会在规定的生命周期终点析构;容器销毁时也会逐个销毁仍在其中的元素。
C++17 还会直接在最终位置构造某些纯右值。例如 T value = T{}; 不需要先构造临时对象再移动。返回具名局部对象时还可能发生具名返回值优化;这类优化会让追踪输出少于按语句表面推测的次数。
追踪输出能说明本次编译发生了什么,却不能用来证明所有允许省略的复制都一定出现。分析代码时先判断语义,再把具体输出当作实现观察。

如果没有声明相应操作,编译器会尝试生成特殊成员。生成不等于“猜出正确业务语义”,它做的是递归的逐成员操作:
若析构函数有函数体,函数体先执行,随后成员和基类反向销毁。析构函数不应让异常逃出,尤其不能在异常展开期间再次抛出。
如果某个成员不支持所需操作,外层类对应的生成操作会被定义为删除。例如,一个成员不可复制,外层类通常也不能靠合成操作复制。
对 std::string 和 std::vector 这样的成员,逐成员复制通常正合适:每个成员已经定义了独立值的复制行为。
#include <string>
#include <vector>
struct Notebook {
std::string title;
std::vector<std::string> lines;
};
Notebook first{"ideas", {"copy", "move"}};
Notebook second = first; // title 和 lines 都按值复制这里不需要手写析构、复制或移动。外层类直接组合成员已有的行为,代码短,规则也一致。
隐式移动的生成条件比“没写移动函数就会有”严格得多。对 C++17 而言,只有类没有用户声明以下任何成员时,编译器才会隐式声明移动构造和移动赋值:
这里的“用户声明”包括 = default 和 = delete。也就是说,单独写一句 ~T() = default; 仍然会抑制隐式移动。
另外,声明任一移动操作会让隐式复制操作被定义为删除。因为编译器把“只声明移动”理解成类可能具有独占语义,不应悄悄补出复制。
下面的静态断言把这些规则变成编译期实验。std::unique_ptr 在这里仅代表一个不可复制、可以移动的成员。
#include <memory>
#include <type_traits>
struct OwnerWithDestructor {
std::unique_ptr<int> value;
~OwnerWithDestructor() = default;
};
struct MovableOwner {
std::unique_ptr<int> value;
MovableOwner() = default;
MovableOwner(const MovableOwner&) = delete;
MovableOwner
第一个类手写了默认析构,隐式移动因此没有生成;它的成员又不可复制,所以最终既不能复制,也不能移动。第二个类把意图全部写明:禁止复制,允许不抛异常的移动。
= default 和 = delete 分别表达两种清楚的设计决定:
Widget(const Widget&) = default; // 逐成员复制就是所需语义
Widget(const Widget&) = delete; // 复制没有合法含义不要通过“某段调用刚好编译失败”间接表达设计。把允许和禁止写进类接口,错误会更早出现,诊断也更接近真正原因。
指针成员暴露了逐成员复制的边界。复制一个 int*,得到的是同一个地址,不是地址所指数组的新副本。
下面的类拥有 data_ 指向的动态数组,却只写了析构。编译器仍会生成逐成员的拷贝构造,所以问题出在 BadBuffer second = first;。
#include <cstddef>
#include <iostream>
class BadBuffer {
public:
explicit BadBuffer(std::size_t size)
: data_(new int[size]{}) {}
~BadBuffer() {
delete[] data_;
}
int& operator[](std::size_t index) {
return data_[index];
复制之后的关系不是“两份数组”,而是“两名自称所有者的对象指向一份数组”:
first.data_ -----+
+----> [99, 0]
second.data_ -----+所以修改 second[0] 也能从 first[0] 观察到。更严重的问题出现在 main 结束时:second 先析构并释放数组,随后 first 又对同一地址执行 delete[]。
这是未定义行为。普通运行可能先打印两个相同值,也可能崩溃或出现别的表现,不能把任何一次普通输出当作保证。
这类实验应单独交给 AddressSanitizer:
clang++ -std=c++17 \
-Wall -Wextra -Wpedantic \
-fsanitize=address -fno-omit-frame-pointer \
bad_buffer.cpp -o bad_buffer
./bad_buffer诊断通常会明确指出一次重复释放,例如:
ERROR: AddressSanitizer: attempting double-free发生重复释放之后,不要继续解释某个打印值为何出现。未定义行为没有可依赖的后续轨迹;诊断工具指出违规位置,才是这个反例的结论。

浅拷贝并非在所有场景都错误。借用指针本来就可能只复制地址,共享身份也可能是明确需求。错误在于:类的析构声称自己独占资源,复制却把同一个地址交给了另一个同样会释放它的对象。
因此,看到裸指针成员时不能立刻得出“必须深拷贝”。先回答两个问题:这个指针是否拥有对象,副本应该独立还是共享。语义确定后,才选择深拷贝、显式共享或禁止复制。
特殊成员应该从用户可观察的行为推导,而不是从“类里有一个指针”机械推导。常见选择有三类:
std::string 和 std::vector 表现为独立值。复制后修改副本,不会改变原对象。连接、锁、唯一设备会话等对象通常更接近独占身份;复制一个“第二份负责人”没有合理含义。缓存视图、协作状态等场景可能需要共享身份,但接口必须让用户知道修改会相互可见。
三法则是一条资源类审查经验:如果必须手写析构,通常也要定义或删除拷贝构造和拷贝赋值。三者共同回答资源怎样复制、旧资源怎样替换、最终怎样释放。
五法则把移动构造和移动赋值也纳入同一次审查。资源可以低成本转交时,移动能避免昂贵的深拷贝。这里的“法则”不是要求把五个函数都手写;= default 和 = delete 同样是明确结论。
零法则通常是首选:让 std::string、std::vector 或其他资源管理类型承担底层责任,业务类不声明任何特殊成员。
#include <string>
#include <vector>
class Report {
public:
void add_score(int score) {
scores_.push_back(score);
}
private:
std::string title_;
std::vector<int> scores_;
};Report 不直接释放内存。它的合成复制会复制标题和分数数组,合成移动会移动这两个成员,合成析构会让成员自行清理。这正是所需的值语义。
可以用以下顺序快速选择:
最可靠的特殊成员往往是没有写出来的特殊成员。前提是每个数据成员已经表达了正确的所有权与复制语义。

有时,资源表示本身就是类的职责。下面的 Buffer 直接拥有动态数组,同时承诺值语义:复制后内容相同,但数组彼此独立。
先写出不变式:
size_ 是可访问元素数量;size_ == 0 时 data_ 可以是空指针;size_ > 0 时 data_ 指向由当前对象独占的 size_ 个 int;完整实现如下:
#include <algorithm>
#include <cassert>
#include <cstddef>
#include <initializer_list>
#include <iostream>
#include <utility>
class Buffer {
public:
Buffer() = default;
explicit Buffer(std::size_t size)
: size_(size),
data_(size == 0 ? nullptr :
在 C++17 严格警告设置下编译运行,输出为:
original: 1 2 3
copy: 9 2 3
moved: 1 2 3
assigned.size(): 0assert 检查两份数组地址不同;修改 copy 后,original 保持不变。这两项共同验证了深拷贝,而不只是验证“值恰好相等”。
拷贝赋值使用了 copy-and-swap:先构造临时副本,再交换表示。它的控制流可以拆成三步。
Buffer replacement(other); 先申请新数组并复制内容。若分配失败,赋值函数体还没有修改当前对象,当前对象保持原值。
swap(replacement); 用不抛异常的交换提交新状态。当前对象取得新数组,临时对象取得旧数组。
函数结束时,临时对象析构并释放旧数组。自赋值也走同一路径,不会先删掉自己还要读取的数据。
这个结构提供强异常保证:复制准备失败时,左侧对象的可观察状态不变。交换必须确实不抛异常,否则“提交”本身又会成为失败点。
移动构造通过 std::exchange 接走大小和地址,并把 other 设为明确的空状态。移动赋值先移动构造临时对象,再交换;当前对象原来的数组仍由临时对象收尾。
copy-and-swap 的代价也很具体:每次拷贝赋值都要分配新数组,不能复用左侧已有容量。性能敏感的容器可以在容量足够时原地赋值,但要额外处理元素赋值抛异常、部分更新和自赋值。先选择可证明正确的版本,再根据测量决定是否增加分支。
深拷贝一个很大的缓冲区需要重新分配并复制全部元素。若右侧对象马上就不再需要原资源,更合理的动作是接走它的表示。
右值引用 T&& 让重载能够识别这类可转移表达式。std::move 的名字容易造成误解:它本身不移动任何内容,只把表达式转换成 xvalue,使移动重载有机会被选中。
Buffer source{1, 2, 3};
Buffer&& named = std::move(source);
Buffer copied = named; // named 是有名字的表达式,属于左值
Buffer moved = std::move(named); // 再次转换后,调用移动构造有名字的右值引用变量仍是左值。这条规则防止一个变量仅因为类型是 T&&,就在每次读取时被反复掏空。只有显式转交时才再次使用 std::move。
移动构造通常完成三件事:
“仍然有效”不等于“值保持不变”,也不普遍等于“必定为空”。标准库类型通常只承诺移后对象处于有效但状态未指定的状态。可以销毁它、给它赋新值,或调用没有前置条件的操作;不要依赖它仍保留旧内容,也不要在没有额外承诺时断言它为空。
我们的 Buffer 主动给出了更强承诺:移动后 size() == 0。这是该类自己的接口决定,不应推广到所有类型。
const 会阻止典型移动,因为移动需要修改右侧:
const Buffer frozen{4, 5, 6};
Buffer result = std::move(frozen); // 通常调用 const Buffer& 拷贝构造std::move(frozen) 的类型带有 const,不能绑定到需要修改参数的 Buffer&&。拷贝构造的 const Buffer& 仍能接收它,所以这里发生深拷贝。
按值返回局部资源对象时,通常直接写对象名:
Buffer make_buffer() {
Buffer result{1, 2, 3};
return result; // 优先 NRVO;未消除时可隐式移动
}不要为了“强制移动”写 return std::move(result);。它把表达式改成 xvalue,通常会失去具名返回值优化资格,结果可能比直接返回更差。
移动的核心不是“更快的复制”,而是改变资源责任。目标取得责任,右侧保留一个可析构、可重新赋值的有效状态。
std::vector 扩容时要在新存储中重新构造旧元素。若搬到一半时某次移动抛出异常,前面已经移动的旧元素可能被改变,恢复扩容前状态会很困难。
当元素可复制,而且移动构造可能抛异常时,标准库实现通常选择复制旧元素,以维护强异常保证。移动构造明确为 noexcept 时,容器可以放心移动。
下面的程序只改变移动构造的异常说明,并统计一次确定发生的重新分配中的复制和移动次数:
#include <iostream>
#include <string>
#include <type_traits>
#include <utility>
#include <vector>
template <bool MoveIsNoexcept>
struct Item {
inline static int copies = 0;
inline static int moves = 0;
std::string value;
explicit Item(std::string
在本次 C++17 标准库环境中,输出为:
may throw: nothrow=false, copies=1, moves=0
noexcept: nothrow=true, copies=0, moves=1第一次插入后,程序先清零计数,再保存当前容量,并向 reserve 请求 old_capacity + 1。请求值严格大于调用前的容量,因此这次 reserve 按标准要求发生重新分配;容器中原有的一个元素必须在新存储中重新构造。这样,计数只观察旧元素迁移,不依赖实现为首次插入选择了多大的容量。reserve(n) 的含义是让容量至少达到 n,并不会把容量上限固定为 n。
std::move_if_noexcept 概括了这种选择:如果移动构造不抛,或者类型根本不可复制,就产生可移动表达式;否则产生 const 左值引用,让复制成为候选。容器不需要承诺内部一定调用某个具名辅助函数,但类型的异常保证会直接影响可用策略。
不可复制而移动可能抛的类型没有复制后备方案,容器只能尝试移动;某些失败路径的保证也会相应变弱。不要为了迎合容器而给独占类型虚构复制语义。
noexcept 必须真实。若异常逃出标记为 noexcept 的函数,程序会调用 std::terminate,调用方没有机会按普通异常恢复。
对只交换整数和指针的 Buffer,移动确实不需要分配,也不调用会抛异常的操作,因此可以安全承诺 noexcept。若类的移动还要调用成员的移动,就要逐个检查这些成员的异常说明。默认生成的移动操作会根据成员能力推导异常说明。

std::move 只提供可转移表达式;真正的资源责任由移动操作接管。请求 reserve(old_capacity + 1) 可确定触发重新分配,而 noexcept 决定容器能否放心选择移动路径。面对一个新类,不要从“我要不要写移动构造”开始。先确定语义,再让特殊成员服从这项决定。
用一句话写出复制承诺:副本是独立值、共享同一身份,还是根本不允许复制。用户能观察到的行为优先于内部表示。
列出类直接拥有的资源和不变式。每个指针都要标明是拥有还是借用;每个句柄都要标明由谁在何时释放。
检查成员现有的复制、移动和析构行为。如果逐成员组合已经满足承诺,停在零法则,不声明特殊成员。
若必须手写析构,立刻成组审查复制构造、复制赋值、移动构造和移动赋值。每项都明确 default、delete 或自定义实现。
常用的编译期检查如下:
#include <type_traits>
static_assert(std::is_copy_constructible_v<ValueType>);
static_assert(std::is_copy_assignable_v<ValueType>);
static_assert(std::is_move_constructible_v<ValueType>);
static_assert(std::is_nothrow_move_constructible_v<ValueType>);
static_assert(!std::is_copy_constructible_v<ExclusiveType
类型特征检查“某个表达式能不能成立”,不总能证明调用了真正的移动构造。若类型没有移动构造,但拷贝构造接受 const T&,从右值构造仍可能成立,只是实际走了复制。需要区分性能路径时,再配合像前面那样的计数实验。
运行测试可以按风险组织:
危险反例与安全实现要分开运行。对浅拷贝类启用 AddressSanitizer,确认能定位重复释放;对修复版同样启用工具,确认没有重复释放、释放后访问或泄漏。工具通过不代表语义正确,副本独立性、自赋值和移后状态仍要靠断言与行为测试验证。
最后,把设计压缩成三个问题即可反复使用:复制后是否独立,移动后谁负责资源,任何退出路径上谁来清理。三个答案一致,特殊成员才算形成了一套完整协议。
为赋值检查旧状态、自赋值和失败路径。若要求强异常保证,让可能失败的准备先发生,再用不抛操作提交。
为移动定义右侧的最低后置条件。只在所有转移动作确实不抛时标记 noexcept,并避免依赖未指定的移后内容。
用类型特征检查接口事实,例如是否可复制、是否可移动、移动构造是否不抛。编译期断言可以防止成员变化悄悄改变外层能力。
运行行为测试:复制独立性、不同大小对象间赋值、经别名自赋值、移动构造、移动赋值、移后再赋值、容器扩容和析构次数平衡。