JavaScript深度剖析之函数式编程

友情提示:本文内容过长,建议先收藏。


内容介绍

  • 为什么要学习函数编程以及什么是函数编程
  • 函数式编程的特性(纯函数、柯里化、函数组合等)
  • 函数式编程的应用场景
  • 函数式编程库Lodash

为什么要学函数式编程?

函数式编程是一个非常古老的概念。

  • 函数式编程是随着React的流行收到越来越多的关注(React的高阶组件使用了高阶函数来实现,高阶函数就是函数式编程的一个特性。Redux也使用了函数式编程的思想。)
  • Vue3也开始拥抱函数式编程
  • 函数式编程可以抛弃this
  • 打包过程中可以更好的利用tree shaking过滤无用代码
  • 方便测试、方便并行处理
  • 有很多库可以帮助我们进行函数式开发:lodash、underscore、ramda

什么是函数式编程?

函数式编程,缩写FP,是一种编程范式,也是一种编程风格,和面向对象是并列的关系。函数式编程我们可以认为是一种思维模式,加上实现方法。其思维方式就是把现实世界事物和事物之间的联系抽象到程序世界(是对运算过程进行抽象)

常听说的编程范式还有面向过程编程(按照步骤来实现)、面向对象编程(把现实中的事物抽象成类和对象,通过封装、继承和多态来演示不同事物之间的联系)。

函数式编程和面向对象编程的不同

  • 从思维方式上来说
    面向对象编程是对事物的抽象,而函数式编程是对运算过程的抽象

对于函数式编程思维方式的理解:

  • 程序的本质:根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多输入和输出的函数。
  • 函数式编程中的函数指的不是程序中的函数Function,而是数学中的函数即映射关系,例如:y=sin(x),是这种x和y的关系
  • 相同的输入时钟要得到相同的输出(纯函数)
  • 函数式编程用描述数据(函数)之间的映射
// 非函数式
let num1 = 2
let num2 = 3
let sum = num1 + num2
console.log(sum)

// 函数式
function add(n1, n2) {
    return n1 + n2
}
let sum = add(2, 3)
console.log(sum)

函数式编程的前置知识

函数是一等公民

在JS中函数就是一个普通的对象,我们可以把函数存储到变量/数组中,它还可以作为另一个函数的参数和返回值,甚至我们可以在程序运行的时候通过new Function('alert(1)')来构造一个新的函数。

  • 函数可以存储在变量中
// 把函数赋值给变量
let fn = function () {
    console.log("hi")
}

fn()

// 一个示例
const 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) }
}

// 优化 赋值的是Views的index方法,不是方法的调用
const BlogController = {
    index: Views.index,
    show: Views.show,
    create: Db.create,
    update: Db.update,
    destroy: Db.destroy
}    

下面两个特性在高阶函数中会有详细说明

  • 函数可以作为参数
  • 函数可以作为返回值

高阶函数

什么是高阶函数?

高阶函数(Higher-order function)

  • 函数可以作为参数
// forEach
// 定义一个遍历数组的并对每一项做处理的函数,第一个函数是一个数组,第二个参数是一个函数。
function forEach (array, fn) {
    for (let i = 0; i < array.length; i++) {
        fn(array[i]) 
    } 
}

// test
let arr = [1, 2, 3]
forEach(arr, item => {
    item = item * 2
    console.log(item) // 2 4 6
})
// filter
// 遍历数组,并把满足条件的元素存储成数组,再进行返回
function filter(array, fn) {
    let results = []
    for (let i = 0; i < array.length; i++) { 
        //如果满足条件  
        if (fn(array[i])) { 
            results.push(array[i]) 
        }    
    }
    return results
}

// test
let arr = [1, 3, 4, 7, 8]
let result = filter(arr, item => item % 2 === 0)
console.log(result) // [4, 8]
  • 函数作为返回值
// 一个函数返回另一个函数
function makeFn () {
    let msg = 'Hello function' 
    return function () { 
        console.log(msg) 
    } 
}

// test
// 第一种调用方式
const fn = makeFn() 
fn() //Hello function

// 第二种调用方式
makeFn()()///Hello function
// once
// 让函数只执行一次

function once(fn) {
    let done = false
    return function() {
        // 判断值有没有被执行,如果是false表示没有执行,如果是true表示已经执行过了,不必再执行
        if(!done) {
            done = true
            // 调用fn,当前this直接传递过来,第二个参数是把fn的参数传递给return的函数
            return fn.apply(this, arguments)
        }
    }
}

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

pay(5) //支付:5 RMB
pay(5)
pay(5)
pay(5)
pay(5)

使用高阶函数的意义

  • 抽象可以帮我们屏蔽细节,我们只需要知道我们的目标和解决这类问题的函数,我们不需要关心实现的细节
  • 高阶函数是用来抽象通用的问题

常用的高阶函数

有一个通用的特点,就是需要一个函数作为参数。

  • forEach
  • map
    对数组中的每个元素进行遍历,并处理,处理的结果放在一个新数组中返回
const map = (array, fn) => { 
    let results = [] 
    for (const value of array) { 
        results.push(fn(value)) 
    }
    return results 
}

// test
let arr = [1, 2, 3, 4]
arr = map(arr, v => v * v)
console.log(arr)
//
  • filter
  • every
    数组中的每一个元素是否都匹配我们指定的一个条件,如果都满足返回true,如果不满足返回false
const every = (array, fn) => { 
    let result = true 
    for (const value of array) {
        result = fn(value) 
        // 如果有一个元素不满足就直接跳出循环
        if (!result) { 
            break 
        }
    }
    return result
}

// test
let arr = [11, 12, 14]
let r = every(arr, v => v > 10)
console.log(r) // false

r = every(arr, v => v > 12)
console.log(r) // false
  • some
    判断数组中是否有一个元素满足我们指定的条件,满足是true,都不满足为false
const some = (array, fn) => { 
    let result = false 
    for (const value of array) {
        result = fn(value) 
        // 如果有一个元素不满足就直接跳出循环
        if (result) { 
            break 
        }
    }
    return result
}

// test
let arr = [1, 3, 4, 9]
let arr1 = [1, 3, 5, 9]
let r = some(arr, v => v % 2 === 0)
console.log(r) // true
r = some(arr1, v => v % 2 === 0)
console.log(r) // false

  • find/findIndex
  • reduce
  • sort

闭包

闭包的概念

闭包:函数和其周围的状态(词法环境)的引用捆绑在一起形成闭包

  • 可以在另一个作用域中调用一个函数的内部函数并访问到该函数作用域中的成员

在上面函数作为返回值的过程中,其实我们就用到了闭包,下面进行语法演示:

function makeFn () {
    let msg = 'Hello function'
}
// 正常情况下,执行完makeFn,里面的变量msg会释放掉
// 但是下面的情况

function makeFn () {
    let msg = 'Hello function'
    return function () { 
        console.log(msg)
    } 
}
// 在上面函数中,返回了一个函数,而且在函数中还访问了原来函数内部的成员,就可以称为闭包

const fn = makeFn()
fn()
// fn为外部函数,当外部函数对内部成员有引用的时候,那么内部的成员msg就不能被释放。当我们调用fn的时候,我们就会访问到msg。

//注意的点:
//1、我们可以在另一个作用域调用makeFn的内部函数
//2、当我们调用内部函数的时候我们可以访问到内部成员

闭包的核心作用

把函数内部成员的作用范围延长

闭包的本质

函数在执行的时候会放到一个执行栈上,当函数执行完毕之后会从执行栈上移除。但是堆上的作用域成员因为被外部引用不能释放,因此内部函数依然可以访问外部函数的成员。

/解读:函数执行的时候在执行栈上,执行完毕之后从执行栈上移除,内部成员的内存被释放。但是在函数执行完毕移除之后,释放内存的时候,如果外部有引用,则内部成员的内存不能被释放。/

闭包的案例

案例一

计算一个数平方和立方的运算

Math.pow(4, 2)
Math.pow(5, 2)
// 后面的二次方三次方很多次重复,下面要写一个二次方三次方的函数
function makePower (power) {
  return function (number) {
    return Math.pow(number, power)
  }
}

// 求平方
let power2 = makePower(2)
let power3 = makePower(3)

console.log(power2(4)) // 16
console.log(power2(5)) // 25
console.log(power3(4)) // 64

调试台的案例演示

案例二

计算不同级别的员工工资

// 假设计算员工工资的函数第一个函数传基本工资,第二个参数传绩效工资
// getSalary(12000, 2000)
// getSalary(15000, 3000)
// getSalary(15000, 4000)

// 不同级别的员工基本工资是一样的,所以我们将基本工资提取出来,之后只需要加上绩效工资
function makeSalary (base) { 
    return function (performance) { 
        return base + performance 
    }
}
let salaryLevel1 = makeSalary(12000)
let salaryLevel2 = makeSalary(15000)

console.log(salaryLevel1(2000)) //14000
console.log(salaryLevel2(3000)) //18000
console.log(salaryLevel2(4000)) //19000

纯函数

纯函数的概念

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

纯函数就类似数学中的函数(用来描述输入和输出之间的关系),y = f(x)

let numbers = [1, 2, 3, 4, 5] 
// 纯函数 
// 对于相同的函数,输出是一样的

// slice方法,截取的时候返回截取的函数,不影响原数组
numbers.slice(0, 3) // => [1, 2, 3] 
numbers.slice(0, 3) // => [1, 2, 3] 
numbers.slice(0, 3) // => [1, 2, 3] 

// 不纯的函数 
// 对于相同的输入,输出是不一样的

// splice方法,返回原数组,改变原数组
numbers.splice(0, 3) // => [1, 2, 3] 
numbers.splice(0, 3) // => [4, 5] 
numbers.splice(0, 3) // => []

// 下面函数也是纯函数 
function getSum (n1, n2) {
    return n1 + n2
}
console.log(getSum(1, 2)) // 3
console.log(getSum(1, 2)) // 3
console.log(getSum(1, 2)) // 3
  • 函数式编程不会保留计算中间的结果,所以变量是不可变的(无状态的)
  • 我们也可以把一个函数的执行结果交给另一个函数处理

Lodash——纯函数的代表

  • lodash 是一个纯函数的功能库,提供了模块化、高性能以及一些附加功能。提供了对数组、数字、对象、字符串、函数等操作的一些方法

体验Lodash

  • 安装

新建文件夹 -> npm init -y -> npm i lodash

  • 体验
const _ = require('lodash')

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

// head的别名是first  _.head(array)也可以
console.log(_.first(array)) //jack
console.log(_.last(array)) //kate

console.log(_.toUpper(_.first(array))) //JACK

console.log(_.reverse(array))  //[ 'kate', 'lucy', 'tom', 'jack' ]
// 数组的翻转不是纯函数,因为会改变原数组。这里的reserve是使用了数组的reverse,所以也不是纯函数

const r = _.each(array, (item, index) => {
  console.log(item, index)
  // kate 0
  // lucy 1
  // tom 2
  // jack 3
})
console.log(r) // [ 'kate', 'lucy', 'tom', 'jack' ]

纯函数的好处

可缓存

因为对于相同的输入始终有相同的结果,那么可以把纯函数的结果缓存起来,可以提高性能。

const _ = require('lodash')

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

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

// 看到输出的4只执行了一次,因为其结果被缓存下来了

那我们可以模拟一个记忆函数

function memoize (f) {
  let cache = {}
  return function () {
    // arguments是一个伪数组,所以要进行字符串的转化
    let key = JSON.stringify(arguments)
    // 如果缓存中有值就把值赋值,没有值就调用f函数并且把参数传递给它
    cache[key] = cache[key] || f.apply(f,arguments)
    return cache[key]
  }
}

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

可测试

纯函数让测试更加的方便

并行处理

  • 多线程环境下并行操作共享的内存数据很可能会出现意外情况。纯函数不需要访问共享的内存数据,所以在并行环境下可以任意运行纯函数
  • 虽然JS是单线程,但是ES6以后有一个Web Worker,可以开启一个新线程

副作用

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

// 不纯的函数,因为它依赖于外部的变量
let mini = 18 
function checkAge (age) { 
    return age >= mini 
}

副作用来源:

  • 配置文件
  • 数据库
  • 获取用户的输入

所有的外部交互都有可能带来副作用,副作用也使得方法通用性下降不适合扩展和可重用性,同时副作用会给程序中带来安全隐患给程序带来不确定性,但是副作用不可能完全禁止,我们不能禁止用户输入用户名和密码,只能尽可能控制它们在可控范围内发生。

柯里化

解决硬编码的问题

// 下面这段代码是解决了不纯的函数的问题,但是里面出现了硬编码
function checkAge (age) { 
    let mini = 18
    return age >= mini 
}


// 普通的纯函数
function checkAge (min, age) {
    return age >= min
}
console.log(checkAge(18, 20))  //true
console.log(checkAge(18, 24))  //true
console.log(checkAge(20, 24))  //true
// 经常使用18,这段代码是重复的。避免重复
function checkAge (min) {
    return function (age) {
        return age >= min
    }
}

let checkAge18 = checkAge(18)
let checkAge20 = checkAge(20)

console.log(checkAge18(20)) //true
console.log(checkAge18(24)) //true

柯里化:当函数有多个参数的时候,我们可以对函数进行改造。我们可以调用一个函数,只传递部分的参数(这部分参数以后永远不变),然后让这个函数返回一个新的函数。新的函数传递剩余的参数,并且返回相应的结果。

// ES6
let checkAge = min => (age => age >= min)
// 输出相同

Lodash中的柯里化 —— curry()

_.curry(func)

  • 功能:创建一个函数,该函数接收一个或多个 func的参数,如果 func 所需要的参数都被提供则执行 func 并返回执行的结果。否则继续返回该函数并等待接收剩余的参数。
  • 参数:需要柯里化的函数
  • 返回值:柯里化后的函数
const _ = require('lodash')

// 参数是一个的为一元函数,两个的是二元函数
// 柯里化可以把一个多元函数转化成一元函数
function getSum (a, b, c) {
  return a + b + c
}

// 定义一个柯里化函数
const curried = _.curry(getSum)

// 如果输入了全部的参数,则立即返回结果
console.log(curried(1, 2, 3)) // 6

//如果传入了部分的参数,此时它会返回当前函数,并且等待接收getSum中的剩余参数
console.log(curried(1)(2, 3)) // 6
console.log(curried(1, 2)(3)) // 6

案例

判断字符串中有没有空白字符,或者提取字符串中所有空白字符,可以使用字符串的match方法:
''.match(/\s+/g)

但是我们要是写一个数组的去处空白字符的方法,上面的代码就无法重用。那我们如何用函数式方法去写

function match(reg, str) {
  return str.match(reg)
}

reg的表达式是重复的,上面的函数如何柯里化,思路是这样的:

//柯里化处理
const _ = require('lodash')

//利用lodash的curry函数,第一个参数是匹配规则,第二个参数是字符串,生成一个match函数
const match = _.curry(function (reg, str) {
  return str.match(reg)
})

// 根据规则haveSpace是一个匹配空格的函数
const haveSpace = match(/\s+/g)

console.log(haveSpace("hello world")) //[ ' ' ]
console.log(haveSpace("helloworld")) //null
// 由此可以判断字符串里面有没有空格

// 那如果是数字的话怎么办呢?
// 根据规则haveNumber是一个匹配数字的函数
const haveNumber = match(/\d+/g)
console.log(haveNumber('abc')) // null

// 对于数组怎么匹配元素中有没有空格
const filter = _.curry(function(func, array) {
  return array.filter(func)
})

// filter函数,第一个参数传递匹配元素中有没有空格
//第二个参数是指定的数组
console.log(filter(haveSpace, ['John Connor','John_Donne'])) // [ 'John Connor' ]

// 如果上述写还是比较麻烦,那么可以再封装一个函数出来
// filter可以传一个参数,然后返回一个函数
// 这个findSpace就是匹配数组元素中有没有空格的函数
const findSpace = filter(haveSpace)
console.log(findSpace(['John Connor','John_Donne'])) // [ 'John Connor' ]

下面对上面的思路做一个小的总结,柯里化的好处就是我们可以最大程度的重用我们的函数

const _ = require('lodash')

//match函数是根据一些正则,匹配字符串,返回匹配结果
const match = _.curry(function (reg, str) {
  return str.match(reg)
})

//haveSpace函数是一个匹配空格的函数
const haveSpace = match(/\s+/g)

//haveNumber函数是一个匹配数字的函数
const haveNumber = match(/\d+/g)

//filter函数是定义一个数组和过滤规则,返回符合匹配规则的数组
const filter = _.curry(function(func, array) {
  return array.filter(func)
})

//findSpace函数是匹配数组元素中有空格并返回符合情况的数组的函数
const findSpace = filter(haveSpace)

柯里化原理模拟

我们找一个之前做过的例子分析一下

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

实现一个柯里化转换函数要进行分析

  1. 入参出参:调用传递一个纯函数的参数,完成之后返回一个柯里化函数
  2. 入参情况分析:
  • 如果curried调用传递的参数和getSum函数参数个数相同,那么立即执行并返回调用结果
  • 如果curried调用传递的参数是getSum函数的部分参数,那么需要返回一个新的函数,并且等待接收getSum的其他参数
  1. 重点关注:
  • 获取调用的参数
  • 判断个数是否相同
// 模拟柯里化函数
function curry (func) {
  // 取名字是为了下面实参小于形参的时候用的
  return function curriedFn(...args) {
    // 判断实参和形参的个数
    if(args.length < func.length) {
      return function() {
        // 等待传递的剩余参数,如果剩余函数的参数加上之前的参数等于形参,那么就返回func
        // 第一部分参数在args里面,第二部分参数在arguments里面,要将两个合并并且展开传递(使用...)
        // concat函数要合并两个数组,arguments为伪数组,所以用Array.from进行转换
        return curriedFn(...args.concat(Array.from(arguments)))
      }
    }
    // 如果实参大于等于形参的个数
    // args是剩余参数,是个数组形式,而返回的时候要展开(使用...)
    return func(...args)
  }
}


// test
const curriedTest = curry(getSum)

console.log(curriedTest(1, 2, 3))  // 6
console.log(curriedTest(1)(2, 3))  // 6
console.log(curriedTest(1, 2)(3))  // 6

柯里化总结

  • 柯里化可以让我们给一个函数传递较少的参数得到一个已经记住了某些固定参数的新函数(比如match函数新生成了haveSpace函数,里面使用了闭包,记住了我们给传递的正则表达式的参数)
  • 这是一种对函数参数的’缓存’(使用了闭包)
  • 让函数变的更灵活,让函数的粒度更小
  • 可以把多元函数转换成一元函数,可以组合使用函数产生强大的功能

函数组合

背景知识

  • 纯函数和柯里化很容易写出洋葱代码 h(g(f(x)))
//获取数组的最后一个元素再转换成大写字母
//先翻转数据 --> 再取第一个元素 --> 再转换成大写字母
_.toUpper(_.first(_.reverse(array)))

函数组合可以让我们把细粒度的函数重新组合生成一个新的函数,避免写出洋葱代码

管道

a --> fn --> b

a-> f3 -> m -> f2 -> n -> f1 -> b

其实中间m、n、是什么我们也不关心
类似于下面的函数

fn = compose(f1, f2, f3)
b = fn(a)

函数组合

  • 函数组合 (compose):如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间
    过程的函数合并成一个函数
  • 函数组合默认是从右到左执行
// 函数组合演示
function compose(f, g) {
  return function (value) {
    return f(g(value))
  }
}

// 数组翻转函数
function reverse (array) {
  return array.reverse()
}

// 获取函数第一个元素函数
function first (array) {
  return array[0]
}

// 组合函数,获取函数最后一个元素
const last = compose(first, reverse)

console.log(last([1, 2, 3, 4])) // 4

Lodash中的组合函数 —— flow()/flowRight()

lodash 中组合函数 flow() 或者flowRight(),他们都可以组合多个函数。

  • flow() 是从左到右运行
  • 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'])) // THREE

函数组合原理模拟

上面的例子我们来分析一下:

入参不固定,参数都是函数,出参是一个函数,这个函数要有一个初始的参数值

function compose (...args) {
  // 返回的函数,有一个传入的初始参数即value
  return function (value) {
    // ...args是执行的函数的数组,从右向左执行那么数组要进行reverse翻转
    // reduce: 对数组中的每一个元素,去执行我们提供的一个函数,并将其汇总成一个单个结果
    // reduce的第一个参数是一个回调函数,第二个参数是acc的初始值,这里acc的初始值就是value

    // reduce第一个参数的回调函数需要两个参数,第一个参数是汇总的一个结果,第二个参数是如果处理汇总的结果的函数并返回一个新的值
    // fn指的是数组中的每一个元素(即函数),来处理参数acc,完成之后下一个数组元素处理的是上一个数组的结果acc
    return args.reverse().reduce(function (acc, fn) {
      return fn(acc)
    }, value)
  }
}


//test
const fTest = compose(toUpper, first, reverse)
console.log(fTest(['one', 'two', 'three'])) // THREE


// ES6的写法(函数都变成箭头函数)
const compose = (...args) => value => args.reverse().reduce((acc, fn) => fn(acc), value)

函数组合-结合律

什么是函数组合结合律?

下面三个情况结果一样,我们既可以把 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))

// 无论上面那种写法,下面都输出THREE这个相同的结果
console.log(f(['one', 'two', 'three'])) // THREE

函数组合-调试

如果我们运行的结果和我们的预期不一致,我们怎么调试呢?我们怎么能知道中间运行的结果呢?

下面这个输入NEVER SAY DIE要对应输出nerver-say-die

注意:
每次把自己加的参数写前面,传入的值写后面

const _ = require('lodash')

// 这里split函数需要传入两个参数,且我们最后调用的时候要传入字符串,所以字符串要在第二个位置传入,这里我们需要自己封装一个split函数
// _.split(string, separator)

// 将多个参数转成一个参数,用到函数的柯里化
const split = _.curry((sep, str) => _.split(str, sep))

// 大写变小写,用到toLower(),因为这个函数只有一个参数,所以可以在函数组合中直接使用

// 这里join方法也需要两个参数,第一个参数是数组,第二个参数是分隔符,数组也是最后的时候才传递,也需要交换
const join = _.curry((sep, array) => _.join(array, sep))

const f = _.flowRight(join('-'), _.toLower, split(' '))

console.log(f('NEVER SAY DIE')) //n-e-v-e-r-,-s-a-y-,-d-i-e

但是最后的结果却不是我们想要的,那么我们怎么调试呢?

// NEVER SAY DIE --> nerver-say-die

const _ = require('lodash')
 
const split = _.curry((sep, str) => _.split(str, sep))
const join = _.curry((sep, array) => _.join(array, sep))

// 我们需要对中间值进行打印,并且知道其位置,用柯里化输出一下
const log = _.curry((tag, v) => {
  console.log(tag, v)
  return v
})

// 从右往左在每个函数后面加一个log,并且传入tag的值,就可以知道每次结果输出的是什么
const f = _.flowRight(join('-'), log('after toLower:'), _.toLower, log('after split:'), split(' '))
// 从右到左
//第一个log:after split: [ 'NEVER', 'SAY', 'DIE' ] 正确
//第二个log: after toLower: never,say,die  转化成小写字母的时候,同时转成了字符串,这里出了问题
console.log(f('NEVER SAY DIE')) //n-e-v-e-r-,-s-a-y-,-d-i-e


// 修改方式,利用数组的map方法,遍历数组的每个元素让其变成小写 
// 这里的map需要两个参数,第一个是数组,第二个是回调函数,需要柯里化
const map = _.curry((fn, array) => _.map(array, fn))

const f1 = _.flowRight(join('-'), map(_.toLower), split(' '))
console.log(f1('NEVER SAY DIE')) // never-say-die

FP模块

函数组合的时候用到很多的函数需要柯里化处理,我们每次都处理那些函数有些麻烦,所以lodash中有一个FP模块

  • lodash 的 fp 模块提供了实用的对函数式编程友好的方法
  • 提供了不可变 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', ' ') 

//BUT
// 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')

体验FP模块对于组合函数的友好

const fp = require('lodash/fp')

const f = fp.flowRight(fp.join('-'), fp.map(fp.toLower), fp.split(' '))

console.log(f('NEVER SAY DIE')) // never-say-die

Lodash-map方法的小问题

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

console.log(_.map(['23', '8', '10'], parseInt)) 
// [ 23, NaN, 2 ]

_.map(['23', '8', '10'], function(...args){
  console.log(...args)
})
// _.map后面的回调函数接受有三个参数,第一个参数是遍历的数组,第二个参数是key/index,第三个参数是对应函数
// 23 0 [ '23', '8', '10' ]
// 8 1 [ '23', '8', '10' ]
// 10 2 [ '23', '8', '10' ]

// parseInt第二个参数表示进制,0默认就是10进制,1不存在,2表示2进制,所以输出是那个样子
//parseInt('23', 0, array)
//parseInt('8', 1, array)
//parseInt('10', 2, array)

// 要解决的话需要重新封装一个parseInt方法

// 而使用fp模块的map方法不存在下面的问题
console.log(fp.map(parseInt, ['23', '8', '10'])) 
// [ 23, 8, 10 ]

Point Free编程风格

是一种编程风格,具体的实现是函数的组合。

Point Free: 我们可以把数据处理的过程定义成与数据无关的合成运算,不需要用到代表数据的那个参数,只要把简单的运算步骤合成到一起,在使用这种模式之前我们需要定义一些辅助的基本运算函数。

  • 不需要指明处理的数据
  • 只需要合成运算过程
  • 需要定义一些辅助的基本运算函数
//Hello World => hello world

//思路:
//先将字母换成小写,然后将空格换成下划线。如果空格比较多,要替换成一个
const fp = require('lodash/fp')

// replace方法接收三个参数
// 第一个是正则匹配pattern,第二个是匹配后替换的数据,第三个是要传的字符串
// 所以这里需要传两个参数
const f = fp.flowRight(fp.replace(/\s+/g, '_'), fp.toLower)

console.log(f('Hello World')) //hello_world

Pointfree案例

//world wild web -->W. W. W
//思路:
//把一个字符串中的额首字母提取并转换成大写,使用. 作为分隔符
const fp = require('lodash/fp')

const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.first), fp.map(fp.toUpper), fp.split(' '))
console.log(firstLetterToUpper('world wild web')) //W. W. W

// 上面的代码进行了两次的遍历,性能较低
// 优化
const firstLetterToUpper = fp.flowRight(fp.join('. '), fp.map(fp.flowRight(fp.first, fp.toUpper)), fp.split(' '))
console.log(firstLetterToUpper('world wild web')) //W. W. W

函子(Functor)

为什么要学函子?

函子(representative functor)是范畴论里的概念,指从任意范畴到集合范畴的一种特殊函子。
我们没有办法避免副作用,但是我们尽可能的将副作用控制在可控的范围内,我们可以通过函子去处理副作用,我们也可以通过函子去处理异常,异步操作等。

什么是Functor

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

理解Functor

class Container {
  constructor (value) {
    // 这个函子的值是保存在内部的,不对外公布
    // _下划线的成员都是私有成员,外部无法访问,值是初始化的传的参数
    this._value = value
  }
  
  //有一个对外的方法map,接收一个函数(纯函数),来处理这个值
  map (fn) {
    // 返回一个新的函子,把fn处理的值返回给函子,由新的函子来保存
    return new Container(fn(this._value))
  }
}

// 创建一个函子的对象
let r = new Container(5)
  .map(x => x + 1) // 6
  .map(x => x ** 2) // 36

// 返回了一个container函子对象,里面有值是36,不对外公布
console.log(r) //Container { _value: 36 }

上面还是面向对象的编程思想,要修改成函数式编程的思想,需要避免使用new

class Container {
  //使用类的静态方法,of替代了new Container的作用
  static of (value) {
    return new Container(value)
  }
  constructor (value) {
    this._value = value
  }
  
  map (fn) {
    return Container.of(fn(this._value))
  }
}

const r = Container.of(5)
            .map(x=>x+2) // 7
            .map(x=> x**2) // 49

console.log(r) // Container { _value: 49 }

总结

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

遗留问题:如果value是null undefined,怎么办?

Container.of(null)
  .map(x=>x.toUpper) // 报错,使得函数不纯

下面会有好几种函子,处理不同的问题

MyBe函子

MayBe 函子的作用就是可以对外部的空值情况做处理(控制副作用在允许的范围)

class MayBe {
  static of (value) {
    return new MayBe(value)
  }
  constructor (value) {
    this._value = value
  }

  map(fn) {
    // 判断一下value的值是不是null和undefined,如果是就返回一个value为null的函子,如果不是就执行函数
    return this.isNothing() ? MayBe.of(null) : MayBe.of(fn(this._value))
  }

 // 定义一个判断是不是null或者undefined的函数,返回true/false
  isNothing() {
    return this._value === null || this._value === undefined
  }
}

const r = MayBe.of('hello world')
  .map(x => x.toUpperCase())

console.log(r) //MayBe { _value: 'HELLO WORLD' }


// 如果输入的是null,是不会报错的
const rnull = MayBe.of(null)
  .map(x => x.toUpperCase())
console.log(rnull) //MayBe { _value: null }

但是这里有一个问题就是,如果map中间有好几步,最后返回是null,并不知道是哪一个步骤返回的。解决这个问题,需要看下一个函子。

Either函子

  • Either 两者中的任何一个,类似于 if…else…的处理
  • 当出现问题的时候,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))
  }
}

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 }
// 为什么结果会不一样?因为Left返回的是当前对象,并没有使用fn函数

// 那么这里如何处理异常呢?
// 我们定义一个字符串转换成对象的函数
function parseJSON(str) {
  // 对于可能出错的环节使用try-catch
  // 正常情况使用Right函子
  try{
    return Right.of(JSON.parse(str))
  }catch (e) {
  // 错误之后使用Left函子,并返回错误信息
    return Left.of({ error: e.message })
  }
}

let rE = parseJSON('{name:xm}')
console.log(rE) // Left { _value: { error: 'Unexpected token n in JSON at position 1' } }
let rR = parseJSON('{"name":"xm"}')
console.log(rR) // Right { _value: { name: 'xm' } }

console.log(rR.map(x => x.name.toUpperCase())) // Right { _value: 'XM' }

IO函子

  • IO就是输入输出,IO 函子中的 _value 是一个函数,这里是把函数作为值来处理
  • IO 函子可以把不纯的动作存储到 _value 中,延迟执行这个不纯的操作(惰性执行),包装当前的操
  • 把不纯的操作交给调用者来处理

因为IO函数需要用到组合函数,所以需要提前安装Lodash

npm init -y

npm i lodash

const fp = require('lodash/fp')

class IO {
  // of方法快速创建IO,要一个值返回一个函数,将来需要值的时候再调用函数
  static of(value) {
    return new IO(() => value)
  }
  // 传入的是一个函数
  constructor (fn) {
    this._value = fn
  }

  map(fn) {
    // 这里用的是new一个新的构造函数,是为了把当前_value的函数和map传入的fn进行组合成新的函数
    return new IO(fp.flowRight(fn, this._value))
  }
}


// test
// node执行环境可以传一个process对象(进程)
// 调用of的时候把当前取值的过程包装到函数里面,再在需要的时候再获取process
const r = IO.of(process)
  // map需要传入一个函数,函数需要接收一个参数,这个参数就是of中传递的参数process
  // 返回一下process中的execPath属性即当前node进程的执行路径
  .map(p => p.execPath)
console.log(r) // IO { _value: [Function] }


// 上面只是组合函数,如果需要调用就执行下面
console.log(r._value()) // C:\Program Files\nodejs\node.exe

Task函子(异步执行)

  • 函子可以控制副作用,还可以处理异步任务,为了避免地狱之门。
  • 异步任务的实现过于复杂,我们使用 folktale 中的 Task 来演示
  • folktale 一个标准的函数式编程库。和 lodash、ramda 不同的是,他没有提供很多功能函数。只提供了一些函数式处理的操作,例如:compose、curry 等,一些函子 Task、Either、 MayBe 等

folktale的安装

首先安装folktale的库

npm i folktale

folktale中的curry函数

const { compose, curry } = require('folktale/core/lambda')

// curry中的第一个参数是函数有几个参数,为了避免一些错误
const f = curry(2, (x, y) => x + y)

console.log(f(1, 2)) // 3
console.log(f(1)(2)) // 3

folktale中的compose函数

const { compose, curry } = require('folktale/core/lambda')
const { toUpper, first } = require('lodash/fp')

// compose 组合函数在lodash里面是flowRight
const r = compose(toUpper, first)
console.log(r(['one', 'two']))  // ONE

Task函子异步执行

  • folktale(2.3.2) 2.x 中的 Task 和 1.0 中的 Task 区别很大,1.0 中的用法更接近我们现在演示的
    函子
  • 这里以 2.3.2 来演示
const { task } = require('folktale/concurrency/task')
const fs = require('fs')
// 2.0中是一个函数,函数返回一个函子对象
// 1.0中是一个类

//读取文件
function readFile (filename) {
  // task传递一个函数,参数是resolver
  // resolver里面有两个参数,一个是reject失败的时候执行的,一个是resolve成功的时候执行的
  return task(resolver => {
    //node中读取文件,第一个参数是路径,第二个是编码,第三个是回调,错误在先
    fs.readFile(filename, 'utf-8', (err, data) => {
      if(err) resolver.reject(err)
      resolver.resolve(data)
    })
  })
}

//演示一下调用
// readFile调用返回的是Task函子,调用要用run方法
readFile('package.json')
  .run()
  // 现在没有对resolve进行处理,可以使用task的listen去监听获取的结果
  // listen传一个对象,onRejected是监听错误结果,onResolved是监听正确结果
  .listen({
    onRejected: (err) => {
      console.log(err)
    },
    onResolved: (value) => {
      console.log(value)
    }
  })
 
 /** {
    "name": "Functor",
    "version": "1.0.0",
    "description": "",
    "main": "either.js",
    "scripts": {
      "test": "echo \"Error: no test specified\" && exit 1"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
      "folktale": "^2.3.2",
      "lodash": "^4.17.20"
    }
  }
  */

案例

在package.json文件中提取一下version字段

const { task } = require('folktale/concurrency/task')
const fs = require('fs')
const { split, find } = require('lodash/fp')
// 2.0中是一个函数,函数返回一个函子对象
// 1.0中是一个类

//读取文件
function readFile (filename) {
  // task传递一个函数,参数是resolver
  // resolver里面有两个参数,一个是reject失败的时候执行的,一个是resolve成功的时候执行的
  return task(resolver => {
    //node中读取文件,第一个参数是路径,第二个是编码,第三个是回调,错误在先
    fs.readFile(filename, 'utf-8', (err, data) => {
      if(err) resolver.reject(err)
      resolver.resolve(data)
    })
  })
}

//演示一下调用
// readFile调用返回的是Task函子,调用要用run方法
readFile('package.json')
  //在run之前调用map方法,在map方法中会处理的拿到文件返回结果
  // 在使用函子的时候就没有必要想的实现机制
  .map(split('\n'))
  .map(find(x => x.includes('version')))
  .run()
  // 现在没有对resolve进行处理,可以使用task的listen去监听获取的结果
  // listen传一个对象,onRejected是监听错误结果,onResolved是监听正确结果
  .listen({
    onRejected: (err) => {
      console.log(err)
    },
    onResolved: (value) => {
      console.log(value) // "version": "1.0.0",
    }
  })

Pointed函子

  • Pointed 函子是实现了 of 静态方法的函子
    of 方法是为了避免使用 new 来创建对象,更深层的含义是of 方法用来把值放到上下文
  • Context(把值放到容器中,使用 map 来处理值)
class Container { 
// Point函子
// 作用是把值放到一个新的函子里面返回,返回的函子就是一个上下文
    static of (value) { 
        return new Container(value)
    }
    ……  
}

// 调用of的时候获得一个上下文,之后是在上下文中处理数据
Contanier.of(2)
 .map(x => x + 5)

Monad函子(单子)

IO函子的嵌套问题

  • 用来解决IO函子多层嵌套的一个问题
const fp = require('lodash/fp')
const fs = require('fs')

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

  map(fn) {
    return new IO(fp.flowRight(fn, this._value))
  }
}

//读取文件函数
let readFile = (filename) => {
  return new IO(() => {
    //同步获取文件
    return fs.readFileSync(filename, 'utf-8')
  })
}

//打印函数
// x是上一步的IO函子
let print = (x) => {
  return new IO(()=> {
    console.log(x)
    return x
  })
}

// 组合函数,先读文件再打印
let cat = fp.flowRight(print, readFile)
// 调用
// 拿到的结果是嵌套的IO函子 IO(IO(x))
let r = cat('package.json')
console.log(r) 
// IO { _value: [Function] }
console.log(cat('package.json')._value()) 
// IO { _value: [Function] }
// IO { _value: [Function] }
console.log(cat('package.json')._value()._value())
// IO { _value: [Function] }
/**
 * {
  "name": "Functor",
  "version": "1.0.0",
  "description": "",
  "main": "either.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "folktale": "^2.3.2",
    "lodash": "^4.17.20"
  }
}
 */

上面遇到多个IO函子嵌套的时候,那么_value就会调用很多次,这样的调用体验很不好。所以进行优化。

什么是Monad函子

  • Monad 函子是可以变扁的 Pointed 函子,用来解决IO函子嵌套问题,IO(IO(x))
  • 一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad

实现一个Monad函子

实际开发中不会这么难,主要是知道monad的实现

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

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

  map(fn) {
    return new IO(fp.flowRight(fn, this._value))
  }

  join () {
    return this._value()
  }

  // 同时调用map和join方法
  flatMap (fn) {
    return this.map(fn).join()
  }
}

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

let print = (x) => {
  return new IO(()=> {
    console.log(x)
    return x
  })
}

let r = readFile('package.json')
          .flatMap(print)
          .join()     
// 执行顺序
/**
 * readFile读取了文件,然后返回了一个IO函子
 * 调用flatMap是用readFile返回的IO函子调用的
 * 并且传入了一个print函数参数
 * 调用flatMap的时候,内部先调用map,当前的print和this._value进行合并,合并之后返回了一个新的函子
 * (this._value就是readFile返回IO函子的函数:
 *      () => {
          return fs.readFileSync(filename, 'utf-8')
        }
 * )
 * flatMap中的map函数执行完,print函数返回的一个IO函子,里面包裹的还是一个IO函子
 * 下面调用join函数,join函数就是调用返回的新函子内部的this._value()函数
 * 这个this._value就是之前print和this._value的组合函数,调用之后返回的就是print的返回结果
 * 所以flatMap执行完毕之后,返回的就是print函数返回的IO函子
 *  */
 
 r = readFile('package.json')
        // 处理数据,直接在读取文件之后,使用map进行处理即可
        .map(fp.toUpper)
        .flatMap(print)
        .join()  

// 读完文件之后想要处理数据,怎么办?
// 直接在读取文件之后调用map方法即可

/**
 * {
  "NAME": "FUNCTOR",
  "VERSION": "1.0.0",
  "DESCRIPTION": "",
  "MAIN": "EITHER.JS",
  "SCRIPTS": {
    "TEST": "ECHO \"ERROR: NO TEST SPECIFIED\" && EXIT 1"
  },
  "KEYWORDS": [],
  "AUTHOR": "",
  "LICENSE": "ISC",
  "DEPENDENCIES": {
    "FOLKTALE": "^2.3.2",
    "LODASH": "^4.17.20"
  }
}
 */

Monad函子小结

什么是Monad?

具有静态的IO方法和join方法的函子

什么时候使用Monad?
  • 当一个函数返回一个函子的时候,我们就要想到monad,monad可以帮我们解决函子嵌套的问题。
  • 当我们想要返回一个函数,这个函数返回一个值,这个时候可以调用map 方法
  • 当我们想要去合并一个函数,但是这个函数返回一个函子,这个时候我们要用flatMap 方法

函数式编程总结

graph LR
函数式编程-->认识函数式编程
函数式编程-->函数相关复习
函数相关复习-->函数是一等公民
函数相关复习-->高阶函数
函数相关复习-->闭包
函数式编程-->函数式编程基础
函数式编程基础-->lodash
函数式编程基础-->纯函数
函数式编程基础-->柯里化
函数式编程基础-->管道
函数式编程基础-->函数组合
函数式编程-->函子
函子-->Functor
函子-->MayBe
函子-->Either
函子-->IO
函子-->Task
Task-->folktale
函子-->Monad

【参考文档】

以上内容源于拉勾教育《大前端高薪训练营》

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值