Effect 修复 Duration 哈希契约:规范化纳秒哈希如何消除 Millis/Nanos 双表示带来的 Hash/Equal 不一致
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
导读
本文围绕 Effect 仓库中一项针对Duration哈希实现的 patch 级修复展开:Duration内部存在Millis与Nanos两套表示,此前两个在语义上相等(Duration.equals返回true)的时长可能计算出不同的哈希值,从而破坏 Hash/Equal 契约并静默破坏HashSet/HashMap的按键查找。文章将结合 packages/effect/src/Duration.ts 的源码与 packages/effect/test/Duration.test.ts 的回归测试,说明修复前后的行为差异、规范化纳秒哈希的实现原理、边界情况的处理方式,以及这一改动对使用Duration作为集合键的开发者意味着什么。
变更背景:Duration的两种内部表示
在 Effect 中,Duration是一个表示时间跨度的高精度类型,支持从纳秒到周的操作。其内部值由一个受判别联合DurationValue承载(见 Duration.ts):
type DurationValue = | { readonly _tag: "Millis"; readonly millis: number } | { readonly _tag: "Nanos"; readonly nanos: bigint } | { readonly _tag: "Infinity" } | { readonly _tag: "NegativeInfinity" }两种有限表示的分工由构造函数make决定(见 Duration.ts):
- 传入整数 number(如
Duration.seconds(5)、Duration.millis(5000))时,存为Millis表示; - 传入非整数 number时,会通过
roundMillisToNanos四舍五入为纳秒后存为Nanos表示; - 传入bigint(如
Duration.nanos(5_000_000_000n))时,直接存为Nanos表示。
因此,同一个真实时间跨度完全可能以两种不同的内部形式存在:
Duration.seconds(5) // { _tag: "Millis", millis: 5000 } Duration.nanos(5_000_000_000n) // { _tag: "Nanos", nanos: 5000000000n }两者在语义上完全相等,但内部结构不同——这正是本次哈希修复要解决的核心矛盾。
问题根因:equals按语义比较,哈希却按结构计算
Effect 的数据结构遵循 Hash/Equal 契约:两个值相等,其哈希值必须相同。哈希表(HashSet、HashMap)依赖这一点来确定查找位置:如果两个相等键的哈希值不同,HashSet.has、HashMap.get就会在错误的分桶中寻找,导致"明明存在却查不到"的静默失败。
然而修复前,Duration的equals与哈希实现走的是两条不同的路径:
- 相等性判断基于语义归一化。
Duration.equals委托给Equivalence(见 Duration.ts),而Equivalence内部使用matchPair,在比较Millis与Nanos两种表示时,会先把Millis通过toNanosUnsafe转换为规范化纳秒再比较(见 Duration.ts 与 toNanosUnsafe)。所以Duration.seconds(5)与Duration.nanos(5_000_000_000n)判定为相等。 - 哈希计算此前却直接基于原始内部表示(
Millis按毫秒数哈希、Nanos按纳秒数哈希)。由于 5000 与 5000000000n 是截然不同的数值,两个相等时长会得到不同的哈希值。
于是出现了违反契约的组合:Equal.equals(a, b) === true但Hash.hash(a) !== Hash.hash(b)。对以Duration为键的HashSet/HashMap而言,这会造成成员查找和按键取值时哈希分桶不一致,产生难以排查的静默 bug。
修复方案:统一走规范化纳秒哈希
本次 changeset(effect包的patch级修复)将Duration的Hash.symbol实现改为对规范化的纳秒形式进行哈希。修复后的实现位于DurationProto的Hash.symbol方法(见 Duration.ts):
Hash.symbol { // Hash equal finite durations using the same canonical nanoseconds // representation used by `equals`. switch (this.value._tag) { case "Millis": { const nanos = this.value.millis * 1_000_000 return Number.isFinite(nanos) ? Hash.hash(roundTiesAwayFromZero(nanos)) : Hash.number(this.value.millis) } case "Nanos": return Hash.hash(this.value.nanos) default: return Hash.structure(this.value) } }关键点拆解:
Millis表示先换算成纳秒:millis * 1_000_000把毫秒放大到纳秒量级,与equals内部toNanosUnsafe的换算(roundMillisToNanos,即roundTiesAwayFromZero(millis * 1_000_000),见 Duration.ts)保持同一口径,保证"相等即同哈希"。Nanos表示直接哈希纳秒值:Hash.hash(this.value.nanos)对 bigint 按其十进制字符串形式哈希(见 Hash.ts 中 bigint 分支string(self.toString(10)))。Infinity/NegativeInfinity保持结构化哈希:不落入纳秒换算分支,直接Hash.structure(this.value)。
这样,Duration.seconds(5)(内部Millis5000)被换算为5_000_000_000纳秒后再哈希,与Duration.nanos(5_000_000_000n)直接对5_000_000_000n哈希得到的结果完全一致,哈希与相等性重新对齐。
边界情况:超大毫秒数与负数
规范化换算并非对所有有限Millis都安全,源码中专门处理了两个边界:
(1)毫秒数过大、换算后溢出number精度时回退到毫秒哈希。Number.isFinite(nanos)分支(第 359 行)用于捕获millis * 1_000_000超出安全范围(如Duration.millis(1e303))的情况。此时换算产物已是Infinity,无法作为纳秒精度使用,实现回退为Hash.number(this.value.millis)。虽然这一分支不再与Nanos表示对齐,但由于如此大的毫秒数本身无法由 bigint 纳秒路径精确承载,这种回退保证了同一Millis值自洽可哈希。回归测试 Duration.test.ts 专门验证了这一场景:
it("Hash.symbol handles finite millis too large to convert to nanos", () => { const duration = Duration.millis(1e303) assertTrue(Equal.equals(duration, Duration.millis(1e303))) assertTrue(HashSet.has(HashSet.make(duration), Duration.millis(1e303))) })(2)负数与小数毫秒的舍入一致性。纳秒换算使用roundTiesAwayFromZero实现"远离零的四舍五入"(正数Math.floor(input + 0.5)、负数Math.ceil(input - 0.5),见 Duration.ts),与Duration构造、equals比较共用同一舍入规则。测试覆盖了负数场景:
strictEqual(Hash.hash(Duration.millis(-5000)), Hash.hash(Duration.nanos(-5_000_000_000n)))(3)无穷值的相等性语义。Duration.infinity与Duration.negativeInfinity在Equivalence中按_tag区分(见 Duration.ts),即两者不相等,哈希上同样区分(Hash.structure会包含_tag信息)。测试断言Hash.hash(Duration.infinity) === Hash.hash(Duration.infinity)且Equal.equals(Duration.infinity, Duration.negativeInfinity) === false。
回归测试:验证契约修复
packages/effect/test/Duration.test.ts 中的测试 "Hash.symbol agrees with equals across Millis/Nanos representations" 完整覆盖了本次修复的核心诉求:
it("Hash.symbol agrees with equals across Millis/Nanos representations", () => { const millisTagged = Duration.seconds(5) const nanosTagged = Duration.nanos(5_000_000_000n) assertTrue(Duration.equals(millisTagged, nanosTagged)) assertTrue(Equal.equals(millisTagged, nanosTagged)) strictEqual(Hash.hash(millisTagged), Hash.hash(nanosTagged)) strictEqual(Hash.hash(Duration.millis(-5000)), Hash.hash(Duration.nanos(-5_000_000_000n))) strictEqual(Hash.hash(Duration.infinity), Hash.hash(Duration.infinity)) assertFalse(Equal.equals(Duration.infinity, Duration.negativeInfinity)) assertTrue(HashSet.has(HashSet.make(millisTagged), nanosTagged)) })测试断言链条清晰地表达了修复目标:
Duration.equals与Equal.equals对两种表示判定相等(语义一致性前提);Hash.hash对两种表示产生相同结果(契约核心:相等必同哈希);- 最终用
HashSet.has(HashSet.make(millisTagged), nanosTagged)做端到端验证:以Millis形式放入集合,用Nanos形式查询,必须命中。
其中HashSet.has这一行正是对"静默破坏HashSet/HashMap查找"这一原始缺陷的直接回归防线——修复前它会返回false,修复后返回true。
对开发者的影响与启示
本次修复以patch级别发布,行为上对绝大多数调用方透明,但对以下几类使用方式有直接意义:
- 以
Duration为键的集合/映射:此前HashSet.has、HashMap.get若出现"用构造方式不同的相等时长查不到"的现象,本次修复后不再发生。构造方式(seconds/millisvsnanos/bigint)不再影响哈希分桶。 - 跨表示混合运算:
Duration.sum、Duration.divide等运算会产生混合Millis/Nanos表示(如 测试用例 中Duration.divide(Duration.seconds(1), 3)得到Duration.nanos(333333333n)),这些结果与直接构造的等值时长久违地拥有相同哈希。 - 通用
Equal/Hash基础设施:Hash.hash对实现Hash.symbol的对象会调用其自定义哈希(见 Hash.ts),Duration正是通过该机制接入整个 Effect 的相等性与哈希体系(Equal.symbol见 Duration.ts)。此次修复保证Duration在HashMap、HashSet、Data、Cause等一切依赖该契约的模块中行为正确。
一个值得记住的通用原则:当自定义类型同时存在"多种内部表示"与"语义相等"时,哈希实现必须与相等性实现使用同一套规范化口径,否则哈希表的查找结果将不可预测。Duration此次以规范化纳秒作为统一哈希口径,正是这一原则的教科书式落地。
如需继续深入,可参考:
- 哈希入口与 bigint/number 哈希规则:packages/effect/src/Hash.ts
Duration的构造、相等性与换算实现:packages/effect/src/Duration.ts- 本次修复的回归测试:packages/effect/test/Duration.test.ts
【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考