この記事は、クラスター Advent Calendar 2022(2ページ目)の14日目の記事です。
昨日は、@pocketberserker さんの、会社に雑LTという文化があったので開催してみたでした。
こんにちは、あるいはこんばんは。
Go歴3ヶ月の@homulerです。
クラスターではサーバーサイドエンジニアをやっています。
今回は、日々の開発でお世話になっている、Goのコードのformatterについての話をします。
Gofmtとgoimports
Goは、公式がGofmtというツールを提供していて、Gofmtを使っているかは別として、ほとんどのプロジェクトにおいて、コードはなんらかのツールを使って自動整形されていると思います。
空気のように存在していて、Go初心者が真っ先にお世話になるツール。
他の言語でもこういったツールは存在しますが、Gofmtは、フォーマットに関して設定が(ほぼ)できないようになっていて、どのルールを採用するかという争いが発生しないのが特徴的です。
Goは、Gofmtの他に、(リポジトリは別ですが)goimportsというツールを提供していて、こちらはimport declarationsの修正が主な用途だと思われますが、Gofmtと同様の整形も行ってくれます1。
コードのフォーマットという点において、goimportsは少し面白い機能を持っていて、
- 複数のimport declarationを1つにマージする2
↓
import "errors" import "fmt"
import ( "errors" "fmt" )
- import specをグルーピング3し、グループ内でアルファベット順に並べる
↓
import ( "github.com/homuler/foo" "golang.org/x/example/stringutil" "local.package.com/bar" "fmt" "errors" )
import ( "errors" "fmt" "github.com/homuler/foo" "golang.org/x/example/stringutil" "local.package.com/bar" )
ことができます4。
これを初めて聞いた時は、素直にすごいなと思ったわけです。
Gofmtは、実際はどうあれ、ASTを好きな形の文字列として出力してるんだろうな、と想像がつくので、それほど中身に興味を持たなかったのですが、goimportsのように、並べ替えやマージを行っているとなると、話は変わってきます。
間にコメントがあった場合、どうしているんでしょうか?
goimportsの挙動
どう整形するかというのは、結局は決めの問題です。
以下では、goimportsの実際の動きを見ていきます。
グルーピング
まずは、グルーピングから。
goimportsは、packageを
- 標準ライブラリ
- appengineのパッケージ
- 外部のパッケージ
- ローカルのパッケージ
の4つに分けて、それぞれの中でパッケージ名による並べ替えを行い、グループの間には空行を挿入します。
また、パッケージ名の間に空行がある場合、これらも別のグループとして扱います。
import (
"fmt"
"github.com/homuler/foo"
"errors"
"context"
)
↓
import (
"fmt"
"github.com/homuler/foo"
"context"
"errors"
)
空行を無視する判断もありそうですが、開発者が意図的に空行を挿入した可能性もあり、それを尊重したものと思われます。
では、コメントが含まれていた場合はどうでしょうか?
import (
"fmt"
"github.com/homuler/foo"
// doc comment for errors
"errors"
"context"
)
↓
import (
"fmt"
"github.com/homuler/foo"
// doc comment for errors
"context"
"errors"
)
コメントを無視してそうに見えますが、これだけだとなんとも言えないので、次に行ってみます。
並べ替え
まずはline comment付きの場合。
import (
"fmt" // fmt
"github.com/homuler/foo" // foo
"errors" // errors
)
↓
import (
"errors" // errors
"fmt" // fmt
"github.com/homuler/foo" // foo
)
なにやらいい感じがします。
間にコメントがある場合も試してみます。
import (
"fmt" // fmt
// foo
"github.com/homuler/foo"
"errors" // errors
)
↓
import (
"fmt" // fmt
// foo
"errors" // errors
"github.com/homuler/foo"
)
まぁ、そういうこともある。
別に開発者の意思を尊重してるわけではない気もしてきましたが、もう一つ見てみます。
マージ
マージにはいろいろなパターンがありますが、一例。
import (
"fmt" // fmt
"github.com/homuler/foo" // foo
)
import (
"context" // context
"errors" // errors
)
↓
import (
"context"
"errors"
"fmt"
"github.com/homuler/foo" // fmt
// foo
)
// context
// errors
潔い!
いや、一周回ってやっぱりすごい気がしてきました。
そもそも、import "C"
を除いて、複数のimport文が書かれるケースは少ないわけですし、マージ処理が必要な状況はめったに発生しないと考えられます。
その場合に、コメントはそのままにして、後でユーザーにコメントを移動させるという判断はもっともです(が、私は怖くてできなさそうな判断でもある)。
……いや、コメント無視したらアカンやろ。
どうしてこうなった
この挙動を説明するための準備として、 go/ast, go/token, go/printer の中身に触れておきます。
go/ast
goimportsは、printer.Config.Fprint
を使ってコードを整形しますが5、その引数として、ソースコードに対応するast.Fileを渡しています。
以降の説明で必要になるのは、以下の3点です。
- ASTの本体は、ast.Declのsliceとして表現される (
Decls
) - すべてのコメントは、
Decls
とは別に保持される(Comments
) - 一部のNode(e.g. GenDecl, ImportSpec)は、自身に紐付いたコメントへの参照(i.e.
Doc
,Comment
)を持つ
go/token
go/tokenもFileと呼ばれる構造体を持ち、これもConfig.Fprint
に引数として間接的に渡されます(直接的には、token.FileSet)。
exported fieldsを持たないのですが、内部にソースファイルの改行位置の情報を保持しています。
token.Fileが重要なのは、ast.Fileだけでは出力結果が一意に決まらないからです。
go/printer
Config.Fprint
は、ASTをpretty printします。
ast.Fileを引数に渡した場合、結局Decls
をprintすることになります。
では、コメントはどうしているかというと、printerが内部にComments
を保持していて
- tokenを出力
- 次のtokenへ
- 未出力のコメントがあったら、コメントを出力(
flush
) - 次のtokenを出力
- ...
という流れを繰り返して出力されます。つまり、Decls
のカーソルとComments
のカーソルを交互に動かしながら出力しているようなイメージです(printer内部では、 intersperseComments
と呼ばれている)6。
最後に、改行についても触れておきます。
token.Fileのところで、ast.Fileだけでは出力結果が決まらないと書きましたが、例えば、以下の2つのコードをgo/parserのParseFile
でparseすると、中身が全く同じast.Fileが得られます。
package p
func f() { }
package p
func f() {
}
しかし、これらを go fmt
した結果は、以下のように異なります。
package p
func f() {}
package p
func f() {
}
前後のtokenの種類によって、処理がまちまち(かつ複雑)で、ここですべてを説明することはできませんが、例えば上記のfunction bodyの例だと、bodySize
という関数の中で、tokenの行番号をチェックすることで、最終的な出力結果を変えています。
if pos1.IsValid() && pos2.IsValid() && p.lineFor(pos1) != p.lineFor(pos2) { // opening and closing brace are on different lines - don't make it a one-liner return maxSize + 1 }
この行番号の情報は、ast.Fileは持っておらず、printerは、ソースファイルに対応するtoken.File(直接的には、token.FileSet)を参照して、これを取得しているというわけです。
さて、ここまで見てきた内容から、次のことが分かります。
- tokenの位置を書き換えた場合、コメントの位置がずれる可能性がある(コメントの位置も更新する必要がある)
- tokenの位置を書き換えた場合、行番号の判定処理が正しくなくなる(token.Fileを更新する必要がある)
以上を踏まえて、いよいよgoimportsの処理内容を見ていきます。
goimports
グルーピング
sortImports
内の処理が関係していますが、ImportSpecの行番号が2以上ずれたらグループ分けをしています。
if j > i && tokFile.Line(s.Pos()) > 1+tokFile.Line(d.Specs[j-1].End()) { // j begins a new run. End this one. specs = append(specs, sortSpecs(localPrefix, tokFile, f, d.Specs[i:j])...) i = j }
間が空行であるかは確認していないので、コメント行が間に挟まっていたとしても、グループ分けされてしまいます。
とはいえ、これがダメとも言えないわけで、もしこれを嫌うなら、少なくともImportSpec.Doc
に入るコメント位置の調整を行う必要があり、処理が複雑になります。
並べ替え
line commentはちゃんと一緒に並べ替えてくれてそうでしたが、実際コメントと一緒に位置を変えています。
が、今書いていて気がつきましたが、ImportSpec.Comment
が一行のコメントであることを仮定してますね(実際は、複数行コメントが入ることもある)。
import (
"fmt" // fmt
"context" /* context
*/
)
↓
import (
"context"
"fmt" // fmt
/* context
*/)
しかし、goimportsからすると、複数行コメントが一緒に移動すると、整形後のコードにおいてcontext
とfmt
は別のグループになってしまうので、これで正しいのかもしれません。難しい……
閑話休題、ImportSpec.Docは見ていないので、これらのコメントは、元の場所に置き去りにされます。
マージ
import declarationのマージ処理は、mergeImports
が行っています。
// Move the imports of the other import declaration to the first one. for _, spec := range gen.Specs { spec.(*ast.ImportSpec).Path.ValuePos = first.Pos() first.Specs = append(first.Specs, spec) }
いや〜潔いです。後続のImportDeclに含まれていたImportSpecの位置を、全て最初のImportDeclのPos()
に変えています。ただし、全てのImportSpecが同じ位置にあることは、実際にはありえないので、ASTのデータとしては問題ないにせよ、これを文字列として出力すれば、コメントや改行の位置は当然破綻します。
ちなみに、この件については、mergeImports
のコメントにも、ちゃんと7書かれています8。
// mergeImports merges all the import declarations into the first one. // Taken from golang.org/x/tools/ast/astutil. // This does not adjust line numbers properly
この方法は、潔さの他にもう一つ良い点があり、ImportDecl以外のDeclに影響を(おそらく)与えません。つまり、他のDeclとtoken.Fileの改行位置が整合するので、これによって、ImportDecl以外の出力結果をめちゃくちゃにせずに済んでいます。
goimportsの悩み
まとめると、goimportsは以下のような問題を抱えています。
- goimportsは一行コメント9以外を無視している
- その結果、ImportSpecの
Doc
に入っていたコメントが、フォーマット後にあらぬ場所に行ってしまう - astutilの関数の呼び出しや、それに準ずる処理が行われた場合、一行コメントもあらぬ場所に行ってしまう
- (詳しく触れていないものの)改行位置を参照する以外の目的で行番号を取得しており、Goとして適格な一部のコードを受け付けない
表面的には、
- 並べ替えの時に、コメントをあまりうまく扱えていないように見える
- コメントが間に入ると、別のグループとして扱われてしまい、並べ替えてくれない
のが問題と言えます。
少なくとも後者については、ImportSpecのDoc
にコメントが入っている限り、一緒に移動させるのが自然であり、現行の動きが正しいようには思えませんが、問題はもう少し複雑です。
例として、ImportSpecのDoc
を尊重することで、グルーピングの挙動を改善10させられるか考えてみます。
goimportsはマージをサポートしているため、
/* fmt */ import "fmt"
// fmt
import "fmt"
というコード片を、以下のように変換します。
/* fmt */
import (
"fmt"
)
// fmt
これは、もともと Doc
として扱われていた // fmt
というコメントを捨てて、Doc
ではなかった /* fmt */
というコメントがDoc
として扱われるようにする動きです。
では、// fmt
も一緒に、以下のようにマージするのはどうでしょうか?
// fmt
/* fmt */ import "fmt"
実は、現行のgo/parserの動きを前提にすると、上記のコードにおいて、// fmt
も/* fmt */
もDoc
ではなくなっているのです。
つまり、ImportSpecのDoc
を参照するだけでは、一度は「うまく」グルーピングできたとしても、その出力結果は、出力前と同じ意味11に解釈されない可能性があります12。
また、より根源的なところで、そもそもgo/printerは、変換後のソースコードをparseして得られるast.Fileが、元のast.Fileと(位置の違いを除いて)同じになることを保証していないため、Doc
やComment
に頼るのでは、問題は解決できないように思われます。
結論
- コメントが何に対するコメントなのか、本当のところは書いた本人にしか分からない13
- goimportsとしては、ユーザーが後からコメントを移動した時に、その変更を受け付けてあげることが重要
- import declarationのNodeがtoken.Fileと不整合になることはあるが、それ以外のNodeを壊さないようにしていて偉い
すべての悩みを消し去りたい、この手で
誰がどういう時にどういう意図でどこにコメントを書くか、知る由もないので、すべてのケースでユーザーの意図通りに動くformatterを作ることはおそらく不可能でしょう。
不可能でしょう……
不可能でしょうけど、やっぱりもう少しいい感じに動いてほしいんだよなー。
というのは、色んな人が感じているようで、issueが立ったり、いろんなツールが書かれたりしています(やっぱりみんな大好きなんですね)。
代表的なところで、gosimportsというのがあり、これは一切のコメントを消し去る代わりに、グルーピングが一意になるようにしています。
これはこれで潔い。
でも、 //lint:ignore
とか書きたいことはないですか……?
ということで、もう一つ生やしてしまいました。
https://github.com/homuler/goimports-wasabi
コメントの位置を、以下のように可能な限り分解して、マージや並べ替えが発生しても、大まかな位置を維持できるようにしています。
// (1)
/*(2)*/ import /*(3)*/ _ /*(4)*/ "fmt" // (5)
// (6)
// (7)
// (8)
/*(9)*/ import /*(10)*/ ( // (11)
// (12)
// (1)'
/*(3)'*/ e /*(4)'*/ "errors" // (5)'
// (6)'
// (13)
) // (14)
// (15)
例えば、これが
// doc comment for fmt
import "fmt"
// doc comment for context
import "context"
// doc comment
import (
// doc comment for errors
"errors"
)
↓
// doc comment
import (
// doc comment for context
"context"
// doc comment for errors
"errors"
// doc comment for fmt
"fmt"
)
こうなります。
ちなみに、改行位置は、ソースコードを再構成したあとにFile.SetLinesForContentを呼ぶことで、整合性を保っています。
goplsが使っているAPIは実装しているので、ごにょごにょすれば、一応goplsに組み込んで動かすこともできます。
format.Source
を使って出力していた頃は、なぜかgoimportsより30%ぐらいパフォーマンスが良かったものの、format.Node
に変えたら、これまたなぜかgoimportsよりパフォーマンスが落ちたので、冬休みになんとかする予定です。
(おまけ)Gofmtの悩み
goimportsは、内部でGofmtと共通のAPIを使っていますが、そんなGofmtにも悩みがあります(知らんけど)。
みんな大好きなGofmt、その動作内容からして、処理がべき等(idempotent)であるはずだと信じていた方もいるのではないかと思いますが、1.19.4時点でべき等ではありません(信じてたのに、裏切られたの?私も同じ気持ちです)。
ライセンス
記事内で、go/tools(BSD-3-Clause)とgo/go(BSD-3-Clause)のコードを一部引用しています。
Copyright (c) 2009 The Go Authors. All rights reserved.
そもそもimport文にコメント……
それ以上はいけない
明日は @BlueRose_Sora さんの記事です。
(っ ॑꒳ ॑c)ワクワク
-
Gofmtのすべての機能にアクセスできるわけではありません(例えば、
-s
を指定してASTを簡略化することはできません) ↩ -
import "C"
が絡むと例外的に無視されますが ↩ -
GofmtもImportSpecを並び替える機能を持っています ↩
-
実際は、
format.Source
も呼んでいますが、内部的にはこちらもConfig.Fprint
を使っています ↩ -
printer.spec
などで、printer.setComment
が呼ばれていて、Nodeに紐付いたコメントは適切な位置に出力されるようにしているように見えますが、ast.Fileのフォーマットを行っている時には、printer.setComment
は何もしません。 ↩ -
exportedでないfunctionのコメントという点に目をつぶれば ↩
-
Taken from golang.org/x/tools/ast/astutil
というのがなかなか趣深いですが、逆に言うと、astutilの関数は行番号を破壊します(token.Fileを見ていないので当然ですが)。 ↩ -
line comment + 行末に一行で書かれているgeneral comment ↩
-
ここでは、コメント行は尊重して、空行でのみグルーピングを行う動作に変えること。本当に改善されたと感じるかは、主観による ↩
-
といっても、go/parserにとっての話で、プログラムとしてのsemanticsは同じ ↩
-
基本的には処理はべき等で、意味が変わったところで出力は変わらない ↩
-
まれに本人にも分からないことがある ↩