Go Wiki:Rangefunc 实验

此页面最初描述了一种实验性的 range-over-function 语言特性。该特性已添加到 Go 1.23 中。有一个博客文章描述了它。

此页面回答了一些关于该更改的常见问题。

range over function 如何运行的简单示例是什么?

考虑这个用于反向迭代切片的函数

package slices

func Backward[E any](s []E) func(func(int, E) bool) {
    return func(yield func(int, E) bool) {
        for i := len(s)-1; i >= 0; i-- {
            if !yield(i, s[i]) {
                return
            }
        }
    }
}

它可以像这样调用

s := []string{"hello", "world"}
for i, x := range slices.Backward(s) {
    fmt.Println(i, x)
}

此程序将在编译器内部转换为更类似于以下程序的程序

slices.Backward(s)(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

主体末尾的return true是循环主体末尾的隐式continue。显式 continue 将同样转换为return true。break 语句将转换为return false。其他控制结构更复杂,但仍然可行。

具有 range 函数的惯用 API 会是什么样?

我们还不知道,这实际上是最终标准库提案的一部分。我们采用的一个约定是,容器的All方法应该返回一个迭代器

func (t *Tree[V]) All() iter.Seq[V]

特定的容器也可能会提供其他迭代器方法。也许列表也会提供反向迭代

func (l *List[V]) All() iter.Seq[V]
func (l *List[V]) Backward() iter.Seq[V]

这些示例旨在表明库可以以一种应该使这些类型的函数可读和可理解的方式编写。

更复杂的循环是如何实现的?

除了简单的 break 和 continue,其他控制流(带标签的 break、continue、goto 跳出循环、return)需要设置一个变量,以便循环外部的代码在循环中断时可以查询该变量。例如,return可能会变成类似doReturn = true; return false的东西,其中return falsebreak的实现,然后当循环完成时,其他生成的代码将执行if (doReturn) return

完整的重写解释在cmd/compile/internal/rangefunc/rewrite.go的实现顶部。

如果迭代器函数忽略 yield 返回 false 会怎样?

对于 range-over-function 循环,为主体生成的 yield 函数会检查它是否在返回 false 后或循环本身退出后被调用。在这两种情况下,它都会引发 panic。

为什么 yield 函数最多只能有两个参数?

必须有一个限制;否则人们会在编译器拒绝荒谬的程序时向编译器提交错误报告。如果我们在真空中进行设计,也许我们会说它是无限的,但实现只需要允许最多 1000 个,或者类似的东西。

然而,我们并非在真空中进行设计:go/astgo/parser 存在,它们只能表示和解析最多两个 range 值。我们显然需要支持两个值来模拟现有的 range 用法。如果支持三个或更多值很重要,我们可以更改这些包,但似乎没有特别强烈的理由支持三个或更多值,因此最简单的选择是停止在两个值,并保持这些包不变。如果将来我们发现有更强烈的理由需要更多值,我们可以重新审视这个限制。

另一个停止在两个值的理由是,为通用代码定义更少的函数签名。今天,(iter)[/pkg/iter] 包可以轻松地为迭代器定义名称

package iter

type Seq[V any] func(yield func(V) bool) bool
type Seq2[K, V any] func(yield func(K, V) bool) bool

循环主体中的堆栈跟踪是什么样的?

循环主体从迭代器函数调用,该函数从包含循环主体的函数调用。堆栈跟踪将显示这种现实情况。这对调试迭代器、与调试器中的堆栈跟踪对齐等非常重要。

如果循环主体延迟调用会怎样?或者如果迭代器函数延迟调用会怎样?

如果 range-over-func 循环主体延迟调用,它会在包含循环的外部函数返回时运行,就像对于任何其他类型的 range 循环一样。也就是说,defer 的语义不依赖于正在遍历的值的类型。如果它们依赖于类型,那将会非常令人困惑。从设计角度来看,这种依赖似乎不可行。有些人建议在 range-over-func 循环主体中不允许使用 defer,但这将是基于正在遍历的值类型的语义更改,并且同样似乎不可行。

循环主体的 defer 恰好在看起来像你不知道 range-over-func 中发生了什么特殊情况时运行。

如果迭代器函数延迟调用,则该调用会在迭代器函数返回时运行。迭代器函数在它耗尽值或被循环主体告知停止时(因为循环主体遇到了一个转换为return falsebreak语句)返回。这正是您对大多数迭代器函数所期望的。例如,从文件返回行的迭代器可以打开文件,延迟关闭文件,然后生成行。

迭代器函数的 defer 恰好在看起来像你不知道该函数是否正在 range 循环中使用时运行。

这对答案可能意味着调用运行的顺序与 defer 语句执行的顺序不同,这里goroutine 的类比很有用。将主函数想象成在一个 goroutine 中运行,而迭代器在另一个 goroutine 中运行,通过一个 channel 发送值。在这种情况下,defer 可能会以与它们创建时不同的顺序运行,因为迭代器在外部函数之前返回,即使外部函数循环主体在迭代器之后延迟调用。

如果循环主体引发 panic 会怎样?或者如果迭代器函数引发 panic 会怎样?

延迟调用以与普通返回相同的顺序运行:首先是迭代器延迟的调用,然后是循环主体延迟的调用并附加到外部函数。如果普通返回和 panic 以不同的顺序运行延迟调用,那会非常令人惊讶。

同样,有一个类比,即迭代器在它自己的 goroutine 中运行。如果在循环开始之前,主函数延迟了对迭代器的清理,那么循环主体中的 panic 会运行延迟的清理调用,这会切换到迭代器,运行它的延迟调用,然后切换回主 goroutine 以继续引发 panic。这是在普通迭代器中延迟调用运行的相同顺序,即使没有额外的 goroutine。

有关这些 defer 和 panic 语义的更详细的理由,请参见此评论

如果迭代器函数恢复循环主体中的 panic 会怎样?

编译器和运行时会检测到这种情况,并触发运行时 panic

range over 函数的性能可以与手工编写的循环一样好吗?

原则上,是的。

再次考虑 slices.Backward 示例,它首先转换为

slices.Backward(s)(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

编译器可以识别 slices.Backward 是微不足道的,并内联它,生成

func(yield func(int, E) bool) bool {
    for i := len(s)-1; i >= 0; i-- {
        if !yield(i, s[i]) {
            return false
        }
    }
    return true
}(func(i int, x string) bool {
    fmt.Println(i, x)
    return true
})

然后它可以识别一个函数文字被立即调用,并内联它

{
    yield := func(i int, x string) bool {
        fmt.Println(i, x)
        return true
    }
    for i := len(s)-1; i >= 0; i-- {
        if !yield(i, s[i]) {
            goto End
        }
    }
End:
}

然后它可以反虚拟化 yield

{
    for i := len(s)-1; i >= 0; i-- {
        if !(func(i int, x string) bool {
            fmt.Println(i, x)
            return true
        })(i, s[i]) {
            goto End
        }
    }
End:
}

然后它可以内联该函数文字

{
    for i := len(s)-1; i >= 0; i-- {
        var ret bool
        {
            i := i
            x := s[i]
            fmt.Println(i, x)
            ret = true
        }
        if !ret {
            goto End
        }
    }
End:
}

从那时起,SSA 后端就可以看到所有不必要的变量,并将其代码与以下代码相同对待

for i := len(s)-1; i >= 0; i-- {
    fmt.Println(i, s[i])
}

这看起来像是相当多的工作,但它只针对简单的主体和简单的迭代器运行,低于内联阈值,因此所涉及的工作量很小。对于更复杂的主体或迭代器,函数调用的开销微不足道。

在任何给定的版本中,编译器可能实现也可能不实现此系列优化。我们在每个版本中都继续改进编译器。

您能提供更多关于 range over 函数的动机吗?

最近的动机是添加泛型,我们预计这将导致定制容器(例如有序映射),并且让这些定制容器与 range 循环很好地配合将是一件好事。

另一个同样好的动机是,为标准库中许多收集一系列结果并将其作为切片返回的函数提供更好的答案。如果结果可以逐个生成,那么允许迭代它们的表示形式比返回整个切片具有更好的可扩展性。我们没有为表示这种迭代的函数定义标准签名。添加对函数的 range 支持既会定义标准签名,又会提供实际的好处,从而鼓励其使用。

例如,以下是一些来自标准库的函数,它们返回切片,但可能应该使用返回迭代器的形式

还有一些我们不愿以切片形式提供的函数,它们可能应该以迭代器形式添加。例如,应该有一个 strings.Lines(text) 来迭代文本中的行。

类似地,可以在 bufio.Reader 或 bufio.Scanner 中迭代行,但您必须知道模式,而对于这两个模式,模式不同,并且对于每种类型往往不同。建立一种表达迭代的标准方式将有助于使现有的许多不同方法趋于一致。

有关迭代器的更多动机,请参见#54245。有关 range over 函数的更多动机,请参见#56413

使用 range over 函数的 Go 程序是否可读?

我们认为可以。例如,使用 slices.Backward 而不是显式的计数递减循环应该更容易理解,特别是对于那些不是每天都看到计数递减循环的开发人员来说,他们必须仔细考虑边界条件以确保它们正确。

确实,range over 函数的可能性意味着,当您看到 range x 时,如果您不知道 x 是什么,您就不知道它将运行什么代码,也不知道它将有多高效。但是切片和映射迭代在运行的代码和速度方面已经相当不同,更不用说 channel 了。普通函数调用也存在这个问题——通常我们不知道被调用函数将做什么——但我们找到了编写可读、可理解的代码的方法,甚至建立了对性能的直觉。

range over 函数也肯定会发生同样的情况。随着时间的推移,我们将建立有用的模式,人们会识别最常见的迭代器,并知道它们的作用。

为什么语义不完全像迭代器函数在 coroutine 或 goroutine 中运行一样?

在单独的协程或 goroutine 中运行迭代器比将所有内容放在一个栈上更昂贵且更难调试。由于我们将把所有内容放在一个栈上,这个事实将改变某些可见的细节。我们看到了上面的第一个:堆栈跟踪显示调用函数和迭代器函数交织在一起,以及显示程序页面中不存在的显式 yield 函数。

将迭代器函数在其自己的协程或 goroutine 中运行可以作为类比或思维模型来帮助理解,但在某些情况下,思维模型不能给出最佳答案,因为它使用两个栈,而实际实现被定义为使用一个栈。


此内容是 Go Wiki 的一部分。