Effect 中 `Function.memoizeIdempotent` 的设计原理:幂等对象变换的固定点缓存与 Schema AST 去重优化
2026/9/15 17:22:20 网站建设 项目流程

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.StructSchema.optionalKeySchema.mutableKeySchema.toTypeSchema.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 } }

关键点有三:

  1. 基于对象身份的WeakMap缓存:以输入对象的引用为键,命中则直接返回缓存结果,不再调用f。由于使用WeakMap,缓存不会阻止输入/输出对象被垃圾回收,长期运行也不会造成内存泄漏。
  2. 双方向登记:计算完result = f(a)后,同时执行cache.set(a, result)cache.set(result, result)。前者是常规 memoization;后者则把result自身登记为键、自身为值——这意味着任何后续以该输出对象为输入(甚至以任意与其身份相同的引用为输入)的调用,都会直接命中缓存
  3. 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 } }

两者差异的本质是是否把输出当作固定点

维度memoizememoizeIdempotent
缓存方向仅缓存 输入 → 输出同时缓存 输入 → 输出 与 输出 → 输出
输出要求任意对象或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.optionalKeymutableKey:属性修饰符的幂等变换

Schema AST 的Context携带isOptionalisMutable两个布尔标记(见 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则是先fliptoType。两者都用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) }

它被用于parameterFromPropertyKeyparameterFromStringpartFromString等属性键参数归一化(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 ?? [] // ... }

调用链的关键节点:

  1. 先把 codec 转为canonicalStringTree形式Schema.toCodecStringTree),其编码形状决定了 provider 数据如何加载:标量 schema 读相邻标量值,对象 schema 读声明属性与匹配的 record 键,数组 schema 读索引子项(见 packages/effect/src/Config.ts#L800-L828 的文档说明)。
  2. 再通过toConfigCursorAST把该 AST编译为"游标解码"用的 AST——解码时每个节点会拿到一个ConfigCursor(packages/effect/src/Config.ts#L663-L668),它携带providerpathnodetoString,解码过程即"沿配置路径逐层定位 provider 节点"(loadCursor/loadChildCursor,packages/effect/src/Config.ts#L672-L682)。
  3. 最后用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.toCodecStringTreeSchemaAST.toEncodedtoConfigCursorAST,而这些步骤都落在上一节提到的幂等缓存之上,同一个 Schema 被多个Config.schema调用复用时,canonical AST 的规范化和 cursor AST 的编译都只会发生一次

六、实际收益与使用建议

收益:减少重复计算与对象分配

从实现层面看,本变更带来的收益主要体现在三方面:

  • 避免重复处理 canonical Schema ASToptionalKey/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 的optionalKeymutableKeytoTypetoEncodedtoCandidate等高频路径,并为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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询