IBM は NASA の衛星制御システムを KLOC あたり 0.1 欠陥という水準で出荷した。当時の業界平均は 10 から 50 の間だった。彼らがこれを達成したのは、より優秀な技術者を採用したり、より長時間働いたりしたからではない。開発者に自身のコードを実行することを禁じたからだ。

これが Cleanroom ソフトウェア工学であり、1970 年代に IBM の Harlan Mills によって開発された。名称は半導体製造に由来する。そこでの目標は、欠陥を生む条件を予防することである。品質を後からテストで入れるのではない。設計の段階から組み込むのだ。

問題:テストは欠陥を発見するが、防げない

多くのソフトウェア工学は、書いて・テストして・直すというサイクルを前提としている。コードを書き、実行し、バグを見つけ、修正する。生産的に感じられる。しかしこれは数学的に、欠陥を残すことが保証されている。

テストはバグの存在を証明できるだけで、その不在を証明することはできない。Dijkstra が 1969 年にそう述べた。もしコードに千の可能な実行パスがあり、テストスイートがそのうち五十をカバーしていれば、未テストのパスは 950 残っている。KLOC あたり 10〜50 という業界平均の欠陥率は、テストの厳密性の欠如ではない。欠陥を存在させるプロセスから予測可能に導かれる出力なのだ。

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〜80 年代、ソフトウェアはテープで出荷された。本番のバグを修正するのは高額だった。今日はネットワーク経由で出荷し、数分でパッチを当てられる。ゼロ欠陥ソフトウェアに対する経済的インセンティブは弱まった。

私たちの多くは、自分のコードを実行するのが好きだ。書いて・実行して・直すという即座のフィードバック・ループは満足感を与える。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 をこっそり行うことはできない。統計的工程管理はチーム全体にわたるデータ収集を必要とする。実行禁止ルールは、締め切りが迫り、開発者が「ちょっとこれが動くか確認したい」と思ったときであっても、経営層が実際に実行しなければならない。

結論

IBM の KLOC あたり 0.1 欠陥は奇跡ではなかった。欠陥を検出するのではなく予防するよう設計されたプロセスの産物だった。box structure や統計的テスト、開発者の分離といった具体的な実践は、現代のソフトウェア文化には異質に感じられるかもしれない。しかし根本的な原理は普遍的だ:入力する前に考える方が、後からデバッグするより安上がりだ。

Cleanroom を丸ごと採用する必要はない。一つの関数から始める。本体より先に契約を書く。実行する前に同僚に論理をレビューしてもらう。バグの出所を追跡し、それを生み出すプロセスを修正する。KLOC あたりゼロ欠陥は、おそらくあなたの目標ではないだろう。しかし 50 から 5 へ移行することは達成可能だし、その方法は同じだ。先に考える。次に入力する。最後に実行する。