
计算机基础
一文彻底掌握浮点数
本文从一个经典问题 0.1+0.2 != 0.3 出发,详细介绍了 IEEE-754 标准下的浮点数表示方法,细致阐述了 3 种浮点数类型的表示逻辑,包括规格化值、非规格化值和特殊值。还介绍了浮点数舍入的 4 种模式,以及浮点数的基本运算。最后,本文结合 Go 语言给出了浮点数不同的输出方式的例子,以及简单介绍了 Go 语言中的 math/big 库在大数运算和精度更高的运算场景中的应用。本文包含大量实例和推演过程,希望能帮助读者彻底掌握浮点数。
经典问题
0.1 + 0.2 = ?
我们写个 Go 程序来测试一下:
func main() {
var f1 float64 = 0.1
var f2 float64 = 0.2
fmt.Println(f1+f2 == 0.3)
fmt.Println(f1 + f2)
}
输出:
false
0.30000000000000004
如此违背 “常识” 的结果,其实是因为当下计算机体系中小数的表示方式是浮点数,而计算机中对浮点数的表示并非百分百精确的,在表示和计算过程中都有可能会丢失精度。
这迫使必须深入理解浮点数在计算机中的存储方式及性质,才能正确处理关于数字的计算问题。
结论先行
定点数
要理解浮点数的第一步是考虑含有小数值的二进制数字。在这之前,我们来看看更加熟悉的十进制表示法:
小数点 . 左边是整数部分,右边是小数部分。其中每个十进制数 di 的取值范围是 0~9。
如十进制的 12.34 即可以表示为:
那其实二进制也是一样的道理,只不过把其中的 10 换成 2,而 di 的取值范围为 0~1。
如二进制的 101.11 可以表示为:
如果我们仅考虑有限长度的编码,那么十进制表示法不能准确表达像 1/3 和 5/7 这样的数。类似的,小数的二进制表示法只能表示那些能够被写成以下形式的数:
其他的值就只能近似地表示。
定点数的整数部分是小数部分的位数是固定不变的,在位数有限的情况下,定点数的取值范围和精度都比较差。于是就有了 IEEE-754 提出的浮点数表示法。
浮点数
所谓“浮点数”(Floating-point numbers),即小数点可以“浮动”,即小数点的位置不是固定的,而是可以根据数值的大小和精度需求移动的。这种表示法允许在广泛的范围内表示数值,同时保持相对恒定的精度。
在计算机中,浮点数通常遵循 IEEE-754 标准。这个标准定义了浮点数的存储和运算方式,确保了不同计算机系统之间的一致性。IEEE-754 用以下形式来表示一个数:
其中:
- s 符号位(Sign bit):表示数值的正负。
- M 尾数(Mantissa):表示数值的有效数字。
- E 指数(Exponent):决定小数点的位置。
IEEE-754 将浮点数的位表示划分成三个部分,分别对各个部分进行编码,对应上面公式右边的 3 个字母:
- 一个单独的符号位
s直接编码符号s。 - 位的阶码字段 编码阶码
E。 - 位小数字段 编码尾数
M,但是编码出来的值也依赖于阶码字段的值是否等于 0。
在 IEEE-754 标准中,定义了两种精度的浮点数,分别是单精度浮点数(32 位)和双精度浮点数(64 位)。
单精度:
- 1 位符号位 s
- 8 位指数 exp
- 23 位尾数 frac
双精度:
- 1 位符号位 s
- 11 位指数 exp
- 52 位尾数 frac
根据 exp 的值,浮点数又可以分成三类:
- 规格化的
- 非规格化的
- 特殊的
其中第三类“特殊的”又可以根据 frac 分成两类:
- 无穷大
- 不是一个数 NaN(Not a Number)
具体如下表所示:
| exp | frac | |
|---|---|---|
| 规格化的 | ≠0 & ≠ 255 | f |
| 非规格化的 | 0 | f |
| 特殊的 | 1 | f |
| - 无穷大 | 1 | 0 |
| - NaN | 1 | ≠0 |
对于不同类型的浮点数,在计算公式 中,exp -> E 和 frac -> M 的方式有所不同。
下面我们来对这几种不同类型进行详细讨论,其中不乏有一些很有趣且充满智慧的设计理念。
特殊值 Special Values
- 指数部分:全为 0。
- 尾数部分:全为 0 则表示无穷大,不全为 0 则表示 NaN。
- 作用:特殊值用于表示那些无法用常规数值表示的情况,如无穷大、非数(NaN)等。这些值通常用于操作的错误或特殊情况的结果,如除以 0、无效操作等。
规格化的值 Normalize Values
- 指数部分:不全为 0 且不全为 1。
- 尾数部分:可以是任意值。
- 作用:用于表示大多数非零数值
在规格化值中:
其中 e 即为 exp,,bias 是偏置量,它的值为 ,其中 k 为 exp 的位数,故:
- 在单精度中,
- 在双精度中,
其中 f 为 frac 表示的数,范围 。
所以一个规格化数,具体可以表示为:
这里有 4 个问题:
- 这个
bias是什么? - 为什么
E要e去减掉一个bias? bias的值是怎么定下的,如单精度为什么是 127,不是 126 或 128?M为什么需要f去加上一个1?
下面我们来对这 4 个问题进行一一解答。
第 1 个问题,这个 bias 是什么?
bias是一个预设的偏移量,用于将指数部分的值偏移到全正数,从而简化处理。
第 2 个问题:为什么 E 要 e 去减去一个 bias?
先说结论:使用 bias(偏置指数,biased exponent)可以允许浮点数以统一的方式表示,同时也使得浮点数的排序和比较变得简单。
首先指数肯定得支持正负形式的出现,那么直接使用无符号整型来表示指数肯定是不行的,因为它无法表示负指数。暂时先抛开 IEEE-754 定下的标准,我们可以尝试用补码来表示指数。
假设我们有两个 32 位的浮点数 A 和 B,并且我们假设它们的指数部分使用 8 位二进制补码表示(这与 IEEE-754 标准不同)。
A的二进制表示:0 0000010 00000000000000000000000B的二进制表示:0 1111110 00000000000000000000000
在这里,第一位是符号位(0 表示正数),接下来的 8 位是以补码形式表示的指数,剩下的 23 位是尾数。
我们想要比较这两个数的大小,需要怎么做呢?
我们先解析这 2 个数:
- 符号位:对于
A和B,符号位都是 0,表示这是两个正数。 - 指数部分(使用补码表示)
A的指数为0000010,解读为正数 +2。B的指数为1111110,在补码表示中,这是一个负数。先加取反后加 1 转换为正数00000010,它表示 -2。
要比较这 2 个数:
- 当我们比较
A和B时,首先需要考虑它们的指数。 - 指数
A为 +2,而B为 -2。即使它们的尾数部分相同(在这个例子中都是 0),A的实际值要大于B,因为正指数表示的数值范围远大于负指数。
可以看出:使用补码表示指数增加了比较过程的复杂性,因为我们需要解读补码并考虑其正负。特别是在涉及到负指数的情况下,我们不能仅仅比较二进制表示的大小,而必须将补码转换为实际的数值,然后再进行比较。
现在回过头来看看 IEEE-754 的设计,假设我们有两个单精度(32 位)浮点数 A 和 B:
A的二进制表示为:0 10000010 00000000000000000000000B的二进制表示为:0 01111110 00000000000000000000000
解析这两个数:
A:符号位为 0(正数),指数部分为10000010(二进制,对应十进制的 130),尾数部分为全 0。B:符号位为 0(正数),指数部分为01111110(二进制,对应十进制的 126),尾数部分为全 0。
计算实际指数值:单精度浮点数的偏置值 bias 为 127,故:
A的实际指数E = 130 - 127 = 3。B的实际指数E = 126 - 127 = -1。
比较这两个数:
- 在减去
bias后,我们可以直接比较指数部分的二进制表示来确定数值的大小。 - 由于
10000010(130)大于01111110(126),因此我们可以直接得出A大于B,而无需考虑负指数的复杂表示问题。
这个例子说明了通过减去偏置值,IEEE-754 标准能够简化浮点数的比较和排序操作。偏置后的指数表示方法允许计算机以统一和高效的方式处理浮点数,无论它们的实际数值大小如何。
第 3 个问题:bias 的值是怎么定下的,如单精度为什么是 127,而不是 126 或 128?
bias值的选择,是为了平衡正负指数的表示范围,并且充分利用指数部分的存储空间。
以单精度为例,exp 占了 8 位,8 位二进制可以表示的值的范围是 。如果我们选择 127 作为 bias,则存储的指数范围就是 。这样可以使得指数部分可以均匀地表示从负大数到正大数的范围(对称)。
在 IEEE-754 标准中,全 0 的指数表示为非规格化数或 0,而全 1 的指数用于表示无穷大或 NaN)。选择 127 作为 bias 可以在保留这些特殊值的同时,提供最大的有效指数范围。
第 4 个问题:M 为什么需要 f 去加上一个 1?
在规格化数中隐含最高位 1 是为了提高尾数部分的表示效率,从而增加精度。
其实这跟科学计数法的很像的,为了确保浮点数表示的唯一性,IEEE-754 规定规格化浮点数最高位一定是非零的。如果不规定最高位非零,同一个数可以有多种不同的浮点表示,例如,在二进制中 0.5 可以表示为 ,也可以表示位 或 等等。这种多重表示会使浮点运算变得复杂且低效。
那既然最高位总是 1,那就没必要显示存储了,还可以使尾数部分中多 1 位的存储空间,从而允许存储更多的有效数字,以提高精度。
非规格化的值 Denormalized Values
- 指数部分:全为 0。
- 尾数部分:可以是任意值。
- 作用:
- 提供表示数值 0 的方法。因为规格化中 ,所以无法表示 0。
- 用于表示非常接近于 0 的数值,这些数值太小,无法用规格化格式表示。它们填补了 0 和最小规格化正数之间的间隙,提供了渐近于 0 的连续表示,防止了所谓的“下溢”。
在非规格化值中:
所以一个规格化数,具体可以表示为:
那这里又有 2 个问题了:
- 为什么指数部分不是 而是 ?
- 为什么 M 不需要隐含的 1 了?
第 1 个问题:为什么指数部分不是 0-bias 而是 1-bias?
这是一个特殊的设计,旨在使非规格化数能够平滑地连接到规格化数的最小正值。
最小的规格化数的指数为 1 - bias。为了在数值上平滑地过渡到非规格化数,非规格化数的实际指数也被设定为 1 - bias。这样,非规格化数就可以代表那些小于最小规格化正数的数值,而不会出现一个数值的“间隙”。
第 2 个问题:为什么 M 不需要隐含的 1 了?
不包含隐含的 1 使得非规格化数能够在浮点数表示中填补 0 和最小规格化数之间的空隙,提供对极小数值的连续表示。
- 避免下溢:非规格化数通过允许尾数部分不以隐含的 1 开始(而是以显式的 0 开始),使得它们可以表示比最小规格化数还要小的数值。这对于避免数值下溢至 0 非常重要,尤其是在累积了多次运算后的场合。
- 精度牺牲:使用非规格化数的代价是牺牲了一些精度。由于没有隐含的最高位 1,非规格化数的精度较低。但这是为了在非常小的数值范围内提供数值的连续性所做的必要妥协。
总结
规格化值、非规格化值和特殊值三种类型共同构成了 IEEE-754 浮点数标准的完整表示体系,使得浮点数能够在计算机中有效低处理从非常小到非常大的数值范围,同时还能应对特殊的计算情况。
举例
参考《深入理解计算机系统》,我们以 8 位浮点数为例,其中:
- 1 位符号 s
- 4 位指数 exp
- 3 位尾数 frac
可以算出 。
其中靠近 0 的是非规格化值:
以 0 0000 001 为例:
再往下,就是规格化值:
以 0 0110 110 为例:
整型转为浮点型
下面以一个例子来直观感受一下一个整型是如何转为浮点型的。
现在我们有一个 int32 的整型 123,我们希望将其转为单精度浮点型 123.0。
1. 将整型用二进制表示出来
2. 规范化表示
3. 计算指数
4. 确定尾数**
这是个规范化值,所以 1.frac 的 1 省略,又因为单精度浮点数 frac 占 23 位,所以我们需要在 111011 后面再填 17 个 0,即:
5. 确定符号位
6. 组合起来
12345.0 = 0 10000101 11101100000000000000000
浮点数舍入
由于浮点数的表示具有固定的精度,在进行运算或表示时,经常会遇到无法精确表示的数值,这就需要采用舍入方法来近似表示这些数值。IEEE-754 标准定义了几种不同的舍入模式,以适应不同的计算需求。
舍入模式
最近舍入(Round to Nearest):
- 这是最常用的舍入模式,也是默认的模式。
- 规则是向最接近的可表示值舍入。如果精确结果位于两个可表示值的中点,通常舍入到最近的偶数(即尾数的最后一位为 0)。
- 这种方法减少了累积误差,确保了在多次运算后的总体精度。
向零舍入(Round Toward Zero):
- 这种模式总是舍入到零的方向,即舍去小数部分。
- 对于正数,这相当于取下限,对于负数,相当于取上限。
向上舍入(Round Up):
- 无论正负,总是向正无穷大的方向舍入。
- 对于任何数值,舍入结果都是不小于原值的最小整数。
向下舍入(Round Down):
- 无论正负,总是向负无穷大的方向舍入。
- 对于任何数值,舍入结果都是不大于原值的最大整数。
舍入的影响
- 精度损失:由于固定的尾数位数,舍入可能导致精度的损失。
- 舍入误差:舍入操作本身可能引入误差,这些误差在连续运算中可能会累积。
- 选择合适的舍入模式:不同的舍入模式适合不同的应用场景。例如,金融计算可能更倾向于使用向零舍入,而科学计算通常使用最近舍入以减少累积误差。
实例
| Mode | 1.40 | 1.60 | 1.50 | 2.50 | -1.50 |
|---|---|---|---|---|---|
| 最近舍入 | 1 | 2 | 2 | 2 | -2 |
| 向零舍入 | 1 | 1 | 1 | 2 | -1 |
| 向上舍入 | 2 | 2 | 2 | 3 | -1 |
| 向下舍入 | 1 | 1 | 1 | 2 | -2 |
浮点数运算
因为浮点数本身就存在精度问题,所以浮点数运算在计算机中是一个近似过程,涉及到精确度的权衡、特殊值的处理、错误的传播,以及舍入规则的应用。
浮点数加减
- 浮点数加法和减法首先需要对操作数进行对齐,使得它们的指数相同。这可能涉及将尾数的二进制表示向右移位,可能导致精度损失。
- 然后执行加法或减法操作。
- 对结果进行规范化和舍入。
注意,浮点数的加减法不满足结合律、交换律和分配律,这你简单分析下应该就可以理解了,这里不赘述了。
假设我们要在单精度浮点数格式下计算:
第 1 步:转为二进制表示
其中 12.375 我们可以用二进制精确表示:
而 0.1 就比较特殊了,用二进制表示的话它会无限循环。
将十进制小数转换为二进制表示涉及到重复乘以 2 的过程,并提取每次乘法后整数部分作为二进制位。这个过程是一个不断重复的过程,直到小数部分变为 0 或开始循环。
-
取
0.1的小数部分乘以 2(即0.1 × 2 = 0.2),整数部分是0,小数部分是0.2。 -
再次取小数部分乘以 2(即
0.2 × 2 = 0.4),整数部分是0,小数部分是0.4。 -
继续这个过程,我们得到以下序列:
0.4 × 2 = 0.8→ 整数部分00.8 × 2 = 1.6→ 整数部分10.6 × 2 = 1.2→ 整数部分10.2 × 2 = 0.4→ 整数部分0…(循环开始)
所以,0.1 的二进制表示开始为 0.0001100110011…,并且这个模式会无限循环下去。
第 2 步:规格化
回顾一下这张图:
所以 12.375 规格化表示为:
- 先规范化为 1.xxxx 形式:
- 指数为:
- 尾数为:
- 汇总:
而 0.1 由于无限循环,我们在单精度下只能保留 23 位,并采用最近舍入,所以 0.1 规格化表示为:
- 先规范为 1.xxxx 形式:
- 指数为:
- 尾数为:
- 汇总:
第 3 步:对齐指数
先把 2 个浮点表示放在一起,好对比:
将两个数的指数对齐,较小的指数增加,同时相应地调整尾数。
这里需要调整将 0.1 的指数从 01111011 调整到 10000010,这里加了 7,所以 0.1 的尾数 1.10011001100110011001100需要右移 7 位,即:0.00000011001100110011001。
第 4 步:相加
现在两个数的指数相同了,我们可以直接把它们的尾数相加:
第 5 步:规范化结果
这里无需规范化。
第 6 步:舍入
这里没有进位,不需要舍入。
第 7 步:浮点化表示
第 8 步:转为十进制
浮点数乘法
- 符号位计算:结果的符号由两个操作数的符号位决定。如果符号位相同(都是正数或都是负数),结果为正;如果符号位不同,结果为负。
- 指数相加:两个数的指数相加,并减去偏置值(单精度浮点数中为 127,双精度为 1023)。
- 尾数相乘:两个数的尾数相乘。这里的尾数包括隐含的最高位 1。
- 结果规范化:如果乘法的结果需要规范化(即调整为
1.xxxx的形式),则相应调整指数。 - 舍入处理:如果需要,对结果进行舍入以适应目标格式。
- 检查溢出或下溢:如果指数超出了表示范围,则发生溢出(结果可能为无穷大或特殊值);如果指数太小,发生下溢(结果可能为 0 或非规格化数)。
假设我们要在单精度浮点数格式下计算:
第 1 步:转为二进制表示
第 2 步:规范化
第 3 步:浮点化
第 4 步:乘法操作
- 符号位:正正得正:
- 指数相加并减去偏置值:
- 尾数相乘:
第 5 步:规范化
这里无需规范化。
第 6 步:舍入
这里无需舍入。
第 7 步:浮点化结果
第 8 步:转为十进制
浮点数除法
浮点数除法类似于乘法,但有一些不同:
- 符号位计算:与乘法类似,结果的符号由两个操作数的符号位决定。
- 指数相减:被除数的指数减去除数的指数,再加上偏置值。
- 尾数相除:被除数的尾数除以除数的尾数。
- 结果规范化:如果必要,调整结果使其规范化。
- 舍入处理:如果需要,对结果进行舍入。
- 检查溢出或下溢:与乘法类似的检查。
假设我们要在单精度浮点数格式下计算:
第 1 步:转为二进制表示
第 2 步:规范化
第 3 步:浮点化
第 4 步:除法操作
- 符号位:正正得正:
- 指数减并加上偏置值:
- 尾数相除:
第 5 步:规范化
这里无需规范化。
第 6 步:舍入
这里无需舍入。
第 7 步:浮点化结果
第 8 步:转为十进制
Go 语言输出浮点数
func main() {
var number float32 = 12.375
fmt.Printf("浮点数:%f\n", number)
fmt.Printf("科学计数法:%e\n", number)
fmt.Printf("保留 2 位小数:%.2f\n", number)
bits := math.Float32bits(number)
bitsStr := fmt.Sprintf("%.32b", bits)
fmt.Printf("输出32位浮点表示:%s %s %s\n", bitsStr[:1], bitsStr[1:9], bitsStr[9:])
}
math/big
Go 语言的 math/big 包提供了对大数的精确计算支持,这些大数的大小超出了标准整数类型(如 int64)或浮点类型(如 float64)的范围。这个包主要用于需要高精度计算的领域,如加密、科学计算等。
主要功能:
- 算术运算:支持基本的加、减、乘、除等算术运算。
- 比较操作:可以比较两个大数的大小。
- 位操作:对大整数进行位操作,如位移、与、或、异或等。
- 解析和格式化:可以从字符串解析大数,也可以将大数格式化为字符串。
示例:
func main() {
a := big.NewFloat(math.MaxFloat64)
b := big.NewFloat(math.MaxFloat64)
sum := big.NewFloat(0)
sum.Add(a, b)
fmt.Println("a:", a)
fmt.Println("sum:", sum)
sum2 := big.NewFloat(0).
SetPrec(15). // 设置精度,prec 越大,精度越高,计算越复杂
SetMode(big.ToZero) // 设置舍入策略
sum2.Add(a, b)
fmt.Println("sum2:", sum2)
}
// a: 1.7976931348623157e+308
// sum: 3.5953862697246314e+308
// sum2: 3.5953e+308
注意事项:
- 性能考虑:由于
math/big提供的是任意精度计算,其性能通常低于原生的固定大小数值类型。 - 内存使用:大数运算可能会消耗更多的内存。
- 方法链式调用:
math/big的许多方法返回接收者本身,支持链式调用。
参考资料
- IEEE-754
- 深入理解计算机系统
- Go 语言底层原理剖析
- https://www.bilibili.com/video/BV1zK4y1j7Cn
- ChatGPT-4
来自开放网络的回应
引用、喜欢、转发和站外回复会通过 Webmention 回到这里。
接收与展示代码已经就绪,注册正式域名后即可开始收集回应。
评论
评论由 GitHub Discussions 保存和管理。