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

[インデックス 15392] ファイルの概要

このコミットは、Go言語の静的解析ツールである cmd/vet における、Error メソッドの誤検出を修正するものです。具体的には、error インターフェースの Error() メソッドと、testing パッケージの (*T).Error() のような、Error という名前を持つが error インターフェースとは異なるシグネチャを持つメソッドを正確に区別し、適切なチェックを行うように改善されています。これにより、cmd/vet が誤った警告を発することを防ぎます。

コミット

commit 4434212f1558c124e1823d3d7279ed63a71a31b8
Author: Rob Pike <r@golang.org>
Date:   Fri Feb 22 17:16:31 2013 -0800

    cmd/vet: use types to test Error methods correctly.
    We need go/types to discriminate the Error method from
    the error interface and the Error method of the testing package.
    Fixes #4753.
    
    R=golang-dev, bradfitz, gri
    CC=golang-dev
    https://golang.org/cl/7396054

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/4434212f1558c124e1823d3d7279ed63a71a31b8

元コミット内容

cmd/vet ツールが Error メソッドを正しくテストするために、go/types パッケージを使用するように変更されました。これは、error インターフェースの Error メソッドと、testing パッケージの Error メソッドを区別する必要があるためです。この変更は、Issue #4753 を修正します。

変更の背景

cmd/vet は、Go言語のコードベースにおける潜在的なバグや疑わしい構造を検出するための静的解析ツールです。特に、fmt.Printf のようなフォーマット文字列を使用する関数呼び出しにおいて、フォーマット指定子と引数の型が一致しないなどの問題を検出する機能を持っています。

このコミット以前の cmd/vet は、Error() という名前のメソッド呼び出しに対して、そのメソッドが printf スタイルのフォーマット文字列と引数を取る可能性があると誤って判断し、警告を発することがありました。しかし、Go言語の組み込み error インターフェースが持つ Error() string メソッドは、引数を取らず、エラーメッセージを表す文字列を返します。このメソッドは printf スタイルのチェックの対象となるべきではありません。

一方で、testing パッケージの (*T).Error()(*T).Errorf() のようなメソッドは、テストの失敗を報告するために使用され、printf スタイルのフォーマット文字列と引数を取ります。これらのメソッドは cmd/vetprintf スタイルのチェックの対象となるべきです。

問題は、cmd/vet がこれら二種類の Error メソッドを区別できず、error インターフェースの Error() メソッド呼び出しに対しても誤って警告を出していた点にありました (Issue #4753)。このコミットは、go/types パッケージを利用して、メソッドの実際の型情報に基づいて正確な判別を行うことで、この誤検出を解消することを目的としています。

前提知識の解説

Go言語の error インターフェース

Go言語において、エラー処理は error という組み込みインターフェースによって行われます。このインターフェースは非常にシンプルで、以下のように定義されています。

type error interface {
    Error() string
}

つまり、任意の型が Error() string というシグネチャ(引数なし、文字列を返す)を持つメソッドを実装していれば、その型は error インターフェースを満たします。このメソッドは、エラーの詳細を記述した文字列を返します。

cmd/vet ツール

cmd/vet は、Goのソースコードを静的に分析し、疑わしい構造や潜在的なバグを報告するツールです。例えば、以下のような問題を検出します。

  • printf フォーマット文字列と引数の不一致
  • 構造体タグの誤り
  • ロックの誤用
  • 未使用の変数やインポート(go vetgo build の一部として実行される go tool vet のラッパーであり、go build が検出しないようなより深い静的解析を行います)

このツールは、コードの品質と信頼性を向上させるために非常に有用です。

go/types パッケージ

go/types パッケージは、Goプログラムの型情報を扱うための標準ライブラリです。このパッケージは、Goのコンパイラが内部的に使用する型システムを公開しており、抽象構文木 (AST) と組み合わせて使用することで、プログラム内の各識別子や式の正確な型情報を取得できます。

go/types を使用することで、以下のようなことが可能になります。

  • 変数、関数、メソッド、インターフェースなどの型を特定する。
  • 型が特定のインターフェースを満たすかどうかを判定する。
  • 式の評価結果の型を取得する。
  • パッケージ間の依存関係を解析する。

cmd/vet のような静的解析ツールにとって、正確な型情報は不可欠であり、このコミットでは go/types を利用して Error メソッドの正確なシグネチャを判別しています。

go/ast パッケージ

go/ast パッケージは、Goのソースコードを抽象構文木 (AST) として表現するためのデータ構造を提供します。ソースコードはまず字句解析され、トークン列に変換された後、構文解析されてASTが構築されます。ASTは、プログラムの構造を木構造で表現したもので、各ノードが式、文、宣言などを表します。

cmd/vet は、このASTを走査することでコードの構造を理解し、問題のあるパターンを検出します。go/types パッケージは、このASTのノードと型情報を関連付けることで、より高度な解析を可能にします。

技術的詳細

このコミットの核心は、cmd/vetgo/types パッケージを利用して、Error という名前のメソッド呼び出しが、Goの組み込み error インターフェースの Error() string メソッドであるかどうかを正確に判別するロジックを導入した点にあります。

  1. go/types の導入と型情報の収集:

    • src/cmd/vet/main.goPackage という新しい構造体が導入されました。この構造体は map[ast.Expr]types.Type を持ち、ASTの各式 (ast.Expr) に対応する型情報 (types.Type) を格納します。
    • doPackage 関数内で、go/types.Context が初期化され、Expr フィールドにコールバック関数 exprFn が設定されます。この exprFn は、型チェック中に各式の型が解決されるたびに呼び出され、その式と型情報を pkg.types マップに保存します。
    • これにより、cmd/vet は解析対象のパッケージ全体の型情報を事前に収集できるようになります。
  2. File 構造体への Package 参照の追加:

    • src/cmd/vet/main.goFile 構造体に pkg *Package フィールドが追加されました。これにより、各ファイル (File) が属するパッケージの型情報 (Package) にアクセスできるようになります。
  3. isErrorMethodCall 関数の導入:

    • src/cmd/vet/print.goisErrorMethodCall(call *ast.CallExpr) bool という新しいヘルパー関数が追加されました。この関数は、与えられた関数呼び出し (call) が、error インターフェースを満たす Error() string メソッドの呼び出しであるかを厳密に判定します。
    • 判定ロジックは以下のステップで行われます。
      • セレクタ式であるか: メソッド呼び出しであるためには、call.Fun*ast.SelectorExpr である必要があります(例: e.Error()e.Error 部分)。関数呼び出し (f()) の場合は false を返します。
      • 引数がないか: error インターフェースの Error() メソッドは引数を取りません。呼び出しに引数がある場合は false を返します。
      • メソッドの型情報の取得: f.pkg.types[sel] を使用して、セレクタ式 (sel) の型情報を取得します。
      • シグネチャの確認: 取得した型が *types.Signature であることを確認します。
      • レシーバの有無: メソッドであるためには、シグネチャにレシーバ (sig.Recv) が存在する必要があります。レシーバがない場合は関数呼び出しとみなし false を返します。
      • 引数の数の確認: シグネチャの引数 (sig.Params) がゼロであることを確認します。
      • 戻り値の数の確認: シグネチャの戻り値 (sig.Results) が1つであることを確認します。
      • 戻り値の型の確認: 唯一の戻り値の型が、組み込みの string 型 (types.Typ[types.String]) と同一であるかを types.IsIdentical を使用して確認します。
  4. numArgsInSignature 関数の導入:

    • src/cmd/vet/print.gonumArgsInSignature(call *ast.CallExpr) int というヘルパー関数が追加されました。これは、関数呼び出しの対象となる関数の仮引数の数を返します。isErrorMethodCall と同様に、f.pkg.types を利用して型情報を取得し、シグネチャから引数の数を抽出します。
  5. checkPrint 関数のロジック変更:

    • src/cmd/vet/print.gocheckPrint 関数は、printf スタイルの関数呼び出しをチェックする主要な関数です。この関数内で、引数が存在しない場合の処理 (if len(args) <= skip) が変更されました。
    • 変更前は、name != "Error" の場合にのみ「引数がない」という警告を出していました。つまり、Error という名前のメソッド呼び出しは、無条件に printf スタイルのチェックから除外されていました。
    • 変更後は、まず name == "Error" かつ f.pkg != nil (型情報が利用可能) かつ f.isErrorMethodCall(call)true の場合、つまり**error インターフェースを満たす Error() string メソッドの呼び出しである場合**は、すぐに return してチェックをスキップします。
    • それ以外の場合(Error メソッドだが error インターフェースを満たさない場合、または Error 以外のメソッドの場合)は、引き続き printf スタイルのチェックを行います。特に、testing パッケージの (*T).Error() のようなメソッドは、引数がない場合に警告の対象となります。

これらの変更により、cmd/vetError という名前のメソッド呼び出しに対して、そのメソッドが実際に error インターフェースの一部であるかどうかを型情報に基づいて正確に判断し、適切な静的解析を行うことができるようになりました。

コアとなるコードの変更箇所

src/cmd/vet/main.go

--- a/src/cmd/vet/main.go
+++ b/src/cmd/vet/main.go
@@ -64,6 +64,7 @@ func Usage() {
 // File is a wrapper for the state of a file used in the parser.
 // The parse tree walkers are all methods of this type.
 type File struct {
+	pkg  *Package
 	fset *token.FileSet
 	name string
 	file *ast.File
@@ -158,6 +159,10 @@ func doPackageDir(directory string) {
 	doPackage(names)
 }
 
+type Package struct {
+	types map[ast.Expr]types.Type
+}
+
 // doPackage analyzes the single package constructed from the named files.
 func doPackage(names []string) {
 	var files []*File
@@ -181,16 +186,21 @@ func doPackage(names []string) {
 		files = append(files, &File{fset: fs, name: name, file: parsedFile})
 		astFiles = append(astFiles, parsedFile)
 	}
+	pkg := new(Package)
+	pkg.types = make(map[ast.Expr]types.Type)
+	exprFn := func(x ast.Expr, typ types.Type, val interface{}) {
+		pkg.types[x] = typ
+	}
 	context := types.Context{
-	// TODO: set up Expr, Ident.
+		Expr: exprFn,
 	}
 	// Type check the package.
-	pkg, err := context.Check(fs, astFiles)
+	_, err := context.Check(fs, astFiles)
 	if err != nil {
 		warnf("%s", err)
 	}
-	_ = pkg
 	for _, file := range files {
+		file.pkg = pkg
 		file.walkFile(file.name, file.file)
 	}
 }

src/cmd/vet/print.go

--- a/src/cmd/vet/print.go
+++ b/src/cmd/vet/print.go
@@ -11,6 +11,7 @@ import (
 	"fmt"
 	"go/ast"
 	"go/token"
+	"go/types"
 	"strconv"
 	"strings"
 	"unicode/utf8"
@@ -274,9 +275,18 @@ func (f *File) checkPrint(call *ast.CallExpr, name string, skip int) {
 		}
 	}
 	if len(args) <= skip {
-		// TODO: check that the receiver of Error() is of type error.
-		if !isLn && name != "Error" {
-			f.Badf(call.Pos(), "no args in %s call", name)
+		// If we have a call to a method called Error that satisfies the Error interface,
+		// then it's ok. Otherwise it's something like (*T).Error from the testing package
+		// and we need to check it.
+		if name == "Error" && f.pkg != nil && f.isErrorMethodCall(call) {
+			return
+		}
+		// If it's an Error call now, it's probably for printing errors.
+		if !isLn {
+			// Check the signature to be sure: there are niladic functions called "error".
+			if f.pkg == nil || skip != 0 || f.numArgsInSignature(call) != skip {
+				f.Badf(call.Pos(), "no args in %s call", name)
+			}
 		}
 		return
 	}
@@ -297,6 +307,95 @@ func (f *File) checkPrint(call *ast.CallExpr, name string, skip int) {
 	}
 }
 
+// numArgsInSignature tells how many formal arguments the function type
+// being called has. Assumes type checking is on (f.pkg != nil).
+func (f *File) numArgsInSignature(call *ast.CallExpr) int {
+	// Check the type of the function or method declaration
+	typ := f.pkg.types[call.Fun]
+	if typ == nil {
+		return 0
+	}
+	// The type must be a signature, but be sure for safety.
+	sig, ok := typ.(*types.Signature)
+	if !ok {
+		return 0
+	}
+	return len(sig.Params)
+}
+
+// isErrorMethodCall reports whether the call is of a method with signature
+//	func Error() error
+// where "error" is the universe's error type. We know the method is called "Error"
+// and f.pkg is set.
+func (f *File) isErrorMethodCall(call *ast.CallExpr) bool {
+	// Is it a selector expression? Otherwise it's a function call, not a method call.
+	sel, ok := call.Fun.(*ast.SelectorExpr)
+	if !ok {
+		return false
+	}
+	// The package is type-checked, so if there are no arguments, we're done.
+	if len(call.Args) > 0 {
+		return false
+	}
+	// Check the type of the method declaration
+	typ := f.pkg.types[sel]
+	if typ == nil {
+		return false
+	}
+	// The type must be a signature, but be sure for safety.
+	sig, ok := typ.(*types.Signature)
+	if !ok {
+		return false
+	}
+	// There must be a receiver for it to be a method call. Otherwise it is
+	// a function, not something that satisfies the error interface.
+	if sig.Recv == nil {
+		return false
+	}
+	// There must be no arguments. Already verified by type checking, but be thorough.
+	if len(sig.Params) > 0 {
+		return false
+	}
+	// Finally the real questions.
+	// There must be one result.
+	if len(sig.Results) != 1 {
+		return false
+	}
+	// It must have return type "string" from the universe.
+	result := sig.Results[0].Type
+	if types.IsIdentical(result, types.Typ[types.String]) {
+		return true
+	}
+	return true
+}
+
+// Error methods that do not satisfy the Error interface and should be checked.
+type errorTest1 int
+
+func (errorTest1) Error(...interface{}) string {
+	return "hi"
+}
+
+type errorTest2 int // Analogous to testing's *T type.
+func (errorTest2) Error(...interface{}) {
+}
+
+type errorTest3 int
+
+func (errorTest3) Error() { // No return value.
+}
+
+type errorTest4 int
+
+func (errorTest4) Error() int { // Different return type.
+	return 3
+}
+
+type errorTest5 int
+
+func (errorTest5) error() { // niladic; don't complain if no args (was bug)
+}
+
 // This function never executes, but it serves as a simple test for the program.
 // Test with make test.
 func BadFunctionUsedInTests() {
@@ -322,8 +421,25 @@ func BadFunctionUsedInTests() {
 	f.Warnf(0, "%s", "hello", 3) // ERROR "wrong number of args in Warnf call"
 	f.Warnf(0, "%r", "hello")    // ERROR "unrecognized printf verb"
 	f.Warnf(0, "%#s", "hello")   // ERROR "unrecognized printf flag"
+	// Something that satisfies the error interface.
 	var e error
-	fmt.Println(e.Error()) // correct, used to trigger "no args in Error call"
+	fmt.Println(e.Error()) // ok
+	// Something that looks like an error interface but isn't, such as the (*T).Error method
+	// in the testing package.
+	var et1 errorTest1
+	fmt.Println(et1.Error())        // ERROR "no args in Error call"
+	fmt.Println(et1.Error("hi"))    // ok
+	fmt.Println(et1.Error("%d", 3)) // ERROR "possible formatting directive in Error call"
+	var et2 errorTest2
+	et2.Error()        // ERROR "no args in Error call"
+	et2.Error("hi")    // ok, not an error method.
+	et2.Error("%d", 3) // ERROR "possible formatting directive in Error call"
+	var et3 errorTest3
+	et3.Error() // ok, not an error method.
+	var et4 errorTest4
+	et4.Error() // ok, not an error method.
+	var et5 errorTest5
+	et5.error() // ok, not an error method.
 }
 
 // printf is used by the test.

コアとなるコードの解説

このコミットの主要な変更は、cmd/vet が Go の型システムをより深く理解し、Error メソッドの呼び出しを正確に分類できるようにした点です。

  1. 型情報の統合 (main.go):

    • 以前の cmd/vet は、ASTを走査する際に、各式の正確な型情報を常に持っているわけではありませんでした。このコミットでは、Package 構造体と File.pkg フィールドを導入することで、このギャップを埋めています。
    • doPackage 関数内で go/types.Context を使用してパッケージ全体の型チェックを実行し、その過程で exprFn コールバックを通じて各 ast.Expr とそれに対応する types.Typepkg.types マップに保存します。これにより、cmd/vet はコードのセマンティックな意味(型)を理解できるようになります。
    • File オブジェクトがそのパッケージの Package 構造体への参照を持つことで、ファイル内の任意のASTノードから関連する型情報にアクセスできるようになります。
  2. Error メソッドの厳密な判別 (print.go):

    • checkPrint 関数は、printf スタイルの関数呼び出しの引数を検証する役割を担っています。この関数が Error という名前のメソッド呼び出しに遭遇した際、以前は単純な名前ベースのチェックしか行っていませんでした。
    • 新しく導入された isErrorMethodCall 関数は、go/types から取得した型情報に基づいて、呼び出されたメソッドが error インターフェースの Error() string メソッドのシグネチャと完全に一致するかどうかを厳密にチェックします。
      • call.Fun がセレクタ式であること(obj.Method() の形式)。
      • メソッドが引数を取らないこと。
      • メソッドがレシーバを持つこと(関数ではなくメソッドであること)。
      • メソッドが1つの戻り値を持ち、その型が string であること。
    • これらの条件をすべて満たす場合のみ、その Error メソッドは error インターフェースの一部であると判断され、printf スタイルの引数チェックから除外されます。
    • もし Error という名前のメソッドであっても、これらの条件を満たさない場合(例: (*testing.T).Error() は引数を取る可能性がある)、それは error インターフェースのメソッドではないと判断され、引き続き printf スタイルの引数チェックの対象となります。
    • numArgsInSignature 関数は、任意の関数呼び出しの仮引数の数を型情報から取得するために使用され、checkPrint の汎用的な引数チェックロジックを補強します。
  3. テストケースの追加:

    • print.go の下部に追加された errorTest1 から errorTest5 までの型とメソッドは、様々なシグネチャを持つ Error メソッドの例を示しています。
    • BadFunctionUsedInTests 関数内の新しいテスト呼び出しは、これらの異なる Error メソッドが cmd/vet によってどのように扱われるかを示しています。
      • var e error; fmt.Println(e.Error())error インターフェースの Error() メソッドなので、ok となり警告は出ません。
      • errorTest1Error(...interface{}) string というシグネチャを持ち、error インターフェースとは異なるため、引数なしで呼び出すと ERROR "no args in Error call" が出ます。これは testing.T.Error() のようなケースを模倣しており、vet が正しく警告を出すべき対象です。
      • 他の errorTest も、それぞれのシグネチャに基づいて vet が正しく動作することを確認しています。

これらの変更により、cmd/vetError メソッドのセマンティックな意味を正確に理解し、開発者が意図しない printf フォーマットの誤用に対してのみ警告を発するようになり、誤検出が大幅に削減されました。

関連リンク

参考にした情報源リンク