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、跳转出循环、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 包可以轻松地为迭代器定义名称

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 的值类型。如果它们取决于,那将非常令人困惑。从设计角度来看,这种依赖关系似乎不可行。有些人建议禁止在 range-over-func 循环体中使用 defer,但这将是基于被 range 的值类型进行的语义更改,似乎同样不可行。

循环体的 defer 运行时机与你不知道 range-over-func 发生了什么特殊情况时看起来完全一样。

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

迭代器函数的 defer 运行时机与你根本不知道该函数被用于 range 循环时看起来完全一样。

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

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

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

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

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

如果迭代器函数恢复循环体中的 panic 会发生什么?

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

range over function 能否与手写的循环一样高效?

原则上,是的。

再次考虑 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 functions 的动机吗?

最近的动机是泛型的加入,我们期望这将导致自定义容器,例如有序映射,并且这些自定义容器能够与 range 循环良好协作将是一件好事。

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

例如,以下是标准库中一些返回切片但可能更适合返回迭代器形式的函数

  • strings.Split
  • strings.Fields
  • 上述的 bytes 变体
  • regexp.Regexp.FindAll 及其相关函数

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

同样,迭代 bufio.Reader 或 bufio.Scanner 中的行是可能的,但你必须知道模式,而且这两种模式不同,并且对于每种类型都倾向于不同。建立表达迭代的标准方式将有助于统一目前存在的许多不同方法。

有关迭代器的更多动机,请参阅 #54245。有关 range over functions 的特定动机,请参阅 #56413

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

我们认为它们是可读的。例如,使用 slices.Backward 而不是显式的倒计数循环应该更容易理解,特别是对于那些不经常看到倒计数循环,并且必须仔细思考边界条件以确保其正确性的开发人员。

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

range over functions 也会发生同样的事情。我们将随着时间的推移建立有用的模式,人们会识别出最常见的迭代器并知道它们的功能。

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

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

将迭代器函数想象成在其自己的协程或 Goroutine 中运行作为类比或心智模型可能很有帮助,但在某些情况下,心智模型并不能给出最佳答案,因为它使用了两个堆栈,而真实实现被定义为使用一个。


此内容是 Go Wiki 的一部分。