基本概念
函数式编程,即使用函数的方式进行编程。这个函数是指数学领域的函数。
数学领域的函数本质上是一种对应关系:
y = x + 1
有x值,便可得到y值。于是这个函数本身表达了x→y的对应关系。函数式编程就是这样的思想:所有的函数都体现了一种映射。
常规的命令式编程是思维方式的直观表达。例如,要将一棵二叉树镜像反转:
var invertTree(root) {
if(root != undefined) {
var leftNew = invertTree(root.left)
var rightNew = invertTree(root.right)
root.left = rightNew
root.right = leftNew
}
return root
}
首先判断当前节点是否为空,然后翻转左子树,再翻转右子树。最后将左右子树交换。
这就是命令式编程,直观地将所有步骤描述出来。最终的结果可能更改原始输入,也可能不会。
而对于函数式编程,则变成了:
var invertTree(root) {
if(root != undefined) {
return new Tree(root.value, invert(root.right), inver(root.left))
}
return root
}
直接定义一个映射关系,将原二叉树直接映射为一棵新二叉树。最终的结果一定不会改变原始输入。
函数式编程的方式
一个数学函数,入参确定,返回值就确定,函数无任何其他副作用。这就是个纯函数。
在函数式编程中,每个封装的模块都是一个纯函数。于是通过对这些小功能模块的组合,就可以组合出各种大的功能模块,且每个大的功能模块也是个纯函数。
例如,我们定义了两个纯函数:f(x)和g(x)。已知x初值为1,为了求最终结果y,需要如下操作:
var t = f(1)
var y = g(t)
现在将f(x)和g(x)组合为一个纯函数来用:
var t = g(f(x))
var y = t(1)
于是两个简单的映射组合为一个复杂的映射。然而对于同一个输入,输出依然是不变的。
然而上述这种组合方式有个条件,那就是前一个函数的返回,刚好是后一个函数的输入。返回值只有一个,因此每个函数都只能有一个输入。而实际应用中,一个函数所需的参数往往不止一个。于是,我们需要某种方式,将多参函数转换为单参函数。这种转换方式称为柯里化。
因此,函数式编程有两个基本的运算:
1. 柯里化:将一个多参函数,转化为单参函数。
2. 合成:将多个纯函数组合为一个纯函数。且满足结合律。
结合律是指改变结合的函数对象,只要执行顺序不变,最终结果就不变。
例如,有3个纯函数:f(x),g(x),h(x)。运算要求这3个纯函数顺序执行。于是:
(f(x)·g(x))·h(x) == f(x)·(g(x)·h(x)) == (f(x)·g(x)·h(x))
即:将f(x)和g(x)组合为一个纯函数,然后再与h(x)组合,等价于将f(x),g(x),h(x)这3个直接组合为一个纯函数。
柯里化
考虑这样一个例子:输入法定年龄s与指定年龄x。判断x>=s。
现在,给定s=18,x1=16,x2=20。
常规写法:
var isAdult = function(s, x) {
return (x>=s)
}
isAdult(18, 16) // false
isAdult(18, 20) // true
然而这种写法每次都需要将标准值18传入。采用柯里化的写法:
var isAdult = function(s) {
return function(x) {
return (x>=s)
}
}
var isAdultS = isAdult(18)
isAdultS(16) // false
isAdultS(20) // true
将其改为lambda表达式写法:
var isAdult = s => (x => (x>=s))
var isAdultS = isAdult(18)
isAdultS(16) // false
isAdultS(20) // true
上面的例子体现了柯里化的两个能力:
1. 将一个单次调用的多参函数,转换为多次调用的单参函数。
2. 将前面已传入的参数保存起来。
组合
有3个操作:x+1
,x*2
,x*3
。现在有x=1,要执行两个计算:
- 先执行r = x+1,再对结果r执行r*2。
- 先执行r = x+1,再对结果r执行r*3。
常规实现思路:
var add1 = x => (x + 1)
var mul2 = x => (x * 2)
var mul3 = x => (x * 3)
mul2(add1(1)) // 4
mul3(add1(1)) // 6
功能上没有问题。然而调用的时候是两个函数顺序调用,是命令式的写法。将函数进行组合:
var add1 = x => (x + 1)
var mul2 = x => (x * 2)
var mul3 = x => (x * 3)
var a1m2 = x => mul2(add1(x)) // 组合
var a1m3 = x => mul3(add1(x)) // 组合
a1m2(1) // 4
a1m3(1) // 6
直接调用组合后的函数即可得到结果。
组合有如下特点:
- 从右向左调用。上面例子中是先调用
add1()
再调用mul2()
。 - 先调用函数的返回值会作为后调用函数的参数。
- 所有函数都是单参数。
组合通常定义一个功能函数compose()
。compose的定义为:
var compose = (...args) => x => args.reduceRight((value, item) => item(value), x);
使用compose()
,则上面的例子就可以改为:
var add1 = x => (x + 1)
var mul2 = x => (x * 2)
var a1m2 = compose(mul2, add1)
a1m2(1)// 4
注意compose()
的参数顺序,其参数中的函数是从右向左执行的。
函子
将f(x)和g(x)进行组合,得到新的映射函数t(x)。
调用这个新的映射函数,所传入的数据一定是某种确定的类型,例如,传入的x是一个自定义的MyType类型。
于是考虑,定义一个对象,用于管理MyType数组。该对象可以方便地生成一个MyType数据,并添加到自身的数组中;也可以对自身管理的所有MyType数据依次调用映射函数t(x)。同时,还可以满足MyType数据的特定约束行为(例如尝试获取越界的数组值时,该对象就返回一个new出的MyType对象)。
这个管理对象,就是函子。
函子是一个具有map方法的容器。具体的函子容器对象内保存了原始数据值,它可以对容器内的所有值调用指定的纯函数,从而将这些值全部映射为处理后的值,并放入一个新的函子容器对象中。
例如,js的Array就是一个函子。一个Array对象,可以使用map来对所有值进行映射,映射后的值会构成一个新的Array对象返回。
实际上,任何具有map()
方法的数据结构,都可以当做函子的实现。因此约定,函子的标志就是容器具有map()
方法。
同时,函数式编程还约定,函子有一个of()
方法,用于生成新的容器。
依然以Array为例:
- 生成一个新的容器:
var r = Array.of(1,2,3) // r = [1,2,3]
- 对新的容器使用纯函数进行映射:
var Add1 = function(x) { return x + 1 }
var mult2 = function(x) { return x * 2 }
var r = r.map(Add1).map(mult2) // r = [2,6,8]
最后得到的r依然是个Array容器对象。