ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

Effect 修复 Duration 哈希契约:规范化纳秒哈希如何消除 Millis/Nanos 双表示带来的 Hash/Equal 不一致 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.tstype 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。修复方案统一走规范化纳秒哈希本次 changeseteffect包的patch级修复将Duration的Hash.symbol实现改为对规范化的纳秒形式进行哈希。修复后的实现位于DurationProto的Hash.symbol方法见 Duration.tsHash.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.tsDuration正是通过该机制接入整个 Effect 的相等性与哈希体系Equal.symbol见 Duration.ts。此次修复保证Duration在HashMap、HashSet、Data、Cause等一切依赖该契约的模块中行为正确。一个值得记住的通用原则当自定义类型同时存在多种内部表示与语义相等时哈希实现必须与相等性实现使用同一套规范化口径否则哈希表的查找结果将不可预测。Duration此次以规范化纳秒作为统一哈希口径正是这一原则的教科书式落地。如需继续深入可参考哈希入口与 bigint/number 哈希规则packages/effect/src/Hash.tsDuration的构造、相等性与换算实现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),仅供参考
返回列表