Contents

Go 里该不该用 Map/Filter/Reduce:从 lo、slices 到迭代器

Go 进入泛型时代后,集合处理基本分成两派:

  • 函数式风格:用 samber/lo(或类似库)把集合操作写成 Map/Filter/Reduce
  • 循环派:继续用 for,把分配、错误处理、提前退出都放在一眼能看全的循环里。

从 Go 1.21 起,标准库通过 slices / maps 新增了一批工具函数;但 Map/Filter/Reduce 仍然不在其中。原因不在于做不出来,而是 Go 更看重三点:读起来直观、性能开销可见、错误处理显式

在 Go 1.21 之前,很多常见操作要么得自己写循环,要么依赖第三方库。如今,slicesmaps 已经提供了一批最基础的工具函数(判断、拷贝、删除、排序等)。

samber/lo 写法 Go 标准库 (1.21+) 备注
lo.Contains(s, x) slices.Contains(s, x) 无需依赖第三方库
lo.Reverse(s) slices.Reverse(s) 标准库为原地修改,不产生新切片

一个最小示例:

package main

import (
	"fmt"
	"slices"
)

func main() {
	s := []int{1, 2, 3}
	fmt.Println(slices.Contains(s, 2)) // true

	slices.Reverse(s)                  // 原地修改
	fmt.Println(s)                     // [3 2 1]
}

建议

  • 标准库能做的就优先用标准库:依赖更少、API 更稳定、团队共识成本更低。
  • 别默认“标准库一定更快”:很多时候差异取决于你是否预分配、是否产生中间切片、是否触发逃逸等。

不少人会问:“为什么 Go 不愿意支持 Map/Reduce/Filter?别的语言也都有 for 循环啊。”

Go 不做这件事,与其说是"技术上做不到",不如说是一个长期的工程取舍:把更多细节留在显式代码里,让团队在性能、调试、错误语义上少踩坑。

典型的 Map/Filter即时求值(eager):每一步都会把结果“落到一个新切片里”。

for 写,你可以把分配过程直接暴露出来,甚至复用 buffer:

func Map[T any, U any](src []T, f func(T) U) []U {
	dst := make([]U, len(src))
	for i, v := range src {
		dst[i] = f(v)
	}
	return dst
}

func Filter[T any](src []T, pred func(T) bool) []T {
	dst := make([]T, 0, len(src)) // 预估上限:len(src)
	for _, v := range src {
		if pred(v) {
			dst = append(dst, v)
		}
	}
	return dst
}

如果你把它们链起来用:

out := Map(Filter(data, isEven), transform)

这里的中间切片会产生实打实的分配和拷贝,也会带来 GC 压力。Go 官方对此保持谨慎,核心是不想让这些开销变成"你看不见、但默默发生"的默认行为。

想直观看到分配情况,可以跑个小基准测试:

func Benchmark_FilterThenMap(b *testing.B) {
	data := make([]int, 1_000_000)
	for i := range data {
		data[i] = i
	}
	b.ReportAllocs()
	b.ResetTimer()

	for i := 0; i < b.N; i++ {
		_ = Map(Filter(data, func(x int) bool { return x%2 == 0 }), func(x int) int { return x * 2 })
	}
}

(你通常会看到:链式调用越多,分配就越多。这不是 lo 的问题,是 eager 风格的天然代价。)

说明:上面的 benchmark 片段是为了展示“分配趋势”,实际跑基准需要补上 import "testing",并保证 Map/Filter 在同一个包内可见。

在 Go 里,Map 一旦要返回 error,链式写法很快就写不下去了:

你要么定义:

func MapE[T any, U any](src []T, f func(T) (U, error)) ([]U, error)

然后每一步都得判断:

out1, err := MapE(data, parse)
if err != nil { return err }
out2, err := MapE(out1, transform)
if err != nil { return err }

这时所谓“链式优雅”基本就没了。相反,一个 for 循环可以把错误处理、提前退出、打点、统计都写得很顺手:

out := make([]User, 0, len(rows))
for _, r := range rows {
	u, err := parseUser(r)
	if err != nil {
		return nil, err
	}
	out = append(out, u)
}

所以 Go 团队通常宁愿你写清楚这段循环,也不愿提供一个“看起来优雅,但一碰到 error 就很尴尬”的标准库 Map

for 循环很难玩出花来,这反而是好事:团队 code review 和长期维护成本都更低。但对标准库而言,一旦引入函数式管道,立刻会引出"链多长算过度"“分配策略怎么定"“错误怎么传递"“并发语义要不要加"等一系列争论,很难在 API 设计上达成统一。

很多语言更鼓励你写“我想要什么”(What):

  • 过滤活跃用户 → 取分数 → 求和
// JavaScript:一条链把意图写完
const result = data
  .filter(user => user.active)
  .map(user => user.score)
  .reduce((a, b) => a + b, 0);

这类写法的好处是“读起来像一句话”,把索引、扩容、临时变量都藏在实现里。

Go 更偏向你写“我怎么做”(How):哪一步分配了切片、哪里会提前退出、错误怎么处理、怎么打点、怎么限流,都能在循环里直接看到。它并不反对抽象,但更在意抽象不要把成本与语义藏起来。

很多语言之所以愿意提供丰富的 map/filter/reduce,是因为它们的生态或编译器本身就更适合这种风格:

  • JavaScript/TypeScript:函数是一等公民,和回调/Promise/异步流天然兼容;链式是默认的表达方式。
  • Rust:迭代器链能靠编译器做非常深的优化(所谓零成本抽象),很多时候性能不输手写循环。
  • Java Stream:很大一部分价值来自并行流(parallel stream)。让“并行化”变成一个库层面的选项,而不是每个人都手写线程池。

但在 Go 里,并发更多是通过 goroutine/chan 在控制流层面表达的。一旦把并发语义塞进 Map/Reduce,API 反而会变得难以统一:到底是顺序执行还是并行?并行时怎么保证顺序?遇到错误是立刻中断还是全部收集?这些问题一旦写进标准库,再想改就难了。

Go 的很多设计都在帮你减少线上踩坑:

  • 性能与可预测性:Go 更强调"你能看出哪里有开销”。链式抽象一旦普及,中间分配、闭包捕获、逃逸这些细节更容易被忽视。
  • 调试体验:在链式调用里打断点往往更别扭;而 for 循环每一步都很好断点,变量也很好观察。
  • 编译器负担:要把 Map/Reduce 写得又快又省分配,往往需要更激进的内联、逃逸分析、闭包优化。Go 编译器追求的是编译速度与实现复杂度的平衡,不太愿意把语言/标准库往"必须靠重度优化才好用"的方向推。

Go 诞生时(2009 年),不少主流语言正朝着更“厚”的方向演进。Go 的设计者刻意把语言收紧,推崇“少即是多”,其中一个出发点就是降低大型团队的风格分裂。

Rob Pike 有句很典型的话(常见转述版本):

I will not help you by adding a feature. I will help you by taking one away.

在现实工程中的体现是:如果一个团队有 100 个人,for 循环写出来大概率大同小异;而函数式组合则会写出很多"聪明但不统一"的写法。

Python 社区偏好:

[x * 2 for x in nums if x > 10]

它仍然是 eager(会生成新列表),但写法更接近“你本来就会写的循环”。在 Python 社区,这种可读性往往比“函数式更纯”更重要。

这里也顺便回答一个常见问题:为什么 Python 主流写法不太追求链式?

一个关键原因是 Python 的哲学(The Zen of Python)里有这么一句:

There should be one, and preferably only one, obvious way to do it

也就是:尽量让主流写法收敛到"一个显而易见的方案”。所以你会看到更多列表推导式/生成器表达式,而不是在标准库层面鼓励到处都是 foo().bar().baz() 的链式风格(当然,第三方库比如 pandas 另说)。

零成本抽象 是 Rust 的主打特性。

所以Rust 可以放心写:

let out: Vec<i32> = nums.iter().filter(|x| **x > 10).map(|x| x * 2).collect();

这是因为编译器(加上 LLVM)能把很多迭代器链优化到接近手写循环的水平,尽量避免中间分配。而 Go 的编译器更追求编译速度与实现复杂度的平衡,不太愿意为此引入同级别的分析与优化成本。

JS 里链式 map/filter/reduce 很常见,一部分原因是它和回调、Promise、异步流生态天然契合。但 Go 更常见的写法是同步直写 + goroutine/chan 组合,函数式管道在 Go 里更多是"看着好看"而不是"非用不可”。

如果你的目标不是"看起来更函数式",而是"少分配,同时还能把逻辑拆开复用",惰性迭代器会更实用。

先用一句话把"迭代器"讲清楚:它不是某个神秘语法,而是一种把遍历过程封装起来的方式:

  • 你提供一个"取下一个值"的机制(更常见:提供一个 yield 回调)
  • 消费者决定要不要继续(例如返回 false 提前结束)
  • 组合(Map/Filter)只是在"产出下一个值"这条链路上做包裹

好处是:你可以写出"可组合"的抽象,但它是惰性执行的——不会为了组合而提前生成一堆中间切片。

下面给一个现在的 Go 版本就能直接用的最小实现:用 Seq[T] 表示“可遍历的序列”(可以把它理解成生成器)。它不会提前生成中间切片,而是按需 yield

package main

import "fmt"

type Seq[T any] func(yield func(T) bool)

func FromSlice[T any](s []T) Seq[T] {
	return func(yield func(T) bool) {
		for _, v := range s {
			if !yield(v) {
				return
			}
		}
	}
}

func Filter[T any](in Seq[T], pred func(T) bool) Seq[T] {
	return func(yield func(T) bool) {
		in(func(v T) bool {
			if pred(v) {
				return yield(v)
			}
			return true
		})
	}
}

func Map[T any, U any](in Seq[T], f func(T) U) Seq[U] {
	return func(yield func(U) bool) {
		in(func(v T) bool {
			return yield(f(v))
		})
	}
}

func Collect[T any](in Seq[T], capHint int) []T {
	out := make([]T, 0, capHint)
	in(func(v T) bool {
		out = append(out, v)
		return true
	})
	return out
}

func main() {
	data := []int{1, 2, 3, 4, 5}
	seq := Map(Filter(FromSlice(data), func(x int) bool { return x%2 == 0 }), func(x int) int { return x * 10 })
	fmt.Println(Collect(seq, len(data))) // [20 40]
}

上面这段代码有两个点值得注意:

  • Filter/Map 并不会分配中间切片,它们只是"包了一层遍历逻辑"
  • Collect 才是把结果真正落到切片里的地方(你可以决定是否收集、收集多少、何时提前停止)

如果你更喜欢"写起来像 range",社区里也一直有 range-over-func 的讨论与演进。无论语法怎么变化,核心思想其实是同一个:可以做抽象,但执行要惰性,让 for 继续当主角

总结一些准则:

  1. 基础操作优先用标准库slices / maps 能解决的就别引入第三方依赖。
  2. 对数据量与生命周期敏感的逻辑,用 for 写清楚:分配、复用、提前退出、错误处理都应该显式。
  3. 需要抽象时,优先考虑"惰性迭代器 + 显式收集":组合层负责拆逻辑,消费层负责控制分配与退出条件。
  4. 别为了函数式而函数式:重点是理解函数式要解决什么问题,把逻辑写成可测试、少副作用、边界清楚的函数。哪怕写 for,也能写出很干净的代码。

不要为了少写几行,把分配和错误语义藏进黑盒。在 Go 的哲学中,Clear is better than clever