缓存与记忆函数 memoize 的本质

1,620 阅读9分钟

我正在参加「掘金·启航计划」

前言

记忆函数,即拥有缓存能力的函数。

对于普通计算函数,更多的是通过空间换时间,在大量复杂计算场景下有一定的优势(长递归或长迭代操作)

简单看个例子

function add(a, b) {
    return a + b;
}

// 假设 memoize 可以实现函数记忆
var memoizedAdd = memoize(add);

memoizedAdd(1, 2) // 3
memoizedAdd(1, 2) // 相同的参数,第二次调用时,从缓存中取出数据,而非重新计算一次

分析

  • 定义:函数记忆是指将上次的计算结果缓存起来,当下次调用时,如果遇到相同的参数,就直接返回缓存中的数据。
  • 原理:实现这样一个 memoize 函数很简单,原理上只用把参数和对应的结果数据存到一个对象中,调用时,判断参数对应的数据是否存在,存在就返回对应的结果数据。

其实看上去有点像高阶函数,我们的 memoize函数接收一个函数作为参数,并在内部对函数参数做了一些处理,如果参数相同我们直接返回缓存的结果,否者就执行一次该函数,并把这次结果放入缓存对象中,最后再返回处理后的函数。

不过,联想使用 React.mome 时,该函数是可以接收第二个参数的,其作用是自定义比较方法来决定是否更新或使用缓存,之后我们也可以提供这个能力供用户自定义使用。

这里我们把 add函数memoize处理后给 memoizedAdd 作为新的函数,此时该函数是具有了缓存能力了的,就使用而言我们正常使用即可,不过,目前同一函数是只会缓存最后一次的运行结果的

思考函数参数、运行结果和 唯一key值的处理。

简单尝试

根据以上思路,我们尝试实现一个 memoize函数,主要考虑 缓存对象与参数判断

例:一个简单的乘法运算

function getSquare(x) {
  return x * x
}

实现缓存能力

// 第一步:添加memo缓存对象
const memo = {}

// 第二步:参数与key处理
function getSquare(x) {
  // 存在直接返回
  if(memo[x]){
      return memo[x]
  }

  // 不存在,先缓存,再返回
  memo[x] = x * x

  return memo[x]
}

memo[x] 的判断可以优化成 memo.hasOwnProperty(x)这种写法,排除值为 undefined的情况

小露一手

首先,函数参数是存在多个的情况,key值的处理需要调整下,最后封装抽象出 memoize函数方便使用

例:一个除法运算

const getDivision = (a, b) => a / b

目标期望

const getDivision = (a, b) => a / b
const getKey = (a, b) => `${a}_${b}` // 自定义 key

const memoGetDivision = memoize(getDivision, getKey)

memoGetDivision(4, 2) // 2
memoGetDivision(4, 2) // 2 不在计算,直接返回结果

两个或多个参数时,需要组合所有参数生成一个唯一key,如:key =${a}_${b},不过,如果是两数相乘 a * b 参数顺序调整为 b * a时考虑判断写法的处理,但就当前来说我们无法预判用户传入的函数与其参数是否会受顺序影响,因此,该情况最好交于使用者自行编写函数判断

针对memoize函数的封装,我们接收两个参数,并返回一个匿名函数

  • 参数一:接收一个函数,该函数的参数不确定
  • 参数二:接收一个是否使用缓存的判断函数

memoize函数实现

function memoize(fn, getKey) {
    // 缓存对象
    const memo = {}

    // 返回匿名函数
    // 包装传入的函数fn后,返回的记忆函数memoGetDivision
    return function memoized(...args) { // ...args => 4, 2
        // 得到 key
        const key = getKey(...args)

        // 有无缓存判断
        if (memo.hasOwnProperty(key)) {
            return memo[key]
        }

        // 通过使用 apply执行fn函数,获取到值,并保存在 memo中
        memo[key] = fn.apply(this, args) // args => [4, 2]

        // 返回 值
        return memo[key]
    }
}

// memo { '4_2': 2 }

异步函数:事件回调

几点思考:

  1. 异步函数的几种形式:callback事件回调、promise
  2. 异步函数的执行结果不是立即返回的
  3. 多个异步函数同时执行时的情况处理
  4. 队列

思路:

当一个异步函数被多次调用时,以 key为唯一标识符(即参数相同),全部放入到队列中,当任意一个接收到返回值时,保存值到缓存、通知队列中所有,并清空队列

queues[key] = [callback1, callback2, callback3]

假设一个异步函数 expensiveOperation会在执行后 1000ms后返回通知,先看下事件回调callback形式的

// 异步回调函数
expensiveOperation(args, (data) => {
  // Do something
})
  • 添加 memoize 后
const memo = {}
function memoExpensiveOperation(key, callback) {
  if (memo.hasOwnProperty(key)) {
    callback(memo[key])
    return
  }

  expensiveOperation(key, (data) => {
    memo[key] = data
    callback(data)
  })
}

存在问题:多次执行时,第一次的结果可能还未返回,expensiveOperation函数会被多次执行

如何处理:当data数据还未返回时,新增的 expensiveOperation函数不再加入到执行环境中,直接放入到队列,确保相同异步函数只有一个在执行等待响应,所有新加入的expensiveOperation函数统一等第一个执行的结果通知

  • 优化后写法
const memo = {} // 缓存对象
const progressQueues = {} // 运行队列

function memoExpensiveOperation(key, callback) {
  if (memo.hasOwnProperty(key)) {
    callback(memo[key])
    return
  }

  if (!progressQueues.hasOwnProperty(key)) {
    // key不存在时,以 key为标识增加一个新的队列
    progressQueues[key] = [callback]
  } else {
    // key存在时,加入到队列,然后退出
    progressQueues[key].push(callback)
    // 直接退出 不再继续往下执行 expensiveOperation函数,即 expensiveOperation函数只会执行一次
    return
  }

  expensiveOperation(key, (data) => {
    // 缓存结果
    memo[key] = data
    // 通知所有队列
    for (let callback of progressQueues[key]) {
      callback(data)
    }
    // 清除队列
    delete progressQueue[key]
  })
}

以上 memo用于缓存结果,progressQueues用于保存函数队列,并通过 key确保只有一个执行函数在运行,其余加入队列统一接收执行函数的结果

  • 示例如下,封装下 memoizeAsync函数
expensiveOperation(key, (data) => {
  // Do something
})

const memoExpensiveOperation = memoizeAsync(expensiveOperation, (key) => key)
function memoizeAsync(fn, getKey) {
  const memo = {}
  const progressQueues = {}

  return function memoized(...allArgs) {
    // 得到函数的 事件回调callback函数
    const callback = allArgs[allArgs.length - 1]
    // 拿到全部参数
    const args = allArgs.slice(0, -1)
    // 自定义 key
    const key = getKey(...args)

    if (memo.hasOwnProperty(key)) {
      callback(key)
      return
    }

    if (!progressQueues.hasOwnProperty(key)) {
      progressQueues[key] = [callback]
    } else {
      progressQueues[key].push(callback)
      return
    }

    // 注意:这里我们使用 call代替了 apply执行函数
    // 因为需要在末尾传递一个回调函数(原本函数结构),所以不使用apply数组传递而是换成参数罗列call的形式
    fn.call(this, ...args, (data) => {
      // memoize result
      memo[key] = data
      // process all the enqueued items after it's done
      for (let callback of progressQueues[key]) {
        callback(data)
      }
      // clean up progressQueues
      delete progressQueue[key]
    })
  }
}

主要是把变量对象放到了函数执行环境内部,以闭包的形式完成隔离,然后 fn.apply替换为 fn.call

异步函数:promise

对于 promise函数,考虑整体以 promise包装处理,并返回 promise

内部匿名函数调整为 new Promise((resolve, reject) => {}),以resolvereject返回执行状态结果值

假设有一个异步函数 fetchData(args),缓存能力实现示例

const memo = {}
const progressQueues = {}

function memoizePromise(fn, getKey) {
    return new Promise((resolve, reject) => {
      // 存在缓存,以 resolve 返回结果值
      if (memo.hasOwnProperty(key)) {
        resolve(memo[key])
        return
      }

      // 队列
      // 考虑成功/异常,以 [resolve, reject] 为一组放入队列
      if (!progressQueues.hasOwnProperty(key)) {
        progressQueues[key] = [[resolve, reject]]
      } else {
        progressQueues[key].push([resolve, reject])
        return
      }

      // 执行异步函数 fetchData
      fetchData(key)
        .then((data) => {
          memo[key] = data // 缓存 data
          // 成功队列通知
          for (let [resolver] of progressQueues[key]) resolver(data)
        })
        .catch((error) => {
          // 失败队列通知
          for (let [, rejector] of progressQueues[key]) rejector(error)
        })
        .finally(() => {
          // 清除队列
          delete progressQueues[key]
        })
    })
}

针对promise的缓存封装可以查看库 p-memoize

lodash.memoize、memoize-one等第三方库对记忆函数的实现分析

lodash.memoize

源码如下,写法比较简洁灵巧,逻辑类似

同样提供两个参数 func-原始函数本身,resolver 自定义key的执行函数

/**
 * @param {Function} func The function to have its output memoized.
 * @param {Function} [resolver] The function to resolve the cache key.
 * @returns {Function} Returns the new memoized function.
 */

function memoize(func, resolver) {
  // 异常过滤
  if (typeof func !== "function" || (resolver != null && typeof resolver !== "function")) {
    throw new TypeError("Expected a function")
  }

  // 返回函数 memoized
  const memoized = function (...args) {
    // 获取到 key:resolver存在则取函数执行结果,否则取参数第一位
    const key = resolver ? resolver.apply(this, args) : args[0]

    // 缓存判断
    // 该写法是直接把缓存对象挂载到函数对象属性cache上
    const cache = memoized.cache
    if (cache.has(key)) {
      return cache.get(key)
    }

    // 执行函数得到 result
    const result = func.apply(this, args)

    // 保存到缓存对象 cache 上
    memoized.cache = cache.set(key, result) || cache
    return result
  }

  // 指定 cache实例,默认为 Map
  memoized.cache = new (memoize.Cache || Map)()
  return memoized
}

// 可指定 缓存对象实例
// 例如:替换 memoize.Cache = WeakMap;
memoize.Cache = Map

export default memoize

lodash的memoize方法其实并不能很好的满足我们对于异步结果的期待,他可以缓存同步函数的执行结果,但是对于异步,因为执行时机和返回结果的不确定性,使得我们并不能直接做到对异步函数执行结果的缓存(参考上面的异步函数写法说明),因此,你可以做的其实是对promise异步函数本身的缓存,如下:

  • 简单场景举例,把得到的结果缓存
const usersCache = new Map();

const getUserById = async (userId) => {
  if (!usersCache.has(userId)) {
    const user = await request.get(`/api/user/${userId}`)
    usersCache.set(userId, user);
  }

  return usersCache.get(userId);
}
  • 并发场景举例,异步函数在第一个函数结果返回之后,才开始缓存,当有多个请求的时候,缓存结果的思路就行不通了。一个取巧的方法是直接缓存promise函数,而不是结果
// await Promise.all([
//   getUserById('user1'),
//   getUserById('user2')
// ]);

const userPromisesCache = new Map();

const getUserById = (userId) => {
  if (!userPromisesCache.has(userId)) {
    // 注意这里是没有使用 async await 写法的
    const userPromise = request.get(`/api/user/${userId}`)
    userPromisesCache.set(userId, userPromise);
  }

  return userPromisesCache.get(userId)!;
};

// lodash.memoize 写法
const getUserById = async (useId) => {
  const user = await request.get(`/api/user/${userId}`)
  return user
}

lodash.memoize(getUserById)

这种写法弊端很明显的,包括执行失败的的异常处理,这里推荐使用 memoizee,他可以满足对promise的支持,有兴趣的可以看看源码进一步研究下。

import memoize from 'memoizee';

const getUserById = async (useId) => {
  const user = await request.get(`/api/user/${userId}`)
  return user
}

const getUserById = memoize(getUserById, { promise: true});

memoize-one

代码逻辑相当简洁了,可以看做lodash.memoize的另一种版本,同样是对异步的支持不友好

import areInputsEqual from './are-inputs-equal'; // 默认提供一个参数比较的函数

function memoizeOne(resultFn, isEqual = areInputsEqual) {
  let lastThis;
  let lastArgs = [];
  let lastResult;
  let calledOnce = false;

  // breaking cache when context (this) or arguments change
  function memoized(this, ...newArgs) {
    if (calledOnce && lastThis === this && isEqual(newArgs, lastArgs)) {
      return lastResult;
    }

    lastResult = resultFn.apply(this, newArgs);
    calledOnce = true;
    lastThis = this;
    lastArgs = newArgs;
    return lastResult;
  }

  return memoized ;
}

总结

首先,对于缓存功能的一个思路其实是很简洁明了,甚至两行代码就能实现基础版,但基于此的发散性思考是值得探究的~

不了解实现逻辑,没看源码之前犹如盲人摸象一般,看似很多东西觉得很难很复杂,其实当你开始去了解的时候,在读源码的过程中会豁然开朗,一切都清晰明了了

对于开源库的很多东西,其实都可以抱着学习的心态去从源码开始,不用把他们想的太复杂,你能从最原始的1.0版本去看,就能了解基本思路了,后续的东西更多的是对细节的处理,抛开细枝末节看整体,把握整体框架后再看细节~

总的来说如果需求不复杂,自己完全可以实现一个简易版的缓存类库,lodash.memoize好用,对于一般的函数值缓存够用,但是对于异步的支持并不好~

参考