第13章:不常见的数据类型
从结构、指针和全局数据出发,练习用所有权、别名、访问边界与生命周期验证不常见数据类型。
学习目标
- 能解释结构、指针和全局数据怎样共同约束对象的所有权、别名、访问权限与生命周期。
- 能用 C++ 或 C 写出一个带明确释放责任的访问路径,并诊断空值、悬空访问和共享状态泄漏。
- 能先预测一个故障的首个偏离点,再用实验、反例和重置后的重放证据验收修法。
先从一个失效的引用开始
把一个对象想成一间有门牌号的房间:地址只告诉你“房间在哪里”,不告诉你谁有钥匙、房间是否还存在,也不说明访客能不能改动里面的东西。第13章讨论的结构、指针和全局数据,表面上是三种不同主题,实际都在回答同一个问题:引用在什么条件下仍然值得信任?
如果释放对象后还有一条路径保留地址,程序可能暂时读到“正确”内容,但那只是未被覆盖的旧记忆;如果全局变量被悄悄改写,调用者即使传入同样参数也得不到同样结果。本章把这些不稳定性改写成可检查的合同:拥有者负责结束生命周期,借用者只能在允许的边界内访问,状态变化必须能追溯到显式输入。
先预测:当你把“拥有者释放对象”改成“多个调用者都可以释放对象”时,最先变化的会是结构、指针还是全局数据?动手试验时一次只切换一个观察面,并保留正常路径、边界路径、故障路径和复位后的轨迹。
第13章 · 引用合同实验
一个对象,三种责任检查
先猜哪一项会先失效,再切换观察面并注入故障。实验不计算假分数,只展示能否说明责任链。
通过:所有权、访问边界和生命周期可以被复述
第13章 不常见的数据类型
本章的判断式可以写成:safe reference = valid owner + live object + permitted access。它不是让代码看起来更复杂的口号,而是一个审查顺序。先问“谁负责”,再问“对象还活着吗”,最后问“这条路径被允许做什么”。任何一步没有证据,引用就只能被当作待验证的假设。
13.1 结构
结构首先是责任边界,而不只是字段的排列。一个容器若把不变量交给调用者维护,外部代码就可能制造半初始化对象、重复释放或无效索引。更稳妥的做法是让构造函数建立合法初始状态,让公开操作检查前置条件,并让析构过程集中处理资源回收。
对象的 ↡由代码明确记录、并负责对象释放或状态回收的一方 需要有唯一的默认答案;如果确实存在共享拥有,就把共享规则和最后一次释放的条件写进接口。仅仅把裸地址放进字段并不能表达责任,类型、命名和文档都应让审查者在不运行程序时也能推出边界。
13.2 指针
指针把“对象在哪里”和“对象能否被访问”拆成了两个问题。创建指针时要记录它是拥有者还是借用者;传递指针时要说明可空性、可变性和有效期;释放之后要让所有仍可达的路径失效。这样的 ↡指向同一个对象但不承担释放责任的第二个访问路径 才不会被误当作第二个拥有者。
用来理解指针的例子
设一个目录节点由 owner 持有,viewer 只读借用。owner 销毁节点后,viewer 仍然保存地址并不构成合法访问;把地址打印出来也不能证明对象存在。这个 ↡对象已结束生命周期但仍被访问的地址或引用 需要沿“创建—借用—释放—再次访问”记录状态,而不是只看最后一次输出。
使用指针的一般技巧
先把指针用途分成拥有、借用和观察三类,再为每一类规定初始化、空值检查、转移和结束条件。需要跨函数传递时,优先使用能表达责任的类型;必须使用裸指针时,也要在参数名、注释或契约中写明“谁释放、何时失效、能否为空”。
1. 先声明结构:不变量由谁守住
C++指针
C++ 的类型系统和 RAII 可以把释放责任放进对象生命周期。默认选择是让唯一拥有关系使用 std::unique_ptr;只有领域确实共享拥有时才考虑 std::shared_ptr,并检查循环引用。普通指针或引用更适合作为不拥有资源的借用视图,调用约定仍要说明它不能越过被拥有对象的生命周期。
std::unique_ptr<Record> owner = make_record();
Record* view = owner.get(); // 借用,不负责 delete
if (view != nullptr) {
consume(*view);
}这段代码的重点不是智能指针的语法,而是 owner 和 view 的责任不同。审查者可以沿着 owner 的作用域找到释放点,也可以检查 view 是否被保存到更长寿命的对象中。
C指针
C 语言把分配和释放责任更多地交给约定。调用 malloc 的函数要明确返回值是否转移所有权;调用 free 前要保证指针来自可释放的分配路径,且释放后不再通过旧地址访问。把 NULL 检查、长度检查和清理顺序写进同一个失败路径,比靠调用者记忆更可靠。
char *buffer = malloc(capacity);
if (buffer == NULL) {
return ERR_NO_MEMORY;
}
use_buffer(buffer, capacity);
free(buffer);
buffer = NULL;在 C 里把指针设为 NULL 不能修复已经复制出去的别名,但它能阻止当前变量被误用;真正的修法仍是缩短借用范围,并让释放协议在接口层可见。
13.3 全局数据
全局数据把对象寿命扩展到模块甚至整个进程,却常常隐藏了“谁读、谁写、何时改变”。它可以用于确实代表单一进程资源的配置或只读表,但可变全局会让每个函数都拥有一份未声明的输入。判断是否合理时,应把并发、测试隔离、初始化顺序和重置成本一起算进去。
与全局数据有关的常见问题
常见故障包括初始化顺序依赖、测试之间残留旧状态、线程间无保护写入,以及某个函数修改了另一个函数依赖的值。它们的共同点是调用点看不到完整因果链:相同参数并不保证相同结果,因为真正的输入藏在模块外部。
使用全局数据的理由
使用全局数据应有可说清的理由,例如进程范围内唯一、只读且成本高的表,或确实需要共享的资源管理器。理由不能只是“省得传参数”;省下的参数会变成难以追踪的隐藏依赖,最后由调试、并发和测试隔离付出更高成本。
只有万不得已时才使用全局数据
“只有万不得已时”不是绝对禁止,而是要求先比较替代方案:参数传递、对象成员、局部缓存、依赖注入或只读配置。若决定保留全局数据,应写下可变字段、初始化入口、并发保护、读写者和清理时机,使后续维护者能判断它是否仍然值得保留。
用访问子程序来取代全局数据
把状态包在一个模块边界内,并通过 ↡把共享状态读写包在输入输出明确的函数边界里 提供最小操作面。调用者只提交参数并接收结果,模块负责检查范围、维护不变量和记录变更。这样做把全局读写改成显式协议,也让测试可以替换一份独立状态。
如何降低使用全局数据的风险
若暂时不能移除全局数据,至少做到四点:缩小可见范围,限制写入入口,区分只读与可变访问,并为重置和并发建立可执行的保护。每个依赖它的函数都要在文档或参数命名中承认这份依赖;否则这份 ↡没有通过参数传入、却会改变结果的共享可变状态 会继续伪装成纯函数。
其他资源
本章的目录范围以 《代码大全》(第2版)公开试读目录 为准;语言实践可对照 Microsoft Press 的 Code Complete 书目页 与 C++ Core Guidelines 中关于资源管理、所有权和接口边界的建议。链接用于核对范围与术语,不代替本章的独立推导。
关键点
- 结构负责守住不变量;指针负责表达访问路径;全局数据负责暴露共享边界,三者都不能把责任留给猜测。
- 原始地址不是所有权证明。先写拥有者、借用者、可空性和失效时机,再选择 C++ 智能指针或 C 的手工协议。
- 能移除的全局状态应移除;不能移除的状态要集中入口、明确初始化、限制写入,并证明复位后能重放同一结果。
- 遇到看似正确的输出,沿生命周期追查首个偏离:对象是否仍存活、访问是否获准、共享状态是否改变了真实输入。
本章小结与复盘
本章把“不常见的数据类型”从语法列表改成一条审查链:先确认结构不变量,再确认指针的所有权与生命周期,最后确认全局数据没有藏起共享可变状态。练习时先预测最早失效的节点,注入一个故障,记录拒绝理由,再点击实验重置并用同一输入重放。若重放轨迹不同,说明修法只遮住了症状,还没有恢复责任边界。
练习
问题 1:一个函数返回指向局部对象的指针。请指出调用者第一次解引用时可能违反哪一条合同,并给出一个 C++ 修法。
问题 2:一个测试修改全局 current_user 后没有清理,下一条测试依赖默认用户。请写出最小诊断步骤,并说明为什么“在下一条测试开头赋默认值”不是最稳妥的边界。
问题 3:某 C 接口返回一个缓冲区和长度,调用者不确定是否应该 free。请补出接口契约中必须出现的三项信息。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 所有权
- 谁负责创建、维持不变量并结束对象生命周期;默认应能在代码中找到一个明确承担者。
- 别名
- 指向同一对象的另一条路径。它可以借用,但不应在没有协议时复制释放责任。
- 悬空指针
- 对象已经销毁,指针值却仍被保留。它偶尔读到旧数据也不代表访问合法。
- 访问子程序
- 把共享状态的读写集中到小接口中,由接口检查输入、维护不变量并返回结果。
- 隐藏输入
- 没有出现在参数里的状态,却能改变函数结果;全局可变数据是常见来源。