サイクロマティック複雑度:測定できても読みやすいとは限らない理由

要約

サイクロマティック複雑度はコードの分岐数を数える指標で、広く知られている「10」という閾値はNIST統計に基づいています。しかし複雑度が低いからといって読みやすいとは限らず、新人エンジニアのオンボーディングコストに大きな影響を与えます。この記事では、この指標を正しく理解し、チーム内で効果的に運用する方法を紹介します。

深夜、デュアルモニターデスク:一つのスクリーンはコードエディタ上で複雑に分岐する発光する配線の束を表示

サイクロマティック複雑度とは、関数を通過する独立したパスの数を数える指標です。分岐がない関数はスコア1。ifを1つ追加すれば2。4つのcaseを持つswitchがあれば6へ跳ね上がります。この数値は、すべてのパスをカバーするのに必要なテストケースの数を示し、その関数を理解するのに必要な時間と相関します。

大切なのは、チームがこの指標をどう使うか、そしてどこで失敗するかです。

実際に測定されるもの

1976年にMcCabeが定義したサイクロマティック複雑度は、グラフ理論では「辺の数 - ノード数 + 2」です。しかし実務では理論不要です。決定ポイントを数えるだけです:ifelse ifcasewhileforcatch&&||、三項演算子。そして1を足します。それがスコアです。

def price(order):
    if order.is_vip:            # +1
        if order.total > 100:   # +1
            return order.total * 0.8
        return order.total * 0.9
    elif order.has_coupon:      # +1
        return order.total * 0.95
    return order.total

このコードはスコア4です。高くはありませんが、「割引を適用する」というシンプルな機能のために既に3段階の分岐があります。パターンは明らかです:複雑度は1つのelifずつ忍び寄り、誰も悪役ではありません。

「10」という閾値の由来、そしてツール間の不一致

誰もが引用する数字は10です。それはNIST Special Publication 500-235に由来します。McCabeとWatsonが、10を超える上限は経験豊富なスタッフ、正式な設計、包括的なテスト計画を持つチーム向けとしていました。つまり:10は法則ではなく、デフォルトです。

ツール間では一致していません。ゲートを設定する前に知っておく価値があります:

4つの出所、4つの数字です。あなたのCIゲートが10で失敗し、同僚のサイドプロジェクトが25でゲートするなら、どちらが間違っているわけではありません。リスク許容度が異なるだけです。マイクロソフトの公式ドキュメントには、NIST推論の詳細が記されています。

実際の設定方針:新規コードは10-15を警告ラインとし、20でプルリクエストが追加レビューの対象となり、50は強制停止です。10以下なら、レビュー時間を費やす価値はありません。

落とし穴:低スコアでも読みにくい関数

ここでチームは失敗します。関数がスコア6でも本当に読みにくいことがあり、スコア14でも些細なことがあります。

10個のcaseを持つ平坦なswitchを考えてください。各caseが定数を返すなら、複雑度は10。ほとんどのエンジニアは15秒で読み終わります。なぜなら、パターンが明らかだから:1つが入って1つが出て、分岐間で状態は持ち越されません。次に、3段階のifネストとループ内で共有変数を変更する関数を考えてください。スコアは6かもしれませんが、どの分岐を本当に実行しているか知るにはコール・スタック全体を保持する必要があり、シニアエンジニアでも5分かかります。

サイクロマティック複雑度はパスを測定します。ネスト深度、変数スコープ、条件とその効果がファイル内でどれだけ離れているかは測定しません。同じスコアの2つの関数は、まったく異なる読み方になることがあります。

「10未満は対応可能」という代理指標として扱い、linterが緑だからプルリクエストを通し、3週間後に新人エンジニアが同じ関数の中で午後を失うのを見たチームは多いです。スコアは合格した。でも読みやすさは向上していません。

サイクロマティック複雑度 vs 認知複雑度:異なる質問

SonarSourceは認知複雑度を2017年に導入し、このギャップを埋めることにしました。サイクロマティック複雑度は「この関数は何個のパスを持つか」と答えます。認知複雑度は「この関数を頭の中に保つのはどれだけ難しいか」と答えます。ネスト深度に罰則を与えることで。

switch文は認知スコアをほとんど動かしません。ループ内の3段階ifは素早く動きます。なぜなら、ネストの各レベルが前のものの精神的コストを複合化させるから。SonarSourceの解説には、自分で実装したい場合のスコアリングルールが書かれており、複雑度をサポートするほとんどのlinterは、別の独立したルールとして認知複雑度もサポートしています。

チームが小さく、誰もが既に複雑な関数の場所を知っているなら、認知複雑度は無視してください。2人以上が自分で書いていないコードをレビューしている瞬間に有効にしてください。それはちょうどこのギャップが対応するためにある場面です。

赤い紐で繋がったカード同士の分岐パターンを示すコルクボード

AIコードレビューツール:何ができて何ができない

GitHub Copilot、CodeRabbit、Qodo、Greptileは全て、ある程度の複雑度シグナルをプルリクエストに表示します。あるツールは閾値を超えた関数にフラグを立てます。あるツールは「このPRは3ファイルの複雑度を増加させた」と要約します。ほとんどが、6ヶ月後にコンテキストなしでファイルを開く人にとってなぜそれが重要なのかを説明しません。

それが実際のギャップです。PRの複雑度警告はコメント内の数字です。追加された分岐がそこにあるべきか、3つのファイルに渡ってロジックが重複していないか、正しい修正が守備節句かメソッド抽出かを教えてくれません。AIツールは数えるのが得意です。でも、混乱の形を説明するのはまだ苦手です。

実際に隙間を埋めるのは、数値を人間がまだ答える必要がある質問と組み合わせることです:この関数は1つのことをするのか、それともif文でラップされた3つのことをするのか?linterはそれに答えません。ただ、どこを見るかを教えてくれるだけです。

100K行のリポジトリで100行のプロジェクトより重要な理由

自分で書いたコードベースでは、複雑度は既に解いた記憶問題です。10個の複雑な関数を名前で知っていて、なぜ複雑なのかを知り、考えなしにそれを回避します。しかし、このオーディエンスのほとんどはそういう状況ではありません。

5人から50人のエンジニアが共有するリポジトリでは、誰も完全な地図を保持していません。スコア22で元の著者が完璧に理解していた関数が、6ヶ月後には、別のエンジニアが1から再構築する必要のある関数になります。通常、締め切りの下で。既にやったことがあります:関数名でgrep、ファイル内でCtrl+F、疑わしい行をgit blame、チームを去った8ヶ月前の誰かにメッセージ。

これは複雑度が検索と相互作用し始める場所でもあります。高複雑度の関数は正確に要約するのが難しく、つまり、チームメイトやコード検索ツールが1文で正確に説明するのが難しくなります。複雑度4の関数に「これは何をするのか」と尋ねると、きれいな答えが得られます。複雑度22の関数に4段階のネストされた分岐で同じ質問をすると、正直な答えは「どのパスについて聞いているかによる」です。その曖昧さが新人エンジニアの初日を遅くし、リポジトリレベルで複雑度を追跡する価値がある理由です。

誰も指標に入れない実際のコスト:オンボーディング時間

これはダッシュボードに表示されない部分です。新人エンジニアがチームに参加する場合、「サイクロマティック複雑度23」を経験しません。彼らが経験するのは:このファイルを開いたが、どのブランチが実行されるか分からず、40分読んでいます。

いくつかのオンボーディングサイクルで粗く測定しました:複雑度スコアが15を超える関数は、新人がウォークスルーで正しく説明するのに、スコアが8未満の関数より約3~4倍時間がかかりました。高複雑度の関数がより多くのことをしたからではなく、どの分岐がどの条件で実行されるかを追跡するのに実際の順序読み時間が必要だからです。誰も最初のパスでネストされた条件を正しくスキャンしません。

これは頻繁にオンボーディングするチームにとって指標が価値を発揮する場所です。これは本当のコード品質数ではありません。それは「次にそれを書かなかった人にこれはどれだけの分数の負担をかけるか」のプロキシです。そのように追跡して下さい。そうすれば、閾値の会話はずっと具体的になります。

パートナープログラミングセッション中、コンピュータ画面を指差す経験豊かなエンジニアと、ノートを取る新人エンジニア

ゲート設定:チームをブロックしない方法

ゲート前に測定してください。以下のいずれかを選んでください:

ゲートを有効にする前に、リポジトリ全体で一度実行してください。ベースラインを得られ、おそらく誰も現在のチーム内にいない40以上の範囲の関数がいくつか見つかります。それらに遡及的にビルドをブロックしません。それはlinterをルーティングすることを教えるだけです。

新規コードでは10-15でゲート。20を超える全てを自動的な却下ではなく、追加レビュアーでフラグ;そのうちいくつか、平坦なswitchなどは問題ありません。50を過ぎた全てを誰かのPRでのコメントではなく、技術債チケットとして扱ってください。

プルリクエストレビューインターフェースの前で、赤と緑のdiff棒が表示される中、メカニカルキーボードで入力する手

制限が有効なのは、ツールチェーンが毎回同じ方法でそれを実行するときだけです。1つの期限のために一度放棄されるルールは永遠に放棄されます。

スコアを追うか、関数を追うか

10でハードゲートし、平坦で、退屈で、読みやすい関数を出荷するチームは良好な状態です。10でハードゲートし、関数を4つの小さなものに分割し始め、誰も3つのタブを開かずにトレースできない鎖で相互に呼び出す関数を出荷するチームは、数字をより良くし、コードベースを悪くしました。

サイクロマティック複雑度は火災警報器であり、消火器ではありません。どこを見るかを教えてくれます。そこで何をするかは教えてくれません。スコアを目標として扱う代わりに、それが代理すべき読みやすさは、チームが緑のダッシュボードと、新人が貢献できるようになるまで3週間かかるリポジトリを持つ方法です。

よくある質問

サイクロマティック複雑度の「10」という数字はどこから来たのですか?
NIST Special Publication 500-235 (1996年のMcCabeとWatson論文)に由来します。10を超える制限は、経験豊かなスタッフと包括的なテスト計画を持つチームが適切とされています。しかし、これは法則ではなく、デフォルトの推奨値です。ESLintは20、Microsoftは25で警告するなど、ツール間で大きく異なります。
複雑度が低いのに読みにくい関数もあります。なぜですか?
サイクロマティック複雑度はパスの数のみを数えます。ネスト深度、変数スコープの複雑さ、条件がファイルのどこに現れるかは測定しません。平坦な10ケースの`switch`は複雑度10ですが直感的ですが、3段階にネストされた`if`は複雑度6でも追跡が難しい場合があります。SonarSourceの「認知複雑度」はこの問題に対応しています。
新人エンジニアのオンボーディングにどのような影響がありますか?
複雑度が15を超える関数は、複雑度8未満の関数と比較して、新人エンジニアの理解に3~4倍の時間がかかります。分岐条件のトレースに順序読み時間が必要だからです。これはデータに基づいた理由で、複雑度を追跡することをお勧めします。
プロジェクト全体にゲートを実装する方法は?
まず測定してベースラインを設定します。Pythonなら`radon cc`、JavaScriptなら`eslint complexity`、Goなら`gocyclo`を使います。新規コードは10-15で警告、20で追加レビュー、50で技術債チケットとします。既存の高複雑度コードには遡及的にゲートをかけず、新規コードへの適用から始めてください。
認知複雑度とサイクロマティック複雑度の違いは何ですか?
認知複雑度はネスト深度に罰則を与えるのに対し、サイクロマティック複雑度は単にパスの数を数えます。例えば平坦な10ケースのswitch文は認知スコアを上げませんが、ネストされた3段階のif文はスコアを大きく上げます。チームの規模や経験によって、どちらが適切かが異なります。