想象一下我们十进制里的分数 1/3
。你想把它写成小数,结果是 0.333333...
,它是一个无限循环小数,你永远也写不完。无论你写多少个 3,它都只是一个近似值,永远不等于 1/3
。
计算机内存是有限的,它没办法存储无限的小数位。所以,它只能在某个位置进行截断或舍入。这就导致了计算机存储的 0.1
,其实并不是精确的 0.1
,而是一个非常非常接近它的近似值,比如 0.10000000000000000555...
。
这个微小的误差,就是所有问题的“万恶之源”。
1. IEEE 754
为了统一标准,计算机界制定了 IEEE 754 规范,它定义了两种最常见的小数存储方式:
- 单精度浮点数 (float): 用 32 位 (4 字节) 存储。
- 双精度浮点数 (double): 用 64 位 (8 字节) 存储,精度更高,也是大多数现代编程语言(如 Python、JavaScript)默认的小数类型。
你可以把它理解成二进制版本的“科学记数法”。一个浮点数被拆成三个部分存储:
- 符号位 (Sign): 1 位,表示正数或负数。
- 指数位 (Exponent): 表示数值的大小范围,类似科学记数法里的
10
的多少次方。 - 尾数位 (Mantissa/Fraction): 表示数值的精度,也就是那个被截断的二进制小数。
double
比 float
拥有更多的指数位和尾数位,所以它能表示的数值范围更大,精度也更高。但请记住,即便是精度更高的 double
,它也只是一个更精确的“近似值”而已。
1.1 原理
要理解它的原理,我们先从熟悉的东西入手:十进制的科学记数法。
一个数字,比如 123.45
,我们可以写成 1.2345 × 10^2
。
一个数字,比如 -0.00678
,我们可以写成 -6.78 × 10^-3
。
无论多大或多小的数,我们都可以用 “符号” (正/负) + “有效数字” (Mantissa) + “指数” (Exponent) 的形式来表示。
IEEE 754 的核心思想完全一样,只不过它用的是二进制。
它规定,一个浮点数(以 32 位的 float
为例)在内存中必须以如下格式存储:
[符号位 S (1位)] [指数位 E (8位)] [尾数位 F (23位)]
这三部分共同构成了二进制的科学记数法,其值为:
Value = (-1)^S × (1.F) × 2^(E - Bias)
1.2 乘 2 取整法
计算机它不认识“0.75”这么复杂的数字,它只认识两个最简单的暗号:“1”(代表“有”)和“0”(代表“没有”)。
0.75 瓶果汁倒进了魔法机器里,变成了 1.5 瓶——也就是满满的 1 瓶,还多出来一个小小的半瓶(0.5 瓶)。
奇奇把剩下的那 0.5 瓶果汁,又倒进了魔法机器里。正好是满满的 1 瓶!
我们每次都把剩下的果汁拿去翻倍,变成暗号“1”或“0”。直到果汁用完为止。是不是很简单呀?
1.3 将十进制 9.625 编码为 32 位浮点数
我们用一个具体的例子 9.625
走一遍完整的流程,你就全明白了。
第 1 步:转换为二进制
- 整数部分:
9
→1001
- 小数部分
0.625
:使用“乘 2 取整法”0.625 × 2 = 1.25
→ 取整数1
0.25 × 2 = 0.5
→ 取整数0
0.5 × 2 = 1.0
→ 取整数1
- 所以,
0.625
→0.101
- 合在一起,
9.625
的二进制就是1001.101
。
第 2 步:规格化 (Normalize)
把这个二进制数写成科学记数法 1.xxxx... × 2^n
的形式。1001.101
→ 1.001101 × 2^3
(小数点向左移动了 3 位)
现在我们得到了两个关键信息:
- 真正的尾数部分是
001101
(小数点后面的部分) - 真正的指数是
3
第 3 步:拆解到三个部分 (S, E, F)
符号位 (S)
9.625
是正数,所以 S = 0。尾数位 (F - Fraction)
这里有一个天才般的设计:隐藏的整数 1 (Implicit Leading Bit)。
因为任何规格化的二进制数,第一位永远是1
(1.xxxx...
),所以根本没必要存储它!这样就可以省出 1 位来提高精度。
所以,我们只存储小数点后面的部分:001101
。
32 位float
的尾数位有 23 位,所以我们要在后面补 0 把它填满。
F = 00110100000000000000000 (共 23 位)指数位 (E - Exponent)
这里有另一个巧妙的设计:指数偏移 (Exponent Bias)。
指数可以是正数(比如2^3
),也可以是负数(比如2^-5
)。如果直接存储,就需要一个额外的符号位来表示指数的正负,这很麻烦。
设计者决定,将存储的指数 E 设计为一个无符号整数。通过加上一个“偏移量”,把所有可能的指数都映射到正数范围。- 对于 32 位
float
,指數有 8 位(范围 0-255),偏移量 Bias = 127。 - 存储的指数
E = 真实指数 + Bias
。
在我们的例子里,真实指数是3
。
所以,E = 3 + 127 = 130
。
现在把130
转换为 8 位二进制:10000010
。
E = 10000010。
- 对于 32 位
第 4 步:组合起来
现在,把 S, E, F 按顺序拼接在一起:
S | E | F
0 | 10000010 | 00110100000000000000000
这就是 9.625
在你电脑内存里真实的 32 位二进制样子!
1.4 为什么 0.1 无法精确表示?
现在你已经掌握了原理,我们再回头看 0.1
的问题就一目了然了。
我们对 0.1
做“乘 2 取整法”:
0.1 × 2 = 0.2
→0
0.2 × 2 = 0.4
→0
0.4 × 2 = 0.8
→0
0.8 × 2 = 1.6
→1
0.6 × 2 = 1.2
→1
0.2 × 2 = 0.4
→0
(开始循环了…)
你会发现 0.1
的二进制是 0.0001100110011...
,其中 0011
无限循环。
而尾数位 F
只有 23 位(double
也只有 52 位),是有限的。计算机只能在某个位置进行截断,这就导致存储的值只是 0.1
的一个非常接近的近似值。这个微小的误差,就是万恶之源。
1.5 比较的陷阱
1 | # 一个经典的例子 |
2. 运算过程
2.1 为什么这么存
“好坏开关”格子 (符号 S):
这个最简单啦!就像玩具的开关一样,1 代表这是个“淘气包”玩具(负数),0 代表这是个“乖宝宝”玩具(正数)。用一个最小的位置,就决定了数字的“性格”,是不是很省地方?“放大镜/缩小镜”格子 (指数 E):
这个格子最神奇!它告诉电脑,我们的数字到底有多“大”或者多“小”。- 如果这里放一个很大的数,就等于给我们的数字装上了一个 超级放大镜,能看到像宇宙里星星那么远的距离!
- 如果这里放一个很小的数,就等于装上了一个 超级缩小镜,能看到像小细菌那么微小的东西!
好处: 只用一个小小的盒子,我们就能表示出特别特别大的数,和特别特别小的数。这就是它的“范围广”!
“积木形状”格子 (尾数 F):
这个格子装着数字最具体的“样子”,就像一盒精密的积木。它决定了我们的数字有多“精确”。比如1.2345
就比1.2
要精确,因为它描述得更详细,用的“积木”更多。
好处: 它保证了我们描述数字的“细节”程度,这就是它的“精度高”!
它把一个数最关键的三个信息——正负(性格)、大小(尺寸)、细节(样子)——分门别类地放好。这样做让我们能用有限的空间(比如 32 个小格子),既能表示天文数字,又能表示微观粒子,还保证了足够的精确度。非常高效!
2.2 计算时怎么处理
好了,现在我们知道怎么存了。那电脑要计算 9.625 + 0.5
的时候,它在做什么呢?
想象一下,有两只小猴子,“大壮”和“小巧”,它们要把自己的身高加在一起。
- 大壮 (代表 9.625): 它的身高暗号是
1.001101 × 2³
。它的“放大镜”度数是 3。 - 小巧 (代表 0.5): 它的身高暗号是
1.0 × 2⁻¹
。它的“放大镜”度数是 -1。
电脑看到这两个暗号,它可不会傻乎乎地直接加哦。它会说:“等一下!你们俩用的‘放大镜’度数不一样,没法直接加!得先统一标准!”
第 1 步:对齐放大镜(专业术语叫“对阶”)
电脑会遵守一个规则:听度数大的那个!
大壮的放大镜度数是 3
,小巧的是 -1
。所以,小巧必须换成和大壮一样的 3
度放大镜。
可是,随便换放大镜,身高不就变了吗?对!所以为了保持身高不变,当小巧把放大镜度数从 -1
调到 3
(相当于放大了 2⁴=16
倍)时,它自己的身高数字就必须缩小 16 倍来补偿。
原来的身高数字 1.0
,小数点就要向左移动 4 位,变成 0.0001
。
现在,小巧的身高暗号变成了:0.0001 × 2³
。
你看,现在大壮和小巧的放大镜度数都是 3 了!它们终于站在了“同一起跑线”上!
第 2 步:加积木(专业术语叫“尾数相加”)
既然放大镜一样了,电脑就可以放心地把它们的“积木”(身高数字)加起来了。
1 | 1.001101 (大壮的身高) |
所以,加起来之后,新的身高是 1.010001
,用的还是那个 3
度的放大镜。
新的身高暗号就是:1.010001 × 2³
。
第 3 步:整理结果(专业术语叫“规格化”)
电脑会检查一下这个新暗号是不是标准格式(1.xxxx...
的样子)。1.010001 × 2³
已经是标准格式了,太棒了!电脑不用再做什么了。
如果刚才加出来的结果是 10.1...
或者 0.1...
,电脑就需要挪动一下小数点,同时调整放大镜的度数,把它变回 1.xxxx...
的标准样子。
最后,电脑把这个最终的暗号 1.010001 × 2³
转换回十进制,就是 10.125
。计算完成!
3. 提问问题
3.1 为什么命名 IEEE 754
这个名字其实很简单,它是一个“组织名”+“标准编号”。
IEEE: 全称是 Institute of Electrical and Electronics Engineers(电气和电子工程师协会)。
754: 就是这个协会内部给“浮点数算术标准”分配的编号。
3.2 是不是只有无理数不精确
并不仅仅是无理数才存不精确!0.1 都不精确。
- 你的积木盒(十进制): 你的盒子里有长度是 10 厘米、1 厘米、0.1 厘米、0.01 厘米 的积木块。
- 电脑的积木盒(二进制): 电脑的盒子里,积木块的长度很特别,它们是 8 厘米、4 厘米、2 厘米、1 厘米,还有更小的 0.5 厘米(一半)、0.25 厘米(一半的一半)、0.125 厘米(一半的一半的一半)…… 它的所有小积木块都是前面一块的“一半长”。
搭一个 0.1 厘米的木条,这个数字我们看着是不是超级简单?就是“十分之一”嘛。
- 你来搭: 你打开自己的积木盒,拿出一块 0.1 厘米 的积木,一步搞定!太简单了!
- 电脑来搭: 电脑打开它的积木盒,挠了挠头,麻烦来了……
- 它想搭
0.1
厘米。 - 它最大的小数积木是
0.5
,太长了,不能用。 0.25
也太长了,不能用。0.125
也太长了……- 好,它找到了一个能用的:0.0625 (也就是 1/16)。
0.1 - 0.0625 = 0.0375
。还差一点点!- 它又在盒子里翻呀翻,找到了 0.03125 (也就是 1/32)。
0.0375 - 0.03125 = 0.00625
。唉呀,还差一丁点儿!- 它只好继续找更小的积木来拼……
你会发现一个神奇的事情:电脑用它的“一半、一半的一半”积木,永远也无法【刚刚好】拼出 0.1 厘米!
- 它想搭
3.3 Decimal 的原理
Decimal
类型的核心原理是:它在内部把一个小数拆成两部分来存储。
- 一个巨大的整数(The Digits / Significand): 也就是把小数点去掉后的所有数字。
- 一个“小数点位置”指令(The Position / Exponent): 告诉电脑,小数点应该从右往左数几位。
让我们来看看 Decimal
是如何给数字拍“快照”的:
对于数字
123.45
Decimal
不会去想 “100 + 20 + 3 + 0.4 + 0.05”- 它会拍一张快照,然后把信息存成一个“魔法配方”:
- 主要材料: 大整数
12345
- 烹饪指令: “把小数点从最右边往左移动 2 位。”
- 主要材料: 大整数
对于我们头疼的
0.1
- 普通
float
类型:(开始手忙脚乱地找 1/16, 1/32… 积木来拼凑) Decimal
类型:(淡定地拿出相机“咔嚓”一声)- 主要材料: 整数
1
- 烹饪指令: “把小数点从最右边往左移动 1 位。”
- 主要材料: 整数
- 普通
对于
0.2
Decimal
类型:(又“咔嚓”一声)- 主要材料: 整数
2
- 烹饪指令: “把小数点从最右边往左移动 1 位。”
- 主要材料: 整数
现在,神奇的事情发生了!当我们计算 0.1 + 0.2
时:
Decimal
类型的内部计算机会读取这两个“魔法配方”,它会做我们人类在纸上做算术时一模一样的事情:
- “嗯,两个配方的‘小数点位置’指令都是移动 1 位,太好了,对齐了!”
- “那我只要把‘主要材料’加起来就行了:
1 + 2 = 3
。” - “最后,再应用那个‘烹饪指令’:把小数点从
3
的右边往左移动 1 位。” - 最终结果:
0.3
。
看到了吗?整个过程,全都是整数运算!它压根儿就没碰过那些二进制小数积木,所以根本没有机会产生 0.30000000000000004
这种奇怪的误差。它从头到尾处理的都是我们人类最熟悉的十进制数字,只是换了一种方式记录而已。
4. 总结
4.1 黄金法则
在编程世界里,我们有一个黄金法则:
- 用
Float
/Double
: 当你处理科学计算、图形、游戏、机器学习等领域,对速度要求极高,且能容忍极微小误差时。比如一个游戏角色的坐标是1.3333333
还是1.3333334
,肉眼根本看不出来。 - 用
Decimal
: 当你处理金钱、财务、账单等任何跟钱有关的计算时。因为在这些领域,精度是绝对的第一位,一分钱都不能差!
4.1 注意的点
凡是涉及“钱”的计算,绝对不要用浮点数!
- 整数运算法:将金额单位转换成最小单位(如“分”),然后用整数进行计算。比如,
12.99
元存储为1299
分。所有计算都在整数上进行,只在最终展示给用户时才除以 100 变回“元”。这是业界最常用、最稳妥的方案。 - 使用
Decimal
类型:几乎所有主流语言都提供了专门用于高精度十进制运算的库或类型。Decimal
类型的原理是模拟十进制的运算,速度会比浮点数慢,但可以保证结果的精确性。 - 不要用 == 直接比较两个浮点数。