开篇
最早知道函数式这个概念是在知乎上,看到有人讨论垠神和田春,有个知乎评论说写 lisp 的都容易走火入魔,当时就去搜了下 lisp 这门语言, 大部分介绍都还是正面为主,学术派、函数式这些都是它的标签。
后来自学转行做程序员,大四做前端那会在学习 React 的时候很多介绍文章都说 React 是很 FP 的,再到后面陆陆续续知道了 Haskell、 Erlang、 Scala 这些函数式语言,原来工业界还是有函数式的一席之地(以前看到一个言论是说这个世界是基于 OOP ,一直深信不疑。
入门
说实话,FP 的文章是很晦涩难懂的,是非常反直觉的。有很多讲 FP 入门的文章都写得很不错,但是有很多都夹杂着大量的术语和数学公式。我觉得入门还是要简单直接,FP 的本身概念是不难懂的。
那到底什么是函数式编程?closure (闭包)、currying(柯里化)这些经常在红宝书出现的词汇到底和函数式是什么样的关系?
Lambda
lambda 是 FP 编程绕不过去的一个核心概念,那什么是 lambda?函数的参数是函数,返回值也是函数。最初的时候这种函数用希腊字母lambda(λ)表示,因此得名。简而言之,以后听到 lambda 表达式的时候可以把他等价于匿名函数就好了。
现在绝大部分语言都实现了函数是第一等公民(First Class),即函数跟其他数据类型是平等地位,比如 Golang 里,你可以把函数赋值给一个变量,把函数当做参数传递给另一个函数。这是实现函数式编程很多概念的基础,比如闭包、柯里化等等。如下:
bar := 1foo := func (a, b int) int {return a + b}
有一说一,Java 8 才支持 Lambda 表达式,之前都没支持,之前看他们 On Java8 函数式章节的一段话,看的还是闻着伤心,听者流泪。

Currying
柯里化是函数式编程最常用的运算手段,它表达的概念很简单:在一个 FP 语言中函数(而不是类)被作为参数进行传递,从而用于减少函数参数的数量。
举个例子,我们实现一个简单的两数想加的函数 add。
func add(x, y int) int {return x + y}func main() {fmt.Println(add(1, 2))}
柯里化之后就变成这样
func add(x int) func(int) int {return func(y int) int {return x + y}}func main() {fmt.Println(add(1)(2)) //3//orbar := add(1)fmt.Println(bar(2)) //3}
是不是很像设计模式的适配器模式,在函数式编程里为什么没有所谓的设计模式这种概念,就是因为抽象程度已经很高,纯函数式语言像 Lisp 它们这种表达能力很强,所以就不需要所谓的设计模式。
so 函数式就是这么直观,那一般什么时候使用柯里化呢?很简单,当你想要封装函数的时候,就是用柯里化的时候。
Compose
我们现在了解了柯里化,为什么要有柯里化这个方法呢?这就要说到函数式另一个最基本的特性:组合。
什么是组合呢?如下:
func bar(x int) int {return x * 2}func foo(x int) int {return x + 1}compose := func(bar, foo func(int) int) func(int) int {return func(x int) int {return bar(foo(x))}}fmt.Println(compose(bar, foo)(2)) //6
可以看到,如果一个值要经常多次函数运算,才能变成另一个值,在函数式编程里就可以把中间步骤合并在一起,这就是组合。
可以看到,要合成 bar(foo(x)) 这种前提就是 bar 和 foo 这种函数只能只能接受一个参数,如果接受多个参数,合成就有点麻烦,所以函数式编程函数默认一般都是一个参数。
组合像一系列管道那样把不同的函数联系在一起,数据就可以也必须在其中流动。
Lazy evaluation
惰性求值是函数式编程特别有特色的地方,它表达的概念也很简单:是将表达式的求值延迟到需要时的过程。
惰性求值使得代码具备了巨大的优化潜能。支持惰性求值的编译器会像数学家看待代数表达式那样看待函数式程序:抵消相同项从而避免执行无谓的代码,安排代码执行顺序从而实现更高的执行效率甚至是减少错误。在此基础上优化是不会破坏代码正常运行的。严格使用形式系统的基本元素进行编程带来的最大的好处,是可以用数学方法分析处理代码,因为这样的程序是完全符合数学法则的。
func main() {fmt.Println(addOrMultiply(true, add(4), multiply(4))) // 8fmt.Println(addOrMultiply(false, add(4), multiply(4))) // 16//executing add//executing multiply//8//executing add//executing multiply//16}func add(x int) int {fmt.Println("executing add") // 这里有 io 打印输出,所以函数是先求值的return x + x}func multiply(x int) int {fmt.Println("executing multiply") // 这里有 io 打印输出,所以函数是先求值的return x * x}func addOrMultiply(add bool, onAdd, onMultiply int) int {if add {return onAdd}return onMultiply}
用高阶函数重写惰性求值的版本:
func add(x int) int {fmt.Println("executing add")return x + x}func multiply(x int) int {fmt.Println("executing multiply")return x * x}func main() {fmt.Println(addOrMultiply(true, add, multiply, 4))fmt.Println(addOrMultiply(false, add, multiply, 4))//executing add//8//executing multiply//16}// 现在是一个高阶函数,因此函数的计算在 if-else中 被惰性求值func addOrMultiply(add bool, onAdd, onMultiply func(t int) int, t int) int {if add {return onAdd(t)}return onMultiply(t)}
一般我们都不会使用惰性求值这种增加代码复杂度,但是假如函数处理成本很大的话,那么惰性求值是非常值得的。
闭包
Go 支持匿名函数,可以作为闭包。我们之前讲到的柯里化的例子就用到了闭包的特性。你可以把闭包当成内联的表达式,好处就是可以直接使用函数内的变量,不必声明。
func incr() func() int {var x intreturn func() int {x++return x}}func main() {i := incr()fmt.Println(i())fmt.Println(i())fmt.Println(i())//1//2//3}
如上图所示,incr 函数返回了一个闭包,闭包捕获到了变量 x, 这时候就不会存在栈上了,而是逃逸到堆上了。
纯函数
函数式是非常强调纯的概念的,何为“纯”?
维基百科的定义:
此函数在相同的输入值时,需产生相同的输出。函数的输出和输入值以外的其他隐藏信息或状态无关,也和由I/O设备产生的外部输出无关。
该函数不能有语义上可观察的函数副作用,诸如“触发事件”,使输出设备输出,或更改输出值以外物件的内容等。
通俗易懂的来说就是一个函数,不会受到外部环境的影响,对于给定的输入,它总是返回相同的输出,并且它的行为是高度可预测的。
func sum(a, b int) int {return a + b}
上面这种就是纯函数,根据两个参数产生固定的输出。如果你在中间引用了容易变化的全局变量就变得不纯了。这样带来的好处就是无状态,不用担心数据竞争,线程安全,不需要线程同步。而且应用程序或者运行环境(Runtime)可以对纯函数的运算结果进行缓存,运算加快速度。
常见的副作用包含:
- 往数据库插入记录
- 发送一个 http 请求
- 可变数据
- 打印/log
优点
单元测试
上面介绍了纯函数,有相同的输入就会有相同的输出,那么对于我们单元测试是非常有利的,很方便。你就能很好的测试观察你写的函数,这简直就是我们进行单元测试梦寐以求的结果。永远不用担心函数会有副作用,谁也不能在运行时修改任何状态。
并发执行
函数式编程不需要考虑死锁这些问题,因为它不修改变量,函数都是无状态,所以根本不存在锁的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很容易开启多线程。
像 Erlang、Scala 这种工业界使用的函数式语言有着不俗的表现力。WhatsApp 用了 Erlang,50 个工程师写出了支撑 9 亿用户的系统 ,每天处理的消息都是亿级别。只能说一句牛逼。
多核时代下,函数式的并发模型又回归了大众的视野,所以学习下 Erlang 这种函数式语言是非常有价值的,函数式语言骨子里的东西是不会过时的。
热部署
函数式编程没有副作用,只要保证接口不变,内部实现是外部无关的。所以,可以在运行状态下直接升级代码,不需要重启,也不需要停机。函数式编写的程序中所有状态就是传给函数的参数,而参数都是储存在栈上的。这一特性让软件的热部署变得十分简单。
Erlang 多年前就证明了这一点(电话系统是不可能停机的),而且,Erlang 实现的系统,,做到了 9 个 9 的可用性。这是什么概念?这意味着全年停机时间不超过 31.56 毫秒。 几乎就是不会停机了。阿里云都只能说自己的可靠性 6 个 9,AWS 的可用性只有 99.95%,意味着每年要停机 4.5 小时左右。再次吹一下 Erlang。
安全 && 性能
干掉了指针,常见的空指针这种 panic 就能避免掉。返回值也能缓存起来,不用担心缓存不一致的情况,类似尾递归那种优化,速度提升 up up。还有老生常谈的 gc 的冤大头也是指针(虽然闭包也会逃逸到堆,但是养成值传递肯定还是好习惯。
总结
软件行业有一句著名的话叫:没有银弹。
函数式语言和函数式编程是银弹吗?它的缺点也显而易见,比如没有 for 循环,只有递归。因为 for 循环是引入了新的变量的,这是在函数式编程里不能接受的。所以带来的问题就是递归次数多了会引起栈溢出。性能会有很大的问题,不过现在编译器都实现了尾递归程序转换为循环,这样内存会大大减少。但是 Golang 是没有实现尾递归优化的。所以在 Golang 程序里尽量减少递归的使用。
纯粹追求函数式会自缚手脚,比如传入指针这种操作是函数式不允许的,这样会让函数变得不纯。想想我们日常开发中是不是有很多传入指针的操作?这个世界是有副作用的,IO 操作这些 (比如键盘的输入输出) 是不可避免的,这些都是函数式所视为洪水猛兽的。
所以我的观点还是,面向对象和函数式编程我全都要(.jpg)。在编写软件过程中,尽量让要测试的函数变得纯,这样有利于我们单元测试。在建模过程中,如果要对一组数据进行排序、加工、查询的话就可以利用函数式进行建模,这样就很自然。对具体问题还是要分析下,到底是面向对象合适一点,还是函数式编程合适一点。
Go team 其实对函数式不是很感冒,比如一些语法提案,想抄一些函数式的语法糖(match,. 等操作符)基本都被枪毙了,尾递归的提案也是被枪毙了,也不能愉快的写递归了(生产代码)。包括 rob pike 也自己实现过 filter,readme 里说应该老老实实用 for 循环。
Golang 里建模还是面向对象。比如 struct method 这种都是数据和逻辑是耦合的,但是函数式常用 map/filter/reduce 这种 api 最核心的理念是数据和逻辑解耦。Go 强调组合和函数式语言里强调的组合比较明显能看出差别,Go 强调的组合是指 struct 名词对象,而函数式强调的组合是基于函数行为的组合,前者是名词(对象)组合,后者是动作(函数)组合。
其实函数式编程深入进去会发现很反直觉,它其实是比面向对象,面向过程难很多的。这篇文章对函数式很多概念其实都没有介绍到,比如函数式的起源是数学的一个分支范畴论(Category Theory)。而理解函数式编程的关键就是理解范畴论,这是一门很复杂的数学。像函数式编程里的数据类型是被成为函子的,这是一种范畴。处理 IO 这些副作用的就是 Monad 函子,它最重要的作用就是实现了 IO 的输入输出。还有函数式编程的好姐妹响应式编程,这里就不再进行扩展。大家感兴趣可以去延伸阅读里扩展阅读下。
对于 Gopher 来说,大家可以重点了解下这个库:https://github.com/thoas/go-funk。它实现了大部分的 api,函数式编程里的管道概念 map, find, contains, filter, 这些大家可以在日常开发中进行使用。
延伸阅读
https://github.com/ReactiveX/RxGo(响应式编程 Go 实现)
https://book.douban.com/subject/25803388/ (Haskell 趣学指南)
https://book.douban.com/subject/1148282/ (Sicp 第二版中译)
https://book.douban.com/subject/26148763/ (Ruby 版 sicp)
https://www.htdp.org/2019-02-24/ (HTDP 第二版)
