Go 1.22 循环变量入门:为什么子测试和 goroutine 更不容易踩坑了

用初学者能看懂的例子解释 Go 1.22 循环变量语义变化,以及它对表驱动测试、goroutine 和日常代码审查的影响。

Go 里有一个经典坑:在循环里启动 goroutine,或者在表驱动测试里写 t.Run,最后所有闭包都拿到了同一个循环变量。很多老 Go 程序员都会下意识写一行 tt := tt,这行代码看起来有点奇怪,但它曾经非常重要。到了 Go 1.22,循环变量的语义发生了调整,很多这类问题会自然消失。

不过,知道“新版本修了”还不够。初学者更需要理解:过去为什么会错,新语义解决了什么,老项目里为什么还会看到旧写法,以及代码审查时该怎么判断。本文从测试和 goroutine 两个场景讲起,不追语言规范细节,重点放在你写业务代码时能用上的判断。

过去的经典问题

先看一个表驱动测试:

func TestNormalize(t *testing.T) {
	tests := []struct {
		name  string
		input string
		want  string
	}{
		{name: "trim", input: " Go ", want: "Go"},
		{name: "empty", input: " ", want: ""},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := strings.TrimSpace(tt.input)
			if got != tt.want {
				t.Fatalf("got %q, want %q", got, tt.want)
			}
		})
	}
}

在旧语义里,tt 是被循环重复赋值的同一个变量。闭包捕获的是变量本身,不是每一轮的值。如果子测试延后执行,或者用了 t.Parallel(),就可能出现所有子测试都看到最后一个 tt 的情况。解决方式通常是:

for _, tt := range tests {
	tt := tt
	t.Run(tt.name, func(t *testing.T) {
		// 使用这一轮自己的 tt
	})
}

这行 tt := tt 创建了一个新的局部变量,让闭包捕获这一轮自己的副本。它不是为了好看,而是为了避免闭包和循环变量之间的共享关系。

Go 1.22 后发生了什么

从 Go 1.22 开始,for 循环中的变量在每次迭代会有更符合直觉的行为。也就是说,上面那类“所有闭包都拿到最后一个值”的问题,在新模块语义下会少很多。你写表驱动测试时,不再需要机械地给每个循环都加 tt := tt

但这不表示所有旧代码都应该立刻删掉这行。原因有三个。第一,很多项目还要兼容旧 Go 版本。第二,保留这行在新版本里通常没有坏处,反而能让读者知道这里存在闭包。第三,团队代码风格可能要求显式写出来,方便和历史代码保持一致。

所以更务实的建议是:新项目如果明确使用 Go 1.22 或更高版本,可以少写这类防御代码;老项目如果已经大量存在 tt := tt,没有必要为了“现代化”专门清理它。代码清理要有收益,不要只为了看起来更新。

goroutine 场景仍然要看共享状态

再看一个 goroutine 例子:

func printNames(names []string) {
	for _, name := range names {
		go func() {
			fmt.Println(name)
		}()
	}
}

Go 1.22 后,循环变量的捕获更符合预期。但并发代码真正危险的地方不只在循环变量,还在共享状态。比如:

func countWords(lines []string) map[string]int {
	counts := map[string]int{}
	var wg sync.WaitGroup

	for _, line := range lines {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for _, word := range strings.Fields(line) {
				counts[word]++
			}
		}()
	}

	wg.Wait()
	return counts
}

即使 line 捕获得没问题,counts 仍然有并发写 map 的问题。修复可以用 mutex:

func countWordsSafe(lines []string) map[string]int {
	counts := map[string]int{}
	var mu sync.Mutex
	var wg sync.WaitGroup

	for _, line := range lines {
		wg.Add(1)
		go func() {
			defer wg.Done()
			local := map[string]int{}
			for _, word := range strings.Fields(line) {
				local[word]++
			}

			mu.Lock()
			for word, n := range local {
				counts[word] += n
			}
			mu.Unlock()
		}()
	}

	wg.Wait()
	return counts
}

这个例子说明:循环变量语义变好了,但并发正确性仍然要靠明确同步。不要把 Go 1.22 的变化理解成“闭包和 goroutine 都安全了”。它解决的是一类常见捕获问题,不是所有并发问题。

子测试命名仍然重要

循环变量修正之后,表驱动测试更不容易写错,但测试质量不只取决于语义。一个好的测试表应该让场景一眼可读:

tests := []struct {
	name    string
	input   string
	want    string
	wantErr bool
}{
	{name: "trim spaces", input: "  Go  ", want: "Go"},
	{name: "reject blank title", input: "   ", wantErr: true},
	{name: "keep unicode text", input: "  你好  ", want: "你好"},
}

失败时最好包含输入和期望:

if got != tt.want {
	t.Fatalf("Normalize(%q) = %q, want %q", tt.input, got, tt.want)
}

语言修掉一个坑,不等于测试自动变好。测试名、错误信息、边界场景,仍然需要认真写。尤其是初学者,不要把表驱动测试写成一堆 case1case2。测试会被后来的人反复阅读,它也是业务规则的文档。

代码审查时怎么判断

如果你在审查代码时看到循环里有闭包,可以按这个顺序看:

  1. 项目的 go.mod 是否声明了较新的 Go 版本。
  2. 闭包里是否只读取循环变量,还是还访问了外部共享状态。
  3. 是否用了 t.Parallel()、goroutine 或回调注册。
  4. 是否有测试覆盖并发或延迟执行场景。

如果只是普通同步循环,通常不用担心。比如:

for _, user := range users {
	fmt.Println(user.Name)
}

这里没有闭包,也没有延迟执行。不要把所有循环都当成危险代码。真正要留意的是变量生命周期被拉长的情况:goroutine、回调、函数返回闭包、子测试并行执行。判断风险时,关注“这段代码什么时候执行”,比只盯着 for 更有效。

老代码迁移的建议

老项目升级 Go 版本时,循环变量语义变化通常是好事,但仍然建议跑完整测试,尤其是表驱动测试和并发测试。如果项目里曾经为了绕过旧语义写了复杂代码,不要急着重构。先确认它确实变成负担,再考虑清理。

比较稳的做法是只在你正在修改的测试附近做小范围整理。比如新增一个测试文件时,按新版本风格写;维护旧文件时,如果 tt := tt 不影响阅读,就保留。这样既能享受新语义带来的便利,也不会制造大面积无意义 diff。

小结

Go 1.22 的循环变量变化让表驱动测试和 goroutine 里的闭包捕获更符合直觉,很多老问题不再需要靠 tt := tt 防御。但它不是并发安全的万能药,map、切片、计数器等共享状态仍然需要 mutex、channel 或 atomic 保护。

对初学者来说,真正要掌握的是闭包捕获变量、延迟执行和共享状态这三个概念。理解了它们,看到旧写法不会困惑,写新代码也能更自然地判断哪里需要谨慎。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页