夏にこんなツイートをしたんです。
gofmt 、 + 演算子の両隣にホワイトスペースが出るときと出ないときがある。 "abc" + "def" と "abc"+"def" が混ざって気持ち悪い。
— Taichi Sasaki (@tchssk) 2015, 8月 11
そうしたら 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 さんが貼ったサンプルコードで、どのようにスペースが調整されてるかを追っていきます。
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 つ。
- 入力されたコードの AST (抽象構文木) を取得 (internal/format.Parse())
- 取得した AST をフォーマット (internal/format.Format())
スペース数の調整は当然 2 で行われています。
1. 入力されたコードの AST (抽象構文木) を取得
internal/format.Parse() から go/parser.ParseFile() を呼び出して AST を取得しています。取得した AST を可視化すると以下のようになります。 (可視化には GoAst Viewer を使わせていただきました)
これは元のコードのうち以下の部分を表しています。
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 。
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)
}
}
printBlank
が true
だったらスペースを入れています。判定に使っている prec
には go/token.Precedence() からオペレータの優先度が入ります。 +
なら 4 。 cutoff
には
メソッド呼び出し時に cutoff()
で判定した優先度が入ってきます。
+
の前後にスペースを出すかはここで制御しています。
長かった ...!
あとがき
作業の合間に少しずつ読み進めていたら、最後にたどり着くまで数日かかってしまいました。
mattn さんはというと
@tchssk go/printer/nodes.go で CallExpr 見てるとこがそれですね。
— mattn (@mattn_jp) 2015, 8月 11
一瞬で目的のコードまでたどり着いてました。さすがです。
標準パッケージもっと読んで精進します。