拷贝控制

掌握C++三/五法则——拷贝构造/拷贝赋值/析构/移动构造/移动赋值,理解值语义与资源管理的核心机制

学习目标

  • 能解释合成拷贝控制成员的行为——什么情况下编译器替你生成、生成的拷贝是浅拷贝还是深拷贝,以及为什么管理堆资源的类不能依赖合成的拷贝
  • 能根据类的资源管理需求,正确实现拷贝构造/拷贝赋值/析构函数——知道拷贝赋值必须先释放旧资源、处理自赋值,析构必须释放所有堆资源
  • 能回答:下面 HasPtr 类的合成拷贝控制成员有什么问题?写出修正后的完整五法则实现。
    class HasPtr {
    public:
        HasPtr(const std::string &s) : ps(new std::string(s)), i(0) {}
    private:
        std::string *ps;
        int i;
    };

机制总览

拷贝控制:机制路径

  1. 1

    直觉:对象拷贝时,到底发生了什么?

    你已经习惯写 auto b = a; 把 a 的值拷贝给 b 。对于 int 、 double 这些简单类型,拷贝就是复制那几个字节——干净利落。但现在你开始设计自己的类了——里面可能有指向堆内存的指针、有文件句柄、有网络连接——这时候 auto b = a; 到底该干什么?

  2. 2

    官方 Chapter 13 的生成规则与值语义契约

    五个特殊成员不是彼此独立的开关。用户声明其中一些成员会抑制另一些成员的隐式生成,成员或基类不可执行某操作时,对应合成函数还可能被定义为 deleted。设计类时应先决定它表现得像值、像共享句柄,还是不可复制资源,再决定 = default 、 = delete 或自定义实现。

  3. 3

    合成的拷贝:编译器替你做了什么

    如果你不为类定义拷贝构造函数和拷贝赋值运算符,编译器可能隐式声明并合成它们,也可能因成员或其他特殊成员规则将其定义为 deleted。

先按顺序建立机制,再进入实验切换阶段并检查失效证据。

章级决策实验

拷贝控制:机制与证据

切换《拷贝控制》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 直觉:对象拷贝时,到底发生了什么?

你已经习惯写 auto b = a; 把 a 的值拷贝给 b 。对于 int 、 double 这些简单类型,拷贝就是复制那几个字节——干净利落。但现在你开始设计自己的类了——里面可能有指向堆内存的指针、有文件句柄、有网络连接——这时候 auto b = a; 到底该干什么?

可核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:对象拷贝时,到底发生了什么?」的契约。

学完《拷贝控制》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

拷贝控制:失效与核验

直觉:对象拷贝时,到底发生了什么?

典型失效

若把「直觉:对象拷贝时,到底发生了什么?」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:对象拷贝时,到底发生了什么?」的契约。

官方 Chapter 13 的生成规则与值语义契约

典型失效

若把「官方 Chapter 13 的生成规则与值语义契约」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「官方 Chapter 13 的生成规则与值语义契约」的契约。

合成的拷贝:编译器替你做了什么

典型失效

若把「合成的拷贝:编译器替你做了什么」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。

核验证据

保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「合成的拷贝:编译器替你做了什么」的契约。

每个判断都必须能落到观测、测试或产物,不能只凭代码表面推测。

直觉:对象拷贝时,到底发生了什么?

你已经习惯写 auto b = a;a 的值拷贝给 b。对于 intdouble 这些简单类型,拷贝就是复制那几个字节——干净利落。但现在你开始设计自己的类了——里面可能有指向堆内存的指针、有文件句柄、有网络连接——这时候 auto b = a; 到底该干什么?

想象工厂流水线上有个零件盒——里面装着螺丝、垫片、还有一张"去仓库第3排货架取专用夹具"的取货单。现在你要复制这个盒子——你是只复印取货单(两个人拿着同一张单子去同一个货架打架),还是真的去仓库拿一副新夹具放进新盒子?前者就是默认的「浅拷贝」——只复制指针本身,不复制指针指向的数据。

这一章解决什么问题? 教你怎么定制五个"特殊函数"——对象被拷贝时、被赋值时、被移动时、被销毁时分别该干什么。没有它们,管理堆资源的类一拷就崩——两个对象抢同一块内存,析构时删两次直接炸掉。

官方 Chapter 13 的生成规则与值语义契约

五个特殊成员不是彼此独立的开关。用户声明其中一些成员会抑制另一些成员的隐式生成,成员或基类不可执行某操作时,对应合成函数还可能被定义为 deleted。设计类时应先决定它表现得像值、像共享句柄,还是不可复制资源,再决定 = default= delete 或自定义实现。

拷贝构造不仅发生在 T b = a:按值传参、按值返回、容器插入与某些初始化也可能调用它,虽然编译器可执行拷贝消除。析构函数不直接销毁成员;它的函数体先运行,随后成员按声明逆序自动析构。合成析构并非“空操作”,它仍会正确析构 stringvector 和智能指针成员。

C++11 的关键生成关系包括:声明移动构造或移动赋值会使合成拷贝操作被定义为 deleted;只有当类没有用户声明的拷贝构造、拷贝赋值、移动操作和析构函数时,编译器才会隐式声明移动操作。用户声明析构函数常会让本可移动的类退回拷贝路径,因此资源类应显式审查全部五项。

零法则优先于五法则:若资源能由 stringvectorunique_ptr 或专用 RAII 句柄成员管理,外层类通常无需声明任何拷贝控制成员。只有当类本身承担资源政策、注册关系或特殊值语义时,才自定义这些入口。

合成的拷贝:编译器替你做了什么

如果你不为类定义拷贝构造函数和拷贝赋值运算符,编译器可能隐式声明并合成它们,也可能因成员或其他特殊成员规则将其定义为 deleted。按成员语义工作:

  • 类类型成员(如 stringvector)调用其自身的拷贝构造函数——string 的拷贝会正确分配新内存并复制字符串内容,所以这一部分是深拷贝
  • 内置类型成员(如 intdouble)直接复制其值——纯值拷贝,没有问题
  • 指针成员(如 int*)只复制指针的地址值——不复制指针指向的堆数据

只对指针本身的地址做拷贝——比如 ps 值是 0x1000,拷贝后就得到另一个也指向 0x1000 的指针。两个人看着同一张取货单去同一个货架搬东西——你不炸谁炸。

浅拷贝 vs 深拷贝:内存视角✕ 浅拷贝(Shallow Copy)= 合成拷贝控制成员只拷贝指针本身——两个对象指向同一块堆内存源对象 obj1ptr → 0x1000data: 42拷贝对象 obj2ptr → 0x1000data: 42堆数据0x1000int data = 42只有一份数据obj2 修改 *ptr → obj1 的 data 也变了!析构时 obj1 和 obj2 都 delete 同一块内存 → 双重释放崩溃auto obj2 = obj1; // 合成拷贝:ptr 值被复制*(obj2.ptr) = 99; // obj1.data 也变成 99!✓ 深拷贝(Deep Copy)= 自定义拷贝控制拷贝时为新对象分配独立的堆内存,并复制内容源对象 obj1ptr → 0x1000data: 42拷贝对象 obj2ptr → 0x2000data: 42堆数据 10x1000int data = 42堆数据 20x2000int data = 42obj2 修改 *ptr → obj1 不受影响 ✓各自析构各自释放 → 无双重释放 ✓// 自定义拷贝构造:分配新堆内存并复制obj2.ptr = new int(*obj1.ptr);💡 核心结论:当类管理堆资源时,必须自定义拷贝控制成员实现深拷贝——否则合成版本只做浅拷贝。
左侧(浅拷贝):编译器合成的拷贝只复制指针值——两个对象共享堆数据,修改一个影响另一个,析构时双重释放。右侧(深拷贝):自定义拷贝构造为每个对象分配独立堆内存并复制数据——各自独立,安全地分别析构。

看上面对比图。左半边展示的就是合成的拷贝——两个 HasPtr 对象的 ps 都指向堆上同一个 string。你改了一个的 *ps,另一个的也变了。更致命的是析构:两个对象离开作用域时都对自己的 ps 执行 delete——同一块内存被释放两次,这叫 ,程序直接炸。

看右侧那张图——正确的做法是为新对象单独分配一块堆内存,把源对象 *ps 的内容复制进去。两个对象各管各的堆内存,改一个不影响另一个,析构时各删各的——这叫深拷贝。

深拷贝的正确姿势:拷贝构造 + 拷贝赋值

要解决上面左图的问题,你需要自己写拷贝构造函数和拷贝赋值运算符。它们是五个特殊成员函数中的前两个。

通常接收 const ClassName&——用已有对象初始化一个刚诞生的新对象:

HasPtr(const HasPtr &orig)
    : ps(new std::string(*orig.ps)), i(orig.i) {}
//      ↑ 先 new 分配新内存,再拷贝 *orig.ps 内容

关键点:构造时 this 对象刚分配还没内容——只需要分配+拷贝,不需要释放任何东西(本来就没有)。

就不同了——a = b 调用时 a 已经存在,可能正持有一块堆内存:

HasPtr& operator=(const HasPtr &rhs) {
    auto *newp = new std::string(*rhs.ps);  // ① 先分配新内存(防止异常时丢失旧数据)
    delete ps;                               // ② 释放旧内存
    ps = newp;                               // ③ 接管新内存
    i = rhs.i;
    return *this;                            // ④ 返回自身引用——支持链式赋值
}

注意顺序——如果先 delete psnew,万一 new 抛异常,ps 已经丢了旧数据,对象就坏了。先 newdelete 是异常安全的正确写法。

还有一个必须处理的边界情况:自赋值。当调用方写 a = a; 时,上面的代码仍然成立——newp 分配成功后再 delete 旧数据,没有问题。但如果有人错误地写成先 delete 再判断 this != &rhsnew——自赋值时你先把 ps 释放了,然后用 *rhs.ps 去拷贝……而 rhs 就是自己,*rhs.psdelete 后已经是野指针了。

析构函数:对象死的时候该做什么

一个对象的生命走到尽头——离开作用域、被显式 delete、或智能指针的引用计数归零——此时必须有人把它的所有堆资源还回去。这个角色就是

~HasPtr() { delete ps; }
// 对象即将销毁 → 把 ps 指向的 string 还回堆 → 各成员逆序析构

什么时候必须自己写析构函数?一句话:当你的类管理了需要手动释放的资源时。具体信号是类里有裸指针成员指向动态分配的内存——如果你 new 了,就必须在析构里 delete。如上面的 HasPtr——ps 指向一个 new 出来的 string,析构函数不 delete ps 就是内存泄漏。

如果成员都是 stringvector、智能指针或其他 RAII 类型,通常不需要自定义析构。编译器合成的析构会按规则自动销毁基类和成员,它不是“空析构”,只是函数体无需你编写清理代码。

析构函数体执行完毕后,成员的销毁按声明顺序的逆序进行——后声明的先销毁。而析构函数本身不能有参数、不能重载——一个类只有一个析构函数。

三/五法则:为什么牵一发而动全身

前面讲的三个函数——拷贝构造、拷贝赋值、析构——构成了 。看上面 HasPtr 的例子:你定义了析构(因为有 delete ps)→ 就必须定义拷贝构造和拷贝赋值(避免浅拷贝导致双重释放)。

C++11 引入移动语义后, 提醒你同时审查移动构造与移动赋值,而不是要求机械地手写五份代码。

五法则(Rule of Five):五个特殊成员函数的关系如果自定义了其中任何一个,通常就需要定义全部五个~ClassName()析构函数(Destructor)释放资源 · 对象销毁时自动调用 · 只能有一个ClassName(const ClassName&)拷贝构造函数用已有对象初始化新对象 · 应做深拷贝ClassName& operator=(const ClassName&)拷贝赋值运算符将已有对象赋值给另一个已有对象 · 应释放旧资源ClassName(ClassName&&) noexcept移动构造函数(C++11)"窃取"源对象资源 · 源对象置为可析构状态ClassName& operator=(ClassName&&) noexcept移动赋值运算符(C++11)释放当前资源 · 接管源对象资源 · 源对象置空📋 左值拷贝语义 —— 不修改源🔄 右值移动语义 —— 窃取资源拷贝后也需各自释放移动后也需释放旧资源💡 法则:如果你需要自定义析构、拷贝或移动中的任意一个,大概率这五个全都要管
析构函数是资源管理的基础。拷贝控制(左)用 const 左值引用、不修改源对象。移动控制(右)用右值引用、窃取源对象资源并把源置为可安全析构状态。五法则:自定义了其中任一个,通常这五个都需要显式定义或显式 = default / = delete。

看上图的关系——析构函数是基础:它决定了"资源应该怎么还"。拷贝控制(构造+赋值)决定了"怎么安全复制一份"。移动控制(构造+赋值)决定了"怎么高效转移所有权,不复制数据"。

实际决策口诀:

  • 如果不需要任何资源管理 → 全都不写,让编译器合成(= 零法则)
  • 优先把资源放进 RAII 成员,外层类继续遵循零法则
  • 若类直接定义资源政策 → 审查五项,按语义实现、= default= delete,不要先写析构再倒推设计

移动语义:不拷贝数据,只转移所有权

拷贝要做深拷贝——分配新内存并逐字节复制。如果源对象马上就要被销毁(比如一个临时对象、函数返回的局部变量),这完全是浪费——明明直接把数据"搬过来"就行。

C++11 引入 。对这个 HasPtr,移动可以接管裸指针并把源指针置空,因此是 O(1):

// 移动构造函数:接管 rhs 的资源,把它置空
HasPtr(HasPtr &&rhs) noexcept
    : ps(rhs.ps), i(rhs.i) {
    rhs.ps = nullptr;  // 源对象安全了——析构时 delete nullptr 是空操作
}

T&& 是移动重载的类型基础;是否真正转移资源取决于被调用函数的实现。

只是类型转换;真正的状态变化由随后调用的移动构造、移动赋值或其他右值重载决定。

移动语义:资源所有权的转移源对象(source)ptr = 0x1000 | size = 100持有堆资源的所有权——正常使用中堆资源 @ 0x1000[100 个 int 的数据...]目标对象(destination)ptr = nullptr尚未被构造/赋值——等待接管资源target.ptr = source.ptr; source.ptr = nullptr;← 两步原子操作:先接管指针,再把源指针置空源对象(source)ptr = nullptr不再持有资源 · 处于"有效但未指定"状态目标对象(destination)ptr = 0x1000 | size = 100接管了堆资源的所有权 ✓ — 负责释放
Step 1:源持有资源,目标为空。Step 2:执行 ptr 转移 + 源指针置空(O(1) 常数时间,无内存分配)。Step 3:目标接管资源负责释放,源处于「有效但未指定」状态(可安全析构或重新赋值)。

移动赋值运算符和拷贝赋值的结构类似——先释放自己当前持有的资源,再接管源的资源:

HasPtr& operator=(HasPtr &&rhs) noexcept {
    if (this != &rhs) {        // 防止自赋值(虽然 a = std::move(a) 很罕见)
        delete ps;             // 释放当前资源
        ps = rhs.ps;           // 接管 rhs 的资源
        i = rhs.i;
        rhs.ps = nullptr;      // rhs 安全了
    }
    return *this;
}

移动后源对象处于什么状态?"有效但未指定"——你可以销毁它(析构安全),可以重新给它赋值,但不能假设它里面还有什么。上面的代码把 rhs.ps 设成了 nullptr——析构时 delete nullptr 合法且无事发生,这是最典型的做法。

noexcept 为什么重要?vector 重分配要维持异常保证,通常通过 move_if_noexcept 风格选择:移动构造可证明不抛时优先移动;若移动可能抛且类型可拷贝,可能改用拷贝;若类型不可拷贝,只能使用可能抛的移动并接受相应保证限制。只有实现确实不会抛时才能标 noexcept,不能为性能谎报。

= delete:明确说「这个操作不允许」

有些类的语义里,拷贝根本不成立。比如 unique_ptr——同一时刻只有一个所有者,拷贝就是违反它存在的意义。C++11 提供了 = delete 语法来在编译期杜绝这种行为:

class NonCopyable {
public:
    NonCopyable() = default;
    NonCopyable(const NonCopyable&) = delete;            // 禁止拷贝构造
    NonCopyable& operator=(const NonCopyable&) = delete;  // 禁止拷贝赋值
};

= default(请求编译器合成默认版本)相反,= delete 明确告诉编译器"这个函数不存在"。任何试图拷贝 NonCopyable 的代码都会在编译期直接报错——不会默默出 bug。

即使不写 = delete,某些场景下编译器也会静默删除合成的拷贝控制成员:比如类里有一个 const 成员——const 不能被赋值,所以合成的拷贝赋值被隐式 = delete。又比如类里有一个 unique_ptr 成员——unique_ptr 不能拷贝,于是整个类的合成拷贝构造和拷贝赋值都被删除。

实际应用口诀:① 需要独占资源 → 只保留移动,删除拷贝;② 需要共享资源 → 保留拷贝,移动自动生成或也手动写;③ 对象语义上不可拷贝(如数据库连接、互斥锁)→ 直接 = delete 拷贝两个。

动手:一步步看资源所有权的转移

下面用一个 Stepper 演示一个 HasPtr 对象被 std::move 后,它的 ps 指针如何从指向堆数据变成了 nullptr。每一步下方配一张 MoveSemanticsDiagram 让你直观看到内存里发生了什么。

猜一猜:执行 auto dest = std::move(source); 之后,source.ps 还是原来那个值吗?用 Stepper 逐步验证你的猜想。

分步1 / 3

① 移动前:源对象持有堆资源

移动语义:资源所有权的转移源对象(source)ptr = 0x1000 | size = 100持有堆资源的所有权——正常使用中堆资源 @ 0x1000[100 个 int 的数据...]目标对象(destination)ptr = nullptr尚未被构造/赋值——等待接管资源target.ptr = source.ptr; source.ptr = nullptr;← 两步原子操作:先接管指针,再把源指针置空源对象(source)ptr = nullptr不再持有资源 · 处于"有效但未指定"状态目标对象(destination)ptr = 0x1000 | size = 100接管了堆资源的所有权 ✓ — 负责释放
Step 1:源持有资源,目标为空。Step 2:执行 ptr 转移 + 源指针置空(O(1) 常数时间,无内存分配)。Step 3:目标接管资源负责释放,源处于「有效但未指定」状态(可安全析构或重新赋值)。

源对象 source 正常存在——ps 指向堆上一个 string("hello")i 值为 0。目标对象 dest 尚不存在(如果是移动构造)或已存在并持有自己的旧资源(如果是移动赋值)。此时源对象一切正常,可以正常读写。图中源指针用紫色箭号指向堆资源,说明所有权归源对象。

完整生命周期:一口看完从生到死的六个阶段

结合前面所有概念——把拷贝、移动、析构串在一起,看一个管理堆资源的对象从构造到销毁的完整旅程:

对象完整生命周期:从诞生到消亡每个阶段对应一个特殊成员函数——资源状态在栈帧间流转1默认构造ClassName()2资源分配new / make_*3拷贝构造ClassName(const &)4拷贝赋值operator=(const &)5移动构造ClassName(&&)6析构~ClassName()ptr = nullptr无资源ptr = 0x1000持有资源新 ptr = 0x2000新对象独立资源先 delete 0x1000 再 new释放旧+分配新ptr = 0x1000接管资源·源置空delete ptr释放资源·对象死📐 生命周期核心准则① 构造时获取资源(构造/拷贝/移动),析构时释放——对称② 拷贝构造应做深拷贝——新对象独立于源对象③ 移动就是把指针"偷"过来——O(1)零分配,源对象置于可析构状态
对象生命周期六阶段:默认构造(无资源)→ 分配资源(持有堆数据)→ 拷贝构造(新对象独立副本)→ 拷贝赋值(释放旧资源、分配新副本)→ 移动构造(接管资源、源置空)→ 析构(释放一切)。每一步对应一个特殊成员函数——这正是 五法则 想要规范的全部行为。

上图按时间顺序展示了六个阶段。注意几个关键转折:拷贝构造创建新堆内存(深拷贝),拷贝赋值先 delete 旧内存再创建新的(自赋值也安全),移动构造直接把指针"偷"过来(源变空),析构做最后清理(delete 所有堆资源)。

代码逐段拆解:HasPtr 五法则完整实现

以下是前面 HasPtr 的完整五法则实现——每一个特殊成员函数都手动写了,每一项设计决策都有解释。

类定义骨架 + 构造函数

#include <string>
#include <iostream>
 
class HasPtr {
    friend void swap(HasPtr&, HasPtr&);
 
public:
    // 默认构造
    HasPtr(const std::string &s = std::string())
        : ps(new std::string(s)), i(0) {}
 
    // 拷贝构造
    HasPtr(const HasPtr &orig)
        : ps(new std::string(*orig.ps)), i(orig.i) {}

析构决定"怎么释放"、拷贝构造决定"怎么安全复制"——这两个是五法则的基础。构造的 ps 总是通过 new 分配新的堆字符串——无论是默认构造还是拷贝构造。i 是普通 int,直接复制即可。注意拷贝构造没有释放任何东西——this 刚分配,还没有旧资源。

拷贝赋值 + 析构

    // 拷贝赋值
    HasPtr& operator=(const HasPtr &rhs) {
        auto *newp = new std::string(*rhs.ps);  // 先分配新——防止异常丢失旧数据
        delete ps;                               // 再释放旧
        ps = newp;                               // 接管新
        i = rhs.i;
        return *this;
    }
 
    // 析构
    ~HasPtr() { delete ps; }

拷贝赋值三步策略:先分配新 → 再释放旧 → 接管新。这是异常安全的标准做法。自赋值时 newp 是旧数据的副本,之后 delete ps 释放旧数据,不存在"删了自己又读自己"的问题。返回 *this 引用支持 a = b = c

析构函数简单直接——delete psps 可能为 nullptr(如果对象被移动过),delete nullptr 合法且什么都不做。析构完成后各成员按声明逆序析构——这里 iint,析构它什么也不做。

移动构造 + 移动赋值

    // 移动构造
    HasPtr(HasPtr &&rhs) noexcept
        : ps(rhs.ps), i(rhs.i) {
        rhs.ps = nullptr;
    }
 
    // 移动赋值
    HasPtr& operator=(HasPtr &&rhs) noexcept {
        if (this != &rhs) {
            delete ps;
            ps = rhs.ps;
            i = rhs.i;
            rhs.ps = nullptr;
        }
        return *this;
    }

移动构造直接接管源对象的指针——ps(rhs.ps)——没有任何 new,这是 O(1) 时间操作。紧接着 rhs.ps = nullptr 把源对象置安全。标了 noexcept——vector 扩容时会优先用移动。

移动赋值先处理自赋值检测(this != &rhs 可防止 a = std::move(a) 这样的诡异操作),再 delete 当前资源 → 接管 rhs 的指针 → 把 rhs 置空。

swap 辅助函数

private:
    std::string *ps;
    int i;
};
 
// 自定义 swap——高效交换两个对象的资源
inline void swap(HasPtr &lhs, HasPtr &rhs) {
    using std::swap;
    swap(lhs.ps, rhs.ps);  // 只交换指针——不拷贝 string
    swap(lhs.i, rhs.i);
}

自定义 swap 只交换两个对象的指针与整数,不触碰字符串内容,并可被采用 using std::swap; swap(a,b); 的泛型代码通过 ADL 找到。C++11 的通用 std::swap 通常使用一次移动构造和两次移动赋值,不是固定的深拷贝;为资源类提供专用 swap 仍能直接表达不抛、常数时间交换契约。

容易踩的坑

小结

  • 合成的拷贝逐成员拷贝——指针成员只复制地址(浅拷贝),导致共享堆数据和双重释放。管理堆资源的类必须自定义深拷贝
  • 五法则:自定义析构/拷贝/移动中任一个,大概率全部五个都要管——它们是一整套资源管理决策
  • 拷贝赋值三步走「先分配新 → 再释放旧 → 接管新」——异常安全且自赋值也正确
  • HasPtr 的移动是 O(1) 指针转移;移动复杂度与源后置状态由类型决定,真实不抛的移动标 noexcept 有助于容器选择移动路径
  • = delete 在编译期禁止不该发生的操作——比运行时崩溃好一万倍。= default 请求编译器合成默认版本——用在想保留默认行为但编译器不再自动生成的场景

练习

问题 1(改代码型) 下面的 StringHolder 类管理一个动态分配的 C 风格字符串——找出拷贝控制相关的问题,写出修正后的完整五法则实现(包含移动语义)。

#include <cstring>
 
class StringHolder {
public:
    StringHolder(const char *s) : sz(std::strlen(s)) {
        data = new char[sz + 1];
        std::strcpy(data, s);
    }
    ~StringHolder() { delete[] data; }
 
private:
    char *data;
    std::size_t sz;
};

问题 2(独立实现题) 设计并实现一个 ResourceManager 类:

  • 它管理一个 int* 动态分配的整数,初始为 nullptr
  • 默认构造:ptr = nullptr
  • allocate(int value):分配 new int(value) 并设置给 ptr(如果已分配则先释放旧的)
  • 完整五法则实现(拷贝/移动/析构)
  • int getValue() const:返回 *ptr,如果 ptr == nullptr 抛异常 std::runtime_error
  • 在 main 中测试:创建 rm1、给它分配值 42、用移动构造创建 rm2、验证 rm2.getValue() == 42rm1ptr 为 nullptr

问题 3(问答型) Message 类包含一个 string 和一个指向 Folder 的指针。如果 Folder 不归 Message 所有(Message 不负责删它),Message 需要自定义析构函数吗?拷贝构造呢?为什么?五法则里哪些需要写?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

合成的拷贝控制成员(synthesized copy control)

编译器按基类和非静态成员逐个执行对应操作而生成的特殊成员。标量复制值,类成员调用自身拷贝;若某个子操作不可用,合成函数可能被定义为 deleted。拥有裸资源的类不能未经语义审查就依赖合成版本。

浅拷贝(shallow copy)

只拷贝指针本身的值(地址),不拷贝指针指向的堆数据。结果:两个对象的指针指向同一块堆内存——修改互相影响,析构导致双重释放。合成的拷贝控制成员对指针就是浅拷贝。类比:只复印了取货单上的货架号,两个人拿着同一张单去同一个货架拿东西。

双重释放(double free)

对同一块堆内存执行两次 delete 的严重错误。属于未定义行为——可能立即崩溃、静默损坏内存、或延迟崩溃。浅拷贝最常见的后果——两个对象析构时各自 delete 同一个指针。修法:深拷贝——每个对象管自己的堆数据。

拷贝构造函数(copy constructor)

用已有对象初始化新对象的构造函数——形如 ClassName(const ClassName&)。新对象刚诞生、没有旧资源——只需分配新堆内存并复制数据。必须传引用(否则拷贝参数本身又触发拷贝构造——无限递归)。

拷贝赋值运算符(copy assignment operator)

把已有对象值赋给另一个已有对象的运算符。实现需兼顾自赋值与异常安全,常先取得新状态再提交,或使用 copy-and-swap;通常返回自身引用以支持链式赋值。

析构函数(destructor)

对象销毁时调用的特殊函数,无参数、无返回类型且不能重载。函数体运行后,成员按声明逆序自动销毁。若资源由 RAII 成员管理,合成析构通常足够;直接承担资源政策时才需要自定义清理。

三法则(Rule of Three)

C++ 经典准则:自定义了析构、拷贝构造或拷贝赋值中任意一个,几乎一定需要全部三个。因为这三个都是"资源管理"的不同侧面——从不同入口做同一件事。反例:如果你不需要析构(没有资源),大概率拷贝也只要合成的版本。

五法则(Rule of Five)

设计资源类时把析构、两项拷贝和两项移动作为一组审查的准则。每项可实现、default 或 delete,并非机械要求手写五个;能由 RAII 成员承担资源时优先采用零法则。

移动语义(move semantics)

允许类型为右值或 xvalue 提供转移式构造与赋值的机制。移动可接管资源,也可逐成员移动;复杂度和源对象后置状态由类型契约决定,不普遍保证 O(1) 或零分配。

右值引用(rvalue reference)

写作 T&&,可绑定临时量或经转换得到的 xvalue。它使重载决议能选择移动入口,但不保证对象即将销毁,也不自动转移资源。

std::move

把表达式无条件转换为对应 xvalue 的标准库工具。本身不移动、不检查安全性;只有后续调用了会修改源的右值重载,源状态才按该类型契约发生变化。

资料与写作方式声明

本章以C++ Primer, Fifth Edition, Chapter 13: Copy Control权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…