前端小菜鸡一枚,第一次写文章分享自己所学的知识,也想记录自己学习的点滴。如有写的不对的地方欢迎指出哈~ 如果文章的内容对你有所帮助麻烦给个赞哈。作为一名人前端小白,自己FP函数的理解肯定不能够完全到位,后续如果对函数式编程又新的见解会持续更新该文章的~
前言:
我们为什么要学习函数式编程?
现在前端的主流框架 React,Vue3 都是函数式编程,这使我们不得不对函数式编程有学习。
既然说到函数式编程是一种编程范式,这也就意味着不他像是一种框架,你只要认真学完之后就能学会。读完这篇文章可能更多的是给你带来的收益是一种启发。函数式编程是需要我们不断的在工作中将知识进行实践,需要时间的沉淀与积累。
函数式编程是编程中的一种范式,与面向对象这种类型编程的风格(思想)是并列关系。
(编程范式: 编程思想或是说是编程中的一种编程风格)
// 求工资的例子 基础工资 + 绩效工资
function add(a,b) {
return a + b
}
引用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等等,这些都是属于高阶函数。
高阶函数的定义:
工作中常用到的高阶函数有:
使用高阶函数的意义:
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 ]
纯函数的好处
//
// 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
纯函数的副作用
所谓的纯函数的副作用就是纯函数内部有依赖外部状态,而外部状态的变更会导致函数变得不纯(可能会导致相同输入得不到相同的输出),可以看看下面这个例子。
let mini = 18
// 不纯的函数,其有很明显的副作用,其内部输出结果依赖外部的mini的状态。
function checkAge(age) {
return age >= mini
}
// 纯的
function checkAge(age) {
let mini = 18
return age >= mini
}
副作用可能包含,但不限于:
总结: 概括来讲,只要是跟函数外部环境发生的交互就都有可能带来副作用,副作用也是不可避免的。比如你的纯函数的输入值需要通过接口去请求拿到,后台接口返回的值会有各种可能,有可能是符合预期的目标值,也有可能是不符合预期的,不符合预期就会带来函数内部发生问题。对于副作用这不是说,要禁止使用一切副作用,而是说,要让它们在可控的范围内发生。
是一个纯函数的功能库,提供了对数组、数字、对象、字符串、函数等操作的一些方法
柯里化的概念:
当一个函数有多个参数,传递一部分参数去调用他(之后这部分参数不在变更),并让其返回一个新函数处理剩余参数
// 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的好处究竟是什么那?等看到函数组合的时候,你就会豁然开朗~ 下面先给出结论。
函数柯里化的好处
函数组合概念:
如果一个函数要经过多个函数处理才能得到最终值,这个时候可以把中间过程的函数合并成一个函数。
下面举一个不那么恰当的例子以及加上图解的形式帮助大家更好的理解函数的组合。
// 伪代码
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函数的应用:
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 }
总结
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函子的特点如下:
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函子
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