数字

78 阅读6分钟

提到数字时,会想到当前所需的数值范围是什么,使用什么类型的数字会比较合适,会不会存在溢出或精度丢失等情况。本文会简单介绍数字的概念,以及日常使用数字时需要注意的一些问题。

不同语言中,对数字的实现形式是不同的,比如在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“吐”数据的时候,地址和数据的最小单位就到字节,不能更小了。

顺便记录一下一些常见单位的换算方式:

1KB=1024B=210B1MB=1024KB=220B1GB=1024MB=230B1TB=1024GB=240B1 KB = 1024 B = 2^{10} B \\ 1 MB = 1024 KB = 2^{20} B \\ 1 GB = 1024 MB = 2^{30} B \\ 1 TB = 1024 GB = 2^{40} B \\

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的二进制表示是这样的:

image.png

数值=b727+b626+...+b121+b020数值=b_7·2^7 + b_6·2^6 + ... + b_1·2^1 + b_0·2^0

数值范围是 0 ~ 255,当所有位都为0时是最小值0,所有位都为1时,是最大值255:

S1=i=70bi2i=127+126+...+121+120S2=2S1=i=81bi2i=128+127+...+122+121S2S1=S1=281=2561=255S_1 =\sum_{i=7}^{0} b_i \cdot 2^i = 1·2^7 + 1·2^6 + ... + 1·2^1 + 1·2^0 \\ S_2 = 2 · S_1 =\sum_{i=8}^{1} b_i \cdot 2^i = 1·2^8 + 1·2^7 + ... + 1·2^2 + 1·2^1 \\ S_2 - S_1 = S_1 = 2^8 - 1 = 256 - 1 = 255

有符号整数在计算机中通常是以补码的形式存储的,有符号整数有原码、反码、补码的概念,使用补码表示有符号整数,是为了用二进制表示负数,并且尽可能让运算简单。感兴趣的读者朋友可以详细了解下为什么,现在只需要记住补码的计算公式即可,有符号整数使用这个公式计算:

bn12n1+i=0n2bi2i−b_{n-1}⋅2^{n-1}+\sum_{i=0}^{n-2}b_i⋅2^i

有符号8位整数int8的二进制表示是这样的: image 1.png

符号位s就是第7位,符号位为1表示负数,为0表示正数

数值=b727+i=06bi2i数值 = −b_7⋅2^7+\sum_{i=0}^6b_i⋅2^i

符号位为1,剩余位全为0时,是最小值-128:

127+0=128-1·2^7 + 0 = -128

符号位为0,剩余位全为1时,是最大值:

027+271=127-0·2^7 + 2^7 - 1 = 127

其他类型整数的计算方式uint8和int8是一样的,只是表示的数值范围根据二进制位数的不同而不同。 无符号n位整数的范围:

02n10 \sim 2^n - 1

有符号n位整数的范围:

2n12n11-2^{n-1} \sim 2^{n-1}-1

整数中int、uint、unitptr类型根据不同平台的架构变化,通常在32位架构上是32位,在64位架构上是64位。

以下是已经计算好的一些整数类型的范围,方便使用时能对各类型的数值范围有大概的印象:

类型范围中文描述(大约值)
int8-128 ~ 127
uint80 ~ 255
int16-32768 ~ 32767-3万2千~ 3万2千
uint160 ~ 655350 ~ 6万5千
int32-2147483648 ~ 2147483647-21亿4748万 ~ 21亿4748万
uint320 ~ 42949672950 ~ 42亿9496万
int64-9223372036854775808 ~ 9223372036854775807-9223372万亿 ~9223372万亿
uint640 ~ 184467440737095516150 ~ 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。

数值=1231+i=300bi2i=231+1230+1229+...+122+121+020=231+(2312)+0=2数值 = −1⋅2^{31}+\sum_{i=30}^0b_i⋅2^i\\ = -2^{31} + 1·2^{30} + 1·2^{29} + ... + 1·2^{2} + 1·2^{1} + 0·2^{0}\\ = -2^{31} + (2^{31} - 2) + 0 \\ = -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=MREN = M · R^E

其中N为任何一个浮点数,M为尾数,E为阶码,R为阶码的基数。

十进制浮点数的表示形式大家应该都比较熟悉,123456.789 这个十进制浮点数,用科学计数法表示就是(d是十进制的意思):

123456.789=1.23456789d105123456.789 = 1.23456789_d · 10 ^{5}

如果阶码E发生变化,小数点的位置也是会发生变化,这就是浮点数中的浮点的意思,小数点是“左右浮动”的。看上面这个数字,如果阶码E从 5 变为 6, 小数点向右移动一位变成1234567.89;阶码E从 5 变为 2,小数点向左移动3位变成123.456789。

二进制的科学计数法表示也是类似的,比如10111.110101这个二进制浮点数,用科学计数法表示就是(b是二进制的意思):

10111.110101=1.0111110101b2410111.110101 = 1.0111110101_{b} · 2^{4}

Go语言中的float32和float64基本遵循IEEE-754标准,IEEE-754的浮点数计算公式为:

Value=(1)S×(1.Fraction)×2(Expbias)Value = (−1)^S×(1.Fraction)×2^{(Exp−bias)}
  • 基数R为2。

  • 尾数M:

    M=(1)S×1.FractionM = (-1)^{S}×(1.Fraction)
    • S是符号位:0表示正数,1表示负数。
    • Fraction是尾数。(虽然Fraction和M都被称为尾数,但它们表示的概念是不同的,Fraction只是M的一部分)
    • 1.Fraction1.Fraction 表示最高位的1是隐含的。
  • 阶码E:

    E=ExpbiasE = Exp - bias
    • Exp是指数,存的是带偏置的指数。
    • bias是偏置常数,值为 2k112^{k-1} - 1,其中k是Exp的位数。
      • 单精度浮点数(32-bit)的偏置常数是:127。2811=1272^{8-1} - 1= 127
      • 双精度浮点数(64-bit)的偏置常数是: 1023。21111=10232^{11-1} - 1 = 1023

image 2.png

IEEE标准中浮点数有这几类:正规数(normal)、次正规数(subnormal)、±0、 正负无穷大和NaN。

1)正规数 (normal)

  • Exp即不是全为0,也不是全为1
  • 有隐含1:有效数是1.Fraction
  • p:Fraction的bit位数(float32=23,float64=52)
  • frac:Fraction 这段比特当作无符号整数的值
1.Fraction=1.f1f2f3...fp2fp1fp=1+f121+f222+...+fp12(p1)+fp2p1.Fraction = 1.f_1f_2f_3...f_{p-2}f_{p-1}f_{p}\\ = 1 + f_1 · 2^{-1}+ f_2 · 2^{-2} + ... + f_{p-1} · 2^{-(p-1)} + f_{p} · 2^{-p}

Fraction部分当作整数时的值frac为:

frac=f12p1+f22p2+...+fp121+fp20frac2p=f121+f222+...+fp12(p1)+fp2pfrac = f_1 · 2^{p-1} + f_2 · 2^{p-2} + ... + f_{p-1} · 2^{1} + f_p · 2^0 \\ \frac{frac}{2^p} = f_1· 2^{-1} + f_2· 2^{-2} + ... + f_{p-1}· 2^{-(p-1)} + f_p· 2^{-p}

所以得到数值计算公式:

Value=(1)S(1+frac2p)2(Expbias)Value = (-1)^S · (1 + \frac{frac}{2^p}) · 2^{(Exp - bias)}

2)次正规数 (subnormal)

  • Exp全为0,并且Fraction ≠ 0
  • 没有隐含的1:有效数是0.Fraction
  • p:Fraction的bit位数(float32=23,float64=52)
  • frac:Fraction 这段比特当作无符号整数的值

数值计算公式:

Value=(1)S(frac2p)2(1bias)Value = (-1)^S · (\frac{frac}{2^p}) · 2^{(1 - bias)}

3)零(±0)

  • Exp全为0,并且Fraction = 0
  • +0还是 -0根据S决定

4)特殊值:正负无穷大和NaN

  • Exp全为1
    • Fraction = 0, 为正负无穷大
    • Fraction ≠ 0,是NaN

float32 (单精度,32位浮点数)

image 3.png 1)正规数

  • Exp即不是全为0,也不是全为1
  • 有隐含1:有效数是1.Fraction

floa32正规数计算.drawio.svg

2)次正规数

  • Exp全为0,并且Fraction ≠ 0
  • 没有隐含的1:有效数是0.Fraction

float32次正规数的计算.drawio.svg

3)零(±0)

  • Exp全为0,并且Fraction = 0
  • +0还是 -0根据S决定

image 4.png

4)特殊值:正负无穷大和NaN

  • Exp全为1
    • Fraction = 0, 为正负无穷大
    • Fraction ≠ 0,是NaN

正负无穷大:

image 5.png

NaN:

image 6.png

float64 (双精度,64位浮点数)

image 7.png

Value=(1)S×(1.Fraction)×2(Expbias)Value = (−1)^S×(1.Fraction)×2^{(Exp−bias)}\\

float64和float32的计算方式一样,只是Exp的位数、Fraction的位数,以及偏置常数与float32不同。

浮点数溢出表现

按照IEEE标准规定,浮点数上溢时可能会变成±Inf,下溢时可能会变成次正规数或是0。

为什么浮点数会有精度丢失

以小数点位全部为1的十进制小数和二进制小数举一个简单的例子:

只看小数的部分,十进制的小数部分是:

110n \frac{1}{10^{n}}

比如十进制数0.111,就是:

1101+1102+1103\frac{1}{10^1} + \frac{1}{10^{2}} + \frac{1}{10^{3}}

而二进制的小数部分是:

12n\frac{1}{2^n}

比如二进制小数0.111,就是:

121+122+123\frac{1}{2^1} + \frac{1}{2^2} + \frac{1}{2^3}

十进制的小数转换为下面这个公式:

110n=1(25)n=12n5n\frac{1}{10^n} \\ = \frac{1}{(2·5)^n} \\ = \frac{1}{2^n · 5^n}

能很直观地看出只有在十进制小数的分母中不包含质因数5的时候(仅包含质因数2时),才能被二进制准确表示。下面两个是能被二进制精确表示的十进制数字:

0.5d=510=12=0.1b 0.25=25100=122=0.01b0.5_d = \frac{5}{10} = \frac{1}{2} = 0.1_b\\ ~\\ 0.25 = \frac{25}{100} = \frac{1}{2^2} = 0.01_b

十进制转换为最简分数后,如果质因数包含5,这个数字就不能被有限二进制位表示了:

0.1=110=1250.1 = \frac{1}{10} = \frac{1}{2·5}

能很直观看出十进制0.1没有这这种二进制位置:

12n\frac{1}{2^n}

从数学的概念上,0.1是能表示为二进制无限循环小数的:

微信图片_20260131224253_26_3.jpg

但计算机用于表示浮点数的位数不是无限的,而是有限位,所以只能通过把无限循环的小数位截断,以及进行舍入,用一个和十进制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类型只能精确表示≤ 2532^{53} 的整数,超过这个范围的整数就不能精确表示了(这个和尾数Fraction的位数有关,float32只能精确表示≤ 2242^{24}的整数)。

二进制数无法通过有限的数位精确表示小数,所以会丢失精度,那么用来表示整数,是不是就是精确的了。比如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序列存储在计算机中的”,字符串也不例外。在这篇文章中已经大概了解到了数字是怎样以二进制方式存储的了,字符串中的字符其实也是转换成对应的数字之后,以二进制形式存储的,下一篇记录字符串相关的内容。