阅读 111

JavaScript中的函数式编程--函子

函子(Functor)

  • 为什么要学函子
    • 通过之前的学习我们已经学习了函数式编程的一些基础,但是我们还没有演示在函数式编程中如何把副作用控制在可控的范围内,以及如何进行异常处理、异步操作等。
  • 什么是Functor
    • 首先:是一个对象
    • 容器:包含值和值的变形关系(这个变形关系就是函数)
    • 函子:是一个特殊的容器,通过一个普通的对象实现,该对象具有map方法,map方法可以运行一个函数对值进行处理(变形关系)

示例:

    // 函子简单示例
    // 首先函子是一个容器也是一个对象,那么我们创建容器对象
    class Container {
        // 函子内部有一个值,通过构造器来接收这个值(value)
        constructor(value) {
            // 将这个值存储起来
            // 这个值是函子内部去维护的,只有函子自己知道,不对外公布
            // 以下划线开始的值是私有的值
            this._value = value
        }
        // 函子具有一个对外的map方法,这个map函数是处理value值的方法,是一个纯函数
        // map方法是通过接收一个函数来处理value值的,所以接收一个函数(fn)
        map(fn) {
            return new Container(fn(this._value))
        }
    }
    
    // 创建函子对象
    const r = new Container(5)
        .map(v => v + 1) // 处理函数为让value值+1,并且map返回一个新的函子,所以我们可以继续使用map处理函数
        .map(v => v * v)
    // 输出看下返回的值
    console.log(r) // => Container { _value: 36 }
    // 我们看到得到的r是一个Container对象,其内部的value值为经过两次map后得到的36
复制代码

通过以上事例我们发现我们每次创建函子的时候都需要调用new命令,这实在不太像函数式编程,因为使用new命令是面向对象编程的标志。 函数式编程一般约定,函子有一个of方法,用来生成新的容器 那我们使用of来改造下上边的示例

示例:使用of改造函子更函数式编程

    class Container {
        // 此处我们使用static来创建一个静态方法
        static of(value) {
            return new Container(value)
        }
        
        constructor(value) {
            this._value = value
        }
        
        map(fn) {
            // 那此处我们也可以直接使用of
            return Container.of(fn(this._value))
        }
    }
    // 使用示例
    const r = Container.of(5)
                .map(v => v + 2)
                .map(v => v * v)
    console.log(r) // => Container { _value: 49 }
    // 通过以上方式我们实现了更函数式编程的函子
    // 上面我们得到了一个r函子对象,而不是一个值,那我们怎么拿出这个值呢?
    // 其实我们永远不去拿出这个值,它是一直存储在函子对象中的
复制代码

总结:

  • 函数式编程的运算不直接操作值,而是由函子完成
  • 函子就是一个实现了map契约(方法)的对象
  • 我们可以吧函子想象成一个函子,这个盒子里封装了一个值
  • 想要处理盒子中的值,我们需要给盒子的map方法传递一个处理函数(纯函数),由这个函数来对值进行处理
  • 最终map方法返回一个包含新值的盒子(函子)

Maybe函子

函子接受各种函数来处理内部容器的值,那么我们就会遇到这样一个问题:容器内部的值可能是一个空值(null/undefined),而外部函数可能未做空值的处理,这时候就可能报错。

使用Maybe函子处理空值情况

示例:

    class Maybe {
        static of(value) {
            return new Maybe(value)
        }
    
        constructor(value) {
            this._value = value
        }
        
        map(fn) {
            // 使用传递的函数处理内部值时判断下
            return this.valid() ? Maybe.of(fn(this._value)) : Maybe.of(null)
        }
        
        // 创建辅助函数来判断空值
        valid() {
            return this._value != null || this._value != undefined
        }
    }
    
    // 测试:不是空值时
    const r = Maybe.of('Hello World')
        .map(x => x.toUpperCase())
        console.log(r) // => Maybe { _value: 'HELLO WORLD' }
    
    // 测试:是空值时
    const r = Maybe.of(null)
        .map(x => x.toUpperCase())
        console.log(r) // => Maybe { _value: null } 
复制代码

Either函子

在Maybe函子中我们学习了如何处理函子内部值是空值的情况,此时我们可以控制传入值的异常。那如果我们在调用传入处理函数fn时返回空值时也会出现异常,那我们应该如何处理呢。

Either函子

  • Either:两者中的任何一个,类似if...else...的处理
  • 异常会让函数变得不纯,Either函子可以用来处理异常

示例:

    // Either函子:二选一
    // 因为是二选一,我们来定义两个函子
    class Left {
        static of(value) {
            return new Left(value)
        }
        
        constructor(value) {
            this._value = value
        }
        
        map(fn) {
            // 此处有所不同
            // 直接返回当前对象
            return this
        }
    }
    
    class Right {
        static of(value) {
            return new Right(value)
        }
        
        constructor(value) {
            this._value = value
        }
        
        map(fn) {
            return Right.of(fn(this._value))
        }
    }
    
    //创建两个函子看一下不同之处
    const l = Left.of(12).map(v => v + 2)
    const r = Right.of(12).map(v => v + 2)
    console.log(l) // => Left { _value: 12 }
    console.log(r) // => Right { _value: 14 }
    
    // 分析:两处结果不同的原因
    // Right函子中map我们是做了正常函子做的事情,得到的结果是预期的
    // Left函子中map我们是直接将当前对象返回,并没有做任何处理,其内部值不会改变
    
    // Left函子有什么作用?
    // 对于纯函数来说,相同的输入要有相同的输出,当发生异常时函子也应该给出相同的输出
    // 因此我们可以使用Left函子来处理异常
    
    // 使用示例:将字符串转成json,在转换时可能发生异常
    function parseJSON(str) {
        try {
            // 当没有异常时正常处理
            return Right.of(JSON.parse(str))
        } catch(e) {
            // 当出现异常时,我们使用Left函子来保存异常
            return Left.of({ error: e.message })
        }
    }
    // 使用
    // 出现异常的
    const errorP = parseJSON('{ name: rh }')
    console.log(errorP) // => Left { _value: { error: 'Unexpected token n in JSON at position 2' } }
    const p = parseJSON('{ "name": "rh" }')
    console.log(p) // => Right { _value: { name: 'rh' } }
    // 通过输出我们可以看到当出现异常时我们能通过Left函子来处理并存储异常
    // 当没有异常时Right函子可以正常执行
复制代码

IO函子

  • IO函子中的_value是一个函数,这里是把函数作为值来处理
  • IO函子可以把不纯的动作存储到_value中,延迟这个不纯的操作(惰性执行),包装当前的操作
  • 把不纯的操作交给调用者来处理

示例:IO函子

    const fp = require('lodash/fp')
    
    class IO {
        // of函数传入的还是一个值
        static of(value) {
            // 此时我们使用IO函子的构造函数
            return new IO(function() {
                // 此时我们通过函数将传递进来的值返回
                return value
            })
        }
        // 此时构造函数里边传入的是一个函数
        constructor(fn) {
            this._value = fn
        }
        
        map(fn) {
            // 返回IO,但是此时我们使用的是IO的构造函数
            // 此时我们使用fp模块中的flowRight将IO函子中存储的value(函数)和map传入的fn进行组合
            return new IO(fp.flowRight(fn, this._value))
        }
    }
    
    // 使用
    // 当前我们使用的是node环境,我们将node中的对象传递进来
    // 当调用IO的of函数时of函数会将我们传递进来值保存到一个函数中,在使用时再来获取process
    // 然后使用map来获取属性
    const io = IO.of(process).map(v => v.execPath)
    console.log(io) // => IO { _value: [Function] }
    // 通过log我们可以看到我们得到了一个io函子,函子中保存的是一个函数
    // value中的function是谁呢?我们来看一下合成过程
    // 1. of方法返回的是io对象,这个io对象中的value存储了一个函数,这个函数返回当前传入的process
    // 2. map方法返回了一个新的io函子,这个新的io函子中value保存的是经过组合的函数
    // 3. map方法中组合了fn和this._value,fn是我们传入的v => v.execPath,this._value是我们使用of得到创建的IO对象中保存的函数(即返回value那个)
    // 4. 那么我们log中得到的function就是分析3中那俩函数的组合
    
    // 获取io对象中的函数
    const ioFn = io._value
    console.log(ioFn()) // => /usr/local/Cellar/node/12.6.0/bin/node (node的执行路径)
复制代码

总结: 我们给map传入的可能是一个不纯的操作,但是经过处理之后,我们保证了IO是以一个纯的操作,不纯的操作我们延迟到了调用_value时,也就达到了副作用在可控范围内。

folktale

函子可以帮我们控制副作用(IO函子),进行异常处理,还能进行异步操作,而在异步操作中会遇到通往“地狱之门”的回调,而是用Task函子能避免出现回调嵌套的情况。

  • 异步任务实现过于复杂,我们可以使用folktale中的Task来演示
  • folktale是一个标准的函数式编程库
    • 它和lodash、ramda不同的是,它没有提供很多的功能函数
    • 只提供了一些函数式处理的操作,例如:compose、curry等;再就是一些函子,如:Task、Either、Maybe等
    • 安装:npm install folktale --save

示例:

    const { compose, curry } = require('folktale/core/lambda')
    const { toUpper, first } = require('lodash/fp')
    
    // folktale提供的curry函数和lodash有所不同
    // curry(arity, fn) arity:标识fn函数有几个参数,fn:要传递的参数
    const f = curry(2, (x, y) => {
        return x + y
    })
    // 使用curry
    console.log(f(1, 2)) // => 3
    console.log(f(1)(2)) // => 3
    
    const ffp = compose(toUpper, first)
    // 使用compose
    console.log(ffp(['one', 'two'])) // => ONE
复制代码

Task函子

  • folktale在2.x版本中的Task和1.0中的区别很大,在这我们使用最新版演示(2.3.2)
  • 其他版本可以查看文档

示例:使用Task函子读取当前package.json中的version字段

    // node fs模块读取文件
    const fs = require('fs')
    const { task } = require('folktale/concurrency/task')
    const { split, find } = require('lodash/fp')
    function readFile(fileName) {
        // 返回task函子
        return task(resolver => {
            // fs3个参数:1.文件路径 2.编码格式 3.回调函数
            fs.readFile(fileName, 'utf-8', (err, data) => {
                // 先看下读取是否出错
                if (err) {
                    resolver.reject(data)
                }
                resolver.resolve(data)
            })
        })
    }
    // 使用
    // 步骤解析:
    // 通过readFile获取task函子
    // task函子提供run方法执行也就是读取package.json文件
    // task函子中我们可以拿到的是整个json数据,此时可以直接在onResolved获取
    // 但是此时在onResolved处理比较麻烦,那我们知道函子都有map方法
    // 此时我们就可以调用task函子的map方法处理获取到的json
    // 处理:1.通过换行符在分割json文件中的每一行数据
    // 处理:2.然后查找具有version字段的数据
    // 此时我们在onResolved中拿到的就是map处理后的数据
    readFile('package.json')
        .map(split('\n'))
        .map(find(v => v.includes('version')))
        .run() // task函子接口
        .listen({ // task函子提供监听方式获取数据
            onReject: err => {
                console.log(err)
            },
            onResolved: value => {
                console.log(value) // => "version": "1.0.0",
            }
        })
复制代码

Pointed函子

Pointed函子我们是第一次提到,但是我们之前在使用函子时一直在使用它

  • Pointed函子是实现了of静态方法的函子
  • of方法是为了避免使用new来创建对象,更深层次的含义是of方法用来把值放到上下文Context中(把值放到容器中(函子),使用map来处理),如下图:

avatar

示例:Pointed函子

    class Container {
        // 具有of方法的函子就是Pointed函子
        static of(value) {
            return new Container(value)
        }
        
        ... // 其余省略
    }
复制代码

Monad函子

学习之前Monad函子之前我们先来看一个之前我们写的IO函子的例子

示例:IO函子,使用IO函子读取文件并输出

    const fp = require('lodash/fp')
    const fs = require('fs')
    class IO {
        static of(value) {
            return new IO(function() {
                return value
            })
        }
        constructor(fn) {
            this._value = fn
        }
        
        map(fn) {
            return new IO(fp.flowRight(fn, this._value))
        }
    }
    
    // 使用IO函子读取文件(package.json)
    const readFile = function(fileName) {
        return new IO(function() {
            // 此处我们使用同步读取
            return fs.readFileSync(fileName, 'utf-8')
        })
    }
    
    const printV = function(v) {
        return new IO(function() {
            console.log(v)
            return v
        })
    }
    
    // 使用
    const cat = fp.flowRight(printV, readFile)
    const r = cat('package.json')
    console.log(r) // => IO { _value: [Function] }
    // 此时得到的r为:IO(IO(v)) 内部_value对应的[Function]返回结果就是io函子
    // 解析:
    // 1.flowRight组合了readFile和printV
    // 2.readFile返回的是一个io函子对象
    // 3.printV中的v是readFile返回的io函子
    // 4.所以printV中保存的值就是readFile返回的io函子
    // 5.因此cat得到的是执行readFile和printV之后的IO(IO(v))
    
    // 那我们如何获取到我们想要的最终的值呢?
    console.log(r._value()._value()) // => 输出package.json
    // 我们可以通过连续调用_value()方法获取到我们想要的,这用起来很不方便
    // 如何解决呢?Monad函子!
复制代码

Monad

  • Monad函子时可以变扁(解决函数嵌套)的Pointed函子
  • 一个函子如果具有join和of两个方法并遵守一些定律就是一个Monad

示例:改造上边的IO函子

    const fp = require('lodash/fp')
    const fs = require('fs')
    class IO {
        static of(value) {
            return new IO(function() {
                return value
            })
        }
        constructor(fn) {
            this._value = fn
        }
        
        // 我们需要在普通的IO函子中添加join方法
        join() {
            // 此处直接将当前函子的值(_value)返回(返回就是函子)
            return this._value()
        }
        
        map(fn) {
            return new IO(fp.flowRight(fn, this._value))
        }
        
        // 我们在使用Monad的时候需要将join和map结合使用
        // 此时我们再添加一个flatMap函数
        // flatMap函数的作用就是调用join和map函数将函子变扁
        flatMap(fn) { // fn供map使用
            return this.map(fn).join()
        }
    }
    
    // 使用IO函子读取文件(package.json)
    const readFile = function(fileName) {
        return new IO(function() {
            // 此处我们使用同步读取
            return fs.readFileSync(fileName, 'utf-8')
        })
    }
    
    const printV = function(v) {
        return new IO(function() {
            console.log(v)
            return v
        })
    }
    
    // 使用
    // 分析:
    // 此处的flatMap的作用是调用函子内部的map方法然后调用join方法
    // 得到的是什么呢?
    // 执行flatMap中map时是执行了printV,返回的是io函子,此时io函子是一个嵌套的函子,内部嵌套的是readFile返回的函子
    // 调用flatMap中的join时返回了当前函子(printV汉子)保存的值(readFile函子)
    // 紧接着在调用readFile函子中的join方法,此时返回的就是readFile返回函子存储的值(_value:读取文件的函数)的执行结果,也就是读取的文件的内容
    const r = readFile('package.json')
                .flatMap(printV)
                .join()
    console.log(r) // => package.json内容
    
    // 如果在读取文件后,需要将内容中的字母转成大写
    const upperR = readFile('package.json')
                    .map(fp.toUpper)
                    .flatMap(printV)
                    .join()
    console.log(upperR) // => 大写的package.json内容
复制代码

什么时候使用map什么时候使用flatMap呢?

  • 当只需要返回一个值时使用map
  • 当需要将嵌套的函子变扁时使用flatMap