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 | sum := lo.Sum( |
发现问题了吗?
数据是从里往外流的,你的眼睛也得从里往外读。就像层层包裹的洋葱,让你的眼睛流泪
逻辑明明是「过滤 → 映射 → 求和」,写出来却是「求和(映射(过滤(...)))」,阅读顺序和数据流向正好相反。括号一层套一层,改一行还得数括号配不配对。我以前一直以为这是 lo 作者图省事,后来才知道不是。
现在看看用 seq 写同样的逻辑:
1 | sum := seq.From([]int{1, 2, 3, 4, 5, 6}). |
从左到右,数据怎么流,代码就怎么读。seq 是一个基于 Go 标准库 iter.Seq 的链式惰性集合库。
为什么 Go 等了十年才能这么写?
很多人以为 lo 的「从内往外」是作者偷懒,其实不是。这是 Go 泛型语法卡住的结果。
Go 1.27 之前,方法不能声明自己的类型参数。也就是说,一个想把 Seq[int] 变成 Seq[string] 的 .Map() 方法,在语法层面根本写不出来:
1 | // Go 1.27 之前:这一行编译不过。 |
方法带不了类型参数,lo 这类库就只能把所有操作做成顶层自由函数,于是有了那串嵌套括号。不是谁懒,是当时只有这一条路。
泛型方法提案 golang/go#77273 在 Go 1.27 里落地,这条限制才算解除。链式、惰性、还能被编辑器自动补全的管道,到这时候才写得出来。seq 就是冲着这个来的。
一句大实话:
seq需要 Go 1.27,目前还是go1.27rc1。提案官方已经接受并实现了,只差正式版发布。把这个库的Go版本设定在 1.27,是为这条方法链付的过路费。
它到底有多省心?来看一组实战
下面这些例子我都从 seq 的测试用例里抠出来的,每行都是真跑得通的。
例 1:数据分析三连——分组、去重、统计
把一串数字按奇偶分组:
1 | groups := seq.From([]int{1, 2, 3, 4, 5, 6}). |
去重之后再求和:
1 | total := seq.Numbers(seq.From([]int{1, 2, 2, 3, 3, 3})). |
例 2:惰性求值,无限序列也能玩
seq 是惰性的。不调用终结操作,整条管道一行都不跑。所以你能定义一个无限序列,再只取你要的那几个:
1 | // 从 1 开始,每次翻倍:1, 2, 4, 8, 16... |
惰性带来的真正好处是短路。Find、Any、All 拿到答案就停,后面的元素根本不碰。百万级数据里找第一个满足条件的,不会陪你白跑到底。
例 3:前缀和?一个 Scan 搞定
1 | // 发射每一步的累积值(含初始值),典型的前缀和 |
例 4:滑动窗口,时序数据的最爱
做监控、做时序分析的应该会喜欢,滑动窗口是内置的:
1 | // 窗口大小 3,步长 1 |
定长分块同样开箱即用:
1 | chunks := seq.From([]int{1, 2, 3, 4, 5}).Chunk(2) |
例 5:两个序列配对——Zip 的优雅
把两个序列「拉链」拼起来,做成 map:
1 | m := seq.ZipMap( |
或者配对后用自定义函数合并,还能顺手改变类型:
1 | merged := seq.ZipWith( |
短的那个序列耗尽就停,完全不用手动判长度。
藏在优雅背后的「神级设计」
如果你只当它是个链式语法糖,那有点可惜。seq 真正有意思的地方,是它把 Go 泛型的那些限制,反过来当成了设计的骨架。
零成本类型转换
seq 的核心类型不是 struct 包装,而是标准库迭代器的定义类型:
1 | type Seq[T any] iter.Seq[T] // 本质就是 func(yield func(T) bool) |
意思是任何 iter.Seq[T] 都能零成本当作 seq.Seq[T] 使用,反过来也行。结果直接喂给 slices.Collect、maps.Keys 都没问题,没有任何转换开销。
一句话就能判断:它该是方法还是函数
先说清楚一个词,下面会反复用到:自由泛型函数。
「自由」是相对「方法」说的,指不绑在某个接收者上的顶层函数;「泛型」指它带自己的类型参数和约束。两者合起来,就是 func Distinct[T comparable](s Seq[T]) Seq[T] 这种写法——它在包级别,自己声明 [T comparable],把序列当普通参数收进来,而不是写成 s.Distinct() 挂在 Seq 上。Go 的 slices、maps 标准库包里那些 slices.Sort[S ...]、maps.Keys[...] 全是这个形态。
为什么有些操作只能这么写,看下面这条规则就懂了。
因为 Seq[T any] 把 T 固定成了 any,方法没办法反过来给 T 加约束。于是我干脆立了一条判断标准:
问一句:这个操作约束了
T本身吗?约束了,就得是自由泛型函数;没约束,才能做成方法。
- 需要
comparable、cmp.Ordered的操作(如Distinct、Max、Sum)→ 自由泛型函数 - 只用方法自带类型参数的操作(如
GroupBy[K]、Map[U])→ 方法
整套 API 谁是方法、谁是函数,全按这一条推出来,没有例外。不用记,照着问一遍就知道答案,比拍脑袋舒服多了。
但是这样不就有回退到lo这个泛型库的痛点了么?
最妙的一招:用「子类型」把链式找回来,解决痛点
这条规则有个副作用:Sum、Distinct 变成自由泛型函数之后,管道又退回从内往外读了(Sum(Distinct(...)))。我的解法是引入三个约束型子类型,把约束提前钉在类型上:
1 | // 用 Numbers/Ordered/Comparable 进入约束世界,之后全程方法链 |
Numbers() 一进门,后面的 .Distinct()、.Sum() 又变回方法了。Numeric ⊂ Ordered ⊂ comparable 这层关系还能用 .Ordered()、.Comparable() 往下降,链一次都不断。绕是绕了点,但在 Go 现有的类型系统里,这大概是能想到的最干净的招了。
它老老实实告诉你,哪些事它不做
现在的开源项目动不动就「全能」「最强」,seq 的文档反而把不做的事一条条列了出来:
- 不做错误处理链,在惰性迭代器上没找到优雅解,留作以后的独立提案
- 不做并行执行,这版只有顺序惰性管道
- 不提供「就地改原数据」的操作,
seq全程只读、不动你的原切片,每一步都产出新序列 - 不做任意深度展平,Go 类型系统表达不了,只给一层
Flatten - 元组到
Tuple4封顶,再多请自己定义 struct - 不是所有操作都惰性。像排序、逆序、取末尾 n 个、滑动窗口这类要回看整条序列才能出结果的操作,会先把源收集成 slice 再处理,做不到边读边算。库里专门用一个
intermediate_materializing.go文件把它们集中放着,注释也标了「内部物化」——该惰性的惰性,该物化的老实物化,不糊弄
它甚至把 gofmt 在 go1.27rc1 上会对泛型方法签名误报这件事也记下来了,说明这是 RC 阶段工具链的 bug,编译和测试都过,正式版应该会消失。
一个肯告诉你边界在哪、连工具链 bug 都标注清楚的库,用起来心里有底。
上手只需一行
1 | go get github.com/smallnest/seq |
记得准备 Go 1.27 工具链,当前是
go1.27rc1。
趁手的操作我按用途分了组,扫一眼就知道有没有你要的。
造序列From(切片)、Of(可变参数)、Empty、Range / RangeStep(整数区间)、Repeat / RepeatInf(重复)、Generate / Iterate(无限生成)、FromChannel(channel)、FromMap(map → 键值序列)
变换 / 过滤(惰性,边读边算)Map(换类型)、FlatMap、FilterMap(过滤+变换合一)、Filter / Reject、Take / Drop、TakeWhile / DropWhile、Scan(前缀累积)、Peek(旁路调试)、Intersperse(插分隔)、Concat(尾部追加)、DistinctBy(按 key 去重)
取子集 / 排序(需回看,内部物化)TakeRight / DropRight、Slice(区间)、Init / Tail、SortBy / Reverse、Chunk(定长分块)、Window(滑动窗口)、Enumerate(配索引)
聚合 / 归约 / 统计Collect(收成 slice)、Fold / Reduce、Count / CountBy、SumBy / MeanBy、MaxBy / MinBy / MaxByKey / MinByKey、Join(拼字符串)、ForEach / ForEachIndexed
查找 / 判断(短路)Find / FindLast / FindIndex / FindLastIndex、Any / All / None、First / Last / Nth、IsEmpty
分组 / 分区GroupBy、KeyBy(建索引)、GroupCount(分组计数)、Partition(按谓词二分)、Span(首个不满足处切)
去重 / 集合运算(约束 comparable,自由泛型函数)Distinct、Contains / IndexOf / LastIndexOf、CountValues / ToSet、Union / Intersect / Difference / SymmetricDifference、Compact(去零值)、Without(排除指定值)、Equal
数值 / 排序(约束 Ordered / Numeric,自由泛型函数)Max / Min、Sum / Product / Mean、Sort
想链式调用上面这些约束操作?走子类型入口Comparable / Ordered / Numbers 进入,之后 .Distinct()、.Max()、.Sum() 都变回方法,再用 .Ordered() / .Comparable() / .Seq() 降级
多序列组合Zip / Zip3 / Zip4、ZipWith(配对并合并)、ZipMap(配成 map)、Unzip(拆分)、Flatten(展平一层)、Concat / Interleave(交错)
键值序列 Seq2MapValues / MapKeys / Map、Filter、Keys / Values、ToMap / CollectPairs / Entries、Associate(Seq[T] → Seq2)
完整签名、每条一句语义,以及设计文档都在仓库里:
写在最后
lo 是个好库,在语言能力不够的那些年,它已经做到了能做的极限。「从内往外」是它绕不过去的坎,不怪它。
seq 站在 Go 1.27 泛型方法这块新地基上,重新试了一次:Go 的集合操作能写得多顺。第一次写完 From(...).Filter(...).Map(...).Sum() 那一行,我盯着屏幕想了两秒——这真的是 Go语言吗?
觉得有意思的话,去仓库给作者点个 Star。
