在Go语言中创建迭代器的最习惯用法是什么?

73
一种选择是使用通道(channels)。通道有点像迭代器(iterators),可以使用range关键字对其进行迭代。但是,当你发现无法在不泄漏协程的情况下退出此循环时,使用通道的限制就显现出来了。
在Go中,创建迭代器模式的惯用方式是什么?
编辑: 通道的根本问题在于它们是推模型(push model)而不是拉模型(pull model)。迭代器是拉模型。您不必告诉迭代器停止。我正在寻找一种以简洁明了的方式遍历集合的方法。我还希望能够链接迭代器(map,filter,fold替代方案)。

2
通道传递事件。这并不意味着它们必须具有推模型,而是取决于您如何使用它们。如果您以线性方式使用它们,则会得到推模型。如果您以客户端/服务器方式使用它们,则可以使用拉模型。 - Rick-777
11个回答

82

频道很有用,但闭包通常更合适。

package main

import "fmt"

func main() {
    gen := newEven()
    fmt.Println(gen())
    fmt.Println(gen())
    fmt.Println(gen())
    gen = nil // release for garbage collection
}

func newEven() func() int {
    n := 0
    // closure captures variable n
    return func() int {
        n += 2
        return n
    }
}

游乐场: http://play.golang.org/p/W7pG_HUOzw

如果您也不喜欢闭包,请使用具有方法的命名类型:

package main

import "fmt"

func main() {
    gen := even(0)
    fmt.Println(gen.next())
    fmt.Println(gen.next())
    fmt.Println(gen.next())
}

type even int

func (e *even) next() int {
    *e += 2
    return int(*e)
}

游乐场: http://play.golang.org/p/o0lerLcAh3

这三种技术之间存在折衷,因此您不能将其中一种提名为惯用语。使用最适合您需求的方法。

链式编程很容易,因为函数是一等对象。以下是闭包示例的扩展。我添加了一个类型intGen,用于整数生成器,可以明确生成器函数在哪里用作参数和返回值。mapInt以通用方式定义,将任何整数函数映射到整数生成器。其他函数,如filter和fold,也可以类似地定义。

package main

import "fmt"

func main() {
    gen := mapInt(newEven(), square)
    fmt.Println(gen())
    fmt.Println(gen())
    fmt.Println(gen())
    gen = nil // release for garbage collection
}

type intGen func() int

func newEven() intGen {
    n := 0
    return func() int {
        n += 2
        return n
    }
}

func mapInt(g intGen, f func(int) int) intGen {
    return func() int {
        return f(g())
    }
}

func square(i int) int {
    return i * i
}

游乐场:http://play.golang.org/p/L1OFm6JuX0


4
非常好。但是你不能在闭包中使用range关键字。你能提供一个for循环的例子吗?迭代如何停止?你可能需要第二个返回值来指示停止迭代。 - Kugel
5
Range函数适用于内置类型,而不是像函数这样的用户自定义类型。您说得对,可以使用第二个返回值来停止迭代。这将更加清晰和符合惯例。您还可以在找到所需内容或处理所需数据量后停止迭代。 - Sonia
3
请注意,如果您确切知道需要处理多少数据,把全部数据存储在slice里可能更合适。除非您需要处理的数据量非常大且无法轻松地放入内存。如果需要,确保在使用完数据后释放所有对它的引用,这样它就可以被垃圾回收。当然,使用slice时可以使用range函数。 - Sonia
3
我在这里对“迭代器”的含义有所放宽,以尝试展示一些有用的技巧。很抱歉没有直接回答问题,而是迎合了愿望。在我第二次回答中,我尝试遵循更为严谨的定义。 - Sonia
1
谢谢提供这个例子。我之前没有注意到Go语言中的函数字面量是闭包,现在发现真是太棒了! - Song Gao
我经常为切片这样做,但我刚意识到我不知道如何在映射上构建这些迭代器。有什么建议吗? - weberc2

42

TL;DR: 忘记闭包和通道,它们太慢了。如果您的集合中的各个元素可以通过索引访问,则选择经典的C迭代数组类型。如果不能,实现一个有状态的迭代器。

我需要遍历一些集合类型,其确切的存储实现尚未确定。这以及其他无数原因使我必须将实现细节与客户端抽象分离,因此我进行了各种遍历方法的测试。完整代码在此, 包括一些使用错误作为值的实现。以下是基准测试结果:

  • classic C iteration over an array-like structure. The type provides the methods ValueAt() and Len():

    l := Len(collection)
    for i := 0; i < l; i++ { value := collection.ValueAt(i) }
    // benchmark result: 2492641 ns/op
    
  • Closure style iterator. The collection's Iterator method returns a next() function (a closure over the collection and cursor) and a hasNext boolean. next() returns the next value and a hasNext boolean. Note that this runs much faster than using separate next() and hasNext() closures returning single values:

    for next, hasNext := collection.Iterator(); hasNext; {
        value, hasNext = next()
    }
    // benchmark result: 7966233 ns/op !!!
    
  • Stateful iterator. A simple struct with two data fields, the collection and a cursor, and two methods: Next() and HasNext(). This time the Iterator() method of the collection returns a pointer to a properly initialized iterator structure:

    for iter := collection.Iterator(); iter.HasNext(); {
        value := iter.Next()
    }
    // benchmark result: 4010607 ns/op
    
尽管我很喜欢闭包,但从性能方面考虑,它不可行。至于设计模式,Go语言中的开发者更倾向于使用“惯用方式”来完成任务,这是有充分理由的。另外,对于迭代器,请在Go源代码树中进行grep:因为提到该名称的文件非常少,所以可以确定迭代器并不是Go语言的特色之一。
此外,请查看这个页面:http://ewencp.org/blog/golang-iterators/ 总之,在这里接口没有任何帮助,除非你想定义一些Iterable接口,但这是完全不同的话题。

11
从性能角度来看,这是不可行的。我喜欢这个双关语,但我不确定我同意结论。看着你提供的数字,我发现它们之间的差异只有3倍左右。我的第一反应是,除非你已经对代码进行了分析,并确定了这次迭代对应用程序的性能瓶颈有重大影响,否则你应该随意选择最具表现力的那个。在许多应用程序中,循环内的工作将占主导地位,而这种迭代差异将是可以忽略的。 - GrandOpener
在 Nim 中使用 iterrr 很好玩。https://github.com/hamidb80/iterrr - hamidb80

25
TL;DR: 在Go中,使用迭代器不是一种惯用的方式,应该留给其他语言。
更深入地说,维基百科条目“迭代器模式”开头写道:“在面向对象编程中,迭代器模式是一种设计模式…”。其中有两个警示信号:首先,面向对象编程概念通常无法很好地转化为Go;其次,许多Go程序员并不认为设计模式很重要。第一个段落还包括“迭代器模式将算法与容器分离”,但仅在说明“迭代器[访问]容器元素”之后才这样做。那到底是怎样的呢?如果算法正在访问容器的元素,它几乎不能声称已经分离了。在许多语言中,答案涉及某种泛型,允许语言泛化相似的数据结构。在Go中,接口是答案。通过拒绝访问结构,并要求所有交互都基于行为,接口通过强制执行更严格的算法和对象解耦。
对于最小的迭代器类型,所需的能力是Next方法。通过仅指定此单个方法签名,Go接口可以表示迭代器对象。如果要将容器类型设置为可迭代,则必须通过实现接口的所有方法来满足迭代器接口。(我们只有一个方法,实际上接口通常仅具有单个方法。)这是一个最小的工作示例:
package main

import "fmt"

// IntIterator is an iterator object.
// yes, it's just an interface.
type intIterator interface {
    Next() (value int, ok bool)
}

// IterableSlice is a container data structure
// that supports iteration.
// That is, it satisfies intIterator.
type iterableSlice struct {
    x int
    s []int
}

// iterableSlice.Next implements intIterator.Next,
// satisfying the interface.
func (s *iterableSlice) Next() (value int, ok bool) {
    s.x++
    if s.x >= len(s.s) {
        return 0, false
    }
    return s.s[s.x], true
}

// newSlice is a constructor that constructs an iterable
// container object from the native Go slice type.
func newSlice(s []int) *iterableSlice {
    return &iterableSlice{-1, s}
}

func main() {
    // Ds is just intIterator type.
    // It has no access to any data structure.
    var ds intIterator

    // Construct.  Assign the concrete result from newSlice
    // to the interface ds.  ds has a non-nil value now,
    // but still has no access to the structure of the
    // concrete type.
    ds = newSlice([]int{3, 1, 4})

    // iterate
    for {
        // Use behavior only.  Next returns values
        // but without insight as to how the values
        // might have been represented or might have
        // been computed.
        v, ok := ds.Next()
        if !ok {
            break
        }
        fmt.Println(v)
    }
}

游乐场:http://play.golang.org/p/AFZzA7PRDR

这是接口的基本概念,但在迭代切片时它过于繁琐。在许多情况下,您可以使用内置语言原语直接迭代基本类型来编写 Go 代码,而不是像其他语言中那样使用迭代器。您的代码保持清晰简洁。如果出现复杂情况,请考虑您真正需要哪些功能。您是否需要从某个函数的随机位置发射结果?通道提供了类似 yield 的功能,允许这样做。您是否需要无限列表或惰性求值?闭包非常适用。您是否有不同的数据类型,需要它们透明地支持相同的操作?接口提供了解决方案。由于通道、函数和接口都是第一类对象,因此这些技术都很容易组合使用。那么什么是最符合习惯用法的方式呢?尝试使用不同的技术,熟悉它们,并尽可能以最简单的方式满足您的需求。迭代器在面向对象的意义上几乎从未是最简单的。


2
我同意C++迭代器并不是那么简单。但是与Python生成器或C#中的IEnumerable<T>相比,它们还是有优势的。你最后一段说得很好,也许我需要尝试一些不同的技术来替代传统的迭代器。 - Kugel
1
这是一个非常深入的惊人答案。@Sonia,你有没有考虑写一本“围棋之道”类型的书或博客系列? - Christopher Poile
14
在Go语言中,迭代器非常符合惯用法,标准库中有许多例子,比如我能想到的bufio.Scanner和tar.Reader。 - weberc2
1
干得好,我认为你的答案是这里最好的之一,因为它包括了一个_可枯竭_迭代器的示例。从Next方法返回一个元组而不是单个值的想法简单而有效。 - Brent Pappas
3
你把模式的定义看得太重了。迭代器的主要目的之一是在生成可重用的值时,不在内存中保存整个值。任何做到这一点的东西都可以被视为某种迭代器,Go 绝对支持这一点。 - Alvaro
显示剩余2条评论

4
这是我想到的一种利用通道和goroutine进行操作的方法:
package main

import (
    "fmt"
)

func main() {
    c := nameIterator(3)
    for batch := range c {
        fmt.Println(batch)
    }
}

func nameIterator(batchSize int) <-chan []string {
    names := []string{"Cherry", "Cami", "Tildy", "Cory", "Ronnie", "Aleksandr", "Billie", "Reine", "Gilbertina", "Dotti"}

    c := make(chan []string)

    go func() {
        defer close(c)
        for i := 0; i < len(names); i++ {
            startIdx := i * batchSize
            endIdx := startIdx + batchSize

            if startIdx > len(names) {
                continue
            }
            if endIdx > len(names) {
                c <- names[startIdx:]
            } else {
                c <- names[startIdx:endIdx]
            }
        }    
    }()

    return c
}

我从Rob Pike的Go并发模式演讲中得到了灵感。

https://play.golang.org/p/M6NPT-hYPNd


我喜欢这个图案的外观。我唯一担心的是,如果由于某种原因用户没有完成对生成器的迭代,那么我们就会有一个悬挂的Go协程。如果这是一个长期运行的应用程序,并且会生成大量这些对象,那将成为一个问题。 - JRogerC

4

从container/list包中看,似乎没有办法实现这一点。如果您要迭代对象,则应使用类C的方式。

像这样:

type Foo struct {
...
}

func (f *Foo) Next() int {
...
}

foo := Foo(10)

for f := foo.Next(); f >= 0; f = foo.Next() {
...
}

4
你可以通过为goroutine提供第二个控制消息通道而不泄漏来打破它。在最简单的情况下,它只是一个chan bool。当你想要停止goroutine时,你发送到这个通道。在goroutine内部,你将迭代器的通道发送和控制通道的监听放入select中。 这里是一个例子。 你可以进一步允许不同的控制消息,例如“跳过”。
你的问题非常抽象,因此更具体的例子会更有帮助。

1
这对于一个简单的迭代来说相当牵强。那性能如何? - Kugel
我经常使用这个模型,因为要处理分页数据并且长度任意的数据流时相对较难。另外,当你完成后记得关闭该通道。close是一个持续发送的操作。 - Dustin

3

我在这个主题上发表了一篇文章:

https://serge-hulne.medium.com/iterators-map-filter-reduce-and-list-processing-in-go-golang-implementing-python-functional-2d24d780051f

有一个相关的Git仓库: https://github.com/serge-hulne/iter/tree/main/iterate

主要思想是:

func Fib(n int) chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for i, j := 0, 1; i < n; i, j = i+j, i {
            out <- i
        }
    }()
    return out
}

用于:

fibs = Fib(100)
for i := range Map(fibs) {
    fmt.Printf("i = %6v\n", i)
}

1
如果迭代器的用户在循环中间中断,就会出现内存泄漏问题。我们的Go协程仍然在旋转,通道将保持打开状态。 - Al.exe

1
这里有许多看似不同的解决方案,这意味着似乎没有一种惯用的方法来解决它。我正在学习Go语言,我认为会有一种方法来利用range的功能,但很遗憾,并没有。下面是我想到的解决方案(与上面的某些解决方案相似)。
// Node Basically, this is the iterator (or the head of it) 
// and the scaffolding for your itterable type
type Node struct {
    next *Node
}

func (node *Node) Next() (*Node, bool) {
    return node.next, node.next != nil
}

// Add add the next node
func (node *Node) Add(another *Node) {
    node.next = another
}

这是我如何使用它:

node := &Node{}
node.Add(&Node{})

for goOn := true; goOn; node, goOn = node.Next() {
    fmt.Println(node)
}

或许更优雅的解决方案:

...
func (node *Node) Next() *Node {
    return node.next
}
...

for ; node != nil; node = node.Next() {
    fmt.Println(node)
}

0

在 agilemde.co.uk/libraries.zip 中有一个 ocliterator.go 的实现,以及其他标准库组件的 Go 版本,如日期和随机数。该迭代器提供类似 Java 集合迭代器和 JavaScript generator 函数迭代器的功能。(注意,该迭代器依赖于 ocl.go 通用库)。


0

如果您不需要并发,请不要使用通道。它们被创建用于组织并发流程。除非您正在尝试实现线程安全的迭代器,否则其速度比任何简单实现慢10到100倍。更多详细信息请查看Go通道是如何实现的?

我不知道惯用的方式,只想分享一些您可以遵循的想法。

可能您最喜欢的GitHub集合库已经有了一些迭代它们的方法。

以及您的应用程序可能已经具有函数式风格的Iterator接口,例如hasNext, next := list.Iter()

最好只需遵循您已经拥有的代码风格。可读性和一致性是您的朋友。

就性能而言,如果您将任何重要的工作单元放在循环内部,结果将是相同的。

当您真正需要时,for循环当然会给您更好的性能。

总之,尽可能使用for循环,遵循代码风格,并重复使用已有的抽象。我选择函数式风格来编写我的小型库,因为我没有依赖或风格限制,希望保持一切简单和美好。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接