浅析js中的纯函数、高阶函数、记忆函数、偏函数
上周分享文档中遇到几个关键名称,纯函数、高阶函数、记忆函数、偏函数....,这里做一下解析与举例
纯函数
简介
纯函数是函数式编程中非常重要的一个概念,简单来说,就是一个函数的返回结果只依赖于它的参数,并且在执行过程中没有副作用,我们就把这个函数叫做纯函数
一个函数,如果符合以下两个特点,那么它就可以称之为纯函数
-
对于相同的输入,永远得到相同的输出(函数的返回结果只依赖于它的参数)
-
没有任何可观察到的副作用(函数执行过程中没有副作用)
解析--函数的返回结果只依赖于它的参数
let greeting = 'Hello' function greet (name) { return greeting + ' ' + name } console.log(greet('World')) // Hello World
function greet (greeting, name) { return greeting + ' ' + name } console.log(greet('Hi', 'Savo')) // Hi Savo
没有副作用的意思是,这个函数的运行,不会修改外部的状态,即不会污染外部作用域
const user = { name: 'Nick Brown' } let flag = false function checkData (user) { if (user.name.length > 4) { flag = true } }
由于定义的函数改变的值在函数作用域之外,导致这个函数成为“不纯”的函数
const user = { name: 'Nick Brown' } function checkData (user) { return user.name.length > 4 } const flag = checkData(user)
除了修改外部的变量,一个函数在执行过程中还有很多方式产生外部可观察的变化,比如说调用 DOM API 修改页面,或者你发送了 Ajax 请求,还有调用 window.reload
刷新浏览器,甚至是 console.log
往控制台打印数据也是副作用
纯函数很严格,也就是说你几乎除了计算数据以外什么都不能干,计算的时候还不能依赖除了函数参数以外的数据
再来看看JavaScript中常用的两个方法slice和splice
const arr1 = [0, 1, 2, 3, 4, 5, 6] const arr2 = [0, 1, 2, 3, 4, 5 ,6] const spliceArr = arr1.splice(0, 2) const sliceArr = arr2.slice(0, 2) console.log('arr1: ', arr1) console.log('spliceArray: ', spliceArr) console.log('arr2: ', arr2) console.log('sliceArray: ', sliceArr) // 执行结果 array1: 2,3,4,5,6 spliceArray: 0,1 array2: 0,1,2,3,4,5,6 sliceArray: 0,1
可以看到,slice和splice的作用是大致相同的,但是splice改变了原数组,而slice却没有,实际开发中,slice这种不改变原数组的方式更安全一些,改变原始数组,是一种副作用
/** * Create a cached version of a pure function. * 只适用于缓存 接收一个字符串为参数的 fn */ export function cached (fn) { const cache = Object.create(null) return function cachedFn (str) { const hit = cache[str] return hit || (cache[str] = fn(str)) } } /** * Capitalize a string. */ export const capitalize = cached((str) => { return str.charAt(0).toUpperCase() + str.slice(1) })
-
纯函数仅依赖于传入的参数,这意味着你可以随意将这个函数移植到别的代码中,只需要提供踏需要的参数即可。如果是非纯函数,有可能你需要一根香蕉,却需要将整个香蕉树搬过去
-
可测试性
纯函数非常容易进行单元测试,因为不需要考虑上下文环境,只需要考虑输入和输出
-
并行代码
英文叫Higher-order function。JavaScript的函数其实都指向某个变量。既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数
特点
-
函数可以作为参数传递(接受一个或多个函数作为输入)
-
函数可以作为返回值输出(输出一个函数)
解析
可以将高阶函数理解为函数之上的函数,它很常用,比如常见的回调函数
-
在ajax异步请求的过程中,回调函数使用的非常频繁
-
在不确定请求返回的时间时,将callback回调函数当成参数传入
-
待请求完成后执行callback函数
const getData = function(url, callback) { ajax.get(url, function(data){ callback(data) }) }
比如 防抖函数(debounce)与节流函数(throttle)
比如一个输入框接受用户不断输入,输入结束后才开始搜索
以页面滚动作为例子,可以定义一个防抖函数,接受一个自定义的 delay值,作为判断停止的时间标识
// 这个是用来获取当前时间戳的 function now() { return +new Date() } /** * 防抖函数,返回函数连续调用时,空闲时间必须大于或等于 wait,func 才会执行 * * @param {function} func 回调函数 * @param {number} wait 表示时间窗口的间隔 * @param {boolean} immediate 设置为ture时,是否立即调用函数 * @return {function} 返回客户调用函数 */ function debounce (func, wait = 50, immediate = true) { let timer, context, args // 延迟执行函数 const later = () => setTimeout(() => { // 延迟函数执行完毕,清空缓存的定时器序号 timer = null // 延迟执行的情况下,函数会在延迟函数中执行 // 使用到之前缓存的参数和上下文 if (!immediate) { func.apply(context, args) context = args = null } }, wait) // 这里返回的函数是每次实际调用的函数 return function(...params) { // 如果没有创建延迟执行函数(later),就创建一个 if (!timer) { timer = later() // 如果是立即执行,调用函数 // 否则缓存参数和调用上下文 if (immediate) { func.apply(this, params) } else { context = this args = params } // 如果已有延迟执行函数(later),调用的时候清除原来的并重新设定一个 // 这样做延迟函数会重新计时 } else { clearTimeout(timer) timer = later() } } }
记忆化(memoization)是一种构建函数的处理过程,能够记住上次计算结果的函数。在实现中,可以这样进行处理:当函数计算得到结果时,就将该结果按照参数存储起来。采取这种方式时,如果另外一个调用也使用相同的参数,我们则可以直接返回上次存储的结果而不是再计算一遍
显而易见,像这样避免既重复又复杂的计算可以显著提高性能。对于动画中的计算、搜索不经常变化的数据或任何耗时的数学计算来说,记忆化这种方式是十分有用的
实例分析
// 自记忆素数检测函数 function isPrime (value) { // 创建缓存 if (!isPrime.answers) { isPrime.answers = {}; } // 检查缓存的值 if (isPrime.answers[value] !== undefined) { return isPrime.answers[value]; } // 0和1不是素数 var prime = value !== 0 && value !== 1; // 检查是否为素数 for (var i = 2; i < value; i++) { if (value % i === 0) { prime = false; break; } } // 存储计算值 return isPrime.answers[value] = prime }
isPrime函数是一个自记忆素数检测函数,每当它被调用时
-
首先,检查它的answers属性来确认是否已经有自记忆的缓存,如果没有,创建一个
-
接下来,检查参数之前是否已经被缓存过,如果在缓存中找到该值,直接返回缓存的结果
-
如果参数是一个全新的值,进行正常的素数检测
-
优点
-
由于函数调用时会寻找之前调用所得到的值,所以用户最终会乐于看到所获得的性能收益
-
它不需要执行任何特殊请求,也不需要做任何额外初始化,就能顺利进行工作
缺陷
-
任何类型的缓存都必然会为性能牺牲内存
-
缓存逻辑不应该和业务逻辑混合,一个方法只需要把一件事情做好
-
对记忆函数很难做负载测试或估算算法复杂度,因为结果依赖于函数之前的输入
偏函数
简介
偏函数属于函数式编程的一部分,使用偏函数可以通过有效地“冻结”那些预先确定的参数,来缓存函数参数,然后在运行时,当获得需要的剩余参数后,可以将他们解冻,传递到最终的参数中,从而使用最终确定的所有参数去调用函数
简而言之,偏函数是 JS 函数柯里化运算的一种特定应用场景,就是把一个函数的某些参数先固化,也就是设置默认值,返回一个新的函数,在新函数中继续接收剩余参数,这样调用这个新函数会更简单
常见场景试例
将一些函数组合封装到一个函数中,调用时可以按顺序实现全部功能
function toUpperCase(str) { return str.toUpperCase() // 将字符串变成大写 } function add(str) { return str + '!!!' // 将字符串拼接 } function split(str) { return str.split('') // 将字符串拆分为数组 } function reverse(arr) { return arr.reverse() // 将数组逆序 } function join(arr) { return arr.join('-') // 将数组按'-'拼接成字符串 } function compose() { const args = Array.prototype.slice.call(arguments) // 类数组转换为数组 const len = args.length - 1 // 最后一个参数的索引 return function(x) { let result = args[len](x) // 执行最后一个函数的结果 while(len--) { result = args[len](result) // 执行每个函数的结果 } return result } } const f = compose(add, join, reverse, split, toUpperCase) console.log( f('cba') ) // A-B-C!!!
在组合函数 compose 中,依次执行 toUpperCase、split、reverse、join、add 实现全部功能
当然有更优雅的写法,通过数组自带的方法实现
const compose2 = (...args) => x => args.reduceRight((result, cb) => cb(res), x) const f = compose2(add, join, reverse, split, toUpperCase) console.log( f('cba') ) // A-B-C!!!