编程范式,对运算过程的抽象,将程序的本质(输入通过运算得到输出的过程)进行进一步的抽象,把运算过程中各种运算进行更加细粒度的划分为各种各样的函数,然后用函数的组合方式来抽象得到整个运算。这样有利于复用代码,灵活组合出不同的运算过程。
函数一词指的是数学中的函数定义,即输入和输出的映射关系
高阶函数可以接收函数作为参数,可以返回函数作为返回值。
将通用的运算过程封装在高阶函数内部,通过传入其他函数作为参数来实现各种不同的处理结果。
(A) => B; // 形如该语法的就是lambda表达式,即ES6中的箭头函数,可以用相对简洁的语法来声明一个匿名函数
因为函数的本质就是映射输入到输出,所以lambda表达式可以很清晰的显示这一点;
当函数调用之后,调用函数的外部环境依然有对函数内部成员的引用,此时内部成员就成为了闭包变量,被调用的函数不会释放内部成员的内存。一般都是从一个外部函数返回一个定义在其中的内部函数的场景。
相同的输入,相同的输出
函数的输出不仅依赖于内部环境,还依赖于外部环境,则此时函数无法保证输出只与输入相关,函数具有副作用,副作用代码就是依赖于外部环境的代码。
所有的外部交互都可能带来副作用
副作用不可避免,要争取将副作用控制在一定范围内。
定义:可以对某一种数据类型进行连续的链式调用该类型的各种方法,从而对该数据进行很复杂的处理行为,并且使得函数式声明形式的代码更加易读。链式调用的前提是方法需要返回该数据的实例。
缺点:链式调用存在着紧耦合,也就是说链式调用的方法必须是该数据类型支持的方法,无法对该数据类型应用任意的函数来处理。
定义:将任意函数组合成有序的函数序列,前一个函数的输出作为后一个函数的输入,这样摆脱了数据类型与方法的耦合问题,使得对任意数据可以采用任意的函数来处理。这也是函数式编程重操作、轻数据的体现。
注意:为了实现管道化,被连接在一起的函数需要在参数数量(称为元数arity)和类型上互相兼容。
函数的复杂性与函数的参数数量息息相关。 单一参数的纯函数简单,也满足函数的单一职责的编程原则。可以采用元组的数据结构(JavaScript没有元组的定义,需要自定义元组类型)来解决将多元参数变为一元参数的问题,或者采用函数的柯里化来将多元函数转换为一元函数。
将多元函数转换为可以接收一元、二元等不定参数的函数,每次传入参数调用后返回一个新的函数,直到最终所接收的参数长度与原来函数长度一致时,才返回多元函数的调用结果。
柯里化可以将函数参数进一步细粒化,由参数的值来定制不同的运算,提高函数的复用性。
_.curry(func)
将传入的func包装返回一个柯里化的函数curried。curried函数如果接收一部分参数(func函数的参数),则返回一个函数继续接收剩余参数;如果接收了全部参数,则返回调用结果。
function curry(func) {
// 命名curried,方便后面进行递归调用;
return function curried(...args) {
// 对func的形参个数(函数的lenght属性)和柯里化后的函数传入的实参个数作比较
if(args.length < func.length) {
return function() {
return curried(...args.concat(Array.from(arguments)));
}
} else {
// 当实参个数不小于形参个数时,则调用func函数;
return func(...args);
}
}
}
纯函数和柯里化容易形成洋葱代码,即函数之间层层嵌套h(g(f(x)))
,不易读懂。
函数组合让细粒度的函数重新组合生成新的函数,让代码更容易读懂。
将细粒度的函数组合起来成为一个新的函数,输入数据经由新的函数处理得到输出结果。如果把一个个小型函数比作小管道,那么新生成的函数即为拼接起来的大管道,输入经过大管道后输出。每个用于组合的函数都是一元函数。
函数组合一般默认从右向左依次执行。如fn = compose(f1, f2, f3)
,则fn的执行对应即为f3、f2、f1的顺序依次执行。这是为了和洋葱代码的嵌套顺序一致,比如前面的代码转换为洋葱代码即为f1(f2(f3()))
,也与赋值语句的顺序一致。
function composeFromRight(...args) {
// 接收value作为输入数据
return function(value) {
return args.reverse().reduce(function(accu, fn) {
return fn(accu);
}, value);
}
}
使用箭头函数来改写整理一下
const composeFromRight = (...args) => value => args.reverse().reduce((accu, fn) => fn(accu), value);
结合律指的是,可以先组合一部分函数生成fc,然后再将fc与其他函数组合直到没有剩余函数。最后的执行结果与组合的顺序无关。例如
// f1, f2, f3是等效的;
const f1 = compose(f, g, h);
const f2 = compose(compose(f, g), h);
const f3 = compose(f, compose(g, h));
经过组合生成的函数在执行中如果出现问题,则需要去定位问题出现在哪个一元函数上。通常在一元函数之间插入日志记录函数,用于输出一元函数的处理结果。
const _ = require('lodash');
// 传入参数时,先传数据,后传处理函数
_.map(['a', 'b', 'c'], _.toUpper); // ['A', 'B', 'C']
const fp = require('lodash/fp');
// 传入参数时,先传处理函数,再传数据
fp.map(fp.toUpper, ['a', 'b', 'c']);
// fp.map方法是自动被curried,可以参数都传入,也可以传入部分参数
fp.map(fp.toUpper)(['a', 'b', 'c']);
const _ = require('lodash');
_.map(['28', '6', '10'], parseInt); // [28, NaN, 2],原因在于每次遍历数组时,传递给parseInt的第二个参数是数组的索引
const fp = require('lodash/fp');
fp.map(parseInt, ['28', '6', '10']); // [28, 6, 10],原因在于传递给parseInt的参数只有一个,即数组元素。fp.map将传入的函数curried,此时parseInt就只接受第一个参数了。
PointFree风格即“无值风格”,也就是说,PointFree风格下的函数是对运算处理过程的抽象,本身与传入的数据无关。
可以参考阮一峰老师的这篇文章Pointfree 编程风格指南
// 目标:world wide web => W.W.W
const fp = require('lodash/fp');
const firstLetterToUpper = fp.flowRight(fp.join('.'), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' ')); // 这里对数组做了两次遍历,性能会比较差;
firstLetterToUpper('world wide web'); // W.W.W
// 两次遍历中的操作变为一次,这样只遍历一次数组即可。
const firstAndToUpper = fp.flowRight(fp.first, fp.toUpper);
函子是包裹值的容器,或者说是一种数据结构,其内部的值只能通过map方法来进行处理,然后返回一个新的同类型的函子。
函子的出现是为了安全地操作数据,将操作数据的函数通过map方法传入,再返回一个新的函子,这样原来函子并未发生任何改变,对数据操作的函数如果抛出异常,则会被函子内部捕获,从而避免与外界交互产生副作用。
// 定义一个函子
class Container {
constructor(value) {
// _value私有属性
this._value = value;
}
map(fn) {
// 通过fn处理内部值,然后传递给函子构造函数来返回一个新的函子,这类似于链式调用处理数据
return new Container(fn(this._value));
}
}
new Container(6).map(v => v + 2).map(v => v * v); // Container { _value: 64 }
// 不通过new来创建
class Container {
// 静态方法of实际上是Monad函子的一种API规范
static of(value) {
return new Container(value);
}
constructor(value) {
// _value私有属性
this._value = value;
}
map(fn) {
// 通过fn处理内部值,然后传递给函子构造函数来返回一个新的函子,这类似于链式调用处理数据
return Container.of(fn(this._value));
}
}
外部在创建函子的时候,如果传入了空值,则会导致函子在处理数据时抛出异常。这种外部传入值的行为是一种副作用,我们需要对这种副作用控制在合理的范围内。
class MayBe {
static of(value) {
return new MayBe(value);
}
constructor(value) {
this._value = value;
}
map(fn) {
// 如果值为空值,则一直返回一个内部值为null的函子
return this.isNothing()? MayBe.of(null): MayBe.fo(fn(this._value));
}
// 辅助函数,用于判断传入的值是否为空值
isNothing() {
return this._value === null || this._value === undefined;
}
}
虽然MayBe函子可以处理内部值为空值的情况,使得不抛出异常,但如果数据处理中有一个环节出现了空值的情况,MayBe函子是无法得知哪个环节出现的问题,也无法记录出现的异常信息。
Either表示两者中的任意一个,因此需要定义两个函子。Either用来对传入数据进行异常记录,如果数据合法,则返回Either类的表示正常的子类Right,如果数据不合法,则返回Either类的表示异常的子类Left,该Left实例将一直携带异常数据传递下去。
// Either需要定义两个函子,一个函子用来记录异常,一个函子用来处理正常的情形,这两个函子都继承自Either函子
class Either {
static of(value) {
return value !== null? Right.of(value): Left.of(value);
}
// 实现传入数据的构造函数
constructor(value) {
this._value = value;
}
}
// Left函子用来处理异常,如果给Left传入的数据出现非法值,则直接返回该Left实例
class Left {
static of(value) {
return new Left(value);
}
map(fn) {
// 注意,这里没有进行fn的调用;
return this;
}
}
// Right函子处理传入数据为合法值的情况
class Right {
static of(value) {
return new Right(value);
}
map(fn) {
return Right.of(fn(this._value));
}
}
const fp = require('lodash/fp');
class IO {
static of(x) {
return new IO(function() {
return x;
})
}
constructor(fn) {
this._value = fn;
}
map(func) {
// map返回的IO函子的_value为各种函数的组合,包括有传入的不纯操作函数;不纯操作的调用由外部来掌握
return new IO(fp.flowRight(func, this._value));
}
}
folktale是一个标准的函数式编程库,提供一些函数式的操作,如compose, curry等等,一些函子如Task, Either, MayBe等等
const { compose, curry } = require('folktale/core/lamda');
// curry()接收两个参数,第二个参数为函数,第一个参数为该函数需要接收的参数个数;
let fn = curry(2, (x, y) => x + y);
const { toUpper, first } = require('lodash/fp');
// func将传入的字符串数组的第一个元素转为大写;
let func = compose(toUpper, first);
以folktale 2.0.3版本为例
const { task } = require('folktale/concurrency/task');
const { split, find } = require('lodash/fp');
const fs = require('fs'); // node的fs模块
// 读取文件的异步操作
function readFile(filename) {
// task()方法返回一个task函子,给task()方法传入的参数为一个带有副作用的函数,
// 在这里是一个读取文件的函数,在调用函子的run()方法时,才会执行副作用函数;
// 这里的操作与Promise API非常相似
return task(resolver => {
fs.readFile(fileName, 'utf-8', (err, data) => {
if(err) {
resolver.reject(err)
}
resolver.resolve(data);
})
});
}
// listen()方法监听异步操作的状态,并传入异常状态和完成状态的回调函数
// 与Promise的then()方法相似
readFile('package.json')
.map(split('\n')) // 将split('\n')这个curried函数存储起来
.map(find(v => v.includes('version'))) // 将find(v => v.includes('version'))这个curried函数存储起来
.run() // 执行组合函数,按顺序先开始执行异步操作,然后执行上面存储的两个函数
.listen({ // 监听执行的状态
onRejected: (err) => {
console.log(err);
},
onResolved: (data) => {
console.log(data);
}
})
of方法用来把值放到上下文context中,即创建一个包含值的容器。
解决函子嵌套的情况,使得扁平化。函子嵌套指的是函数func接收函子a作为参数并返回a,然后将这个函数func作为函子b的参数,这样函子b._value为函数func, 调用func()返回函子a;数据的处理形式可能就会变为b._value()._value()
这样的形式;
具有join和of两个方法且遵守一定规律的函子即为Monad函子
// join方法调用_value()来返回函子,从而解除了函子嵌套;
// 这里的join方法只解除了一层嵌套,可以使用递归的方式来解除多层嵌套,只留下嵌套最深的函子
join() {
return this._value();
}