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
为什么一个指针成员会改变整个类的复制语义
类与动态内存(dynamic memory and classes)的风险不在 new 语法本身,而在对象是否宣称拥有那块存储。编译器生成的复制操作按成员复制:整数复制值,指针也只复制地址。若两个对象都认为自己是唯一 owner,它们会修改同一缓冲区,并在析构时释放同一地址两次。
- 2
析构函数只解决一个对象的退出路径
构造函数先分配并建立字符串值,析构函数使用与分配匹配的 delete[] 。析构必须能处理每个成功构造的状态,并且不能让异常逃出。静态成员计数可以观察存活对象数量,但它是类级共享状态,不属于每个对象。
- 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,它们会修改同一缓冲区,并在析构时释放同一地址两次。
#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 = a 和 a = 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::string、std::vector、std::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 会把无限增长变成拒绝,不能只看到队长受控就称系统稳定。
三步审计动态资源类
第一步:画所有权与复制状态
标出每个指针的 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:复现浅复制。 两个拥有
char*的对象默认复制后,怎样用地址与析构顺序证明错误?
- 问题 2:审计 operator=。 为什么
delete[] data_; data_=new...不能满足强失败保证?
- 问题 3:验证队列模拟。 到达率大于服务率但容量固定时,为什么队长看似稳定仍不能判定系统健康?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 所有权
- 对象最终释放资源并维持其唯一或共享协议的责任。
- 动态内存
- 运行时取得并由 owner/RAII 结束生命周期的存储。
- 析构函数
- 对象退出时完成资源清理责任的特殊成员函数。
- 复制构造函数
- 根据已有对象建立新对象并定义资源关系的构造函数。
- 赋值运算符
- 把已存在左对象替换为右对象值的 operator= 成员。
- 对象指针
- 保存类对象地址但不自动表达 owner 和有效期的指针。
- 队列
- 按 FIFO 规则入队和出队的抽象数据类型。