js 怎么用memoize创建记忆化函数

要创建一个记忆化函数,核心是通过缓存避免重复计算,提升性能。1. 实现记忆化需创建高阶函数,接收原函数并返回带缓存逻辑的包装函数;2. 使用 map 存储参数与结果的映射,通过 json.stringify(args) 生成键,但需注意属性顺序、循环引用等问题;3. 对于非原始类型参数,json.stringify 有局限,可采用自定义序列化、哈希函数或 weakmap 优化;4. 必须使用 apply 或 call 保留原函数 this 上下文;5. 记忆化适用于纯函数,如递归算法、昂贵计算、稳定 api 调用等场景;6. 需警惕副作用函数不适用、内存泄漏、缓存键不一致和缓存过期等问题。因此,记忆化是性能优化的有效手段,但需权衡适用性与开销,确保正确使用才能发挥其最大价值。

JavaScript 中,要创建一个记忆化(memoized)函数,核心思路就是给一个函数加上缓存层。这意味着当函数被调用时,它会先检查之前是否用相同的参数调用过。如果答案是肯定的,它就直接返回之前计算并存储的结果,而不是重新执行函数本身。这样能显著提升性能,尤其对于那些计算成本高昂或者会频繁重复调用的函数来说。

解决方案

实现一个记忆化函数,通常我们会创建一个高阶函数(higher-order function),它接受一个原始函数作为参数,然后返回一个包装了缓存逻辑的新函数。下面是一个基础的

memoize

实现:

function memoize(func) {
  const cache = new Map(); // 使用 Map 作为缓存,键可以是任何值,包括对象

  return function(...args) {
    // 将参数转换为一个唯一的键。这里简单地用 JSON.stringify,但要注意它的局限性
    const key = JSON.stringify(args);

    if (cache.has(key)) {
      console.log(`从缓存中获取结果: ${key}`);
      return cache.get(key);
    }

    // 如果缓存中没有,则调用原始函数,并存储结果
    console.log(`计算结果并存入缓存: ${key}`);
    const result = func.apply(this, args); // 确保原始函数的 this 上下文被保留
    cache.set(key, result);
    return result;
  };
}

// 示例:一个模拟耗时计算的函数
function expensiveCalculation(num1, num2) {
  console.log(`正在执行耗时计算: ${num1}, ${num2}...`);
  // 模拟耗时操作,例如复杂的数学运算或网络请求
  let sum = 0;
  for (let i = 0; i < 100000000; i++) {
    sum += Math.sqrt(i);
  }
  return num1 + num2 + sum;
}

// 创建记忆化版本的函数
const memoizedCalculation = memoize(expensiveCalculation);

console.log(memoizedCalculation(10, 20)); // 第一次调用,会执行计算
console.log(memoizedCalculation(10, 20)); // 第二次调用,直接从缓存获取
console.log(memoizedCalculation(5, 15));  // 第一次调用,会执行计算
console.log(memoizedCalculation(10, 20)); // 第三次调用,再次从缓存获取

这个

memoize

函数的核心就是那个

cache

Map。每次调用

memoizedCalculation

时,它会根据传入的参数生成一个

key

。如果这个

key

已经在

cache

里了,那太好了,直接把存好的结果拿出来用。否则,它就老老实实地调用原始的

expensiveCalculation

函数,把结果存进

cache

,然后返回。

记忆化函数在实际开发中为什么如此重要?

说实话,我个人在写代码的时候,遇到性能瓶颈,记忆化函数经常是我的“救火队员”之一。它的重要性主要体现在几个方面:

首先,最直观的就是性能提升。设想一下,你有一个递归函数,比如计算斐波那契数列,如果不用记忆化,每次都会重复计算大量的子问题。举个例子,计算

fib(5)

需要

fib(4)

fib(3)

,而

fib(4)

又需要

fib(3)

fib(2)

。你会发现

fib(3)

被算了不止一次。但如果记忆化了,

fib(3)

第一次算完存起来,后面再遇到就直接取结果,效率高得不是一点半点。

其次,它能减少不必要的资源消耗。这不仅仅是CPU时间,还可能是网络请求、数据库查询或者文件读写。比如一个函数需要从远程API获取数据,如果数据在短时间内不会变化,我们完全可以记忆化这个函数。这样,后续对相同参数的调用就不用再发起网络请求了,直接从内存里拿,响应速度自然就快了。这对于用户体验来说,简直是质的飞跃。

再者,对于纯函数来说,记忆化简直是天作之合。一个纯函数,只要输入相同,输出就必然相同,而且没有副作用。这种特性让记忆化变得非常安全和可靠。你不需要担心缓存的结果会因为外部状态的变化而变得不准确。所以,在设计函数时,如果能让它们保持纯粹,那么在需要优化性能时,记忆化就成了一个非常自然的选项。在我看来,它就是那种“小投入大回报”的优化手段。

记忆化函数有哪些常见的应用场景和需要注意的限制?

记忆化函数确实是个好东西,但它也不是万能药,有它擅长的领域,也有它力不从心的地方。

常见的应用场景:

  1. 递归算法优化:刚才提到的斐波那契数列就是经典案例。任何存在重叠子问题的递归算法,比如动态规划问题,都可以考虑用记忆化来避免重复计算,大大提升效率。
  2. 昂贵的计算:比如复杂的数学运算、图像处理中的像素计算、数据转换或解析等。如果这些计算是纯粹的,并且输入输出固定,那么记忆化能显著减少重复计算的开销。
  3. API调用或数据库查询:对于那些结果相对稳定,或者在一定时间内不会变化的远程数据获取,记忆化可以减少网络延迟和服务器负载。当然,这通常需要配合缓存过期策略。
  4. React等前端框架中的性能优化

    React.memo

    useMemo

    就是记忆化思想在组件和钩子层面的应用,它们帮助避免不必要的组件渲染和昂贵的计算,从而提升UI响应速度。

需要注意的限制:

  1. 副作用函数不适用:这是最重要的一点。如果你的函数有副作用(比如修改了全局变量、执行了I/O操作),那么记忆化它会带来问题。因为当你从缓存中取结果时,副作用并不会再次发生,这很可能导致意想不到的行为或者数据不一致。记忆化是为纯函数而生的。
  2. 内存消耗:缓存是需要占用内存的。如果你的函数会被大量不同的参数调用,或者参数本身就很复杂、很大,那么缓存可能会迅速膨胀,导致内存占用过高,甚至引发内存泄漏。这时候,你可能需要一个更智能的缓存策略,比如LRU(最近最少使用)缓存,来限制缓存的大小。
  3. 缓存键的复杂性:当函数的参数是对象、数组或其他非原始类型时,生成一个唯一的、稳定的缓存键会变得很复杂。简单地

    JSON.stringify

    有它的局限性(例如,属性顺序不同会导致键不同,无法处理循环引用,对函数和

    undefined

    的处理也特殊)。这可能导致缓存失效,或者生成不必要的重复缓存条目。

  4. 缓存失效问题:记忆化函数本身并不知道什么时候它的缓存结果“过期”了。如果函数依赖的外部数据发生了变化,但函数参数没变,那么记忆化函数仍然会返回旧的缓存结果。这意味着你可能需要手动清除缓存,或者实现一个更复杂的缓存过期机制。这往往是记忆化最头疼的地方。

总的来说,记忆化是把双刃剑。用得好,性能飞升;用不好,可能引入新的bug或者内存问题。在决定使用它之前,最好先评估一下函数的特性以及它可能带来的开销。

如何处理多参数和非原始类型参数的记忆化?

嗯,这确实是记忆化函数里比较“烧脑”的部分,尤其当参数不再是简单的数字或字符串时。我经常在这个地方纠结,因为处理不好就可能导致缓存失效或者性能反而下降。

1. 处理多参数:

对于多个原始类型参数,比如

func(a, b, c)

,最简单粗暴的方法就是把它们拼接成一个字符串作为键。比如

key =

${a}-${b}-${c}``。但这种方法要求参数的顺序和类型都非常固定。

更通用的做法,就像上面示例里用的

JSON.stringify(args)

。它能把一个参数数组转换为一个字符串。这对于原始类型数组和简单的对象数组通常是有效的。

// 简单拼接
const key = args.join('_'); // 适用于参数都是原始类型,且不含下划线等特殊字符的情况

// JSON.stringify
const key = JSON.stringify(args); // 更通用,但有局限性

2. 处理非原始类型参数(对象、数组、函数等):

这是真正的挑战所在。

  • JSON.stringify

    的局限性:

    • 属性顺序问题

      {a: 1, b: 2}

      {b: 2, a: 1}

      经过

      JSON.stringify

      后,字符串可能不同,导致它们被视为不同的键,即使它们在逻辑上是等价的。

    • 循环引用:如果对象中存在循环引用,

      JSON.stringify

      会报错。

    • 函数和

      undefined

      JSON.stringify

      会忽略对象中的函数属性和值为

      undefined

      的属性,这可能导致两个逻辑上不同的对象生成相同的键。

    • 性能开销:对于非常大的或复杂的对象,

      JSON.stringify

      本身就是一项耗时操作,这可能会抵消记忆化带来的性能提升。

  • 更高级的键生成策略:

    • 自定义序列化函数:你可以编写一个更智能的函数来将参数“规范化”为键。例如,可以对对象属性进行排序,确保顺序一致;或者只提取对象中对函数结果有影响的关键属性来生成键。这需要根据具体业务逻辑来定,没有银弹。

    • 哈希函数:使用一个稳定的哈希函数来生成参数的哈希值作为键。这比

      JSON.stringify

      更高效,但仍然面临如何处理对象深度、循环引用等问题。社区里有一些库提供了比较健壮的哈希算法。

    • WeakMap:如果你的缓存键是对象,并且你希望当这些对象不再被其他地方引用时,它们能被垃圾回收,那么

      WeakMap

      是一个不错的选择。

      WeakMap

      的键必须是对象,不能是原始值。它的缺点是你无法遍历

      WeakMap

      ,也无法知道它有多少个条目。这使得它不适合需要管理缓存大小(比如LRU)的场景。

      function memoizeWithWeakMap(func) {
        const cache = new WeakMap(); // 外层 WeakMap 缓存第一个参数
        return function(objArg, ...restArgs) {
          // 如果第一个参数不是对象,这种方式就不太适用
          if (typeof objArg !== 'object' || objArg === null) {
              // 回退到其他键生成方式或直接不缓存
              const key = JSON.stringify([objArg, ...restArgs]);
              // ...这里需要一个Fallback机制或者报错
              return func.apply(this, [objArg, ...restArgs]);
          }
      
          let innerMap = cache.get(objArg);
          if (!innerMap) {
            innerMap = new Map();
            cache.set(objArg, innerMap);
          }
      
          // 对剩余参数生成键
          const restKey = JSON.stringify(restArgs);
          if (innerMap.has(restKey)) {
            return innerMap.get(restKey);
          }
      
          const result = func.apply(this, [objArg, ...restArgs]);
          innerMap.set(restKey, result);
          return result;
        };
      }

      这种嵌套

      WeakMap

      Map

      的方式,可以处理第一个参数是对象的情况,并对后续参数进行序列化。但它依然有局限性。

  • 保留

    this

    上下文

    memoize

    函数内部,当调用原始函数

    func

    时,务必使用

    func.apply(this, args)

    func.call(this, ...args)

    。这样可以确保原始函数内部的

    this

    关键字指向正确的上下文,否则,如果原始函数依赖

    this

    ,结果会出错。

总的来说,处理非原始类型参数的记忆化,没有一个放之四海而皆准的完美方案。很多时候,这需要你深入理解业务逻辑,判断哪些参数是真正影响结果的,然后为这些关键参数设计一个高效且稳定的键生成策略。如果参数的复杂性使得键生成变得非常昂贵或不可靠,那么可能记忆化就不是最好的选择了,或者需要引入更专业的缓存库来管理。我个人的经验是,对于简单的场景,内置的

Map

JSON.stringify

足够了;但一旦涉及复杂对象,我就会非常谨慎,甚至考虑是不是有其他算法或数据结构能更好地解决问题。

以上就是js 怎么用memoize创建记忆化函数的详细内容,更多请关注骃骐网【www.myinqi.com】。

相关推荐:

Python中如何对NumPy数组执行快速幂运算_使用power函数实现向量化

np.power 比 快因底层C向量化实现,避免Python循环开销;支持out/where/dtype等参数,更安全可控;对负底数非整指数默认报错,而 静默返回nan;np.float_power专为负底数浮点指数设计。 为什么 np.power 比循环调用 ** 快得多 因为 np.power 是底层 C 实现的向量化函数,它一次性处理整个数组,避免 Python 循环开销和逐元素类型检查。而...

如何在Python中实现带参数的装饰器_通过三层函数嵌套传递外部参数

带参数的装饰器必须三层:外层接收参数并返回装饰器,中层接收函数,内层接收调用参数;不加@functools.wraps会导致__name__、__doc__等元信息丢失,影响调试、反射和IDE功能;参数校验应放在外层。 为什么带参数的装饰器必须用三层函数 因为 Python 的 @ 语法只接受一个可调用对象作为装饰器,而你传进去的 @log(level="DEBUG") 实际上是先执行 log(l...

如何在对象属性批量更新后仅执行一次回调函数

本文介绍一种优雅的 python 设计模式:通过私有属性 + 只读属性 + 显式 update() 方法,确保对象状态变更时回调函数(如数据校验、视图刷新等)仅被触发一次,避免重复计算,同时保持 ide 类型提示与代码可维护性。 本文介绍一种优雅的 python 设计模式:通过私有属性 + 只读属性 + 显式 update() 方法,确保对象状态变更时回调函数(如数据校验、视图刷新等)仅被触发一次...

Python中如何求NumPy数组的百分位数_使用percentile函数进行统计

numpy.percentile用于计算数组指定百分位数,关键参数包括a(输入数组)、q(0–100间百分位数)、axis(计算轴,默认None展平);含NaN需设nan_policy='omit';method控制插值方式;keepdims配合axis保留维度。 percentile函数的基本用法和参数含义 numpy.percentile 是计算数组中指定百分位数的主力函数,它不修改原数组,返...

如何确保对象属性批量更新后仅触发一次回调函数

本文介绍一种优雅的 Python 设计方案:通过禁用直接属性赋值 + 提供类型安全的 update() 方法,实现在多个属性变更后统一、且仅执行一次回调函数,兼顾 IDE 智能提示、代码可维护性与运行时可靠性。 本文介绍一种优雅的 python 设计方案:通过禁用直接属性赋值 + 提供类型安全的 `update()` 方法,实现在多个属性变更后统一、且仅执行一次回调函数,兼顾 ide 智能提示、代...

Python如何实现图的深度优先遍历_基于栈结构或递归函数实现

递归DFS栈溢出风险高因CPython默认递归深度仅1000,长链或环易触发RecursionError;隐式栈不可控,闭包和帧对象加剧内存开销;图应优先用显式栈实现。 递归实现DFS时,为什么栈溢出风险比预期高? Python默认递归深度限制是1000层,图中存在长链或环状结构时极易触发RecursionError。这不是算法错,而是CPython解释器对递归调用的硬性约束。 用sys.setr...

如何使用Python中的calendar模块判断闰年_调用isleap函数

应直接使用 calendar.isleap() 判断闰年,它准确实现公历规则、线程安全、性能优且自 Python 2.3 起稳定兼容,避免手写逻辑出错或误用类型。 直接用 calendar.isleap() 判定闰年,别自己写逻辑 Python 的 calendar.isleap() 是最可靠、最省事的闰年判断方式。它封装了格里高利历(公历)全部规则:能被 4 整除但不能被 100 整除,或能被 ...

Python中如何获取当前正在运行的所有异步任务_使用asyncio.all_tasks函数

asyncio.all_tasks 返回当前事件循环中所有未完成的Task对象集合(set),为空常因未启动事件循环、无活跃循环、任务已完结或跨线程未正确设置循环。 asyncio.all_tasks 返回什么,为什么有时为空 asyncio.all_tasks 返回当前事件循环中所有未完成的 Task 对象集合(set),但它**只作用于当前线程中已运行且未关闭的事件循环**。常见误区是直接调用...

高效实现 Pandas DataFrame 中基于多列的条件列创建

本文介绍多种优化方法,帮助你在处理大规模数据时高效创建条件列,包括向量化操作、numpy.select、pd.cut 和缓存函数调用等策略,显著减少计算开销并提升可读性与执行速度。 本文介绍多种优化方法,帮助你在处理大规模数据时高效创建条件列,包括向量化操作、`numpy.select`、`pd.cut` 和缓存函数调用等策略,显著减少计算开销并提升可读性与执行速度。 在 Pandas 中为大型 ...

如何修复Python中NumPy不支持中文路径的问题_通过open函数读取字节流

NumPy的loadtxt等函数无法读取中文路径的根本原因在于其底层调用C标准库fopen时依赖系统默认编码(如Windows的GBK),而Python 3传递的是Unicode字符串,未做显式编码适配,导致OSError或UnicodeDecodeError;正确做法是用open('rb')读字节流,再经io.BytesIO包装后传入NumPy函数。 NumPy的loadtxt、genfromt...