逆用生成器消费内部迭代器

创建于 3/18/2026

内部迭代和外部迭代的各种相关想法

这些想法是在 Golang 发布迭代器的时候产生的,最后一节则是前两天刚了解的。前两节与标题无关。

内部迭代与外部迭代

内部迭代(或者说推模式,本文不做区分),迭代器接受回调函数并以逐个元素调用之。而外部迭代(拉模式),迭代器通常拥有类似 next 的方法,由调用方决定何时获取。

Golang 的迭代器是内部迭代,这非常少见。C++、Java、Python、JavaScript 等语言的都是外部迭代。

不难发现,外部迭代下,消费迭代器比较容易,直接一个个用 next 拉取就行了,想什么时候拉取都行;编写迭代器则需手写状态机,一次必须推送一个值,无法直接复用语言本身的执行逻辑。而内部迭代正好相反。所以,许多语言提供了生成器,让编写迭代器也能复用本身的执行逻辑,想什么时候推送(yield)都行。

那么,内部迭代有什么优势?如果内联优化足够,有时会更快。例如 Rust 连接两个迭代器的 Iterator::chain,若使用外部迭代(for 循环),则每次循环都需要判断前一个迭代器是否已结束;若使用内部迭代(for_each 方法),则会生成两个循环。

内部迭代的劣势就严重了:它无法实现 zip!要实现 zip,必须同时或交替遍历两个迭代器,故而至少有一个需要拉模式,而内部迭代不支持拉模式。反之没有这个问题,因为用外部循环可以实现内部循环,就像 Java 的 Stream 那样。

那么,Golang 是如何解决这个问题的呢?有个 iter.Pull 函数可以把推模式转为拉模式,起到了生成器的功能,还无需新语法。它是怎么实现的?看看源码——这个函数竟然起了个协程,每次 next 就运行协程,yield 就暂停!虽然你协程开销小,但也不能这么搞吧?这种击穿一切优化的做法,我还能说什么呢?

有栈协程与生成器

这一做法咋一看非常离谱,细细一想还挺合理的。JavaScript(二者没啥区别,以下只说前者)的生成器同样是在维持推模式语法的同时,返回拉模式的外部迭代器;同样是每次 next 就继续运行生成器,yield 就暂停。这与 iter.Pull 的逻辑如出一辙!

不妨把 iter.Seq 看作(推模式的)生成器,把 iter.Pull 看作将生成器转为(拉模式的)迭代器的办法。在 JavaScript 中,定义生成器并返回迭代器,二者一次完成,不可分割;Golang 则分开了它们。

从这个角度看,生成器本就是一种协程。JavaScript 贯彻了无栈协程,而 Golang 崇尚有栈协程,那把生成器一并搞成有栈协程,似乎也没有什么问题。有栈协程的语法没有传染性,故而可以用库实现,无需专门的 yield 语法。更有他山之石:Lua 的协程没有自带调度器,暴露出的接口天然就与生成器非常相似。对上了,都对上了!

别急,有反转

那我就想了,编写外部迭代器可以用生成器简化,那消费内部迭代器能否简化呢?

当然是可以的。只需逆用生成器,让 yield 随便什么值 返回迭代器的结果(相当于 next 方法)就行。代码如下:

ts
function consumer<T, R>(gen: Generator<unknown, R, IteratorResult<T>>) {
    let result: IteratorResult<unknown, R> = gen.next()
    return {
        f(value: T) {
            if (!result.done) result = gen.next({ done: false, value })
            return !result.done
        },
        run() {
            while (!result.done) result = gen.next({ done: true, value: undefined })
            return result.value
        }
    }
}

具体逻辑很简单,直接看用例。consumer 函数把生成器变成了一个函数 f,每次调用函数都继续运行生成器,并把参数包装为迭代器返回值的形式发给生成器。最后用 run 不断给生成器发送结束的值,直到生成器返回。

ts
const c = consumer(function* () {
    let x: IteratorResult<string> = yield 0
    if (x.done) return ''
    let output = x.value
    while (!(x = yield 0).done) output += ',' + x.value
    return output
}())

const arr = ['abc', 'd', 'efg']
arr.forEach(c.f)
console.log(c.run()) // 'abc,d,efg'

如此一来,就能用外部迭代的形式来消费内部迭代器了,仅需把 .next() 换成 yield 0 就行。甚至这种做法也是可组合的,可以用 yield* 某个使用了yield的函数() 实现。

前人们都说过了

这个想法不太可能我首先想出来的。拷打 LLM 之后,我发现了 Iteratee 这种东西。Yesod 的这些文章做了通俗的介绍。它似乎是为了处理流式的 IO(主要是输入处理)而设计的,用来取代 Lazy I/O。因此,介绍它的文章大多把注意力放在了 IO、资源管理之类,而我关心的则是流处理的部分,与其本身的侧重点有些偏差。由于我对此完全不了解(前两天我还没听说过这个概念!),更是没有用过,本节内容大多只根据文档写出,很可能有误,希望读者指正。

Iteratee 是流的消费者,可以增量地接收输入(每次可接收若干输入或 EOF),处理后给出结果。它类似 fold 的参数的抽象,不过把中间状态封装为了续延函数。举个例子,「返回所有输入之和」就是一个简单的 Iteratee。其接口就像生成器或者迭代器(Iterator),但身份相反:每次调用迭代器,可以获取一个新值,若已结束则无值可取;而每次调用 Iteratee,需要提供一些新值,若结束则无需提供并且可以拿到最终结果。而在功能上,我感觉有点像 Java 的 Collector?不过我没有找到到此类说法。显然,它是(或者说 Enumerator 是)推模式的。

Iteratee 是 Monad,其效应包括接收输入、返回结果和报错。它还是 Monad Transformer,可以在读取和处理过程中产生其他副作用。其 Monad 语义是串行,先让一个 Iteratee 读取,得出结果后让另一个继续读取剩余部分。并行也可以实现(单生产者、多消费者),即让两个 Iteratee 读取同一个输入,然后给出二者的结果,其中无需将输入遍历两次(感觉很像 Collector 啊!迭代器无法如此组合)。通过 Monad Transformer,它还可以自然地合并多个输入(多生产者、单消费者),把另一个的效应 lift 一下就行(这也太精妙了,我没能力想到类比)。故而 Iteratee 可以组合。

与 Iteratee 紧密相关的概念有 Enumerator 和 Enumeratee。Enumerator 是生产者,同样可组合(例如接在一起)。给它一个 Iteratee,它可以把数据喂给 Iteratee。其功能与迭代器差不多,只是出发点相反。Enumeratee 则是转换器,map、filter 之类都是其实例。它可以与 Enumerator 组合以修改其提供的值,或者与 Iteratee 组合以修改其接收的值,或者可以自相组合(类比函数复合)。我觉得其功能有点像 Clojure 的 transducer,不过也没有找到到此类说法。

将迭代器的效应(yield)写成命令式,JavaScript 的生成器是之。那 Iteratee 的效应写成命令式呢?很遗憾,JavaScript 等语言并不具备此抽象(当然,用 Koka 很容易写,在十行内就能写出)。不过,生成器并不只有发出值的功能:yield 表达式是有值的,可以接收输入;而且生成器也可以返回结果。它更接近没有调度器的协程,可以与 Lua 类比。这使得 async/await 可以 polyfill,也让我的灵感顺理成章地与 Iteratee 接近。

既然生成器同时支持接收与发送,那 Iteratee 能不能也更进一步,同时支持二者呢?conduitpipes 就是这么做的。它们都有两个关键的原语:await 用于接收上游的输入;yield 用于向下游发送输出。生产者只用 yield,消费者只用 await,转换器二者都用。这允许了一个非常牛逼的设计:让生产者、转换器、消费者全部使用同一种类型(暂将此类型称为「管道」),靠类型参数区分!管道有输入、输出、结果三种类型:不 await,那就是输入类型为 ();不 yield,那就是输出类型为 Void。类型统一了,那么组合的运算符也可以统一,生产者与转换器、生产者与消费者、转换器与消费者,以及转换器自相组合,通通用同一个运算符,事实上也就是上游管道的输出对着下游管道的输入。全部组合完了,合成了个既不接收也不发送的「管道」,就可以运行得到最终结果了。

Iteratee 只有接收,不断等待输入,是推模式。但管道既可接收又可发送,是推模式还是拉模式?答案是拉模式。运行组合而成的管道时,会运行最后一个被组合的管道,若它接收值(await)就去前面的管道中要,等前面发送了值(yield)就传给它。过程由下游驱动,所以是拉模式。

需要注意,JavaScript 生成器的接收和发送都是朝着下游的,且接收发送一次搞定;而 conduit 是向上游接收、对下游发送,且有两个不同的原语。二者并不一致。不过,若是要朝某一端既接收又发送,pipes 是支持的。其管道可以向上游、下游分别发送、接收,与收发相关的共有四个类型参数、四个原语。也就是说,所谓的上下游其实完全对称。如果把原语反过来用,一切结果不变。