第13章:不常见的数据类型

从结构、指针和全局数据出发,练习用所有权、别名、访问边界与生命周期验证不常见数据类型。

学习目标

  • 能解释结构、指针和全局数据怎样共同约束对象的所有权、别名、访问权限与生命周期。
  • 能用 C++ 或 C 写出一个带明确释放责任的访问路径,并诊断空值、悬空访问和共享状态泄漏。
  • 能先预测一个故障的首个偏离点,再用实验、反例和重置后的重放证据验收修法。

先从一个失效的引用开始

把一个对象想成一间有门牌号的房间:地址只告诉你“房间在哪里”,不告诉你谁有钥匙、房间是否还存在,也不说明访客能不能改动里面的东西。第13章讨论的结构、指针和全局数据,表面上是三种不同主题,实际都在回答同一个问题:引用在什么条件下仍然值得信任?

如果释放对象后还有一条路径保留地址,程序可能暂时读到“正确”内容,但那只是未被覆盖的旧记忆;如果全局变量被悄悄改写,调用者即使传入同样参数也得不到同样结果。本章把这些不稳定性改写成可检查的合同:拥有者负责结束生命周期,借用者只能在允许的边界内访问,状态变化必须能追溯到显式输入。

先预测:当你把“拥有者释放对象”改成“多个调用者都可以释放对象”时,最先变化的会是结构、指针还是全局数据?动手试验时一次只切换一个观察面,并保留正常路径、边界路径、故障路径和复位后的轨迹。

第13章 · 引用合同实验

一个对象,三种责任检查

先猜哪一项会先失效,再切换观察面并注入故障。实验不计算假分数,只展示能否说明责任链。

先把不变量放进对象边界观察面:结构 · 故障开关只改变一个条件所有权责任已声明容器对象别名责任已声明等待检查生命周期责任已声明等待检查通过通过:所有权、访问边界和生命周期可以被复述记录首个偏离点,然后回到同一基线重放;这才是生命周期验证。

通过:所有权、访问边界和生命周期可以被复述

第13章 不常见的数据类型

本章的判断式可以写成:safe reference = valid owner + live object + permitted access。它不是让代码看起来更复杂的口号,而是一个审查顺序。先问“谁负责”,再问“对象还活着吗”,最后问“这条路径被允许做什么”。任何一步没有证据,引用就只能被当作待验证的假设。

13.1 结构

结构首先是责任边界,而不只是字段的排列。一个容器若把不变量交给调用者维护,外部代码就可能制造半初始化对象、重复释放或无效索引。更稳妥的做法是让构造函数建立合法初始状态,让公开操作检查前置条件,并让析构过程集中处理资源回收。

对象的 需要有唯一的默认答案;如果确实存在共享拥有,就把共享规则和最后一次释放的条件写进接口。仅仅把裸地址放进字段并不能表达责任,类型、命名和文档都应让审查者在不运行程序时也能推出边界。

13.2 指针

指针把“对象在哪里”和“对象能否被访问”拆成了两个问题。创建指针时要记录它是拥有者还是借用者;传递指针时要说明可空性、可变性和有效期;释放之后要让所有仍可达的路径失效。这样的 才不会被误当作第二个拥有者。

用来理解指针的例子

设一个目录节点由 owner 持有,viewer 只读借用。owner 销毁节点后,viewer 仍然保存地址并不构成合法访问;把地址打印出来也不能证明对象存在。这个 需要沿“创建—借用—释放—再次访问”记录状态,而不是只看最后一次输出。

使用指针的一般技巧

先把指针用途分成拥有、借用和观察三类,再为每一类规定初始化、空值检查、转移和结束条件。需要跨函数传递时,优先使用能表达责任的类型;必须使用裸指针时,也要在参数名、注释或契约中写明“谁释放、何时失效、能否为空”。

分步1 / 3

1. 先声明结构:不变量由谁守住

不常见数据类型的安全边界先声明责任,再允许访问;释放不能留下仍可使用的别名1所有权谁负责释放访问前检查合同2别名谁可以借用访问前检查合同3生命周期何时仍有效访问前检查合同safe reference = valid owner + live object + permitted access当前步骤:结构 · 用一条反例检查责任是否仍然可追踪
结构、指针和全局数据不是三张孤立清单,而是同一个引用合同的三个观察面。

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);
}

这段代码的重点不是智能指针的语法,而是 ownerview 的责任不同。审查者可以沿着 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。请补出接口契约中必须出现的三项信息。

名词解释

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

所有权
谁负责创建、维持不变量并结束对象生命周期;默认应能在代码中找到一个明确承担者。
别名
指向同一对象的另一条路径。它可以借用,但不应在没有协议时复制释放责任。
悬空指针
对象已经销毁,指针值却仍被保留。它偶尔读到旧数据也不代表访问合法。
访问子程序
把共享状态的读写集中到小接口中,由接口检查输入、维护不变量并返回结果。
隐藏输入
没有出现在参数里的状态,却能改变函数结果;全局可变数据是常见来源。

资料与写作方式声明

本章以《代码大全》(第2版)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

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

讨论

评论区加载中…