Cleanroom software engineering 每千行程式碼僅產生 0.1 個缺陷。業界平均水準是 10 到 50。問題是,完整的 Cleanroom 要求你將團隊拆分為 author 和 verifier,在每個 module 之前編寫 formal specification,並禁止 developer 執行自己的程式碼,直到獨立的 test team 對其進行統計驗證。

大多數 engineering manager 看到這套流程,快速算了一下 headcount,便認為品質不值得付出這麼多 overhead。他們對了一半。完整的 Cleanroom process 確實很沉重。但其 underlying principles 是輕量的,你可以在不僱用 verification army、不禁止 cargo test 的情況下採納它們。

Cleanroom 到底是什麼

Cleanroom 是 1970 年代由 IBM 的 Harlan Mills 開發的一種 software development process。名字來源於半導體製造:你預防產生缺陷的條件,而不是事後再 inspect 出來。核心主張是,如果你設計得足夠謹慎,使得 bug 在程式碼寫出之前就變為不可能,那麼軟體就可以透過 construction 達到 correct。

該方法建立在三種實踐之上:statistical process control 下的 incremental development、box structures 的 function-theoretic design、以及由獨立團隊執行的 statistical usage testing。這三種實踐相互依賴。打破其中一個,其他兩個就會失去力量。

正是這種 interdependency 催生了 overhead 的 myth。團隊以為要麼全部採納,要麼完全不採納。事實並非如此。這些實踐相互 reinforcement,但各自獨立也能產生價值。

真正的 overhead 在哪裡

人們所恐懼的 80% overhead 來自兩項具體要求。

第一,no-execution rule。在完整的 Cleanroom 中,編寫程式碼的 developer 不編譯、不執行、不寫 unit test。獨立的 verification team 負責所有執行。這迫使 developer 在敲鍵盤之前把每種情況都推理清楚,也正是程式碼第一次就能跑對的原因。但這同時也要求你將 engineering headcount 翻倍,或將現有團隊拆成兩個互相怨恨的組。

第二,formal verification step。在編寫 clear box implementation 之前,團隊先寫 black box specification 和 state box refinement,然後手工證明 state box 與 black box 等價。這是 pencil-and-paper proof,不是 proof assistant。它確實有效,但大多數團隊沒有那麼多時間和 training。

第三項實踐,statistical process control 下的 incremental development,如果你已經在做 sprint,那它實際上是免費的。你交付小的 increment,測量每個 increment 的 defect density,當某個 increment 超出缺陷目標時就叫停,找出方法哪裡出了問題。這不過是定義更嚴格的 “done” 的 data-driven retrospective。

務實子集:保留結構,砍掉官僚

只要保留 structural discipline、拋棄 organizational mandates,你就能獲得 Cleanroom 的大部分缺陷削減效果。

以下是該保留的。

先寫 contract,再寫 code。 不是 Z notation 的 formal specification。只需清晰描述 input、output、precondition 和 postcondition。如果你寫不出 correct 意味著什麼,就不可能寫出 correct 的程式碼。

顯式 encode state machine。 大多數 bug 藏在 developer 認為不可能發生的 state transition 裡。在寫邏輯之前,先用 table 或 data structure 定義好狀態和轉換。

讓別人測試你的 logic。 authorship 和 verification 的分離是 Cleanroom 最強大的思想。你不需要獨立的 team,只需要一個沒寫過這段程式碼的人來設計 test case。自己測試自己的程式碼,測的只是自己的 assumption。

按 increment 測量 defect density。 追蹤每個 phase 漏掉多少 bug。如果 integration bug 總是漏網,問題不在於 developer 粗心,而在於你的 process 允許 integration bug 存在。

以下是該砍掉的。

砍掉 no-execution rule。 讓 developer 執行自己的程式碼。先推理再執行的 discipline 即使允許事後快速 sanity check 也依然有價值。關鍵是編譯前先想清楚,而不是假裝編譯器不存在。

砍掉 formal hand proof。 除非你在寫 avionics software,否則 pencil-and-paper proof 是 overkill。用 property-based tests 和 type-driven design 替代。它們是同一套推理的 mechanized version,而且能在 CI 裡跑。

砍掉 statistical usage testing 的要求。 完整 Cleanroom 針對 usage profile 而不是 code coverage 做測試。如果你有資料,這很好。如果沒有,property-based testing 和 mutation testing 用你現有的工具就能給你幾乎同樣的 confidence。

Python 中的輕量 Cleanroom 工作流

下面展示單個 module 的實際做法。從 contract 開始。

"""
Black Box: Token Bucket Rate Limiter

Stimuli: request(n), add_tokens(k)
Precondition: n > 0, k >= 0, capacity > 0
Postcondition:
  - request(n) grants iff available tokens >= n
  - request(n) reduces available tokens by n if granted
  - add_tokens(k) increases available tokens by k, capped at capacity
  - available tokens never negative, never exceeds capacity
"""

然後 encode state machine。

from enum import Enum, auto

class RateLimitState(Enum):
    READY = auto()      # tokens >= 1, requests may grant
    DEPLETED = auto()   # tokens == 0, requests deny

# Transitions depend on token count, not external events
# READY -> DEPLETED when tokens reach 0
# DEPLETED -> READY when tokens added above 0

然後寫 implementation。

import time
from dataclasses import dataclass

@dataclass
class TokenBucket:
    capacity: int
    tokens: int
    refill_rate: float
    last_refill: float

    def _refill(self) -> None:
        now = time.monotonic()
        elapsed = now - self.last_refill
        added = int(elapsed * self.refill_rate)
        if added > 0:
            self.tokens = min(self.capacity, self.tokens + added)
            self.last_refill = now

    def request(self, n: int) -> bool:
        if n <= 0:
            raise ValueError("request must be positive")
        self._refill()
        if self.tokens >= n:
            self.tokens -= n
            return True
        return False

    def add_tokens(self, k: int) -> None:
        if k < 0:
            raise ValueError("cannot add negative tokens")
        self.tokens = min(self.capacity, self.tokens + k)

contract 放在 docstring 裡。state machine 是顯式的。implementation 很短,可以透過 inspection 驗證。這不是完整 Cleanroom,也不是 cowboy coding。

你真正可以做的 verification step

在完整 Cleanroom 中,獨立團隊基於 usage profile 寫統計測試。在務實版本中,你寫 property-based tests,然後請同事 review。

from hypothesis import given, strategies as st

@given(
    capacity=st.integers(min_value=1, max_value=1000),
    initial=st.integers(min_value=0, max_value=1000),
    requests=st.lists(st.integers(min_value=1, max_value=100), max_size=50),
)
def test_token_bucket_never_overdrafts(capacity, initial, requests):
    bucket = TokenBucket(
        capacity=capacity,
        tokens=min(initial, capacity),
        refill_rate=0.0,
        last_refill=time.monotonic(),
    )

    for n in requests:
        granted = bucket.request(n)
        assert bucket.tokens >= 0
        if granted:
            # tokens were deducted, so pre-request balance was sufficient
            pass
        else:
            # request denied, current tokens insufficient
            assert bucket.tokens < n

這個 test 不檢查具體 output,而是檢查 invariant:bucket 永遠不會批准超過其持有量的 token。這就是 Cleanroom proof 的 property-based 版本。它自動執行,能發現你想不到的 edge case,而且不需要獨立的 verification team。

trade-off 是真實存在的

務實的 Cleanroom 不是免費的。先寫 contract 需要 discipline。顯式的 state machine 在程式碼”顯而易見”時感覺像 boilerplate。讓別人測試你的 logic 需要協調。

它也確實不如真傢伙強大。完整 Cleanroom 的 no-execution rule 能強制一種深度推理,而當你離 REPL 只有一個 keystroke 時,這種深度是無法複製的。formal proof 能抓住 property-based tests 可能漏掉的邏輯錯誤——前提是你的 properties 本身沒錯。

但比較對象不是務實 Cleanroom 對完整 Cleanroom,而是務實 Cleanroom 對你現在的做法。如果你現在的 process 每千行出 20 個缺陷,而務實 Cleanroom 能把你降到 5,那就是用一小部分 overhead 換來了 4 倍提升。

什麼時候值得這樣做

別在每個 utility function 上都用這套。把它用在 bug 代價高的程式碼上:authorization、billing、distributed protocol、超過三個狀態的 state machine、以及任何在生產環境中引發過兩次 incident 的東西。

需要這套方法的訊號不是複雜度,而是反覆出現的意外。如果你的團隊在 test 或 production 裡不斷發現同一類 bug,問題不是 developer 粗心,而是這段 code 的 contract 從未被定義,因此 “correct” 從未被 specified。

從一個 module 開始

你不需要管理層批准,也不需要 overhaul process。挑一個以前咬過你的 module。在碰 implementation 之前,先在 docstring 裡寫好 contract。定義狀態和轉換。請一位同事不看你的程式碼來寫測試。在 CI 裡跑 property-based tests。

就這些。沒有獨立團隊,沒有 formal notation,沒有禁止執行自己程式碼的規定。

Cleanroom 每千行 0.1 個缺陷不是 magic。它是一個強迫人們在動手之前先定義 correct 的 process 的 output。你用一份 docstring、一張 state machine table、以及一位測試你 assumption 而非測試你程式碼的同事,就能獲得大部分同樣的效果。