Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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 です。このディレクティブは、主にコード生成ツール(例: yacclex のようなツール、あるいはGoのテンプレートエンジンなど)によって生成されたGoコードにおいて使用されます。

//line ディレクティブの目的は、生成されたコードの特定の部分が、元の(生成元となった)ソースファイルのどの位置に対応するかをコンパイラに伝えることです。これにより、コンパイルエラーやデバッグ情報が、生成されたGoファイルではなく、元のソースファイルと行番号で報告されるようになります。これは、開発者が問題をより簡単に特定し、デバッグする上で非常に役立ちます。

冪等性 (Idempotency)

冪等性とは、ある操作を複数回実行しても、1回実行した場合と同じ結果になる性質を指します。数学やコンピュータサイエンスの分野で広く使われる概念です。

gofmt のようなコードフォーマッタにとって、冪等性は極めて重要です。もし gofmt が冪等でなければ、以下のような問題が発生します。

  1. バージョン管理システムでのノイズ: gofmt を実行するたびにコードがわずかに変更されると、Gitなどのバージョン管理システムで不要な差分(diff)が大量に発生し、実際の意味のある変更が埋もれてしまいます。
  2. CI/CDパイプラインの不安定化: 自動ビルドやテストのパイプラインにおいて、gofmt の実行が毎回異なる出力を生むと、ビルドのキャッシュが無効になったり、テストが不安定になったりする原因となります。
  3. 開発者の混乱: 開発者がコードを整形するたびにファイルが変更されると、コードの安定性に対する信頼が損なわれます。

このコミットは、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つの主要な問題が修正されました。

  1. インデントの扱い: //line ディレクティブは、通常のGoコードとは異なり、特定の列に配置されるべきコンパイラディレクティブです。しかし、go/printer がコメントを整形する際の一般的なインデントルールが //line ディレクティブにも適用されてしまい、不適切なインデントが付与される可能性がありました。このコミットでは、//line ディレクティブを検出した場合に一時的にインデントを無効にする処理を追加することで、この問題を解決しています。

  2. 内部的なファイル位置の同期: //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.indent0 に設定します。これにより、//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.go123 を抽出します。
  • そして、defer キーワードを使って無名関数を登録しています。この defer 関数は、writeComment 関数が終了する直前に実行されます。
  • defer 関数の中では、プリンタの内部的な位置情報 (p.pos) を、パースしたファイル名 (file) と行番号 (line) に更新しています。p.pos.Column1 に設定されます。

この defer を使った p.pos の更新が、冪等性を保証する上で非常に重要です。//line ディレクティブは、コンパイラに対して「この行以降のコードは、指定されたファイルと行番号から来ているものとして扱え」と指示します。go/printer がこのディレクティブを処理する際、自身の内部的なファイルと行番号の追跡を、ディレクティブが示す値に同期させる必要があります。この同期が行われることで、その後のコードの整形が、//line ディレクティブが示す「仮想的な」ソース位置を基準として行われるようになります。これにより、gofmt が複数回実行されても、//line ディレクティブが原因で出力が変わることがなくなり、gofmt の冪等性が完全に保証されるようになりました。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント(go/printergofmtgo/token パッケージに関する情報)
  • Go言語のソースコード(特に src/pkg/go/printer/printer.go
  • Go言語における //line ディレクティブに関する情報源(Goのコンパイラやツールに関するドキュメント)