Effect 修复 Number.remainder 科学计数法小浮点取模错误:源码剖析与测试验证
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
本篇文章围绕 effect-smol 仓库中Number.remainder的一处缺陷修复展开:当操作数以科学计数法表示(如1e-7)时,旧实现会返回错误结果。文章将结合 Number.ts 的源码实现与 Number.test.ts 的完整测试矩阵,讲解修复原理、边界语义以及该函数在 Effect Schema 的multipleOf校验中的实际应用,帮助读者掌握在 TypeScript 中安全处理浮点取模的正确姿势。
背景:原生%运算符在浮点取模上的精度陷阱
JavaScript 的%运算符返回的是"截断除法"的余数,其符号跟随被除数。它本质上是二进制浮点运算,因此对十进制小数天然不精确:
0.3 % 0.2 // => 0.09999999999999998(期望 0.1)当操作数落入科学计数法区间时问题更隐蔽。Number.prototype.toString()对1e-7这类小浮点会直接输出"1e-7"字符串,任何基于字符串小数点拆分(split("."))来对齐小数位的算法都会失效,从而计算出完全错误的余数。这正是本次 changeset 修复的根因,记录见 CHANGELOG.md(变更条目 #1893 与后续的 #2605)。
Number.remainder 的 API 形态
在 Effect 的Number模块中,remainder与divide、increment等数学函数一样遵循双调用形态约定(data-first />export const remainder: { (divisor: number): (self: number) => number //>import { Number, pipe } from "effect" Number.remainder(3, 2) // => 1 Number.remainder(0.3, 0.2) // => 0.1(原生 % 会得到 0.09999999999999998) pipe(0.3, Number.remainder(0.2)) // => 0.1
官方文档对它的定位是:"返回一个操作数除以另一个操作数后剩下的余数,始终取被除数的符号;用于在计算数值余数时,比直接使用 JavaScript 的%对十进制操作数保留更好的精度"。
修复前的问题复现
以divisor = 1e-7为例。旧实现先把两个操作数都转成字符串,统计小数点后的位数,再用toFixed(decCount)对齐后转整数求模。但1e-7的字符串形式是"1e-7",split(".")[1]取不到小数部分,对齐逻辑完全失效,导致余数结果错误——这正是 changeset 描述的 "incorrect results for small floats in scientific notation (e.g.1e-7)"。
修复后,正确的语义是:当被除数是1e-7的整数倍时余数为 0,否则返回精确余数。例如:
Number.remainder(3e-7, 1e-7) // => 0 Number.remainder(2.5e-7, 1e-7) // => 5e-8(2.5 不是整数倍)修复实现:BigInt 精确整数化算法
本次修复在 Number.ts 中引入了一条科学计数法专用分支。remainder主体先做字符串探测:
export const remainder = dual(2, (self, divisor) => { const selfString = self.toString() const divisorString = divisor.toString() if (selfString.includes("e") || divisorString.includes("e")) { // 科学计数法分支:先做有限性与零除数校验 if (!Number.isFinite(self) || !Number.isFinite(divisor) || divisor === 0) { return NaN } return remainderWithScientificNotation(self, divisor) } // 常规小数路径:按小数点位数对齐后转整数求模 const selfDecCount = (selfString.split(".")[1] || "").length const divisorDecCount = (divisorString.split(".")[1] || "").length const decCount = Math.max(selfDecCount, divisorDecCount) const selfInt = parseInt(self.toFixed(decCount).replace(".", "")) const divisorInt = parseInt(divisor.toFixed(decCount).replace(".", "")) return (selfInt % divisorInt) / Math.pow(10, decCount) })常规路径的局限在于toFixed最多支持 100 位小数,超出即抛 RangeError,这也是必须单独处理科学计数法的原因之一。
科学计数法路径的核心是toScientificInteger:把操作数转换为"整数系数 × 10 的整数次幂"的精确表示,再以 BigInt 做模运算,彻底绕开浮点精度:
function toScientificInteger(n: number): readonly [coefficient: bigint, exponent: number] { const scientific = Math.abs(n).toExponential() const eIndex = scientific.indexOf("e") const digits = scientific.slice(0, eIndex).replace(".", "") const coefficient = BigInt(digits) * (n < 0 ? -BigInt(1) : BigInt(1)) return [coefficient, Number(scientific.slice(eIndex + 1)) - digits.length + 1] }随后把两个操作数统一放大到相同的最小指数(Math.min(selfExponent, divisorExponent)),用 BigInt 求余后按指数缩回:
function remainderWithScientificNotation(self, divisor) { const [selfCoefficient, selfExponent] = toScientificInteger(self) const [divisorCoefficient, divisorExponent] = toScientificInteger(divisor) const exponent = Math.min(selfExponent, divisorExponent) const selfInteger = selfCoefficient * BigInt(10) ** BigInt(selfExponent - exponent) const divisorInteger = divisorCoefficient * BigInt(10) ** BigInt(divisorExponent - exponent) const out = selfInteger % divisorInteger if (out === BigInt(0)) { return self < 0 || Object.is(self, -0) ? -0 : 0 } const remainder = Number(`${out}e${exponent}`) // 防下溢:余数缩回时可能被舍入为 0,此时返回带符号的最小正数 return remainder === 0 ? Math.sign(self) * Number.MIN_VALUE : remainder }两个细节值得注意:其一,余数为 0 时保留被除数的符号(包括通过Object.is识别的-0);其二,当 BigInt 余数缩回 Number 时若因精度限制被舍入为 0,会退回Math.sign(self) * Number.MIN_VALUE,避免把非零余数错误地报告为整除。
边界语义矩阵:测试如何锁定行为
Number.test.ts 为该修复提供了完整的测试矩阵,逐条对应上文实现的语义承诺:
| 场景 | 用例 | 期望结果 |
|---|---|---|
| 符号跟随被除数 | remainder(-5, 2) | -1 |
| 负零保留 | remainder(-4, 2) | -0(需Object.is判定) |
| 负除数 | remainder(5, -2)/remainder(-5, -2) | 1/-1 |
| 除数为 0 | remainder(5, 0)、remainder(1e-101, 0) | NaN |
| 非有限操作数 | NaN、±Infinity参与 | NaN |
| 科学计数法整除 | remainder(3e-7, 1e-7) | 0 |
| 科学计数法非整除 | remainder(2.5e-7, 1e-7) | 5e-8 |
| 超出 toFixed 上限 | remainder(2.5e-101, 1e-101) | 5e-102 |
| 次正规数整除 | remainder(Number.MIN_VALUE * 2, Number.MIN_VALUE) | 0 |
| 非零次正规余数 | remainder(1.042e-321, 1e-323) | Number.MIN_VALUE |
| 大数科学计数法 | remainder(1e21, 3) | 1 |
其中 "beyond the toFixed precision limit" 用例(1e-101级别)直接验证了 BigInt 路径存在的必要性:这类数值即使走toExponential也无法用toFixed(100)表达,必须依赖整数系数展开。次正规数(subnormal)用例则验证了Number.MIN_VALUE下溢保护分支的正确性。
实际应用:JSON Schema multipleOf 校验
remainder不是孤立的数学工具,它在 Effect Schema 的 JSON Schema 互操作层有真实消费场景。在 fromJsonSchemaDocument.ts 中,解析multipleOf关键字时正是通过remainder判断数值是否整除:
return remainder(value as number, payload.divisor) === 0由于 JSON Schema 的multipleOf允许任意精度的十进制小数(如multipleOf: 0.01),此处对remainder的精度要求极高——任何取模误差都会直接导致合法的 schema 校验失败。这也解释了为什么该缺陷修复被定位为patch级别:它影响的是 Effect 中所有依赖Number.remainder进行数值判定的下游功能,而不只是数学模块本身。
小结
- 问题本质:原生
%与基于字符串小数点对齐的朴素算法,都无法正确处理科学计数法(e记法)表示的浮点取模。 - 修复方案:在 Number.ts 中新增
remainderWithScientificNotation+toScientificInteger,用toExponential分解出整数系数与指数,再以 BigInt 精确求模,并辅以-0符号保留与Number.MIN_VALUE下溢保护。 - 验证手段:Number.test.ts 覆盖了科学计数法、超 toFixed 精度、次正规数、大数、符号与负零、非有限输入等全部关键分支。
- 影响范围:
Number.remainder同时是 fromJsonSchemaDocument.ts 中multipleOf校验的底层实现,修复直接提升了 Effect Schema 对十进制精度约束的判定准确性。
如果你在自己的项目中需要处理金额、比例、进制换算等对十进制余数精度敏感的逻辑,可以直接采用本文的"字符串分解 + BigInt 求模"模式,或者在 TypeScript 项目中直接引入 Effect 的Number.remainder(effect包 v4 通过npm install effect@rc安装,详见 README.md),从而获得与上述测试矩阵完全一致的可预期行为。
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考