テストとコード品質

[コードスメル #1]スパゲッティコードはなぜスパゲッティなのか

スパゲッティコードが指すのは長さや汚さではなく、制御フローです。1968年のダイクストラによるgoto反対の手紙を起点に、gotoが消えた後もスパゲッティが残った理由と、循環的複雑度による測定方法をまとめます。

読了 7 分
[コードスメル #1]スパゲッティコードはなぜスパゲッティなのかのカバー画像

コードがめちゃくちゃなとき、開発者はスパゲッティのようだと言います。では、正確には何がスパゲッティなのでしょうか。

次回の記事として予定しているコードスメル #2では、次のパッケージを引き続き見ていきます。

長いから? 汚いから?

どちらでもありません。スパゲッティが指すのは制御フローです。

実行順序を目で追おうとすると麺のように絡み合い、どこへ飛ぶのか分からない状態。それが本来の意味です。

制御フローだけでなく役割まで1ファイルに絡み合ったコードを分離する過程は、800行のコンポーネントに関心の分離を適用した事例で引き続き見られます。

この言葉のルーツをたどると、1968年に発表された1通の手紙に行き着きます。

ダイクストラが書いた1通の手紙

1968年3月、エツヘル・ダイクストラはACM(Association for Computing Machinery、米国計算機学会)の学会誌に短い文章を掲載しました(原文)。

題名は「Go To Statement Considered Harmful」。本文2ページの手紙でした。

ダイクストラが最初に付けた題名は「A Case Against the Go To Statement」でした。現在の挑発的な題名に変更したのは、当時の編集者ニクラウス・ヴィルトです。

この題名の形式は後に「○○ Considered Harmful」という定型句として定着しました。編集者の手腕はかなり遠くまで影響したわけです。

主張の核心はこうです。人は静的なコードを読み、動的な実行過程を頭の中に描きます。

しかしgotoが多いコードでは、「今この行を実行中」という事実だけではプログラムの状態を説明できません。どこから来たのか分からないからです。

順次実行、条件分岐、反復だけを使うコードは異なります。

実行位置を座標のように表せます。「外側のループの3回目で、内側のifの真の分岐」のようにです。

gotoはこの座標系を壊します。

この主張には理論的な裏付けもありました。2年前の1966年、コラード・ベームとジュゼッペ・ヤコピーニは、順次・選択・反復の3つだけで任意のプログラムを表現できることを証明しました(論文)。

gotoなしでも可能だと数学的に保証されたわけです。

gotoが作った図

当時のコードがどんな形だったかを見ると、感覚がつかめます。

10 IF X > 100 THEN GOTO 70
20 IF X < 0 THEN GOTO 90
30 Y = X * 2
40 IF Y > 50 THEN GOTO 70
50 PRINT Y
60 GOTO 100
70 PRINT "TOO BIG"
80 GOTO 100
90 PRINT "NEGATIVE"
100 END

10行しかなくても、理解するには紙にフローを描く必要があります。各GOTOが1本の線で、その線が上下に交差します。

500行になったらどうなるかは想像に難くありません。

実際、1970〜80年代のBASICや初期のFortranコードがそうで、その塊を見て生まれた言葉がスパゲッティでした。

GOTOジャンプが絡み合ったフロー図と、順次・分岐だけを使った構造化フロー図の比較
左は実行位置を座標で表せますが、右は表せません

gotoは消えたのにスパゲッティは残った

ここで不思議なことが起きます。最近のコードにはgotoがほとんどありません。

Swiftにはそもそもありません。存在する言語でも、リソースの後片付け地点へ戻るイディオム程度に用途が限定されています。

それなのに、スパゲッティコードという言葉はむしろ頻繁に使われます。

gotoは原因ではなく、症状の1つだったからです。

本当の問題は読む人がフローを予測できないことです。現代のコードには、それを生み出す別の方法がいくらでもあります。

**ネスト地獄。**ifの中にif、その中にクロージャ、さらにその中にif。

矢印のようにインデントが深くなるため、破滅のピラミッドと呼ばれます。gotoのように飛び回りはしませんが、条件の組み合わせごとにどの行へ到達するか追跡しにくい点は同じです。

**コールバック地獄。**非同期処理をコールバックでつなぐと、実行順序とコードの順序がずれます。

上から下へ読む順序が実行順序ではなくなった瞬間、座標系は再び崩れます。

// 実行順序をコードの順序として読めません
loadUser(id) { user in
    loadProfile(user) { profile in
        loadPosts(profile) { posts in
            DispatchQueue.main.async {
                self.render(posts)   // エラー処理はどの層に?
            }
        }
    }
}

**グローバル状態。**どの関数からでもいつでも変更できる変数があると、その値がなぜそうなったのか追跡するためにプロジェクト全体を調べる必要があります。

シングルトンがよく批判の対象になる理由でもあります。

**イベントスープ。**Aが通知を送るとBが受け取って状態を変え、その状態を監視していたCがさらに通知を送ります。

各要素は短くきれいなのに、全体のフローはどこにも記録されていません。

リアクティブコードでよく生じる形で、ある意味ではgotoより追跡が困難です。ジャンプ先がコードに書かれていないからです。

循環的複雑度の独立経路を示す制御フローグラフとしきい値ゲージの画像
複雑度の数値は答えではなく、どこを見るべきかを示すサインです

数字で測れるのか

「このコードはスパゲッティみたいだ」は主観的な表現です。そこで1976年、トーマス・マッケイブが循環的複雑度(Cyclomatic Complexity)という指標を提案しました(論文)。

計算は単純です。コードの制御フローをグラフに描き、独立した経路がいくつあるか数えます。

実務では分岐点の数に1を加えて近似します。if、for、while、case、&&、??ごとに1ずつ増えると考えれば、だいたい合っています。

値が10を超えたら関数を分割する時期だとする慣例があります。マッケイブ自身も、この数字を絶対的な基準ではなく妥当な上限として示しました。

実際、switch1つで20種類のケースを列挙する関数は複雑度が20を超えても、読みやすいことがあります。

SwiftLintのcyclomatic_complexityルールが似た値を測定します。

ただしSwiftLintが数えるのはif・guard・for・while・repeat・case・catchだけで、&&や??は数えません。デフォルトの警告基準は10です。

プロジェクトで有効にしておくと、静かに成長していた関数がある日検出されます。

クヌースの反論

この話を「ダイクストラの勝ち」で終えるなら、半分しか理解していません。

1974年、ドナルド・クヌースは「Structured Programming with go to Statements」という長い論文で反論しました(論文)。

要点は、gotoそのものが悪ではないということでした。ネストしたループから一度に抜ける場合のように、gotoを使うほうがフローが明確になる状況もあります。

無理に排除しようとしてBooleanフラグ変数を作り、条件文を重ねると、そちらのほうが悪いコードになります。

現在の言語が出した結論は、折衷案に近いものです。無制限のジャンプはなくし、よく使うパターンには専用構文を用意しました。

break、continue、ラベル付きbreak label、そしてSwiftのdeferとguardがその例です。

// guard: 例外的なケースを上で取り除き、本体を平坦にする
func process(_ data: Data?) throws -> Packet {
    guard let data else { throw ParseError.empty }
    guard data.count > headerSize else { throw ParseError.tooShort }
    // ここからは条件がすべて整理された状態
    return try decode(data)
}

guardは、C時代のgoto cleanupパターンが言語機能として定着したものに近い構造です。ジャンプ先を1か所に固定し、名前を付けたわけです。

まとめ

  • スパゲッティコードは汚いコードではなく、制御フローを予測できないコードです。
  • 出発点は1968年のダイクストラの手紙で、核心となる論拠は「実行位置を座標で表せなければならない」でした。
  • gotoは消えましたが、深いネスト、コールバックのネスト、グローバル状態、イベントの連鎖が同じ問題を再び生みます。
  • 循環的複雑度でおおよそ測定でき、10前後が一般的な警告ラインです。
  • クヌースの反論どおり、目的はgotoの撲滅ではなく、フローの予測可能性です。guardとasync/awaitは、その目的を言語が代わりに守る仕組みです。

次回は正反対の方向に壊れたコードを見ます。フローはとても整っているのに、値を1つ追加するだけで7ファイルを修正しなければならないコードです。

ラザニアコードです。

あわせて読みたい

出典と確認基準