Jalex Chang
2023.11.16
Per-Iteration Loop Variable in Go
Jalex Chang
- Saff Software Engineer @ Crescendo Lab
- Gopher
- Love software engineering, database systems, and distributed systems
Agenda
-
Introduction
-
New language specification
-
Compatibility
-
Discussions
-
Summary
References
[1] Golang blog, https://go.dev/blog/loopvar-preview
[2] Proposal of Per-Iteration Loop Variable, https://go.googlesource.com/proposal/+/master/design/60078-loopvar.md
[3] Official demo: https://github.com/golang/go/issues/60078#issuecomment-1671371277
[4] Related discussions: https://github.com/golang/go/discussions/56010
Introduction
In this tech talk, we want to introduce the semantic changes on For Loop Variables since Go 1.22.
Topics will be covered in the talk:
-
Rationales behind the changes
-
New language specification
-
How Go handles compatibility for breaking changes
-
How does semantic change affect our daily life
For Loop variables at present
- Current For Loop variables are per-loop scoped.
- It's annoying/troublesome when using For Loop variables with closure, pointer, and goroutine.
var prints []func()
for i := 0; i < 3; i++ {
prints = append(prints, func() {
println(i)
})
}
for _, print := range prints {
print()
}
// Output:
// 3
// 3
// 3
var prints []func()
for _, s := range []string{"a", "b", "c"} {
prints = append(prints, func() {
println(s)
})
}
for _, print := range prints {
print()
}
// output:
// c
// c
// c
For Clause:
For Range:
Workarounds
Explicitly declare variables again within an iteration scope, forcing the variables to become per-iteration scoped.
var prints []func()
for i := 0; i < 3; i++ {
i := i
prints = append(prints, func() { println(i) })
}
for _, print := range prints {
print()
}
// Output:
// 0
// 1
// 2
var prints []func()
for _, s := range []string{"a", "b", "c"} {
s:=s
prints = append(prints, func() { println(s) })
}
for _, print := range prints {
print()
}
// output:
// a
// b
// c
For Clause:
For Range:
Why are semantics worth changing? And why now?
- Probably every Go programmer in the world has suffered from this before.
- We meet the problem again and again.
- Current workarounds are unclear and sometimes unnecessary.
- 12K of top 14k git repos have used the workaround "
x:=x
". - Half of the commits were unnecessary.
- 12K of top 14k git repos have used the workaround "
-
Go module (go.mod) enables fine-grained compiling control.
- It gives us a way to guarantee that all old code is unaffected, even in a build containing new code.
- Only when we change required Go version in go.mod, packages in that module get the new semantics.
Semantics Changes
New semantics since Go 1.22 - For Clause
The init statement may be a short variable declaration (
:=
), but the post statement must not. Each iteration has its own separate declared variable (or variables). The variable used by the first iteration is declared by the init statement. The variable used by each subsequent iteration is declared implicitly before executing the post statement and initialized to the value of the previous iteration's variable at that moment.
var prints []func()
for i := 0; i < 3; i++ {
prints = append(prints, func() { println(i) })
}
var prints []func()
{
i_outer := 0
first := true
for {
i := i_outer
if first {
first = false
} else {
i++
}
if !(i < 3) {
break
}
prints = append(prints, func() { println(i) })
i_outer = i
}
}
New semantics since Go 1.22 - For Range
The iteration variables may be declared by the “range” clause using a form of short variable declaration (:=). In this case their types are set to the types of the respective iteration values and their scope is the block of the “for” statement; each iteration has its own separate variables. If the iteration variables are declared outside the “for” statement, after execution their values will be those of the last iteration
var prints []func()
for _, s := range []string{"a", "b", "c"} {
prints = append(prints, func() { println(s) })
}
var prints []func()
{
var s_outer string
for _, s_outer = range []string{"a", "b", "c"} {
s := s_outer
prints = append(prints, func() { println(s) })
}
}
Experimental demo in Go 1.21
Go's official demo: https://go.dev/play/p/lDFLrPOcdz3
Enable the experiment flag "loopvar" to hint at Go compiler rewrites For Loop.
// GOEXPERIMENT=loopvar
package main
func main() {
var prints []func()
for i := range make([]int, 5) {
prints = append(prints, func() { println(i) })
}
for _, p := range prints {
p()
}
}
// output:
// 0
// 1
// 2
// 3
// 4
Compatibility
User controllable "breaking changes"
The change in language specification will fix far more programs than it breaks, but it may break a very small number of programs - buggy-already codes
To make the potential breakage completely user-controlled, the rollout would decide whether to use the new semantics based on the go
line in each package’s go.mod
file.
- Enable the mew semantics (per-iteration lool variables) only if Go compiler and Go program’s required version ≥ 1.22.
- Otherwise, keep the old semantics (per-loop loop variables).
Example - control semantics by Go modules
Transition support tooling
To transit the new semantics safely, two tools are supported:
- Compiler flag loopvar: reports every loop compiling differently due to the new semantics.
go build (or test) gcflags=-d=loopvar=2 ...
-
bisect: a new program runs a test repeatedly with different sets of loops opted into the new semantics.
- Using a binary search-like algorithm, bisect can pinpoint the exact loop or loops that, when converted to the new semantics, cause a test failure.
bisect -compile=loopvar go test ...
We have used bisect in a conversion of Google's internal monorepo to the new loop semantics. The rate of test failure caused by the change was about 1 in 8,000.
Example - compiler flag
$ go test ./loopvar/...
ok command-line-arguments 0.247s
// loopvar/sum_test.go
package main
import "testing"
func TestSum(t *testing.T) {
list := []int{2, 4, 6}
want := 12
if got := Sum(l); got != want {
t.Errorf("Sum(%v) = %v, want %v", list, got, want)
}
}
$ go install golang.org/dl/gotip@latest
$ gotip download
$ GOEXPERIMENT=loopvar gotip test ./loopvar/...
--- FAIL: TestSum (0.00s)
sum_test.go:9: Sum([2 4 6]) = 2, want 12
FAIL
FAIL loopvar 0.244s
FAIL
Old semantics:
New semantics:
// loopvar/sum.go
package main
func Sum(list []int) int {
m := make(map[*int]int)
for _, x := range list {
// In old semantic,
// value of &x is always the same.
m[&x] += x
}
for _, sum := range m {
return sum
}
return 0
}
func main() {
list := []int{2, 4, 6}
print(Sum(list))
}
$ gotip build -gcflags=-d=loopvar=2 ./loopvar
loopvar/sum.go:5:9: loop variable x \
now per-iteration, heap-allocated
Example - compiler flag (real-world application)
$ GOEXPERIMENT=loopvar gotip test ./internal/...
ok github.com/chatbotgang/cantata/internal/adapter/eventbroker 1.299s
ok github.com/chatbotgang/cantata/internal/adapter/repository/es 1.267s
ok github.com/chatbotgang/cantata/internal/adapter/repository/gcs 1.506s
ok github.com/chatbotgang/cantata/internal/adapter/repository/local 1.793s
ok github.com/chatbotgang/cantata/internal/adapter/repository/postgres 2.402s
ok github.com/chatbotgang/cantata/internal/app/service/auth 1.317s
ok github.com/chatbotgang/cantata/internal/app/service/cdp 1.250s
ok github.com/chatbotgang/cantata/internal/app/service/chat 2.788s
ok github.com/chatbotgang/cantata/internal/app/service/organization 1.504s
......
ok github.com/chatbotgang/cantata/internal/app/service/utils 1.408s]
ok github.com/chatbotgang/cantata/internal/app/service/workertask 1.274s
ok github.com/chatbotgang/cantata/internal/domain/chat 1.299s
ok github.com/chatbotgang/cantata/internal/domain/common 1.449s
ok github.com/chatbotgang/cantata/internal/domain/common/requestid 1.668s
ok github.com/chatbotgang/cantata/internal/domain/organization 1.357s
ok github.com/chatbotgang/cantata/internal/router 1.305s
$ gotip build -gcflags=-d=loopvar=2 -o bin/cantata ./cmd/cantata
$
Example - bisect
$ go install golang.org/x/tools/cmd/bisect@latest
$ bisect -compile=loopvar gotip test ./cmd/loopvar/...
bisect: checking target with all changes disabled
bisect: run: GOCOMPILEDEBUG=loopvarhash=n gotip test ./loopvar/...... ok (11 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=n gotip test ./loopvar/...... ok (11 matches)
bisect: checking target with all changes enabled
bisect: run: GOCOMPILEDEBUG=loopvarhash=y gotip test ./loopvar/...... FAIL (11 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=y gotip test ./loopvar/...... FAIL (11 matches)
bisect: target succeeds with no changes, fails with all changes
bisect: searching for minimal set of enabled changes causing failure
bisect: run: GOCOMPILEDEBUG=loopvarhash=+0 gotip test ./loopvar/...... FAIL (7 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+0 gotip test ./loopvar/...... FAIL (7 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+00 gotip test ./loopvar/...... FAIL (3 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+00 gotip test ./loopvar/...... FAIL (3 matches)
......
bisect: run: GOCOMPILEDEBUG=loopvarhash=v+x0b0 gotip test ./loopvar/...... FAIL (1 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=v+x0b0 gotip test ./loopvar/...... FAIL (1 matches)
bisect: FOUND failing change set
--- change set #1 (enabling changes causes failure)
loopvar/sum.go:5:9: loop variable x now per-iteration
loopvar/sum.go:5:9: loop variable x now per-iteration (loop inlined into loopvar/sum.go:17)
loopvar/sum.go:5:9: loop variable x now per-iteration (loop inlined into loopvar/sum_test.go:8)
---
Use the same example as compiler flags (loopvar_test.go):
Example - bisect (real-world application)
$ bisect -compile=loopvar gotip test ./internal/...
bisect: checking target with all changes disabled
bisect: run: GOCOMPILEDEBUG=loopvarhash=n gotip test ./internal/...... ok (102 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=n gotip test ./internal/...... ok (102 matches)
bisect: checking target with all changes enabled
bisect: run: GOCOMPILEDEBUG=loopvarhash=y gotip test ./internal/...... ok (102 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=y gotip test ./internal/...... ok (102 matches)
bisect: fatal error: target succeeds with no changes and all changes
Takeaways
In this sharing, we have introduced the semantics changed on For Loop Variables since Go 1.22.
- The scope of Loop variables is changed to per-iteration.
- Go compiler re-writes applications automatically.
-
Go module (go.mod) provides a great compiling control
- 📌 Keep the required Go version < 1.22 if your applications aren't ready for the breaking changes.
- 📌 Use bisect if your application is covered by tests well.
- 📌 Use compiler flag loopvar when building.
-
Finally, let's pray😇
We are hiring now!
Thanks for listening
Per-Iteration Loop Variable in Go
By Jalex Chang
Per-Iteration Loop Variable in Go
Go is planned to change For Loop semantics in 1.22 (https://go.dev/blog/loopvar-preview), changing the scope of loop variables from per-loop to per-iteration. In this sharing, let’s talk about (1) the rationale behind this “breaking change” and (2) how it affects our systems and daily life.
- 311