Chapter 12:Classes and Dynamic Memory Allocation

对齐第6版 Chapter 12:掌握类与动态内存、复制构造函数、赋值运算符、对象指针、Rule of Three 与链式队列模拟。

学习目标

  • 能解释默认成员复制为何会让动态内存 owner 产生别名、double delete 与悬空指针,并画出对象/堆状态
  • 能实现满足 Rule of Three 的复制构造、复制赋值与析构,复现自赋值和分配失败路径
  • 能设计有容量与 FIFO 不变量的链式 Queue,并分析到达率、服务率、拒绝率和等待时间的模拟证据

机制总览

Chapter 12:Classes and Dynamic Memory Allocation:机制路径

  1. 1

    为什么一个指针成员会改变整个类的复制语义

    类与动态内存(dynamic memory and classes)的风险不在 new 语法本身,而在对象是否宣称拥有那块存储。编译器生成的复制操作按成员复制:整数复制值,指针也只复制地址。若两个对象都认为自己是唯一 owner,它们会修改同一缓冲区,并在析构时释放同一地址两次。

  2. 2

    析构函数只解决一个对象的退出路径

    构造函数先分配并建立字符串值,析构函数使用与分配匹配的 delete[] 。析构必须能处理每个成功构造的状态,并且不能让异常逃出。静态成员计数可以观察存活对象数量,但它是类级共享状态,不属于每个对象。

  3. 3

    复制构造函数建立独立的新对象

    复制构造函数(copy constructor)通常接收 const Class& ,用于以同类型对象初始化新对象。复制初始化、按值传参、按值返回(是否消除复制由规则决定)和容器操作都可能需要它。深复制要分配独立存储并复制完整值。

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

章级决策实验

Chapter 12:Classes and Dynamic Memory Allocation:机制与证据

切换《Chapter 12:Classes and Dynamic Memory Allocation》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。

选择推理阶段

当前阶段 · 为什么一个指针成员会改变整个类的复制语义

类与动态内存(dynamic memory and classes)的风险不在 new 语法本身,而在对象是否宣称拥有那块存储。编译器生成的复制操作按成员复制:整数复制值,指针也只复制地址。若两个对象都认为自己是唯一 owner,它们会修改同一缓冲区,并在析构时释放同一地址两次。

可核验证据

从干净构建开始,以固定输入运行本节示例,再加入一个边界或故障场景验证「为什么一个指针成员会改变整个类的复制语义」的状态变化。

学完《Chapter 12:Classes and Dynamic Memory Allocation》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。

失效—证据矩阵

Chapter 12:Classes and Dynamic Memory Allocation:失效与核验

为什么一个指针成员会改变整个类的复制语义

典型失效

若只复述「为什么一个指针成员会改变整个类的复制语义」结论而不追踪状态、所有权和失败路径,示例扩展成多文件或多对象程序后就容易偏离预期。

核验证据

从干净构建开始,以固定输入运行本节示例,再加入一个边界或故障场景验证「为什么一个指针成员会改变整个类的复制语义」的状态变化。

析构函数只解决一个对象的退出路径

典型失效

若只复述「析构函数只解决一个对象的退出路径」结论而不追踪状态、所有权和失败路径,示例扩展成多文件或多对象程序后就容易偏离预期。

核验证据

从干净构建开始,以固定输入运行本节示例,再加入一个边界或故障场景验证「析构函数只解决一个对象的退出路径」的状态变化。

复制构造函数建立独立的新对象

典型失效

若只复述「复制构造函数建立独立的新对象」结论而不追踪状态、所有权和失败路径,示例扩展成多文件或多对象程序后就容易偏离预期。

核验证据

从干净构建开始,以固定输入运行本节示例,再加入一个边界或故障场景验证「复制构造函数建立独立的新对象」的状态变化。

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

为什么一个指针成员会改变整个类的复制语义

类与动态内存(dynamic memory and classes)的风险不在 new 语法本身,而在对象是否宣称拥有那块存储。编译器生成的复制操作按成员复制:整数复制值,指针也只复制地址。若两个对象都认为自己是唯一 owner,它们会修改同一缓冲区,并在析构时释放同一地址两次。

owned-text.hpp
#include <cstddef>
 
class OwnedText {
public:
    explicit OwnedText(const char* text);
    ~OwnedText();
 
private:
    char* data_;
    std::size_t length_;
};

只声明析构函数仍不完整。OwnedText b = a 会调用隐式复制构造函数,把 data_ 地址原样交给 b;两个析构函数随后都执行 delete[] data_。必须先决定:复制对象是否共享、深复制、禁止复制,还是转移所有权。

析构函数只解决一个对象的退出路径

构造函数先分配并建立字符串值,析构函数使用与分配匹配的 delete[]。析构必须能处理每个成功构造的状态,并且不能让异常逃出。静态成员计数可以观察存活对象数量,但它是类级共享状态,不属于每个对象。

#include <cstring>
 
OwnedText::OwnedText(const char* text)
    : data_{nullptr}, length_{std::strlen(text)} {
    data_ = new char[length_ + 1];
    std::memcpy(data_, text, length_ + 1);
}
 
OwnedText::~OwnedText() {
    delete[] data_;
}

若构造期间 new 失败,对象有效期不会开始,析构函数也不会运行;已构造的成员会自动回滚。裸指针构造代码必须让失败前没有泄漏,使用 std::string 成员则可把这项责任交给成员类型。

复制构造函数建立独立的新对象

复制构造函数(copy constructor)通常接收 const Class&,用于以同类型对象初始化新对象。复制初始化、按值传参、按值返回(是否消除复制由规则决定)和容器操作都可能需要它。深复制要分配独立存储并复制完整值。

OwnedText::OwnedText(const OwnedText& other)
    : data_{new char[other.length_ + 1]},
      length_{other.length_} {
    std::memcpy(data_, other.data_, length_ + 1);
}

复制后 a.data_ != b.data_,但字符内容相等;修改一方不影响另一方,两个析构各释放自己的数组。参数必须是引用,否则为传入参数又需要复制构造,会无限递归;const 让源对象不被修改且可复制 const 值。

复制赋值替换一个已经存在的值

赋值运算符(assignment operator)与复制构造的起点不同:左对象已经拥有资源,必须在不泄漏、不双重释放的前提下替换它。还要处理 a = aa = b = c

OwnedText& OwnedText::operator=(const OwnedText& other) {
    if (this == &other) {
        return *this;
    }
 
    char* fresh = new char[other.length_ + 1];
    std::memcpy(fresh, other.data_, other.length_ + 1);
 
    delete[] data_;
    data_ = fresh;
    length_ = other.length_;
    return *this;
}

这里先成功创建新状态再释放旧状态;若分配抛出,左对象保持原值。返回 *this 的引用支持链式赋值,并避免额外复制。

Rule of Three 是一条所有权一致性警报

若类需要自定义析构、复制构造或复制赋值中的任一个,通常三个都要审查,这就是 Rule of Three。它不是要求所有类都手写三项,而是提醒“资源责任已经超出默认成员语义”。现代代码更理想的 Rule of Zero 是让 std::stringstd::vectorstd::unique_ptr 等成员携带所有权协议,外层类无需手工控制复制。

class Text {
public:
    explicit Text(std::string value) : value_{std::move(value)} {}
 
private:
    std::string value_;  // copy/destruction already coherent
};

若语义是唯一 owner,可删除复制并定义移动;若语义是共享 owner,使用 shared_ptr 还需明确共享的是不可变值、可变状态还是缓存。选工具前必须先写对象价值语义。

对象指针把访问有效期与对象有效期分开

对象指针(pointers to objects)使用 -> 访问成员;指针变量本身和目标对象具有不同生命周期。栈对象地址在离开作用域后悬空;动态对象地址在 delete 后悬空;对象数组指针还需携带元素数量。

OwnedText local{"alpha"};
OwnedText* borrowed = &local;     // non-owning; valid only with local
 
OwnedText* owned = new OwnedText{"beta"};
// use owned->member() through public interface
delete owned;
owned = nullptr;

nullptr 只表示当前不指向对象,不能修复其他别名。生产代码应以自动对象或智能指针表达 owner,裸指针/引用只表达范围受控的借用。

Queue 用链式节点隔离动态分配

Queue 抽象数据类型按 FIFO 取出最早进入的元素。链式表示需要 front、rear、item count 和 capacity 不变量:空队列两端都为空;非空 rear 指向最后节点;节点数量等于 count;count 不超过 capacity。调用者不应看到 Node。

class Queue {
public:
    explicit Queue(std::size_t capacity);
    ~Queue();
    Queue(const Queue&) = delete;
    Queue& operator=(const Queue&) = delete;
 
    bool enqueue(const Customer& item);
    bool dequeue(Customer& out);
 
private:
    struct Node { Customer item; Node* next; };
    Node* front_{nullptr};
    Node* rear_{nullptr};
    std::size_t count_{0};
    const std::size_t capacity_;
};

enqueue 先检查容量并分配节点,成功后连接 rear;从空队列进入非空时 front/rear 同时指向首节点。dequeue 保存旧 front、复制 item、推进 front 后删除旧节点;删除最后节点时还要把 rear 归零。析构循环删除全部节点。

队列模拟要验证系统,不只验证容器

客户模拟把随机到达、服务持续时间、当前服务任务与 Queue 组合。每个时间片先推进服务,再按到达模型尝试入队,然后在服务台空闲时出队。除了队列长度,还要累计 served、rejected、总等待时间和最大队长。

for (int minute = 0; minute < horizon; ++minute) {
    if (serviceRemaining > 0) {
        --serviceRemaining;
    }
    if (arrives(rng)) {
        Customer c{minute, sampleService(rng)};
        if (!line.enqueue(c)) ++rejected;
    }
    if (serviceRemaining == 0 && line.dequeue(current)) {
        totalWait += minute - current.arrivalMinute();
        serviceRemaining = current.serviceMinutes();
        ++served;
    }
}

先预测到达率低于、等于和高于服务率时队长趋势,再切换实验面板。有限 capacity 会把无限增长变成拒绝,不能只看到队长受控就称系统稳定。

三步审计动态资源类

分步1 / 3

第一步:画所有权与复制状态

标出每个指针的 owner、borrower、分配形式和销毁者,比较 shallow copy、deep copy、move 与 RAII member。

小结

  • 类拥有动态内存时,默认 pointer member copy 会产生别名,不能替代资源语义
  • copy constructor 建立新对象,assignment operator 替换已有对象,两者都要与 destructor 的 owner 协议一致
  • Rule of Three 提醒审查复制控制;优先用 RAII member 达成 Rule of Zero
  • pointers to objects 不携带 owner 或有效期,裸指针更适合短期借用
  • Queue 必须维持 front/rear/count/capacity 不变量,队列模拟还需测量拒绝和等待

练习

  1. 问题 1:复现浅复制。 两个拥有 char* 的对象默认复制后,怎样用地址与析构顺序证明错误?
  1. 问题 2:审计 operator=。 为什么 delete[] data_; data_=new... 不能满足强失败保证?
  1. 问题 3:验证队列模拟。 到达率大于服务率但容量固定时,为什么队长看似稳定仍不能判定系统健康?

名词解释

名词解释

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

所有权
对象最终释放资源并维持其唯一或共享协议的责任。
动态内存
运行时取得并由 owner/RAII 结束生命周期的存储。
析构函数
对象退出时完成资源清理责任的特殊成员函数。
复制构造函数
根据已有对象建立新对象并定义资源关系的构造函数。
赋值运算符
把已存在左对象替换为右对象值的 operator= 成员。
对象指针
保存类对象地址但不自动表达 owner 和有效期的指针。
队列
按 FIFO 规则入队和出队的抽象数据类型。

讨论

评论区加载中…