什么是函数式编程?

它详细的解释可以参考维基百科。缘起数学家 Alonzo Church 提出了 Lambda 演算的概念,可以用函数组合的方式来描述计算过程,换句话来说,如果一个问题能够用一系列函数组合的算法来表达,那么这个问题就认为是可计算的。


Gardenia jasminoides

它和面向对象编程一样,也是一种编程范式。强调执行的过程而非结果,通过一系列的嵌套的函数调用,完成一个运算过程。它主要有以下几个特点:

  • 函数是”一等公民”:函数优先,和其他数据类型一样。

  • 只用”表达式”,不用”语句”:通过表达式(expression)计算过程得到一个返回值,而不是通过一个语句(statement)修改某一个状态。

  • 无副作用:不污染变量,同一个输入永远得到同一个数据。

  • 不可变性:前面一提到,不修改变量,返回一个新的值。

范畴论与函数式编程

  • 在函数式编程中,函数就是一个管道(pipe)。这头进去一个值,那头就会出来一个新的值,没有其他作用。
  • 所以,为什么函数式编程要求函数必须是纯的,不能有副作用?因为它是一种数学运算,原始目的就是求值,不做其他事情,否则就无法满足函数运算法则了。
  • 本质上,函数式编程只是范畴论的运算方法,跟数理逻辑、微积分、行列式是同一类东西,都是数学方法,只是碰巧它能用来写程序。

函数的合成与柯里化

函数式编程有两个最基本的运算:合成和柯里化

函数的合成

如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做”函数的合成”(compose)。
合成两个函数的简单代码如下。

1
2
3
4
5
const compose = function (f, g) {
return function (x) {
return f(g(x));
};
}

函数的合成还必须满足结合律。

1
2
3
4
5
compose(f, compose(g, h))
// 等同于
compose(compose(f, g), h)
// 等同于
compose(f, g, h)

合成也是函数必须是纯的一个原因。因为一个不纯的函数,怎么跟其他函数合成?怎么保证各种合成以后,它会达到预期的行为?
前面说过,函数就像数据的管道(pipe)。那么,函数合成就是将这些管道连了起来,让数据一口气从多个管道中穿过。

柯里化

f(x)和g(x)合成为f(g(x)),有一个隐藏的前提,就是f和g都只能接受一个参数。如果可以接受多个参数,比如f(x, y)和g(a, b, c),函数合成就非常麻烦。
这时就需要函数柯里化了。所谓”柯里化”,就是把一个多参数的函数,转化为单参数函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 柯里化之前
function add(x, y) {
return x + y;
}

add(1, 2) // 3

// 柯里化之后
function addX(y) {
return function (x) {
return x + y;
};
}

addX(2)(1) // 3

有了柯里化以后,我们就能做到,所有函数只接受一个参数。后文的内容除非另有说明,都默认函数只有一个参数,就是所要处理的那个值。

函子

函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。

函数式编程在前端开发中的应用

函数式编程的概念其实出来也已经好几十年了,我们能在很多编程语言身上看到它的身影。比如比较纯粹的 Haskell,以及一些语言开始逐渐成为多范式编程语言,比如 Swift,还有 Kotlin,Java,Js 等都开始具备函数式编程的特性。这么多语言开始逐渐有了支持,函数式编程对于我们的生活到底能够带来一些什么好处呢?

提到现代前端开发,那么 React 肯定是逃不开的一个话题。在 React 技术栈中,函数式编程有哪些体现呢?

Stateless components

在 React 0.14 之后推出的,先来看一段代码

1
2
3
function MyComponent(props) {
return <div>My props name {props.name}</div>
}

这是一个简单的无状态组件,我们没有用createClass或者是extends React.Component来创建一个组件,而是通过一个 Pure function 返回了一个组件。
那么这里的好处是什么呢?

  1. 简洁,一眼可以看出这个组件的作用;

  2. 无副作用,只要传入同一个 props 那么 render 出来的组件一定是相同的;

  3. 测试更友好;

  4. 没有this,要知道this还是难倒了好多英雄好汉的;

  5. 更容易实现 SSR(这一点我并未考证,有知道的朋友可以补充)。

当然,使用Stateless component并不是万能的,可以很明显的看到没有了 React 的生命周期,这个问题通常我们会结合 HOC 来解决。你看这一点就印证了前面说的,通过函数的组合完成对结果的表达,是不是很有意思。

Redux

在前端应用越来越复杂的今天,数据流管理是一件很重要的事,Redux 就是来解决这个问题的。它是 Flux 架构的演化实现,官方 GitHub 解释为 Predictable state container(可预测状态机)。

在 Redux 中我们存在一个单的树形结构的 state,单一数据源降低了多数据源的信任问题。State 是通过每个 reducer 的结果组合而来的,每个 reducer 都是一个 Pure function,如下:

1
2
3
4
5
6
7
8
9
export const isLoading = (state = false, action) => {
switch (action.type) {
case MarketActionsTypes.FETCH_MARKET_DATA_START: {
return false;
}
default:
return false;
}
};

在 reducer 中不会直接修改每个 state 中的状态,而是返回一个新的状态,然后整个 state 的结果通过一个个 reducer 的结果归纳出来。 我们来看 reducer 源码是怎么工作的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
....
// from https://github.com/reduxjs/redux/blob/b02310b359a0832f65873d024570d411b465ced9/src/combineReducers.js#L162
let hasChanged = false
const nextState = {}
for (let i = 0; i < finalReducerKeys.length; i++) {
const key = finalReducerKeys[i]
const reducer = finalReducers[key]
const previousStateForKey = state[key]
const nextStateForKey = reducer(previousStateForKey, action)
if (typeof nextStateForKey === 'undefined') {
const errorMessage = getUndefinedStateErrorMessage(key, action)
throw new Error(errorMessage)
}
nextState[key] = nextStateForKey
hasChanged = hasChanged || nextStateForKey !== previousStateForKey
}
return hasChanged ? nextState : state
....

我们可以很直观的看到,最后的 state 结果是通过将每个 reducer 生成的局部结果组合起来得到一个新的 nextState,而不是直接在原有的 state 上进行修改。

所以,我们再回过头来看看它的定义——可预测状态机。
每个 reducer 都是一个纯函数,只要输入恒定,那么输出肯定是恒定的。同时,无副作用的特性可以保证 state 不会被意外修改,那么整个应用的 state 都是可以准确的知道的。
最终的结果可以通过一系列的信息组合得来,这是一个很重要的改变。

在函数式编程中,我们将一个个复杂的问题抽象成一个个过程的表达,然后再将不同的过程结果组合起来,更加容易找到问题的解决办法。对于我们在其他领域的编码也是一样的道理,剥离问题表面,还原问题本质。有了这样的思维的时候,当你和别人在看同一个问题的时候,你会更容易有一种拨云见日的感觉。

除了抽象的能力,分解问题的能力也是很重要的一个启发。将问题化小,分而治之,然后组合结果。当然这个能力不仅仅是函数式编程才具备的,分治法在很多算法里已经体现得淋漓尽致了。不过这里还是想再提一下这个话题,分而治之可以在各个维度的工作上进行运用,小到一个算法的具体实现,然后到一个问题的过程分解,甚至大到一个工作任务的拆解,都可以用分而治之的思维去寻找解题之法。

当然函数式编程还有其他的一些不足之处,比如有人会说在函数式编程中数据复制可能会比较严重,可能会造成性能问题。这个问题我是这样看的,局部来说他可能确实看起来会存在一定的影响。但是从另一个角度来说,在我们使用函数式编程的时候,不用担心全局变量被破坏,没有执行顺序的依赖。我们在并行编程的时候,也不需要依赖于过多的锁的,那么反而最终可以提升最终性能。

参考: