DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

フロー解析を実装した静的解析ツールをOSS公開しました

こんにちは、SWETの秦野です。

2024/8/22のCEDEC2024にてIKさんと私でRoslynアナライザーに関する発表を行いました。 そして先日、本発表で紹介した静的解析ツールをOSSとして公開しました。

本記事では、CEDECの発表内容と公開したツールについて紹介していきます。 また、本ツールで実装されている静的解析技術(主にフロー解析)について解説します。

CEDEC2024での発表

発表資料はこちらにあるので、本ブログでは概要だけ紹介します。

本発表では、社内のあるゲーム開発プロジェクトにC#の静的解析ツールを開発・導入した事例を紹介しました。

静的解析ツールはRoslynアナライザーとして実装しました。 本ツールは構文解析だけでなくフロー解析を行っていて、文の実行順序や変数の定義と参照の関係を認識できます(この詳細については後述します)。

弊社では本ツール以外にも複数のRoslynアナライザーを導入していて、不具合の早期発見に役立てています。 これらのツールで検出したコードの情報をクラウド上に蓄積しダッシュボード化することで、コードの品質を観測できるようにしています。

公開した静的解析ツール

今回公開したMustAwaitAnalyzerTaskUniTaskawait忘れを検出する静的解析ツールです(以後、Taskを例に説明しますがUniTaskでも同様です)。

C#では、Taskクラスとasync/awaitキーワードを利用することで非同期処理を簡潔に記述できます。 たとえば下記のようなコードで、DeleteAsyncメソッドを実行しながら別の処理を実行できます。

public async Task DoAsync()
{
    using var client = new HttpClient();
    var task = client.DeleteAsync("https://example.com/hoge");

    // 何か別の処理をする

    await task;
}

async/awaitの利点の1つは、非同期に実行したメソッドで例外が発生したとき、その呼び出し元に例外を伝播してくれるという点です。 上記のコードの場合、DeleteAsyncメソッドで例外が発生すると、その呼び出し元であるDoAsyncメソッドに伝播され例外を発生させます。

しかし、awaitを記述せず、かつTaskクラスのExceptionプロパティをチェックしなかった場合、呼び出し先の例外が捕捉されません。 このようなコードは意図しない挙動を引き起こすことがあります。

public async Task DoAsync()
{
    using var client = new HttpClient();
    var task = client.DeleteAsync("https://example.com/hoge");

    // 何か別の処理をする

    // await task; awaitを忘れると、DeleteAsyncで例外が発生しても捕捉されない
}

このようなawait忘れを検知する静的解析ツールMustAwaitAnalyzerを開発しました。

フロー解析の導入

開発当初は構文解析によってawait忘れを検知していました(発表スライドのこのあたり)。 構文木を探索し、Task型の変数やTask型を返すメソッド呼び出しの親ノードがawait式であれば問題なし、そうでなければ警告するという検知ロジックです。 しかし、このロジックでは正しく検知できないことがありました。 たとえば以下のようなコードです。

async Task MultiTask(bool b1)
{
    var list = new List<Task>(); // s1
    var task1 = DoAsync();       // s2
    if (b1)                      // s3
    {
        var task2 = DoAsync();   // s4
        list.Add(task2);         // s5
    }
    list.Add(task1);             // s6
    await Task.WhenAll(list);    // s7
}

この例の場合、先述の検知ロジックではs2やs4のDoAsyncメソッドの呼び出しがawaitされていないという警告が出力されます。 しかし実際には、DoAsyncメソッドが返すTaskオブジェクトがリストに格納され、それらをs7でまとめてawaitするという問題のないコードです。 問題ないコードだと正しく判断するためには、以下のようにプログラムの実行の流れを追っていく必要があります(下記はs2のDoAsyncメソッドについてですが、s4でも同様です)。

  1. s2で、Task型を返すメソッドが呼び出されtask1に代入される
  2. s6で、s2で代入されたtask1がTask型のList(list)に追加(Add)される
  3. s7で、listを引数にTask.WhenAllメソッドが実行されている
  4. s7で、そのTask.WhenAllがawaitされている

このように、ある文の実行が変数を経由して別の文の実行に影響を与えるという依存関係を明らかにする必要があります。 これを実現する静的解析技術がフロー解析(flow analysis)です。

フロー解析には制御フロー解析とデータフロー解析の大きく2種類があります。 この2種類のフロー解析について説明します(詳細を知りたい方は末尾のリファレンスも参照してください)。 フロー解析でよく出てくる用語も紹介しながら記述しているので、コードリーディングや文献調査の際に参考になればと思います。

ちなみに、MustAwaitAnalyzerのフロー解析の実装はこのあたりです。

制御フロー解析

制御フロー解析では、文の実行順序、分岐、合流を解析し、それらを有向グラフで表現します。 下図はMultiTaskメソッドの例です。

このグラフを制御フローグラフ(control flow graph)と言います。 制御フローグラフの各ノードは、その途中に分岐も合流もない文の列になっています。 たとえばノードB1はs1, s2, s3で構成されていますが、s3から分岐が始まるためs4からは別のノードB2になっています。 また、s6がその分岐の合流地点となるため、これも別のノードB3になっています。 このノードの単位を基本ブロック(basic block)と言います。

制御フローグラフを見れば文の実行順序が分かります。 基本ブロック内は上から下の順で実行されます。基本ブロック間は辺の接続元から接続先の順で実行されます。

MustAwaitAnalyzerの目的では、制御フローグラフはデータフロー解析のための準備という位置づけですが、 制御フローグラフだけで検出できる問題もあります。 たとえば実行されることのないコードの検出です。 C#ではCS0162としてこの問題が警告されますが、これは制御フローグラフを探索することで分かります。 Entryを起点に辺をたどっていき、到達できなかったノードが実行されないコードです。

Roslynでは制御フローグラフをモデル化したクラスControlFlowGraphが用意されています。 MustAwaitAnalyzerでもこのクラスを利用しています。

データフロー解析

データフロー解析では、ある代入文で定義された変数の値がどこで参照されるかという関係を解析します。 ある代入文pとある参照文qがあり、文pで定義された値を文qで参照するとき、pの定義がqに到達する(reach)と言います。 この到達の関係を明らかにするために制御フローグラフを使います。

変数xを定義する文pと参照する文qが同じ基本ブロックにあれば、qから最も近いpがqに到達します。 たとえばMultiTaskメソッドの変数task2はs4で定義され、s5で参照されます。 これは、基本ブロックにはその途中に分岐も合流もないという性質から導けます。 仮にs4とs5の間にtask2を定義する文sxがあった場合、sxがs5に到達し、s4の定義は無効になります。 そのため「最も近い」という条件が入っています。

変数xを定義する文pと参照する文qが異なる基本ブロックにある場合は、辺の接続元の基本ブロックにある定義文から接続先の基本ブロックにある参照文に到達します。 たとえばB1のs1で定義されたlistが到達するのはB2とB3の参照文です。つまり、s5, s6, s7になります。 また、s2で定義されたtask1が到達するのはs6です(B2にはtask1の参照文がない)。 ただし、定義文のある基本ブロックBxから参照文のある基本ブロックByに至るまでに同じ変数の定義文があった場合、Bxの定義はByに到達しません。 たとえば、仮にB1とB3の間に別の基本ブロックがあり、そこでtask1が定義されていたらs2はs6に到達しません。 このような計算手順は定式化されていてデータフロー方程式(data flow equation)と呼ばれています。

以上の手順で到達の関係を解析すれば、先述したプログラムの実行の流れを機械的に追うことができます。

  1. s2で、Task型を返すメソッドが呼び出されtask1に代入される
  2. s6で、s2で代入されたtask1がTask型のList(list)に追加(Add)される
  3. s7で、listを引数にTask.WhenAllメソッドが実行されている
  4. s7で、そのTask.WhenAllがawaitされている

4は構文解析で分かりますが、1から3の順で実行されることは制御フローグラフから分かります。 また、1で代入した変数task1が2でlistに追加されるという流れは、s2の定義がs6に到達することに対応しています。

Roslynでは、どの文でどの変数を定義および参照するかを取得できます(AnalyzeDataFlowメソッド)。 MustAwaitAnalyzerでもこのクラスを利用しています。

終わりに

フロー解析を取り入れた静的解析ツールは現状あまり多くないかもしれませんが、これまでレビューに頼るしかなかった部分を自動化できる可能性があります。 フロー解析は構文解析よりも実装コストがかかることが多いのですが、RoslynのAPIがあるおかげでかなり省力化できます。 今回公開したOSSに限らず、このような静的解析ツールを広めていきたいと考えています。

リファレンス