Go 1.23新特性:Iterators和reflect.Value.Seq

创建于 2024年7月31日修改于 2024年7月31日
Go

如果你一直在关注 Go 1.23 的新特性,你可能已经听说过新的泛型自定义迭代函数。编写这些迭代器的签名有点复杂,但使用它们很简单。

这里有一个例子:

// counter 从零开始计数
func counter() iter.Seq[int] {
	// 暂略过
}

func main() {
	for n := range counter() {
		if n > 5 {
			break
		}
		fmt.Println(n)
	}
}

输出是你所期望的:

0
1
2
3
4
5

如果使用自定义迭代器很简单,那么如何实现它呢?

首先,让我们在 Python 中定义一个类似的函数:

def counter():
    n = 0
    while True:
        yield n
        n += 1

在 JavaScript 中:

function* counter() {
  let n = 0;
  while (true) {
    yield n;
    n++;
  }
}

现在是 Go 版本:

// counter 从零开始计数
func counter() iter.Seq[int] {
	return func(yield func(int) bool) {
		n := 0
		for {
			if !yield(n) {
				return
			}
			n++
		}
	}
}

Go 版本迭代器的有趣之处在于,它没有像 yieldfunction * 这样的特殊语法来创建迭代器函数。类型 iter.Seq 是一个泛型命名类型,表示 func(yield func(V) bool),其中 V 是任意类型。换句话说,迭代器只是一个接收回调的普通函数。回调函数使用从迭代值流中获取的值调用,并返回 truefalse。如果返回 true,迭代器应该返回序列中的下一个值。如果返回 false,迭代器应该进行必要的清理(在这种情况下没有)并停止迭代。

顺便提一下,在 Go 1.24 中,一旦泛型类型别名可用,这可能会写成 return func(yield iter.Yield[int]) {。旧签名仍然可以使用,但新签名稍微容易些编写和阅读。

你可能已经通过使用 Go 通道编写类似迭代器的东西了。不过基于通道的迭代器有三个主要问题:

  1. 因为之前 Go 没有泛型,你必须为每种你想要流式传输的类型编写新的辅助程序逻辑。
  2. 因为它们使用通道和 goroutine,每个发送和接收的值都必须由 Go 运行时调度器调度,这很慢。
  3. 因为通道应该在一个地方写入或读取,而不是两者都做,你需要到处传递取消通道以通知生成器退出,否则你会遇到很多僵尸 goroutine。

问题1已经解决了。问题2更难解决,但理论上在某些情况下可能可以优化掉一些对运行时调度器的调用。(实际上,这就是一些迭代器现在在底层的工作方式。)但问题3对使用通道作为迭代器的想法确实是致命的。

这是使用退出通道作迭代器的一些样例代码:

func sendOrFail(n int64, out chan<- int64, quit <-chan struct{}) bool {
	select {
	case out <- n:
		return true
	case <-quit:
		return false
	}
}

func counter(quit <-chan struct{}) <-chan int64 {
	out := make(chan int64)
	go func() {
		defer close(out)
		var n int64

		if ok := sendOrFail(n, out, quit); !ok {
			return
		}
		n++
	}()
	return out
}

这个方法是在 context.Context 发明之前,所以你可以今天通过使用上下文而不是退出通道来稍微整理一下它,但最终结果是一样的。你需要不断传递一个关闭令牌以知道何时停止迭代,这在嵌套迭代器时变得非常复杂。以下是使用它的样子:

ctx, cancel := context.WithCancel(context.Background())
for n := range counter(ctx.Done()) {
	if n > 5 {
		break
	}
	fmt.Println(n)
}
cancel() // 告诉 counter 关闭,否则它会永远泄漏!

实际上,如果你尝试让 sendOrFail 更通用一点,使其接受一个上下文并自动创建和关闭通道,它可能看起来像这样:

func buildChannel[T any](ctx context.Context, callback func(func(T) bool)) <-chan T {
	ch := make(chan T)
	yield := func(v T) bool {
		select {
		case ch <- v:
			return true
		case <-ctx.Done():
			return false
		}
	}
	go func() {
		defer close(ch)
		callback(yield)
	}()
	return ch
}

func counter(ctx context.Context) <-chan int64 {
	return buildChannel(ctx, func(yield func(int64) bool) {
		var n int64
		for {
			if !yield(n) {
				return
			}
			n++
		}
	})
}

换句话说,你最终得到了与 Go 1.23 迭代器相同的签名,只是多了一个包装函数和上下文参数!

使用 Go 1.23 实际添加的自定义迭代器并编写 return func(yield func(int) bool) 略有点尴尬,但比到处传递关闭令牌并在每个调用点使用它们要好得多。

可以看到 Go 迭代器已经是目前的最佳选择了。

那么reflect.Value.Seqreflect.Value.Seq2reflect.Type.CanSeqreflect.Type.CanSeq2 能不能行呢?

reflect.Value.Seq 的文档字符串说:

Seq 返回一个 iter.Seq[Value],循环遍历 v 的元素。如果 v 的种类是 Func,它必须是一个没有结果并且接受一个类型为 func(T) bool 的参数的函数。如果 v 的种类是 Pointer,指针元素类型必须是 Array。否则 v 的种类必须是 IntInt8Int16Int32Int64UintUint8Uint16Uint32Uint64UintptrArrayChanMapSliceString

调用 Seq 方法就像调用 for x := range v { xValue := reflect.ValueOf(x) },适用于任意类型 vCanSeq 方法只是报告一个类型是否可以用作迭代器。

可能令人惊讶的是,由于它就像一个 range 语句一样工作,当遍历一个切片时,Seq 方法返回的值只是表示每个切片项索引的整数。

func printEach(x any) {
	v := reflect.ValueOf(x)
	if !v.Type().CanSeq() {
		panic("bad type")
	}
	for x := range v.Seq() {
		fmt.Println(x.Interface())
	}
}

func main() {
	printEach([]string{"a", "b", "c"})
}

Output:

0
1
2

要获取切片元素,请使用 Seq2 和第二个 range 值:

func printEach(x any) {
	v := reflect.ValueOf(x)
	if !v.Type().CanSeq2() {
		panic("bad type")
	}
	for x, y := range v.Seq2() {
		fmt.Println(x.Interface(), y.Interface())
	}
}

func main() {
	printEach([]string{"a", "b", "c"})
}

输出:

0 a
1 b
2 c

反射包已经有一个方法可以遍历 map,即 reflect.Value.MapRange,但使用它不如使用 reflect.Value.Seq2 那么好:

iter := reflect.ValueOf(m).MapRange()
for iter.Next() {
	k := iter.Key()
	v := iter.Value()
	// ...
}

// vs.

for k, v := range reflect.ValueOf(m).Seq2() {
	// ...
}

原文:https://blog.carlana.net/post/2024/golang-reflect-value-seq/