聊一聊函数式编程的函子是干什么的

Functor(函子)

为什么要学习函子

到目前为止已经学习了函数式编程的一些基础,比如说纯函数、柯里化、函数组合,函数式编程其实就是把一些运算抽象为函数,将来这些函数我们可以做到最大化的重用,另外我们还知道,我们的函数式编程是建立在数学思想上的,比如我们的纯函数就是我们数学中的函数,我们现在要学习的函子,其实也是建立在数学的基础上的,但是我们还没有演示在函数式编程中如何把副作用控制在可控范围内、异常处理、异步操作等,而这些我们之后可以使用函子来控制。

什么是Functor

  • 容器:包含值和值的变形关系(这个变形关系就是函数)
    • 换句话说那这个容器里边就包含了这个值以及处理这个值的函数
  • 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法,map方法可以运行一个函数对值进行处理(变形关系)
    • 我们可以把它想象为一个盒子,这个盒子里边有一个值,并且这个盒子对外要公布一个方法,这个方法就是map,map会接受一个参数,参数是一个对值进行处理的函数
  • 示例
class Container {
     
    constructor (value) {
     
        this._value = value
    }

    map (fn) {
     
        return new Container(fn(this._value))
    }
}

let r = new Container(5).map(x => x + 1).map(x => x * x)
console.log(r)
/**
 * 函子是一个普通的对象,这个对象里边应该维护一个值,并且要对外公布一个map方法
 * 所以我们可以通过一个类来描述函子
 * 
 * 1. 我们先定义一个类,类名Container也只是因为函子是一个容器所以这样比较见名知意而已
 * 
 * 2. 我们要先在这个类中定义一个构造函数constructor
 *    但我们创建函子时,函子内部要有一个值,所以在构造函数中我们要把这个值传递进来,函子内部把这个值存储起来
 *    注意这个值是函子内部维护的,只有他自己知道,这个值是包含在一个盒子里面的,它是不对外公布的
 * 
 * 3. 所以定义的时候我们用this._value,我们要提前约定,所有以_开头的属性都是私有属性
 *    也就是说这个属性我们不想让外部去访问,他的值就是我们在初始化的时候传递进来的value
 * 
 * 4. 接下来我们这个盒子要对外公布一个map方法,map方法的作用是接收一个处理值的函数
 *    这个函数也是一个纯函数,因为我们要把value传递给这个函数,由这个函数去真正处理我们这个值
 * 
 * 5. 在这个函数中我们要去处理这个值,并且返回一个新的盒子,也就是返回一个新的函子
 * 
 * 6. 再返回新的函子时我们要把处理的值传递给这个Container
 * 
 * 这就完成了一个基本的函子
 * 
 * 我们现在在来思考一个问题
 * 
 * 我们每次要创建一个函子的时候,我们都要去调用new来处理,这样有些不方便,所以我们可以把new这个操作封装一下
 * */ 

class Container {
     

    static of (value) {
     
        return new Container(value)
    }

    constructor (value) {
     
        this._value = value
    }

    map (fn) {
     
        return  Container.of(fn(this._value))
    }
}

let r = Container.of(5)
            .map(x => x + 2)
            .map(x => x * x)
console.log(r._value)

/**
 * 我们每次调用Container的时候都需要使用New,看起来很面向对象,因为都需要new,
 * 而现在我们用的不是面向对象的思想,我们用的是函数式编程,所以我们想避免这个new
 * 
 * 那怎么避免呢?
 * 
 * 我们可以在 Container 创建一个静态的方法是 static,方法名字叫做 of
 * of 方法的作用就是给我们返回一个函子对象,但是我们在创建时要传入一个 value,所以 of 就去接受一个 value
 * 然后在 of 方法内直接去 return 一个 new Container 并且将value传入进去
 * 
 * 然后在map方法内将 new Container改善成Container.of 
 * */ 

函子总结

  • 函数式编程的运算不直接操作值,而是由函子完成
  • 函子就是一个实现了map契约的对象
  • 我们可以把函子想象成一个盒子,这个盒子里封装了一个值
  • 想要处理盒子中的值,我们需要给盒子的map方法传递一个处理值的函数(纯函数)有这个函数来对值进行处理
  • 最终 map 方法返回一个包含新值的盒子(函子)
// 调用Container.of()并传递一个参数时,不小心传递一个null或者undefind,这时候会发生什么问题呢?

class Container {
     

    static of (value) {
     
        return new Container(value)
    }

    constructor (value) {
     
        this._value = value
    }

    map (fn) {
     
        return  Container.of(fn(this._value))
    }
}

let r = Container.of(null)
    .map(x => x.toUpperCase())
console.log(r)

/**
 * 因为我们传递的是null,而在 map 方法里边我们要去执行 null.toUpperCase()
 * 所以这里会出现空值得异常 --- TypeError: Cannot read property 'toUpperCase' of null
 * 
 * 并且会让我们这个函数不纯,因为纯函数是对相同的输入始终有相同的输出
 * 而我们输入null的时候,此时这个函数没有输出,而是出现了异常
 * 
 * 那这个时候我们传入的null,其实就是我们的副作用,接下来我们需要解决这个问题,去控制这个副作用
 * 
 * 下一小节我们会通过一个新函子,去处理空值的问题
 * */ 

MayBe函子

  • 在编程的过程中可能会遇到很多错误,需要对这些错误做相应的处理
  • MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)
  • 示例
class MayBe {
     
    static of (value) {
     
        return new MayBe(value)
    }

    constructor (value) {
     
        this._value = value
    }
    
    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())
// console.log(r)   // MayBe { _value: 'HELLO WORLD' }

// 传入一个null或者undefined时
// let r = MayBe.of(null)
//     .map(x => x.toUpperCase())
// console.log(r)   // MayBe { _value: null }

// 多次调用的中间传入一个null或者undefined时
let r = MayBe.of('hello')
    .map(x => x.toUpperCase())
    .map(x => null)
    .map(x => x.split(' '))
console.log(r)  // MayBe { _value: null }
// 不知道什么时候变为Null的
  • 虽然我们使用MayBe是可以避免传入null或者undefined时出现异常,但是在多次调用时结果如果出现了null或者undefined时,我们不确定具体是哪个位置导致的,我们在下一节的学习中通过Either函子去解决这个问题

Either函子

  • Either 两者中的任何一个,类似于 if…else… 的处理

  • 异常会让函数变得不纯,Either 函子可以用来做异常处理

  • 示例

const {
      truncate } = require("fs")

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))
    }
}

// let r1 = Right.of(12).map(x => x + 2)
// let r2 = Left.of(12).map(x => x + 2)

// console.log(r1) // Right { _value: 14 }
// console.log(r2) // Left { _value: 12 }

/**
 * 我们需要先定义两个类,两个类之间有区别,主要是Map方法中有所区别
 * 
 * 我们打印后的两个输出的结果是不一样的,两个代码都是一样的,只是一个使用left创建,一个试用right创建
 * 
 * right中的值 +2 返回了14,但是 left 中直接是返回了传入的数据,没有做任何处理
 * 
 * 我们可以对比两个类中的map方法
 * 
 * Left 中 直接把当前对象返回了,并没有调用我们传入的fn,这样做是为了让Left中嵌入一个错误消息
 * 
 * 下边我们来掩饰一个可能会发生错误的函数,比如我们要把一个json形式的字符串,转成一个json对象
 * */ 

function parseJSON (str) {
     
    try{
     
        return Right.of(JSON.parse(str))
    } catch (e) {
     
        return Left.of({
     error : e.message})
    }
}

// let r1 = parseJSON('{ name: zs }')
// console.log(r1)  // Left { _value: { error: 'Unexpected token n in JSON at position 2' } }

// let r2 = parseJSON('{ "name": "zs" }')
// console.log(r2) // Right { _value: { name: 'zs' } }

let r3 = parseJSON('{ "name": "zs" }')
            .map(x => x.name.toUpperCase())
console.log(r3) // Right { _value: 'ZS' }

/**
 * 我们先声明一个叫做 parseJSON 的函数,并给他接收一个参数
 * 
 * 接下来我们要调用JSON.parse把我们传入的字符串转为json对象并且返回
 * 
 * 因为调用JSON.parse时可能会出现异常,所以我们使用 try {} catch(e) {} 
 * 
 * 因为出现错误情况我们不去处理的话,那就不是一个纯函数
 * 现在我们希望用函数式的方式去处理,所以我们需要写一个纯函数
 * 
 * 我们现在要在try里return一个函子,我们会把我们转换后的结果交给这个函子,将来在这个函子内部去处理
 * 
 * 如果出现错误我们是不能不管的,我们也要返回一个值,因为对于纯函数来说,对于相同的输入,始终要有相同的输出
 * 
 * 那这个时候我们也要返回一个函子,因为我们Either中有Left和Right,我们用right去处理正确的值
 * 如果出现异常的时候我们可以返回一个left中的函子,而left这个函子里边,可以帮我们去存储一些错误的信息
 * */ 

IO 函子

  • 现在我们已经对函子有一个简单的认识,我们可以把它想象为一个盒子,盒子里保存一个值,然后通过调用盒子的map方法,我们可以传入一个函数,通过这个函数,对盒子里的值进行处理,现在我们学习一个新的函子,
  • IO 函子中的 _value 是一个函数,因为函数是一等公民,所以这里是把函数作为值来处理
  • IO 函子可以把不纯的动作存储到 _value中,_value中存储的是函数,我们在函子内部并没有调用这个函数,所以通过IO函数我们其实是延迟执行这个不纯的操作(惰性执行)包装当前的操作是一个纯的操作
  • 把不纯的操作交给调用者来处理
  • 示例
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))
    }
}

let r = Io.of(process).map( x => x.execPath)
console.log(r._value()) // C:\Program Files\nodejs\node.exe

/**
 * constructor要去接收一个函数,因为IO函子里边保存的是函数,我们将接收到的函数保存在 _value 中
 * 
 * of方法与之前有点差异,他要接受一个数据,然后返回一个新的IO函子
 * 
 * 在这里便要返回一个IO函子,所以要调用IO构造函数,然后传入一个函数
 * 因为刚刚我们写过它的构造函数,它的构造函数需要接收的是一个函数
 * 
 * 所以在这里我们传入一个函数,在这个函数内,我们把刚刚of方法的值返回
 * 
 * 到这里我们其实可以感受到IO函子,最终想要的还是一个结果,只不过他把取值的过程包装到了函数里边来
 * 将来需要值的时候,在来执行这个函数,来取值
 * 
 * map方法和之前一样还是需要传递一个fn,这个方法里边我们还是要返回一个IO函子,这里我们要调用IO的构造函数
 * 而不是调用of方法,因为外部方法里面,我们把当前函子的value,也就是这个函数和我们传入的这个函数
 * 组合成一个新的函数,而不是调用函数去给值 --- 这就是和以前不一样的地方
 * */ 
  • 总结
    • IO函子内部帮我们包装了一些函数,当我们在传递函数的时候,有可能这个函数是一个不纯的操作,但我们不管他是纯还是不纯,我们IO这个函子在执行中,他返回的结果始终是一个纯的操作,IO中有可能包裹不纯的操作,但是当前这个执行始终是一个纯的操作,我们调用map方法时始终会返回一个IO的函子,但是我们IO函子当中的_value属性,它里边保存的这些函数,因为它里边最终要合并很多函数,所以它里边有可能是不纯的,我们把这些不纯的操作,延迟到了调用的时候,我们通过IO函子控制了副作用在可控范围内发生

folktale

Task异步执行

函子可以帮我们控制副作用,进行异常处理,还可以帮我们处理异步任务,因为在异步操作中,会出现回调地狱而使用 Task 函子,会避免回调地狱。

  • 异步任务的实现过于复杂,我们是用 folktale 中的 Task 来演示
  • folktale 一个标准的函数式编程库
    • 和lodash、ramda 不同的是,他没有提供很多功能函数
    • 只提供了一些函数处理的操作,例如:compose、curry等,一些函子Task、Either、MayBe 等
const {
      compose, curry } = require('folktale/core/lambda')
const {
      toUpper, first } = require('lodash/fp')

// 这里的柯里化,与lodash中的柯里化,稍微有一点点的区别
let f = curry(2, (x, y) => x + y)
/**
 * 两个参数:
 *      第一个:指明我们后边这个函数他有几个参数,目的是为了避免一些错误
 * */ 
console.log(f(1, 2))
console.log(f(1)(2))
console.log(f()(1, 2))

// compose --- 函数组合 --- 和lodash中的函数组合 flowRight 不一样
let f = compose(toUpper, first)

console.log(f(['one', 'two']))

Task 函子

  • folktale(2.3.2)2.x 中的Task 和 1.0 中的 Task 区别很大, 1.0 中的用法更接近我们现在掩饰的函子
  • 这里以2.3.2来演示
  • 示例
// 读取package.json,并将其中的version解析出来

const {
      task } = require('folktale/concurrency/task')   // 导入task函子,路径可查官网
const fs = require('fs')                                // 导入fs模块
const {
      split, find } = require('lodash/fp')            // 导入lodash/fp的方法

// 读取文件的函数,需要一个参数 --- 文件的路径,相对路径可以直接写文件名'package.json'
function readFile ( filename ) {
     
    /**
     * 返回一个task函子,需要接受一个函数,函数的参数是固定的resolver,可以通过官网来查询
     * resolver是一个对象,里边提供了两个方法:resolve() 和 reject() --- 类似于promise
     * */ 
    return task(resolver => {
     
        // 异步读取文件,需要三个参数:1---读取的路径;2---使用的编码;3---回调函数(在node中是错误优先);
        fs.readFile(filename, 'utf-8', (err, data) => {
     
            // 判断读取文件时是否出错
            if(err){
     
                // 失败时调用
                resolver.reject(err)
            }
            // 成功时调用
            resolver.resolve(data)
        })
    })
}

// 传入路径
readFile('package.json')
    /**
     * 执行完readFile他不会去读取文件,而是返回了一个Task的函子
     * 我们想要它读取文件的话需要调用Task给我们提供的.run方法
     * */ 
    .run()
    /**
     * 读取完文件,我们没有传递resolve,我们不知道如何去处理这个数据
     * 所以我们需要Task给我们提供的listen来监听当前的执行状态,这里是以时间的方式来给我们提供的
     * */ 
    .listen({
     
        // 失败时执行的
        onRejecter: err => {
             
            console.log(err)
        },
        // 成功是执行的
        onResolved: value =>{
            
            console.log(value)
        }
    })

// 以换行为切割,分成数组,然后再去其中寻找version
readFile('package.json')
    .map(split('\n'))
    .map(find(x => x.includes('version')))
    .run()
    .listen({
     
        onRejecter: err => {
     
            console.log(err)
        },
        onResolved: value =>{
     
            console.log(value)
        }
    })

Pointed函子

  • Pointed函子时实现了of静态方法的函子
  • of方法是为了避免使用new来创建对象,更深层的含义是of方法用来把值放到上下文Context(把值放到容器中,使用Map来处理值)
  • 示例
class Content {
     
    static of (value) {
     
        return new Content(value)
    }
    ......
}

let c = Content.of(2)
            .map(x => x + 3)
console.log(c)

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))
    }
}

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 cat = fp.flowRight(print, readFile)

/**
 * 结构:
 *      IO(IO(x))
 * 
 * 外层IO是print返回的这个函子
 * 内层IO是readFile返回的函子
 * 
 * 因为先调用readFile这个函子,我们把readFile返回的这个函子传递给了print这个函数
 * 
 * 所以print里边new IO内部输出的x其实就是readFile返回的这个函子,所以我们现在拿到的结果是一个函子嵌套了一个函子
 * */ 
let r = cat('package.json')

// console.log(r)     // IO { _value: [Function] }
// console.log(r._value())     // IO { _value: [Function] }    IO { _value: [Function] }
console.log(r._value()._value())        // IO { _value: [Function] }    { ... 打印出了package.json ...}
  • 现在我们的代码已经结束了,效果也完成了,但是也可以看出,我们在调用嵌套函子中的函数时,非常的不方便
  • 如果函子有嵌套的话,我们想要调用嵌套函子中的函数的时候我们需要 r._value()._value() 这样确实是可以实现,但是看起来确实不太合适
  • 所以我们想要把它改造一下,如何改造就需要用到 Monad(单子)

Monad(单子)

  • Monad函子是可以变扁的Pointed函子,IO(IO(x))
    • 变扁:上一节我们遇到了函子嵌套时调用起来不方便的问题,变扁就是为了解决嵌套的问题
    • 函数嵌套的话,我们可以使用函数组合来解决这个问题,而函子嵌套的话,我们可以使用Monad(单子)
  • 一个函子如果具有 join 和 of 两个方法并遵循一些定律就是一个Monad
    • of我们已经很熟悉了,join其实也不复杂,这些定律我们就先不去考虑,因为都是一些简单的数学规律
    • join — 里边直接返回了我们对 _value 的调用,因为在monad里边,最终会返回一个函子,所以我们通过调用 _value,可以返回一个函子,这样可以解决函子嵌套的问题
  • 示例
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))
    }

    join () {
     
        return this._value()
    }

    /**
     * 我们在使用monad的时候,经常会把,join和map联合起来使用
     * 因为map的作用是把我们当前这个函数和我们函子内部的_value组合起来,返回一个新的函子
     * 
     * map在组合这个函数的时候这个函数最终也会返回一个函子,所以我们需要调用Join将其变扁
     * 
     * flatMap的作用就是同时调用map和join
     * */ 

    flatMap (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')
            .flatMap(print)
            .join()

/**
 * 调用readFile的时候会返回一个函子,函子里边包裹了我们读取文件的操作
 * 
 * 我们需要将读文件的操作和打印的操作合并起来
 * 
 * 也就是接下来要将print调用进来
 * 
 * 调用 map 还是 flatMap 取决于当我们要合并的这个函数
 * 返回的直接是值就用map,返回的是一个函子我们就需要使用flatMap了
 * */ 

console.log(r)
  • 总结
    • 什么是monad:其实就是有一个静态的IO方法和join方法的函子
    • 什么时候使用monad:当一个函数返回一个函子的时候我们要想到monad,monad可以帮我们解决函子嵌套的问题
      • 当我们想要合并一个函数,并且这个函数直接返回一个值,这个时候我们可以调用map方法
      • 当我们想要合并一个函数,这个函数想要返回一个函子,这个时候我们要用flatMap方法

你可能感兴趣的:(javascript)