10
10

More than 5 years have passed since last update.

gofmt は式に含まれるスペースをいい感じに調整してくれる

Posted at

夏にこんなツイートをしたんです。

そうしたら mattn さんの目に留まったらしく、 github.com/golang/go で質問してくれました。

cmd/gofmt: doesn't unity spaces in expression with plus operator #12105

When two arguments are given, gofmt removes spaces both sides in plus operator. Is this expected?

すぐに Rob Pike からの返事。

Yes. Gofmt uses spaces to show grouping appropriate to operator precedence.

なるほど。 Go は演算子の優先度を考慮してスペースを調整してくれるようです。

不具合ではないということでその時はスルーしてしまったんですが、今回あらためて gofmt (cmd/gofmt) のコードを読んでみました。上記 Issue に mattn さんが貼ったサンプルコードで、どのようにスペースが調整されてるかを追っていきます。

sample.go
package main

import "fmt"

func main() {
    s := "foo" + "bar"                    // space plus space
    fmt.Println(s)                        //
    fmt.Println("foo" + "bar")            // space plus space
    fmt.Println("foo"+"bar", "bar"+"baz") // just plus
}

gofmt を読む

まず gofmtMain() で引数を判定して processFile() を呼び出しています。

processFile の要点は 2 つ。

  1. 入力されたコードの AST (抽象構文木) を取得 (internal/format.Parse())
  2. 取得した AST をフォーマット (internal/format.Format())

スペース数の調整は当然 2 で行われています。

1. 入力されたコードの AST (抽象構文木) を取得

internal/format.Parse() から go/parser.ParseFile() を呼び出して AST を取得しています。取得した AST を可視化すると以下のようになります。 (可視化には GoAst Viewer を使わせていただきました)

ast.png

これは元のコードのうち以下の部分を表しています。

sample.go
    fmt.Println("foo" + "bar")            // space plus space
    fmt.Println("foo"+"bar", "bar"+"baz") // just plus

2. 取得した AST をフォーマット

internal/format.Format() から go/printer.Config.Fprint()go/printer.Config.fprint()go/printer.printNode() とたどっていきます。

このメソッドで AST の node に応じた分岐をしています。今回挙動を知りたいのは ast.Expr です。さらにたどる ... 。 go/printer.expr()go/printer.expr1()

ここでまた分岐です。今回は ast.CallExpr 。

nodes.go
    case *ast.CallExpr:
        if len(x.Args) > 1 {
            depth++
        }
        if _, ok := x.Fun.(*ast.FuncType); ok {
            // conversions to literal function types require parentheses around the type
            p.print(token.LPAREN)
            p.expr1(x.Fun, token.HighestPrec, depth)
            p.print(token.RPAREN)
        } else {
            p.expr1(x.Fun, token.HighestPrec, depth)
        }
        p.print(x.Lparen, token.LPAREN)
        if x.Ellipsis.IsValid() {
            p.exprList(x.Lparen, x.Args, depth, 0, x.Ellipsis)
            p.print(x.Ellipsis, token.ELLIPSIS)
            if x.Rparen.IsValid() && p.lineFor(x.Ellipsis) < p.lineFor(x.Rparen) {
                p.print(token.COMMA, formfeed)
            }
        } else {
            p.exprList(x.Lparen, x.Args, depth, commaTerm, x.Rparen)
        }
        p.print(x.Rparen, token.RPAREN)

関数名の部分 (x.Fun) は go/printer.expr1() を再帰的に呼び出していますね。。末端ノードまでの再起呼び出しで AST を処理しているわけです。

引数が 2 つ以上の場合に入る ,

そして引数部分 (x.Args) 。こちらは go/printer.exprList() を呼んでいます。

        for i, x := range list {
            if i > 0 {
                // use position of expression following the comma as
                // comma position for correct comment placement
                p.print(x.Pos(), token.COMMA, blank)
            }
            p.expr0(x, depth)
        }

引数が 2 つ以上あった場合、 , を挿入しています。
fmt.Println("foo"+"bar", "bar"+"baz"), を出力しているのがここです。

やっとスペースを出しているところにたどり着いた ...!

しかしまだ終わっていません。 + の両端のスペースが残っています。

+ の両端に入る (ときと入らないときがある) スペース

各引数は go/printer.expr0()go/printer.expr1() とまた再帰呼び出し。今度は ast.BinaryExpr です。

func (p *printer) binaryExpr(x *ast.BinaryExpr, prec1, cutoff, depth int) {
    prec := x.Op.Precedence()
    if prec < prec1 {
        // parenthesis needed
        // Note: The parser inserts an ast.ParenExpr node; thus this case
        //       can only occur if the AST is created in a different way.
        p.print(token.LPAREN)
        p.expr0(x, reduceDepth(depth)) // parentheses undo one level of depth
        p.print(token.RPAREN)
        return
    }

    printBlank := prec < cutoff

    ws := indent
    p.expr1(x.X, prec, depth+diffPrec(x.X, prec))
    if printBlank {
        p.print(blank)
    }
    xline := p.pos.Line // before the operator (it may be on the next line!)
    yline := p.lineFor(x.Y.Pos())
    p.print(x.OpPos, x.Op)
    if xline != yline && xline > 0 && yline > 0 {
        // at least one line break, but respect an extra empty line
        // in the source
        if p.linebreak(yline, 1, ws, true) {
            ws = ignore
            printBlank = false // no blank after line break
        }
    }
    if printBlank {
        p.print(blank)
    }
    p.expr1(x.Y, prec+1, depth+1)
    if ws == ignore {
        p.print(unindent)
    }
}

printBlanktrue だったらスペースを入れています。判定に使っている prec には go/token.Precedence() からオペレータの優先度が入ります。 + なら 4 。 cutoff には
メソッド呼び出し時に cutoff() で判定した優先度が入ってきます。

+ の前後にスペースを出すかはここで制御しています。

長かった ...!

あとがき

作業の合間に少しずつ読み進めていたら、最後にたどり着くまで数日かかってしまいました。

mattn さんはというと

一瞬で目的のコードまでたどり着いてました。さすがです。
標準パッケージもっと読んで精進します。

10
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
10