提到数字时,会想到当前所需的数值范围是什么,使用什么类型的数字会比较合适,会不会存在溢出或精度丢失等情况。本文会简单介绍数字的概念,以及日常使用数字时需要注意的一些问题。
不同语言中,对数字的实现形式是不同的,比如在Go中数字有int32、int64、float32、float64等类型;在JavaScript中数字只有Number类型,是一种双精度浮点数,不管是整数还是浮点数,都是Number类型(表示范围上相当于Go的float64类型)。
Go:
var a float64 = 1.0
var b int64 = 1
fmt.Printf("%T %T\n", a, b) // float64 int64
JavaScript:
let a = 1.0;
let b = 1;
console.log(typeof a, typeof b) // number number
数字的核心概念部分其实都是一样的,因为实现形式不同,数字在不同语言的代码中,表现可能会不同,要将核心概念和不同语言的特点结合使用。
接下来的数据结构等一系列文章,都以Go语言为例进行说明。
bit(比特)
bit(比特 binary digit) 是信息的最小单位,表示一个二进制位(0 或 1)。
硬件能比较精确地区分两种状态,例如低电平/高电平,断电/通电等,这些状态被抽象为了0或1的二进制位,所有数据最终都是以bit序列存储在计算机中的。(为什么是二进制位,不是三进制或者十进制?因为更多状态实现起来非常困难且不精确)
人们日常生活中,比较常用的是十进制数字,比如12个月,十进制数字12转换为二进制数字就是1100。
byte(字节)
1byte=8bits
十进制12等于二进制1100,看起来只需要4bits就能存储12这个数了,但实际至少需要8bits来存储,因为在计算机中CPU 和内存系统能直接访问的最小数据单元是1byte,这和历史原因、硬件实现等有关,地址总线按字节寻址,数据总线的位数通常也为8的倍数。以读数据为例,CPU从内存“找”数据,内存给CPU“吐”数据的时候,地址和数据的最小单位就到字节,不能更小了。
顺便记录一下一些常见单位的换算方式:
Go中的数字类型
Go中的数字类型有整数、浮点数、复数。
- 整数
- 有符号整数:int8 、int16 、int32、int64 、int
- 无符号整数:uint8(byte) 、uint16 、uint32 、uint64 、uint 、uintptr
- 浮点数:float32、float64
- 复数:complex64、complex128
rune类型是int32类型的别名,其实就是数字类型,但语义上是Unicode码点,指的是单个字符的类型。
Go语言中的byte类型就是uint8,是一种数据类型,和上面说过的那个byte(更多是存储单位)有所不同。例:
变量a的类型是byte(数据类型):
var a byte = 1
“数字.md”这个文件的大小是15394byte(存储单位):
$ stat -f%z 数字.md
15394
整数
以unit8和int8为例,无符号8位整数unit8的二进制表示是这样的:
数值范围是 0 ~ 255,当所有位都为0时是最小值0,所有位都为1时,是最大值255:
有符号整数在计算机中通常是以补码的形式存储的,有符号整数有原码、反码、补码的概念,使用补码表示有符号整数,是为了用二进制表示负数,并且尽可能让运算简单。感兴趣的读者朋友可以详细了解下为什么,现在只需要记住补码的计算公式即可,有符号整数使用这个公式计算:
有符号8位整数int8的二进制表示是这样的:
符号位s就是第7位,符号位为1表示负数,为0表示正数。
符号位为1,剩余位全为0时,是最小值-128:
符号位为0,剩余位全为1时,是最大值:
其他类型整数的计算方式uint8和int8是一样的,只是表示的数值范围根据二进制位数的不同而不同。 无符号n位整数的范围:
有符号n位整数的范围:
整数中int、uint、unitptr类型根据不同平台的架构变化,通常在32位架构上是32位,在64位架构上是64位。
以下是已经计算好的一些整数类型的范围,方便使用时能对各类型的数值范围有大概的印象:
| 类型 | 范围 | 中文描述(大约值) |
|---|---|---|
| int8 | -128 ~ 127 | |
| uint8 | 0 ~ 255 | |
| int16 | -32768 ~ 32767 | -3万2千~ 3万2千 |
| uint16 | 0 ~ 65535 | 0 ~ 6万5千 |
| int32 | -2147483648 ~ 2147483647 | -21亿4748万 ~ 21亿4748万 |
| uint32 | 0 ~ 4294967295 | 0 ~ 42亿9496万 |
| int64 | -9223372036854775808 ~ 9223372036854775807 | -9223372万亿 ~9223372万亿 |
| uint64 | 0 ~ 18446744073709551615 | 0 ~ 18446744 万亿 |
比较常用的就是int32和int64类型。自增id,时间戳之类的可能会较大的值用int64, 一般数值范围比如一个视频的秒级时长就用int32。 PS:视频时长这种数字,已经明确了是正数了,不选择用uint32是为了和不同语言更好地兼容以及方便后续代码的扩展。
整数溢出表现
接下来说一下在Go语言中,整数在运行期间溢出的表现,假设用一个int32或uint32变量来存放一个30分钟视频秒级别的总播放时长,在海量用户观看视频的过程中就一直在累加这个值。(只是为了举例,实际一般都会用有符号整数类型)
先看用int32的情况,已经累计到了最大值 2147483647,再加1的时候,得到的结果是什么:
01111111 11111111 11111111 11111111
+ 1
= 10000000 00000000 00000000 00000000
最大值+1会变成了int32负数的最小值:-2147483648。
var maxInt32 int32 = math.MaxInt32
fmt.Println(maxInt32 + 1) // -2147483648
如果是批量统计了再汇总,两个2147483647相加,得到的结果是什么:
01111111 11111111 11111111 11111111
+ 01111111 11111111 11111111 11111111
1 11111111 11111111 11111111 11111110
↑ 超过32位的高位会被舍弃
= 11111111 11111111 11111111 11111110
会得到-2。
如果用uint32, 已经到了最大值4294967295,再加1的结果是什么:
11111111 11111111 11111111 11111111
+ 1
1 00000000 00000000 00000000 00000000
↑ 超过32位的高位会被舍弃
= 00000000 00000000 00000000 00000000
最大值+1会变成uint32类型的最小值0。
int32和uint32整数溢出时,都只会保留低32位。
假使这个视频总播放时长统计功能,使用了int32类型的整数来累加时长,那么溢出之后就会变成负数,计算完之后存到数据库中,统计界面查询的时候就可能看到总的播放时长是一个负数,于是产生了bug。(如果已经产生了bug并出现了脏数据,只能尝试从日志或其他数据中寻找方法,对脏数据进行修复)
怎么避免这种情况呢?
1)在计算前先做范围检查,判断两者相加不会大于最大值,也不会小于最小值。
func addInt32(a, b int32) (int32, bool) {
if (b > 0 && a > math.MaxInt32-b) || (b < 0 && a < math.MinInt32-b) {
return 0, false // 溢出的情况返回false,由外层进行告警或快照之类的处理
}
return a + b, true
}
2)换成数字范围更大的int64类型存储。
3)如果int64的范围都不够用,考虑使用big.Int类型:
import (
"math/big"
"fmt"
)
func AddBigInt() {
a, _ := new(big.Int).SetString("9223372036854775807", 10) // int64的最大值
b, _ := new(big.Int).SetString("9223372036854775807", 10) // int64的最大值
sum := new(big.Int).Add(a, b)
fmt.Println(sum) // 18446744073709551614
}
int32和uint32的类型截断也是只保留低32位:
var a uint64 = 429496729500
var b uint32 = uint32(a)
fmt.Println(b) // 只保留低32位 4294967196
整数计算是不会有精度丢失的,因为计算机能精确表示整数。(一些疑似精度丢失(实则并不是)的情况,是由于溢出、类型截断之类的导致的)
浮点数能表示的数字范围比整数大得多,那么int64的数字范围不够用的时候,能考虑换成float64类型吗?下文浮点数的部分会给出答案。
浮点数
浮点数的一般科学计数法是:
其中N为任何一个浮点数,M为尾数,E为阶码,R为阶码的基数。
十进制浮点数的表示形式大家应该都比较熟悉,123456.789 这个十进制浮点数,用科学计数法表示就是(d是十进制的意思):
如果阶码E发生变化,小数点的位置也是会发生变化,这就是浮点数中的浮点的意思,小数点是“左右浮动”的。看上面这个数字,如果阶码E从 5 变为 6, 小数点向右移动一位变成1234567.89;阶码E从 5 变为 2,小数点向左移动3位变成123.456789。
二进制的科学计数法表示也是类似的,比如10111.110101这个二进制浮点数,用科学计数法表示就是(b是二进制的意思):
Go语言中的float32和float64基本遵循IEEE-754标准,IEEE-754的浮点数计算公式为:
-
基数R为2。
-
尾数M:
- S是符号位:0表示正数,1表示负数。
- Fraction是尾数。(虽然Fraction和M都被称为尾数,但它们表示的概念是不同的,Fraction只是M的一部分)
- 表示最高位的1是隐含的。
-
阶码E:
- Exp是指数,存的是带偏置的指数。
- bias是偏置常数,值为 ,其中k是Exp的位数。
- 单精度浮点数(32-bit)的偏置常数是:127。
- 双精度浮点数(64-bit)的偏置常数是: 1023。
IEEE标准中浮点数有这几类:正规数(normal)、次正规数(subnormal)、±0、 正负无穷大和NaN。
1)正规数 (normal)
- Exp即不是全为0,也不是全为1
- 有隐含1:有效数是1.Fraction
- p:Fraction的bit位数(float32=23,float64=52)
- frac:Fraction 这段比特当作无符号整数的值
Fraction部分当作整数时的值frac为:
所以得到数值计算公式:
2)次正规数 (subnormal)
- Exp全为0,并且Fraction ≠ 0
- 没有隐含的1:有效数是0.Fraction
- p:Fraction的bit位数(float32=23,float64=52)
- frac:Fraction 这段比特当作无符号整数的值
数值计算公式:
3)零(±0)
- Exp全为0,并且Fraction = 0
- +0还是 -0根据S决定
4)特殊值:正负无穷大和NaN
- Exp全为1
- Fraction = 0, 为正负无穷大
- Fraction ≠ 0,是NaN
float32 (单精度,32位浮点数)
1)正规数
- Exp即不是全为0,也不是全为1
- 有隐含1:有效数是1.Fraction
2)次正规数
- Exp全为0,并且Fraction ≠ 0
- 没有隐含的1:有效数是0.Fraction
3)零(±0)
- Exp全为0,并且Fraction = 0
- +0还是 -0根据S决定
4)特殊值:正负无穷大和NaN
- Exp全为1
- Fraction = 0, 为正负无穷大
- Fraction ≠ 0,是NaN
正负无穷大:
NaN:
float64 (双精度,64位浮点数)
float64和float32的计算方式一样,只是Exp的位数、Fraction的位数,以及偏置常数与float32不同。
浮点数溢出表现
按照IEEE标准规定,浮点数上溢时可能会变成±Inf,下溢时可能会变成次正规数或是0。
为什么浮点数会有精度丢失
以小数点位全部为1的十进制小数和二进制小数举一个简单的例子:
只看小数的部分,十进制的小数部分是:
比如十进制数0.111,就是:
而二进制的小数部分是:
比如二进制小数0.111,就是:
十进制的小数转换为下面这个公式:
能很直观地看出只有在十进制小数的分母中不包含质因数5的时候(仅包含质因数2时),才能被二进制准确表示。下面两个是能被二进制精确表示的十进制数字:
十进制转换为最简分数后,如果质因数包含5,这个数字就不能被有限二进制位表示了:
能很直观看出十进制0.1没有这这种二进制位置:
从数学的概念上,0.1是能表示为二进制无限循环小数的:
但计算机用于表示浮点数的位数不是无限的,而是有限位,所以只能通过把无限循环的小数位截断,以及进行舍入,用一个和十进制0.1近似的二进制数表示0.1,比如:
真实: 0.0001100110011001100110011001100110011...
存储: 0.00011001100110011001100 (截断/舍入)
这就是浮点数精度丢失的原因,在进行运算的时候,两个近似值相加,再进行截断/舍入,就会放大这种差异,比如经典的0.1 + 0.2 不等于 0.3。
用Go和JS打印了下0.1+0.2,终端看到的值不一样(JS是用Node执行的),
Go:
fmt.Printf("%.20f\n", 0.1 + 0.2) // 0.29999999999999998890
JS:
console.log(0.1+0.2) // 0.30000000000000004
两边都是float64类型,遵循IEEE标准,所以底层存储应该是一样的,只是不同语言将这种存储转换为字符串的方式有所不同,一个取了较低的值(<0.3),一个取了较高的值(>0.3)。
为了解决浮点数的精度丢失问题,可以使用decimal包来做十进制的精确计算:
import (
"github.com/shopspring/decimal"
"fmt"
)
func AddNum() {
d1, _ := decimal.NewFromString("0.1")
d2, _ := decimal.NewFromString("0.2")
sum := d1.Add(d2)
fmt.Println(sum.StringFixed(20)) // 0.30000000000000000000
}
二进制数无法通过有限的数位精确表示小数,所以会丢失精度,那么用来表示整数,是不是就是精确的了。比如int64整数溢出的时候,是否考虑用能表示的数字范围更广的float64类型。答案是不会,因为float64能精确表示的整数范围小于int64的范围,float64类型只能精确表示≤ 的整数,超过这个范围的整数就不能精确表示了(这个和尾数Fraction的位数有关,float32只能精确表示≤ 的整数)。
二进制数无法通过有限的数位精确表示小数,所以会丢失精度,那么用来表示整数,是不是就是精确的了。比如int64整数溢出的时候,是否能考虑用数字范围更广的float64类型。答案是不能,因为float64能精确表示的整数范围小于int64的范围,float64类型只能精确表示≤ 2^53 的整数,超过这个范围的整数就不能精确表示了(这个和尾数Fraction的位数有关,float32只能精确表示≤ 2^24的整数)。
例如,float64是没有2^53 + 1这个整数的位置的:
n := int64(1) << 53 // 2^53
fmt.Printf("%.0f\n", float64(n)) // 9007199254740992
fmt.Printf("%.0f\n", float64(n+1)) // 9007199254740992(被舍入)
fmt.Printf("%.0f\n", float64(n+2)) // 9007199254740994
复数
Go中的复数类型一般情况下不会用到,可以简单了解下是什么概念就可以了。
- complex64:实部和虚部都是 float32,占 8 字节
- complex128:实部和虚部都是 float64,占 16 字节(默认是complex128类型)
import (
"fmt"
"math/cmplx"
"reflect"
)
func ComplexNumber() {
a := 3 + 4i
b := 1 - 2i
fmt.Println("a + b =", a+b) // (4+2i)
fmt.Println("|a| =", cmplx.Abs(a)) // 5
fmt.Println(reflect.TypeOf(a)) // complex128
}
结尾
数字涉及到的知识远不止于此,之后如果有数字相关的内容再单开文章写。
文中说过”所有数据最终都是以bit序列存储在计算机中的”,字符串也不例外。在这篇文章中已经大概了解到了数字是怎样以二进制方式存储的了,字符串中的字符其实也是转换成对应的数字之后,以二进制形式存储的,下一篇记录字符串相关的内容。