从函数式编程角度批判 Go


很多人说 Go 简单。我觉得这话只说对了一半。Go 的确简单,但它简单的方式并不是把问题想清楚以后得到的简洁,而是把很多重要概念直接拿掉,然后要求程序员用重复、纪律和约定把缺失的东西补回来。

从函数式编程的角度看,Go 最大的问题不是“没有某个语法糖”,而是它没有认真对待程序的结构。函数式编程并不只是 mapfilterreduce,也不只是把函数当参数传来传去。函数式编程的核心是:让程序尽量由值、组合、不可变数据和清晰的等式关系构成。这样程序可以被替换、推导、局部理解,而不是靠人脑追踪一堆状态变化。

Go 正好相反。Go 的设计重心是命令式流程、可变数据和显式控制。它的优点是直接,缺点也是直接。它让你很容易写出“下一步做什么”,却不鼓励你思考“这个东西是什么”。

函数是一等公民,但语言不是

Go 有函数值,有闭包,也可以写高阶函数。所以有人会说 Go 当然支持函数式编程。

这是一种很浅的理解。

一个语言有函数值,不等于它有函数式思想。C 也可以用函数指针,Java 也有 lambda,甚至很多脚本语言都能把函数塞进变量里。但函数式编程关心的不是“函数能不能当值”,而是函数是不是主要的建模工具。

在 Go 里,函数经常只是回调、适配器、工具参数。真正的程序结构仍然围绕 struct、可变字段、接口方法、循环和错误返回展开。函数很少成为组合的中心。你可以写高阶函数,但语言本身不给你太多帮助。没有表达式化的 if,没有模式匹配,没有代数数据类型,没有方便的不可变更新,没有高阶类型,也没有一种自然的方式表达“这个计算会产生什么效果”。

结果就是:Go 可以写函数式代码,但写起来像在逆着语言纹理走。

错误处理不是类型,而是噪音

Go 的错误处理常被描述为“显式”。显式当然比隐藏好。但 Go 的问题是,它把显式理解成了重复。

x, err := f()
if err != nil {
	return err
}
y, err := g(x)
if err != nil {
	return err
}
z, err := h(y)
if err != nil {
	return err
}

这不是清晰,这是把控制流的下水道铺在客厅中间。

从函数式编程角度看,错误不是一个需要到处手动检查的偶然事件,而是一种可以建模的计算上下文。EitherResultOption 这些东西的意义,不是为了显得高级,而是把“可能失败”放进类型结构里,然后用组合规则传播它。

Go 的 error 是一个接口值。它能表达“有错误”,但不能很好表达“什么样的错误在类型层面可能发生”。它也不参与组合。每一步都要手工展开,每一步都要写 if err != nil。这使得程序看起来很显式,实际上却把真正的逻辑淹没了。

更糟的是,Go 同时保留了 panic / recover。于是语言表面上说“错误应该作为值返回”,背后又留下了另一套非局部跳转机制。你当然可以说 panic 只该用于不可恢复错误。但这仍然说明:Go 没有一个统一的效果模型。它靠规范、文化和代码审查维持秩序,而不是靠类型系统表达秩序。

nil 是最廉价的复杂性

函数式语言经常被人嘲笑有一堆 OptionMaybe。可是这些东西的目的很简单:把“可能没有”变成一个显式类型。

Go 选择了 nil

nil 看起来简单,因为它少打几个字。可是它把复杂性转移到了每一个使用点。一个指针、slice、map、channel、function、interface 都可能和 nil 发生关系。程序员必须在脑子里记住哪个值可能为空,哪个值不能为空,哪个接口值看似不是 nil 但里面包着一个 nil 指针。

这不是简单。这是把类型系统本来可以承担的工作交给人的记忆。

函数式编程强调用类型排除非法状态。Go 则经常允许非法状态进入系统,然后要求程序员到处防守。很多 Go 代码不是在表达业务逻辑,而是在写“这个东西会不会是 nil”的仪式。

可变性是默认道路

Go 的 slice、map、struct 字段、指针都让可变状态非常自然。你当然可以约定不改,但语言不会帮你。

很多 Go 程序的函数签名看起来很朴素:

func UpdateUser(user *User) error

问题是,单看这个签名你不知道它到底改了什么。它可能改 user.Name,可能改 user.Profile.Address,可能把某个 slice append 了,可能通过内部 map 影响别的对象。指针把变化藏在函数签名后面,slice 和 map 又让共享底层状态变得平常。

函数式编程追求引用透明:同样的输入得到同样的输出,函数调用可以被结果替换。Go 的常见写法则是:输入看起来一样,但外部世界可能已经变了。

这不是说所有可变状态都是错的。系统编程、网络服务、并发程序当然需要状态。问题在于,一个语言如果不提供良好的不可变数据建模能力,程序员就会把状态变化写成默认风格。久而久之,整个代码库都变成“先传一个指针进去,再祈祷它只改该改的地方”。

接口很实用,但抽象能力有限

Go 的 interface 有一个漂亮的地方:结构化实现。一个类型不需要声明自己实现了某个接口,只要方法集匹配就行。这在工程上很方便。

但从函数式抽象角度看,它仍然很贫瘠。

函数式编程里的抽象经常不是“这个对象有哪些方法”,而是“这个类型构造器有什么代数性质”。例如 functor、applicative、monad、foldable 这些东西,关心的是结构和组合规律。它们不只是方法集合,而是一组可以推理的接口。

Go 的泛型补上了一部分能力,但没有把这条路走完。它可以写类型参数和约束,可以消除一些重复代码。但它没有高阶类型,不能自然表达 F[T] 这种“类型构造器上的抽象”。于是很多函数式抽象无法在 Go 里漂亮地落地,只能变成具体类型上的工具函数,或者退化成 interface 加运行时分派。

Go 的抽象哲学是“小接口”。这在 IO 边界很好用。但当你想表达更一般的结构时,它就显得像一把只适合拧螺丝的刀。你可以拿它切菜,但别说它是菜刀。

没有模式匹配,数据结构就不诚实

函数式语言喜欢代数数据类型,不是因为语法洁癖,而是因为它能诚实地表达“一个值只能是这些情况之一”。

比如一个表达式可以是:

  • 数字
  • 变量
  • 加法
  • lambda
  • 函数调用

在函数式语言里,这通常是一个 sum type。处理它时用模式匹配,编译器还能检查你是否漏掉情况。

Go 没有这样的东西。你通常会用 interface、struct、type switch 或手写 tag 字段模拟。

type Expr interface{}

type Number struct { Value int }
type Add struct { Left, Right Expr }

这当然能跑。但它没有封闭性。别人可以塞进新的实现。编译器也不能轻易告诉你所有情况都处理完了。你会写出一堆 switch v := expr.(type),然后在 default 里返回一个错误,仿佛语言设计的问题是运行时错误处理可以解决的。

数据结构不诚实,控制流就会变脏。你本来想表达“语法树有这些形状”,最后却写成“运行时猜一下它到底是什么”。

并发不是效果系统

Go 最自豪的是 goroutine 和 channel。它们确实好用,也比很多语言的线程 API 轻便。

但这不是函数式意义上的效果管理。

go f() 一写,新的执行流就飞出去了。它什么时候结束,谁拥有它,错误怎么回来,资源如何取消,生命周期是否被约束,这些都不在类型里。你可以用 context、errgroup、channel、select 搭出相对健康的结构。但这仍然是库和纪律,不是语言层面的保证。

函数式编程并不是不要副作用,而是要让副作用有边界、有类型、有组合方式。Go 的并发模型鼓励你启动过程,传消息,靠约定收尾。对于工程实践这可能够用,但从语义上看,它仍然是命令式的:事情发生了,状态改变了,你去追踪吧。

Go 的真正定位

所以 Go 是坏语言吗?不是。

Go 很适合写网络服务、命令行工具、基础设施胶水、部署简单的后端程序。它编译快,工具链统一,标准库实用,部署心智负担低。它的成功不是偶然。

但 Go 的成功不等于它在语言设计上深刻。它解决的是组织工程问题:让大量程序员写出差不多风格的代码,让代码容易构建、容易部署、容易审查。它牺牲的是表达力、抽象能力和类型系统能提供的推理空间。

从函数式编程角度看,Go 的问题可以总结成一句话:

Go 把很多本该由语言和类型系统保证的事情,交给了程序员的自觉。

这在小程序里很舒服,在大系统里就开始昂贵。因为人的自觉不能组合,约定不能检查,代码审查不能替代类型系统。

如果你只是想把请求接进来,查数据库,返回 JSON,Go 很好。如果你想精确表达领域模型,组合复杂计算,控制副作用边界,让编译器替你排除大量非法状态,Go 就会显得笨重而贫乏。

它不是不会写函数式代码。它只是没有把函数式编程中最重要的东西放在心上。

而这正是批判它的理由。