Effect 中Function.memoizeIdempotent的设计原理:幂等对象变换的固定点缓存与 Schema AST 去重优化
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
本篇文章基于当前仓库(Effect 4.0,核心包版本为4.0.0-rc.115)中的变更记录 .changeset/pre/memoize-idempotent-asts.md 展开。该变更以patch级别引入Function.memoizeIdempotent,并将其应用于 Schema AST 的规范化处理(包括optional/mutable属性修饰符的幂等变换),同时为 Config 的 cursor AST 编译增加缓存。读完本文,你将理解:什么是幂等(idempotent)对象变换、memoizeIdempotent与既有memoize在语义上的本质区别、其固定点缓存的实现机制,以及它是如何被用于避免 Schema AST 被反复加工、提升 Schema 构造与 Config 解析性能的。
一、变更背景:为什么要"幂等"的 memoization
Effect 的Schema模块内部维护一棵不可变的 Schema AST(抽象语法树)。大量高层 API(如Schema.Struct、Schema.optionalKey、Schema.mutableKey、Schema.toType、Schema.toEncoded)在底层都会反复对同一棵 AST 节点进行变换,例如:
- 标记某个属性为可选(
isOptional); - 标记某个属性为可变(
isMutable); - 剥离或翻转编码链(
encoding); - 将 AST 归一化为 union 选择时的候选类型。
这些变换有一个共同特征:对同一输入重复应用,得到的结果在对象身份(identity)上是可复用的——第一次变换产出的结果,再次变换仍是它自己(或等价的规范化结果)。这就是"幂等变换":f(f(x))与f(x)在语义上等价,且结果对象本身可以被视为不动点(fixed point)。
memoizeIdempotent正是为这类场景设计的:它不仅能缓存"输入 → 输出"的映射,还能把输出本身也登记为缓存键,从而把每个已计算的结果当作固定点来复用。
二、memoizeIdempotent的源码实现
该 API 定义在 packages/effect/src/Function.ts#L1350-L1385,属于@category caching,@since 4.0.0:
/** * Creates a memoized idempotent object transformation that caches both inputs * and their outputs by object identity. * * **When to use** * * Use when an object transformation is idempotent and its output can be safely * reused as a fixed point. * * **Details** * * After computing an input, the returned function caches both the input and * the output. Calling it with either reference returns the output without * invoking the supplied function again. * * **Gotchas** * * The returned function treats each computed output as a fixed point. If * applying the supplied function to an output would produce an observably * different value, this memoization changes that behavior. * * @see {@link memoize} for memoizing functions without an idempotence requirement * @category caching * @since 4.0.0 */ export function memoizeIdempotent<A extends object>(f: (a: A) => A): (a: A) => A { const cache = new WeakMap<A, A>() return (a) => { const cached = cache.get(a) if (cached !== undefined) return cached const result = f(a) cache.set(a, result) cache.set(result, result) return result } }关键点有三:
- 基于对象身份的
WeakMap缓存:以输入对象的引用为键,命中则直接返回缓存结果,不再调用f。由于使用WeakMap,缓存不会阻止输入/输出对象被垃圾回收,长期运行也不会造成内存泄漏。 - 双方向登记:计算完
result = f(a)后,同时执行cache.set(a, result)与cache.set(result, result)。前者是常规 memoization;后者则把result自身登记为键、自身为值——这意味着任何后续以该输出对象为输入(甚至以任意与其身份相同的引用为输入)的调用,都会直接命中缓存。 undefined语义:与memoize一致(参见 packages/effect/src/Function.ts#L1328-L1339 中对memoize的说明),undefined被保留用来表示缓存未命中,因此不承诺支持返回undefined的变换。从类型签名f: (a: A) => A也能看出,本 API 仅适用于"对象到对象"的变换。
与memoize的语义对比
紧邻其上的memoize(packages/effect/src/Function.ts#L1339)签名更宽:
export function memoize<A extends object, O extends {} | null>(f: (a: A) => O): (ast: A) => O { const cache = new WeakMap<object, O>() return (a) => { const cached = cache.get(a) if (cached !== undefined) return cached const result = f(a) cache.set(a, result) return result } }两者差异的本质是是否把输出当作固定点:
| 维度 | memoize | memoizeIdempotent |
|---|---|---|
| 缓存方向 | 仅缓存 输入 → 输出 | 同时缓存 输入 → 输出 与 输出 → 输出 |
| 输出要求 | 任意对象或null | 必须是A类型对象,且变换幂等 |
| 用输出再调用 | 会再次执行f | 直接命中缓存,不执行f |
| 适用场景 | 一般性防重复计算 | 幂等变换、规范化、AST 去重 |
Gotcha(使用陷阱):memoizeIdempotent把每个计算出的输出都当作固定点。如果你的变换函数对某个输出再次应用时会产出可观察的不同结果(即并非真正幂等),那么 memoization 会悄悄改变原有行为。因此它只应作用于"结果再次变换等于自身"的函数。
三、测试用例如何验证"固定点"语义
变更配套的测试位于 packages/effect/test/Function.test.ts#L334-L361,两个用例分别验证了固定点缓存的两个方向:
describe("memoizeIdempotent", () => { it("caches the output as a fixed point", () => { let callCount = 0 const input = { id: "input" } const output = { id: "output" } const f = F.memoizeIdempotent((obj: { id: string }) => { callCount++ return obj === input ? output : obj }) assert.strictEqual(f(input), output) assert.strictEqual(f(output), output) assert.strictEqual(callCount, 1) }) it("caches an input that is already a fixed point", () => { let callCount = 0 const f = F.memoizeIdempotent((obj: object) => { callCount++ return obj }) const input = {} assert.strictEqual(f(input), input) assert.strictEqual(f(input), input) assert.strictEqual(callCount, 1) }) })第一个用例"caches the output as a fixed point"验证了双方向登记:f(input)计算出output后,f(output)不再执行用户函数(callCount保持为 1),直接返回已缓存的output——这就是"输出即固定点"。
第二个用例"caches an input that is already a fixed point"验证了平凡场景:当变换函数返回输入本身(恒等变换,本身就是固定点)时,重复调用同样只执行一次。
四、在 Schema AST 中的落地:避免规范化处理被反复执行
变更记录明确指出:memoizeIdempotent被用来"避免重复处理 canonical Schema ASTs,包括 optional 和 mutable 属性修饰符"。在 packages/effect/src/SchemaAST.ts 中,可以找到多处应用:
1.optionalKey与mutableKey:属性修饰符的幂等变换
Schema AST 的Context携带isOptional与isMutable两个布尔标记(见 packages/effect/src/SchemaAST.ts#L580-L626),并有配套的读取辅助函数isOptional/isMutable(packages/effect/src/SchemaAST.ts#L4502-L4516)。
optionalKey将字段标记为可选:
export const optionalKey: <A extends AST>(ast: A) => A = memoizeIdempotent(<A extends AST>(ast: A): A => { const context = ast.context ? ast.context.isOptional === false ? new Context(true, ast.context.isMutable, ast.context.constructorDefault, ast.context.annotations) : ast.context : new Context(true, false) return optionalKeyLastLink(replaceContext(ast, context)) })mutableKey将字段标记为可变(非readonly):
export const mutableKey = memoizeIdempotent(<A extends AST>(ast: A): A => { const context = ast.context ? ast.context.isMutable === false ? new Context(ast.context.isOptional, true, ast.context.constructorDefault, ast.context.annotations) : ast.context : new Context(false, true) return mutableKeyLastLink(replaceContext(ast, context)) })(两处源码分别见 packages/effect/src/SchemaAST.ts#L4384-L4391 与 packages/effect/src/SchemaAST.ts#L4401-L4408。)
这里的幂等性一目了然:一个已经带isOptional === true上下文的 AST,再次应用optionalKey会命中ast.context.isOptional === false ? ... : ast.context分支,直接返回原上下文,不会产生新的Context对象。用memoizeIdempotent包装后,这种"重复应用无变化"的调用直接从缓存返回,避免了对同一棵 AST 反复构造新节点。
这些底层函数对应着Schema层的公开 API:Schema.optionalKey(packages/effect/src/Schema.ts#L2317)、Schema.requiredKey(packages/effect/src/Schema.ts#L2336)、Schema.mutableKey(packages/effect/src/Schema.ts#L2442)、Schema.readonlyKey(packages/effect/src/Schema.ts#L2461),以及类型层的Optionality = "required" | "optional"描述(packages/effect/src/Schema.ts#L87-L97)。
典型使用方式(来自Schema.optionalKey的文档示例,packages/effect/src/Schema.ts#L2300-L2312):
import { Schema } from "effect" const schema = Schema.Struct({ name: Schema.String, age: Schema.optionalKey(Schema.Number) }) // Type: { readonly name: string; readonly age?: number } type Person = typeof schema["Type"]2.toType/toEncoded:编码链的规范化
toType负责剥离 AST 上的编码链,返回类型侧的 AST;toEncoded则是先flip再toType。两者都用memoizeIdempotent包装(packages/effect/src/SchemaAST.ts#L4558 与 packages/effect/src/SchemaAST.ts#L4605-L4607):
export const toType = memoizeIdempotent(<A extends AST>(ast: A): A => { if (ast.encoding) { return toType(replaceEncoding(ast, undefined)) } // ... 递归规整 + 合并 encodingChecks 到 checks }) export const toEncoded = memoizeIdempotent((ast: AST): AST => { return toType(flip(ast)) })由于toType是幂等的(已经去掉编码的 AST 再次toType不会变化),memoizeIdempotent能保证:当toType的递归规整路径上出现同一节点时,直接复用已有结果,避免重复的深度遍历。
3.toCandidate:union 选择候选类型的归一化
toCandidate(packages/effect/src/SchemaAST.ts#L3263-L3276)用于 union 解码时把成员 AST 归一化为"候选类型"(如"string"、"number"、"array"等)。它同样用memoizeIdempotent包装,并在循环中递归调用ast.recur?.(toCandidate, identity),保证共享子节点只被处理一次。
4. 通用工具:applyToSelfOrLastLinkEncodingIdempotent
SchemaAST 内部还提供了一个基于memoizeIdempotent的通用组合子(packages/effect/src/SchemaAST.ts#L4302-L4314):
export function applyToSelfOrLastLinkEncodingIdempotent( f: (ast: AST) => AST, options?: { readonly stopAt?: (link: Link) => boolean } ) { function out(ast: AST): AST { if (ast.encoding) { const last = ast.encoding[ast.encoding.length - 1] return options?.stopAt?.(last) ? ast : replaceEncoding(ast, updateLastLink(ast.encoding, out)) } return f(ast) } return memoizeIdempotent(out) }它被用于parameterFromPropertyKey、parameterFromString、partFromString等属性键参数归一化(packages/effect/src/SchemaAST.ts#L4741-L4776),这些函数负责把索引签名参数、Symbol、Number、Literal 等节点转换为对应的 StringTree 编码。使用memoizeIdempotent后,同一 AST 节点的参数归一化结果会被复用,避免反复执行ast.toCodecStringTree()这类较重的转换。
五、Config 侧:缓存 cursor AST 编译
变更记录的另一半是"Cache Config schema cursor AST compilation"——为 Config 的 cursor AST 编译加缓存。
Config.schema是 Effect Config 中通过 Schema 定义结构化配置的核心入口(packages/effect/src/Config.ts#L877-L883):
export function schema<T>(codec: Schema.ConstraintCodec<T, unknown>, path?: string | ConfigProvider.Path): Config<T> { const codecStringTree = Schema.toCodecStringTree(codec) const encodedAst = SchemaAST.toEncoded(codecStringTree.ast) const decodeCursor = SchemaParser.decodeUnknownEffect( Schema.make<Schema.Codec<T, ConfigCursor>>(toConfigCursorAST(codecStringTree.ast)) ) const localPath = typeof path === "string" ? [path] : path ?? [] // ... }调用链的关键节点:
- 先把 codec 转为canonical
StringTree形式(Schema.toCodecStringTree),其编码形状决定了 provider 数据如何加载:标量 schema 读相邻标量值,对象 schema 读声明属性与匹配的 record 键,数组 schema 读索引子项(见 packages/effect/src/Config.ts#L800-L828 的文档说明)。 - 再通过
toConfigCursorAST把该 AST编译为"游标解码"用的 AST——解码时每个节点会拿到一个ConfigCursor(packages/effect/src/Config.ts#L663-L668),它携带provider、path、node与toString,解码过程即"沿配置路径逐层定位 provider 节点"(loadCursor/loadChildCursor,packages/effect/src/Config.ts#L672-L682)。 - 最后用
SchemaParser.decodeUnknownEffect构造出实际执行解码的decodeCursor。
toConfigCursorAST本身用memoize做了 AST 级缓存(packages/effect/src/Config.ts#L730):
const toConfigCursorAST = memoize((root: SchemaAST.AST): SchemaAST.AST => { const seen = new WeakSet<SchemaAST.AST>() const recur = SchemaAST.applyToSelfOrLastLinkEncoding((ast) => { seen.add(ast) switch (ast._tag) { case "Objects": { /* 收集属性键与匹配的索引签名键 */ } case "Arrays": { /* 按索引读取子节点 */ } // ... } }) // ... })值得注意的是,memoizeIdempotent与既有memoize在这里是配套使用的:toConfigCursorAST整体用memoize缓存(同一棵根 AST 只编译一次),其内部递归用的applyToSelfOrLastLinkEncoding也带有 memoization。由于Config.schema在构造时会同步执行Schema.toCodecStringTree、SchemaAST.toEncoded与toConfigCursorAST,而这些步骤都落在上一节提到的幂等缓存之上,同一个 Schema 被多个Config.schema调用复用时,canonical AST 的规范化和 cursor AST 的编译都只会发生一次。
六、实际收益与使用建议
收益:减少重复计算与对象分配
从实现层面看,本变更带来的收益主要体现在三方面:
- 避免重复处理 canonical Schema AST:
optionalKey/mutableKey/toType/toEncoded/toCandidate等高频规范化操作,对同一节点只执行一次,后续调用走WeakMap命中; - 减少新对象分配:幂等变换命中缓存后不再
new Context、不再构造替换后的 AST 节点,降低 GC 压力; - Config 解析提速:
Config.schema构造期的 StringTree 规整与 cursor AST 编译被缓存,同一 Schema 复用于多个配置项时开销摊薄。
使用建议
- 仅对真正幂等的对象变换使用
memoizeIdempotent:要求f(f(x))与f(x)语义等价,且输出可作为固定点复用; - 若变换不满足幂等,或输出可能为
undefined/null,请改用一般的memoize(它也不支持undefined返回值)或自行实现缓存; - 由于缓存基于对象身份(WeakMap 引用键),结构相等但身份不同的对象不会共享缓存项——这在 Schema AST 场景中恰好是期望行为,因为 AST 节点以引用相等作为规范化判据;
- 缓存本身是惰性且无界的(按活跃对象数量增长),但因为
WeakMap不持有强引用,配合不可变 AST 的使用方式不会造成泄漏。
七、小结
Function.memoizeIdempotent是 Effect 4.0 在函数式缓存原语上的一处小而关键的补充:它以"幂等变换 + 固定点缓存"为语义,用两行cache.set实现了输入/输出双向登记,让"结果再次变换等于自身"的规范化函数可以安全地复用输出。这一原语被立刻应用到 Schema AST 的optionalKey、mutableKey、toType、toEncoded、toCandidate等高频路径,并为Config.schema的 cursor AST 编译提供了缓存支撑。对于需要处理大量共享 AST 节点、或希望在应用层实现幂等规范化缓存的开发者,这套实现与配套测试(packages/effect/test/Function.test.ts#L334-L361)都是可直接参考的范本。
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考