本章导读

卷二从数学开始。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 / IsFiniteNaN / 无穷 / 有限三分,互斥且完备
指对数xrtMathLog2 / Exp2 / Log1p / Expm12 底对数指数;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|)——绝对容差与相对容差取大者生效:

1零附近相对容差失效(0 与 1e-10 差十倍却都该算零)→ 传绝对容差
2大数值绝对容差失效(1e9 差 0.05 在精度上无意义)→ 传相对容差
3组合两个都传,Near 取 max——两组场景各自被照顾
4整数IntNear 用单一容差(整数无表示误差)

选容差的实用口径:绝对容差 = 你认为"物理上无差别"的量级(坐标 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,每个函数都用已知常数做了正反两个方向的断言:

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;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single -include xrt.h impl.c examples/math/tour/main.c -lws2_32 -liphlpapimath: min/max/sign/trunc/mod/rad okmath: isnan/isinf/isfinite = 1/1/1math: log2/exp2/log1p/expm1 okmath: cbrt(-27) = -3

刚才发生了什么。 ① 关系族双向验证: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,三种容差选择一次走完:

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;
}
$ gcc -O1 -DXRT_MODULE_ALL -I single impl.c examples/math/near/main.c -lws2_32 -liphlpapirelative: nearabsolute: nearinteger : near

刚才发生了什么。 ① 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) 替代。

常见错误

坑 1:浮点用 == 比较

症状:断言偶发失败、图形对齐判定随机不通过、不同平台行为不一致;最阴险的形态是"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();
}
坑 2:假设取模总是非负

症状:环形缓冲下标、哈希分桶在负数输入下越界或方向翻转;从 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 个条目)

点击查看完整签名、参数约束、返回值与错误说明