函数式编程 -- 函子(Functor)

文章内容输出来源:拉勾教育 大前端高薪训练营

前言

我在另一篇文章 函数式编程 – 纯函数、柯里化函数 中写到,副作用会让一些函数变得不纯,那么,我们如何把副作用控制在可控的范围内呢,这就涉及到了函子的概念。

函子(Functor)

1. 什么是函子

在开始学习之前,我们先来了解什么是函子?

  • 函子是一个容器,包含值和值的变形关系(即函数)。

  • 函子是一个特殊的容器,通过一个普通的对象来实现,该对象具有 map 方法,map 方法可以运行一个函数对值进行处理(变形关系)

    代码如下(示例):

    // 一个容器,包裹一个值
    class Container {
        constructor (value) {	        
            this._value = value // 使用_表示变量私有化
        }
        // map方法, 传入变形关系(函数),将容器里面的每一个值,映射到另一个容器
        map (fn) {
            return Container.of(fn(this._value))
        }
    }
    
    // 创建函子对象
    let r = new Container(5)
    	   .map(x => x + 1) // 返回新的函子对象, 在新的函子对象中保存值
    	   .map(x => x * x )
    console.log(r);
    

    上面的代码中,Container 是一个函子,它的map方法接受函数f作为参数,然后返回一个新的函子,里面包含的值是被 fn 处理过的(fn(this._value))。
    上面生成新的函子对象的时候,用了 new 命令。new 命令是面向对象编程的标志,不符合函数式编程的思想。

  • 函数式编程一般约定,函子有一个of方法,用来生成新的容器。

    那么,我们接下来就用 of 方法替换掉 new 进行改造。

    代码如下(示例):

    class Container {
        // of 使用static,将其设置为静态方法,可以使用 "类.类方法" 的方式调用
        static of (value) {
            return new Container(value)
        }
        ...... // 下面代码和上面的一样,就不在此赘述了
    }
    // 链式编程
    let r = Container.of(5).map(x => x + 2).map(x => x * x)
    console.log(r);		
    
  • 总结

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

2. MayBe 函子

空值问题

  • 函子接受各种函数,处理容器内部的值。但是,当容器内部的值是一个空值(比如null),而外部函数未必有处理空值的机制,如果传入空值,很可能就会出错。

    代码如下(示例):

    // 值如果不小心传入了空值(副作用) 
    Container.of(null) .map(x => x.toUpperCase()) 
    // TypeError: Cannot read property 'toUpperCase' of null 12
    

解决方案

  • MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围),准确的说是,它的 map 方法里面设置了空值检查。

    代码如下(示例):

    class Maybe {
        map (fn) {
            return this.isNothing() ? Maybe.of(null) : Maybe.of(fn(this._value))
        }
    
        isNothing () {
            return this._value == null || this._value == undefined
        }
    }
    
    // 测试
    let r = Maybe.of('Hello World').map(x => x.toUpperCase()).map(x => null).map(x => x.split(' '))
    console.log(r);
    

    然而,在 MayBe 函子中,我们很难确认是哪一步产生的空值问题,要解决这个问题,我们就要借助下面的 Either 函子 ,去处理异常情况。

3. Either 函子

在普通的面向对象编程中,我们通常使用条件运算语句 if…else… 进行异常等方面的判断。而在函数式编程中,我们是用 Either 函子 进行表达。Either,英文意思,两者中的任何一个。

  • Either 函子内部有两个值:左值(Left)和右值(Right)。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。

    代码如下(示例):

    // 记录错误信息, 右值不存在时使用的默认值
    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))
        }
    }
    
    // Either 用来处理异常
    function parseJSON (str) {
        try {
            return Right.of(JSON.parse(str))
        } catch (e) {
            return Left.of({error: e.message })
        }
    }
    
    // let r = parseJSON('{ name: zs }')
    // console.log(r) // 执行 Left
    
    let r = parseJSON('{ "name": "zs" }').map(x => x.name.toUpperCase())
    console.log(r) // 执行 Right
    

4. IO 函子

在程序运行中,往往会有很多的函数依赖于外部环境,从而会带来相应的副作用,这也就是我们前面所说的不纯函数,在这里,我们就不多加赘述了。那么,如何可以把不纯的函数,让它 “纯”起来呢?为了解决这个问题,我们需要一个新的 Functor,即 IO 函子

特性

  • IO 函子与其他函子的不同在于,IO 函子中的 _value 是一个函数,把函数作为值来处理。

  • IO 函子可以把不纯的动作存储到 _value(函数) 中,延迟执行这个不纯的操作(惰性执行)。可以认为,IO 包含的是被包裹的操作的返回值。

  • IO 函子把不纯的操作交给调用者来处理。

    代码如下(示例):

    const { values } = require('lodash')
    const fp = require('lodash/fp')
    
    class IO {
        static of (value) { // 
            return new IO(function () {
                return value
            })
        }
    
        constructor (fn) { // value 存储函数
            this._value = fn
        }
    
        map (fn) {
        	// 将传入的 fn 进行包裹,利用fp.flowRight() 使之柯里化
            return new IO(fp.flowRight(fn, this._value))
        }
    }
    
    // 调用,process:node中的进程模块
    let r = IO.of(process).map(p => p.execPath)
    // console.log(r) // IO { _value: [Function] }
    console.log(r._value()); // 当前node进程的执行路径
    

5. Folktale

  • folktale 一个标准的函数式编程库,和 lodash、ramda 不同的是,他没有提供很多功能函数。

  • 只提供了一些函数式处理的操作,例如:compose、curry 等,一些函子 Task、Either、 MayBe 等。

    代码如下(示例):

    // Folktale 函数式编程库
    const { toUpper, first } = require('lodash/fp')
    const { compose, curry } = require('folktale/core/lambda')
    
    // 第一个参数是传入函数的参数个数
    let f = curry(2, (x, y) => x + y)
    console.log(f(1, 2));
    console.log(f(1)(2));
    
    let f = compose(toUpper, first)
    console.log(f(['one', 'two']));
    
  • Task 异步执行
    Task 函子通过类似 Promise 的 resolve 的风格来声明一个异步流程,在下面的代码中声明的 readFile 函数中返回的 Task 函子 并没有真正发起请求,它只声明了一个请求动作,这个动作并没有被执行。

    代码如下(示例):

    const fs = require('fs')
    const { task } = require('folktale/concurrency/task')
    const { split, find } = require('lodash/fp') 
    
    function readFile (filename) {
    	// 通过类似 Promise 的 resolve 的风格来声明一个异步流程,返回一个Task 函子
        return task(resolver => {
        	// fs 的readFile() 执行的是异步操作
            fs.readFile(filename, 'utf-8', (err, data) => {
            	// 类似Promise中的resolve 和 reject
            	// reject用来报错误信息,resolve用来获取执行成功的数据。
                if (err) resolver.reject(err)
    	        resolver.resolve(data)
            })
        })
    }
    
    let version = readFile('../package.json') // 只声明读取文件的动作,该动作并未执行
    			 .map(split('\n'))   // 通过 map 方法,添加不同的数据操作流程。
                 .map(find(x => x.includes('version'))) // includes() 方法用于判断字符串是否包含指定的子字符串。
                 .run()           // 调用 run() 触发上面的动作,进行
    		     .listen({
    		         onRejected: err => { // 执行失败
    		             console.log(err);
    		         },
    		         onResolved: value => { // 执行成功
    		             console.log(value);
    		         }
    		     })
    console.log(version); // "version": "1.0.0"
    

在上面的代码中,Task 的异步流直到 run 之前都仅仅是「动作」,没有「执行」。task 函子中提供了 run() 方法,用来触发动作的执行。也就是说,执行 run 方法之后,才会触发上面的文件读取,以及对文件内容的一系列处理等操作。Task 函子中,还提供了 listen() 方法,用来监听事件的执行状态。onRejected 表示 动作执行失败后,要执行的函数,onResolved 表示 动作执行成功后,要执行的函数。

6. Pointed 函子

  • Pointed 函子是实现了 of 静态方法的函子;

  • of 方法是为了避免使用 new 来创建对象,更深层的含义是 of 方法用来把值放到上下文
    Context(把值放到容器中,使用 map 来处理值)

    代码如下(示例):

    class Container { 
    	static of (value) { 
    		return new Container(value) 
    	}
    	......
    }
    Contanier.of(2) .map(x => x + 5)
    

7. Monad(单子)

  • 在使用 IO 函子的时候,如果我们写出如下代码:

    代码如下(示例):

    const fs = require('fs') 
    const fp = require('lodash/fp') 
    let readFile = function (filename) { 
    	return new IO(function() { // 返回一个文件类型的实例
    		return fs.readFileSync(filename, 'utf-8') 
    	}) 
    }
    let print = function(x) { 
    	return new IO(function() { 
    		console.log(x)  // 将文件内容输出
    		return x 
    	})
    } 
    
    // IO(IO(x)) 
    // 调用 _value() 时,执行的是print 中的function
    let cat = fp.flowRight(print, readFile) 
    
    // 调用 
    let r = cat('package.json')._value()._value() 
    console.log(r)
    
    

特性

  • Monad 函子是可以变扁的 Pointed 函子,IO(IO(x))

  • Monad 内部封装的值是一个函数(这个函数返回函子)

  • 一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad

    代码如下(示例):

    // IO Monad
    const fs = require('fs')
    const fp = require('lodash/fp')
    
    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))
        }
    	
    	// 通过 join 方法避免函子嵌套
        join () { 
            return this._value()
        }
    
        // 同时调用map 和 join
        flatMap (fn) {
            // this.map(fn) 调用完后,返回函子
            return this.map(fn).join()
        }
    }
    
    // 读取文件的内容,并且把他们打印出来
    let readFile = function (filename) {
        return new IO(function () {
            return fs.readFileSync(filename, 'utf-8') // 同步读取文件
        })
    }
    
    let print = function (x) {
        return new IO(function () {
            console.log(x)
            return x
        })
    }
    
    let r = readFile('../package.json') // 返回函子时,调用faltMap; 返回值时,调用map
            // .map(x => x.toUpperCase())
            .map(fp.toUpper)
            .flatMap(print)    // 返回 IO { _value: [Function] } -- 函子
            .join()            // 返回 map 后的文件内容
    console.log(r);
    

作用

  • Monad 函子 主要用来解决函子嵌套的问题,通过 join 方法避免函子嵌套。

何时使用

  • 当一个函数返回一个函子的时候,需要使用 Monad。

总结

  • 简单说,Monad就是一种设计模式,表示将一个运算过程,通过函数拆解成互相连接的多个步骤。你只要提供下一步运算所需的函数,整个运算就会自动进行下去。也就是说,Monad 是将一个会返回包裹值的函数应用到一个被包裹的值上。

参考
函数式编程入门教程
异步流程与 Task 函子
JavaScript函数式编程 IO涵子,错误处理涵子

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值