开始记录我的 2019-Read-Record 记录一些有意思的知识点和疑难杂症。
1 整理字符工作
有这么一帮人,他们对字节,编码等计算机概念一窍不通。他们的职责是对人类日常生活中用到字符和文字进行归纳和整理:包含一组不重复,无序元素的集合,如下:
此时集合包含10个元素,无序且无重复元素,为了更直观且抽象的表示,他们采用两种方式来表示:
编号 | 字符 |
---|---|
1 | p |
2 | m |
3 | s |
4 | t |
5 | 学 |
6 | 习 |
7 | 字 |
8 | 符 |
9 | 编 |
10 | 码 |
… ... | … ... |
采用一维数字编号,查找关系为 1->p
、2->m
以此类推.
区/位 | 1 | 2 | 3 | 4 |
---|---|---|---|---|
1 | p | m | s | t |
2 | 学 | 习 | 字 | 符 |
3 | 编 | 码 | ... | ... |
4 | ... | ... | ... | ... |
采用二维区位编号,查找需要一对数字(1,1)->p
、(1,2)->m
以此类推。
上述知识完全不涉及计算机知识,仅仅是集合中字符元素归纳整理的两种方式罢了,之后我们谈及更多的是一维数字编号方式。
2. 计算机背景下的字符整理
2.1 定长编码
计算机底层无法存储“p”,“m”...“码”等字符,而采用一连串的0、1表示数据,以字节(byte)为单位(00000000 - 11111111,16进制表示为:0x00 - 0xFF)。因此计算机底层真正存储的是字符编号,注意到计算机底层的编号索引是从0开始,所以我们需要对方式一编号稍作修改。
编号 | 编码后计算机实际存储 | 字符 |
---|---|---|
1 | 0 | p |
2 | 1 | m |
3 | 2 | s |
4 | 3 | t |
5 | 4 | 学 |
6 | 5 | 习 |
7 | 6 | 字 |
8 | 7 | 符 |
9 | 8 | 编 |
10 | 9 | 码 |
… ... | ... ... | … ... |
倘若计算机用一个字节存储字符的编号,同时以一个字节解读数据,例如计算机底层的一串数据是“00000000 00000001 00000010 00000011 00000100 00000101 00000110 00000111 00001000”根据上表可以解读为“pmst学习字符编码”。
那么问题来了,一个字节最多可以表示256个字符。随着源源不断地往集合中加入新字符,编号不断累加直到超过255,一个字节已经无法满足我们的需求。
因此我们改用两个字节来表示字符编号,同时以两个字节单位解读数据。此时范围为 0x0000 - 0xFFFF,容量为65536个字符。
尽管解决了字符容量问题,但是对于“m”这个字符我们需要用2个字节0x0001来表示,原先只需要一个字节0x01表示,底层存储字节多了一倍。显然这种并不是我们所期望的。
2.2 变长编码
我们既要考虑集合字符的容量问题,又要减少不必要的字节浪费,因此引入了"变长编码"概念,对于那些编号超过255的字符,我们保留两个字节表示,而对于那些编号较小的字符,我们当然希望使用1个字节存储,而不是2个字节。
变长设计的核心问题自然就是如何区分不同的变长字节,只有这样才能在解码时不发生歧义。
道理我都懂,但是按照表格映射关系解析计算机底层数据时,何时用一个字节解读,何时又用两个字节解读呢?这是个棘手的问题!
答案是高位区分。
编号 | 编码后计算机实际存储 | 字符 |
---|---|---|
1 | 0 | p |
… | … | … |
65 | 64(0x40) | x |
… | … | … |
128 | 127(0x7F) | h |
129 | 32768(0x80 00) | e |
130 | 32769(0x80 01) | l |
131 | 32770(0x80 02) | o |
… ... | ... ... | … ... |
仔细观察上述表格,集合中字符编号未变,但是在计算机中实际存储规则变了,计算机现在知道如何区分用一个字节去解读还是两个字节。当计算机读到0x00-0x7F范围的字节值,会直接按照表格映射关系去解释,例如碰到0x40这个字节,会直接解释成“x”;而碰到0x80-0xFF范围字节值,会先保留它不作解释,会继续读入下一个字节后才进行映射,例如“0x80 0x01”,先读入0x80,计算机知道处于0x80-0xFF范围,需要再读入一个字节才能解释,于是再次读入0x01,合并后为0x8001,到表格映射关系中找到对应的字符“l”。
总结: 这里以一个简单的示例解释了变长编码的概念。上面提到的简单变长编码方式弊端很多,首先可表示字符的范围现在为0x00-0x7F 以及 0x8000 - 0xFFFF,硬生生地把0x80-0x7FFF这段可利用空间去除了。至于原因,请思考:倘若这段区间也做字符映射,那么计算机碰到 0x80 或是其他值,它还能区分用1个字节还是2个字节解释吗?
3 Unicode 字符集
还记得第一节说到专门有一帮人负责归纳整理世界上所有的字符吗?即Unicode字符集,看下定义:
Unicode(统一码、万国码、单一码)是计算机科学领域里的一项业界标准,包括字符集、编码方案等。Unicode 是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。1990年开始研发,1994年正式公布
现在来说说这个字符集的容量有多少。按照之前一维数字编号,目前范围是0x0000 - 0x10FFFF(21位 bits)。这里要引入“平面”这一概念,一个平面能放置65536(0xFFFF)个字符。因此Unicode字符集可以划分为17个平面:
平面号 | 平面范围 |
---|---|
Plane0 —— Basic Multilingual Plane 基本多语言平面 | 0x0000-0xFFFF |
Plane1 —— 后续16个平面统称为SP Supplementary Planes) | 0x10000-0x1FFFF |
Plane2 | 0x20000-0x2FFFF |
... | ... |
Plane15 | 0xF0000-0xFFFFF |
Plane16 | 0x100000-0x10FFFF |
用图表示:
第一个平面即是BMP(Basic Multilingual Plane 基本多语言平面),也叫Plane 0,它的码点范围是U+0000~U+FFFF。这也是我们最常用的平面,日常用到的字符绝大多数都落在这个平面内。
上图彩色的平面即 BMP,范围为0x0000 - 0xFFFF,但我们并没有“塞满”这个平面,其中部分位置用于保留,换句话说,BMP的容量只有6万多个字符,想象一下把这些字符整合起来放在一起,密密麻麻!GNU Unifont就制作了一张这样的图片。见http://unifoundry.com/pub/unifont-7.0.03/unifont-7.0.03.bmp 打开可能会费一点时间。
前面说到BMP是我们最常用的平面,日常用到的字符绝大多数都集中在这里,而对于中文来说,我们常用[\u4E00-\u9FA5]正则表达式来匹配,其实也就是指定中文在Unicode字符集中的编号范围0x4E00 - 0x9FA5。其实稍加计算就知道这个范围的容量不过两万多点,中文显然不止这个数。其实9FA5后面还有不少的汉字,它们中间又还夹杂着一些符号,所以想正确地表示Unicode中的汉字还是个不小的挑战。
这里引用一段:应该说,Unicode处在不断发展中,它有一百多万的空间,目前也只是定义了十万左右的字符,还会不断增加,汉字自然也有可能增加,所以汉字的范围实际上是动态的,变化的。当然了,常用的基本落在了这一范围内,而事实上已经包含了许多的不常用汉字,毕竟连只有6千多字的GB2312中都含有大量的不常用汉字。在要求不那么严格的应用中,按以上范围去判断基本也OK,而“汉字”这一概念实际上也没有准确定义,比方说上图中一些“偏旁部首”,这些是“汉字”吗?
平常我们看到诸如 \u
,U+
紧跟十六进制数字,其实就是Unicode字符集中字符的编号了,例如 \u4E00
其实就是对应 Unicode 字符集中的汉字“一”。再次强调这里其实不涉及计算机知识,而是单纯的归纳整理的方式而已:一维数字编号。
现在说说计算机底层如何存储这个编号,是直接存储0x4E00吗,还是找到一种映射关系,将0x4E00处理成其他数据后再存储到计算底层里,这里涉及的东西很多,下文会一一解答。
4 UTF - Unicode 转换格式
至此,我们对Unicode字符集有了初步的了解,再次强调一下:即使对字节,编码等计算机概念一窍不通此时是没有任何关系的!正如前面多次强调,字符集就是收集日常所用的所有字符,然后将其归纳整理,比如Unicode这个组织用数字对每个字符编号,将世界上所有文字和符号的字符收录,从0开始编号,一直到0x10FFFF(强调:21位 bits)。想象下,有一张两列的表,左边是编号,右边是对应的字符,看到这你可能脱口而出:一一对应关系。即得到编号0去查表,就知道对应的字符是NULL
;同理,拿“a”字符去查索引,应该是0x0041(十进制的65)。
编号 | 字符 |
---|---|
0 | NULL |
... | ... |
0x10FFFF | 未知... |
再换种表示形式,本质其实是一样的:
更多时候 Unicode 字符集中的字符表示形式应该有两种:U+[xxxx] 和 \u[xxxx],其中x代表十六进制数字。等等!Uncode字符集范围不是到21位的 0x10FFFF吗,怎么这里只有4个x
? 难道是因为常用的都在第一平面BMP,它的范围是0x0000-0xFFFF?如果你已经开始这么思考,恭喜你,差不多已经理解我要表达的意思了。
4.1 UTF-32
计算机底层并非直接存储"a","1","丁"等字符,而是先转变成另外一种形式后再存储。首先想到的且最简单的:将编号以二进制存储。如下:
编号 | 编码后的索引 | 字符 |
---|---|---|
0 | 0 | NULL |
... | ... | ... |
0x10FFFF | 0x10FFFF | 未知... |
编码后的索引和之前归纳整理的编号一模一样,编码或者说映射关系其实很简单:
int map(int idx){
return idx;
}
早期字符数量还没有现在这么庞大,使用2个字节存储编号足矣(0x0000-0xFFFF)。但随着新字符的加入,字符数量已经超出了上限65536。既然2个字节不够表示,解决方法很简单:4个字节,即 UTF-32 编码。
尝试下,创建一个文件并写入内容“1a”,然后以 UTF-32 编号格式存储,接着以16进制查看这个文件内容(你可以选择可视化工具,或者使用hexdump
命令查看),最后看到的应该是0x00000031 0x00000061
;现在从计算机读取内容,即``0x00000031 0x00000061`,编辑器每读入4个字节开始解码,查表得到相应的字符最后呈现给我们,这也是最终我们看到的,这里用到的是定长编码方式。
思考:既然 UTF32 存储方便容易理解,为何不一用到底?干嘛还要UTF8-16,UTF-8呢?
其实原因前面已经提及过一次:当存储内容是英文字符或是其他较小编号时,每个字符都要使用4个字节来存储,太过浪费,利用率极低!
为此才有了变长编码 UTF-16 和 UTF-8 。
4.2 UTF-8
UTF-8是变长的编码方案,可以有1,2,3,4四种字节组合。在前面的定长与变长篇章我们提到UTF-8采用了高位保留方式来区别不同变长,如下:
0XXXXXXX 有效编码位:7 bits
110XXXXX 10XXXXXX 有效编码位:11 bits
1110XXXX 10XXXXXX 10XXXXXX 有效编码位:16 bits
11110XXX 10XXXXXX 10XXXXXX 10XXXXXX 有效编码位:21 bits
一问:为何使用
0
,110
,1110
和11110
来作为区分?使用1
,11
,111
和1111
可以吗?
一答:显然使用后者无法区分,比如读入第一个1
,你无法确定是一个字节数据;继续读入一个1
,你还是无法确定是2个字节数据,同理,读入111
也一样无法确定,但是读入1111
就可以确定是四个字节数据了,至于前面的,我们必须读到0才可以确定几位数,比如110
,我们才知道是两个字节数据,但是这样使得有效数据位变少,因此我们还是打算以0
,110
,1110
和11110
区分。
二问:为何第二位起使用固定的10
?
二答:倘若我们使用0
,10
,110
和1110
区分读入几个字节,讲道理之后可以不用保留位啊,直接读XXXX XXXX
不是利用率更高吗? —— 我个人认为计算机可以从任何一个字节读入,万一从某个XXXX XXXX
读取时,恰巧这个数又是以110
或者1110
打头的,那一旦开始就读错,之后就是连锁反应,全部都解释错误了。所以我们对于后面的字节也需要保留位,至于10
,应该是为了增加可利用位数吧,总不能用11110
来作为保留位吧。
如上,
0
,10
,110
这些都是保留的固定位,X表示是有效编码位。单字节最高位都是0,多字节的最高位都是1.
针对多字节来讲,我们可以称之为N(N > 1)字节模式,首字节以“N个1再加0”打头,后跟“N-1”个以“10”打头的字节。
说完用 UTF-8 编码后的四种计算机底层存储形式,再来反向说说读取过程。计算机每次读入一个字节,以高位来区分是用1个字节解码,还是2,3,4字节解码。
重点来了:我们实际真正要用的是有效编码位,也就是X
,UTF-8 编码就是从这些二进制数据中提取有效编码位,组合得到一个新的十六进制值作为索引去Unicode字符集中查找对应字符。—— 就是这么简单!
- 一字节有效编码位有7位,2^7=128,可以表示字符集区间 U+0000U+007F(0127)。
一字节留给了ASCII,所以UTF-8兼容ASCII。
- 二字节有效编码位只有5+6=11位,最多只有2^11=2048个编码空间,所以数量众多的汉字是无法容身于此的了。字符集区间 U+0080U+07FF(1282047)使用二字节。
注意:这里编号范围是128~2047,因为去掉了一字节的码点(因为下限是U+0080),所以不会占满2048个编码空间,是有冗余的,但你不能把适用于一字节的码点放到这里来编码。下同。
这个需要解释下为什么范围设定了U+0080~U+07FF 其实可以看下编号后的字符集 U+D800~U+DFFF 是空的!要用作代理区,其实吧就是为了不影响之后的解码冲突。
- 三字节模式可看到光是保留位就达到4+2+2=8位,相当一字节,所以只剩下两字节16位有效编码位,它的容量实际也只有65536。码点U+0800U+FFFF(204865535)使用三字节编码。
我们前面说到,一些汉字字典收录的汉字达到了惊人的10万级别。基本上,常用的汉字都落在了这三字节的空间里,这就是我们常说的汉字在UTF-8里用三字节表示。当然了,这么说并不严谨,如果这10万的汉字都被收录进来的话,那些偏门的汉字自然只能被挤到四字节空间上去了。
- 四字节的可以看到它的有效位是3+6+6+6=21位,前面说到最大的码点10FFFF也是21位,U+FFFF以上的增补平面的字符都在这里来表示。
按照UTF-8的模式,它还可以扩展到5字节,乃至6字节变长,但Unicode说了码点就到10FFFF,不扩充了,所以UTF-8最多到四字节就足够了。
下面演示如何将汉字”你“(U+4F60)编码存储到计算机底层,来自字符集与编码一文,强烈推荐!
上图显示了一有效位为 15 位的码点到三字节转换的一个基本原理,我们还可看到原来4F60 中的一头一尾的两个 4 和 0 在转换后还存在于最终的三字节结果中。UTF-8 三字节模式固定了 1110 的开头模式,所以多数汉字总是以 1110 开头,换成 16 进制形式,1110 就是字母 E。
如果看到一串的 16 进制有如下的形式:EX XX XX EX XX XX…每三个三个字节前面都是 E 打头,那么它很可能就是一串汉字的 UTF-8 编码了。
其它变长字节转换道理也类似,其中分组从低位开始,高位如不足则补零。这里就不再示例了。
UTF-8 编码的误解:首先并非将 Unicode 字符集编号直接存储到计算机底层,其次前文已经说过按照高位来区分读取多少个字节(四种情况=1个字节 - 0xxxxxxx,2 - 110xxxxx,3 - 1110xxxx,4 - 11110xxx),但是对于读到的数据并非一定是字符在 Unicode 字符集中的编号,我们需要通过提取有效编码位并组合成新的索引,才去字符集中查询。
小结:UTF-8 变长编码方案很好地帮我们解决了字节利用率的问题,在 UTF32 编码方案里,字符"a"底层存储4个字节 0x00000041。而改用 UTF8 只需要一个字节。当然喽,正如上面演示的 UTF-8编码方式,比UTF-32编码f(x){return x}
的要复杂一些。
另外我们也能注意到,对于U+0800~U+FFFF范围内(即 BMP 平面)的中文字符,上文说了用 UTF-8 编码需要三个字节,但是中文显然不可能只有6万多个字,那些偏门的中文在Unicode字符集中的编号都是在 U+FFFF 之上了,显然用 UTF-8 编码要用四个字节。
4.3 UTF-16
UTF-8 作为变长编码方案对于 U+0000~U+07FF 范围字符的字节利用率是值得肯定的,而对于 BMP 中U+0800~U+FFFF 确要用3个字节,至于大于U+FFFF的通通都是4个字节,有没有在这方面更好的方案呢?
有,就是作为压轴出场的UTF-16,它是一种变长的2或4字节编码模式。对于BMP内的字符使用2字节编码,其它的则使用4字节组成所谓的代理对来编码。这是如何做到的呢? 且听我一一道来。
还记得GNU Unifont就制作了一张Unicode字符集成员的图片吗?地址:http://unifoundry.com/pub/unifont-7.0.03/unifont-7.0.03.bmp ,请找到D8行至DF行,这里不对应任何字符,是一块空白区域,也就是所谓的代理区(Surrogate Area),在Unicode字符集中的范围是U+D800 ~ U+DFFF(容量为2048),如果以U+DBFF为中心点,将其一分为二,也就有了高代理区(D800–DBFF)和低代理区(DC00–DFFF)两部分,各占1024。下面请回想第一章的二维表示方式,如果行列都有1024,那么这张表格就有1024*1024=16*65536= 16 * 0xFFFF
,我们的一个平面容量为0xFFFF,所以它恰好可以表示增补的16个平面中的所有字符。 这张表格如下:
U+0800~U+DFFF 之所以不对应字符,是因为以代理方式存储到内存中时,只要遇到字节在这个范围内的 我们就知道需要进行代理转换了!计算得到0xFFFF编号之上的字符了 也就是那16个补充平面。Unicode 字符集编号显然也会为了编码适当做出调整,比如这里为了代理映射,我们将U+0800~U+DFFF这段区域保留了,不映射字符了。
再次强调:UTF-16 是一种变长的2或4字节编码模式,我们将 Unicode 字符集划分2个范围来讨论:
字符集区间 U+0000~U+FFFF:计算机以2个字节为单位读取底层数据,如果读到的两个字节不在代理区的范围(U+D800 ~ U+DFFF),那么就直接当做编号直接去Unicode 字符集中查询,例如0x0041查询到的是”a“,0x4F60查询到的是”你“ —— 还记得UTF-8是编码成3个字节存储和读取解码的吗?
字符集区间 U+10000~U+10FFFF,即16个增补平面:计算机同样以2个字节为单位读取底层数据,如果读到的两个字节高代理区的范围(D800–DBFF),接着再次读取2个字节,一定要是低代理区(DC00–DFFF)。这样2+2 组成一个代理对(Surrogate Pair)查询对应的字符。注意必须是这种先高后低的顺序,如果出现两个高,两个低,或者先低后高,都是非法的。
UTF-16的例子其实很简单,核心其实不过是上面的那种二维表格,(D800,DC00)对应了 Unicode 字符集中编号为U+01000的字符;(DBFF,DFFF)对应了 Unicode 字符集中编号U+10FFFF的字符。就是这么简单。
5 BOM
reserved