本章导读
卷二从数学开始。xrtMath 家族补齐的是 C 标准数学库的"工程缺口":Min/Max/Sign 这类泛型关系运算、NaN/Inf/有限的三分分类、Log1p/Expm1 这类数值稳定版本,以及全书反复使用的浮点"接近"判断 xrtMathNear。本章除了把函数族过一遍,重点讲透两个工程上最容易踩的点:浮点为什么不能用 == 比较、取模在负数下的语义——它们是后面所有数值、采样、比较章节的地基。
10.1引入
三段代码,三段记忆。第一段:断言 0.1 + 0.2 == 0.3 失败了——浮点表示误差是常识,但每个项目仍会重新踩一次。第二段:图形代码里 fmod(-7, 3) 返回 -1 而不是期望的 2——C 的取模随被除数符号(截断语义),与 Python 等语言(向下取整语义)不同,跨语言背景的团队在这里写出过真实的方向 bug。第三段:想在 1e9 量级的坐标上判断"两个点是否重合",用绝对容差 1e-9 永远不相等,用相对容差在零附近又彻底失效——单一容差两头不讨好。
这三段记忆对应 xrtMath 的三个核心设计:Near 的组合容差、Mod 的显式截断语义(文档写明、测试钉死,不靠猜)、分类族的三分完备(NaN/Inf/有限互斥且覆盖)。数学库很小,但每一处都对着真实工程伤口。
还有一个容易被忽视的价值:这一章的函数在后面每一卷都会反复出现。容差比较是第 11 章分布检验与卷十二性能基准的断言基础;分类三分是数据解析(卷四)清洗非法浮点的第一道闸;Log2/Exp2 是容器容量增长与位图索引的数学底座。把 Near 与分类族用熟,后面读到任何数值断言都不需要再想第二遍。
10.2概念
函数族地图
| 分族 | 函数 | 一句话 |
|---|---|---|
| 关系 | xrtMathMin / Max / Sign / Trunc / Mod / Rad | 取小取大、三态符号、截断取整、截断取模、角度转弧度 |
| 分类 | xrtMathIsNaN / IsInf / IsFinite | NaN / 无穷 / 有限三分,互斥且完备 |
| 指对数 | xrtMathLog2 / Exp2 / Log1p / Expm1 | 2 底对数指数;Log1p/Expm1 在零附近数值稳定 |
| 根 | xrtMathCbrt | 立方根,负数域正确(pow(x, 1/3) 在负数上是坑) |
| 接近 | xrtMathNear / xrtMathIntNear | 组合容差浮点比较;整数"接近"判断 |
Sign 返回三态 -1/0/1(不是布尔)——它服务于排序与方向判断,零有自己的语义。Log1p(x) 计算 ln(1+x):当 x 极小时直接算 log(1+x) 会把 1+x 舍入成 1 得到零,Log1p 专门绕开这个精度陷阱;Expm1 是它的反操作。这对函数在利率、概率、增量统计里是标配。
Rad 把角度转弧度——三角函数吃弧度,物理与配置里的角度是度,这一对转换最容易写错(乘 π/180 还是除)。Trunc 是向零截断(2.7 → 2、-2.7 → -2),注意它与"向下取整"(-2.7 → -3)不同——需要向下取整语义时自己组合,别把 Trunc 当万能取整。
浮点比较:组合容差
xrtMathNear(a, b, abs, rel) 的判定是 |a-b| ≤ max(abs, rel×|a|)——绝对容差与相对容差取大者生效:
选容差的实用口径:绝对容差 = 你认为"物理上无差别"的量级(坐标 1e-9 米),相对容差 = 你接受的舍入比例(千分之一传 0.001)。两个都传永远是最稳的写法。
一个综合例子:坐标对齐
把本章工具串成一个真实判断:"两次激光测距算出的坐标是否指向同一个点?"假设量级在米级、仪器精度 0.01 毫米、两次测量有 3 毫秒时钟差。正确的判断是三步:先 IsFinite 滤掉测量失败产生的 NaN/Inf(分类先行,否则后续比较全部无意义);坐标差用 Near(dx, 0, 1e-5, 1e-9) 判零附近、Near(dx, x, 0, 1e-6) 判大数值——组合容差两侧都开;时间差用 IntNear(t1, t2, 5000) 毫秒容差。这个三步模式(分类 → 组合容差 → 整数容差)在传感器融合、采样对齐、回归测试断言里反复出现,值得当成肌肉记忆。
取模与符号语义
xrtMathMod(7, 3) = 1、xrtMathMod(-7, 3) = -1——结果随被除数符号(C fmod 的截断语义)。这与"模运算是循环映射"的直觉(-7 mod 3 = 2,向下取整语义)不同。为什么选截断?因为它与 C 标准一致、与硬件指令一致、且 Mod(a,b) 满足 Trunc(a/b)*b + Mod(a,b) == a 的恒等式——语义自洽且可推导。需要"总是非负"的循环下标,用 ((a % n) + n) % n 的惯用法显式表达,别让库猜你。
与第 11 章的衔接:数值确定性底座
xrtMath 家族与第 11 章的随机体系共享"确定性可复现"的地基:随机模块的可复现断言依赖本章 Near 做数值对拍。两章合起来构成卷二"数值确定性"的底座——后面的容器(卷三)与解析(卷四)在测试里都会回头用这两个工具。
跨平台一致性
xrtMath 的另一层价值是同一输入跨编译器得到可比较的结果——分类、关系、Near 全是精确语义;指对数族在常用平台上的精度策略保持一致。测试快照(比如第 11 章随机数的固定种子序列)因此能在不同 CI 机器上对拍。
10.3示例
完整程序:函数族双向断言
来自仓库范例 examples/math/tour/main.c,每个函数都用已知常数做了正反两个方向的断言:
*/ #include <stdio.h> #include <math.h> #include <xrt.h> /* 浮点近似比较(容差 1e-9)。 */ static bool exampleNear(double fLeft, double fRight) { return xrtMathNear(fLeft, fRight, 1e-9, 0.0); } int main(void) { int iResult = 1; /* ---- 关系族 ---- */ if ( (xrtMathMin(2.0, 3.0) != 2.0) || (xrtMathMin(3.0, 2.0) != 2.0) || (xrtMathMax(2.0, 3.0) != 3.0) || (xrtMathMax(3.0, 2.0) != 3.0) || (xrtMathMin(-1.0, 1.0) != -1.0) ) { goto Cleanup; } if ( (xrtMathSign(-5.0) != -1) || (xrtMathSign(5.0) != 1) || (xrtMathSign(0.0) != 0) ) { goto Cleanup; } if ( (xrtMathTrunc(2.7) != 2.0) || (xrtMathTrunc(-2.7) != -2.0) ) { goto Cleanup; } if ( !exampleNear(xrtMathMod(7.0, 3.0), 1.0) || !exampleNear(xrtMathMod(-7.0, 3.0), -1.0) ) { goto Cleanup; /* 取模随被除数符号(C 的 fmod 截断语义) */ } if ( !exampleNear(xrtMathRad(180.0), 3.14159265358979) ) { goto Cleanup; } printf("math: min/max/sign/trunc/mod/rad ok\n"); /* ---- 分类族:NaN / Inf / 有限三分 ---- */ { double fNaN = 0.0 / 0.0; double fInf = 1.0 / 0.0; if ( !xrtMathIsNaN(fNaN) || xrtMathIsNaN(1.0) || xrtMathIsNaN(fInf) || !xrtMathIsInf(fInf) || xrtMathIsInf(1.0) || xrtMathIsInf(fNaN) || !xrtMathIsFinite(1.5) || xrtMathIsFinite(fNaN) || xrtMathIsFinite(fInf) ) { goto Cleanup; } } printf("math: isnan/isinf/isfinite = 1/1/1\n"); /* ---- 指对数族 ---- */ if ( !exampleNear(xrtMathLog2(8.0), 3.0) || !exampleNear(xrtMathLog2(1.0), 0.0) || !exampleNear(xrtMathExp2(10.0), 1024.0) || !exampleNear(xrtMathLog1p(0.0), 0.0) || !exampleNear(xrtMathExpm1(0.0), 0.0) || !exampleNear(xrtMathLog1p(1.0), 0.693147180559945) || !exampleNear(xrtMathExpm1(1.0), 1.718281828459045) ) { goto Cleanup; } printf("math: log2/exp2/log1p/expm1 ok\n"); /* ---- 立方根:负数域 ---- */ if ( !exampleNear(xrtMathCbrt(27.0), 3.0) || !exampleNear(xrtMathCbrt(-27.0), -3.0) || !exampleNear(xrtMathCbrt(0.0), 0.0) ) { goto Cleanup; } printf("math: cbrt(-27) = %g\n", xrtMathCbrt(-27.0)); iResult = 0; Cleanup: return iResult; }
刚才发生了什么。 ① 关系族双向验证:Min(2,3) 与 Min(3,2) 都得 2——参数顺序不影响语义,这类"对称断言"是关系函数的标准测法。② Mod(-7,3) 断言为 -1,把截断语义钉进测试——如果你的代码依赖别的语义,会在断言处立刻暴露而不是上线后变成方向 bug。③ 分类三函数用 0.0/0.0(NaN)、1.0/0.0(Inf)、1.5(有限)三个样本互斥验证——NaN 连"等于自己"都是假,分类函数是唯一可靠的判别手段。④ Cbrt(-27) = -3:立方根在负数域有定义且正确,这是它区别于 pow 的关键。
完整程序:组合容差的三种场景
来自 examples/math/near/main.c,三种容差选择一次走完:
*/ #include <stdio.h> #include <xrt.h> int main(void) { /* 相对容差:100 vs 100.05,差 0.05 ≤ 0.001×100 → near。 */ printf("relative: %s\n", xrtMathNear(100.0, 100.05, 0.0, 0.001) ? "near" : "different"); /* 绝对容差:零附近,1e-10 ≤ 1e-9 → near(相对容差在此失效)。 */ printf("absolute: %s\n", xrtMathNear(0.0, 1e-10, 1e-9, 0.0) ? "near" : "different"); /* 整数版:|1000-1003| = 3 ≤ 5 → near(采样抖动判断等场景)。 */ printf("integer : %s\n", xrtMathIntNear(1000, 1003, 5) ? "near" : "different"); return 0; }
刚才发生了什么。 ① 100 与 100.05 用相对容差 0.001 判近:差 0.05 ≤ 0.1,通过——大数值场景该用相对。② 0 与 1e-10 用绝对容差 1e-9 判近——相对容差在这里会要求两者差为零(0 的任意倍都是 0),彻底失效。③ 整数版 IntNear(1000, 1003, 5):采样抖动、时钟偏差这类"差几个单位无所谓"的判断不需要浮点容差那一套。注意示例里两个 Near 调用各传了一个零容差——组合容差允许你只开一边,但工程上更常见的做法是两侧都给:绝对侧兜住零附近,相对侧兜住大数值,一行判断覆盖全量程。
把 Near 放进断言宏是很多团队的标准动作:ASSERT_NEAR(got, want) 展开成 Near 调用,测试既不因表示误差误报,也不因容差过宽漏报。写测试时先想清楚"这道断言在什么量级上工作",再选容差——这比背数字重要。
10.4契约
- Near 组合:
|a-b| ≤ max(abs, rel×|a|);容差传零即关闭该侧;两侧都传最稳;断言宏建议直接建立在Near上。 - Mod 截断:结果随被除数符号,恒等式
Trunc(a/b)*b + Mod(a,b) == a成立;需要非负下标自己规范化。 - Trunc 向零:
-2.7 → -2;与向下取整不同,两种语义不要混用。 - Sign 三态:
-1/0/1,零不是正数也不是负数。 - 分类三分:NaN/Inf/有限互斥且完备;判断 NaN 只能用
IsNaN,任何比较运算对 NaN 都不可靠。 - Rad 方向:角度 → 弦度,乘
π/180;写反了三角函数全错,值得用断言钉一遍。 - Cbrt 负域:立方根负数正确;不要用
pow(x, 1.0/3.0)替代。
常见错误
症状:断言偶发失败、图形对齐判定随机不通过、不同平台行为不一致;最阴险的形态是"debug 构建过、release 构建挂"。
原因:浮点运算的舍入路径依赖编译器优化与中间精度,0.1+0.2 的结果与 0.3 在二进制上就是不同的数。
double fSum = 0.1 + 0.2; if ( fSum == 0.3 ) { /* 永假:二进制表示不同 */ accept(); }
double fSum = 0.1 + 0.2; if ( xrtMathNear(fSum, 0.3, 1e-12, 1e-9) ) { /* 组合容差 */ accept(); }
症状:环形缓冲下标、哈希分桶在负数输入下越界或方向翻转;从 Python/Go 迁移的代码高发。
原因:C 的 % 与 xrtMathMod 都是截断语义,负被除数得负结果;"模为非负"是另一些语言的语义。
int iIndex = (int)xrtMathMod(iOffset, 3.0); /* iOffset=-7 → -1 */ Touch(Buffer[iIndex]); /* 越界:负下标 */
int iIndex = ((int)iOffset % 3 + 3) % 3; /* 显式规范化到 [0,3) */ Touch(Buffer[iIndex]);
编程练习
写程序构造 NaN(0.0/0.0)、Inf(1.0/0.0)、有限(1.5)三个值,用分类族打印每个值的三分类结果(三行九个布尔),验证互斥完备;再用 == 比较 NaN 与它自己,打印结果并解释为什么分类不能靠比较。
对 Near(100.0, x, 0.0, 0.001) 用二分找出判近的 x 边界,与手算 100×(1±0.001) 对比。提示:边界外侧再挪一个 ulp(最小表示单位)就该翻转为"不相近";两侧边界各测一次,确认相对容差是对称生效的。
对 x 从 1e-15 到 1e-1 取十个点,分别计算 log(1+x) 与 xrtMathLog1p(x)、与高精度参考值(可用 long double 版本近似)的误差,画成对照表。验收标准:表格显示 log(1+x) 在小 x 处误差爆炸而 Log1p 保持稳定,并用自己的话解释原因(提示:1+x 的舍入)。
小结
| 知识点 | 速查 | ||
|---|---|---|---|
| 比较 | Near(a,b,abs,rel) 取 `max(abs, rel× | a | );整数用 IntNear` |
| 取模 | 截断语义随被除数符号;非负下标自己 ((x%n)+n)%n | ||
| 符号 | Sign 三态 -1/0/1 | ||
| 分类 | IsNaN / IsInf / IsFinite 互斥完备;NaN 与任何比较都不可靠 | ||
| 稳定函数 | 零附近用 Log1p / Expm1,别用 log(1+x) | ||
| 负数根 | 立方根用 Cbrt;pow(x,1/3) 负数不可用 |
📖 本章涉及的 API 参考手册:math(20 个条目)
点击查看完整签名、参数约束、返回值与错误说明