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]
}

很多人会问,“为什么标准库不直接提供一个 Keys(m) 返回所有 key 的切片?”其实不是做不到,而是 Go 的设计更希望你能清楚地看到:要不要分配内存、要不要排序,都由你自己决定,代码也一目了然(这里假设 m 是一个 map[string]V):

keys := make([]string, 0, len(m))
for k := range m {
	keys = append(keys, k)
}
// 如需稳定顺序:slices.Sort(keys)

更实用的建议

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

这里插一个常见追问:“为什么 Go 不愿意支持 Map/Reduce/Filter?别的语言也都有 for 循环啊。”

这确实是个很敏锐的观察。Go 不做这件事,不是“技术上做不到”,更像是一个长期的工程取舍: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 可以大胆写:

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

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

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

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

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

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

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

下面给一个今天就能直接用的最小版本:用 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 的语境里,清晰通常比巧妙更值钱