Polyscan
← All posts
code-qualitystatic-analysisdead-codepolyscanpyscn

ツールはどうやってデッドコードを見つけるのか

実行されないコードを、ツールはどう見分けるのでしょうか。制御フローグラフによる検出の仕組みと、分岐や例外処理で判定が難しくなる理由を、静的解析ツールの開発で起きた誤検出を通して解説します。

return のすぐ後に処理が書かれていたら、「このコードは実行されない」と気づくのは簡単です。では、いくつもの条件分岐がある関数や、例外処理を含む関数ではどうでしょうか。すべての経路を頭の中でたどるのは、だんだん難しくなります。

こうした「実行されることのないコード」を探すのが、静的解析ツールのデッドコード検出機能です。プログラムを実行せずにソースコードを読み、処理がどこへ進むかを調べます。

ただし、ツールなら必ず正しく見分けられるわけではありません。私たちが開発するツールも、あるライブラリの後処理を「実行されない」と誤って報告していました。なぜそんな間違いが起きるのか。その理由を知ると、デッドコード検出の仕組みと、結果を読むときの注意点が見えてきます。

実行されなくても、読む人には負担になる

まず、Python のゲームエンジン pyved-engine にある関数を見てみます(一部省略)。

def do_flip(self, show_widgets=True, reset_standing_by=True):
    # TODO inspect before removal
    raise NotImplementedError  # not sure we should still use this in Pyved ...
    self.widget_tooltip = None
    if show_widgets:
        self.render_widgets()
    if self.notifications:
        self.render_notifications()
    ...                                       # さらに14行

この関数は raise で例外を送出するため、その下の18行は実行されません。コメントからは、作者もそれを承知のうえで、削除するかどうかの判断を保留していることが読み取れます。ただし、残しておくことにもコストはかかります。次にこのファイルを開いた人は、18行の描画コードが実行されないことを確認し、raise がバグなのか、意図的に置かれたものなのかを判断しなければなりません。

早期 return を追加した後に古い実装が残ったり、デバッグ用の raise の下に元の処理が残ったりすることもあります。動作に影響しないため見過ごされがちですが、読む人には「なぜ残っているのか」を考える負担が生じます。自動検出が役に立つのは、こうした箇所を見つけて、整理するきっかけを作れるからです。

なお、デッドコードには、ここで見た「関数内のどの経路からも到達できないコード」のほかに、「どこからも使われていない関数やファイル」も含まれます。後者はファイル間の参照関係を調べる別の解析で、以前の記事で扱いました。この記事では、前者の「到達不能なコード」に絞って説明します。

処理の流れをグラフにして調べる

到達不能なコードを探すツールは、関数ごとに処理の流れを表す「地図」を作ります。文を停留所に、次の処理へ進む経路を矢印にたとえるとわかりやすいでしょう。if では矢印が2本に分かれ、ループでは先頭へ戻る矢印が、return では出口へ向かう矢印が伸びます。地図ができたら、入口から矢印をたどっていきます。どの経路をたどっても着けない停留所が、デッドコードです。

この地図を「制御フローグラフ」と呼びます。現在使われている多くのプログラミング言語よりも歴史のある考え方で、1970年には Frances Allen が論文で説明しています。

私たちが開発している静的解析ツールも、この方法を使っています。JavaScript / TypeScript 向けの解析機能を持つ polyscan と、Python 向けの pyscn です。以下では、この2つのツールの実装を例に、検出の仕組みを見ていきます。

グラフを作ってたどる処理は、比較的短時間で済みます。たとえば HTTP ライブラリの got にある7,383個の関数を polyscan で解析したところ、ノート PC で約300ミリ秒でした。難しいのは探索の速さよりも、ソースコードの実行経路を正しくグラフに表すことです。

難しいのは、実行経路を正確に表すこと

必要な矢印が1本抜けると、実行されるコードを「到達不能」と判定してしまいます。逆に、存在しない経路に矢印を描くと、本当は到達不能なコードを見逃します。私たちのツールでは、その両方が起きていました。

return の直後と、分岐の後では何が違うか

先ほどの pyved-engine の例では、raise の後に続く文へ進む経路がありません。return、throw、break、continue も同様で、同じブロック内の次の文へは進みません。こうしたケースは、処理の流れを変える文がすぐ手前にあるため、比較的簡単に判定できます。

一方、if と else の両方で return する場合は、分岐の先まで調べる必要があります。

function label(enabled: boolean) {
    if (enabled) {
        return "on";
    } else {
        return "off";
    }
    console.log("done"); // どちらの分岐からも到達できない
}

console.log の直前だけを見ても、実行されない理由はわかりません。どちらの分岐も関数の出口に向かい、ここへ合流する経路がないことを確認して、初めて到達不能だと判断できます。

polyscan の JavaScript 解析では、10月までこのパターンを見落としていました。ソースコードを構文として読み取るパーサーから else を受け取っても、グラフの構築処理がその内部を調べていなかったのです。その結果、else 内に return があっても、その後のコードへ進めるものとして扱っていました。

この不具合は、同じグラフを使う複雑度の計算にも影響していました。else if が分岐として数えられていなかったためです。内部を正しく解析するように修正すると、到達不能なコードを検出できるようになり、got 全体の複雑度も1%弱上がりました。

return しても、finally は実行される

分岐の見落としとは逆に、必要な経路を描かなかったために起きたのが、冒頭で触れた後処理の誤検出です。

polyscan 0.6.0 で got と latchkey を解析したところ、計14か所の「到達不能な文」が報告されました。latchkey は、コーディングエージェントが Web サービスにログインするためのツールです。got で5か所、latchkey で9か所。タイマーの停止、ブラウザの終了、イベントリスナーの解除など、すべて後処理のコードでした。

共通していたのは、finally の中にあることです。got のコードを見てみます。

// got, source/core/options.ts
try {
    return await operation(changedState);
} finally {
    this.#trackedStateMutations = undefined;   // 到達不能として報告された行
}

try 内に return がありますが、関数を抜ける前に finally が実行されます。operation が成功しても例外を送出しても、追跡用の状態を解除する処理は通ります。終了の仕方にかかわらず後処理を行うための構文だからです。

ところが、当時の実装では、try が最後まで実行される場合にしか finally への矢印を描いていませんでした。return があると、直接関数の出口へ向かうグラフになってしまい、finally へ到達する経路が消えていたのです。14件の指摘は、すべてこの誤りによるものでした。

修正では、return などで途中で抜ける場合の経路をまとめて表す矢印を、try から finally へ1本追加しました。一方、finally から try 文の後へ進む矢印は、try または catch が途中で抜けずに最後まで実行される可能性がある場合にだけ描きます。そのため、try { return } finally { ... } after() の after() は、引き続き到達不能として報告されます。pyscn はもともと finally を常に到達可能として扱っていたため、Python 側にはこのバグはありませんでした。

このバグは、外部のオープンソースプロジェクトを解析し、指摘をソースコードと照合する中で見つかりました。自分たちのコードだけでは、こうした構文の組み合わせを十分に検証できませんでした。ここまでに紹介した問題への修正は、polyscan 0.6.1 に含まれています。

どこまで解析し、どこからは判断を保留するか

では、言語の構文を正しく扱えれば、実行されないコードをすべて見つけられるのでしょうか。実際には、構文だけでわかることと、値や呼び出し先まで調べないとわからないことがあります。

たとえば、ループを抜ける break がない while (true) では、ループの後に進む経路がないため、後続のコードを到達不能として報告できます。一方、polyscan と pyscn のこの解析では、条件式の値を一般に評価することはしません。if (false) の中も指摘の対象外です。値を追跡する解析は、コンパイラや、それに対応したリンターに任せています。

関数呼び出しにも限界があります。process.exit() や sys.exit() は呼び出し元に戻りませんが、この解析では通常の呼び出しとして扱います。例外を送出するだけのヘルパー関数も同様です。そのため、これらの後に続くコードは指摘されません。

例外処理では、実行される可能性を広めに見積もっています。try の1行目が return でも、戻り値を求める途中で例外が発生すれば catch に進みます。発生箇所を正確に絞り込むのは難しいため、どちらのツールも try からすべての例外ハンドラへ経路を作ります。

この方針では、実際には到達不能なコードを見逃すこともあります。それでも、不確かな箇所を「実行されない」と断定するより、指摘の確実性を優先しています。ツールの結果を読むときも、「指摘がないからデッドコードがない」とは限らない点を押さえておく必要があります。

ひとつの領域に、ひとつの指摘

検出できた後にも、もうひとつ仕事が残っています。連続する20行が実行されないときに、20件の警告を出すのでは使いにくいでしょう。どの範囲が、なぜ実行されないのかを、ひとまとまりで伝える必要があります。

pyscn には、このまとめ方にも不具合がありました。9月の時点では、最初に紹介した pyved-engine の関数に対して4件の指摘を出しており、対象の行範囲も重なっていました。別の関数では、同じ到達不能な領域の後半が、前半とは異なる理由で「到達不能な分岐」として報告されていました。

原因は、実装を簡略化したことにありました。到達不能になった理由を調べる際、直前の5行以内にある return や raise だけを探していたのです。それより離れた場所では原因を特定できず、別の指摘として扱われていました。

修正後は、行数ではなくグラフ上のつながりを使っています。return などの後に作られる到達不能な停留所にラベルを付け、そこからつながる到達不能な停留所にも同じラベルを引き継がせます。現在の pyscn は、上の関数を「raise の後、19行」という1件の指摘にまとめて報告します。10月には、JavaScript チェッカーにも同じ修正を加えました。

指摘の件数は、人もエージェントも優先順位を決める手がかりにします。到達不能な領域が数か所しかないのに「77件」と表示されれば、実態をつかみにくくなり、対応の判断も変わってしまいます。

意図的に残されたデッドコード

ここまでの問題を直して、到達不能な範囲を正しく報告できるようになったとしても、削除してよいかどうかは別の判断です。実行されない文が、プログラムの意味に影響する場合があるからです。

たとえば、pyscn は HTTP クライアントの httpx で3か所の到達不能な文を検出しました。いずれも次の形でした。

# httpx/_types.py
class SyncByteStream:
    def __iter__(self) -> Iterator[bytes]:
        raise NotImplementedError(
            "The '__iter__' method must be implemented."
        )  # pragma: no cover
        yield b""  # pragma: no cover

この yield は実行されないので、チェッカーの指摘自体は正しいものです。しかし、この1行があることで、このメソッドはジェネレータ関数になります。呼び出すとイテレータが返り、反復を始めた時点で初めて raise が実行されます。yield を削除すると、__iter__ は呼び出された時点で例外を送出し、型注釈で示されたイテレータを返さなくなります。

作者はこの行に # pragma: no cover を付け、カバレッジの測定対象から外しています。実行されないことを承知のうえで残しているのです。デッドコードチェッカーの指摘を確認しても、このコードは削除する必要がありません。

ツールの指摘は、コードを確認するきっかけであって、削除すべきという結論ではありません。

指摘を受けたら、理由から確認する

デッドコード検出を使うときは、件数を減らすことよりも、個々の指摘の理由を確かめることが大切です。まずは該当箇所を開き、次の順番で確認すると判断しやすくなります。

  1. 本当に到達できないか。 直前の return だけでなく、分岐や finally を含む経路も確認します。got と latchkey の14件のように、ツールが間違えている場合もあります。
  2. 実行されなくても役割があるか。 httpx の yield のように、関数の性質を変える文は残す必要があります。
  3. 不要なら削除する。 古い実装やデバッグの残りであれば、残す理由はありません。後で必要になったときは、バージョン管理の履歴から取り出せます。

確認のタイミングは、コードを変更した直後が適しています。早期 return を追加した理由を覚えているうちなら、その下に残った処理が必要かどうかも判断しやすいからです。コーディングエージェントに変更を任せる場合も、同じ作業の中でチェッカーを実行させると、不要なコードを残さずに済みます。

polyscan と pyscn では、次のコマンドでデッドコード検出を試せます。

uvx pyscn analyze --select deadcode .        # Python
npx polyscan analyze --select deadcode .     # JS/TS

どちらのツールも、到達不能な範囲とその理由を報告します。return などの直後に続くコードは critical、分岐をたどって到達不能と判断したコードは warning として区別しています。polyscan は、未使用のエクスポートやファイルも報告します。

ツールが調べるのは「そこへ実行が到達するか」です。「そのコードを残す意味があるか」まで含めて判断するのは、結果を読む側の仕事です。仕組みと限界を知っておくことで、指摘をその判断に役立てられます。


出典と参考:

  • F. E. Allen, Control Flow Analysis, SIGPLAN Notices 5(7), 1970.
  • Python ドキュメント The try statement:try の中で return が実行されると "the finally clause is also executed 'on the way out'"。Yield expressions:"Using a yield expression in a function's body causes that function to be a generator function."
  • MDN try...catch、finally ブロックが実行されるタイミングについて。
  • 修正:polyscan #172、#206、#211。pyscn #471。
  • この記事の計測:got は 6b5aafc、latchkey は 0d61172 で、修正前は polyscan 0.6.0、修正後は polyscan 0.6.1。pyved-engine は 73383cf、httpx は b5addb6 で pyscn 1.33.1。いずれも既定の設定。引用コードは一部を省略し、説明用のコメントを加えています。label 関数は説明のために作成した例です。