一、前言
如果你在搜索引擎中查询函子(Functor)的定义,你会陷入数学和范畴论的海洋。最后愈发的摸不着头脑,找不清方向。这正是我这几天所遭遇的真实情况。
学习,需要方法。想要搞清楚一个概念、一个定义、一个原理,首先要知道它如何使用?其次,要搞明白它的用途是什么?用来解决什么问题?最后,要搞清楚它的最佳实践是怎样的?
每个人看待同一件事物的角度和侧重点都不同,得出的结论就会有出入。首先要学会理性的看待别人的分析和结论,不要盲目跟从或者否定。更不要被高大上的专业术语带偏了节奏。从用途和产生背景上去找本质,然后回归概念和实践本身,才是最行之有效的学习方法。
二、基本概念
1. 什么是函子(Functor)?
百度百科这样定义:
在范畴论中,函子(functor)是范畴间的一类映射,通俗地说,是范畴间的同态。
按照这句话推查下去,你需要搞明白什么是范畴?什么又是同态?什么是态射?自函子、幺半群等等等... 这些问题很复杂(我想我的数学知识储备,不足以搞明白这些问题),也不是我们应该关注的重点。我们只是想搞清楚,在函数式编程中Functor和Monad是什么?如何正确的使用它们? 仅此而已。
简单说,函子是一种受规则约束的数据类型,其中包含值(value)和值的变形关系(函数)
这句话同样很抽象,抽象到依然不知所云。 虽然,到此为止我们依然没搞明白函子是什么?但至少能由此获取一部分信息:
- 函子是受规则约束的
- 函子是一种数据类型
- 函子包含值和值的变形关系
为了进一步搞明白函子是什么?我们首先需要搞明白函子的用途是什么?用来解决什么问题?
2. 函子的用途是什么?
函子的用途是什么呢?这个问题需要从前面讲过的函数组合(Function Composition)讲起。函数组合是一种把多个函数组合成新函数的方式,它解决了函数嵌套调用的问题,还提供了函数拆分组合的方式。
为了方便观察和对比演示,下面的例子都采用管道(Pipeline)代替函数组合。
举个简单的例子
// 管道(Pipeline)
function pipe(...funcs) {
const length = funcs.length
let index = length
while (index--) {
if (typeof funcs[index] !== 'function') {
throw new TypeError('Expected a function')
}
}
return function(...args) {
let index = 0
let result = length ? funcs[index].apply(this, args) : args[0]
while (++index < length) {
result = funcs[index].call(this, result)
}
return result
}
}
// 加法运算
const add = (a:number) => a + 5
const double = (x:number) => x * 2
const square = (n:number) => Math.pow(n,2)
// square(double(add(3))) =》pipe(add, double, square)(3)
const format = pipe(add, double, square)
const result = format(3) // 256
上面的add、double、square方法参数和返回值都是number,前面一个参数的返回值,跟后面一个函数的参数类型正好对应,这完全符合函数组合的应用场景。但是,如果应用函数变成下面这样呢?
const add = (a:number) => { return { value: a + 5 } }
const double = (x:number) => { return { value: x * 2 } }
// ...
const format = pipe(add, double, square)
const result = format(3) // NaN
调整后,add(3)的返回值是{value:8},但是double函数的参数要求number类型,而且double的执行结果{ value: 8 } * 2 => NaN,更无法满足squre的要求。这显然不符合函数组合的应用场景。为了保证函数返回值和参数的衔接性,适用于函数组合的应用场景。我们可以做一些额外的工作,来处理一下add函数的返回值,让它符合double函数的参数要求。
const format = pipe(add, (data)=>double(data.value), (data)=>square(data.value))
const result = format(3) // 256
这里,我们给double和squre加了一层函数,处理上一个函数的执行结果,适配自身参数规则要求。目的是为了满足函数组合的应用场景。仔细观察,不难发现,其实这种处理参数的方式,是一种映射:{value: number} => number。说到映射,很自然的就会想到映射函数(map)。
const map = (fn) => (data) => fn(data.value)
const format = pipe(add, map(double), map(square))
const result = format(3) // 256
当然,如果按照函数组合的思路,还可以调整成下面这样,这更符合函数式编程的风格。
const map = data => data.value
const format = pipe(add, map, double, map, square)
无论那种办法,都能够解决参数适配的问题。但实践中问题往往要复杂的多,为了满足函数组合的使用场景,可能要写很多不同的map函数,代码非常分散,不利于管理。因此我们需要一种新的函数组合方式来解决这些问题。它应该满足下面这些特征:
- 优雅的书写形式
- 可以按顺序串联起一组函数
- 具备数据变形映射能力(map方法),保证数据正确传输
就像数组方法那样的链式调用,基本满足上述所有特征。
其实数组就是一个典型的Functor
举一个数组链式调用的例子
[3].map(v => v + 5)
.map(v => v * 2)
.map(v => Math.pow(v, 2)) // [256]
也即
[3].map(add).map((d)=>double(d.value)).map((d)=>square(d.value))
经过一番处理,[3]变成了[256]。这意味着,我们需要一个类似数组的数据类型Functor。它有一个属性值value和一个map方法。map方法可以处理value,并生成新的Functor实例。
按照描述,我们可以构建一个最基本的Functor。
因为涉及到数据类型,下面我们用Typescript演示示例
class Functor<T> {
private value:T;
constructor(val:T){
this.value = val
}
public map<U>(fn:(val:T)=>U){
let rst = fn(this.value)
return new Functor(rst)
}
}
验证一下Functor的应用实例,是否符合我们想要的数据类型?
new Functor(3)
.map(d=>add(d))
.map(d=>double(d.value))
.map(d=>square(d.value)) // Functor { value: 256 }
经验证,顺利的从{value:3}变成了{value:256}。这就是函子,一种受规则约束,含有值(value)和值的变形关系(函数map)的数据类型(容器)。 它是一种新的函数组合方式,可以链式调用,可以用于约束传输的数据结构,可以映射适配函数的输出值与下一个函数输入值,可以一定程度上避免函数执行的副作用。
三、常用函子
下面一起认识一些常用的函子。了解一下它们的适用场景,用来解决什么问题?最重要的是可以对比了解一下函子的设计思路和演变过程。
1. Pointed函子(Pointed Functor)
上面例子中,我们是用new操作符创建了Functor的实例,这是典型的面向对象编程。这并不符合函数式编程的编程风格,所以我们需要把创建实例的过程改造成函数。
我们还可以继续借鉴数组类型(Array)的设计思路,参照Array.of方法(of方法用于创建一个具有可变数量参数的新数组实例),同样给Functor添加一个of静态方法。
先看一下Array.of的用法
Array.of(7); // [7]
Array.of(1, 2, 3); // [1, 2, 3]
Pointed继承Functor,并增加了static方法of。我们把实现了of静态方法的函子叫做Pointed函子。
class Pointed<T> extends Functor<T> {
public static of(val){
return new Pointed(val)
}
}
示例
Pointed.of(3)
.map(d=>add(d))
.map(d=>double(d.value))
.map(d=>square(d.value)) // Pointed { value: 256 }
2. Maybe函子(Maybe Functor)
函子的value可以是任意值,包括空值null,undefined。这意味着value“可能”非空,也“可能”是空值。
// Just 表示值‘存在’,Nothing表示空值,相似于null、undefined的概念
type Maybe<T> = Just<T> | Nothing
如果把空值直接赋值给函数,函数未必有容错处理机制,可能会抛出异常。比如下面例子中,s的值是null,直接调用toUpperCase,就会报TypeError错误。
Pointed.of(null).map(function (s) {
return s.toUpperCase();
}); // TypeError: Cannot read property 'toUpperCase' of null
为了解决这个问题,可以先对value进行非空判断,然后再应用于函数。我们把这种经过非空容错处理的函子叫做Maybe函子。
class Maybe<T> extends Pointed<T> {
static of<T>(val:T){
return new Maybe(val)
}
isNothing(){
return this.value === null || this.value === undefined
}
public map<U>(fn:(val:T)=>U){
if (this.isNothing()) return Maybe.of(null)
let rst = fn(this.value)
return Maybe.of(rst)
}
}
Maybe函子输入空值,不会有报错问题,而且经过一系列的映射变形value值始终都是null。这意味着如果封装输入的是空值,那么最后的结果必然是Maybe {value:null}
Maybe.of(null).map(function (s) {
return s.toUpperCase();
}) // Maybe {value:null}
Maybe在读取对象的深层次属性时非要有用。为保证属性存在,需要非空判断,避免发生TypeError异常。
通常我们这样做
const user = {
name: "Holmes",
address: {
street: "Baker Street",
number: "221B"
},
}
const street = user && user.address && user.address.street;
有了Maybe我们可以这样做
Maybe.of(user)
.map(user => user.address)
.map(address => address.street)
当然如果你柯理化足够熟悉的话,还可以转换成这样
const getProp = currying((key,obj) => obj[key])
Maybe.of(user)
.map(getProp('address'))
.map(getProp('street'))
3. Either函子(Either Functor)
Maybe 函子可以避免发生空值异常,却无法避免发生其他异常,也不能捕捉异常信息。Either 函子能帮助我们解决这样的问题。Either 函子内部有两个值,左值(left)和右值(right),右值是正常情况下使用的值,左值是右值不存在的时候使用的默认值或者捕捉一些详细信息。
主要用于两个用途:
- 代替条件运算(if...else),提供默认值
- 代替异常处理(try...catch),捕捉详细的错误信息
class Either<T, D> extends Functor<T | D> {
private left: T
private right: D
constructor(left: T, right: D) {
super(null);
this.left = left;
this.right = right;
}
static of<T,D>(left:T, right:D) {
return new Either(left, right);
}
isNothing() {
return this.right === null || this.right === undefined
}
public map<U>(fn: (val: T | D) => U) {
return this.isNothing() ?
Either.of(fn(this.left), this.right) :
Either.of(this.left, fn(this.right));
}
}
代替条件判断
Either.of(1, 5).map(x => x * 2) // Either{value:null, left:1,right:10}
Either.of(1, null).map(x => x * 2) // Either{value:null, left:2, right:null}
代替try..catch处理异常
// 解析 JSON 字符串
function parseJson(jsonStr: string): Either<any, any> {
let rst = null
let err = null
try {
rst = JSON.parse(jsonStr)
} catch (e) {
err = e
}
return Either.of(err, rst)
}
// => Either { value: null, left: 'SYNTAXERROR', right: null }
parseJson('{name:"Lucy"}').map((val) => val.name.toUpperCase())
// => Either { value: null, left: null, right: 'LUCY' }
parseJson('{"name":"Lucy"}').map((val) => val.name.toUpperCase())
4. Applicative 函子(Applicative Functor)
函子的值可以是任意数据类型,当然也可以是函数。函数出了可以作为“值”被计算和传递之外,还有一个更重要的特性:函数可以被执行。那么value值是函数的函子,是否同样可以被执行呢?下面我们一起讨论一下这个问题。
我们知道,函数(function)除了可以直接调用之外,它还有一个apply方法,可以把函数应用于其它对象,并调用执行。
例如
[].map.apply([1,2,3],[n=>n*2]) // [2,4,6]
同样,函子也可以具备这样的特性。
- value值是函数
- 具备一个ap方法,可以应用于其他的函子
我们把这种value值是函数,且具备一个apply(通常用ap代替)方法,使它可以应用于另外一个函子上面的函子称之为可应用的函子,也即Applicative 函子。
class Applicative<T> extends Pointed<T> {
public static of<T>(val:T){
return new Applicative(val)
}
isNothing(){
return this.value === null || this.value === undefined
}
public map<U>(fn:(val:T)=>U){
if (this.isNothing()) return Applicative.of(null)
let rst = fn(this.value)
return Applicative.of(rst)
}
public ap(Other)){
return Other.map(this.value)
}
}
做为可应用的函子,Applicative适用的场景有很多。比如Currying函数多次传参数链式调用。
import { curry } from 'ramda'
const add = curry((a,b,c)=> a + b + c)
// Applicative { value: 10 }
Applicative.of(add(2)).ap(Applicative.of(3)).ap(Applicative.of(5))
这里还有一个很有意思的特性可以了解一下,有助于我们更好理解和掌握Applicative。把一个值封装成函子,然后map到一个函数,跟把函数封装成函子,然后ap到一个封装值的函子,是等价的。用表达式表示就是:
F.of(x).map(f) == F.of(f).ap(F.of(x))
5. Monad函子(Monad Functor)
函子的value除了可以是函数之外,还可以是函子。下面这个例子,计算结果就是一个value值是函子的函子。在此基础上,继续执行下去,很可能会造成函子层层嵌套,最后取出value值会非常困难。
Maybe.of(3).map(n => Maybe.of(n + 2)) // Maybe { value: Maybe { value: 5 } }
Monad函子的作用就是解决这个问。Monad函子含有一个flatMap 方法,它的作用是保证返回一个单层的函子。如果map方法的结果是一个嵌套函子,它会取出结果的value值,保证返回的是一个单层函子,避免出现嵌套的情况。
class Monad<T> exteds Functor<T>{
static of<T>(val:T){
return new Monad(val)
}
isNothing() {
return this.value === null || this.value === undefined
}
public map<U>(fn:(val:T)=>U){
if (this.isNothing()) return Monad.of(null)
let rst = fn(this.value)
return Monad.of(rst)
}
public join(){
return this.value
}
public flatMap<U>(fn:(val:T)=>U){
return this.map(fn).join()
}
}
示例
Monad.of(3).flatMap(val => Monad.of(val + 2)) // Monad { value: 5 }
通常讲,Monad函子就是实现flatMap方法的Pointed函子。
6. IO函子
函子的value值可以是函数,但只要是函数就很难避免副作用,比如IO操作就是副作用的典型场景。为了保证副作用的相对可控,可以把操作过程封装成函子。
const readFile = (name) => Maybe.of(()=> `请求并返回${name}文件内容`)
很显然,这样同样会带来函子嵌套的问题。所以IO函子应该也是有flatMap方法和join方法的Monad。为了解决副作用的问题,我们把执行过程封装进函子,目的是为了缓存参数和延迟执行。所以,这里的map和join以及flatMap方法都需要覆写,以便执行缓存和获取结果。
type Effect<T> = () => T
class IO<T> {
private value: Effect<T>
static of<T>(val: T) {
return new IO(() => val)
}
constructor(val: Effect<T>) {
this.value = val
}
public map<U>(fn: (val: T) => U):IO<U> {
if (this.isNothing()) return IO.of(null)
return new IO(() => fn(this.value()))
}
isNothing() {
return this.value === null || this.value === undefined
}
public join() {
return this.value()
}
public flatMap<U>(fn: (val: T) => IO<U>): IO<U> {
return new IO(() => fn(this.value()).join())
}
}
把上面的例子略作改动,验证一下
const readFile = (name) => new IO(() => {
console.log('readFile')
return `请求并返回${name}文件内容`
})
const format = (info) => new IO(() => {
console.log('format')
return `格式化内容("${info}")`
})
const print = (info) => new IO(() => {
console.log('print')
return `打印格式化后的内容:${info}`
})
// 并没有执行
const readAndPrint = readFile('book.pdf').flatMap(format).flatMap(print)
readAndPrint.join()
// readFile
// format
// print
// 打印格式化后的内容:格式化内容("请求并返回book.pdf文件内容")
上面整个flatMap过程,只是缓存了整个执行过程,并没有真正的执行IO读取文件。直到调用join方法才开始真正的执行。虽然IO读取操作有副作用,但是封装读取动作的IO实例是相对可控的,一定程度上降低了整个操作过程的副作用。
四、总结
下面我们简单总结一下函子的用途和特性:
- 函子支持链式调用,是一种函数组合方式
- 函子是一种数据类型,一定程度上保证了数据传输的稳定性,一定程度上降低了函数执行的副作用
- Functor可以将一个函数运用到一个封装的值上
- Applicative可以将一个封装过的函数运用到一个封装的值上
- Monad可以将一个返回封装值的函数运用到一个封装的值上
五、参考资料
www.ruanyifeng.com/blog/2017/0…
jiyinyiyong.github.io/monads-in-p…