函数式编程--Functor、Applicative、Monad

793 阅读12分钟

一、前言

如果你在搜索引擎中查询函子(Functor)的定义,你会陷入数学和范畴论的海洋。最后愈发的摸不着头脑,找不清方向。这正是我这几天所遭遇的真实情况。

学习,需要方法。想要搞清楚一个概念、一个定义、一个原理,首先要知道它如何使用?其次,要搞明白它的用途是什么?用来解决什么问题?最后,要搞清楚它的最佳实践是怎样的?

每个人看待同一件事物的角度和侧重点都不同,得出的结论就会有出入。首先要学会理性的看待别人的分析和结论,不要盲目跟从或者否定。更不要被高大上的专业术语带偏了节奏。从用途和产生背景上去找本质,然后回归概念和实践本身,才是最行之有效的学习方法。

二、基本概念

1. 什么是函子(Functor)?

百度百科这样定义:

在范畴论中,函子(functor)是范畴间的一类映射,通俗地说,是范畴间的同态。

p01.png

按照这句话推查下去,你需要搞明白什么是范畴?什么又是同态?什么是态射?自函子、幺半群等等等... 这些问题很复杂(我想我的数学知识储备,不足以搞明白这些问题),也不是我们应该关注的重点。我们只是想搞清楚,在函数式编程中Functor和Monad是什么?如何正确的使用它们? 仅此而已。

简单说,函子是一种受规则约束的数据类型,其中包含值(value)和值的变形关系(函数)

这句话同样很抽象,抽象到依然不知所云。 虽然,到此为止我们依然没搞明白函子是什么?但至少能由此获取一部分信息:

  • 函子是受规则约束的
  • 函子是一种数据类型
  • 函子包含值和值的变形关系

为了进一步搞明白函子是什么?我们首先需要搞明白函子的用途是什么?用来解决什么问题?

2. 函子的用途是什么?

p02.png

函子的用途是什么呢?这个问题需要从前面讲过的函数组合(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}

p03.png

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…

juejin.cn/post/684490…

juejin.cn/post/684490…

jiyinyiyong.github.io/monads-in-p…

llh911001.gitbooks.io/mostly-adeq…

medium.com/@magnusjt/t…