[インデックス 10425] ファイルの概要
このコミットは、Go言語の標準ライブラリの一部である src/pkg/go/printer/printer.go
ファイルに対する変更です。このファイルは、Go言語のソースコードを抽象構文木(AST)から整形されたテキスト形式に変換する役割を担う go/printer
パッケージの主要な実装を含んでいます。具体的には、gofmt
ツールがGoコードを標準的なスタイルに整形する際に利用する低レベルのプリンタロジックを提供します。
コミット
commit 7d1d8fe430a3e1463bced18cd4e5bf08a0fa6c75
Author: Russ Cox <rsc@golang.org>
Date: Wed Nov 16 17:55:35 2011 -0500
go/printer: make //line formatting idempotent
Fixes "test.sh" (long test) in src/cmd/gofmt.
R=gri
CC=golang-dev
https://golang.org/cl/5307081
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/7d1d8fe430a3e1463bced18cd4e5bf08a0fa6c75
元コミット内容
go/printer: make //line formatting idempotent
Fixes "test.sh" (long test) in src/cmd/gofmt.
R=gri
CC=golang-dev
https://golang.org/cl/5307081
変更の背景
このコミットの背景には、Go言語の公式フォーマッタである gofmt
のテストにおける問題がありました。gofmt
は、Goのソースコードを整形する際に、その結果が「冪等(べきとう)である」ことが非常に重要です。冪等性とは、ある操作を複数回実行しても、1回実行した場合と同じ結果になる性質を指します。つまり、gofmt
を一度実行して整形されたコードに、再度 gofmt
を実行しても、コードは一切変更されないべきです。
しかし、特定の条件下で //line
ディレクティブを含むGoコードを gofmt
で整形すると、その結果が冪等にならないというバグが存在しました。これにより、src/cmd/gofmt
内の test.sh
スクリプト(特に「long test」と記述されている部分)が失敗していました。このテストは、gofmt
の出力が安定していること、すなわち冪等であることを検証するためのものでした。
//line
ディレクティブの不適切な整形が、gofmt
の複数回実行で異なる出力をもたらし、テストの失敗につながっていたため、この問題を解決し、go/printer
が //line
ディレクティブを常に冪等に処理するように変更する必要がありました。
前提知識の解説
go/printer
パッケージ
go/printer
はGo言語の標準ライブラリの一部で、Goのソースコードを抽象構文木(AST: Abstract Syntax Tree)から、整形されたテキスト形式に変換する機能を提供します。これは、gofmt
のようなツールがGoコードを標準的なスタイルに整形する際の基盤となるパッケージです。ASTは、Goコンパイラがソースコードを解析して生成する、プログラムの構造を表現するデータ構造です。go/printer
はこのASTをトラバースし、Go言語の構文規則と整形ルールに従ってコードを再構築します。
gofmt
ツール
gofmt
はGo言語の公式なコードフォーマッタです。Go言語のコードベース全体で一貫したコーディングスタイルを強制するために設計されています。gofmt
は、インデント、スペース、改行などの書式を自動的に調整し、Goコミュニティで広く受け入れられている標準的なスタイルに準拠させます。これにより、コードの可読性が向上し、異なる開発者間でのスタイルに関する議論が不要になります。gofmt
は内部的に go/printer
パッケージを利用しています。
//line
ディレクティブ
//line
ディレクティブは、Go言語のコンパイラに対する特殊な指示です。その形式は通常 //line filename:line_number
または //line filename:line_number:column_number
です。このディレクティブは、主にコード生成ツール(例: yacc
や lex
のようなツール、あるいはGoのテンプレートエンジンなど)によって生成されたGoコードにおいて使用されます。
//line
ディレクティブの目的は、生成されたコードの特定の部分が、元の(生成元となった)ソースファイルのどの位置に対応するかをコンパイラに伝えることです。これにより、コンパイルエラーやデバッグ情報が、生成されたGoファイルではなく、元のソースファイルと行番号で報告されるようになります。これは、開発者が問題をより簡単に特定し、デバッグする上で非常に役立ちます。
冪等性 (Idempotency)
冪等性とは、ある操作を複数回実行しても、1回実行した場合と同じ結果になる性質を指します。数学やコンピュータサイエンスの分野で広く使われる概念です。
gofmt
のようなコードフォーマッタにとって、冪等性は極めて重要です。もし gofmt
が冪等でなければ、以下のような問題が発生します。
- バージョン管理システムでのノイズ:
gofmt
を実行するたびにコードがわずかに変更されると、Gitなどのバージョン管理システムで不要な差分(diff)が大量に発生し、実際の意味のある変更が埋もれてしまいます。 - CI/CDパイプラインの不安定化: 自動ビルドやテストのパイプラインにおいて、
gofmt
の実行が毎回異なる出力を生むと、ビルドのキャッシュが無効になったり、テストが不安定になったりする原因となります。 - 開発者の混乱: 開発者がコードを整形するたびにファイルが変更されると、コードの安定性に対する信頼が損なわれます。
このコミットは、go/printer
が //line
ディレクティブを処理する際にこの冪等性を保証することを目的としています。
test.sh
(long test in src/cmd/gofmt
)
src/cmd/gofmt
ディレクトリ内の test.sh
スクリプトは、gofmt
ツールの機能と正確性を検証するためのテストスイートです。特に「long test」と記述されている部分は、より広範なテストケースや、複数回の整形実行による冪等性の検証など、時間のかかる包括的なテストを実行するセクションを指していると考えられます。このテストが失敗していたということは、gofmt
の重要な特性である冪等性が損なわれていたことを示しています。
技術的詳細
このコミットの技術的な核心は、go/printer
が //line
ディレクティブを処理する際の内部状態管理の改善にあります。以前の実装では、//line
ディレクティブがコードの整形に与える影響が完全に考慮されていなかったため、gofmt
が複数回実行されると、//line
ディレクティブの周辺の書式が微妙に変化し、冪等性が失われていました。
具体的には、以下の2つの主要な問題が修正されました。
-
インデントの扱い:
//line
ディレクティブは、通常のGoコードとは異なり、特定の列に配置されるべきコンパイラディレクティブです。しかし、go/printer
がコメントを整形する際の一般的なインデントルールが//line
ディレクティブにも適用されてしまい、不適切なインデントが付与される可能性がありました。このコミットでは、//line
ディレクティブを検出した場合に一時的にインデントを無効にする処理を追加することで、この問題を解決しています。 -
内部的なファイル位置の同期:
//line
ディレクティブは、コンパイラに対して「この行以降のコードは、指定されたファイルと行番号から来ているものとして扱え」と指示します。go/printer
は、整形中のコードの現在のファイル名と行番号を内部的に追跡しています(p.pos
)。以前の実装では、//line
ディレクティブを単なるコメントとして出力するだけで、この内部的なファイル位置の追跡をディレクティブが示す値に同期させていませんでした。この非同期が、その後の整形処理に影響を与え、冪等性の喪失につながっていました。
このコミットでは、//line
ディレクティブを検出した際に、そのディレクティブが示すファイル名と行番号を go/printer
の内部的な位置情報 (p.pos
) に反映させるロジックが追加されました。これにより、go/printer
は //line
ディレクティブが示す「仮想的な」ソース位置を正しく認識し、その後のコードの整形が、あたかもその仮想的な位置から読み込まれたかのように行われるようになります。この同期処理によって、gofmt
が複数回実行されても、//line
ディレクティブが原因で出力が変わることがなくなり、冪等性が保証されるようになりました。
コアとなるコードの変更箇所
変更は src/pkg/go/printer/printer.go
ファイルに集中しています。
--- a/src/pkg/go/printer/printer.go
+++ b/src/pkg/go/printer/printer.go
@@ -13,6 +13,8 @@ import (
"io"
"os"
"path/filepath"
+ "strconv"
+ "strings"
"text/tabwriter"
)
@@ -244,6 +246,8 @@ func (p *printer) writeItem(pos token.Position, data string) {
p.last = p.pos
}
+const linePrefix = "//line "
+
// writeCommentPrefix writes the whitespace before a comment.
// If there is any pending whitespace, it consumes as much of
// it as is likely to help position the comment nicely.
@@ -252,7 +256,7 @@ func (p *printer) writeCommentPrefix(pos, next token.Position, prev *ast.Comment, isKeyword bool) {
// a group of comments (or nil), and isKeyword indicates if the
// next item is a keyword.
//
-func (p *printer) writeCommentPrefix(pos, next token.Position, prev *ast.Comment, isKeyword bool) {
+func (p *printer) writeCommentPrefix(pos, next token.Position, prev, comment *ast.Comment, isKeyword bool) {
if p.written == 0 {
// the comment is the first item to be printed - don't write any whitespace
return
@@ -337,6 +341,13 @@ func (p *printer) writeCommentPrefix(pos, next token.Position, prev *ast.Comment
}
p.writeWhitespace(j)
}
+
+ // turn off indent if we're about to print a line directive.
+ indent := p.indent
+ if strings.HasPrefix(comment.Text, linePrefix) {
+ p.indent = 0
+ }
+
// use formfeeds to break columns before a comment;
// this is analogous to using formfeeds to separate
// individual lines of /*-style comments - but make
@@ -347,6 +358,7 @@ func (p *printer) writeCommentPrefix(pos, next token.Position, prev *ast.Comment
n = 1
}
p.writeNewlines(n, true)
+ p.indent = indent
}
}
@@ -526,6 +538,26 @@ func stripCommonPrefix(lines [][]byte) {
func (p *printer) writeComment(comment *ast.Comment) {
text := comment.Text
+ if strings.HasPrefix(text, linePrefix) {
+ pos := strings.TrimSpace(text[len(linePrefix):])
+ i := strings.LastIndex(pos, ":")
+ if i >= 0 {
+ // The line directive we are about to print changed
+ // the Filename and Line number used by go/token
+ // as it was reading the input originally.
+ // In order to match the original input, we have to
+ // update our own idea of the file and line number
+ // accordingly, after printing the directive.
+ file := pos[:i]
+ line, _ := strconv.Atoi(string(pos[i+1:]))
+ defer func() {
+ p.pos.Filename = string(file)
+ p.pos.Line = line
+ p.pos.Column = 1
+ }()
+ }
+ }
+
// shortcut common case of //-style comments
if text[1] == '/' {
p.writeItem(p.fset.Position(comment.Pos()), p.escape(text))
@@ -599,7 +631,7 @@ func (p *printer) intersperseComments(next token.Position, tok token.Token) (dro
var last *ast.Comment
for ; p.commentBefore(next); p.cindex++ {
for _, c := range p.comments[p.cindex].List {
-\t\t\tp.writeCommentPrefix(p.fset.Position(c.Pos()), next, last, tok.IsKeyword())\n+\t\t\tp.writeCommentPrefix(p.fset.Position(c.Pos()), next, last, c, tok.IsKeyword())\n \t\t\tp.writeComment(c)\n \t\t\tlast = c
}
コアとなるコードの解説
1. linePrefix
定数の追加
+const linePrefix = "//line "
//line
ディレクティブを識別するための文字列定数が追加されました。これにより、コード内で //line
ディレクティブを検出する際の可読性と保守性が向上します。
2. writeCommentPrefix
関数の変更
-func (p *printer) writeCommentPrefix(pos, next token.Position, prev *ast.Comment, isKeyword bool) {
+func (p *printer) writeCommentPrefix(pos, next token.Position, prev, comment *ast.Comment, isKeyword bool) {
...
+ // turn off indent if we're about to print a line directive.
+ indent := p.indent
+ if strings.HasPrefix(comment.Text, linePrefix) {
+ p.indent = 0
+ }
...
+ p.indent = indent
writeCommentPrefix
関数は、コメントの前に空白を書き込む役割を担います。この関数に comment *ast.Comment
パラメータが追加され、現在処理しているコメントオブジェクト自体にアクセスできるようになりました。
最も重要な変更は、//line
ディレクティブを検出した場合のインデント処理です。
strings.HasPrefix(comment.Text, linePrefix)
で、コメントが//line
ディレクティブであるかをチェックします。- もしそうであれば、現在のプリンタのインデントレベル (
p.indent
) を一時的にindent
変数に保存し、p.indent
を0
に設定します。これにより、//line
ディレクティブがインデントされずに、行の先頭から出力されるようになります。 - コメントの出力後、
p.indent = indent
で元のインデントレベルに戻します。この処理により、//line
ディレクティブが常に正しい位置に整形されることが保証されます。
3. writeComment
関数の変更
func (p *printer) writeComment(comment *ast.Comment) {
text := comment.Text
+ if strings.HasPrefix(text, linePrefix) {
+ pos := strings.TrimSpace(text[len(linePrefix):])
+ i := strings.LastIndex(pos, ":")
+ if i >= 0 {
+ // The line directive we are about to print changed
+ // the Filename and Line number used by go/token
+ // as it was reading the input originally.
+ // In order to match the original input, we have to
+ // update our own idea of the file and line number
+ // accordingly, after printing the directive.
+ file := pos[:i]
+ line, _ := strconv.Atoi(string(pos[i+1:]))
+ defer func() {
+ p.pos.Filename = string(file)
+ p.pos.Line = line
+ p.pos.Column = 1
+ }()
+ }
+ }
+
// shortcut common case of //-style comments
if text[1] == '/' {
p.writeItem(p.fset.Position(comment.Pos()), p.escape(text))
writeComment
関数は、実際のコメントテキストを書き込む役割を担います。この関数に、//line
ディレクティブの処理に関する最も重要なロジックが追加されました。
- 同様に
strings.HasPrefix(text, linePrefix)
で//line
ディレクティブであるかをチェックします。 - もし
//line
ディレクティブであれば、そのテキストからファイル名と行番号をパースします。例えば//line foo.go:123
からfoo.go
と123
を抽出します。 - そして、
defer
キーワードを使って無名関数を登録しています。このdefer
関数は、writeComment
関数が終了する直前に実行されます。 defer
関数の中では、プリンタの内部的な位置情報 (p.pos
) を、パースしたファイル名 (file
) と行番号 (line
) に更新しています。p.pos.Column
は1
に設定されます。
この defer
を使った p.pos
の更新が、冪等性を保証する上で非常に重要です。//line
ディレクティブは、コンパイラに対して「この行以降のコードは、指定されたファイルと行番号から来ているものとして扱え」と指示します。go/printer
がこのディレクティブを処理する際、自身の内部的なファイルと行番号の追跡を、ディレクティブが示す値に同期させる必要があります。この同期が行われることで、その後のコードの整形が、//line
ディレクティブが示す「仮想的な」ソース位置を基準として行われるようになります。これにより、gofmt
が複数回実行されても、//line
ディレクティブが原因で出力が変わることがなくなり、gofmt
の冪等性が完全に保証されるようになりました。
関連リンク
- Go Gerrit Code Review: https://golang.org/cl/5307081
参考にした情報源リンク
- Go言語の公式ドキュメント(
go/printer
、gofmt
、go/token
パッケージに関する情報) - Go言語のソースコード(特に
src/pkg/go/printer/printer.go
) - Go言語における
//line
ディレクティブに関する情報源(Goのコンパイラやツールに関するドキュメント)