Effect 修复 Duration 哈希契约:规范化纳秒哈希如何消除 Millis/Nanos 双表示带来的 Hash/Equal 不一致
2026/9/15 19:01:17 网站建设 项目流程

Effect 修复 Duration 哈希契约:规范化纳秒哈希如何消除 Millis/Nanos 双表示带来的 Hash/Equal 不一致

【免费下载链接】effectBuild production-ready applications in TypeScript项目地址: https://gitcode.com/GitHub_Trending/ef/effect

导读

本文围绕 Effect 仓库中一项针对Duration哈希实现的 patch 级修复展开:Duration内部存在MillisNanos两套表示,此前两个在语义上相等(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 契约:两个值相等,其哈希值必须相同。哈希表(HashSetHashMap)依赖这一点来确定查找位置:如果两个相等键的哈希值不同,HashSet.hasHashMap.get就会在错误的分桶中寻找,导致"明明存在却查不到"的静默失败。

然而修复前,Durationequals与哈希实现走的是两条不同的路径:

  • 相等性判断基于语义归一化。Duration.equals委托给Equivalence(见 Duration.ts),而Equivalence内部使用matchPair,在比较MillisNanos两种表示时,会先把Millis通过toNanosUnsafe转换为规范化纳秒再比较(见 Duration.ts 与 toNanosUnsafe)。所以Duration.seconds(5)Duration.nanos(5_000_000_000n)判定为相等。
  • 哈希计算此前却直接基于原始内部表示(Millis按毫秒数哈希、Nanos按纳秒数哈希)。由于 5000 与 5000000000n 是截然不同的数值,两个相等时长会得到不同的哈希值。

于是出现了违反契约的组合:Equal.equals(a, b) === trueHash.hash(a) !== Hash.hash(b)。对以Duration为键的HashSet/HashMap而言,这会造成成员查找和按键取值时哈希分桶不一致,产生难以排查的静默 bug。

修复方案:统一走规范化纳秒哈希

本次 changeset(effect包的patch级修复)将DurationHash.symbol实现改为对规范化的纳秒形式进行哈希。修复后的实现位于DurationProtoHash.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) } }

关键点拆解:

  1. Millis表示先换算成纳秒millis * 1_000_000把毫秒放大到纳秒量级,与equals内部toNanosUnsafe的换算(roundMillisToNanos,即roundTiesAwayFromZero(millis * 1_000_000),见 Duration.ts)保持同一口径,保证"相等即同哈希"。
  2. Nanos表示直接哈希纳秒值Hash.hash(this.value.nanos)对 bigint 按其十进制字符串形式哈希(见 Hash.ts 中 bigint 分支string(self.toString(10)))。
  3. 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.infinityDuration.negativeInfinityEquivalence中按_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.equalsEqual.equals对两种表示判定相等(语义一致性前提);
  • Hash.hash对两种表示产生相同结果(契约核心:相等必同哈希);
  • 最终用HashSet.has(HashSet.make(millisTagged), nanosTagged)做端到端验证:以Millis形式放入集合,用Nanos形式查询,必须命中。

其中HashSet.has这一行正是对"静默破坏HashSet/HashMap查找"这一原始缺陷的直接回归防线——修复前它会返回false,修复后返回true

对开发者的影响与启示

本次修复以patch级别发布,行为上对绝大多数调用方透明,但对以下几类使用方式有直接意义:

  1. Duration为键的集合/映射:此前HashSet.hasHashMap.get若出现"用构造方式不同的相等时长查不到"的现象,本次修复后不再发生。构造方式(seconds/millisvsnanos/bigint)不再影响哈希分桶。
  2. 跨表示混合运算Duration.sumDuration.divide等运算会产生混合Millis/Nanos表示(如 测试用例 中Duration.divide(Duration.seconds(1), 3)得到Duration.nanos(333333333n)),这些结果与直接构造的等值时长久违地拥有相同哈希。
  3. 通用Equal/Hash基础设施Hash.hash对实现Hash.symbol的对象会调用其自定义哈希(见 Hash.ts),Duration正是通过该机制接入整个 Effect 的相等性与哈希体系(Equal.symbol见 Duration.ts)。此次修复保证DurationHashMapHashSetDataCause等一切依赖该契约的模块中行为正确。

一个值得记住的通用原则:当自定义类型同时存在"多种内部表示"与"语义相等"时,哈希实现必须与相等性实现使用同一套规范化口径,否则哈希表的查找结果将不可预测。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),仅供参考

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

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

立即咨询