函数式编程08:memoize与缓存的可观察行为
零已经算出来,第二次为何还计算
一个纯计算输入相同就返回相同值,缓存看起来只需把结果放进字典。但如果命中判断写成 cache[key] || compute(),结果恰好是零、false、空字符串或 null 时,第二次调用仍会走 compute。函数值没有算错,调用次数已经与缓存目标不同。
函数缓存 memoize保留了这一技巧入口。它的核心行是 cache[argStr] = cache[argStr] || pureFunc.apply(pureFunc, arguments),键来自 JSON.stringify(arguments)。本章独立复现该代码,分别检查假值、this、键碰撞、可变输入与容量;旧文内容由原入口维护,不在这里覆盖。
本章有 Java 21 与 Node 两个实际入口。Java 展示有界缓存与空值命中,JavaScript 保留动态语言反例。缓存只在明确输入域和值观察方式下讨论优化,不把命中率当作正确性证明。
命中检查必须独立于结果内容
JavaScript 实验逐项让底层函数返回零、false、空字符串、null,连续调用同一键两次。旧实现每项计数二,使用 Map.has 的实现每项计数一。断言同时检查返回值与计数,避免“屏幕显示一样”掩盖没有缓存成功的问题。
1 | |
Map.has 判断键存在,不对缓存值求真假。删除后重新插入用于更新本教学缓存的最近访问顺序。ECMAScript 的 Map.prototype.has 规范算法将存在性检查独立定义;这是 API 依据,具体的淘汰策略则是本实验自行实现。
Java 版本使用 containsKey 加 get,也能区分缺失与已缓存的 null。仅使用 get 返回值是否为空,同样会混淆这两种状态。实验还让第一次计算抛错、第二次成功、第三次命中,明确当前只缓存成功结果。失败重试是一项策略选择,不应由偶然赋值顺序决定而不写进说明。
参数列表不一定包含全部输入
旧实现的 apply(pureFunc, arguments) 把 this 绑定为函数对象本身。若被包装的方法计算 this.rate * n,通过 .call({rate:2},3) 调用包装器仍无法取得预期接收者,实验得到 NaN。仅保留调用者的 this,应改为 fn.apply(this,args),简单透传对照得到六。
不过透传 this 还没有完成缓存修复。两个接收者费率不同,却以同一个 n 作为键,会错误共享结果;同一个接收者的 rate 后续变化,也会使旧缓存失效。本章修正版因此明确只接收不依赖接收者的单参数纯函数,方法场景优先将所需费率和版本编码成显式稳定输入。
如果确实需要缓存实例方法,可以为每个接收者维护独立缓存,或把接收者身份纳入键;这仍不能解决其可变字段失效问题。WeakMap 可以改变对象保留方式,却不能自动知道哪个字段修改后应失效。存在性、接收者与失效是三个独立契约。
函数闭包也可能包含隐藏配置。即使没有 this,捕获可变税率的报价函数仍会随环境变化。把它缓存后,相同键可能继续返回旧税率结果。应保存规则版本或仅在固定环境内使用缓存,而不能靠给函数命名为 pureFunc 声明它已经纯。
序列化键丢失了哪些区别
实验调用旧包装器,底层函数区分 NaN 与 null。两者在对应 JSON 参数表示中产生相同内容,先缓存 NaN 分支之后再传 null,得到的仍是 NaN 分支结果。另一个函数区分空对象与含函数属性的对象,JSON 忽略函数属性后同样发生错误共享。
这里的碰撞不是普通哈希冲突。正常哈希表可以在哈希相同后继续用相等规则区分键;序列化已把两份输入压成同一个字符串,原始差别消失,字典没有信息可用于二次判断。修复不能只换更快的哈希算法,需要改变键表示或限制输入域。JSON.stringify 及其序列化算法定义了非有限数字和不可表示属性的处理规则;本章用实际调用检验它们怎样影响缓存键。
JavaScript 教学修正版只接受一个稳定原始键:字符串、布尔值或有限数字,并拒绝负零、对象及其他类型。数字一与字符串一作为不同 Map 键,实验验证不会混用。拒绝负零是因为某些函数能够区分正零与负零,而键相等策略可能不能;宁可明确排除,也不隐含声称适用于所有数字函数。
多参数可以转成一个有明确类型与顺序的结构,但不能随意用分隔符拼接。字符串本身可能含分隔符,缺失字段与空字符串也可能被合并。最简单的领域键通常是稳定标识加规则版本,且编码可唯一解析。实验练习以商品标识和 revision 表达版本,不尝试构造万能对象序列化器。
可变参数与可变结果分别失效
一个按对象身份缓存的数量报价函数,第一次对 quantity=2 返回二百;把同一对象改为 quantity=9 后,再调用仍命中二百,而直接计算为九百。实验明确断言这组过期结果。对象身份没有变化,值已经变化,这就是身份键不适合直接代表可变业务内容的原因。
按内容序列化可变对象不一定产生同一种过期错误,它可能产生新键,却把每一个历史内容都留在缓存中。内容复杂时还要处理循环引用、顺序、特殊值与编码成本。两种键策略各自的问题应分别说明,不能拿身份键的失败直接证明任何内容键都失败。
返回结果也可能泄漏修改。实验让缓存返回 {value:2},调用方把 value 改成九十九,第二次相同输入得到九十九。缓存保存的是同一个对象引用,没有重建历史值。即使输入和底层函数都稳定,允许调用方修改结果也会破坏值观察下的等价。
可选协议包括返回不可变结果、按调用复制结果,或明确让调用方拥有共享对象。复制增加成本且未必适合资源句柄,冻结也要考虑嵌套字段。对订单报价这样的纯核心,优先返回稳定值通常最容易解释;缓存应保留这一契约,而不是创造新的共享修改通道。
容量有界与失效不是同一件事
Java 实现使用开启访问顺序的 LinkedHashMap。命中会更新最近访问顺序,新键插入后若超过容量,移除最久未访问项。JDK 21 LinkedHashMap 文档说明访问顺序构造方式与相关操作行为。当前代码是单线程教学实现,不承诺并发复合操作原子性。
容量二时依次访问 a、b、a、c、b。前两项填满缓存,第三次 a 命中并更新顺序;c 进入后淘汰 b;最后 b 需要重新计算。底层总调用四次。Java 以整数键复现相同形状,JavaScript 使用字符串键;两端都有明确断言。
容量限制的是键值对数量,不能直接限制字节数。一个结果可能很大,键本身也可能保留长字符串或对象图。若业务真正要求固定内存预算,应按权重管理,或者限制输入与结果大小。此处没有测量实际堆占用,也没有把“最多两个条目”称为内存泄漏的完整防护。
LRU 只根据访问历史选择淘汰项,不知道数据是否过期。报价规则变化后,最近使用的条目仍可能错误;版本键或失效协议处理正确性,容量策略处理保留成本。TTL 也需要明确时间来源和过期时点,不会把一个依赖实时外部状态的函数自动变成纯函数。
计算比键构造还便宜时,缓存可能增加总成本。命中仍要做键比较和维护顺序,未命中还要执行原计算并保存结果。只有相同输入重复率、结果大小与计算成本适合,缓存才值得加入;本章没有给出性能排序,调用次数减少也不等于吞吐必然增加。
缓存何时仍然是原函数的替代
在稳定输入、稳定结果和无可观察副作用的前提下,命中返回旧结果可以保留按值观察的行为。离开这些条件后,缓存就是一次语义变化。若函数每次生成唯一编号,相同参数并不意味着需要相同编号;若函数读取账户余额,调用者可能期望当前值;若函数发送通知,省掉一次执行可能省掉一条通知。此时首先需要决定业务需要的是复用、快照还是重复执行,而不是先寻找更健壮的字典实现。
异常也是可观察结果。缓存失败可以减少重复的昂贵失败,却可能阻止暂时故障恢复后重试;不缓存失败则可能让高频坏输入反复消耗资源。当前 Java 版本选择仅缓存成功,并用第一次失败后第二次成功的场景锁定这一策略。若要记忆失败,应明确失败保持多久、是否按错误类别区分,以及调用方是否允许看到同一个异常对象。改变这项政策需要新的测试,不能把它当作不影响行为的重构。
键的相等关系必须细到足以区分结果。若两个不同订单具有相同金额,而报价还依赖会员等级,只把金额作为键就不充分。反过来,把整个请求对象的所有字段都放入键可能过细:跟踪标识不同会阻止本来合法的复用。领域键应包含影响结果的稳定信息,并排除仅用于诊断且不影响计算的信息;这个判断来自实际公式,而不是序列化器能够处理哪些字段。
缓存测试可以把底层调用序列作为第二份输出。对每个输入先运行不带缓存的基线,再运行缓存实现,比较结果,并检查预期命中和淘汰后的执行次数。对可变输入反例则有意比较两者不相等,说明当前键协议失效。这样负例并非“程序没崩溃就通过”,而是明确证明某个危险条件确实会产生旧值;修正版还要以拒绝非法键或引入版本的断言覆盖自己的防线。
自测与修改练习
手算:容量二,访问 a、b、a、c 后保留哪两个键?保留 a 和 c,最近的是 c。再访问 b 会重新计算并淘汰 a。若采用插入顺序而不在命中时更新,淘汰序列会不同,说明名称为 LRU 的实现也需要事件验证。
类型题:一个 Order → Quote 函数读取 Order 的可变数量并返回可变 Quote,只有函数类型是否足够证明可缓存?不足。至少还需确认输入版本稳定、隐藏环境进入键、结果不能被调用者破坏,以及失败是否缓存。函数箭头没有记录这些条件。
可运行修改题以两端 exercise 的 revision 键为起点,同商品版本一计算一次,版本二计算一次,版本二再调用应命中。给缓存增加一项显式 invalidate 操作,清除版本二后重算,断言底层调用数增加一,同时版本一是否保留按容量规则判断。不要将“调用了 invalidate”当成成功,必须观察后续是否重新执行。
Java 的 Main.java与 JavaScript 的 memoize.mjs共同组成本章实验,统一运行:
1 | |
result.json保存两个入口的环境、源码哈希、命令和真实断言输出,覆盖四种假值、this 丢失、两类 JSON 碰撞、LRU、可变输入、可变结果、失败重试与版本键。滚动 ECMAScript 文档用于核对 Map 规则,实际 JavaScript 行为以记录中的 Node 版本为准。并发防击穿、分布式失效、持久化与 TTL 都不由这一小型 memoize 提供。


