複雑な問題をデバッグするのは大変な作業です。特に、どこがバグの原因なのかはっきりしない時はとても頭を悩ませることでしょう。

バグの原因を特定するために、コードを必要最低限まで減らしてテストする(reduced test case)のはよく取られる手段でしょう。しかし、それを手動でやるのは非常に大変な作業です。

そこで今回ご紹介するのが、C-Reduceです。C-Reduceは、必要最小限のコードファイルを生成する作業を自動化してくれるプログラムです。どう使うのか、一緒に見ていきましょう。

C-Reduceの概要

C-Reduceは主に2つの考えに基づいています。

1つ目は、「リダクションパス」という考えです。これは、ソースコード上でなされる変換です。C-Reduceは行を削除したり、トークンをより短くしたりといった、多くの変換を伴います。

2つ目は、「重要度テスト(interestingness test)」という考えです。リダクションパスでは、ほとんどバグを含まないプログラムか、コンパイルされていないプログラムのどちらかを生成します。そこでC-Reduceを使うときには、変換されたプログラムが「重要」かどうかを検証する、少量のスクリプトを用意する必要があります。何を指して「重要」とするかは、場合によって変わってきます。もし、バグを探し出すのが目的なら、返還後でもまだバグが起きている方を「重要」とするでしょう。

どんな場合であろうと、C-Reduceはテストを実施できる変換されたプログラムを提供しようとします。

C-Reduceのインストール

C-Reduceは多くの依存関係を持っており、一からインストールするのは簡単ではありません。ありがたいことに、Homebrewから簡単にインストールできるのでそちらを使っていきましょう。

brew install creduce

もしご自身でインストールしたい場合は、こちらのGitHubからC-Reduceのインストールファイルを見てください。

C-Reduce利用の簡単なサンプル

C-Reduceの目的は、膨大な量のコードを少なくすることです。そのため、C-Reduceのシンプルな例を考えるのは難しいのですが、できる限りやってみます。

こちらが、謎の警告を出すC言語プログラムです。


    $ cat test.c
    #include 

    struct Stuff {
        char *name;
        int age;
    }

    main(int argc, char **argv) {
        printf("Hello, world!\n");
    }
    $ clang test.c
    test.c:3:1: warning: return type of 'main' is not 'int' [-Wmain-return-type]
    struct Stuff {
    ^
    test.c:3:1: note: change return type to 'int'
    struct Stuff {
    ^~~~~~~~~~~~
    int
    test.c:10:1: warning: control reaches end of non-void function [-Wreturn-type]
    }
    ^
    2 warnings generated.

どうやら、structがmain関連で何かうまくいっていないようです。コードを削減することで、原因を特定できるでしょう。

ここで「重要度テスト」が必要です。上のプログラムをコンパイルし、アウトプットの中で警告をチェックするシェルスクリプトを書きます。C-Reduceはとても優秀ですが、ときに私たちが真に欲しい結果からかけ離れたプラグラムを生成してしまいます。これを制御するために、警告をチェックするだけでなく、他にエラーを発生させるプログラムは却下し、struct stuffがコンパイルの結果の中に入るようにします。こちらがそのスクリプトです。


    #!/bin/bash

    clang test.c &> output.txt
    grep error output.txt && exit 1
    grep "warning: return type of 'main' is not 'int'" output.txt &&
    grep "struct Stuff" output.txt

まず、プログラムをコンパイルし、コンパイルの結果をoutput.txtに出力します。もしアウトプットに”error”の文字が含まれていたら、直ちにエラーコードを出力して処理を抜け出しましょう。そうすることで、このプログラムは「重要でない」ことを知らせられます。

”error”の文字が含まれていなければ、スクリプトは警告とstruct Stuffの両方をチェックします。コード0で処理を抜け出せられれば成功したことを意味します。このスクリプトのコードが0で処理が終了した場合、警告もstruct Stuffも発見できたということになり、コードが1だったら、その内のどちらかが見つからなかったということになります。C-Reduceは、終了コードが0だったら最終的に出力されたプログラムが「重要」だと理解し、コードが1だったら「重要」でなく削除すべきだと理解します。

さあ、C-Reduceを実行してみましょう。


    $ creduce interestingness.sh test.c 
    ===< 4907 >===
    running 3 interestingness tests in parallel
    ===< pass_includes :: 0 >===
    (14.6 %, 111 bytes)

    ...lots of output...

    ===< pass_clex :: rename-toks >===
    ===< pass_clex :: delete-string >===
    ===< pass_indent :: final >===
    (78.5 %, 28 bytes)
    ===================== done ====================

    pass statistics:
      method pass_balanced :: parens-inside worked 1 times and failed 0 times
      method pass_includes :: 0 worked 1 times and failed 0 times
      method pass_blank :: 0 worked 1 times and failed 0 times
      method pass_indent :: final worked 1 times and failed 0 times
      method pass_indent :: regular worked 2 times and failed 0 times
      method pass_lines :: 3 worked 3 times and failed 30 times
      method pass_lines :: 8 worked 3 times and failed 30 times
      method pass_lines :: 10 worked 3 times and failed 30 times
      method pass_lines :: 6 worked 3 times and failed 30 times
      method pass_lines :: 2 worked 3 times and failed 30 times
      method pass_lines :: 4 worked 3 times and failed 30 times
      method pass_lines :: 0 worked 4 times and failed 20 times
      method pass_balanced :: curly-inside worked 4 times and failed 0 times
      method pass_lines :: 1 worked 6 times and failed 33 times

              ******** .../test.c ********

    struct Stuff {
    } main() {
    }

最後に、変換されたコードが出力されます。また、変換されたコードは、オリジナルのファイルにも保存されます。そのため、必ず元のファイルのコピー(もしくは、すでにバージョン管理されているファイル)で実行し、オリジナルのもので実行しないようにしましょう。

この変換されたプログラムによって、問題がよりはっきりしました、私はstruct Stuffの宣言の後にセミコロン「;」を忘れていたのですね。くわえて、mainで返す値のタイプ指定も忘れていました。これが原因で、コンパイラはmainでの返却タイプをstruct Stuffと解釈していたのです。mainはintを返さなければいけないので、それが警告として出ていたのです。

XcodeでC-Reduceを実行する

ひとつのファイルしかないプロジェクトでは、すぐに動作が確認できました。では、より複雑なプロジェクトではどうでしょう?私たちの多くはXcodeのプロジェクトを持っていますので、その内のひとつを変換したい場合はどうすれば良いのか見ていきましょう。

これは、C-Reduceの仕組み上、少々扱いにくいです。C-Reduceは削減したいファイルを新しいディレクトリにコピーして、その中で「重要」なスクリプトを実行します。このおかげで、多くの検証を並行して実施できますが、もし他のファイルをまたいで実行させる必要があれば、検証は処理の途中で終了してしまいます。「重要」なスクリプトは任意のコマンドを実行できるので、プロジェクトの残りファイルも全て一時的なディレクトリにコピーし、そこでテストをすることにしましょう。

Xcodeで標準のCocoa Objective-Cアプリプロジェクトを作成して、AppDelegate.mファイルを以下のように修正しました。


    #import "AppDelegate.h"

    @interface AppDelegate () {
        NSWindow *win;
    }

    @property (weak) IBOutlet NSWindow *window;
    @end

    @implementation AppDelegate

    - (void)applicationDidFinishLaunching: (NSRect)visibleRect {
        NSLog(@"Starting up");
        visibleRect = NSInsetRect(visibleRect, 10, 10);
        visibleRect.size.height *= 2.0/3.0;
        win = [[NSWindow alloc] initWithContentRect: NSMakeRect(0, 0, 100, 100) styleMask:NSWindowStyleMaskTitled backing:NSBackingStoreBuffered defer:NO];
        [win makeKeyAndOrderFront: nil];
        NSLog(@"Off we go");
    }


    @end

この奇妙なコードは、アプリ起動時にクラッシュしてしまいます。


    * thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=EXC_I386_GPFLT)
      * frame #0: 0x00007fff3ab3bf2d CoreFoundation`__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 13

これはあまり有益なバックトレースではありません。デバッグも可能ですが、代わりにコードを減らしてみましょう!

「重要度テスト」をするためにはもう少し工夫する必要があります。まず、アプリをタイムアウトさせるようにしましょう。私たちはクラッシュを探していますが、もしアプリがクラッシュしなかったら、アプリはただ開いたままになってしまうため、アプリを数秒後に終わらせてテストを継続させる必要があります。便利なPerlのスニペットがあります。


    function timeout() { perl -e 'alarm shift; exec @ARGV' "$@"; }

次に、Xcodeプロジェクトを全てコピーしましょう。


   cp -a ~/Development/creduce-examples/Crasher .

AppDelegate.mファイルは自動的には正しい場所に配置されないので、全体をコピーする必要があります。(注:C-Reduceは削減したファイルをコピーバックするので、ここではむしろmvよりもcpを使うことに注意してください。mvを使うとよく分からない致命的なエラーが出てしまいます)


    cp AppDelegate.m Crasher/Crasher

次に、Crasherディレクトリに移動しましょう。失敗したら処理を出るようにしながら、プロジェクトをビルドします。


    cd Crasher
    xcodebuild || exit 1

もしこれが成功したら、タイムアウトとともにアプリを起動しましょう。筆者のシステムでは、xcodebuildがビルドの結果をローカルのbuildディレクトリに配置するように設定されています。人によってはおそらく違うように設定されているでしょうから、はじめにチェックしてください。もし共有のビルドディレクトリにビルドされるように設定しているのであれば、C-Reduceのパラレルビルドを無効にするために、コマンドラインに –n 1を追加してください。


    timeout 5 ./build/Release/Crasher.app/Contents/MacOS/Crasher

もしクラッシュが起きたら、139という特殊なコードを出力して処理を終えてください。これをexitコード0と翻訳して、それ以外の場合は、コード1で処理を抜け出すようにします。


    if [ $? -eq 139 ]; then
        exit 0
    else
        exit 1
    fi

では、C-Reduceを実行してみましょう。


    $ creduce interestingness.sh Crasher/AppDelegate.m
    ...
    (78.1 %, 151 bytes)
    ===================== done ====================

    pass statistics:
      method pass_ints :: a worked 1 times and failed 2 times
      method pass_balanced :: curly worked 1 times and failed 3 times
      method pass_clex :: rm-toks-7 worked 1 times and failed 74 times
      method pass_clex :: rename-toks worked 1 times and failed 24 times
      method pass_clex :: delete-string worked 1 times and failed 3 times
      method pass_blank :: 0 worked 1 times and failed 1 times
      method pass_comments :: 0 worked 1 times and failed 0 times
      method pass_indent :: final worked 1 times and failed 0 times
      method pass_indent :: regular worked 2 times and failed 0 times
      method pass_lines :: 8 worked 3 times and failed 43 times
      method pass_lines :: 2 worked 3 times and failed 43 times
      method pass_lines :: 6 worked 3 times and failed 43 times
      method pass_lines :: 10 worked 3 times and failed 43 times
      method pass_lines :: 4 worked 3 times and failed 43 times
      method pass_lines :: 3 worked 3 times and failed 43 times
      method pass_lines :: 0 worked 4 times and failed 23 times
      method pass_lines :: 1 worked 6 times and failed 45 times

              ******** /Users/mikeash/Development/creduce-examples/Crasher/Crasher/AppDelegate.m ********

    #import "AppDelegate.h"
    @implementation AppDelegate
    - (void)applicationDidFinishLaunching:(NSRect)a {
      a = NSInsetRect(a, 0, 10);
      NSLog(@"");
    }
    @end

とても短くなりましたね!NSLogの行が原因みたいです。もちろん、C-Reduce実行後に残ったプログラムがクラッシュの原因であることは明らかなのですが。それ以外のものは a = NSInsetRect(a, 0, 10)の行しかありません。 “ a “はどこから来て、なぜこれがいけないのでしょうか?ただのapplicationDidFinishLaunchingのパラメータのはずですが……なるほど、これはNSRectではないのですね。


    - (void)applicationDidFinishLaunching:(NSNotification *)notification;

パラメータータイプのミスマッチが、スタックを汚してクラッシュを起こしていたようです!

このサンプルを実行するのにC-Reduceは時間がかかりました。なぜなら、Xcodeプロジェクトは単一のファイルをコンパイルするのに時間がかかるうえ、実行中にそれぞれのテストケースで5秒のタイムアウトを実行していたからです。C-Reduceはそれぞれのテストケースが成功するたびに元のディレクトリに削減されたファイルをコピーバックします。そのため、作業中にテキストエディタでファイルを開きっぱなしにできます。もう十分C-Reduceを行ったと思ったら、^Cで終了し、部分的に削減されたファイルを残すことができます。もう少し継続したいと思ったら、再度実行してそこから続けましょう。

SwiftでC-Reduceを実行する

もしSwiftでバグの原因を見つけたいと思ったらどうすれば良いでしょうか?筆者は当初、その名前から、C-ReuceはC言語でしか使えないと思ってました(あとは他の多くのツールと同じ様にC++でもおそらく使えるだろうと)。

けれど、私の認識は嬉しいことに間違っていました。C-Reduceは確かにC言語特有のリダクションパスを持っていますが、比較的言語にとらわれないリダクションパスも多く持っています。少し有効でなくなるかもしれませんが、バグに対して「重要度テスト」を書く限り、C-Reduceはどんな言語であろうと働くだろうと思います。

試してみましょう。コンパイル時の優良なバグをbugs.swift.orgで見つけました。すでに修正されているものでしたが、Xcode 9.3のSwiftではエラーが起きていて、私はたまたまそのバージョンを持っていたのでこれで試したいと思います。こちらが、少々変更を加えたソースコードです。


    import Foundation

    func crash() {
        let blah = ProblematicEnum.problematicCase.problematicMethod()
        NSLog("\(blah)")
    }

    enum ProblematicEnum {
        case first, second, problematicCase

        func problematicMethod() -> SomeClass {
            let someVariable: SomeClass

            switch self {
            case .first:
                someVariable = SomeClass()
            case .second:
                someVariable = SomeClass()
            case .problematicCase:
                someVariable = SomeClass(someParameter: NSObject())
                _ = NSObject().description
                return someVariable // EXC_BAD_ACCESS (simulator: EXC_I386_GPFLT, device: code=1)
            }

            let _ = [someVariable]
            return SomeClass(someParameter: NSObject())
        }

    }

    class SomeClass: NSObject {
        override init() {}
        init(someParameter: NSObject) {}
    }

    crash()

最適化を有効にして実行してみましょう。


    $ swift -O test.swift 
    :0: error: fatal error encountered during compilation; please file a bug report with your project and the crash log
    :0: note: Program used external function '__T04test15ProblematicEnumON' which could not be resolved!
    ...

「重要度テスト」は今回に関しては十分にシンプルです。コマンドを実行してexitコードを確認してみましょう。


    swift -O test.swift
    if [ $? -eq 134 ]; then
        exit 0
    else
        exit 1
    fi

実行の結果、以下のプログラムが生成されました。


    enum a {
        case b, c, d
        func e() -> f {
            switch self {
            case .b:
                0
            case .c:
                0
            case .d:
                0
            }
            return f()
        }
    }
    class f{}

実際のバグを見つけ出すのはこの記事の目的ではないので行いません。しかし、このリダクションは実際にプログラムを修正するときには本当に便利でしょう。私たちはかなりシンプルなテストケースを用いて問題に取り組むことができます。また、swiftの宣言とクラスのインスタンス化の間にいくつかのインタラクションがあると推定できます。なぜなら、C-Reduceは不必要なインタラクションを取り除けるからです。この性質のおかげで、私たちはクラッシュを引き起こすコンパイルの最中に何が起きているのかのヒントを得られます。

結論

バグの原因を特定するために、コードを必要最低限まで減らしてテストする(reduced test case)のはそんなに洗練されたデバッグ手法ではありませんが、それを自動化すればかなり便利になります。C-Reduceはデバッグツールとして素晴らしいツールでしょう。全てに最適なわけではありませんが、ある一定のバグに対してはかなり有効です。複数のファイルがあるテストケースの場合は少しトリッキーですが、「重要度テスト」がその問題は解決してくれます。また、C-Reduceその名前に関わらず、Swiftなどの他の言語でも利用できます。なので、C言語のプロジェクトじゃないからって諦めないでくださいね!

(原文:Mike Ash 翻訳:Yui Shimizu)

SHARE

RELATED

  • お問い合わせ
  • お問い合わせ
  • お問い合わせ