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

[インデックス 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: 1i: 1, v: 2i: 2, v: 3 のように出力されると期待するかもしれません。しかし、実際には iv はループの各イテレーションで同じメモリ位置を共有するため、goroutineが実行される頃にはループが終了しており、iv は最後のイテレーションの値(この場合は 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 ループ変数の誤用検出ロジックを実装しています。

検出のメカニズム

  1. vetRangeLoops フラグの追加: src/cmd/vet/main.go-rangeloops という新しいコマンドラインフラグが追加されました。これにより、ユーザーはこの特定のチェックを有効/無効にできます。
  2. ASTウォークの拡張: go/ast パッケージを使用してGoのソースコードの抽象構文木(AST)を走査します。File 構造体の Visit メソッドに *ast.RangeStmt のケースが追加され、walkRangeStmt メソッドが呼び出されるようになりました。
  3. rangeloop.go のロジック:
    • checkRangeLoop 関数が、ast.RangeStmtfor...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

Makefilerangeloop.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 の定義が移動し、walkRangeStmtcheckRangeLoop を呼び出すように変更されています。

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.gocheckRangeLoop 関数がこのコミットの核心です。

  1. フラグチェック:

    if !*vetRangeLoops && !*vetAll {
        return
    }
    

    vetRangeLoops フラグが明示的に有効になっているか、または vetAll フラグ(すべてのチェックを有効にする)が有効になっている場合にのみ、チェックを実行します。

  2. ループ変数の取得:

    key, _ := n.Key.(*ast.Ident)
    val, _ := n.Value.(*ast.Ident)
    if key == nil && val == nil {
        return
    }
    

    ast.RangeStmt ノードから、ループのインデックス変数 (n.Key) と値変数 (n.Value) を取得します。どちらも存在しない場合は、チェックをスキップします。

  3. 最後のステートメントのチェック:

    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ノードが格納されます。

  4. 関数リテラルの確認:

    lit, ok := last.Fun.(*ast.FuncLit)
    if !ok {
        return
    }
    

    go または defer の引数が関数リテラル(匿名関数)であるかをチェックします。関数リテラルでない場合は、このチェックの対象外です。

  5. クロージャボディの検査:

    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言語の公式ドキュメント
  • Go言語のソースコード (特に src/cmd/vet ディレクトリ)
  • Go言語のFAQ
  • Go言語のクロージャとループ変数のキャプチャに関する一般的な解説記事 (Web検索による)