闭包回顾


在《闭包是个什么球》中介绍说闭包是一个特殊的高阶函数,它会返回另一个函数,而这个被返回的函数,又引用了其上层函数中的变量。那么,这种特殊的或者说奇怪的函数有什么用呢?我在另一篇《闭包有什么用》一文里罗列了一些。今天再次回到闭包这个话题,使用一个例子来具体说明其用处,这个例子就是 memoize。

memoize


memoize,是一个常见的函数,很多库比如 lodash 或者 rambda 中都有,可供你拿来就用。它用空间换时间,让耗时的操作只会执行一次,从而加快程序的运行速度。

它的实现,就用到了闭包。我们试着来实现一个 naive memoize 吧!首先明确一下需求:

对于某个函数 fn,只要它一旦被 memoize 过,那么,对于同样的参数,它将立即给出结果而不需要再次计算。

动手写代码实现 memoize 前,先想想怎么验证它是否被实现了呢?(这是一个测试驱动开发的好习惯)

思路是在某个函数中增加一个计数器,一旦调用一次,就增加 1。所以对于同样的参数调用,期待这个计数器只会被增加一次。不如就用加法函数来验证吧: typescript it(memoize sum, () => { let count = 0;

const sum = (x1: number, x2: number) => {
  count++;

  return x1 + x2;
};

const memoizedSum = memoize(sum);

expect(count).toBe(0);
expect(memoizedSum(1, 1)).toEqual(2);
expect(count).toBe(1);
expect(memoizedSum(1, 1)).toEqual(2);
expect(count).toBe(1);
expect(memoizedSum(1, 2)).toEqual(3);
expect(count).toBe(2);

});

跑一下,发现 memoize 含没有被定义,我们来写个实现: typescript export const memoize = (fn: Function) => { const cache: Record<string, any> = {};

return (...args: any) => { const key = JSON.stringify(args); if (!cache[key]) { cache[key] = fn(...args); }

return cache[key];

}; };

再次运行测试,通过了!仔细看一下这个幼稚的实现,简直是闭包定义(返回一个引用了上层函数也就是外部作用域里的变量的函数的高阶函数)的完美体现。首先,memoize 接受一个函数作为参数;其次,它并不执行那个参数,而是返回另外一个函数,这个被返回的函数体才会去调用原本传入的函数,因此将原函数的执行延迟了。最后,返回的函数体中引用了上层函数里定义的 cache 变量。

总结


到这里,一个幼稚的 memoize 就实现了,用法和 lodash 里的 memoize 类似。

避坑指南


我曾经在使用 lodash 的 memoize 时,希望用最小的改动,将一个耗时操作封装一下,成为一个只会将同样的事情做一次的操作,于是写了这样的代码: diff export timeConsumingFn = () { ... }

export main() { ...

  • timeConsumingFn()
  • _.memoize(timeConsumingFn)() ... }

结果发现,还是每次执行都很耗时,感觉被 lodash 的 memoize 骗了。后来仔细一想,原来时自己的使用姿势不正确,因为上面的写法中,虽然对原耗时操作进行了 memoize 封装,但是每次调用都是重新封装一次,于是被封装后的函数,对它来说,每次都是第一次执行,所以导致了原来的目标没有实现。还记得在 memoize 函数体中有一个 cache 吗?以上写法导致每次都是新建一个 cache,每次的 cache 里存了一个操作结果后,就没有继续利用了。就是说,以上写法不仅没能让原本的操作加快速度,反而增加了很多内存占用。

正确的写法


应该将 memoize 封装后的函数使用新的函数名保存,并保证每次执行使用第一次封装后的新函数(关键在于封装只需要而且只能进行一次)。 diff export timeConsumingFn = () { ... }

  • memoizedTimeConsumingFn = _.memoize(timeConsumingFn)

export main() { ...

  • timeConsumingFn()
  • memoizedTimeConsumingFn() ... }

成功实现加快程序运行速度的目标!

将避坑指南写进测试里


以上介绍了 lodash 的 memoize 避坑指南,那么我们写的幼稚的 memoize 有同样的坑吗?当然有,那么写一个测试用例,以示警告:

typescript it(doesnt memoize if you use it like this: , () => { let count = 0; const sum = (x1: number, x2: number) => { count++;

return x1 + x2;

};

expect(count).toBe(0); expect(memoize(sum)(1, 1)).toEqual(2); expect(count).toBe(1); expect(memoize(sum)(1, 1)).toEqual(2); expect(count).toBe(2); });

忘掉避坑指南


虽然有办法避坑,但是那个坑实在太容易掉进去了呀!毕竟 memoize 这个名字听上去就是只要用一下它就将原函数记住了呀,还非要一个额外的变量存储那个被记住的函数?

所以我们现在将幼稚的 memoize 再增强一下吧,这个增强版,我们希望即使直接用,不用新的变量,也能达到同样的效果。不如把这个增强的 memoize 函数叫做 memoized 吧,我们希望达到的效果,也写成测试用例,只需要把那个预警测试用例稍改一下: typescript it(does memoize if you use memoized instead, () => { let count = 0; const sum = (x1: number, x2: number) => { count++;

return x1 + x2;

};

expect(count).toBe(0); expect(memoized(sum)(1, 1)).toEqual(2); expect(count).toBe(1); expect(memoized(sum)(1, 1)).toEqual(2); expect(count).toBe(1); });

看上去非常完美,怎么实现这个 memoized 呢?我有一个很直接简单粗暴的想法,可能也是一个疯狂的想法:为什么不用 memoize 将它自己封装一下呢?毕竟 memoize 是一个函数,而 memoize 的参数可以是任何函数。于是我写下了如下的实现代码: typescript export const memoized = memoize(memoize);

有点不敢相信,但是运行一下测试,通过!

使用 memoized,再也不用记住那个避坑指南了,减少了很多认知负担。从此这个程序员过上了幸福的编码生活。