目录
一.范畴论
- 函数式编程是范畴论的数学分支是一门很复杂的数学,认为世界上所有概念体系都可以抽象出一个个范畴
- 彼此之间存在某种关系概念、事物、对象等等,都构成范畴。任何事物只要找出他们之间的关系,就能定义
- 箭头表示范畴成员之间的关系,正式的名称叫做“态射”(morphism)。范畴论认为,同一个范畴的所有成员,就是不同状态的“变形”(transformation)。通过“态射”,一个成员可以变形成另一个成员
二.函数式编程基础理论
- 函数式编程其实相对于计算机的历史而言是一个非常古老的概念,甚至早于第一台计算机的诞生。函数式编程的基础模型来源于λ(Lambda x=>x*2)演算,而λ演算并非设计于在计算机上执行,它是在20世纪三十年代引入的一套用于研究函数定义、函数应用和递归的形式系统。
- 函数式编程不是用函数来编程,也不是传统的面向过程编程。主旨在于将复杂的函数符合成简单的函数(计算理论,或者递归论,或者拉姆达演算)。运算过程尽量写成一系列嵌套的函数调用。
- Javascript是披着c外衣的Lisp
// 用函数编程,非函数式编程 function test(){ } test()
- 真正的火热是随着React的高阶函数而逐渐升温
三.特性
- 函数是一等公民。所谓“第一等公民”,指的是函数与其他数据类型一样,传入另一个函数,或者作为别的函数的返回值。
- 不可改变变量。在函数式编程中,我们通常理解的变量在函数式编程中也被函数代替了:在函数式编程中变量仅仅代表某个表达式。这里所说的"变量"是不能被修改的。所有的变量只能被赋一次初值
- 没有“副作用”
- 不修改状态
- map和reduce是最常用的函数式编程方法
四.高阶函数
1.纯函数
- 对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态。
-
var xs = [1,2,3,4]; //Array.slice是纯函数,因为它没有副作用,对于固定的输入,输出总是固定的 xs.slice(0,2); xs.splice(0,2); //会改变源数据,非纯函数
优缺点:
-
import _ from 'loadsh'; var sin = _.memorize(x => Math.sin(x)); //第一次计算的时候会稍微慢一点 var a = sin(1); //第二次有了缓存,速度极快 var b = sin(1); //纯函数不仅可以有效降低系统的复杂度,还有很多很棒的特性,比如可缓存性
-
//不纯的 var min = 18; var checkage = age => age > min; //纯的,这很函数式 var checkage = age => age > 18; //在不纯的版本中,checkage不仅取决于age 还有外部依赖的变量min //纯的checkage把关键数字18硬编码在函数内部,扩展性比较差,柯里化优雅的函数式可以解决问题 //柯里化之前 function add(x, y) { return x + y } add(1, 2) //柯里化之后 function addX(y) { return function (x) { return x + y } } addX(2)(1)
2.函数的柯里化
- 传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
-
var checkage = min => (age => age > min); var checkage2 = checkage(18); checkage2(20);
function foo(p1, p2) { this.val = p1 + p2; } var bar = foo.bind(null, 'p1'); var baz = new bar('p2'); console.log(baz.val);
事实上柯里化是一种 “预加载” 函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,是一种非常高效的编写函数的方法。
3.point free
- 把一些对象自带的方法转化成纯函数,不要命名转瞬即逝的中间变量
-
//转变前,我们不关心str这个中间变量 const f = str => str.toUpperCase().split(' '); //转换后 var toUpperCase = word => word.toUpperCase(); var split = x => (str => str.split(x)); var f = compose(split(' '), toUpperCase); f("abcd efgh") //这种风格能够帮助我们减少不必要的命名,让代码保持简洁和通用 //实现一个compose function compose(...funcs) { // 没有传入函数运行直接返回参数 if (funcs.length === 0) { return arg => arg } // 只传入一个函数,就返回其本身 if (funcs.length === 1) { return funcs[0] } // 核心代码其实就是一句reduce, reduce特性就是按顺序执行,并且将结果传递给下一次执行 // reduce顺序执行多个相依赖的promise也很好用 return funcs.reduce((a, b) => (...args) => a(b(...args))) }
4.声明式与命令式代码
- 命令式代码的意思就是,通过编写一条又一条指令去让计算机执行一些动作,这其中一般会涉及到很多繁杂的细节。而声明式就要优雅很多,通过写表达式方法来声明我们想干什么,而不是通过一步一步的指示
//命令式 let arr = [] let list = [{ id: 1, name: '李四' }] for (let i = 0; i < list.length; i++) { arr.push(list[i].id) } //声明式 let arr = list.map(c => c.id)
5.优缺点
- 函数式编程的一个明显的好处就是这种声明式的代码,对于无副作用的纯函数,我们完全可以不考虑函数内部是如何实现的,专注于编写业务代码。优化代码时,目光只需要集中在这些稳定坚固的函数内部即可
- 不纯的函数式代码会产生副作用或者依赖外部系统环境,使用它们时总是要考虑这些不干净的副作用。在复杂的系统中,维护成本加大。
6.惰性函数
- 函数执行的分支只会在函数第一次调用的时候执行,在第一次调用过程中,该函数会被覆盖为另一个按照合适方式执行的函数,这样任何对原函数的调用就不用再经过执行的分支了。
// 因为各浏览器之间的行为差异,经常会在函数中包含了大量的 if 语句, // 以检查浏览器特性,解决不同浏览器的兼容问题。 function addEvent(type, element, fun) { if (element.addEventListener) { addEvent = function (type, element, fun) { element.addEventListener(type, fun, false); } } else if (element.attachEvent) { addEvent = function (type, element, fun) { element.attachEvent('on' + type, fun); } } else { addEvent = function (type, element, fun) { element['on' + type] = fun; } } return addEvent(type, element, fun); } // 声明函数时就指定适当的函数 var addEvent = (function () { if (document.addEventListener) { return function (type, element, fun) { element.addEventListener(type, fun, false); } } else if (document.attachEvent) { return function (type, element, fun) { element.attachEvent('on' + type, fun); } } else { return function (type, element, fun) { element['on' + type] = fun; } } })();
7.高阶函数
- 函数当参数,把传入的函数做一个封装,然后返回这个封装函数,达到更高程度的抽象。
//命令式 let add = function(a, b) { return a + b } function math(func, array) { return func(array[0], array[1]) } math(add, [1, 2])
8.尾调用
某个函数的最后一步是调用另一个函数,中间没有其他操作。
- 传统递归:普通递归时,内存需要记录调用的堆栈所在的深度和位置信息。在最底层计算返回值,再根据记录的信息,跳回上一层级计算,然后再跳回更高一层,依次运行,直到最外层的调用函数。在cpu计算和内存会消耗很多,而且深度过大时,会出现堆栈溢出。
function sum(n) { if (n === 1) return 1; return n + sum(n - 1) } sum(5) // (5 + sum(4)) // (5 + (4 + sum(3))) // ... // (5 + (4 + (3 + (2 + sum(1))))) // (5 + (4 + (3 + (2 + 1)))) // ... // (5 + (4 + 6)) // (5 + 10) // 15
- 细数尾递归:整个计算过程是线性的,调用一次sum(x, total)后,会进入下一个栈,相关的数据信息跟随进入,不再放在堆栈上保存。当计算完成最后的值之后,直接返回到最上层的sum(5,0)。这能有效防止堆栈溢出。
- 尾调用优化:即只保留内层函数的调用记录。 函数调用会在内存形成一个"调用记录",又称"调用帧"(call frame),保存调用位置和内部变量等信息。如果在函数A的内部调用函数B,那么在A的调用记录上方,还会形成一个B的调用记录。等到B运行结束,将结果返回到A,B的调用记录才会消失。如果函数B内部还调用函数C,那就还有一个C的调用记录栈,以此类推。所有的调用记录,就形成一个"调用栈"(call stack)。 如果所有函数都是尾调用,那么完全可以做到每次执行时,调用记录只有一项,这将大大节省内存。
- 在ES6,通过尾调用优化,js代码在解释成机器码的时候,将会向while看齐,也就是说,同时拥有数学表达能力和while的效能。 ES6的尾调用优化只在严格模式下开启,正常模式是无效的。这是因为在正常模式下,函数内部有两个变量,可以跟踪函数的调用栈。
arguments:返回调用时函数的参数。 func.caller:返回调用当前函数的那个函数。 尾调用优化发生时,函数的调用栈会改写,因此上面两个变量就会失真。 严格模式禁用这两个变量,所以尾调用模式仅在严格模式下生效。
-
function sum(x, total) { if (x === 1) { return x + total } return sum(x - 1, x + total) } // sum(5, 0) // sum(4, 5) // sum(3, 9) // sum(2, 12) // sum(1, 14) // 15
-
// 尾递归优化 function foo(n) { return bar(n*2) } function bar() { // 查看调用帧 console.trace() } // 只有一个执行栈 foo@ VM65:2 (anonymous) @ VM65:10 // 强制指定 只留下bar return continue !return #function() // 浏览器并未支持
- 闭包:当函数可以记住并访问所在的词法作用域时,就产生了闭包,即使函数是在当前词法作用域之外执行。
// 虽然外层的makePowerFn函数执行完毕,栈上的调用帧被释放, // 但是堆上的作用域并不能被释放,因此power依旧可以被powerFn函数访问,这样就形成了闭包 function makePowerFn(power) { function powerFn(base) { return Math.pow(base, power); } return powerFn; } var square = makePowerFn(2); square(3); // 9
五.函子
- 函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。
-
函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
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
方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。
因此,学习函数式编程,实际上就是学习函子的各种运算。由于可以把运算方法封装在函子里面,所以又衍生出各种不同类型的函子,有多少种运算,就有多少种函子。函数式编程就变成了运用不同的函子,解决实际问题。
六.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)
七.Maybe函子
- 函子接受各种函数,处理容器内部的值。这里就有一个问题,容器内部的值可能是一个空值(比如
null
),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。Functor.of(null).map(function (s) { return s.toUpperCase(); }); // TypeError class Maybe extends Functor { map(f) { return this.val ? Maybe.of(f(this.val)) : Maybe.of(null); } } Maybe.of(null).map(function (s) { return s.toUpperCase(); }); // Maybe(null)
八.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 函子的另一个用途是代替
try...catch
,使用左值表示错误。
九.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));
十.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)。
十一.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,然后选取最后一行输出。