Goによる静的解析入門

f:id:Imorley:20181223123414j:plain

この記事は「BASE Advent Calendar 2018」24日目の記事です。

devblog.thebase.in

はじめに

BASEでエンジニアとしてインターンをしている池田です。今日はクリスマスイブということで、以前から気になっていたグリューワイン(ドイツのクリスマスに欠かせないホットワイン)をクリスマスマーケットに飲みに来ています。

普段はBASE BANKというBASEの100%子会社にて金融事業の立ち上げを行っています。BASE BANKのプロダクトのAPIはGoを使って書かれているのですが、BASEでのGoの使用はこのプロジェクトが初だったので、開発基盤整備の一貫で様々なlinterの導入を行いました。その際にlinterの内部がどうなっているかに興味を持ち、すごく簡単なlinterの仕組みを実際に作ってみたので、今回はその知見をお話しします。

Goと静的解析

Goには、標準パッケージとして静的解析を行うためのgoパッケージが存在します。 また何よりGoは文法がシンプルな静的型付け言語なので、静的解析をとても簡単に行うことができました。

今回はその中でも、字句解析を行うgo/token、構文解析を行いAST(abstract syntax tree: 抽象構文木)を取り扱うgo/astgo/parserパッケージを用います。

今回は、megacheckというlinterを用いた際に実際に注意された、

bool値で条件分岐するときは、
if x == false {...}
じゃなくて
if !x {...}
の方がいい

といったことを検出してみたいと思います。 今回の静的解析の簡単な流れを下図に示します。 f:id:Imorley:20181223121212p:plain

抽象構文木のノードを探索していき、条件に当てはまるものがあったら警告するといった感じです。

やってみる

では実際にコードを見ていきましょう。 下がコード全体です。少しずつ解説していきます。

func main() {
    // 指定したファイルの構文解析を行う
    fset := token.NewFileSet()
    f, err := parser.ParseFile(fset, "sample.go", nil, 0)
    if err != nil {
        log.Fatal("Error:", err)
    }

    // 抽象構文木を探索する
    ast.Inspect(f, func(n ast.Node) bool {

        // 二項演算子ではない場合は無視
        expr, ok := n.(*ast.BinaryExpr)
        if !ok {
            return true
        }

        // == の場合
        if expr.Op == token.EQL {

            xIdent, ok := expr.X.(*ast.Ident) // Expr型をast.Ident型に型アサーション
            if !ok {
                return true
            }
            yIdent, ok := expr.Y.(*ast.Ident) // Expr型をast.Ident型に型アサーション
            if !ok {
                return true
            }

            // 識別子を文字列として取り出す
            xString := xIdent.String()
            yString := yIdent.String()

            switch yString {
            case "true":
                fmt.Printf("- got: %s == %s\n+ want: %s\n", xString, yString, xString)
            case "false":
                fmt.Printf("- got: %s == %s\n+ want: !%s\n", xString, yString, xString)
            }

            return true
        }

        return true
    })
}

ファイルの読み込みと構文解析

// 指定したファイルの構文解析を行う
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "sample.go", nil, 0)
if err != nil {
    log.Fatal("Error:", err)
}

まずはsample.goというファイルを指定してparser.ParseFileにより構文解析を行います。

package main

func sample() {
    isAvailable := false
    if isAvailable == false {
        return
    }
}

ここで、「あれ、まず字句解析しないの?」と思った方もいらっしゃるかもしれません。 実はgo/parserパッケージが内部で字句解析を行うため、直接字句解析を行うことはあまりないのです。

抽象構文木の探索

続いて、構文解析した結果により作られた抽象構文木を扱います。

// 抽象構文木を探索する
ast.Inspect(f, func(n ast.Node) bool {
        ...
    }
)

ast.Inspectは抽象構文木のノードをトラバースする関数です。この中のfunc(n ast.Node) bool { ... }で、抽象構文木の各ノードに対して一律の処理をかけます。 実際にどのような処理をするか見ていきましょう。

// 二項演算子ではない場合は無視
expr, ok := n.(*ast.BinaryExpr)
if !ok {
    return true
}

まずはast.Node型のノードをast.BinaryExpr型に変換しています。 ast.BinaryExprは+==などのような二項演算子を表す型です。今回はx == falseのような箇所を検出したいので、この型に変換できない場合は無視して次に進みます。(なぜ最初にast.BinaryExp型に変換できるノードを探しているのかは下で説明します)

ast.BinaryExprは以下のような構造体です。

type BinaryExpr struct {
    X     Expr        // 左のオペランド
    OpPos token.Pos   // オペレーターの位置(ファイルの何行目の何バイト目か)
    Op    token.Token // オペレーター
    Y     Expr        // 右のオペランド
}

また下図は二項演算を抽象構文木にした場合の図で、BinaryExpr構造体のフィールド(X, Y, Op)との対応が見て取れると思います。 f:id:Imorley:20181223121042p:plain

図を見てわかる通り、二項演算では演算子が親ノード、オペランドが子ノードになるため、このステップではまずast.BinaryExp型に変換できるノードを探していました。

続いて、上記の結果返ってきたast.BinaryExp型の値に対して処理をかけます。

// == の場合
if expr.Op == token.EQL {

    xIdent, ok := expr.X.(*ast.Ident) // ast.Expr型をast.Ident型に型アサーション
    if !ok {
        return true
    }
    yIdent, ok := expr.Y.(*ast.Ident) // ast.Expr型をast.Ident型に型アサーション
    if !ok {
        return true
    }

    // 識別子を文字列として取り出す
    xString := xIdent.String()
    yString := yIdent.String()

    switch yString {
    case "true":
        fmt.Printf("- got:  %s == %s\n+ want: %s\n", xString, yString, xString)
    case "false":
        fmt.Printf("- got:  %s == %s\n+ want: !%s\n", xString, yString, xString)
    }

    return true
}

今回は簡単にx == yのような部分だけ検出したいので、まずオペレーターOptoken.Token型の==と等しいか調べています。 そしてオペレーターが==だった場合に、次は各オペランドをast.Identという型に変換しているのがわかるかと思います。

ここでIdentが何かというと、識別子を表します。

f:id:Imorley:20181223121153p:plain

上図のように、字句解析のあとの変数名や関数名、bool値は識別子を表すast.Ident型、1"dog"など数値リテラルや文字リテラルはast.BasicLit型になります。 今回はある変数をboolと比較している箇所を評価したいため、その条件に合致するようなオペランド対を探しています。 あとは、該当するような箇所があったらその旨を出力して終わりです。

実際にsample.goにこのlinterをかけた結果を見てみましょう。

# go run main.go
- got:  isAvailable == false
+ want: !isAvailable

ご覧のように、sample.go内でbool値と直接比較している箇所を検出できています。

終わりに

今回は、標準の静的解析パッケージであるgoパッケージを使って簡単な静的解析ツールを試作してみました。 goパッケージを使えば、lintの他にもコードの自動生成や自動フォーマットをすることもできます。 またこのパッケージのソースコードを読むことでコンパイラや言語処理系の勉強にもなるので、静的解析はGoの中でも面白いトピックの一つなのではないでしょうか。

では、よいクリスマスをお過ごしください!