Go 1.22中for循环语义变得不同了


Go 1.22修改了for循环的语义!

具体来说,只有在循环中声明了循环变量的 for 循环的语义发生了变化。

例如,在下面这段代码中,前两个循环的语义没有变化,但后两个循环的语义发生了变化(从 Go 1.21 到 1.22)。

Go 1.21:
for k, v = range aContainer {...}
 for a, b, c = f(); condition; statement {...}

Go 1.22:
 for k, v := range aContainer {...}
for a, b, c := f(); condition; statement {...}

注意,上述黑体 斜体部分是不同,原来的"="变成了":=",在等于号前面加了冒号。

前两个循环Go 1.21没有声明各自的循环变量,但后两个循环Go 1.22声明了。
这就是区别所在。

  • 前两个循环的Go 1.21语义没有改变。
  • 但是后两个循环Go 1.22语义改变了

案例1
让我们来看一个简单的 Go 程序,它经历了从 Go 1.21 到 Go 1.22 的语义变化(和行为变化):

//demo1.go
package main

func main() {
    c, out := make(chan int), make(chan int)

    m := map[int]int{1: 2, 3: 4}
    for i, v := range m {
        go func() {
            <-c
            out <- i+v
        }()
    }

    close(c)

    println(<-out + <-out)
}

我们可以安装多个 Go 工具链版本来检查输出结果。在这里,我使用GoTV tool 工具来(方便地)选择 Go 工具链版本。

输出:
$ gotv 1.21. run demo1.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo1.go
14
$ gotv 1.22. run demo1.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo1.go
10

行为差异显而易见:

  • 在 Go 1.22 之前,它打印 14;
  • Go 1.22 之后,则打印 10。

造成这种差异的原因是

  • 在 Go 1.22 之前,for 循环中使用的每个新声明的循环变量在执行循环的过程中被所有迭代共享。因此,最终两个新创建的 goroutines 中使用的 i 和 v 循环变量的值都是 3 4。
  • 自 Go 1.22 起,for 循环中使用的每个新声明的循环变量在每次迭代开始时都会被实例化为一个独特的实例。换句话说,它现在是按迭代作用域的。因此,两个新创建的 goroutine 中使用的 i 和 v 循环变量的值分别是 1 2 和 3 4。(1+2) + (3+4) 得到 10。

在 1.22 版之前,为了得到与新语义相同的结果,程序中的循环应改写为

    for i, v := range m {
        i, v := i, v // 增加这一行
        go func() {
            <-c
            out <- i+v
        }()
    }

在新的语义下,添加的一行变得没有必要了。

案例2
同样,下面的程序也经历了从 Go 1.21 到 Go 1.22 的语义/行为变化:

// demo2.go
package main

func main() {
    c, out := make(chan int), make(chan int)

    for i := 1; i <= 3; i++ {
        go func() {
            <-c
            out <- i
        }()
    }

    close(c)

    println(<-out + <-out + <-out)
}

输出:
$ gotv 1.21. run demo2.go
[Run]: $HOME/.cache/gotv/tag_go1.21.7/bin/go run demo2.go
12
$ gotv 1.22. run demo2.go
[Run]: $HOME/.cache/gotv/tag_go1.22.0/bin/go run demo2.go 
6

更改的影响
我个人认为,将 for-range 循环改为 for-range 循环的理由是充分的。for-range 循环的新语义变得更加直观。这一改动只影响 for k, v := range ... {...} 循环,其中的 := 符号强烈暗示循环变量是按迭代作用域的。没有引入任何影响。这种变化的影响几乎是正面的。

另一方面,在我看来,改为 for;; 循环的理由并不充分。提议者提出的主要理由是为了与 for-range 循环保持一致(它们都是 for 循环)。但是,如果认为下面 alike 循环中的循环变量是按迭代作用域的,那就完全不直观了。

for a, b, c := anExpression; aCondition; postStatement {
    ... // loop body
}

在循环执行过程中,a, b, c := anExpression 语句只被执行一次,因此直观地说,循环变量在循环执行过程中只被显式实例化一次。

新的语义使得循环变量在每次迭代时都被实例化,这意味着必须有一些隐式代码来完成这项工作。
的确如此。Go 1.22+ 规范说:
每次迭代都有自己单独声明的变量。第一次迭代使用的变量由 init 语句声明。之后每次迭代使用的变量都是在执行 post 语句之前隐式声明的,并初始化为前一次迭代的变量值。

从 Go 1.22 开始,上面的循环实际上等价于下面的伪代码(抱歉,新的语义很难解释得清楚和完美。没有一篇 Go 官方文档能成功实现这一目标。在此,我已经尽力了):

{
    a_last, b_last, c_last := anExpression
    pa_last, pb_last, pc_last = &a_last, &b_last, &c_last
    first := true
    for {
        a, b, c := *pa_last, *pb_last, *pc_last
        if first {
            first = false
        } else {
            postStatement
        }
        if !(aCondition) {
            break
        }
        pa_last, pb_last, pc_last = &a, &b, &c
        ... // loop body
    }
}

哇,好多神奇的隐式代码。对于一种提倡显式的语言来说,这实在令人尴尬。
隐式往往会带来意想不到的惊喜,这并不令人惊讶。

更多点击标题

最后的话
总的来说,我认为 for-range 循环的新语义的影响是积极的,而 for;; 循环的新语义的影响是消极的。这只是我的个人观点。

对于引入的神奇隐含性,for;;循环的新语义可能需要在编写代码时花费额外的调试时间,在某些情况下还需要在代码审查和理解时花费额外的认知精力。

for;; 循环的新语义可能会在现有代码中引入潜在的性能下降和数据竞争问题,需要仔细审查并进行可能的调整。根据具体情况,这些问题可能会被及时发现,也可能不会被及时发现。

在我看来,for;;循环的新语义所带来的好处既少又小,而缺点则更为突出和严重

Go 1.22 中引入的语义变化大大降低了保持向后兼容性的门槛。
这是一个糟糕的开端。