在上篇文章中,对函数式编程有了一个初步的认识,本篇文章是学习背后的范畴论,并且在结尾提到了“态射”。现在知道函数式编程的核心思想是将计算过程看作函数之间的变化,而不是通过改变程序状态来实现计算,这也与范畴论中的态射相对应,态射在范畴论中表示对象之间的变换,还是依赖于这本书中的学习。
Foreword
范畴论中还提供了一些概念和思想,如幺半群(SemiGroup)、函子(Functor)、单子(Monad)等等,可以用于描述和分析函数式编程的一些特征,例如:幺半群可以用于描述函数的组合,单子可以用于处理副作用。其还提供了抽象的方法来理解和推到函数式程序的性质,帮助开发者更好的理解程序的行为与结构,提供一些规范。当然本篇只是对范畴论进行初步的认识,其涉及的知识很多,需要花费大量精力去学习;范畴论在 JavaScript 中的应用,本质上为了解决函数组合的问题。对于函数组合,就不得不提到上篇文章中的链式问题,之前是借助 compose/pipe 函数,在范畴论中,可以构造一个能够创造新盒子的盒子。
const Box = (x) => ({
map: (f) => Box(f(x)),
valueOf: () => x,
});
Box 函数的关键在于 map 方法,在调用时候做了两件事情:
- 执行了回调函数 ,入参为当前 Box 的 。
- 将回调函数的结果放入了新的 Box 里。
const add4 = (num) => num + 4;
const newBox = Box(10).map(add4);
console.log(newBox.valueOf());
创建一个 Box,将 add4 作为 map 的参数传入,执行后在 newBox 函数的上下文中,已经保存了新的 x 的值,对于上篇的计算函数:
const add4 = (num) => num + 4;
const multiply3 = (num) => num * 3;
const divide2 = (num) => num / 2;
因此可以采用链式调用,在每次的 map 中,只需要关注 ,即可知每一步在执行的任务,不需要关注 map 如何构建出新的 map,数据的流向等等。
const computeBox = Box(10)
.map(add4)
.map(multiply3)
.map(divide2)
.valueOf();
该盒子其实就是范畴论在函数式编程的一种表达形式。这个 Box 在范畴论中的学名叫做 Functor(函子),从数学定义的角度而言,其式一种能够将一个范畴映射到另一个范畴的东西。在Category Theory for Programmers: The Preface 中有一副插画。
In a category, if there is an arrow going from A to B and an arrow going from B to C then there must also be a direct arrow from A to C that is their composition. This diagram is not a full category because it’s missing identity morphisms (see later).
图中的小猪表示程序中的数据,而箭头是对象与对象之间的映射,也就是前面提到过的态射,本质上就是函数。在范畴中的函数是可以进行复合运算的,例: 和 都是范畴下的函数,因此有如下表达式:
在书中提到的一句话:作为一个死磕过函数式编程、并且在大型项目中反复实践过函数式编程的老开发,我可以非常确信地说,范畴论对于函数式编程最关键的影响,就在于“复合”,或者说在于“函数的组合”。
Functor
在上面通过 Box 衍生出了对 Functor 的定义,常见的 Array.prototype.map 也是实现了上述 Box 中 map 方法的数据结构,因此 Array 其实也属于 Functor。对于一个合法的 Functor 需要满足两个条件,其一是 map 方法创建出来的新盒子应该和原来的盒子等价;另一个是盒子一定要具有可组合性。对于某个数组,如下代码:
const arr = [1, 2, 3, 4, 5];
const arr2 = arr.map((item) => item * 2);
获取其本身的值类似于调用上述 Box 中的 valueOf
,并且还可以调用 map 创建出了另一个数组盒子(arr2
),所以可将数组抽象成一个盒子,这类 Box 作为最简单的 Functor,也称之为“Identity Functor”。对于不同的 Functor,通过额外添加一个标识函数(inspect)区分,如下:
const Identity = (x) => ({
map: (f) => Identity(f(x)),
valueOf: () => x,
inspect: () => `Identity {${x}}`,
});
对于同一类的 Functor 往往都具有相同的 map 行为,通过对 map 进行其他复杂操作构建出不同的 Functor,如下代码所示,构建出另一个 Functor,“Maybe Functor”。
const isEmpty = (x) =>
x === undefined || x === null;
const Maybe = (x) => ({
map: (f) =>
isEmpty(x) ? Maybe(null) : Maybe(f(x)),
valueOf: () => x,
inspect: () => `Maybe {${x}}`,
});
在 Maybe Functor 中,执行回调函数 之前,会先执行校验函数isEmpty
;如果入参 为空,map 则不会执行回调函数,返回一个“空盒子”。
const add1 = (x) => x + 1;
const add2 = (x) => {
x + 2;
};
const toString = (x) => x.toString();
const res = Maybe(10)
.map(add1) // Maybe {11}
.map(add2) // Maybe {undefined}
.map(toString)
.inspect();
在 add2
函数中没有返回值,导致创建出的空盒子,并且不会继续执行 toString
。
为什么需要 Maybe Functor?
如果这里使用 Identity Functor,会导致 add2 返回的 undefined 基础上继续调用 toString 方法,也显然是一个 Error!因此 Maybe Functor 可以把错误控制在组合链的内部。
因此对于“盒子模式”的存在,不仅仅是换了个方式使用 compose/pipe,更加重要的是我们可以通过对盒子的封装,即实现了组合,同时确保了副作用的控制(例如 Maybe Functor 控制了空盒子的识别)。
Monad
单子(Monad)作为一种特殊的函子(Functor),其内部即实现了 map 方法,又实现了 flatMap 方法。flatMap 方法则是为了解决“嵌套盒子”的问题,也就是在 Functor 内部嵌套了 Functor 的情况。对于嵌套盒子,这部分相对于上面而言稍微复杂了些,分了两种情况,我直接引用书中的例子。第一种,先行计算场景下的嵌套 Functor。
const add = (x) => Maybe(x + 1);
const res = Maybe(10).map(add);
这里直接由于 add 返回的是一个 Maybe,因此 Maybe 中嵌套了一层 Maybe,可以通过控制台打印进行验证。对于过去回调函数 期望的入参是数据本身,而不是装着盒子的数据,如果在上述代码中再延长原有的调用链,会存在入参是 Maybe 盒子的问题。第二种则是非线性计算场景下的嵌套 Functor。
有以下函数方法:
const add1 = (x) => x + 1;
const add2 = (x) => x + 2;
const comp = (a, b) => {
return add1(a) + add2(b);
};
返回两个函数调用的返回值求和,现通过 Identity 对 comp 函数计算流程进行改造:
const compF = (a, b) =>
Identity(add1(a)) // Identity Box A
.map(
// invoke `map` return new Identity Box B
(x) => Identity(add2(b)).map((y) => x + y)
);
上述情况下,由于在 Box A 中调用 map 时返回了一个 Box B 因此也出现了嵌套。这里可能会有所迷惑,因为线性与非线性都是在调用 map 时,由于返回了新的盒子从而出现了嵌套的问题,就我个人的理解是因为非线性调用时,获取了执行上下文中的内容,而线性调用是依赖于链式调用的入参,不依赖于执行上下文,这也许是两者本质的区别。
在面对这里嵌套盒子的问题中,可以使用连续调用两次 valueOf 进行解开盒子,如下代码:
compF(2, 3).valueOf().valueOf();
但是这样显然显得不是那么优雅,其次可能不知道 valueOf 何时调用,这个时候就需要前面提到的 flatMap 来解开盒子取出数据。现在来看,调用 flatMap 时就代表需要在执行完回调函数 之后需要调用一次 valueOf 来解开盒子,因此对于 Monad 的实现有如下代码:
const Monad = (x) => ({
map: (f) => Monad(f(x)),
valueOf: () => x,
inspect: () => `Monad {${x}}`,
flatMap: (f) => f(x),
});
上述明明说的是在执行完回调函数 之后再调用一次 valueOf 解开盒子,理论上的代码应该是 flatMap: f => map(f).valueOf()
为什么不这么写呢?
这是因为此时 flatMap 与 map 是同级对象方法,两者实际上并不在同一个上下文中,因此这样调用 map 肯定是错误的!
调用 valueOf 去解开盒子本质上就是不要去再次包裹那一层 Monad,所以可以直接调用 即可解开盒子。
现在来看把之前的 comF 中的 Identity 替换成 Monad,如下代码:
const compF = (a, b) =>
Monad(add1(a)) // Monad Box A
.flatMap(
// invoke `map` return new Monad Box B
(x) => Monad(add2(b)).flatMap((y) => x + y)
);
console.log(comF(2, 3)); // 8
Semigroup&Monoid
先来说半群(Semigroup),Wikipedia 中有如下定义:
在数学中,半群是闭合于结合性二元运算之下的集合 S 构成的代数结构。
三个关键特征:闭合、结合性、二元运算。
闭合:是指对某一个集合的成员进行运算之后得到的任然是那个集合的成员,如:1+2=3
,1 和 2 是整数集合,得到的 3 也是整数集合。
结合性:在小学中就学过加法结合律(具有结合特性),即:(a + b) + c = a + (b + c)
。
二元运算:在前面的函子和单子学习中发现它们都是一元函数,现在来看,在 Semigroup 中基本的行为是二元函数。
由于在盒子模式中,返回链上总是其原始集合,因此肯定是满足闭合条件;而二元运算,就涉及到两个要素:操作数和操作符。两者映射到函数式编程中,运算数可以看作函数的入参,操作符可以抽象成 concat 函数;至于为啥要抽象成 concat 函数这就是为了保证其具有结合性的关键,Array.prototype.concat()
和 String.prototype.concat()
两者都是满足结合性。
通过上述的定义,这里可以定义出一个一个类型为 Add 的 Semigroup 盒子,如下代码:
const Add = (value) => ({
value,
concat: (box) => Add(value + box.value),
});
Add(1).concat(Add(2)).concat(Add(3));
现在在来看 Monoid 就很简单,根据 Wikipedia 有如下定义:
A monoid is an algebraic structure intermediate between semigroups and groups, and is a semigroup having an identity element.
在数学中,identity element 被称之为单位元的东西,它和任何运算数相结合时,都不会改变那个运算数,在加法中类似于 ,乘法中类似于 ;在函数式编程中,其是一个 empty
函数。
const Add = (value) => ({
value,
concat: (box) => Add(value + box.value),
});
Add.empty = () => Add(0);
Add(1).concat(Add(2)).concat(Add(3));
empty 与任何数相结合时都不会改变运算数。