等了十年的 Go 链式管道,终于来了:seq 让你像写 Scala 一样写 Go

seq 库的一行代码,从左读到右。写过 lo.Map(lo.Filter(...)) 的人,大概会愣一下。

这个库的开发我昨天晚上在微信上了做了直播,展示我如何使用Loop Engineering 从 0 构建出来。使用的是火山引擎的coding plan, GLM-5.2模型,花了 2 小时,耗费Token 7.23M。你可以查看直播回放:

你也可以访问这个库的项目地址: https://github.com/smallnest/seq, tasks目录中有需求文档和设计文档。

先看一个让你眼花的写法

要做的事很简单:把切片里的偶数挑出来,平方,求和。

用过 Go 里最火的工具库 samber/lo 的话,你多半会写成这样:

1
2
3
4
5
6
sum := lo.Sum(
lo.Map(
lo.Filter([]int{1, 2, 3, 4, 5, 6}, func(x int, _ int) bool { return x%2 == 0 }),
func(x int, _ int) int { return x * x },
),
)

发现问题了吗?

数据是从里往外流的,你的眼睛也得从里往外读。就像层层包裹的洋葱,让你的眼睛流泪

逻辑明明是「过滤 → 映射 → 求和」,写出来却是「求和(映射(过滤(...)))」,阅读顺序和数据流向正好相反。括号一层套一层,改一行还得数括号配不配对。我以前一直以为这是 lo 作者图省事,后来才知道不是。

现在看看用 seq 写同样的逻辑:

1
2
3
sum := seq.From([]int{1, 2, 3, 4, 5, 6}).
Filter(func(x int) bool { return x%2 == 0 }).
SumBy(func(x int) int { return x * x })

从左到右,数据怎么流,代码就怎么读。seq 是一个基于 Go 标准库 iter.Seq 的链式惰性集合库。


为什么 Go 等了十年才能这么写?

很多人以为 lo 的「从内往外」是作者偷懒,其实不是。这是 Go 泛型语法卡住的结果。

Go 1.27 之前,方法不能声明自己的类型参数。也就是说,一个想把 Seq[int] 变成 Seq[string].Map() 方法,在语法层面根本写不出来:

1
2
3
// Go 1.27 之前:这一行编译不过。
// 编译器会告诉你:方法不允许有自己的 [U any]。
func (s Seq[T]) Map[U any](f func(T) U) Seq[U]

方法带不了类型参数,lo 这类库就只能把所有操作做成顶层自由函数,于是有了那串嵌套括号。不是谁懒,是当时只有这一条路。

泛型方法提案 golang/go#77273 在 Go 1.27 里落地,这条限制才算解除。链式、惰性、还能被编辑器自动补全的管道,到这时候才写得出来。seq 就是冲着这个来的。

一句大实话:seq 需要 Go 1.27,目前还是 go1.27rc1。提案官方已经接受并实现了,只差正式版发布。把这个库的Go版本设定在 1.27,是为这条方法链付的过路费。


它到底有多省心?来看一组实战

下面这些例子我都从 seq 的测试用例里抠出来的,每行都是真跑得通的。

例 1:数据分析三连——分组、去重、统计

把一串数字按奇偶分组:

1
2
3
4
5
6
7
8
groups := seq.From([]int{1, 2, 3, 4, 5, 6}).
GroupBy(func(x int) string {
if x%2 == 0 {
return "even"
}
return "odd"
})
// groups => map[string][]int{"even": {2,4,6}, "odd": {1,3,5}}

去重之后再求和:

1
2
3
4
total := seq.Numbers(seq.From([]int{1, 2, 2, 3, 3, 3})).
Distinct().
Sum()
// total => 6 (1+2+3)

例 2:惰性求值,无限序列也能玩

seq 是惰性的。不调用终结操作,整条管道一行都不跑。所以你能定义一个无限序列,再只取你要的那几个:

1
2
3
4
5
6
// 从 1 开始,每次翻倍:1, 2, 4, 8, 16...
// 这是个无限序列,但 Take(5) 只会驱动它跑 5 次
powers := seq.Iterate(1, func(x int) int { return x * 2 }).
Take(5).
Collect()
// powers => [1, 2, 4, 8, 16]

惰性带来的真正好处是短路。FindAnyAll 拿到答案就停,后面的元素根本不碰。百万级数据里找第一个满足条件的,不会陪你白跑到底。

例 3:前缀和?一个 Scan 搞定

1
2
3
4
5
// 发射每一步的累积值(含初始值),典型的前缀和
prefix := seq.From([]int{1, 2, 3, 4}).
Scan(0, func(acc, x int) int { return acc + x }).
Collect()
// prefix => [0, 1, 3, 6, 10]

例 4:滑动窗口,时序数据的最爱

做监控、做时序分析的应该会喜欢,滑动窗口是内置的:

1
2
3
// 窗口大小 3,步长 1
wins := seq.From([]int{1, 2, 3, 4, 5}).Window(3, 1)
// => [1,2,3], [2,3,4], [3,4,5]

定长分块同样开箱即用:

1
2
chunks := seq.From([]int{1, 2, 3, 4, 5}).Chunk(2)
// => [1,2], [3,4], [5]

例 5:两个序列配对——Zip 的优雅

把两个序列「拉链」拼起来,做成 map:

1
2
3
4
5
m := seq.ZipMap(
seq.From([]string{"a", "b", "c"}),
seq.From([]int{1, 2, 3}),
)
// m => map[string]int{"a": 1, "b": 2, "c": 3}

或者配对后用自定义函数合并,还能顺手改变类型:

1
2
3
4
5
6
merged := seq.ZipWith(
seq.From([]int{1, 2}),
seq.From([]string{"x", "y"}),
func(a int, b string) string { return b + string(rune('0'+a)) },
).Collect()
// merged => ["x1", "y2"]

短的那个序列耗尽就停,完全不用手动判长度。


藏在优雅背后的「神级设计」

如果你只当它是个链式语法糖,那有点可惜。seq 真正有意思的地方,是它把 Go 泛型的那些限制,反过来当成了设计的骨架。

零成本类型转换

seq 的核心类型不是 struct 包装,而是标准库迭代器的定义类型:

1
2
type Seq[T any]     iter.Seq[T]      // 本质就是 func(yield func(T) bool)
type Seq2[K, V any] iter.Seq2[K, V]

意思是任何 iter.Seq[T] 都能零成本当作 seq.Seq[T] 使用,反过来也行。结果直接喂给 slices.Collectmaps.Keys 都没问题,没有任何转换开销。

一句话就能判断:它该是方法还是函数

先说清楚一个词,下面会反复用到:自由泛型函数

「自由」是相对「方法」说的,指不绑在某个接收者上的顶层函数;「泛型」指它带自己的类型参数和约束。两者合起来,就是 func Distinct[T comparable](s Seq[T]) Seq[T] 这种写法——它在包级别,自己声明 [T comparable],把序列当普通参数收进来,而不是写成 s.Distinct() 挂在 Seq 上。Go 的 slicesmaps 标准库包里那些 slices.Sort[S ...]maps.Keys[...] 全是这个形态。

为什么有些操作只能这么写,看下面这条规则就懂了。

因为 Seq[T any]T 固定成了 any,方法没办法反过来给 T 加约束。于是我干脆立了一条判断标准:

问一句:这个操作约束了 T 本身吗?约束了,就得是自由泛型函数;没约束,才能做成方法。

  • 需要 comparablecmp.Ordered 的操作(如 DistinctMaxSum)→ 自由泛型函数
  • 只用方法自带类型参数的操作(如 GroupBy[K]Map[U])→ 方法

整套 API 谁是方法、谁是函数,全按这一条推出来,没有例外。不用记,照着问一遍就知道答案,比拍脑袋舒服多了。

但是这样不就有回退到lo这个泛型库的痛点了么?

最妙的一招:用「子类型」把链式找回来,解决痛点

这条规则有个副作用:SumDistinct 变成自由泛型函数之后,管道又退回从内往外读了(Sum(Distinct(...)))。我的解法是引入三个约束型子类型,把约束提前钉在类型上:

1
2
3
4
// 用 Numbers/Ordered/Comparable 进入约束世界,之后全程方法链
sum := seq.Numbers(seq.From([]int{1, 2, 2, 3})).
Distinct().
Sum()

Numbers() 一进门,后面的 .Distinct().Sum() 又变回方法了。Numeric ⊂ Ordered ⊂ comparable 这层关系还能用 .Ordered().Comparable() 往下降,链一次都不断。绕是绕了点,但在 Go 现有的类型系统里,这大概是能想到的最干净的招了。


它老老实实告诉你,哪些事它不做

现在的开源项目动不动就「全能」「最强」,seq 的文档反而把不做的事一条条列了出来:

  • 不做错误处理链,在惰性迭代器上没找到优雅解,留作以后的独立提案
  • 不做并行执行,这版只有顺序惰性管道
  • 不提供「就地改原数据」的操作,seq 全程只读、不动你的原切片,每一步都产出新序列
  • 不做任意深度展平,Go 类型系统表达不了,只给一层 Flatten
  • 元组到 Tuple4 封顶,再多请自己定义 struct
  • 不是所有操作都惰性。像排序、逆序、取末尾 n 个、滑动窗口这类要回看整条序列才能出结果的操作,会先把源收集成 slice 再处理,做不到边读边算。库里专门用一个 intermediate_materializing.go 文件把它们集中放着,注释也标了「内部物化」——该惰性的惰性,该物化的老实物化,不糊弄

它甚至把 gofmtgo1.27rc1 上会对泛型方法签名误报这件事也记下来了,说明这是 RC 阶段工具链的 bug,编译和测试都过,正式版应该会消失。

一个肯告诉你边界在哪、连工具链 bug 都标注清楚的库,用起来心里有底。


上手只需一行

1
go get github.com/smallnest/seq

记得准备 Go 1.27 工具链,当前是 go1.27rc1

趁手的操作我按用途分了组,扫一眼就知道有没有你要的。

造序列
From(切片)、Of(可变参数)、EmptyRange / RangeStep(整数区间)、Repeat / RepeatInf(重复)、Generate / Iterate(无限生成)、FromChannel(channel)、FromMap(map → 键值序列)

变换 / 过滤(惰性,边读边算)
Map(换类型)、FlatMapFilterMap(过滤+变换合一)、Filter / RejectTake / DropTakeWhile / DropWhileScan(前缀累积)、Peek(旁路调试)、Intersperse(插分隔)、Concat(尾部追加)、DistinctBy(按 key 去重)

取子集 / 排序(需回看,内部物化)
TakeRight / DropRightSlice(区间)、Init / TailSortBy / ReverseChunk(定长分块)、Window(滑动窗口)、Enumerate(配索引)

聚合 / 归约 / 统计
Collect(收成 slice)、Fold / ReduceCount / CountBySumBy / MeanByMaxBy / MinBy / MaxByKey / MinByKeyJoin(拼字符串)、ForEach / ForEachIndexed

查找 / 判断(短路)
Find / FindLast / FindIndex / FindLastIndexAny / All / NoneFirst / Last / NthIsEmpty

分组 / 分区
GroupByKeyBy(建索引)、GroupCount(分组计数)、Partition(按谓词二分)、Span(首个不满足处切)

去重 / 集合运算(约束 comparable,自由泛型函数)
DistinctContains / IndexOf / LastIndexOfCountValues / ToSetUnion / Intersect / Difference / SymmetricDifferenceCompact(去零值)、Without(排除指定值)、Equal

数值 / 排序(约束 Ordered / Numeric,自由泛型函数)
Max / MinSum / Product / MeanSort

想链式调用上面这些约束操作?走子类型入口
Comparable / Ordered / Numbers 进入,之后 .Distinct().Max().Sum() 都变回方法,再用 .Ordered() / .Comparable() / .Seq() 降级

多序列组合
Zip / Zip3 / Zip4ZipWith(配对并合并)、ZipMap(配成 map)、Unzip(拆分)、Flatten(展平一层)、Concat / Interleave(交错)

键值序列 Seq2
MapValues / MapKeys / MapFilterKeys / ValuesToMap / CollectPairs / EntriesAssociate(Seq[T]Seq2)

完整签名、每条一句语义,以及设计文档都在仓库里:


写在最后

lo 是个好库,在语言能力不够的那些年,它已经做到了能做的极限。「从内往外」是它绕不过去的坎,不怪它。

seq 站在 Go 1.27 泛型方法这块新地基上,重新试了一次:Go 的集合操作能写得多顺。第一次写完 From(...).Filter(...).Map(...).Sum() 那一行,我盯着屏幕想了两秒——这真的是 Go语言吗?

觉得有意思的话,去仓库给作者点个 Star。