JS 深度剖析-函数式编程范式

547 阅读19分钟

一、函数式编程

1、前端框架在使用函数式编程是随着React的流行受到越来越多的关注。Vue3也开始拥抱函数式编程
2、改变编程方式,函数式编程可以抛弃this
3、优化打包、测试、打包过程中更好的利用 tree shaking 过滤无用代码、方便测试、方便并行处理
4、方便使用第三方库,有很多库可以帮助我们进行函数式开发:lodashunderscoreramda

二、函数式编程概念

1、常见的编程范式(编程思想)

面向过程编程(Process-oriented programming, POP
面向对象编程(Object-oriented programming, OOP
函数式编程(Function Programming, FP

2、不同编程范式的区别

2.1、思维方式层面
面向过程编程(POP):简单理解是按照步骤来一步步实现想要的功能 。
面向对象编程(OOP):把现实世界中的事物抽象成程序世界中的类和对象,通过封装、继承、多态来演示事物之间的联系。
函数式编程(FP):把现实世界中的事物和事物之间的联系抽象到程序世界中(对运算过程的抽象)

  • 程序的本质:根据输入通过某种运算获得相应的输出的函数
  • x -> f(某种联系、映射)-> y, y = f(x)
  • 函数式编程中所指的函数不是程序中的函数(方法),而是数学中的函数即映射关系,例如 y=sin(x), x和y的关系
  • 相同的输入始终可以获得相同的输出(纯函数)
  • 函数式编程就是用来描述数据()函数之间的映射,或者说是对运算过程的抽象
// 非函数式
let a = 1
let b = 2
let sum = a + b
// 函数式
function add(a, b) {
    return a + b
}
let sum = add(a, b)

3、函数式编程的优势
3.1、封装的函数可以重复利用,减少代码量
3.2、函数式编程抽象出的函数大多是细粒度的函数,这些函数可以进行组合出功能强大的函数

三、函数式编程相关概念

1、如何理解函数是一等公民?

MDN First-class Function

  • 函数可以存储在变量中
  • 函数可以作为参数
  • 函数可以作为返回值
    解析:以上3点使用,在JavaScript函数就是一个普通的对象(可以通过new function 定义)。因为它就是个普通的对象,因此,它把它储存到变量中。因为它就是个普通的对象,因此它可以作为另一个函数的参数或者返回值。

演示:把函数赋值给变量

// 把函数赋值给变量
var fn = function () {
    console.log('hello First-class Function')
}
fn()

// 一个示例
var BlogController = {
    index (posts) { return Views.index(posts) },
    show (post) { return Views.show(post) },
    create (attrs) { return Db.create(attrs) },
    update (post, attrs) { return Db.update(post, attrs) },
    destroy (post) { return Db.destroy(post) }
}

// 优化示例
var BlogController = {
    index: Views.index,
    show: Views.show,
    create: Db.create,
    update: Db.update,
    destroy:Db.destroy
}
// 解析:场景是当一个函数包裹另一个函数,并且它们的形式也相同。
// 将名称和参数相同的函数或方法赋值给另一个名称和参数相同函数或方法

2· 什么是高阶函数?

高阶函数(Higher-order function

  • 可以把函数作为参数传递给另一个函数
  • 可以把函数作为另一个函数的返回结果

3 · 高阶函数-函数作为参数

示例: 演示把函数作为参数传递给另一个函数

let arr = [1, 3, 4, 7 ,8]
// 简单模拟forEach
function forEach(array, fn) {
    for (let i = 0; i < array.length; i++) {
        fn(array[i])
    }
}
// 测试forEach
forEach(arr, function(item) {
    console.log(item)
})
// 简单模拟filter
function filter(array, fn) {
    let result = []
    for (let i = 0; i < array.length; i++) {
        if(fn(array[i])) {
            result.push(array[i])
        }
    }
    return result 
}
// 测试filter
let r = filter(arr, function(item) {
    return item % 2 === 0
})
console.log(r)

总结:函数作为参数传递给函数,会使函数变得更加灵活

4 · 高阶函数-函数作为返回值

语法:把函数作为另一个函数的返回值

function makeFn() { // 定义函数
    const msg = 'hello function'
    return function () {
        console.log(msg)
    }
}
makeFn()() //执行

演示:一个有意义的函数封装,once(函数只执行一次) 场景:当用户在进行订单支付的时候,要求函数只执行一次

function once(fn) {
    let done = false // 标记传入的函数是否被执行过
    return function() { // 函数作为返回值
        if (!done){
            done = true
            return fn.apply(this, arguments) // arguments指return的匿名函数的传参
        }
    }
}

let pay = once(function(money) {
    console.log(`支付:${money} RMB`)
})

pay(5)
pay(5)
pay(5)

5 · 高阶函数-使用意义

首先明确函数式编程的核心思想:对运算过程进行抽象,抽象为函数,在任何地方使用 高阶函数-使用意义:

  • 抽象可以帮我们屏蔽细节,只需关注我们的目标
  • 高阶函数就是用来帮我们抽象一些通用的问题 举例方便理解:
// 面向过程的方式
let array = [1, 2, 3, 4]
for (let i = 0; i < array.length; i++) {
    console.log(array[i])
}
// 高阶高阶函数
let array = [1, 2, 3, 4]
forEach(array, item => { // 使用抽象的函数屏蔽细节实现,只关注目标
    console.log(item)
})
let r = filter(array, item => {
    return item % 2 === 0
})

6 · 常用的高阶函数

常用:forEach、map、filter、every、some、find/findIndex、reduce、sort ...

// 模拟map
const map = (arr, fn) => {
    let results = []
    for (value of arr) {
        results.push(fn(value))
    }
    return results
}
// 测试
// let arr = [1, 2, 3]
// arr = map(arr, v => v * v)
// console.log(arr)

// 模拟every
const every = (arr, fn) => { 
    let result = true
    for (value of arr) { 
        result = fn(value)
        if(!result) break
    }
    return result
}
// 测试
// let arr = [15, 11]
// let r = every(arr, v => v > 10)
// console.log(r)

// 模拟some
const some = (arr, fn) => { 
    let result = false
    for (value of arr) { 
        result = fn(value)
        if(result) break
    }
    return result
}
// 测试
// let arr = [1, 2, 9, 7]
// let r = some(arr, v => v % 2 === 0)
// console.log(r)

总结:通过将一个函数作为参数传递给另一个函数,可以让另一个函数变的更加灵活。

7 · 闭包概念

  • 闭包:函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包
  • 特点:可以在另一个作用域中调用一个函数的内部函数,并访问到该函数的作用域中的成员

7.1、闭包基本示例:

function makeFn() { // 定义函数
    const msg = 'hello function'
    return function () {
        console.log(msg)
    }
}
const fn = makeFn()
fn()
  • 上由示例可见闭包的核心作用:延长了外层函数makeFn作用域中的成员msg的作用范围。
    解析:正常情况下makeFn()执行之后会释放msg 占用的内存。但由于返回的内部函数中引用了msg, 因此在执行完makeFn()之后不会释放msg 占用的内存。

7.2、闭包应用示例:

function once(fn) {
    let done = false // 标记传入的函数是否被执行过
    return function() { // 函数作为返回值
        if (!done){
            done = true
            return fn.apply(this, arguments) // arguments指return的匿名函数的传参
        }
    }
}

let pay = once(function(money) {
    console.log(`支付:${money} RMB`)
})

pay(5)
pay(5)

闭包的本质:函数在执行的时候会被放到一个执行栈上,当函数执行完毕后会从执行栈上移除,但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。 解析:当once()被执行后,它会从执行栈上移除,但 once 函数中定义的done变量正被 pay 函数中引用,因此不能释放堆中done的内存。

8 · 闭包的案例

8.1、案例一:封装number的power次方函数,在浏览器中分析它的执行栈和作用域

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script>        
        function makePow(power) {
            return function(number) {
                return Math.pow(number, power)
            }
        }

        let power2 = makePow(2)
        let power3 = makePow(3)
        console.log(power2(2))
        console.log(power2(4))
        console.log(power3(3))
    </script>
</body>
</html>

1、当执行到代码第20行时,此时调用script标签中的代码,其实就是在一个匿名函数中调用的。Scope作用域中存在2个作用域,一个时是let声明的变量的作用域,另一个是全局作用域。
微信图片_20210626084604.png
2、当代码执行到第21行时,此时的调用栈中有2个匿名函数,一个是script,一个是makePow函数中返回的匿名函数。Scope作用域中存在4个作用域,依次是局部作用域、闭包中变量、let声明的变量的作用域、全局作用域。因此makePow函数中返回的匿名函数中可以访问到power变量,值为2。最后打印结果为4

微信图片_20210626090430.png

四、函数式编程-纯函数

1、纯函数

概念:纯函数是函数式编程中的核心, 函数式编程中的函数就是纯函数。
纯函数(Pure functions):必须满足2个必要条件
相同的输入永远会得到相同的输出,而且没有任何可观察的副作用

  • 纯函数就类似数学中的函数(用来描述输入和输出之间的关系),y = f(x)
  • lodash 是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法

2、纯函数与不纯函数

数组的 slice 和 splice 分别是:纯函数和不纯的函数

let numbers = [1, 2, 3, 4, 5]
// 纯函数
numbers.slice(0, 3)// => [1, 2, 3]
numbers.slice(0, 3)// => [1, 2, 3]
numbers.slice(0, 3)// => [1, 2, 3]

// 不纯的函数(不满足相同的输入永远会得到相同的输出)
numbers.splice(0, 3)// => [1, 2, 3]
numbers.splice(0, 3)// => [4, 5]
numbers.splice(0, 3)// => []

3、自定义纯函数

// 自定义纯函数
function sum (n1, n2) {
    return n1 + n2
}
console.log(sum(1 + 2)) // 3
console.log(sum(1 + 2)) // 3
console.log(sum(1 + 2)) // 3

分析自定义纯函数的使用过程:

  • 现象:函数式编程不会保留计算中间的结果,所以变量是不可变(无状态的)
  • 结论:因此我们可以把一个函数的执行结果交给另一个函数去处理。

4、Lodash中的一些方法

// 演示lodash
// first / last / toUpper / each / includes / find /findIndex
const _ = require('lodash')

const array = ['jack', 'tom', 'lucy', 'kate']

console.log(_.first(array))
console.log(_.last(array))
console.log(_.toUpper(_.first(array)))
console.log(_.reverse(array))

_.forEach(array, (item, index) => { 
    console.log(item, index)
})

5、纯函数的好处

5.1、可缓存

  • 因为纯函数对相同的输入始终有相同的结果,所以可以把纯函数的结果缓存起来。
  • 因为存在一些运算耗时和复杂的纯函数,所以可以缓存结果,提升性能
// lodash中的记忆函数
const _ = require('lodash')

function getArea (r) {
    console.log(r)
    return Math.PI * r * r
}

let getAreaWithMemory = _.memoize(getArea)
console.log(getAreaWithMemory(4)) // 4  50.26548245743669
console.log(getAreaWithMemory(4)) // 50.26548245743669

自已模拟一个memoize函数

function getArea (r) {
    console.log(r)
    return Math.PI * r * r
}

// 模拟memoize
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))
console.log(getAreaWithMemory(4))

5.2、可测试
因为纯函数始终有输入和输出,而单元测试就是在断言函数执行的结果。
5.3、并行处理
场景:在多线程环境下同时去操作共享内存数据(比如全局变量)的时候,可能会发生意外的情况,不确定最终的结果。

纯函数是一个封闭的空间,纯函数不需要访问共享的数据,只依赖于传入的参数,因此在并行环境下可以任意运行纯函数。

虽然JavaScript是单线程执行的,但是ES6新增了一个对象Web Worker。因此在ES6之后,我们可以开启多线程执行以提升性能(不常用)。

6、副作用

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

// 不纯的
let mini = 18
// 函数checkAge的返回结果,受全局变量mini的影响(所谓的副作用)
function checkAge (age) {
    return age >= mini
}

// 纯的(有硬编码,后续可以通过柯里化解决)
function checkAge (age) {
    let mini = 18
    return age >= mini
}

解析:副作用让一个函数变得不纯(如上例),纯函数是根据相同的输入返回相同的输出,如果函数依赖于外部的状态就无法保证输出相同,就会带来副作用。

7、副作用的来源

  • 配置文件
  • 数据库
  • 获取用户的输入
  • ... 总结:
    1、所有外部交互都可能产生副作用,副作用会使方法的通用性下降、不适合扩展、重用性下降
    2、副作用还会给程序到来一定的安全隐患(如外部交互用户的输入可能存在跨站脚本攻击)
    3、副作用不可能完全禁止,但应尽可能控制它们在可控的范围中发生

五、柯里化

1、之前在考虑函数副作用时,遇到的场景问题如下:

// 纯函数(有硬编码,将通过柯里化解决)
function checkAge (age) {
    let mini = 18
    return age >= mini
}

2、经过改造后成为普通的纯函数

function checkAge (min, age) {
    return age >= min
}
console.log(checkAge(18, 20))
console.log(checkAge(18, 22))
console.log(checkAge(18, 24))

分析上述代码存在如下场景问题:
以18岁为基准的函数调用经常使用,基准值 min = 18 需要重复定义。

3、通过闭包和高阶函数解决场景问题

// function checkAge (min) {
//     return function (age) {
//         return age >= min
//     }
// }
// ES6实现上述函数
let checkAge = min => (age => age >= min)

// 基准值在闭包中只定义一次
let checkAge18 = checkAge(18) 
let checkAge20 = checkAge(20)
console.log(checkAge18(20))

4、总结函数柯里化概念:

当函数存在多个参数的时候,如:checkAge (min, age),
通过闭包与高阶函数结合使用,调用一个函数只传递部分参数(这部分参数以后永远不变,如:18),
并且返回一个函数去接收剩余的参数,并且返回相应的结果。

5、Lodash中的柯里化方法

_.curry(func) 纯函数

  • 功能:创建一个函数,该函数接收一个或多个func的参数,如果func所需要的参数都被提供,则执行func并返回执行的结果,否则继续返回该函数并等待接收剩余的参数。
  • 参数:需要柯里化的函数
  • 返回值:柯里化后的函数
const _ = require('lodash')
// 需要柯里化的函数
function sum (a, b, c) {
    return a + b + c
}
// 柯里化后的函数
let curried = _.curry(sum)
console.log(curried(1, 2, 3)) // 6
console.log(curried(1, 2)(3)) // 6
console.log(curried(1)(2)(3)) // 6
// curry函数可以将任意多元函数转化为一元函数

6、柯里化案例

6.1、功能需求:提取字符串中的空白字符 | 数字

// ''.match(/\s+/g)
// ''.match(/\d+/g)
// 定义纯函数
function match (reg, str) {
    return str.match(reg)
}

存在问题:当经常使用match纯函数提取指定字符,存在重复定义reg。

6.2、如何解决?
方案:采用函数柯里化解决问题

const _ = require('lodash')
// 为了避免声明变量,直接将纯函数匿名后传递给curry
const match = _.curry(function (reg, str) {
    return str.match(reg)
})
// 通过函数柯里化生成了新函数
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
// 测试
console.log(haveSpace('hello world')) // [ ' ' ]
console.log(haveNumber('1234abc')) // [ '1234' ]
console.log(haveNumber('abc')) // null

6.3、功能需求:寻找数组中具有空白字符的元素

const _ = require('lodash')
// 为了避免声明变量,直接将纯函数匿名后传递给curry
const match = _.curry(function (reg, str) {
    return str.match(reg)
})
// 通过函数柯里化生成了新函数
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
// 定义lodash函数柯里化的纯函数
const filter = _.curry(function (func, array) {
    return array.filter(func)
})
// 给柯里化的纯函数传递2个参数
console.log(filter(haveSpace, ['John Connor', 'Tom'])) // [ 'John Connor' ]
console.log(filter(haveSpace, ['Hello World', 'Kate'])) // [ 'Hello World' ]

存在问题:每次调用都需要传递haveSpace,应该将它固化为一个特定需求的函数。

6.4、如何解决?
方案:给柯里化的纯函数filter只传递1个参数(简单理解为功能参数)并重新定义。

const _ = require('lodash')
// 为了避免声明变量,直接将纯函数匿名后传递给curry
const match = _.curry(function (reg, str) {
    return str.match(reg)
})
// 通过函数柯里化生成了新函数
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
// 定义lodash函数柯里化的纯函数
const filter = _.curry(function (func, array) {
    return array.filter(func)
})
const findSpace = filter(haveSpace)
console.log(findSpace(['John Connor', 'Tom'])) // [ 'John Connor' ]
console.log(findSpace(['Hello World', 'Kate'])) // [ 'Hello World' ]

7、回顾lodash中curry方法的使用

const _ = require('lodash')
// 需要柯里化的函数
function sum (a, b, c) {
    return a + b + c
}
// 柯里化后的函数
let curried = _.curry(sum)
console.log(curried(1, 2, 3)) // 6
console.log(curried(1, 2)(3)) // 6
console.log(curried(1)(2)(3)) // 6
// curry函数可以将任意多元函数转化为一元函数

8、模拟实现curry方法

function curry (func) {
    return function curriedFn(...args) {
        // 判断实参的个数(args.length)是否小于形参的各个数(func.length)
        // 当条件不满足时,说明func需要的参数已经满足,执行func(...args)
        if (args.length < func.length) {
            return function () {                
                // args是闭包中的变量,它保存了函数上一次的传参
                // arguments则是本次调用的传参,两者合并就是累计传参
                // 这里内层函数需要调用外层函数,传入合并后的累计参数
                // 因此给外层函数定义名称为curriedFn,方便调用
                return curriedFn(...args.concat(Array.from(arguments)))
            }
        }
        return func(...args)
    }
}
// 测试功能(通过)
let curried = curry(sum)
console.log(curried(1, 2, 3)) // 6
console.log(curried(1, 2)(3)) // 6

9、柯里化总结

  • 柯里化可以让我们给一个函数传递较少的参数,得到一个已经记住某些固定参数的新函数。这是一种对函数参数的缓存。 示例加深理解:
const _ = require('lodash')
// 为了避免声明变量,直接将纯函数匿名后传递给curry
const match = _.curry(function (reg, str) {
    return str.match(reg)
})
// 通过函数柯里化生成了已经记住某些固定参数的新函数
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
  • 让函数变得更加灵活,让函数的粒度更小 示例:
// 基于上面的示例,定义更细粒度的函数
// 定义lodash函数柯里化的纯函数
const filter = _.curry(function (func, array) {
    return array.filter(func)
})
const findSpace = filter(haveSpace)
  • 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能

六、函数组合

1、函数组合解决的问题

1.1、问题描述
纯函数和柯里化很容易写出洋葱代码 h(g(f(x)))
微信图片_20210626195707.png
1.2、实例场景
获取数组的最后一个元素再转换成大写字母

_.toUpper(_.first(_.reverse(array)))  

1.3、解决方案
函数组合可以让我们把细粒度的函数重新组合生成一个新的函数

2、预置概念-管道

微信图片_20210626200642.png

// 伪代码
fn = compose(f1, f2, f3)
b = fn(a)

3、函数组合

  • 定义:函数组合 (compose):如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数
  • 形象理解:函数就像是数据的管道,函数组合就是把这些管道连接起来,让数据穿过多个管道形成最终结果
  • 执行顺序:函数组合默认是从右到左执行
function composition (f, g) { 
    return function (value) {
        return f(g(value))
    }
}
reverse = array =>array.reverse()
first = array => array[0]

const last = composition(first, reverse)
console.log(last([1,2,3,4])) // 4

4、Lodash中组合函数的使用

  • lodash 中组合函数 flow() 或者 flowRight(),他们都可以组合多个函数
  • low() 是从左到右运行
  • flowRight() 是从右到左运行(更常使用)
// 组合函数的使用
const _ = require('lodash')
const toUpper = s => s.toUpperCase()
const reverse = arr => arr.reverse()
const first = arr => arr[0]
const f = _.flowRight(toUpper, first, reverse)
console.log(f(['one', 'two', 'three']))

5、Lodash中组合函数的模拟

flowRight组合函数的模拟

const toUpper = s => s.toUpperCase()
const reverse = arr => arr.reverse()
const first = arr => arr[0]
// flowRight组合函数的模拟
// function compose (...args) {
//     return function (value) {
//         // 从右向左依次执行函数,并将执行结果作为下个函数的参数传入, 
//         // 最后将结果返回     
//         return args.reverse().reduce(function (acc, fn) {
//             return fn(acc)
//         }, value) // 设置初始值为传入的参数
//     }
// }

const compose = (...args) => value => args.reverse()
                .reduce((acc, fn) => { return fn(acc) }, value)
const f = compose(toUpper, first, reverse)
console.log(f(['one', 'two', 'three'])) // THREE

6、函数组合-结合律

函数的组合要满足结合律 (associativity):

  • 我们既可以把 g 和 h 组合,还可以把 f 和 g 组合,结果都是一样的
// 结合律(associativity)
let f = compose(f, g, h)
let associative = compose(compose(f, g), h) == compose(f, compose(g, h))
// true

所以代码还可以像下面这样:

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

7、函数组合-调试

如何调试组合函数:

const _ = require('lodash')
const trace = _.curry((tag, v) => {
    console.log(tag, v)
    return v
})
const split = _.curry((sep, str) => _.split(str, sep))
const join = _.curry((sep, array) => _.join(array, sep))
const map = _.curry((fn, array) => _.map(array, fn))
const f = _.flowRight(join('-'), trace('map 之后'), map(_.toLower),
trace('split 之后'), split(' '))
console.log(f('NEVER SAY DIE'))

存在问题:lodash中定义的_.split、_.join、 _.map 虽然是平常经常使用的方法,但并不适合在函数组合中使用,如果非要使用需要进行柯里化处理。因为函数组合中需要的是一元函数

8、函数组合-Lodash-fp模块

8.1、如何解决函数组合调试中存在的问题: 使用Lodash-fp模块
8.2、Lodash-fp模块

  • Lodash-fp模块提供了实用的对函数式编程友好的的fp(Functional programming)模块
  • 提供了不可变 auto-curried iteratee-first data-last 的方法
// lodash 模块
const _ = require('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')

8.3、改造函数组合-调试中代码

const fp = require('lodash/fp')
const f = fp.flowRight(fp.join('-'), fp.map(_.toLower), fp.split(' '))
console.log(f('NEVER SAY DIE'))

9、Lodash-map方法的小问题

9.1、lodash/fp模块和Lodash模块中map方法的区别

const _ = require('lodash')
console.log(_.map(['22', '6', '10'], parseInt)) // [ 22, NaN, 2 ]
// parseInt('22', 0, ['22', '6', '10'])
// parseInt('5', 0, ['22', '6', '10'])
// parseInt('10', 0, ['22', '6', '10'])

const fp = require('lodash/fp')
console.log(fp.map(parseInt,['22', '6', '10'])) // [ 22, 6, 10 ]

七、Functor(函子)

Functor(函子)

截止到目前已经学习了一些函数式编程的基础,但还没有演示在函数式编程中如何将副作用控制在可控的范围内,以及异常的处理和异步操作。而函子就是解决这些问题的。
函子就是一个容器,它包含了值和值的变形关系(这个变形关系就是函数)。该容器是通过一个普通对象实现的,该对象具有map方法,map方法可以运行一个函数对值进行处理。

普通函子

根据对函子的描述,得知它需要满足两点:

  • 内部维护一个值,并且该值只能在容器内操作(类似私立属性)
  • 存在一个map方法, 可以向map方法中传入参数对值进行修改,同时将新值传递给新的容器 按以上要求实现这个函子:
class Container {
    // 定义一个静态方法返回类的实例,避免在外部使用new来创建对象
    static of (value) { 
        return new Container(value)
    }
    // 在构造函数中定义私有属性_value,只允许容器内部进行操作
    constructor(value) {        
        this._value = value
    }
    // map方法通过传入函数fn,修改私有属性_value的值
    map (fn) { 
        return Container.of(fn(this._value))
    }
    // join方法返回函数内部的值
    join () { 
        return this._value
    }
}
// 采用loadsh/fp模块测试, 需求:获取数组最后一个元素的大写
const fp = require('lodash/fp')
const getFirst = array => array[0]
let r = Container.of(['aa', 'bb', 'cc'])
        .map(fp.reverse)
        .map(getFirst)
        .map(fp.toUpper)
console.log(r.join()) // 'CC'

函子本身是一个容器,数据流入容器后经过fn处理后,新值流入下一个容器处理。但是这其中缺少异常情况的处理,若其中一个函子的处理出现问题,该如何处理?程序如何保证正常运行?出现异常的情况会很多,首先针对外部传入空值(nullundefined)的情况,就轮到MayBe函子出场了...

MayBe函子

Maybe函子的作用就是可以对外部传入空值的情况进行处理(控制副作用在允许的范围内)

class Maybe {
    static of (value) {
        return new Maybe(value)
    }
    constructor(value) {
        this._value = value
    }
    map (fn) {
        return this.isEmpty() ? Maybe.of(null) : Maybe.of(fn(this._value))
    }
    isEmpty () {
        return this._value === null || this._value === undefined
    }
    join () {
        return this._value
    }
}

let r = Maybe.of('aaa')
    .map(x => x.toUpperCase())
    .map(x => null)
    .map(x => x.split(' '))
console.log(r.join()) // null

Maybe针对空值的情况将直接返回null,之后的fn处理就不会造成程序异常了。但又出现一个新的新问题,怎么定位是那一步产生了空值?如何捕获函数内部的异常?这轮到Either函子出场了...

Either函子

Either函子由LeftRight函子组成,类似if/else的逻辑。当某个函子出现异常时,由Left函子储存异常的值,并返回当前函子,本身不再继续传递值,Right则正常执行返回新的函子。

// 处理异常的函子,返回自身实例,不再返回新的函子
class Left {
    static of (value) {
        return new Left(value)
    }
    constructor(value) {
        this._value = value
    }
    map () {
        return this
    }
    join () {
        return this._value
    }
}
// 普通函子,正常返回新的函子
class Right {
    static of (value) {
        return new Right(value)
    }
    constructor(value) {
        this._value = value
    }
    map (fn) {
        return Right.of(fn(this._value))
    }
    join () {
        return this._value
    }
}

当我们想解析一段JSON字符串时,如果传入的字符串的格式错误,则必然出现异常。此时可以借助Either函子处理异常。

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

let l = parseJSON("{ name: tom }")
        .map(x => x.name.toUpperCase())
console.log(l.join()) // { error: 'Unexpected token n in JSON at position 2' }
let r = parseJSON('{ "name": "tom" }')
        .map(x => x.name.toUpperCase())
console.log(r.join()) // TOM

IO函子

截至到这里,我们使用的函子MayBeEither是对函数执行过程中产生的异常进行处理。但是如果传递的fn是一个不纯的函数,那它会一个受影响的值并产生副作用。这种情况是无法避免的,因此可以把不纯的动作储存在_value中,延迟执行这个不纯的操作,最后把不纯的操作交给调用者来处理(类似“甩锅”)。

const fp = require('lodash/fp')
class IO {
    static of (x) {
        return new IO(() => x)
    }
    constructor(fn) {
        this._value = fn
    }
    map (fn) {
        return new IO(fp.flowRight(fn, this._value))
    }
    join () {
        return this._value()
    }
}
// 测试
let r = IO.of([1, 2, 3])
    .map(fp.reverse)
    .map(fp.first)
console.log(r.join()) // 3

测试代码中虽然给IO函子传递了一个数组,但它会在函子内部转成一个函数储存在this._value中,fp.flowRight进行函数组合。只有当我们调用join()才能得到返回值,这就保证了惰性求值,等需要时再调用。我们保证了这个过程是纯的,至于函数组合中不纯的操作交给调用者来处理。

Folktale-Task函子

截止到这里,我们主要对FP中的异常处理和如何控制副作用。在开发中经常会遇到异步操作的场景,我们可以借助folktable这个库Task函子来帮我们处理异步任务。

folktale 是一个标准的函数式编程库和 lodash、ramda 不同的是,他没有提供很多功能函数, 只提供了一些函数式处理的操作,例如:compose、curry 等,一些函子 Task、Either、MayBe 等
对于node环境中读取文件的异步操作,我们可以借助Task函子来处理读取成功和失败的情况。

const { task } = require('folktale/concurrency/task')
const fs = require('fs')
const fp = 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(fp.split('\n'))
    .map(fp.find((x) => x.includes('version')))
    .map(fp.toUpper)
    .run()
    .listen({
        onRejected: (err) => {
            console.log(err)
        },
        onResolved: (value) => {
            console.log(value)
        }
    })

task函子是FP中处理异步操作的函子,它和promise的操作很像。

Monad函子

上面的IO函子存在一些问题如下:

const fp = require('lodash/fp')
const fs = require('fs')

class IO {
    static of (x) {
        return new IO(() => x)
    }
    constructor(fn) {
        this._value = fn
    }
    map (fn) {
        return new IO(fp.flowRight(fn, this._value))
    }
    join () {
        return this._value()
    }
}

let readFile = function (filename) {
    return new IO(() => {
        return fs.readFileSync(filename, 'utf-8')
    })
}
let print = function (x) {
    return new IO(() => {
        console.log(x)
        return x
    })
}

let cat = fp.flowRight(print, readFile)
// IO(IO(x)) 存在问题:IO嵌套  api调用不优雅 
let r = cat('package.json')
console.log(r.join())
// IO { _value: [Function] }
// IO { _value: [Function] }

为什么当执行join后打印了两次函子,而没有打印文件内容?当运行cat('package.json')后,执行了函数组合和函子值的设置。执行readFile,返回new IO(() => { return fs.readFileSync(filename, 'utf-8') }),同时将这个函子内部的_value设为() => { return fs.readFileSync(filename, 'utf-8') },接着把new IO(() => { return fs.readFileSync(filename, 'utf-8') })传递给printprint同理设置它的函子的值为

this._value = () => {
    console.log(new IO(() => fs.readFileSync(filename, 'utf-8')))
    return new IO(() => fs.readFileSync(filename, 'utf-8'))
}

然后返回一个嵌套的函子,此时的f为:

new IO(() => {
    console.log(new IO(() => fs.readFileSync(filename, 'utf-8')))
    return new IO(() => fs.readFileSync(filename, 'utf-8'))
})

当执行join方法时,会执行下面的函数:

() => {
  console.log(new IO(() => fs.readFileSync(filename, 'utf-8')));
  return new IO(() => fs.readFileSync(filename, 'utf-8'))
}

此时进行了第一次打印,接着这个函数又返回了一个函数,所以我们使用console可以打印这个返回的函子。而我们其实真正要执行的函子还是没有执行,所以需要对这个返回的函子再次调用join方法,才可以去读取文件:

console.log(f.join().join()) // 文件内容

我们可以看到当函子嵌套函子时,代码将变得十分复杂,所以我们需要在函子内部实现一个flatMap方法来让函子“扁平化”。

flatMap (fn) {
 return this.map(fn).join()
}

改造后的代码如下:

let r = readFile('package.json')
        .map(fp.toUpper)
        .flatMap(print)
        .join()

当一个函数返回一个函子的时候,我们就应该想到采用Monad函子,在函子的内部实现类似flatMap等方法。当一个函数返回值的时候,我们可以采用map方法。可以很好地解决问题。

八、函数式编程总结

函数式编程涉及了函数的基础理论:函数是一等公民、高阶函数、闭包。在此基础扩展了村函数、函数柯里化以及函数组合的知识。为了保证函数式编程中的副作用可控、异常被处理、异步正确处理,引入了各有针对性的函子。函数式编程的思想是对过程的抽象,它可以有效提升我们代码编程的质量,减少代码量、避免缺陷(副作用)。

参考

ECMAScript2015~2020语法全解析
箭头函数
函数式编程指北
函数式编程入门教程