memoize 保存函数在特定输入上的计算结果。同一输入再次出现时,直接返回已保存的值。使用它需要先定义输入相等、计算依赖和结果是否允许共享;缓存命中不能靠结果的真假值判断。

假值命中的反例

原先的教学实现如下,保留它用于复现问题:

1
2
3
4
5
6
7
8
const memoize = pureFunc => {
const cache = {};
return function() {
const argStr = JSON.stringify(arguments);
cache[argStr] = cache[argStr] || pureFunc.apply(pureFunc, arguments);
return cache[argStr];
};
};

cache[argStr] || compute() 在保存结果为 0、false、空字符串或 null 时仍会再次计算。实验对每个值连续调用两次,旧实现的计数为 2,修正后的计数为 1。这里的调用计数用于暴露缓存行为;业务纯函数不应依赖它产生结果。

有界、单参数的教学实现

以下版本用 Map.has 区分未保存与已保存假值;容量固定,命中刷新访问次序,超过容量移除最久未访问的项。输入只允许稳定的 string、boolean 与有限 number;排除 -0、对象和多参数。函数不得依赖隐含 this 或可变外部状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function bounded(fn, capacity) {
if (!Number.isInteger(capacity) || capacity < 1)
throw new RangeError('capacity');
const cache = new Map();
return function (key) {
if (arguments.length !== 1
|| !['string', 'number', 'boolean'].includes(typeof key)
|| (typeof key === 'number'
&& (!Number.isFinite(key) || Object.is(key, -0)))) {
throw new TypeError('one stable primitive key required');
}
if (cache.has(key)) {
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
}
const value = fn(key);
cache.set(key, value);
if (cache.size > capacity) cache.delete(cache.keys().next().value);
return value;
};
}

这是收窄输入域的同步缓存,并非通用多参数 memoize 库。number 1 与字符串 "1" 保持区别;-0 被拒绝,以免把依赖符号零的函数结果按 Map 的键比较合并。函数抛异常时还没有写入缓存,后续调用会重新执行;返回可变对象时,同一命中会返回同一对象引用,调用者修改结果会污染后续观察。

序列化键与 this

修正假值命中仍不够。JSON 序列化会丢失某些区别:实验中的 NaN 与 null 形成相同键,空对象与包含函数属性的对象也形成相同键。仅用对象身份作键时,修改对象字段后仍可能取得过时结果。上面的输入限制主动排除这些情况;需要复杂参数时,应另外定义可验证的稳定键与版本,而不是继续宣称 JSON.stringify 可以覆盖任意 JavaScript 值。

旧实现还把 pureFunc 自身作为 this 传给被缓存函数。实验令函数读取调用方的 rate,原包装器返回 NaN,直接 fn.apply(this, args) 则得到 6。保留 this 只修复转发行为,不证明缓存键已经包含接收者状态。报价规则依赖 rate 时,将 rate 或规则版本显式放进稳定输入,再决定如何缓存。

复现与练习

执行 node examples/functional-programming/run.mjs 08。完整 JavaScript 正反例检查假值、键碰撞、this、LRU、可变输入与结果别名;Java 对照检查零值与 null 命中、容量、失败重试和规则版本。实际输出与环境保存在08 运行证据。

手算容量为 2 的缓存依次接收 a,b,a,c,b 时的计算次数,再运行断言验证。命中第二个 a 会刷新其访问顺序;插入 c 时淘汰 b,最后一个 b 重新计算,共计算 4 次。修改练习为报价键增加规则版本,检查同商品不同版本各计算一次、相同版本再次命中。

缓存正确性还取决于是否允许观察执行次数、对象身份、耗时和空间。纯计算可以在声明的数值结果等价范围内作结果缓存;外部动作不能因为参数相同就被任意省略。完整学习路线从函数式编程导读进入。

参考资料

ECMAScript Map.has与 JSON.stringify分别定义键存在性与序列化行为;本页的计数、碰撞和别名结果由所链接的本地实验检查。