[インデックス 13869] ファイルの概要
このコミットは、Go言語の静的解析ツールである go vet
に、for...range
ループの変数の誤用を検出する機能を追加するものです。具体的には、ループ変数がクロージャ(特にgoroutineやdefer文内で使用される関数リテラル)によってキャプチャされる際に発生する、よくある落とし穴を検出します。
コミット
commit 51e85612f9beff6bb715199c1ddf5ff421c4ae77
Author: Andrew Gerrand <adg@golang.org>
Date: Tue Sep 18 14:19:31 2012 -0700
vet: add range variable misuse detection
R=fullung, r, remyoudompheng, minux.ma, gri, rsc
CC=golang-dev
https://golang.org/cl/6494075
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/51e85612f9beff6bb715199c1ddf5ff421c4ae77
元コミット内容
vet: add range variable misuse detection
このコミットは、go vet
ツールに、for...range
ループの変数の誤用を検出する機能を追加します。
変更の背景
Go言語の for...range
ループは、イテレーションごとに新しい変数を宣言するのではなく、同じ変数を再利用します。この挙動は、ループ内でgoroutineを起動したり、defer文を使用したりして、ループ変数をクロージャでキャプチャする場合に、予期せぬ結果を引き起こすことがあります。
例えば、以下のコードを考えます。
package main
import "fmt"
import "time"
func main() {
s := []int{1, 2, 3}
for i, v := range s {
go func() {
fmt.Printf("i: %d, v: %d\n", i, v)
}()
}
time.Sleep(time.Second) // goroutineが実行されるのを待つ
}
多くのプログラマは、このコードが i: 0, v: 1
、i: 1, v: 2
、i: 2, v: 3
のように出力されると期待するかもしれません。しかし、実際には i
と v
はループの各イテレーションで同じメモリ位置を共有するため、goroutineが実行される頃にはループが終了しており、i
と v
は最後のイテレーションの値(この場合は i: 2, v: 3
)を保持しています。結果として、出力は i: 2, v: 3
が複数回表示されることになります。
この問題はGoのFAQにも記載されており、Goプログラマが頻繁に遭遇する落とし穴の一つでした。go vet
は静的解析ツールとして、このような潜在的なバグをコンパイル時に検出することで、開発者がより堅牢なコードを書くのを助けることを目的としています。このコミットは、この一般的な誤用パターンを自動的に特定し、警告を発する機能を追加することで、開発者の負担を軽減し、コードの品質を向上させます。
前提知識の解説
go vet
とは
go vet
は、Go言語のソースコードを静的に解析し、疑わしい構成要素や潜在的なエラーを報告するツールです。コンパイラが検出できないが、実行時に問題を引き起こす可能性のあるコードパターン(例: printf
フォーマット文字列の誤り、到達不能なコード、構造体タグの誤りなど)を特定します。go vet
はGoツールチェインの一部であり、go vet ./...
のように実行することで、プロジェクト全体のコードをチェックできます。
for...range
ループ
Go言語の for...range
ループは、スライス、配列、文字列、マップ、チャネルをイテレートするために使用されます。
- スライス/配列:
for index, value := range slice
の形式で、インデックスと要素のコピーが返されます。 - 文字列:
for index, runeValue := range string
の形式で、バイトインデックスとUnicodeコードポイント(rune)が返されます。 - マップ:
for key, value := range map
の形式で、キーと値のコピーが返されます。 - チャネル:
for value := range channel
の形式で、チャネルから受信した値が返されます。
重要なのは、for...range
ループのイテレーション変数は、ループの各イテレーションで再利用される単一の変数であるという点です。新しい変数が各イテレーションで作成されるわけではありません。
クロージャと変数キャプチャ
Goの関数リテラル(匿名関数)はクロージャであり、それが定義されたスコープ内の変数を参照できます。クロージャが外部の変数を参照する場合、その変数は「キャプチャ」されます。キャプチャされた変数は、クロージャが実行される時点でのその変数の値を使用します。
for...range
ループの文脈では、ループ変数がクロージャによってキャプチャされると、クロージャが実行されるときにはループがすでに終了しているため、ループ変数は最後のイテレーションの値を保持しています。これが、前述の「予期せぬ結果」の原因となります。
この問題を回避するには、ループ内で新しい変数を宣言し、ループ変数の値をその新しい変数にコピーしてからクロージャに渡すのが一般的なGoのイディオムです。
package main
import "fmt"
import "time"
func main() {
s := []int{1, 2, 3}
for i, v := range s {
i := i // 新しい変数 i を宣言し、現在の i の値をコピー
v := v // 新しい変数 v を宣言し、現在の v の値をコピー
go func() {
fmt.Printf("i: %d, v: %d\n", i, v)
}()
}
time.Sleep(time.Second)
}
または、クロージャの引数として値を渡す方法もあります。
package main
import "fmt"
import "time"
func main() {
s := []int{1, 2, 3}
for i, v := range s {
go func(i, v int) { // 引数として i と v を受け取る
fmt.Printf("i: %d, v: %d\n", i, v)
}(i, v) // 現在の i と v の値を渡す
}
time.Sleep(time.Second)
}
技術的詳細
このコミットは、go vet
ツールに rangeloop.go
という新しいファイルを追加し、for...range
ループ変数の誤用検出ロジックを実装しています。
検出のメカニズム
vetRangeLoops
フラグの追加:src/cmd/vet/main.go
に-rangeloops
という新しいコマンドラインフラグが追加されました。これにより、ユーザーはこの特定のチェックを有効/無効にできます。- ASTウォークの拡張:
go/ast
パッケージを使用してGoのソースコードの抽象構文木(AST)を走査します。File
構造体のVisit
メソッドに*ast.RangeStmt
のケースが追加され、walkRangeStmt
メソッドが呼び出されるようになりました。 rangeloop.go
のロジック:checkRangeLoop
関数が、ast.RangeStmt
(for...range
ループ)のボディを検査します。- この関数は、ループの最後のステートメントが
go
ステートメント (*ast.GoStmt
) またはdefer
ステートメント (*ast.DeferStmt
) であるかどうかをチェックします。これは、全プログラム解析なしで検出できる範囲を限定するためです。 - もし最後のステートメントが
go
またはdefer
であり、その呼び出しが関数リテラル (*ast.FuncLit
) である場合、その関数リテラルのボディをast.Inspect
を使って再帰的に走査します。 - 走査中に
*ast.Ident
(識別子)ノードが見つかり、それがループのインデックス変数 (n.Key
) または値変数 (n.Value
) のオブジェクト (n.Obj
) と一致する場合、f.Warn
を呼び出して警告を発します。警告メッセージは「range variable [変数名] enclosed by function」となります。
制限事項
コミットメッセージのコメントにもあるように、この検出は「deferまたはgoステートメントがループボディの最後のステートメントであるインスタンスのみをチェック」します。これは、全プログラム解析を必要とせずに検出できる範囲に限定するためです。つまり、ループの途中でgoroutineやdeferが起動される場合や、ループ変数が直接ではなく、別の変数に代入されてからクロージャに渡されるような複雑なケースは検出されません。
rangeloop.go
内の BadRangeLoopsUsedInTests
関数は、このチェックが検出できるケースとできないケースの例を示しており、ツールの限界を明確にしています。
コアとなるコードの変更箇所
src/cmd/vet/Makefile
--- a/src/cmd/vet/Makefile
+++ b/src/cmd/vet/Makefile
@@ -4,4 +4,4 @@
test testshort:
go build
- ../../../test/errchk ./vet -printfuncs='Warn:1,Warnf:1' print.go
+ ../../../test/errchk ./vet -printfuncs='Warn:1,Warnf:1' print.go rangeloop.go
Makefile
に rangeloop.go
がテスト対象ファイルとして追加されています。
src/cmd/vet/main.go
--- a/src/cmd/vet/main.go
+++ b/src/cmd/vet/main.go
@@ -30,6 +30,7 @@ var (
vetPrintf = flag.Bool("printf", false, "check printf-like invocations")
vetStructTags = flag.Bool("structtags", false, "check that struct field tags have canonical format")
vetUntaggedLiteral = flag.Bool("composites", false, "check that composite literals used type-tagged elements")
+ vetRangeLoops = flag.Bool("rangeloops", false, "check that range loop variables are used correctly")
)
// setExit sets the value for os.Exit when it is called, later. It
@@ -60,7 +61,7 @@ func main() {
flag.Parse()
// If a check is named explicitly, turn off the 'all' flag.
- if *vetMethods || *vetPrintf || *vetStructTags || *vetUntaggedLiteral {
+ if *vetMethods || *vetPrintf || *vetStructTags || *vetUntaggedLiteral || *vetRangeLoops {
*vetAll = false
}
@@ -197,6 +198,8 @@ func (f *File) Visit(node ast.Node) ast.Visitor {
case *ast.InterfaceType:
f.walkInterfaceType(n)
+ case *ast.RangeStmt:
+ f.walkRangeStmt(n)
}
return f
}
@@ -206,6 +209,16 @@ func (f *File) walkCall(call *ast.CallExpr, name string) {
f.checkFmtPrintfCall(call, name)
}
+// walkCallExpr walks a call expression.
+func (f *File) walkCallExpr(call *ast.CallExpr) {
+ switch x := call.Fun.(type) {
+ case *ast.Ident:
+ f.walkCall(call, x.Name)
+ case *ast.SelectorExpr:
+ f.walkCall(call, x.Sel.Name)
+ }
+}
+
// walkCompositeLit walks a composite literal.
func (f *File) walkCompositeLit(c *ast.CompositeLit) {
f.checkUntaggedLiteral(c)
@@ -242,12 +255,7 @@ func (f *File) walkInterfaceType(t *ast.InterfaceType) {
}
}
-// walkCallExpr walks a call expression.
-func (f *File) walkCallExpr(call *ast.CallExpr) {
- switch x := call.Fun.(type) {
- case *ast.Ident:
- f.walkCall(call, x.Name)
- case *ast.SelectorExpr:
- f.walkCall(call, x.Sel.Name)
- }
+// walkRangeStmt walks a range statment.
+func (f *File) walkRangeStmt(n *ast.RangeStmt) {
+ checkRangeLoop(f, n)
}
main.go
では、vetRangeLoops
という新しいブール型フラグが追加され、main
関数内でこのフラグが有効な場合に vetAll
フラグを無効にするロジックが追加されています。また、ASTの Visit
メソッドに *ast.RangeStmt
のケースが追加され、新しい walkRangeStmt
関数が呼び出されるようになっています。walkCallExpr
の定義が移動し、walkRangeStmt
が checkRangeLoop
を呼び出すように変更されています。
src/cmd/vet/rangeloop.go
(新規ファイル)
// Copyright 2012 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
/*
This file contains the code to check range loop variables bound inside function
literals that are deferred or launched in new goroutines. We only check
instances where the defer or go statement is the last statement in the loop
body, as otherwise we would need whole program analysis.
For example:
for i, v := range s {
go func() {
println(i, v) // not what you might expect
}()
}
See: http://golang.org/doc/go_faq.html#closures_and_goroutines
*/
package main
import "go/ast"
// checkRangeLoop walks the body of the provided range statement, checking if
// its index or value variables are used unsafely inside goroutines or deferred
// function literals.
func checkRangeLoop(f *File, n *ast.RangeStmt) {
if !*vetRangeLoops && !*vetAll {
return
}
key, _ := n.Key.(*ast.Ident)
val, _ := n.Value.(*ast.Ident)
if key == nil && val == nil {
return
}
sl := n.Body.List
if len(sl) == 0 {
return
}
var last *ast.CallExpr
switch s := sl[len(sl)-1].(type) {
case *ast.GoStmt:
last = s.Call
case *ast.DeferStmt:
last = s.Call
default:
return
}
lit, ok := last.Fun.(*ast.FuncLit)
if !ok {
return
}
ast.Inspect(lit.Body, func(n ast.Node) bool {
if n, ok := n.(*ast.Ident); ok && n.Obj != nil && (n.Obj == key.Obj || n.Obj == val.Obj) {
f.Warn(n.Pos(), "range variable", n.Name, "enclosed by function")
}
return true
})
}
func BadRangeLoopsUsedInTests() {
var s []int
for i, v := range s {
go func() {
println(i) // ERROR "range variable i enclosed by function"
println(v) // ERROR "range variable v enclosed by function"
}()
}
for i, v := range s {
defer func() {
println(i) // ERROR "range variable i enclosed by function"
println(v) // ERROR "range variable v enclosed by function"
}()
}
for i := range s {
go func() {
println(i) // ERROR "range variable i enclosed by function"
}()
}
for _, v := range s {
go func() {
println(v) // ERROR "range variable v enclosed by function"
}()
}
for i, v := range s {
go func() {
println(i, v)
}()
println("unfortunately, we don't catch the error above because of this statement")
}
for i, v := range s {
go func(i, v int) {
println(i, v)
}(i, v)
}
for i, v := range s {
i, v := i, v
go func() {
println(i, v)
}()
}
}
このファイルは、for...range
ループ変数の誤用検出の主要なロジックを含んでいます。checkRangeLoop
関数がASTを走査し、問題のあるパターンを特定します。BadRangeLoopsUsedInTests
関数は、テストケースとして使用されることを意図しており、検出されるべきエラーと検出されないエラーの例を示しています。
コアとなるコードの解説
src/cmd/vet/rangeloop.go
の checkRangeLoop
関数がこのコミットの核心です。
-
フラグチェック:
if !*vetRangeLoops && !*vetAll { return }
vetRangeLoops
フラグが明示的に有効になっているか、またはvetAll
フラグ(すべてのチェックを有効にする)が有効になっている場合にのみ、チェックを実行します。 -
ループ変数の取得:
key, _ := n.Key.(*ast.Ident) val, _ := n.Value.(*ast.Ident) if key == nil && val == nil { return }
ast.RangeStmt
ノードから、ループのインデックス変数 (n.Key
) と値変数 (n.Value
) を取得します。どちらも存在しない場合は、チェックをスキップします。 -
最後のステートメントのチェック:
sl := n.Body.List if len(sl) == 0 { return } var last *ast.CallExpr switch s := sl[len(sl)-1].(type) { case *ast.GoStmt: last = s.Call case *ast.DeferStmt: last = s.Call default: return }
ループボディ (
n.Body.List
) の最後のステートメントがgo
ステートメントまたはdefer
ステートメントであるかをチェックします。これら以外の場合は、このチェックの対象外として処理を終了します。last
変数には、go
またはdefer
の引数として渡された関数呼び出しのASTノードが格納されます。 -
関数リテラルの確認:
lit, ok := last.Fun.(*ast.FuncLit) if !ok { return }
go
またはdefer
の引数が関数リテラル(匿名関数)であるかをチェックします。関数リテラルでない場合は、このチェックの対象外です。 -
クロージャボディの検査:
ast.Inspect(lit.Body, func(n ast.Node) bool { if n, ok := n.(*ast.Ident); ok && n.Obj != nil && (n.Obj == key.Obj || n.Obj == val.Obj) { f.Warn(n.Pos(), "range variable", n.Name, "enclosed by function") } return true })
ast.Inspect
を使用して、キャプチャされた関数リテラルのボディを再帰的に走査します。- 走査中に見つかったノードが識別子 (
*ast.Ident
) であり、かつその識別子がループのインデックス変数または値変数と同じオブジェクト (n.Obj
) を参照している場合、それはループ変数がクロージャによってキャプチャされていることを意味します。 - この場合、
f.Warn
を呼び出して警告メッセージを出力します。n.Pos()
は警告の発生位置(ファイル名と行番号)を提供し、n.Name
はキャプチャされた変数の名前です。
- 走査中に見つかったノードが識別子 (
このロジックにより、go vet
は、for...range
ループ内でgoroutineやdefer文によってループ変数が誤ってキャプチャされる一般的なケースを効果的に検出できるようになります。
関連リンク
- Go言語のFAQ: Closures and goroutines: https://golang.org/doc/go_faq.html#closures_and_goroutines
go vet
のドキュメント (Go 1.0.3): https://golang.org/cmd/vet/ (このコミットが適用された当時のバージョンに近いドキュメント)go/ast
パッケージのドキュメント: https://pkg.go.dev/go/ast
参考にした情報源リンク
- Go言語の公式ドキュメント
- Go言語のソースコード (特に
src/cmd/vet
ディレクトリ) - Go言語のFAQ
- Go言語のクロージャとループ変数のキャプチャに関する一般的な解説記事 (Web検索による)