跳到主要内容

函数式编程

范畴论 Category Theory

函数式编程是范畴论的数学分支,是一门很复杂的数学,认为世界上所有概念体系都有可以抽象出一个个范畴

彼此之间存在某种关系概念、事务、对象等等,都构成范畴,任何事务只要找出他们之间的关系,就能定义

范畴

箭头表示范畴成员之间的关系,正式名称为“态射”(morphism)。范畴论认为,同一个范畴的所有成员,就是不同状态下的“变形”(transformation)。通过“态射”,一个成员可以变成另外一个成员。

  • 所有成员是一个集合
  • 变形关系是函数

函数式编程基础概念

函数必须是接受一个参数,函数必须返回一个值,函数应该依据接收到的参数运行(如 x),而不是外部的环境运行,对于给定的 x 只会输出唯一的 y

函数式不是用函数来编程,也不是传统的面向对象过程,主旨是在于将复杂的函数,分解成简单的函数,运算的过程尽量写成一系列函数的嵌套调用

通俗写法 function xx(){} 区别开函数和方法;方法要与指定的对象绑定,函数可以直接调用

函数式编程其实相对于计算机的历史而言是一个非常古老的概念.甚至早于第一台计算机的诞生

函数式编程的基础模型来源于λ(lambda x=>x*2);演算,而λ演算并非设计在计算机上执行,它是在 20 世纪三十年代引入的一套用于研究函数定义,函数应用和递归的形式系统

JavaScript 是披着 c 外衣的 LISP

真正的火热是随着 react 的高阶组件而逐步升温; redux 把它领向高潮!

函数是一等公民

函数是第一等公民.所谓的"第一等公民",指的是函数与其他数据类型一样,处于平等地位,可以赋值给其他变量,也可以作为参数;传入另一个函数,或者作为别的函数的返回值

不可变变量.在函数式编程中,我们通常可以理解的变量在函数式编程中也被函数代替了;在函数式编程中变量仅仅代表某个表达式,这里所说的"变量"是不能被修改的.所有的变量只能被赋值一次初始值

map 和 reduce 它们是最常用的函数式编程的方法

特点

  • 函数是“一等公民”
  • 只用表达式,不使用语句; 没有 if,else 什么的东西,用数学的思维解决它
  • 没有副作用
  • 不修改状态
  • 引用透明(函数运行只依靠参数,且相同的输入总是获得相同的输出)

函数式编程核心概念

纯函数

纯函数,对于相同的输入总是有相同的输出;而且没有任何可观察的副作用,也不依赖任何外部环境的状态

使用函数式编程的时候,函数务必要纯;但是实际开发中并不能保证代码的纯度,目标是尽量的纯;避免面被污染

let arr = [1,2,3]
console.log(arr.slice(0,3)) //[1,2,3]
console.log(arr.slice(0,3)) //[1,2,3]
console.log(arr.slice(0,3)) //[1,2,3]
//slice 就是纯函数;

console.log(arr.splice(0,3)) //[1,2,3]
console.log(arr.splice(0,3))//[]
console.log(arr.splice(0,3))//[]
//splice 就改变了 arr,这个 arr 就被污染了;相同的输入,输出结果不同

优缺点


// area 1
import _ from 'lodash';
var sin = _.memorize(x => Math.sin(x));

// 第一次缓存会慢一些
var a = sin(1);
// 第二次有了缓存,速度极快
var b = sin(1);

// area 2
var min = 18;
// 非纯函数
var checkage = age => age > min;
// 纯函数
var checkage = age => age > 18

纯函数不仅可以有效降低系统的复杂度,还有很多很棒的性能,比如可缓存性,但扩展性较差,可以通过函数的柯里化来解决

纯度和幂等性

幂等性

指函数执行无数次后还具有相同的效果,同一参数运行一次函数应该与连续两次或多次结果一致。幂等性在函数式编程中与纯度相关,但有不一致

Math.abs(Math.abs(-42))

偏应用函数

偏应用函数(partial application)

传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数

偏函数之所以偏,就在于其只能处理那些能与至少一个 case 语句匹配的输入,而不能处理所有可能的输入

// 带一个函数参数和该函数的部分参数
const parital = (f, ...args) => {
(...moreArgs) => {
f(...args, ...moreArgs)
}
}

const add3(a, b, c) => a + b + c

// 偏应用 2 和 3 到 add3 给你的一个单参数的函数
const fivePlus = partial(add3, 2, 3)
fivePlus(4)
// bind 实现
const add1More = add3.bind(null, 2, 3) // (c)=> 2 + 3 + c

函数的柯里化

柯里化(curried) 通过偏应用函数实现。它把一个多参数的函数转换为一个嵌套一元函数的过程

传递给函数一部分参数来调用它,让他返回一个函数去处理剩下的函数

改改上边的例子:

var checkage = min => (age => age > min)
var ck18 = checkage(18)
ck18(20)

函数的反柯里化

函数柯里化,是固定部分参数,返回一个接受剩余参数的函数,也称为部分计算函数,目的是为了缩小适用范围,创建一个针对性更强的函数

那么反柯里化函数,从字面上讲,意义和用法和函数柯里化相反,扩大适用范围,创建一个应用范围更广的函数。使本来只有特定对象才适用的方法,扩展到更多的对象

// 柯里化
// 柯里化之前
function add (x, y) {
return x + y
}
add(1, 2) // 3

// 柯里化之后

function addX(x) {
return function(y) {
return x + y
}
}
add(1)(2) // 3

// 反柯里化
Function.prototype.unCurring = function() {
var self = this
return function () {
var obj = Array.prototype.shift.call(arguments)
return self.apply(obj, arguments)
}
}

var push = Array.prototype.push.unCurrying()
var obj = {}
push(obj, 'first', 'two')
console.log(obj)

优缺点

事实上,柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到已经记住了这鞋参数的新函数,某种意义上讲,这是一种对参数的缓存,是一种非常搞笑的编码函数的方法

柯里化和偏应用的区别

柯里化的参数列表是从左向右的,如果使用 setTimeout 这种就得额外的封装

    const setTimeoutWraper = (timeout,fn)=>{
setTimeout(fn,timeout)
}
const delayTenMs = curring(setTimeoutWraper)(10)
delayTenMs(()=>console.log('1'));
delayTenMs(()=>console.log(2))

setTimeoutWraper 显得多余,这时候我们就可以使用偏函数,使用 curry 和partial 是为了让函数参数或者函数设置变得更加简单和强大, curry 和 partial实现方式可以参考 lodash

函数组合

函数柯里化之后,一旦写出 h(g(f(x))) 为了解决这个问题并且让函数更加自由,函数组合应运而生

函数组合子

更多了解查看

compose 函数只能组合接受一个参数的函数,类似于 filter、map、接受两个参数(投影函数:总是在应用转换操作,通过传入高阶参数后返回数组),不能被直接组合可以借助偏函数包裹后继续组合

函数组合的数据流是从右至左,因为最右边的函数首先执行,将数据传递给下一个函数一次类推,有人喜欢另外一种方式,最左侧先执行,我们可以实现 pipe 管道函数。它和 compose 所做的事情一致,只不过交换了数据的方向

因此我们需要对组合子管理程序的控制流

命令式代码能够使用 if-else 和 for 这样的过程控制,函数式则不能.所以我们需要函数组合子.组合子可以组合其他函数(或者其他组合子),并作为控制逻辑单元的高阶函数,组合子通常不声明任何变量,也不包含任何业务逻辑,它们旨在管理函数程序执行流程,并在链式调用中对中间结果进行操作

常见组合子

  • 辅助组合子

nothing(没有)、identity(照旧)、defaultTo(默许)、always(恒定)

  • 函数组合子

收缩(gather) ,展开(spread) , 颠倒(reverse) , 左偏(partial) ,右偏(partialRight),柯里化(curry) , 弃离(tap) , 交替(alt) ,补救(tryCatch) ,同时(seq),聚集(converge),映射(map),分捡(useWith),规约(reduce),组合(compose)

  • 谓语组合子

过滤(filter),分组(group),排序(sort)

  • 其他

组合子变换 juxf

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),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。

Maybe 函子就是为了解决这一类问题而设计的。简单说,它的 map 方法里面设置了空值检查。

class Maybe extends Functor {
constructor(value) {
super();
this.val = value;
}
isnothing() {
return !!!this.val;
}

map(f) {
if (this.isnothing()) {
// 如果没有值,不执行变形函数,直接返回一个新函子 null。
return Maybe.of(null);
} else {
return Maybe.of(f(this.val));
}
}
}

Either 函子

条件运算 if...else 是最常见的运算之一,函数式编程里面,使用 Either 函子表达。

Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。

class Either extends Functor {
constructor(value) {
super();
this.val = value;
}
isnothing() {
return !!!this.val;
}
map(left, right) {
if (this.isnothing()) {
return Either.of(left(null));
} else {
return Either.of(right(this.val));
}
}
}

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 {
constructor(value) {
super();
this.val = value;
}
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,然后选取最后一行输出。

总结

  • 面向过程编程:想到哪写到哪。
  • 函数式编程:提纯无关业务的纯函数,函数套函数产生神奇的效果。
  • 函数式编程里,同样的输入一定会有同样的输出,永远不依赖外部的状态。
    • 纯函数可以记忆(同样的输入一定会有同样的输出),不跟外界有任何关系,抽象代码方便。
  • 函数式编程可以解决多线程死锁问题,在每一个函数式编程里面,根本不设计到外部的那个被几个线程争执的变量。
  • 函数式编程可以用来抽象业务逻辑,当系统里有很多可以复用,组合起来有更强大的功能的时候,可以考虑抽库,增加代码健壮性,方便单元测试。
  • 函数式编程会充盈着大量的闭包,闭包是 js 中常见的核心知识。
  • 函数柯里化:函数接收一堆参数,返回一个新函数,用来继续接收参数,处理业务逻辑。它可以记住参数,相当于是对参数的一种缓存。
  • 函数组合:是为了解决多个函数嵌套调用产生的洋葱式的代码。
  • 惰性函数:比较懒的函数,下一次就不想再求值了(将上一次的运行结果缓存起来了)。
  • 高阶函数:将函数传给函数,让函数具有更复杂的能力和功能。

流行函数式编程库

  • RxJS
  • cycleJS
  • lodash
  • underscoreJS
  • ramdaJS

参考链接

函数式编程入门教程