IBM 交付了一套 NASA 卫星控制系统,每千行代码仅有 0.1 个缺陷。当时的行业平均水平在 10 到 50 之间。他们并非依靠招聘更聪明的工程师或延长工作时间来达成这一目标。他们靠的是禁止开发者运行自己的代码。
这就是 Cleanroom 软件工程,由 IBM 的 Harlan Mills 于 1970 年代开发。名称源自半导体制造业,其目标是预防产生缺陷的条件。质量不是测进去的,而是设计进去的。
问题:测试只能发现缺陷,不能预防缺陷
大多数软件工程都默认遵循写-测-修的循环。你写代码,运行它,发现缺陷,修复它。这感觉很有效率。但从数学上讲,它注定会留下缺陷。
测试只能证明缺陷存在,永远无法证明缺陷不存在。Dijkstra 在 1969 年就说过这一点。如果你的代码有一千条可能的执行路径,而测试套件只覆盖了五十条,那么就有九百五十条路径未经测试。当时行业平均 10-50 个缺陷每 KLOC 的缺陷率,并不是测试不够严格造成的。它是一个允许缺陷存在的流程所必然输出的结果。
Cleanroom 反其道而行。不是先写代码再测试,而是写出构造即正确的代码。
Cleanroom 究竟如何运作
这一方法包含三项严格且相互配合的实践。
第一,在统计过程控制下的增量式开发。 项目被拆分为多个小增量,每个增量增加一组定义明确的功能。每个增量都作为一个完整的单元,经历规格说明、设计、验证和测试。关键在于按增量测量缺陷率。如果某个增量超出缺陷目标,流程即被叫停,团队去查找方法本身出了什么问题,而不是追究个人责任。
第二,使用 box structure 的函数理论设计。 每个软件组件都在三个层次上定义:
- Black box: 纯数学函数形式的外部行为,从输入到输出。没有状态,没有实现细节。
- State box: 内部状态转换函数。组件维护什么状态,状态如何变化?
- Clear box: 实际实现,由已验证的组件构建而成。
在继续下一层之前,每一层都必须针对上一层进行验证。State box 未经证明正确,就不写 Clear box。Black box 未经证明正确,就不写 State box。这听起来很慢。其实不慢,因为你不需要调试。你不会在调试器里耗掉整个下午。代码第一次编译就能跑通。
第三——这也是最让人想不通的一点:开发者不能执行测试。 写代码的人不能编译、运行或单元测试自己的代码。测试由一个独立的团队使用统计方法进行。他们把软件当作 Black box,基于使用轮廓而不是代码结构来生成测试用例。
这种分离并非官僚主义的残忍。它是强制构造正确性的核心机制。如果你知道自己无法运行代码来“看看它能不能工作”,你就被迫在敲键盘之前把每种情况都想清楚。你不能指望编译器帮你抓错,也不能指望快速跑一下 python script.py 就能暴露显而易见的缺陷。你必须一次性写对。
重要的数字
IBM 联邦系统事业部在 1980 年代和 1990 年代的多个项目中使用了 Cleanroom。结果在不同团队、不同语言和不同应用领域都保持一致。
NASA 卫星控制系统:最终测试每 KLOC 0.1 个缺陷。COBOL 计费系统:每 KLOC 0.3 个缺陷。Ada 实时系统:每 KLOC 0.4 个缺陷。对比当时的行业平均:单元测试阶段每 KLOC 10-50 个缺陷,交付时仍残留 5-10 个。
Cleanroom 团队的交付速度也更快。当时的行业数据显示,50-70% 的软件工作量花在测试和调试上。Cleanroom 团队把这段时间用于设计,代码一次就能跑通。
为什么几乎没人使用
既然 Cleanroom 如此有效,为什么不是人人都在用呢?
你无法在一个冲刺里“随便试试”。这套方法是相互依赖的。统计过程控制只有在测量每个增量时才有效。box structure 只有在进行形式化验证时才有效。禁止执行规则只有绝对执行时才有效。部分采用带来的不是任何好处,而是全部开销。
市场也变了。1970 年代和 1980 年代,软件通过磁带交付。生产环境的缺陷修复成本高昂。今天我们通过网络交付,几分钟就能打补丁。对零缺陷软件的经济激励已经减弱。
大多数人也喜欢运行自己的代码。写-跑-修的即时反馈循环令人满足。Cleanroom 要求你把这种满足感推迟到彻底想清楚问题之后。对很多开发者来说,这感觉就像在不许回读的情况下写散文。
不用全盘接受,也能借鉴的要点
你大概无法在公司里实施完整的 Cleanroom。你的上司会用奇怪的眼神看你。但你可以采纳其中的部分做法,获得实际收益。
先写接口,再写实现。 在写函数体之前,先定义输入、输出和前置条件。说明函数承诺什么,而不只是它做什么。
# precondition: items is a non-empty list of comparable elements
# postcondition: returns the smallest element in items
# raises: ValueError if items is empty
def min_item(items: list) -> any:
if not items:
raise ValueError("items must not be empty")
smallest = items[0]
for item in items[1:]:
if item < smallest:
smallest = item
return smallest
这是个再简单不过的函数,但这种纪律可以扩展。更复杂的例子是:在实现之前先显式定义状态机。
from enum import Enum, auto
class ConnectionState(Enum):
DISCONNECTED = auto()
CONNECTING = auto()
CONNECTED = auto()
CLOSING = auto()
# Allowed transitions:
# DISCONNECTED -> CONNECTING (on connect())
# CONNECTING -> CONNECTED (on handshake complete)
# CONNECTING -> DISCONNECTED (on timeout/error)
# CONNECTED -> CLOSING (on close())
# CLOSING -> DISCONNECTED (on ack received)
# Any other transition is illegal and raises StateError
class StateMachine:
_transitions = {
ConnectionState.DISCONNECTED: {ConnectionState.CONNECTING},
ConnectionState.CONNECTING: {ConnectionState.CONNECTED, ConnectionState.DISCONNECTED},
ConnectionState.CONNECTED: {ConnectionState.CLOSING},
ConnectionState.CLOSING: {ConnectionState.DISCONNECTED},
}
def __init__(self):
self.state = ConnectionState.DISCONNECTED
def transition(self, new_state: ConnectionState) -> None:
if new_state not in self._transitions.get(self.state, set()):
raise StateError(f"Illegal transition: {self.state.name} -> {new_state.name}")
self.state = new_state
通过显式编码合法转换,你让非法状态无法表示。代码不可能进入无效状态,因为状态机会阻止它。这就是 box structure 哲学的微缩版。
让别人测试你的代码。 作者与验证者的分离是 Cleanroom 最激进、却也最容易移植的想法。当你测试自己的代码时,你只是在验证自己的假设。别人会试到你没考虑到的那个情况,因为你觉得它“显然”不会发生。
按工作单位测量缺陷密度。 追踪每个流程阶段漏掉了多少缺陷。如果你的冲刺总是带着集成缺陷交付,问题不是粗心的开发者。问题在于你的流程允许集成缺陷存在。修复流程。
哪里会失效
Cleanroom 不是万能解药。它在需求稳定、正确性比上市速度更重要时效果最好。它不适用于探索性开发和快速原型,因为那里的目标是发现用户想要什么,而不是正确实现一份已知的规格说明。
它还需要管理层支持。你无法偷偷实施 Cleanroom。统计过程控制需要跨团队收集数据。禁止执行规则需要管理层真正强制执行,哪怕 deadline 逼近,开发者只想“快速验证一下这个能不能跑”。
结论
IBM 的每 KLOC 0.1 个缺陷不是奇迹。它是一个旨在预防缺陷而非检测缺陷的流程的产物。box structure、统计测试、开发者隔离这些具体实践,对现代软件文化来说可能显得陌生。但底层原则是永恒的:动手前想清楚,比事后调试更便宜。
你不必全盘接受 Cleanroom。从一个函数开始。先写契约,再写函数体。让同事在运行前Review逻辑。追踪缺陷来源,修复产生缺陷的流程。每 KLOC 零缺陷可能不是你的目标,但从 50 降到 5 是可实现的,方法也一样。先想。再敲。最后跑。