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

755 阅读7分钟

范畴的抽象——函子

函数式编程思想源于范畴论,理解函数式编程的关键在于理解范畴论。

范畴论是数学的一门学科,以抽象的方法处理数学概念,将这些概念形式化成一组组的“物件”及“态射”。数学中许多重要的领域可以形式化为范畴。使用范畴论可以令这些领域中许多难理解、难捉摸的数学结论更容易叙述证明。范畴最容易理解的一个例子为集合范畴,其物件为集合,态射为集合间的函数。但需注意,范畴的物件不一定要是集合,态射也不一定要是函数;一个数学概念若可以找到一种方法,以符合物件及态射的定义,则可形成一个有效的范畴,且所有在范畴论中导出的结论都可应用在这个数学概念之上。

范畴论使用函数,表达范畴之间的关系。

伴随着范畴论的发展,就发展出一整套函数的运算方法。这套方法起初只用于数学运算,后来有人将它在计算机上实现了,就变成了今天的"函数式编程"。

在范畴论中,函子是范畴间的一类映射。函子也可以解释为小范畴范畴内的态射。

什么是函子?

函子可以理解为一个范畴的抽象,它是函数式编程中的基本单位,它包含了一些值,同时暴露出一个方法可以对其中的值进行操作,也就是说,函子是一个包含了值的容器。

下面我们一起来实现一个函子

class Contaniner {
  static of(value) {
    return new Contaniner(value)
  }
  constructor (value) {
    this._value = value
  }
  map (fn) {
    return new Contaniner(fn(this._value))
  }
}

const res = Contaniner.of(3).map((v) => v * 3 ).map((v) => v + 1)
console.log(res)

Container就是一个函子,它暴露了一个静态的of方法来生成一个函子,利用of方法返回一个新的函子,在构造函数中接受一个参数并赋值到函子内部,同时它的map方法接受一个函数,这个函数就是对函子内部值的操作。

总结:

  • 容器包含了值和值的变形关系,函子就是一个容器,他内部有个值,一个map方法接受一个函数(变形关系)。
  • 函数式编程不直接操作值,而是由函子完成。
  • 函子就是一个实现了map的对象。
  • 我们可以把函子想象成一个盒子,盒子中封装了一个值,如果想处理这个值就需要给它传递一个纯函数,由这个函数对值进行处理。
  • 最终map方法返回一个包含新值的函子,所以可以进行链式调用。

为什么要使用函子?

我们在函数式编程中要使用纯函数,但函数难免会有副作用,使用函子就可以把副作用控制在可控的范围内,比如异步操作,异常处理等。

Maybe函子

我们在编程中可能会遇到很多异常,异常会让函数变得不纯,需要对这些异常进行相应的处理。 比如在我们给函子传入一个空值,同时对它进行操作的时候函数就会报错。这个时候我们就可以使用Maybe函子去对他进行处理。

// MayBe函子
class MayBe {
  static of (value) {
    return new MayBe(value)
  }
  constructor (value) {
    this._value = value
  }
  
  map(fn) {
    return this.isNullOrUndefined() ? MayBe.of(null) : MayBe.of(fn(this._value))
  }

  isNullOrUndefined() {
    return this._value === null || this._value === undefined
  }
}

const res = MayBe.of(3).map(x => x * 2).map(x => null).map(x => x + 1)
console.log(res)

当传入Maybe函子的值为空值时,Maybe函子始终返回null,控制了副作用。

但是当多次调用map方法的时候,我们就不知道哪一次的调用使maybe函子的值为null,这个时候我们可以使用Either函子。

Either函子

//Either函子模拟
class Right {
  static of(value) {
    return new Right(value)
  }
  constructor (value) {
    this.value = value
  }
  map(fn) {
    return Right.of(fn(value))
  }
}
class Left {
  static of(value) {
    return new Left(value)
  }
  constructor (value) {
    this.value = value
  }
  map(fn) {
    return this
  }
}
const parseJson = (str) => {
  try {
    return Right.of(JSON.parse(str))
  } catch (e) {
    return Left.of({ error: e.message })
  }
}
console.log(parseJson('{ name" : "zs" }'))

我们可以将正确调用的值放到Right函子中,调用处理函数。如果出现了异常,我们返回一个left函子将错误信息记录下来。

IO函子

编程过程中的读写操作是不纯的,但是我们可以使用函子将不纯的操作延迟执行,把不纯的操作交给调用者来处理。

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))
  }
}
const r = IO.of(process).map(p => p.execPath)
console.log(r._value())

在of方法中,返回的IO函子中封装传入的函数,map方法将IO函子内部的函数和传入的新的操作组合起来返回一个新的io函子,最后调用函子内部的_value方法,就将副作用延迟执行了

Folktale 中的 Task函子

当遇到异步操作的时候,我们可以时候Folktale库中提供的Task函子,Folktale中只提供了一些函数式处理的操作,比如compose、curry等方法,还有Task、Either、Maybe等函子。 Task函子

// folktale Task函子的使用
const { task } = require('folktale/concurrency/task')
const fs = require('fs')
const { split, find, flowRight } = require('lodash/fp')
function readFile (fileName) {
  return task(resolver => {
    fs.readFile(fileName, 'utf-8', (err, data) => {
      if(err) resolver.reject(err)
      resolver.resolve(data)
    } )
  })
}

readFile('package.json')
  .map(flowRight(find(x => x.includes('version')), split('\n')))
  .run()
  .listen({
    onRejected: (res) => {
      console.log(res)
    },
    onResolved: (res) => {
      console.log(res)
    }
  })

Task是一个函数,return一个Task函子,传入一个箭头函数,箭头函数的参数是一个resolver对象,当执行正确结果的时候调用resolver.onResolve()方法,当出错调用resolver.onReject()方法。

调用函子的run方法后函子开始执行,然后在listen()方法中监听这个返回值。

Pointed 函子

Pointed函子我们一点儿也不陌生,因为我们一直在使用它,Pointed函子就是指实现了of静态方法的函子。

of方法表面上是避免使用new去创建对象,实际上是用of方法把值放到上下文Context(函子)中,使用map来处理值。

在of方法中,返回的结果(new一个函子)就是一个上下文,将来在这个上下文中去处理数据

IO函子存在的问题

当使用函数组合去组合IO函子的时候,因为IO函子返回的还是一个包裹了函数的IO函子,他向下传递的时候返回的函子会被上一个IO函子作为参数接受,形成函子的嵌套。

假设我们要写一个读文件的函子,同时调用打印文本的函子,就会存在问题

// IO 函子的问题
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))
  }
}

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))
let r = cat('package.json')._value()._value()
console.log(r)

最终要调用IO函子中的方法需要循环调用内部_value()方法

Monad函子

Monad是可以变扁的Pointed函子,如果函子出现嵌套,调用起来会非常不方便,变扁就是解决函子嵌套的问题。

在函数组合中,使用组合函数可以解决函数嵌套的问题,在函子中可以使用monad函子解决函数嵌套的问题。

如果一个函子同时具有join和of两个方法,那他就是一个Monad函子

还是读取文件和打印文件的实例,使用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 () {
    return this._value()
  }

  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')
          // .map(x => x.toUpperCase())
          .map(fp.toUpper)
          .flatMap(print)
          .join()

console.log(r)

当传入的是函子的时候可以调用flatMap方法去组合IO函子,此时会自动执行函子_value的方法同时返回一个函子,此时再调用join()就可以得到组合后函子的值了。

此时如果想在读文件和打印文本之间加入把字符串变大写的方法,只需要在函子flatMap之前加入map方法传入toUpperCase方法就好。

当使用函子的处理方法返回一个函子的时候,就要使用monad函子,调用monad函子的flatMap方法,当处理方法返回值的时候调用map方法。