学习笔记-函数式编程范式

112 阅读12分钟

函数式编程范式

为什么学习函数式编程

  • 函数式编程是随着react的流行受到了越来越多的关注
  • vue 3也开始拥抱函数式编程
  • 函数式编程可以抛弃this
  • 打包过程中可以更好的利用tree shaking过滤无用代码
  • 方便测试和并行处理

当前也有很多库可以帮助我们进行函数式开发,eg:lodash,underscore,ramda

函数式编程概念

函数式编程(Functional Programming,FP),FP是编程范式之一。 编程范式还有:

  • 面向过程编程: 按照步骤,一步一步来实现。
  • 面向对象编程:把现实世界中的事物抽象成程序世界中的类和对象,通过封装,继承和多态来演示事物事件的联系
  • 函数式编程:把现实世界的事物和事物之间的联系抽象到程序世界。

程序的本质:根据输入,经过运算,获得输出。执行这个过程的就是函数。而函数式编程就是对这个运算过程进行抽象,但是面向对象编程是对事物进行抽象。

函数式编程中的函数指的不是程序中的函数(方法),指的是数学中的函数,就是一个映射关系,eg: y = sin(x)。此时,相同的输入始终要得到相同的输出(纯函数)

//非函数式
let num1 = 1
let add = num1 + 1

//函数式
function add1(num){
    return num + 1
}
let add = add1(2)

函数是一等公民

  • 函数可以存储在变量中
  • 函数可以作为参数
  • 函数可以作为返回值

eg:

//函数表达式就是把函数赋值给变量
let fun = function (){
    console.log('hello')
}

//改造成赋值给变量
const obj = {
    show(params){ return otherObj.show(params) }
}
//修改后
const obj = {
    show: otherObj.show
}

高阶函数(Higher-order function)

高阶函数就是把函数作为参数传递给另一个函数,或者把函数作为另一个函数的返回结果。

// 利用高阶函数-将函数作为参数写一个forEach函数
function forEach(arr, fn) {
  for (let i = 0; i < arr.length; i++) {
    fn(arr[i], i)
  }
}
const arr = [1, 2, 3, 4, 5]
forEach(arr, (item, index) => {
    console.log(item, index)
})

// 利用函数可以作为返回值,实现一个once函数-只执行一次
function once(fn) {
  let mark = false
  return function () {
    if (!mark) {
      mark = true
      fn.apply(this, arguments)
    }
  }
}
let pay = once((money) => {
  console.log(`${money} RMB`)
})
pay(100)
pay(100)
pay(100)
pay(100)

高阶函数的意义:

  • 抽象通用的问题
  • 屏蔽细节,只关注我们的目标

常用的高阶函数: forEach, filter, map, every, some...

闭包

在把函数当作返回值返回时,内部函数可以访问外部函数中的变量,就形成了闭包。

function outter() {
 let num = 0
 return function inner() {
   console.log(num++)
 }
}
let o = outter()
o()
o()
o()

以上例子,如果outter内部没有闭包,outter调用完毕后,outter从执行栈上移除,同时它内部的变量num也会从内存中移除,但是内部有了inner引用了变量num,outter从执行栈移除后,num因为还被inner引用,不会被移除,即-堆上的作用域成员因为被外部引用不能释放。

纯函数

纯函数:相同的输入永远会得到相同的输出,而且没有任何可观察的副作用。

lodash是要给纯函数的功能库,提供了对数组,数字,对象,字符串,函数等操作的一些方法。

eg:数组的slice方法是纯函数,splice是不纯的函数。

  • slice:返回数组中的指定部分,不会改变原数组。
  • splice:对数组进行操作返回该数组,会改变原数组。
let arr = [1, 2, 3, 4, 5, 6, 7, 8, 9]
// slice 没有修改原数组,每次返回结果一样,是纯函数
console.log(arr.slice(1, 3))  //[ 2, 3 ]
console.log(arr.slice(1, 3))  //[ 2, 3 ]
console.log(arr.slice(1, 3))  //[ 2, 3 ]
// splice 修改了原数组,每次返回结果不一样,是不纯的函数
console.log(arr.splice(1, 3)) //[ 2, 3, 4 ]
console.log(arr.splice(1, 3)) //[ 5, 6, 7 ]
console.log(arr.splice(1, 3)) //[ 8, 9 ]

纯函数的好处

  • 可缓存。因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来。
// 自己实现一个类似lodash中的 memoize
function getArea(r) {
  console.log(r)
  return Math.PI * r * r
}
function memoize(f) {
  let cache = {}
  return function () {
    let key = JSON.stringify(arguments)
    cache[key] = cache[key] || f.apply(f, arguments)
    return cache[key]
  }
}
let getAreaWithMemory = memoize(getArea)
console.log(getAreaWithMemory(4)) // 4 50.26548245743669
console.log(getAreaWithMemory(4)) // 50.26548245743669
console.log(getAreaWithMemory(5)) // 5 78.53981633974483
  • 可测试
  • 并行处理。在多线程环境(eg:Web Worker)下并行操作共享的内存数据很可能出现意外,纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数。

纯函数的副作用

eg:

// 不纯
let mini = 18
function checkAge(age) {
  return age >= mini
}
// 纯的,有硬编码
function checkAge(age) {
  let mini = 18
  return age >= mini
}

如果函数依赖于外部状态,就无法保证输出相同,就会带来副作用,变得不纯。

在函数内部定义一个变量的值,又会出现硬编码。不过这个可以通过柯里化解决。

副作用不可完全禁止,尽可能控制他们在可控范围内发生。

柯里化

使用柯里化可以解决硬编码的问题。

柯里化: 当一个函数有多个参数的时候,先传递一部分参数调用它,这部分参数以后永远不变,然后返回一个新的函数接受剩余的参数,返回结果。 eg:

let checkAge = min => (age => age >= min)
let checkAge18 = checkAge(18)
let checkAge20 = checkAge(20)

console.log(checkAge18(20))
console.log(checkAge20(24))

lodash中的柯里化函数

const _ = require('lodash')
function getSum(a, b, c) {
  return a + b + c
}
const curried = _.curry(getSum)
console.log(curried(1, 2, 3)) //6
console.log(curried(1)(2, 3)) //6
console.log(curried(1)(2)(3)) //6

lodash中的curry方法可以将一个多元函数,转换为任意多元函数。

自己实现一个lodash.curry

function curry(fn){
  return function a(...args){
    if(args.length < fn.length){
      return function(){
        return a(...args.concat(Array.from(arguments)))
      }
    }
    return fn(...args)
  }
}
function getSum(a, b, c) {
  return a + b + c
}
const curried = curry(getSum)
console.log(curried(1, 2, 3))
console.log(curried(1)(2, 3))
console.log(curried(1)(2)(3))

总结

  • 柯里化可以返回一个新函数,这个新函数已经记住了某些固定参数
  • 这是一种对函数参数的‘缓存’
  • 让函数的粒度更小,更灵活
  • 可以把多元函数转换成一元函数,组合使用函数产生强大的功能

函数组合

纯函数和柯里化很容易写出洋葱代码,eg:_.toUpper(_.first(_.reverse(arr))),而以函数组合的形式 fn = compose(f1, f2, f3) 把中间过程的函数合并成一个函数,看起来更简洁。函数组合默认是从右到左执行,所以执行顺序是 f3, f2, f1。

// 函数组合演示
// 洋葱代码并没有被省略,而是被封装起来了
function compose(f, g) {
  return function (value) {
    return f(g(value))
  }
}
// 实现将数组先反转在取第一个
function reverse(arr) {
  return arr.reverse()
}
function first(arr) {
  return arr[0]
}
const last = compose(first, reverse)
console.log(last([1, 2, 3, 4, 5]))  //5

lodash中的组合函数

  • flow() 从左到右执行
  • flowRight() 从右到左执行,使用的更多
//  使用lodash 中的函数组合的方法: _.flowRight()
const _ = require('lodash')
//  实现先翻转,再去第一个,再大写
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = s => s.toUpperCase()

const f = _.flowRight(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))

自己实现flowRight

function compose(...args) {
  return function (value) {
    return args.reverse().reduce((pre, pro) => {
      return pro(pre)
    }, value)
  }
}

// 改为箭头函数
const compose = (...args) => value => args.reverse().reduce((pre, pro) => pro(pre), value)

const reverse = arr => arr.reverse()
const first = arr => arr[0]
const toUpper = s => s.toUpperCase()
const f = compose(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))  // THREE

函数组合-结合律

函数组合要满足结合律,即:

let associative = comose(compose(f,g),h) == compose(f,compose(g,h)) //true

函数组合-调试

函数组合的方式,如果最终结果不是预期,很难找出是哪一步出了错,eg:

// NEVER SAY DIE  -->  never-say-die
//将左侧字符串转换成右侧的字符串,要经过分割,变小写,再用-隔开
const log = _.curry((tag, v) => {
  console.log(tag, v)
  return v
})
//为了满足函数组合参数都是一个,将split和join柯里化
// _.toLower()
// _.split()
const split = _.curry((sep, str) => _.split(str, sep))
// _.join()
const join = _.curry((sep, arr) => _.join(arr, sep))

const f = _.flowRight(join('-'), _.toLower, split(' '))
console.log(f('NEVER SAY DIE')) //n-e-v-e-r-,-s-a-y-,-d-i-e

打印后发现结果和预期不一样,但是不好定位错误位置,我们可以给函数组合的每个函数中间加个log来打印每次结果是否正确。

const log = (v) => {
  console.log(v)
  return v
}
const f = _.flowRight(join('-'), _.toLower, log, split(' ')) //[ 'NEVER', 'SAY', 'DIE' ]
const f = _.flowRight(join('-'), log, _.toLower, split(' ')) //never,say,die

可以看到,在_.toLower后,数组变成了字符串格式,和预期不一样。但是这样打log,如果同时放入多个log,分不清打印的是哪一步,可以通过改造:

// tag 用来标注位置,v是结果,用_.curry柯里化
const log = _.curry((tag, v) => {
  console.log(tag, v)
  return v
})
const f = _.flowRight(join('-'), log('lower 后'), _.toLower, log('split 后'), split(' '))
// split 后 [ 'NEVER', 'SAY', 'DIE' ]
// lower 后 never,say,die

lodash-fp模块

lodash中的fp模块提供了实用的对函数式编程友好的方法,并且函数优先,数据滞后,自动柯里化。

// lodash 模块,  都是数据优先,函数滞后
_.map(['a', 'b', 'c'], _.toUpper) // ['A','B','C']
_.map(['a', 'b', 'c']) // ['a', 'b', 'c']

_.split('Hello World', ' ')

// lodash/fp 模块,  都是函数优先,数组滞后
const fp = require('lodash/fp')

fp.map(fp.toUpper, ['a', 'b', 'c'])
fp.map(fp.toUpper)(['a', 'b', 'c'])

fp.split(' ', 'Hello World')
fp.split(' ')('Hello World')

将前面的例子做修改

const f = fp.flowRight(fp.join('-'),  fp.split(' '),  fp.toLower)
console.log(f('NEVER SAY DIE'))  // never-say-die
lodash 和 lodash/fp 模块中的map方法的区别

首先执行console.log(_.map(['23','8','10'], parseInt)) // [ 23, NaN, 2 ],想将数组中的字符串转为数字,但是最终拿到的结果和预期不一样,让我们看一下_.map的解释: image.png 函数接收到的是三个参数,value,index,array,所以此时parseInt是这样执行的:

parseInt('23', 0, array)
parseInt('8', 1, array)
parseInt('10', 2, array)

parseInt的第二个参数是几进制,所以此时结果不符合预期。但是看一下fp.map方法:

image.png 可以看到fp.map方法中的函数只接受一个参数,console.log(fp.map(parseInt, ['23', '8', '10'])) //[ 23, 8, 10 ],parseInt接受一个参数,就不会出现刚才的问题。

Point Free

Point Free是一种编程的风格,具体形式就是函数组合。

  • 不需要指明处理的数据
  • 只需要合成运算规则
  • 需要定义一些辅助的基本运算函数 eg:const f = fp.flowRight(fp.join('-'), fp.split(' '), fp.toLower) 这个例子中将运算过程合成,没有指明要处理的数据。
// Hello      Word => hello_world
// fp 中的方法都是柯里化的
// fp.replace可以接受3个参数,1 匹配的正则,  2 替换成什么  3 要修改的字符串
// fp.replace是柯里化所以接收两个参数可以返回一个函数,这个函数可以接受一个字符串
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)
console.log(f('Hello      Word')) // hello_word

案例:

// 把一个字符串中的首字母提取,转换成大写,使用. 作为分隔符
// world wild web ==> W. W. W
const f = fp.flowRight(fp.toUpper, fp.join('. '), fp.map(fp.first), fp.split(' '), fp.replace(/\s+/g, ' '))
// const f = fp.flow(fp.replace(/\s+/g, ' '), fp.split(' '), fp.map(fp.first), fp.join('. '), fp.toUpper)
console.log(f('world  wild  web')) // W. W. W

Functor(函子)

  • 容器:包含值和值的变形关系(函数)
  • 函子:是一个特殊的容器,通过一个普通的对象来实现,该对象具有map方法,map方法可以运行一个函数对值进行处理。
// Functor 函子
class Container {
  constructor(value) {
    // 维护一个不对外公布的静态变量
    this._value = value
  }
  // map方法通过fn处理值,并通过新的函子返回
  map(fn) {
    return new Container(fn(this._value))
  }
}
// map方法返回的是新的函子,所以还可以调用map方法
const lr = new Container(5)
  .map(v => v + 3)
  .map(v => v * 2)

console.log(lr)  // Container { _value: 16 }

上边的new Container可以通过封装:

class Container {
  static of(value) {
    return new Container(value)
  }
  constructor(value) {
    // 维护一个不对外公布的静态变量
    this._value = value
  }
  // map方法通过fn处理值,并通过新的函子返回
  map(fn) {
    return Container.of(fn(this._value))
  }
}
// map方法返回的是新的函子,所以还可以调用map方法
const lr = Container.of(5)
  .map(v => v + 3)
  .map(v => v * 2)

console.log(lr)  // Container { _value: 16 }

总结:

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

MayBe函子

  • 对编程过程中可能遇到的错误做相应的处理
  • 对外部的空值情况做处理(控制副作用在允许的范围)
class MayBe {
  static of(value) {
    return new MayBe(value)
  }
  constructor(value) {
    this._value = value
  }
  // 通过 isNothing 判断是否为空值
  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(null)
  .map(x => x.toUpperCase())
console.log(r)  // MayBe { _value: null }

但是,以下这种情况,不会出错,但是什么时候出现null不知道

let r = MayBe.of('hello world')
  .map(x => x.toUpperCase())
  .map(x => null)
  .map(x=> x.toUpperCase())
console.log(r)  // MayBe { _value: null }

可以通过Either函子来解决

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))
  }
}
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 1' } }
let r2 = parseJson('{"name":"zs"}')
  .map(x => x.name.toUpperCase())
console.log(r2) // Right { _value: 'ZS' }

使用Left来处理异常,Right来做正确的操作。

IO函子

  • IO是input,output的意思。IO函子中的_value 是一个函数,整理把函数作为值来处理。
  • IO函子可以把不纯的动作存储到_value中,延迟执行,包装它的操作是纯的。
  • 把不纯的操作交给调用者来处理。
// IO 函子
class IO {
  static of(value) {
    // this._value 就是这里的function
    return new IO(function () {
      return value
    })
  }
  constructor(fn) {
    this._value = fn
  }
  map(fn) {
    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 (anonymous)] }
console.log(r._value())  // D:\node.js\node.exe
IO函子的问题
const fs = require('fs')
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()  // IO { _value: [Function (anonymous)] }
let r = cat('../../../package.json')._value()._value()
console.log(r)

如果出现函子嵌套IO(IO(x)),需要拿多层_value才能拿到值。

Monad函子

  • Monad函子是可以变扁的Pointed函子IO(IO(x))
  • 一个函子如果具有join和of两个方法并遵守一些定律就是一个Monad
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()
  }
}
const fs = require('fs')
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(fp.toUpper)
  .flatMap(print)
  .join()
console.log(r)

Pointed函子

  • Pointed函子实现了of静态方法的函子
  • of方法是为了避免使用new来创建对象,更深层的含义是of方法用来把值放到上下文Context。

Task函子

Folktale

folktale是一个标准的函数式编程库,但是它和lodash,ramda不同的是,他没有提供很多功能函数,而是提供了一些函数式处理的操作,eg:compose, curry等,一些函子Task,Either,MayBe等。

基本使用:

const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')
let f = curry(2, (x, y) => {
  return x + y
})
console.log(f(1, 2)) // 3
console.log(f(1)(2))// 3

let n = compose(toUpper, first)
console.log(n(['one', 'two'])) // ONE

folktale 2.x 中的Task和 1.0中的Task区别很大,1.0用法接近前边的例子,下边一2.3.2来演示Task的使用:

Folktale中Task函子的使用
// Task 处理异步任务
const fs = require('fs')
const { task } = require('folktale/concurrency/task')
const { split, find } = require('lodash/fp')
function readFile(filename) {
  return task(resolver => {
    fs.readFile(filename, 'utf-8', (err, data) => {
      if (err) resolver.reject(er)
      resolver.resolve(data)
    })
  })
}
readFile('../../../package.json')
  .map(split('\n'))
  .map(find(x => x.includes('version')))
  .run()
  .listen({
    onRejected: err => {
      console.log(err)
    },
    onResolved: value => {
      console.log(value)
    }
  })