一文读懂JavaScript函数式编程重点-- 实践 总结
什么是函数式编程?
函数式编程是一种思维方式,函数式编程与命令式编程最大的不同其实在于:
函数式编程关心数据的映射,命令式编程关心解决问题的步骤。函数式编程的初衷来, 也就是: 希望可以允许程序员用计算来表示程序, 用计算的组合来表达程序的组合, 而非函数式编程则习惯于用命令来表示程序, 用命令的顺序执行来表达程序的组合。
好记性不如烂笔头,有时间将JS函数式编程,在JS方面毕竟有限,如果真要学习好函数式编程,建议学习下Haskell,本文就是将关于JS方面知识点尽可能总结全面。
- 柯里化
- 偏应用
- 组合与管道
- 函子
- Monad
1. 柯里化
- 什么是柯里化呢?
柯里化是把一个多参数函数转化为一个嵌套的一元函数的过程。下面我们用介绍柯里化时候很多文章都会使用的例子,加法例子(bad smile)。
// 原始版本
const add = (x,y) => x + y;
// ES6 柯里化版本
const addCurried = x => y => x + y;
你没有看错,就是这么简单,柯里化就是将之前传入的多参数变为传入单参数,解释下,柯里化版本,其实当传入一个参数addCurried(1)时,实际会返回一个函数 y=>1+y,实际上是将add函数转化为含有嵌套的一元函数的addCurried函数。如果要调用柯里化版本,应该使用addCurried(1)(2)方式进行调用 会达到和add(1,2)一样的效果,n 个连续箭头组成的函数实际上就是柯里化了 n - 1次,前 n - 1 次调用,其实是提前将参数传递进去,并没有调用最内层函数体,最后一次调用才会调用最内层函数体,并返回最内层函数体的返回值。
看到这里感觉是不是很熟悉,没错,React 中间件。
以上是通过ES6箭头函数实现的,下面我们构建curryFn来实现这个过程。
此函数应该比较容易理解,比较函数参数以及参数列表的长度,递归调用合并参数,当参数都为3,不满足,调用fn.apply(null, args)。
例子: 使用以上的curryFn 数组元素平方函数式写法。
const curryFn = (fn) => {
if(typeof fn !== 'function'){
throw Error ('Not Function');
}
return function curriedFn(...args){
if(args.length < fn.length){
return function(){
return curriedFn.apply(null, args.concat(
[].slice.call(arguments)
))
}
}
return fn.apply(null, args);
}
}
const map = (fn, arr) => arr.map(fn);
const square = (x) => x * x;
const squareFn = curryFn(map)(square)([1,2,3])
从上例子可以观察出curryFn函数应用参数顺序是从左到右。如果想从右到左,下面一会会介绍。
2. 偏应用
上面柯里化我们介绍了我们对于传入多个参数变量的情况,如何处理参数关系,实际开发中存在一种情况,写一个方法,有些参数是固定不变的,即我们需要部分更改参数,不同于柯里化得全部应用参数。
const partial = function (fn, ...partialArgs) {
let args = partialArgs;
return function(...fullArguments) {
let arg = 0;
for (let i = 0; i < args.length && arg < fullArguments.length; i++) {
if (args[i] === null) {
args[i] = fullArguments[arg++];
}
}
return fn.apply(null, args)
}
}
partial(JSON.stringify,null,null,2)({foo: 'bar', bar: 'foo'})
应用起来 2 这个参数是不变的,相当于常量。简单解释下这个函数,args指向 [null, null, 2], fullArguments指向 [{foo:'bar', bar:'foo'}] ,当i==0时候 ,这样 args[0] ==fullArguments[0],所以args就为[{foo:'bar', bar:'foo'},null,2],然后调用,fn.apply(null, args)。
3. 组合与管道
组合
组合与管道的概念来源于Unix,它提倡的概念大概就是每个程序的输出应该是另一个未知程序的输入。我们应该实现的是不应该创建新函数就可以通过compose一些纯函数解决问题。
- 双函数情况
const compose = (a, b) => c => a(b(c))
我们来应用下:
const toNumber = (num) => Number(num);
const toRound = (num)=> Math.round(num);
// 使用compose
number = compose(toRound,toNumber)('4.67'); // 5
- 多函数情况
我们重写上面例子测试:
const compose = (...fns) => (value) => fns.reverse().reduce((acc, fn) => fn(acc), value);
const toNumber = (num) => Number(num);
const toRound = (num)=> Math.round(num);
const toString = (num) => num.toString();
number = compose(toString,toRound,toNumber)('4.67'); // 字符串 '5'
从上面多参数以及双参数情况,我们可以得出compose的数据流是从右到左的。那有没有一种数据流是从左到右的,答案是有的就是下面我们要介绍的管道。
管道
管道我们一般称为pipe函数,与compose函数相同,只不过是修改了数据流流向而已。
const pipe = (...fns) => (value) => fns.reduce((acc, fn) => fn(acc), value);
const toNumber = (num) => Number(num);
const toRound = (num)=> Math.round(num);
const toString = (num) => num.toString();
number = compose(toString,toRound,toNumber)('4.67'); // 数字 5
4. 函子
函子(Functor)即用一种纯函数的方式帮我们处理异常错误,它是一个普通对象,并且实现了map函数,在遍历每个对象值得时候生成一个新对象。我们来看几个实用些的函子。
- MayBe 函子
// MayBe 函数定义
const MayBe = function (val) {
this.value = val;
}
MayBe.of = function (val) {
return new MayBe(val);
}
// MayBe map 函数定义
MayBe.prototype.isNothing = function () {
return (this.value === null || this.value === underfind)
}
MayBe.prototype.map = function (fn) {
return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this.value));
}
MayBe并不关心参数是否为null或者underfind,因为它已经被MayBe函子抽象出来了,代码不会因为null或者underfind崩溃,可以看出,通过函子我们不需要关系那些特殊情况下的判断,程序也不会以为的崩溃。
另外一点是,当都多个map链式调用时,如果第一个map参数是null或者underfind,并不会影响到第二个map正常运行,也就是说,任何map的链式调用都会调用到。
MayBe.of('abc').map((x)=>x.toUpperCase()) // MayBe { value: 'ABC' }
// 参数为null
MayBe.of(null).map((x)=>x.toUpperCase()) // MayBe { value: null }
// 链式调用中第一个参数为null
MayBe.of('abc').map(()=>null).map((x)=> 'start' + x) // MayBe { value: null }
- Either函子
Either函子主要解决的是MayBe函子在执行失败时不能判断哪一只分支出问题而出现的,主要解决的分支扩展的问题。
我们实现一下Either函子:
const Nothing = function (val) {
this.value = val;
}
Nothing.of = function (val) {
return new Nothing(val);
}
Nothing.prototype.map = function (f) {
return this;
}
const Some = function(val){
this.value = val;
}
Some.of = function(val) {
this.value = val;
}
Some.prototype.map = function(fn) {
return Some.of(fn(this.value))
}
const Either = {
Some: Some,
Nothing: Nothing
}
实现包含两个函数,Nothing函数只返回函数自身,Some则会执行map部分,在实际应用中,可以将错误处理使用Nothing,需要执行使用Some,这样就可以分辨出分支出现的问题。
5. Monad
Monad应该是这几个中最难理解的概念了,因为本人也没有学过Haskell,所以也可能对Monad理解不是很准确,所以犹豫要不要写出来,打算学习Haskell,好吧,先记录下自己理解,永远不做无病呻吟,有自己感触与理解才会记录,学过之后再次补充。
Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算所需的函数,整个运算就会自动进行下去。那么构成Monad 组成条件有哪些呢?
- 类型构造器,因为Monad实际处理的是数据类型,而不是值,必须有一个类型构造器,这个类型构造器的作用就是如何从指定类型构造新的一元类型,比如Maybe<number>,定义
Maybe<number>
了基础类型的类型number
,我们月可以把这种类型构造器理解为封装了一个值,这个值既可以是用数据结构进行封装,也可以使用函数,通过返回值表达封装的值,一般也说Monad是一个“未计算的值”、“包含在上下文(context)中的值”。 - 提升函数。这个提升函数一般指的是return或者unit,说白了,提升函数就是将一个值封装进了Monad这个数据结构中,签名为 return :: a -> M a 。将unit基础类型的值包装到monad中的函数。对于Maybe monad,它将2类型number的值包装到类型的值Maybe(2)Maybe<number>。
- 绑定函数bind。绑定函数就像一个管道,它解封一个Monad,将里面的值传到第二个参数表示的函数,生成另一个Monad。形式化定义为(ma 为类型为的 Monad 实例,是转换函数)。此bind功能是不一样的Function.prototype.bind 功能。它用于创建具有绑定this值的部分应用函数或函数。
就像一个盒子一样,放进盒子里面(提升函数),从盒子里面取出来(绑定函数),放进另外一个盒子里面(提升函数),本身这个盒子就是类型构造器。
举一个常用的例子,这也是Monad for functional programming,里面除法的例子,实现一个求值函数evaluate
,它可以接收类似
function evaluate(e: Expr): Maybe<number> {
if (e.type === 'value') return Maybe.just(<number>e.value);
return evaluate((<DivisionExpr>e.value).left)
.bind(left => evaluate((<DivisionExpr>e.value).right)
.bind(right => safeDiv(left, right)));
}
在像JavaScript这样的面向对象语言中,unit
函数可以表示为构造函数,函数可以表示为bind
实例方法。
还有三个遵守的monadic法则:
- bind(unit(x), f) ≡ f(x)
- bind(m, unit) ≡ m
- bind(bind(m, f), g) ≡ bind(m, x ⇒ bind(f(x), g))
const unit = (value: number) => Maybe.just<number>(value);
const f = (value: number) => Maybe.just<number>(value * 2);
const g = (value: number) => Maybe.just<number>(value - 5);
const ma = Maybe.just<number>(13);
const assertEqual = (x: Maybe<number>, y: Maybe<number>) => x.value === y.value;
// first law
assertEqual(unit(5).bind(f), f(5));
// second law
assertEqual(ma.bind(unit), ma);
// third law
assertEqual(ma.bind(f).bind(g), ma.bind(value => f(value).bind(g)));
前两个说这unit是一个中性元素。第三个说bind应该是关联的 - 绑定的顺序无关紧要。这是添加具有的相同属性:(8 + 4) + 2与...相同8 + (4 + 2)。
举几个比较常见的Monad:
1. Promise Monad
没有想到吧,你平时使用的Promise就是高大上的Monad,它是如何体现的这三个特性呢?
- 类型构造器就是Promise
- unit提升函数 为x => Promise.resolve(x)
- 绑定函数 为Promise.prototype.then
fetch('xxx')
.then(response => response.json())
.then(o => fetch(`xxxo`))
.then(response => response.json())
.then(v => console.log(v));
最简单的 P(A).then(B) 实现里,它的 P(A) 相当于 Monad 中的 unit 接口,能够把任意值包装到 Monad 容器里。支持嵌套的 Promise 实现中,它的 then 背后其实是 FP 中的 join 概念,在容器里还装着容器的时候,递归地把内层容器拆开,返回最底层装着的值。Promise 的链式调用背后,其实是 Monad 中的 bind 概念。你可以扁平地串联一堆 .then(),往里传入各种函数,Promise 能够帮你抹平同步和异步的差异,把这些函数逐个应用到容器里的值上。回归这节中最原始的问题,Monad 是什么呢?只要满足以上三个条件,我们就可以认为它是 Monad 了:正如我们已经看到的,Promise.resolve() 能够把任意值包装到 Promise 里,而 Promise/A+ 规范里的 Resolve 算法则实际上实现了 bind。因此,我们可以认为:Promise 就是一个 Monad。
2. Continuation Monad
continuation monad用于异步任务。幸运的是,ES6没有必要实现它 - Prmise对象是这个monad的一个实现。
- Promise.resolve(value)包装一个值并返回一个promise(unit函数)。
- Promise.prototype.then(onFullfill: value => Promise)将一个值转换为另一个promise并返回一个promise(bind函数)的函数作为参数。
Promise为基本的continuation monad提供了几个扩展。如果then
返回一个简单的值(而不是一个promise对象), 他将被视为Promise,解析为该值 自动将一个值包装在monad中。
第二个区别在于错误传播。Continuation monad允许在计算步骤之间仅传递一个值。另一方面,Promise有两个不同的值 - 一个用于成功值,一个用于错误(类似于Either monad)。可以使用方法的第二个回调then或使用特殊。catch方法捕获错误。
下面定义了一个简单的Monad类型,它单纯封装了一个值作为value
属性:
var Monad = function (v) {
this.value = v;
return this;
};
Monad.prototype.bind = function (f) {
return f(this.value)
};
var lift = function (v) {
return new Monad(v);
};
我们将一个除以2的函数应用的这个Monad:
console.log(lift(32).bind(function (a) {
return lift(a/2);
}));
// > Monad { value: 16 }
连续应用除以2的函数:
// 方便展示用的辅助函数,请忽视它是个有副作用的函数。
var print = function (a) {
console.log(a);
return lift(a);
};
var half = function (a) {
return lift(a/2);
};
lift(32)
.bind(half)
.bind(print)
.bind(half)
.bind(print);
//output:
// > 16
// > 8