JavaScript 函数式编程

我心飞翔 分类:javascript

函数式编程的认识

前端小菜鸡一枚,第一次写文章分享自己所学的知识,也想记录自己学习的点滴。如有写的不对的地方欢迎指出哈~ 如果文章的内容对你有所帮助麻烦给个赞哈。作为一名人前端小白,自己FP函数的理解肯定不能够完全到位,后续如果对函数式编程又新的见解会持续更新该文章的~

前言:
我们为什么要学习函数式编程?
现在前端的主流框架 React,Vue3 都是函数式编程,这使我们不得不对函数式编程有学习。


既然说到函数式编程是一种编程范式,这也就意味着不他像是一种框架,你只要认真学完之后就能学会。读完这篇文章可能更多的是给你带来的收益是一种启发。函数式编程是需要我们不断的在工作中将知识进行实践,需要时间的沉淀与积累。

函数式编程是编程中的一种范式,与面向对象这种类型编程的风格(思想)是并列关系。
(编程范式: 编程思想或是说是编程中的一种编程风格)

  • 函数式编程:将现实世界中的事物与事物之间的 联系 抽象到程序世界(抽象其运算过程)
    • 程序的本质:根据输入通过某种运算获得相应的输出,程序开发过程中会涉及很多有输入和输出的函数
    • 函数式编程的函数是指数学中的函数例如:** y=sin(x)** ,x和y的映射关系
    • x->f(联系、映射)->y,y=f(x)
// 求工资的例子  基础工资 + 绩效工资
function add(a,b) {
	return a + b
}
 

函数式编程前置知识

函数是一等公民(First-class Function)

引用mdn中对函数是一等公民的定义:
当一门编程语言的函数可以被当作变量一样用时,则称这门语言拥有头等函数。例如,在这门语言中,函数可以被当作参数传递给其他函数,可以作为另一个函数的返回值,还可以被赋值给一个变量。

MDN 函数是一等公民

// 函数(本身就是对象)像对象使用的例子
// 不论我们以哪种方式声明函数,其都是 Function 的实例
//  可以通过 new 关键字来声明 (ps: 在js高级程序设计中讲到不建议这样声明函数)
const fn = new Function()
console.log(fn) // [Function: anonymous]
// 像对象一样拥有自己的实例属性
const sum = (a, b) => a + b
console.log(sum.length) // 2
// 可以存储到数组对象中
const obj = {
sum
}
const arr = [sum]
console.log(obj.sum(1, 2)) // 3
console.log(arr[0](3, 5)) // 8
// 作为参数
const callback = (pre,cur) => pre + cur
console.log([1,2,3,4].reduce(callback,0))
// 作为返回值
function doOnce(fn) {
let isDone = false
return function (money) {
if (isDone) return
isDone = true
fn(money)
}
}
const pay = doOnce(money => {
console.log(`支付了${money}$`)
})
// 多次调用pay仅执行一次打印 支付了100$
pay(100)
pay(100)
pay(100)

总结为:

  • 函数可以作为变量使用
  • 函数可以作为另一个函数的参数
  • 函数可以作为返回值

当我第一次得知这个概念的时候,非常困惑不知为什么称函数为一等公民?函数其实跟跟普通对象的功能一样嘛,唯一不同的是它可以调用(可,额,这就可以成为一等公民 ???)

不过也不用太纠结,只是个概念而已。我们只需要记住上面总结的话就行了,这是接下来我们要学习 高阶函数和函数柯里化的基础。

高阶函数

高阶函数听起来很高大上有没有~ 但是我们在日常开发中经常会用到。比如数组中常用的方法: forEach,map,filter,find,some等等,这些都是属于高阶函数。

高阶函数的定义:

  • 函数可以作为令一个函数的参数
  • 函数可以作为另外一个函数的返回值

工作中常用到的高阶函数有:

  • forEach
  • map
  • filter
  • every
  • some
  • find/findIndex
  • reduce
  • sort
  • .....

使用高阶函数的意义:

  • 屏蔽细节,只关注目标
  • 将通用性问题进行抽象
const arr1 = [1, 2, 3, 4]
const arr2 = [11, 12, 13, 14]
// 现在我们有两个需求,找出第一个数组中大于2的数,以及第二个数组中小于13的数,你会怎么做?
// 面向过程式的编程
// 第一个需求
let res1 = []
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] > 2) {
res.push(arr[i])
}
}
// 第二个需求
let res2 = []
for (let i = 0; i < arr2.length; i++) {
if (arr2[i] < 13) {
res2.push(arr[i])
}
}
// 使用高阶函数
function myFilter(arr, fn) {
let res = []
for (let i = 0; i < arr.length; i++) {
if (fn(arr[i], i, arr)) {
res.push(arr[i])
}
}
return res
}
const res1 = myFilter(arr1, itme => item > 2)
const res2 = myFilter(arr2, item => item < 13)
// 1. 对比面向过程式的实现需求,使用高阶函数可以帮助我们屏蔽细节(如使用for循环),只关注我们的目标(拿到arr1数组大于2的数).
// 2. 可以抽象通用性问题,如当前这个例子 过滤数组中的元素。

闭包

挖个坑,笔者接下来会写有关闭包的博客,请大家持续关注。

函数式编程基础

纯函数

纯函数的概念:相同输入永远会得到相同的输出,并且没有任何可观察的副作用
相同输入与相同输出是指相同的输入x永远会得到相同的输出y。y=sin(x)
在这里插入图片描述

// 数组的 slice 与 splice 方法
const arr = [1, 2, 3, 4, 5]
// 纯函数
// slice 方法 就是纯函数,无论多少次调用,只要输入的指相同,永远会得到相同的输出。
console.log(arr.slice(0, 3)) // [ 1, 2, 3 ]
console.log(arr.slice(0, 3)) // [ 1, 2, 3 ]
console.log(arr.slice(0, 3)) // [ 1, 2, 3 ]
// 不纯的函数
// splice方法
console.log(arr.splice(0, 2)) // [ 1 , 2 ]
console.log(arr.splice(0, 2)) // [ 3 , 4 ]
console.log(arr.splice(0, 2)) // [ 5 ]

纯函数的好处

  1. 可以缓存运算结果。
// 
// lodash中memoize方法分析:
// memoize函数接受一个fn作为参数,返回一个新的函数
// 利用闭包特性对调用的结果进行缓存,如果传递进来的参数有被缓存过,就返回调用的结果。如果没有被缓存过,就执行调用方法。
function memoize(fn) {
let cache = {}
return function (...args) {
// 因为纯函数相同输入永远会得到相同的输出,将入参作为key值进行保存。
let key = JSON.stringify(args)
if (!cache[key]) {
cache[key] = fn(...args)
}
return cache[key]
}
}
const newGetAreaFn = memoize(getArea)
console.log(newGetAreaFn(4))
console.log(newGetAreaFn(4))
console.log(newGetAreaFn(4))
console.log(newGetAreaFn(5))
// console 如下:
// 234234
// 50.26548245743669
// 50.26548245743669
// 50.26548245743669
// 234234
// 78.53981633974483
  1. 纯函数不依赖外部环境的共享数据,可以任意作用域环境中调用。
  2. 纯函数让测试更为方便,只需要关注输入与输出。
  3. 更少的 Bug。

纯函数的副作用
所谓的纯函数的副作用就是纯函数内部有依赖外部状态,而外部状态的变更会导致函数变得不纯(可能会导致相同输入得不到相同的输出),可以看看下面这个例子。

let mini = 18
// 不纯的函数,其有很明显的副作用,其内部输出结果依赖外部的mini的状态。
function checkAge(age) {
return age >= mini
}
// 纯的
function checkAge(age) {
let mini = 18
return age >= mini
}

副作用可能包含,但不限于:

  • 更改文件系统,系统配置
  • 往数据库插入记录
  • 发送一个 http 请求
  • 可变数据
  • 获取用户输入
  • 访问系统状态
  • 等等

总结: 概括来讲,只要是跟函数外部环境发生的交互就都有可能带来副作用,副作用也是不可避免的。比如你的纯函数的输入值需要通过接口去请求拿到,后台接口返回的值会有各种可能,有可能是符合预期的目标值,也有可能是不符合预期的,不符合预期就会带来函数内部发生问题。对于副作用这不是说,要禁止使用一切副作用,而是说,要让它们在可控的范围内发生。

lodash 模块

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

柯里化

柯里化的概念:
当一个函数有多个参数,传递一部分参数去调用他(之后这部分参数不在变更),并让其返回一个新函数处理剩余参数

// curry 案例
const _ = require('lodash')
// 需求: 
// 通过函数柯里化的形式,根据不同条件过滤数组中的符合要求的元素
// 生成一个match柯里化函数
//  match() 方法检索返回一个字符串匹配正则表达式的结果(结果为数组,如果为匹配到返回null)。
const match = _.curry((reg, str) => {
return str.match(reg)
})
// 传递部分参数去调用他,返回一个函数去处理剩余参数
const haveSpace = match(/\s+/g)
const haveNumber = match(/\d+/g)
// console.log(haveSpace('hello world')) // ['']
// console.log(haveNumber('hello world')) // null
// 得到一个filter柯里化函数
const filter = _.curry((fn, arr) => {
return arr.filter(fn)
})
// console.log(filter(haveSpace, ['hello world', 'helloooooo'])) // [ 'hello world' ]
// 得到一个匹配数组中待空字符串的方法
const findSpace = filter(haveSpace)
console.log(findSpace(['helloMolly', 'reborn jiang']))

模拟lodash柯里化的实现

// 分析:
// 1. curry 接受一个函数callback,并返回一个函数。
// 2. 返回函数的入参的个数需要跟callback函数的参数个数进行比较,如果参数个数相等直接调用该函数,
// 如果返回函数的入参个数小于callback函数的个数就继续返回一个函数等待接受剩余参数,直等到
// 参数相等就调用该函数。
function myCurry(fn) {
return function curry(...args) {
// 1. 返回函数入参个数 与 fn函数进行比较
if (args.length < fn.length) {
// 3. 小于就继续返回函数等待待剩余参数
return function () {
// 4. 将第二个返回函数参数与第一个返回函数的参数拼接之后再次与fn的参数个数比较,如果还是小于此时就需要用到递归调用curry,直等到全部剩余参数都传递执行该函数。
let curTotalParmams = args.concat(Array.from(arguments))
return curTotalParmams.length < fn.length ? curry(...curTotalParmams) : fn.apply(this, curTotalParmams)
}
} else {
// 2. 返回函数的入参 可能大于或等于 fn的入参个数,就调用该函数
return fn.apply(this, args)
}
}
}
// 简化代码
function myCurry(fn) {
return function curryFn(...args) {
if (args.length < fn.length) {
return function () {
return curryFn(...args.concat(Array.from(arguments)))
}
}
return fn(...args)
}
}
// 测试一波
function add(a, b, c, d) {
return a + b + c + d
}
// 将 add 函数转换为curry函数
const curryAdd = myCurry(add)
console.log(curryAdd(1)(2)(3)(4)) // 10

通过上面curry案例 ,感觉通过curry的方式来实现需求返回增加了代码的复杂性,那么函数的curry的好处究竟是什么那?等看到函数组合的时候,你就会豁然开朗~ 下面先给出结论。

函数柯里化的好处

  • 通过curry可以将多元函数转换为一元函数,可以让函数变得更加的灵活,让函数的颗粒度更小。
  • 搭配"好搭档" compose函数可以组合功能更加强大的函数。

函数组合

函数组合概念:
如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。

  • 被组合的函数要求必须是纯函数和细颗粒度的函数。

下面举一个不那么恰当的例子以及加上图解的形式帮助大家更好的理解函数的组合。

  • 举例子

函数就像是工厂一般,函数的输入值是原材料,输出值是商品。原材料要经过需要道加工工序才能够得到最终的商品。每一道加工工序就是函数,将多个加工工序构成一条生产线。函数组合就是生产线。

// 伪代码
function factory(rawMaterials) {
// 输入原材料
// 面向过程
// 原材料加工
// 在此处对原材料进行n次加工之后
const res1 = process1(rawMaterials)
const res2 = process2(res1)
// .....
const product = process3(resN)
// 函数式
// 采用函数组合的形式
const productLine = compose(process1, process2, ..., processN)
const product = productLine(rawMaterials)
// 输出商品
return product
}
  • 下面放上图解说明(将函数比作为管道)

管道
下面这张图表示程序中使用函数处理数据的过程,给fn函数输入参数a,返回结果b。可以想想a数据通过一个管道得到了b数据。
在这里插入图片描述
当fn函数比较复杂的时候,我们可以把函数fn拆分成多个小函数,此时多了中间运算过程产生的m和n。下面这张图中可以想象成把fn这个管道拆分成了3个管道f1,f2,f3,数据a通过管道f3得到结果m,m再通过管道f2得到结果n,n通过管道f1得到最终结果b在这里插入图片描述
在这里插入图片描述

lodash中compose函数的应用:

  • 的odash中组合函数flow()或者flowRight(),他们都可以组合多个函数。
  • flow()是从左到右运行
  • flowRight()是从右到左运行,使用的更多一些
const fp = require('lodash/fp')
// 需求: 取数组的最后一个元素转换为大写
let arr = ['rebornjiang', 'helloworld']
function reverse(arr) {
return fp.reverse(arr)
}
function getFirstEl(arr) {
return arr[0]
}
function upperCase(str) {
return fp.upperCase(str)
}
// 上面得到三个纯函数, 使用lodash中提供的函数组合方法
// flow && flowRight 都能得到相同的结果
// const getUpperCaseFromArr = fp.flow(reverse,getFirstEl, upperCase)
const getUpperCaseFromArr = fp.flowRight(upperCase,getFirstEl, reverse)
console.log(getUpperCaseFromArr(arr)) // HELLOWORLD

实现自己的函数组合方法

const fp = require('lodash/fp')
let arr = ['rebornjiang', 'helloworld']
function reverse(arr) {
return fp.reverse(arr)
}
function getFirstEl(arr) {
return arr[0]
}
function upperCase(str) {
return fp.upperCase(str)
}
// 分析:
// compose 接受多个函数,并返回一个组合函数。
// 对组合函数进行调用时,会对每一个函数进行依次调用,将上一个函数的返回值作为下一个函数的入参,直等到最后一个函数执行结束之后返回结果
function compose(...args) {
return function (val) {
// 模拟flowRight,反转数组。
return args.reverse().reduce((accumulator, curFn) => {
return curFn(accumulator)
}, val)
}
}
const getResult = compose(upperCase, getFirstEl, reverse)
console.log(getResult(arr)) // HELLOWORLD

函数组合需要满足结合律
函数组合的结合律按照小学数学中乘法的结合律来理解(三个数相乘,先把前两个数相乘,再和另外一个数相乘,或先把后两个数相乘,再和另外一个数相乘,积不变,叫做乘法结合律。)

// 以上面的例子来说:
// 将后面两个方法再次组合,所得到结果是相同的,这就是函数的组合的结合律
const getResult = compose(compose(upperCase, getFirstEl), reverse)
console.log(getResult(arr)) // HELLOWORLD

函数组合的调试
函数组合之后,又该怎么调试那?

// 利用 trace 柯里化函数
const trace = fp.curry((msg, val) => {
console.log(msg, val)
return val
})
const getResult = compose(compose(upperCase, getFirstEl), trace('reverse之后'), reverse)
console.log(getResult(arr)) 
// reverse之后 [ 'helloworld', 'rebornjiang' ]
// HELLOWORLD

函子

个人对函子的理解是非常片面的,查询了别的资料还是蒙的。函子相比较与函数柯里化,函数组合,PointFree编程风格 在实际项目中容易实际的用到开发中,函子难啊。个人就不浪费这上面花太多时间了,等待后续个人有新的见解在来更新。

为什么要使用函子?
将函数式编程中的副作用控制在可控的范围内、异常处理、异步操作等。

什么是函子?
函子就像是一个盒子(对象),是为了将值控制在可控的范围内。该对象有一个map方法,map方法接受一个函数对值进行处理。

// 一个简单的函子,要操作值通过map方法来进行操作。
class Container {
constructor(value) {
this._value = value
}
map(fn) {
return new Container(fn(this._value))
}
}
console.log(new Container(3).map(x => x+2)) // Container { _value: 5 }

在这里插入图片描述

总结

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

MayBe函子
MayBe函子目的就是为了解决函数式编程中因外部传递空值而导致的异常错误的出现,换句话说将副作用控制在允许的范围。

// maybe 函子
class MayBe {
// 隐式new一个对象,避免跟面向对象编程风格混淆。
static of(val) {
return new MayBe(val)
}
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
}
}
// console.log(Container.of(null).map(item => item.toUpperCase())) // 异常
console.log(MayBe.of(null).map(item => item.toUpperCase)) // MayBe { _value: null }

Either函子
Either两者其一,类似 if..else 。
Either目的是为了做代码异常的处理。

// 需求: 拦截 JSON.parse()解析非json字符串的错误
class Left {
static of(value) {
return new Left(value)
}
constructor(value) {
this._value = value
}
map(fn) {
return this
}
}
class Right {
static of(val) {
return new Right(val)
}
constructor(value) {
this._value = value
}
map(fn) {
return Right.of(fn(this._value))
}
}
function parseJson(json) {
try {
return Right.of(JSON.parse(json))
} catch (err) {
return Left.of({ error: err })
}
}
// console.log(parseJson({name: 323})) // 捕获了异常
console.log(parseJson('{"name":"reborn"}')) // Right { _value: { name: 'reborn' } }

IO函子
IO函子的特点如下:

  • this_value 保存的是一个函数,把函子的值放在函数内返回。这样做的目的可以延迟执行这个函数,把不纯的操作交给函数的调用者处理,这样可以保证当前的是纯的操作。
const fp = require('loadsh/fp')
// IO 函子
class IO {
static of(value) {
return new IO(function () {
return value
})
}
constructor(fn) {
this._value = fn
}
// 注意IO函子map返回的IO函子实例不是通过of来隐式创建,直接通过new 关键字创建的。
// 通过of 会有多层函数嵌套的问题。
map(fn) {
return new IO(fp.flowRight(fn, this._value))
}
}
// 调用
const io = IO.of(process).map(item => process.execPath)
console.log(io._value())

Monad函子

  • Monad 函子是可以变扁的 Pointed 函子(带有of方法的就是Pointed函子),为了解决 IO(IO(x)) 函子嵌套的问题。
  • 一个函子如果具有 join 和 of 两个方法并遵守一些定律就是一个 Monad
const fp = require('loadsh/fp')
const fs = require('fs')
// Monad 函子
// 需求:读取package.json 文件并打印
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))
}
// 新增IO函子的原型方法
join() {
return this._value()
}
// 调用map与join方法 
flatMap(fn) {
return this.map(fn).join()
}
}
// 调用
function readFile(path) {
return new IO(function () {
return fs.readFileSync(path, 'utf-8')
})
}
function print(val) {
return new IO(function () {
console.log(val)
return val
})
}
// const readFileAndPrint = fp.flowRight(print, readFile)
// 在调用readFileAndPrint之后会将readFile的方法返回的包含读取操作的IO函子作为参数传递给到print方法
// print 方法又返回一个IO函子,该函子的_value中的方法又返回读取操作的IO函子,会多一层嵌套关系。
// console.log(readFileAndPrint('package.json')._value()._value())
// 使用monad函子可以解决IO函子嵌套问题,看如下代码
// 解析: 
// 调用readFile的时候返回了一个包含读取文件操作的IO函子,调用map方法将读取文件的操作与转大写的操作进行
// 函数组合之后返回一个新的IO函子,调用flatMap将函子print方法与组合的方法的函子再次进行组合得到一个新的
// 函子其包含了读文件&转大写&打印,flatMap中join方法执行之后组合函数执行得到了print方法返回的IO函子,
// 之后调用join来打印转过大写的文件内容
const res = readFile('package.json').map(fp.toUpper).flatMap(print).join()
console.log(res)

Task函子
Task函子目的是为了处理异步操作
folktale 中的 Task 来演示

const { task } = require('folktale/concurrency/task')
function readFile(filename) {
return task(resolver => {
fs.readFile(filename, 'utf-8', (err, data) => {
if (err) resolver.reject(err)
resolver.resolve(data)
})
})
}
// 调用 run 执行
readFile('package.json')
.map(split('\n'))
.map(find(x => x.includes('version')))
.run().listen({
onRejected: err => {
console.log(err)
},
onResolved: value => {
console.log(value)
}
})

参考资料:
函数式编程指南
网易云音乐团队函数式编程的文章
阮一峰老师函数式编程入门
阮一峰老师PointFree编程风格
阮一峰老师图解Monad

回复

我来回复
  • 暂无回复内容