テストとコード品質

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

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

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

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

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

長いから? 汚いから?

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

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

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

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

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

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

タイトルは「Go To Statement Considered Harmful」。本文2ページの手紙でした。

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

このタイトル形式は後に「○○ Considered Harmful」という決まり文句になったので、編集者の手腕はかなり遠くまで影響しました。

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

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

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

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

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はifguardforwhilerepeatcasecatchだけを数え、&&??は数えません。デフォルトの警告基準は10です。

プロジェクトで有効にしておけば、静かに肥大化していた関数がいつか検出されます。

クヌースの反論

この話を「ダイクストラが勝った」で終えるなら、半分しか知りません。

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

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

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

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

breakcontinue、ラベル付きbreak label、そしてSwiftのdeferguardがその例です。

// 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の撲滅ではなく、フローの予測可能性です。guardasync/awaitは、その目的を言語が代わりに守る仕組みです。

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

ラザニアコードです。

あわせて読みたい

出典と確認基準