oldhu's

26
Aug 2022

函数式编程的一些内在逻辑

函数式编程想解决什么问题

函数式编程想消除程序的复杂性。

在 Out of the Tar Pit 一文中指出,程序的复杂性的最主要来源是状态管理,以及状态管理带来的分析与理解负担。

举例来说,维护一个大型的系统中的函数,如果某个函数读取或修改了外部状态,那只阅读这一个函数,就很难理解这个函数能影响的范围。如果函 数会被并发执行,还有可能出现不可预测的bug(data race)。

函数式编程解决这个问题的思路是:让每一个函数没有副作用,不依赖也不影响任何外部的状态。一个函数,给定确定的输入,就一定返回一个确定 的结果。这样的函数,更接近数学意义上的函数,叫纯函数(Pure Function)。

这体现了函数式与面向对象方法的一个很大的不同:OO是希望将状态与方法封装到一起,而函数式希望他们分开。

基于Pure Function,会推演出什么

不可变

首先,最直接的,就是不可变数据(immutability)。只有当数据不可变的时候,才能放心地将值作为参数传给一个(纯)函数。函数这样可以放心地实 现它对外的承诺:不改变外部的任何状态。

由于数据不可变,想要在一个循环里不断累加一个变量,不断修改一个变量这种在之前大量使用的编程习惯,就变得不再可行。因此基于不可变数据, 就要引入一个重要的范式(pattern):递归(recursion)。

递归会带来比较重的堆栈负担,这时候就要引入递归的累加器(accumulator)范式和尾递归(tail recursion)优化。

函数签名与组合

对于无副作用的函数,可以写出清晰的函数签名,说明函数的输入类型和输出类型。(有副作用的函数,写了签名也没用,因为有隐藏的影响无法在 签名表达出来)

有了签名,比如一个函数是 A -> B,另一个函数 B -> C,就可以提供一个工具,把这两个函数组合成 A -> C , 这就是函数组合 (compose)

不断将小函数组合成一个更大的函数,是函数式编程里管理复杂度的一个重要手段。

函数的柯里化(curry),在一定程度上,也可以认为是为了调整函数的参数数量,从而为函数组合准备好相应的函数。

容器数据类型

为了实现Pure Function,还要考虑如何让一个函数表达不可计算(异常,数据输入不合法等问题),这时候,就需要引入类似于 Option/Some/Either/Result 这样的容器数据类型。在数据类型中,可以包含正常的返回值,也可以包含对不可计算的表示(None, Error等)。

有了容器数据类型,在函数组合时就会遇到困难,这时候就需要引入 Functor, Applicative, Monad这些工具

不可能都是Pure Function

任何一个程序,最终是要和外部世界交互的,比如读文件,显示界面等。所以不可能所有的函数都是Pure Function。

所以函数式编程,本质上是在寻找一种用于表达副作用的数据结构,通过编写、组合多个Pure Function,最终生成这个数据结构,由程序的另外一 个部分,来解释、执行这个数据结构。

比如React,所有的开发动作,都是在生成一个数据结构,最终将数据交给底层的diff和vdom去更新真正的DOM。

所以,处理副作用的原则就是将副作用带来的不确定性限制在一定的范围,让程序的其它部分保持Pure,从而符合函数式编程的要求。

comments powered by Disqus