字符编码的原理

19 阅读18分钟

很早之前我就想搞清楚编程中的编码到底是什么原理,我记得刚开始学Java开发时,看到资料中说Java的字符串采用的是UTF-16编码。但是,实际工作中项目里面的.java文件又是以UTF-8编码保存的。这个问题好多年前我就在网上搜过各种资料,想搞明白是怎么回事。但是,搜到的资料讲的都不是很系统,导致这个问题它一放好多年。现在有了AI之后,想搞明白这件事实在是太轻松了。

编码的原理

SOS求救信号大家都知道吧?当你在野外遭遇危险时,你可以利用电灯向远方发出三短三长三短的光线,当别人看到这个三短三长三短的光线时,就知道有人在发求救信号。这其实就是编码的基本原理。将数字以固定的规则排列,就能代表人类的各种语言。

编码

计算机(电脑、手机)仅能识别 0 和 1 这两个二进制数字,若要让计算机处理人类语言,需先对语言字符编码 —— 为每个字符分配唯一的十进制数字,再将该数字转换为二进制,计算机按预设规则解码后,就能识别对应的人类语言。

计算机中的存储单位

计算机的存储单位包括 bit(位)、byte(字节)、KB、MB、GB、TB 等。其中 bit 是最小单位,仅能存储 1 个二进制位(0 或 1);1 byte = 8 bit,即 1 个字节可存储 8 个二进制位(8 个 0/1 的组合)。8个0或者1,通过不同的排列规则,比如00110011或者11001100,可以组合出256种规则。好了,知道这个我们来说字符编码的原理。

现在计算机的最小存储单位为:byte(字节),即使你只需要存储一个二进制数字0,也会占用一个字节的存储空间。比如,英文字母"A"对应的编码为65,转换为二进制后为:1000001,只有7个二进制数字,但是存储的时候也会按照1个字节(8个bit)去进行存储。将1000001前面补一个二进制数字0后,再进行存储。前面补0并不会影响1000001本身所代表的含义,比如十进制的数字9,在9前面补一个0变成09,正常人看到之后还是知道这是数字9。

ASCII编码

计算机(电脑)最早是美国人发明的,当时由于计算机(电脑)还没有在全球流行开来,所以美国人当时只对美国人常用的字符制定了一套编码规则,就是ASCII编码。这套编码只对英文的26个字母、以及空格、换行、问号、感叹号、数字等常见字符做了编码,共计128个字符。这对使用纯英文沟通的人已经完全够用了。编码就是从0开始到127。127这个十进制数字转换为二进制是1111111有7个二进制的1,上面说过计算机的存储单位,1byte可以保存8个二进制的0或者1,所以ASCII编码每个字符,最多(最大的数字编号就是127)也就占用一个字节(byte)的大小。

Unicode编码

显然,中文的字符远远不止128个字符。要想在电脑上显示中文,中国就得重新制定一套编码规则,其他国家也一样。制定编码规则很简单,大家都从0开始进行编码,给自己国家的每一个字符指定一个唯一的数字编号就可以了。

但这种方式极易造成混乱,比如0这个编码,在中文的编码里面可能代表“我”这个汉字,但是在其他国家就可能代表“A”这个字母,不同国家之间的人想通过互联网交流,会极为不便。

于是Unicode编码应运而生,Unicode编码对世界上所有人类使用的字符进行统一编码,也是从0开始,给世界上所有人类使用的字符指定一个唯一的数字编号。Unicode编码现在共计编码了大约有1114111个字符,包括表情符号。也就是说Unicode编码,现在最大的数字编号为1114111。

各个国家都使用这个编码规则就不会乱套了,0这个编码只会对应一个唯一的字符,在全世界都一样。

Unicode编码的存储规则

Unicode编码虽然解决了字符到编码的映射规则,但是没有解决字符编码应该怎么存储?也就是说,Unicode编码后的数字编号,在计算机中应该怎么存储?如果按照Unicode编码的规则直接存储,就太浪费计算机的磁盘空间了。

因为Unicode编码的最大数字编号为:1114111,这个数字转换为二进制后为:100001111111111111111,有21个二进制数字,需要使用3个字节(byte)才能存得下。但是比如英文字母"A"的数字编号为:65,转换为二进制后为:1000001,只有7个二进制数字,只要1个字节(byte)就能存下。怎么存储就是一个问题。

我知道你和我有一样的想法,你可能这样想:“将Unicode的数字编号按转换后的真实二进制存储就行了,有多少个二进制数字,就存储几个字节就行了。比如英文字母A存储的时候,只存储到一个字节里面就行了。中文的数字编码如果转换为二进制后,有16个二进制数字,就存储俩个字节不就行了?”。但是这样是行不通的,如果你这样存储,你读取的时候怎么知道一次要读取几个字节呢?有的字符占一个字节、有的字符占3个字节,你必须读完一个字符的所有字节,才能得到正确字符编码。

比较简单的解决办法就是上文说过的补0规则,在存储Unicode编码时,统一按照最大的数字编号去存储,无论什么字符统一使用3个字节去存储。这样虽然能解决问题,但是就太浪费磁盘空间了。如果对一段全是英文的内容,采用这种方式存储,就会浪费整整俩倍的存储空间。因为一个英文字符只需要1个字节就能存储了。

所以,UTF-8、UTF-16这种编码就应运而生了。

UTF-16编码

UTF-16编码规定,对于Unicode编码从065535的这些字符使用2个字节进行存储。对于编码从655361114111的这些字符使用4个字节进行存储。

那UTF-16编码读取的时候,UTF-16怎么知道什么时候该一次读取俩个字节,什么时候又该一次读取4个字节呢?这就要结合历史原因进行分析了,其实Unicode最开始只收录了65535个字符,65535这个十进制数字,转换为二进制后为:1111111111111111,刚好为16个二进制数字。最早的时候UTF-16一次只需要读取2个字节就行了。并且UTF-16编码也是Unicode官方的编码存储方式。但实际上最早0到65535这些编码中,很多编码也没有用上,很多编码都是空白字符,因为没有那么多字符可以收录。Unicode只是先准备了065535的编号。

最开始Unicode官方认为这些字符就足够人类使用了。但是随着历史的发展,人类发明的字符越来越多,Unicode不得不继续收录新的字符。Unicode为了能保证UTF-16编码可以兼容这些新增的字符,在Unicode编码中给UTF-16预留了一段代理区域,也就是5529657343这个区间,这个区间的编码不会分配给任何人类的字符。UTF-16利用这段区间可以计算出什么时候要一次读取2个字节的数据,什么时候要一次读取4个字节的数据。

UTF-16存储的时候,对于字符编号小于55296的字符编码,直接将字符编码转换为二进制进行存储,无论转换为二进制后有几个二进制数字,统一按照2个字节的大小进行存储,读取时先一次性读取2个字节,如果读出来的数字编号小于55296,直接拿着数字编号去找对应的字符就可以了。但是如果读到的数字编号大于55296,此时必须再往下读取2个字节的内容,然后把这4个字节的内容拼在一起得到一个数字编号,根据数字编号就能得到对应的字符内容了。

举个例子:对于“𠮷”这个中文字符,“𠮷”在Unicode中的数字编号为133999。UTF-16存储这个数字编号时,会先按照UTF-16的算法规则进行拆分,将“𠮷”的数字编号133999拆分为俩部分进行存储。

第一步:计算偏移量,使用“𠮷”的数字编号133999减去固定数字65536得到68463。为什么要减去65536呢?因为Unicode早期字符数字编号只到65535,从65536开始都是新增的数字编号,对于大于等于65536的字符数字编号需要用新的规则进行处理。

第二步:计算高代理的值,使用上面计算出来的偏移量68463除以固定数字1024得到66(只保留整数部分)。使用固定数字55296加上66得到55362。这个55362就是高代理的值,然后将55362这个值转换为二进制存储到计算机中,占用2个字节的存储空间。

第三步:计算低代理的值,使用上面计算出来的偏移量68463减去66乘以1024的值,也就是68463减去67584得到879。然后,使用固定数字56320加上879得到57199。这个57199就是低代理的值,然后将57199这个值转换为二进制存储到计算机中,占用2个字节的存储空间。

为什么低代理会使用固定数字56320呢?这是因为Unicode将5529657343这个区间的编码分为了俩部分,高代理区间(55296-56319)和低代理区间(56320-57343)。这个俩个区间刚好都包含1024个数字,1024*1024=1048576。刚好Unicode第二次编码是从655361114111新增的,刚好新增了1048576个数字编号。Unicode设置的这俩个代理区间,组合后刚好可以表示第二次新增的这些数字编号。嘘,别问,问就是数学的魅力。

读取的时候也是一样的:当UTF-16读取俩个字节后,发现读到的数字为55362大于高代理区间55296,这个时候UTF-16就知道必须再继续往下读俩个字节的数据,第二次读取到的值为57199。此时UTF-16已经读取4个字节的数据了,可以将这4个字节的数据,通过算法还原为真实的数字了。
还原的步骤如下:

第一步:将读取到的前俩个字节的数字55362减去55296得到66
第二步:将读取到的后俩个字节的数字57199减去56320得到879
第三步:计算偏移量66 * 1024 + 879 = 68463
第四步:使用偏移量68463加上固定数字65536得到133999,刚好就是“𠮷”这个中文字符的Unicode数字编号,拿着这个编号就能找到对应的中文字符。

编码和解码到这里结束了。

UTF-8编码

上面讲的UTF-16编码,对于字符要么使用2个字节存储,要么使用4个字节存储。其实还是比较浪费存储空间,因为对于保存纯英文的字符来说,还是浪费了1倍的存储空间。UTF-8就是为了解决这个问题来的。

UTF-8是一种变长编码,UTF-8会根据Unicode字符的数字编码去动态计算每个字符存储时需要占用的真实大小。比如针对英文字符,存储时只需要占用1个字节的存储空间。对于中文字符,存储时可能需要占用2个字节的存储空间。在UTF-8中一个字符最小只需要占用1个字节的存储空间,最大可能需要占用6个字节的存储空间。

UTF-8的编码规则其实也很简单,首先UTF-8编码的字节是有模板的,每个模板都是固定的,比如:0xxx xxxx110x xxxx 10xx xxxx1110 xxxx 10xx xxxx 10xx xxxx1111 0xxx 10xx xxxx 10xx xxxx 10xx xxxx,这里我只列了4个UTF-8编码的字节模板。比如,要使用UTF-8编码存储英文字母A,英文字母A的Unicode编码为65,转换为二进制后为:1000001,有7个二进制数字。那此时在UTF-8编码的规则里面,英文字母A就必须使用第一个模板0xxx xxxx进行存储,因为第一个模板0xxx xxxx里面刚好有7x,把英文字母A的Unicode编码往里面放就行了,放完之后变成01000001,当UTF-8读取的时候,看到开头的第一个二进制位是0就知道这个是1个字节,只需要读取1个字节就行了,读取后把0后面的7个二进制转换成Unicode的十进制数字编码就能得到对应的Unicode字符了。

同理,要使用UTF-8编码存储中文汉字,的Unicode编码为20013,转换为二进制后为:100 1110 0010 1101,有15个二进制数字。此时UTF-8编码的第一字节模板里面只有7个x,不够替换。第二个模板里面只有11个x也不够替换,只能使用第三个字节模板。我们将100 1110 0010 1101填充到UTF-8编码的第三个字节模板里面,填充时要从左(高位)到右(低位)开始填充。先将100 1110 0010 1101最左边的100填充到1110 xxxx 10xx xxxx 10xx xxxx这个模板里面。但是由于第三个模板里面有16个x,但是的Unicode编码只有15个二进制数字,所以还是采用前面补0的规则,将的Unicode编码前面补0变成0100 1110 0010 1101,然后将最左边的0100填充到1110 xxxx 10xx xxxx 10xx xxxx模板里面变成1110 0100 10xx xxxx 10xx xxxx,接下来把剩下的1110 0010 1101最左边的6个,填充到模板里面变成1110 0100 1011 1000 10xx xxxx,再把生效的10 1101最左边的6个,填充到模板里面变成1110 0100 1011 1000 1010 1101

UTF-8为什么使用这个模板,是因为每个模板里面的固定格式代表的是一种规则。比如,第一个模板0xxx xxxx,当UTF-8读到这个字节时,看到开头的0就知道,这是一个字节的数据,只需要读取一个字节的数据,就可以了。第二个模板110x xxxx 10xx xxxx,前面的俩个1代表这是俩个字节的数据。以此类推,第三个模板1110 xxxx 10xx xxxx 10xx xxxx,前面的三个1代表这是三个字节的数据。

UTF-8 的编码规则很简单,只有二条:

1)对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。

2)对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

Java的UTF-16

你可能在某些资料中看到过说Java的String类型采用UTF-16编码,这句话的意思就是Java程序在运行过程中,将String类型的值采用UTF-16编码的方式存储到内存中了。注意,这跟.java文件采用什么类型的编码保存没有任何关系。如果你的.java文件采用的是UTF-8编码保存的,那么JVM读取.java文件里面保存的内容时,肯定也是以UTF-8编码进行读取的。但是JVM从.java文件中将文件中的内容读取到内存中后,就会以UTF-16编码的方式将String变量的内容进行编码,存储到JVM的内存中。如果java代码需要将JVM内存中数据保存到磁盘中,java代码会自动将内存中UTF-16编码的数据读取出来,然后再以java代码指定的编码方式,将数据编码后保存的你要保存的文件中。

Java内置了很多编码和解码的工具类

Python的Unicode编码

在Python3中,程序运行时内存里的字符串采用的是 Unicode 编码(注意:是 Unicode 编码,而非 UTF-8 或 UTF-16)。也就是说,Python3总是会按照字符串中最大字节去存储数据。

比如s = 'A中𠮷',这个字符中'𠮷'的Unicode编码最大,存储时需要占用4个字节的存储空间,那么Python3在存储s这个变量时会占用12个字节的存储空间,即使英文字符 A 实际上只需要 1 个字节就能存储。

如果s = 'AB',那么Python3在内存中存储的时候,只会占用2个字节存储空间,因为这个字符串里面最大的Unicode编码也只需要1个字节就足够存储了。

Python会在字符串对象头的里记录存储时采用的存储宽度,读取的时候按照字符串对象头里面记录的存储宽度去读取就行了,一次读取字符串对象头里面记录的存储宽度的字节数就行了。

码点到字符的映射关系

其实,编程语言根本不认识人类的字符或者说编程语言根本不知道人类的字符长什么样子。当我们编写.java文件时,在.java文件中写了这样一行代码String s = "中文",然后点击保存时保存在磁盘文件里面的内容都是Unicode的字符编码,当JVM运行.java文件时,对JVM来说,JVM读取到的是Unicode的字符编码,你能看到电脑屏幕显示为中文,是因为IDE调用了操作系统的图形渲染程序,将Unicode的字符编码渲染为中文了。

Unicode的字符编码到字符的映射关系是存储在字体文件(.ttf/.otf/.woff2)里面的。字体文件内部都会包含一张cmap(Character Mapping,字符映射)表,知道Unicode码点就能在这个表里面找到对应的字符。