你可能据说过函数式编程(Functional programming),甚至已经使用了一段时间。javascript
可是,你能说清楚,它究竟是什么吗?html
网上搜索一下,你会轻松找到好多答案。前端
- 与面向对象编程(Object-oriented programming)和过程式编程(Procedural programming)并列的编程范式。
- 最主要的特征是,函数是第一等公民。
- 强调将计算过程分解成可复用的函数,典型例子就是
map
方法和reduce
方法组合而成 MapReduce 算法。- 只有纯的、没有反作用的函数,才是合格的函数。
上面这些说法都对,但还不够,都没有回答下面这个更深层的问题。java
为何要这样作?git
这就是,本文要解答的问题。我会经过最简单的语言,帮你理解函数式编程,而且学会它那些基本写法。github
须要声明的是,我不是专家,而是一个初学者,最近两年才真正开始学习函数式编程。一直苦于看不懂各类资料,立志要写一篇清晰易懂的教程。下面的内容确定不够严密,甚至可能包含错误,可是我发现,像下面这样解释,初学者最容易懂。算法
另外,本文比较长,阅读时请保持耐心。结尾还有 Udacity 的《前端工程师认证课程》的推广,很是感谢他们对本文的赞助。编程
1、范畴论
函数式编程的起源,是一门叫作范畴论(Category Theory)的数学分支。json
理解函数式编程的关键,就是理解范畴论。它是一门很复杂的数学,认为世界上全部的概念体系,均可以抽象成一个个的"范畴"(category)。前端工程师
1.1 范畴的概念
什么是范畴呢?
维基百科的一句话定义以下。
"范畴就是使用箭头链接的物体。"(In mathematics, a category is an algebraic structure that comprises "objects" that are linked by "arrows". )
也就是说,彼此之间存在某种关系的概念、事物、对象等等,都构成"范畴"。随便什么东西,只要能找出它们之间的关系,就能定义一个"范畴"。
上图中,各个点与它们之间的箭头,就构成一个范畴。
箭头表示范畴成员之间的关系,正式的名称叫作"态射"(morphism)。范畴论认为,同一个范畴的全部成员,就是不一样状态的"变形"(transformation)。经过"态射",一个成员能够变造成另外一个成员。
1.2 数学模型
既然"范畴"是知足某种变形关系的全部对象,就能够总结出它的数学模型。
- 全部成员是一个集合
- 变形关系是函数
也就是说,范畴论是集合论更上层的抽象,简单的理解就是"集合 + 函数"。
理论上经过函数,就能够从范畴的一个成员,算出其余全部成员。
1.3 范畴与容器
咱们能够把"范畴"想象成是一个容器,里面包含两样东西。
- 值(value)
- 值的变形关系,也就是函数。
下面咱们使用代码,定义一个简单的范畴。
class Category { constructor(val) { this.val = val; } addOne(x) { return x + 1; } }
上面代码中,Category
是一个类,也是一个容器,里面包含一个值(this.val
)和一种变形关系(addOne
)。你可能已经看出来了,这里的范畴,就是全部彼此之间相差1
的数字。
注意,本文后面的部分,凡是提到"容器"的地方,所有都是指"范畴"。
1.4 范畴论与函数式编程的关系
范畴论使用函数,表达范畴之间的关系。
伴随着范畴论的发展,就发展出一整套函数的运算方法。这套方法起初只用于数学运算,后来有人将它在计算机上实现了,就变成了今天的"函数式编程"。
本质上,函数式编程只是范畴论的运算方法,跟数理逻辑、微积分、行列式是同一类东西,都是数学方法,只是碰巧它能用来写程序。
因此,你明白了吗,为何函数式编程要求函数必须是纯的,不能有反作用?由于它是一种数学运算,原始目的就是求值,不作其余事情,不然就没法知足函数运算法则了。
总之,在函数式编程中,函数就是一个管道(pipe)。这头进去一个值,那头就会出来一个新的值,没有其余做用。
2、函数的合成与柯里化
函数式编程有两个最基本的运算:合成和柯里化。
2.1 函数的合成
若是一个值要通过多个函数,才能变成另一个值,就能够把全部中间步骤合并成一个函数,这叫作"函数的合成"(compose)。
上图中,X
和Y
之间的变形关系是函数f
,Y
和Z
之间的变形关系是函数g
,那么X
和Z
之间的关系,就是g
和f
的合成函数g·f
。
下面就是代码实现了,我使用的是 JavaScript 语言。注意,本文全部示例代码都是简化过的,完整的 Demo 请看《参考连接》部分。
合成两个函数的简单代码以下。
const compose = function (f, g) { return function (x) { return f(g(x)); }; }
函数的合成还必须知足结合律。
compose(f, compose(g, h)) // 等同于 compose(compose(f, g), h) // 等同于 compose(f, g, h)
合成也是函数必须是纯的一个缘由。由于一个不纯的函数,怎么跟其余函数合成?怎么保证各类合成之后,它会达到预期的行为?
前面说过,函数就像数据的管道(pipe)。那么,函数合成就是将这些管道连了起来,让数据一口气从多个管道中穿过。
2.2 柯里化
f(x)
和g(x)
合成为f(g(x))
,有一个隐藏的前提,就是f
和g
都只能接受一个参数。若是能够接受多个参数,好比f(x, y)
和g(a, b, c)
,函数合成就很是麻烦。
这时就须要函数柯里化了。所谓"柯里化",就是把一个多参数的函数,转化为单参数函数。
// 柯里化以前 function add(x, y) { return x + y; } add(1, 2) // 3 // 柯里化以后 function addX(y) { return function (x) { return x + y; }; } addX(2)(1) // 3
有了柯里化之后,咱们就能作到,全部函数只接受一个参数。后文的内容除非另有说明,都默认函数只有一个参数,就是所要处理的那个值。
3、函子
函数不只能够用于同一个范畴之中值的转换,还能够用于将一个范畴转成另外一个范畴。这就涉及到了函子(Functor)。
3.1 函子的概念
函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。
它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系能够依次做用于每个值,将当前容器变造成另外一个容器。
上图中,左侧的圆圈就是一个函子,表示人名的范畴。外部传入函数f
,会转成右边表示早餐的范畴。
下面是一张更通常的图。
上图中,函数f
完成值的转换(a
到b
),将它传入函子,就能够实现范畴的转换(Fa
到Fb
)。
3.2 函子的代码实现
任何具备map
方法的数据结构,均可以看成函子的实现。
class Functor { constructor(val) { this.val = val; } map(f) { return new Functor(f(this.val)); } }
上面代码中,Functor
是一个函子,它的map
方法接受函数f
做为参数,而后返回一个新的函子,里面包含的值是被f
处理过的(f(this.val)
)。
通常约定,函子的标志就是容器具备map
方法。该方法将容器里面的每个值,映射到另外一个容器。
下面是一些用法的示例。
(new Functor(2)).map(function (two) { return two + 2; }); // Functor(4) (new Functor('flamethrowers')).map(function(s) { return s.toUpperCase(); }); // Functor('FLAMETHROWERS') (new Functor('bombs')).map(_.concat(' away')).map(_.prop('length')); // Functor(10)
上面的例子说明,函数式编程里面的运算,都是经过函子完成,即运算不直接针对值,而是针对这个值的容器----函子。函子自己具备对外接口(map
方法),各类函数就是运算符,经过接口接入容器,引起容器里面的值的变形。
所以,学习函数式编程,实际上就是学习函子的各类运算。因为能够把运算方法封装在函子里面,因此又衍生出各类不一样类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不一样的函子,解决实际问题。
4、of 方法
你可能注意到了,上面生成新的函子的时候,用了new
命令。这实在太不像函数式编程了,由于new
命令是面向对象编程的标志。
函数式编程通常约定,函子有一个of
方法,用来生成新的容器。
下面就用of
方法替换掉new
。
Functor.of = function(val) { return new Functor(val); };
而后,前面的例子就能够改为下面这样。
Functor.of(2).map(function (two) { return two + 2; }); // Functor(4)
这就更像函数式编程了。
5、Maybe 函子
函子接受各类函数,处理容器内部的值。这里就有一个问题,容器内部的值多是一个空值(好比null
),而外部函数未必有处理空值的机制,若是传入空值,极可能就会出错。
Functor.of(null).map(function (s) { return s.toUpperCase(); }); // TypeError
上面代码中,函子里面的值是null
,结果小写变成大写的时候就出错了。
Maybe 函子就是为了解决这一类问题而设计的。简单说,它的map
方法里面设置了空值检查。
class Maybe extends Functor { map(f) { return this.val ? Maybe.of(f(this.val)) : Maybe.of(null); } }
有了 Maybe 函子,处理空值就不会出错了。
Maybe.of(null).map(function (s) { return s.toUpperCase(); }); // Maybe(null)
6、Either 函子
条件运算if...else
是最多见的运算之一,函数式编程里面,使用 Either 函子表达。
Either 函子内部有两个值:左值(Left
)和右值(Right
)。右值是正常状况下使用的值,左值是右值不存在时使用的默认值。
class Either extends Functor { constructor(left, right) { this.left = left; this.right = right; } map(f) { return this.right ? Either.of(this.left, f(this.right)) : Either.of(f(this.left), this.right); } } Either.of = function (left, right) { return new Either(left, right); };
下面是用法。
var addOne = function (x) { return x + 1; }; Either.of(5, 6).map(addOne); // Either(5, 7); Either.of(1, null).map(addOne); // Either(2, null);
上面代码中,若是右值有值,就使用右值,不然使用左值。经过这种方式,Either 函子表达了条件运算。
Either 函子的常见用途是提供默认值。下面是一个例子。
Either .of({address: 'xxx'}, currentUser.address) .map(updateField);
上面代码中,若是用户没有提供地址,Either 函子就会使用左值的默认地址。
Either 函子的另外一个用途是代替try...catch
,使用左值表示错误。
function parseJSON(json) { try { return Either.of(null, JSON.parse(json)); } catch (e: Error) { return Either.of(e, null); } }
上面代码中,左值为空,就表示没有出错,不然左值会包含一个错误对象e
。通常来讲,全部可能出错的运算,均可以返回一个 Either 函子。
7、ap 函子
函子里面包含的值,彻底多是函数。咱们能够想象这样一种状况,一个函子的值是数值,另外一个函子的值是函数。
function addTwo(x) { return x + 2; } const A = Functor.of(2); const B = Functor.of(addTwo)
上面代码中,函子A
内部的值是2
,函子B
内部的值是函数addTwo
。
有时,咱们想让函子B
内部的函数,可使用函子A
内部的值进行运算。这时就须要用到 ap 函子。
ap 是 applicative(应用)的缩写。凡是部署了ap
方法的函子,就是 ap 函子。
class Ap extends Functor { ap(F) { return Ap.of(this.val(F.val)); } }
注意,ap
方法的参数不是函数,而是另外一个函子。
所以,前面例子能够写成下面的形式。
Ap.of(addTwo).ap(Functor.of(2)) // Ap(4)
ap 函子的意义在于,对于那些多参数的函数,就能够从多个容器之中取值,实现函子的链式操做。
function add(x) { return function (y) { return x + y; }; } Ap.of(add).ap(Maybe.of(2)).ap(Maybe.of(3)); // Ap(5)
上面代码中,函数add
是柯里化之后的形式,一共须要两个参数。经过 ap 函子,咱们就能够实现从两个容器之中取值。它还有另一种写法。
Ap.of(add(2)).ap(Maybe.of(3));
8、Monad 函子
函子是一个容器,能够包含任何值。函子之中再包含一个函子,也是彻底合法的。可是,这样就会出现多层嵌套的函子。
Maybe.of( Maybe.of( Maybe.of({name: 'Mulburry', number: 8402}) ) )
上面这个函子,一共有三个Maybe
嵌套。若是要取出内部的值,就要连续取三次this.val
。这固然很不方便,所以就出现了 Monad 函子。
Monad 函子的做用是,老是返回一个单层的函子。它有一个flatMap
方法,与map
方法做用相同,惟一的区别是若是生成了一个嵌套函子,它会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的状况。
class Monad extends Functor { join() { return this.val; } flatMap(f) { return this.map(f).join(); } }
上面代码中,若是函数f
返回的是一个函子,那么this.map(f)
就会生成一个嵌套的函子。因此,join
方法保证了flatMap
方法老是返回一个单层的函子。这意味着嵌套的函子会被铺平(flatten)。
9、IO 操做
Monad 函子的重要应用,就是实现 I/O (输入输出)操做。
I/O 是不纯的操做,普通的函数式编程无法作,这时就须要把 IO 操做写成Monad
函子,经过它来完成。
var fs = require('fs'); var readFile = function(filename) { return new IO(function() { return fs.readFileSync(filename, 'utf-8'); }); }; var print = function(x) { return new IO(function() { console.log(x); return x; }); }
上面代码中,读取文件和打印自己都是不纯的操做,可是readFile
和print
倒是纯函数,由于它们老是返回 IO 函子。
若是 IO 函子是一个Monad
,具备flatMap
方法,那么咱们就能够像下面这样调用这两个函数。
readFile('./user.txt') .flatMap(print)
这就是神奇的地方,上面的代码完成了不纯的操做,可是由于flatMap
返回的仍是一个 IO 函子,因此这个表达式是纯的。咱们经过一个纯的表达式,完成带有反作用的操做,这就是 Monad 的做用。
因为返回仍是 IO 函子,因此能够实现链式操做。所以,在大多数库里面,flatMap
方法被更名成chain
。
var tail = function(x) { return new IO(function() { return x[x.length - 1]; }); } readFile('./user.txt') .flatMap(tail) .flatMap(print) // 等同于 readFile('./user.txt') .chain(tail) .chain(print)
上面代码读取了文件user.txt
,而后选取最后一行输出。
10、参考连接
- JS 函数式编程指南
- Taking Things Out of Context: Functors in JavaScript
- Functor.js
- Maybe, Either & Try Functors in ES6
- Why Category Theory Matters
(正文完)
============================
感谢你读完了全文。下面还有一个推广,请再花一分钟阅读。
(完)