最近在看js的函数式编程,觉得很酷,此文是对一些知识的脉络梳理总结,主要是为了方便自己理解,并非原创知识。会在文末贴出参考文章链接。
文中主要提到11点,分别是:高阶函数、纯函数、PointFree、函数合成、函数柯里化、范畴、函子(Functor)、Either、Applicative Functor、IO、Monad。最后会列举一个示例。
一、高阶函数:
函数式编程的基础都会用到高阶函数,高阶函数是一个接收函数作为参数或将函数作为输出返回的函数。
例如:Array.prototype.map
let arr = [1, 2, 3]
let fun = (item) => {
return item * 2
}
let result = arr.map(fun)
console.log(result) // => [ 2, 4, 6 ]
在本例中,fun
就被当作参数传递给了map
,map
就是一个高阶函数。
二、纯函数:
定义: 纯函数的定义是,对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态。
副作用: 只要是跟函数外部环境发生的交互就都是副作用。
好处:
1、可缓存性,纯函数总能够根据输入来做缓存。
2、可移植性/自文档化,纯函数与环境无关、是完全自给自足的,它需要的所有东西都能轻易获得。
3、可测试性,无需伪造测试环境。
4、引用透明性,如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。
三、PointFree:
把PointFree
写在前面,是因为觉得PointFree也是一种应该事先知道的基本思想。
定义:PointFree
是一种思想,函数无须提及将要操作的数据是什么样的,不必声明参数,只在意运算过程。这样会让函数更简洁,但也并不是说一定要去除所有的参数。
例如:
//这不Piont free,这个str除了让代码变长,其实是毫无意义的。
var f = str => str.toUpperCase().split(' ');
在下面代码中fun
函数是Piont free
的, 但是为了保证fun
是Piont free
的,免不了让toUpperCase
与split
不那么Piont free
,需要自行取舍。
var toUpperCase = word => word.toUpperCase();
var split = x => (str => str.split(x));
// compose是一种函数式方法,后面会提到,暂时先不用管。
var fun = compose(split(' '), toUpperCase);
f("abcd efgh");
// =>["ABCD", "EFGH"]
四、函数合成:
定义: 如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做"函数的合成"(compose)。
例如:
//两个函数的组合
var compose = function(f, g) {
return function(x) {
return f(g(x));
};
};
//或者
var compose = (f, g) => (x => f(g(x)));
var add1 = x => x + 1;
var mul5 = x => x * 5;
compose(mul5, add1)(2); // =>15
五、函数柯里化:
在上面组合的示例中,有一个隐藏的前提,就是f和g都只能接受一个参数。如果可以接受多个参数,比如f(x, y)
和g(a, b, c)
,函数合成就非常麻烦。 这时就需要函数柯里化了。
柯里化: 把一个多参数的函数,转化为单参数函数。
//比较容易读懂的ES5写法
var add = function(x){
return function(y){
return x + y
}
}
//ES6写法,也是比较正统的函数式写法
var add = x => (y => x + y);
//试试看
var add2 = add(2);
var add200 = add(200);
add(10)(20) // =>30
add2(2) // =>4
add200(50) // =>250
六、范畴:
函数式编程的起源,是一门叫做范畴论(Category Theory)的数学分支。
定义: 范畴就是使用箭头连接的物体。也就是说,彼此之间存在某种关系的概念、事物、对象等等,都构成"范畴"。随便什么东西,只要能找出它们之间的关系,就能定义一个"范畴"。
上图中,各个点与它们之间的箭头,就构成一个范畴。
箭头表示范畴成员之间的关系,正式的名称叫做"态射"(morphism)。范畴论认为,同一个范畴的所有成员,就是不同状态的"变形"(transformation)。通过"态射",一个成员可以变形成另一个成员。
七、函子(Functor):
我们可以把"范畴"想象成是一个容器,里面包含两样东西。
1、 值(value)
2、 值的变形关系,也就是函数。
函数不仅可以用于同一个范畴之中值的转换,还可以用于将一个范畴转成另一个范畴。这就涉及到了函子(Functor)。
函子首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
上图中,左侧的圆圈就是一个函子,表示人名的范畴。外部传入函数f
,会转成右边表示早餐的范畴。
可以自己写一个简单的容器:
var Container = function(x) {
this.__value = x;
}
Container.of = x => new Container(x);
//试试看
Container.of(1);
//=> Container(1)
Container.of('abcd');
//=> Container('abcd')
我们调用 Container.of 把东西装进容器里之后,由于这一层外壳的阻挡,普通的函数就对他们不再起作用了,所以我们需要加一个接口来让外部的函数也能作用到容器里面的值:
Container.prototype.map = function(f){
return Container.of(f(this.__value))
}
我们可以这样使用它:
Container.of(3)
.map(x => x + 1) //=> Container(4)
.map(x => 'Result is ' + x); //=> Container('Result is 4')
注意: 一般约定,函子的标志就是容器具有map方法。该方法将容器里面的每一个值,映射到另一个容器。
也就是说,如果我们要将普通函数应用到一个被容器包裹的值,那么我们首先需要定义一个叫 Functor 的数据类型,在这个数据类型中需要定义如何使用 map 来应用这个普通函数。
八、Either:
条件运算if…else是最常见的运算之一,函数式编程里面,使用 Either 函子表达。Left 和 Right 是它的两个子类。Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是在值不存在时使用。
用js实现一下:
class Either {
constructor(value) {
this._value = value
}
get value() {
return this._value
}
static left(a) {
return new Left(a)
}
static right(a) {
return new Right(a)
}
}
// 注意两个类的map方法不同
class Left extends Either {
map() {
return this
}
}
class Right extends Either {
map(f) {
return Either.right(f(this.value))
}
}
测试一下:
var str = '测试'
if (str) {
return Either.right(str).map((x) => '有数据:' + x) // => Right { _value: '有数据:测试' }
}
return Either.left('没有数据') // => Left { _value: '没有数据' }
九、Applicative Functor:
函子里面包含的值,完全可能是函数。我们可以想象这样一种情况,一个函子的值是数值,另一个函子的值是函数。
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
函子。
用js实现一下:
var Ap = function (x) {
this.val = x
}
Ap.of = function (val) {
return new Ap(val)
}
Ap.prototype.map = function (f) {
return new Ap(f(this.val))
}
// 注意:this.val 是一个函数,将会接收另一个 functor 作为参数,所以我们只需 map 它
Ap.prototype.ap = function (F) {
return F.map(this.val)
}
function addTwo(x) {
return x + 2
}
Ap.of(addTwo).ap(Ap.of(2)) //=> Ap { val: 4 }
ap 函子的意义在于,对于那些多参数的函数,经过柯里化,就可以从多个容器之中取值,实现函子的链式操作。
function add(x) {
return function (y) {
return x + y
}
}
Ap.of(add).ap(Ap.of(2)).ap(Ap.of(8)) //=> Ap { val: 10 }
十、IO:
I/O 是不纯的操作,普通的函数式编程没法做,我们需要让不纯的操作变“纯”起来。我们采取的方式是定义一个IO
的函子,然后将不纯的操作包裹起来,使其返回一个IO
函子。
例如
var fs = require('fs');
var readFile = function(filename) {
return new IO(function() {
return fs.readFileSync(filename, 'utf-8');
});
};
读取文件操作本来是不纯的,但是通过返回一个IO
函子,使其变纯了。IO
函子的定义如下:
const R = require('ramda');
// 注意,它的 __value 是一个函数
var IO = function (f) {
this._value = f
}
IO.of = (x) => new IO(x)
IO.prototype.map = function (f) {
return new IO(R.compose(f, this.__value))
}
测试一下:
var getUrl = new IO(() => window.location.href)
var result = getUrl.map(function (x) {
return x
})
// 注意这里的取值方式,需要取到内层的_value()
console.log(result._value())
十一、Monad:
Monad
函子的作用是,总是返回一个单层的函子。
函子是一个容器,可以包含任何值。函子之中再包含一个函子,也是完全合法的。但是,这样就会出现多层嵌套的函子。
Maybe.of(
Maybe.of(
Maybe.of({name: 'Mulburry', number: 8402})
)
)
上面这个函子,一共有三个Maybe
嵌套。如果要取出内部的值,就要连续取三次this.val
。这当然很不方便,因此就出现了 Monad
函子。
它有一个flatMap
方法,与map
方法作用相同,唯一的区别是如果生成了一个嵌套函子,它会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的情况。
下面用js实现以下,先定义一个join
方法。
IO.prototype.join = function () {
return this._value ? this._value : IO.of(null)
}
// 试试看
var foo = IO.of(IO.of('123'))
foo.join() //=> IO('123')
现在写另一个简化的IO
操作:
const R = require('ramda')
var IO = function (f) {
this._value = f
}
IO.of = (x) => new IO(x)
IO.prototype.map = function (f) {
return new IO(R.compose(f, this._value))
}
IO.prototype.join = function () {
return this._value ? this._value() : IO.of(null)
}
var map = R.curry((f, x) => x.map(f))
var join = (x) => x.join()
// 简化的获取数据的操作
var getData = function (x) {
return new IO(() => {
return x
})
}
// 简化的对数据的处理操作1
var addStr1 = function (x) {
return new IO(() => {
return x + '测试1'
})
}
// 简化的对数据的处理操作2
var addStr2 = function (x) {
return new IO(() => {
return x + '测试2'
})
}
var test = R.compose(join, map(addStr2), join, map(addStr1), getData)
var reault = test('获取数据:')
console.log(reault._value()) // => 获取数据:测试1测试2
可以看到join
方法可以把Functor
拍平(flatten),我们不可能总是在 map 之后手动调用 join 来剥离多余的包装,现在写一个flatMap
的方法。
IO.prototype.flatMap= function(f) {
return this.map(f).join();
}
var flatMap = _.curry((f, functor) => functor.flatMap(f))
重新测试,依旧能行。
var test = R.compose(flatMap(addStr2), flatMap(addStr1), getData)
var reault = test('获取数据:')
console.log(reault._value()) // => 获取数据:测试1测试2
最后,由于我们返回的都是IO
函子,所以可以实现方便的链式操作。像下面这样:
// var test = R.compose(flatMap(addStr2), flatMap(addStr1), getData)
var reault = getData('获取数据:').flatMap(addStr1).flatMap(addStr2)
console.log(reault._value()) // => 获取数据:测试1测试2
最后来看搬运的函数式示例吧,觉得对比清晰。
下面是一段服务器返回的 JSON 数据。
现在要求是,找到用户 Scott 的所有未完成任务,并按到期日期升序排列。
过程式编程的代码如下:
上面代码不易读,出错的可能性很大。
现在使用 Pointfree 风格改写。
var getIncompleteTaskSummaries = function(membername) {
return fetchData()
.then(R.prop('tasks'))
.then(R.filter(R.propEq('username', membername)))
.then(R.reject(R.propEq('complete', true)))
.then(R.map(R.pick(['id', 'dueDate', 'title', 'priority'])))
.then(R.sortBy(R.prop('dueDate')));
};
另一种写法是,把各个then里面的函数合成起来。
// 提取 tasks 属性
var SelectTasks = R.prop('tasks');
// 过滤出指定的用户
var filterMember = member => R.filter(
R.propEq('username', member)
);
// 排除已经完成的任务
var excludeCompletedTasks = R.reject(R.propEq('complete', true));
// 选取指定属性
var selectFields = R.map(
R.pick(['id', 'dueDate', 'title', 'priority'])
);
// 按照到期日期排序
var sortByDueDate = R.sortBy(R.prop('dueDate'));
// 合成函数
var getIncompleteTaskSummaries = function(membername) {
return fetchData().then(
R.pipe(
SelectTasks,
filterMember(membername),
excludeCompletedTasks,
selectFields,
sortByDueDate,
)
);
};
最后,这篇文章也是自己在学习总结,主要为了自己理解,肯定有疏漏和问题。下面贴出一些参考原文,供大家查阅。
函数式编程入门教程
Pointfree 编程风格指南
函数式编程指北
JavaScript函数式编程
Ramda 函数库参考教程