为什么要学习编码
个人认为,程序本质上是对数据的处理过程。所以了解计算机储存的数据与人类能够认知的数据如何进行转换(也就是编码),有助于我们更深层次的理解代码。
什么是编码
我们常说的编码格式,其实包含两个概念:字符集和字符编码。
- 字符集:是一个系统支持的所有抽象字符的集合。 字符是各种文字和符号的总称,包括各国家文字、标点符号、图形符号、数字等,如ASCII字符集、GBXXXX字符集、BIG5字符集、Unicode字符集。
- 字符编码:是一套法则,使用该法则能够对自然语言的字符的一个集合(如字母表或音节表),与其他东西的一个集合(如二进制或电脉冲)进行配对,如ASCII编码、GBXXXX编码、BIG5编码、Unicode编码。
Unicode字符集与char类型
Unicode,统一码联盟官方中文名称为统一码[1],又译作万国码、统一字元码、统一字符编码[2],是统一字符编码标准(The Unicode Standard)的简称,属一种信息技术领域的业界标准,其整理、编码了世界上大部分的文字系统,使得电脑可以用更为简单的方式来呈现和处理文字。
Java语言中的char类型采用的就是Unicode编码,虽然char类型被称作字符型,但实际上char类型保存的是该字符所对应的Unicode编码,如字符’A’在系统中会被保存为0x0041。
值得一提的是,1991年发布了Unicode 1.0,当时仅占用了65536个代码值中不到一半的部分,所以Java在设计时采用16位的Unicode字符集,也就是一个char字符占两个字节。但是Unicode至今仍在不断增修,每个新版本都加入更多新的字符。目前最新的版本为2022年9月公布的15.0.0[4],已经收录超过14万个字符(第十万个字符在2005年获采纳),所以说如今单个char值已经不足以表示全部的Unicode字符了。
UTF-8与UTF-16
Unicode的实现方式不同于编码方式。一个字符的Unicode编码确定。但是在实际传输过程中,由于不同系统平台的设计不一定一致,以及出于节省空间的目的,对Unicode编码的实现方式有所不同。Unicode的实现方式称为Unicode转换格式(Unicode Transformation Format,简称为UTF)
在Unicode中,字符的编码是确定的,编码值使用16进制表示,并加上U+作为前缀表示,例如字符’A’的编码值表示为U+0041。
我们把U+0041这样的编码值称为码点(code point),把不同的UTF实现中的最小存储单元称为代码单元,在UTF-8中,一个代码单元由8位二进制数组成,在UTF-16中,一个代码单元有16位二进制数组成。
但并不是说代码单元的存储粒度越小,越节省空间。某些字符采用UTF-16表示,只需要一个代码单元,也就是2个字节,而采用UTF-8表示,则需要三个代码单元,也就是3个字节。所以采用什么的编码实现还需要看具体使用的字符区间。
Unicode的编码空间从U+0000到U+10FFFF,共有1,112,064个码点(code point)可用来映射字符。Unicode的编码空间可以划分为17个平面(plane),每个平面包含216(65,536)个码位。17个平面的码位可表示为从U+xx0000到U+xxFFFF,其中xx表示十六进制值从0x00到0x10,共计17个平面。第一个平面称为基本多语言平面(Basic Multilingual Plane, BMP),或称第零平面(Plane 0),其他平面称为辅助平面(Supplementary Planes)。
接下来会详细介绍UTF-16和UTF-8两种编码实现方式。
UTF-16
基本多语言平面的码点范围为U+0000到U+FFFF,其中从U+D800到U+DFFF之间的码位区段是永久保留不映射到Unicode字符,被称为替代区域(surrogate area)。
在UTF-16中,基本多语言平面的码点,都可以用一个代码单元来表示,而辅助平面的码点,则需要通过一对替代区域的代码单元来表示,称作代理对(Surrogate Pair)。对于代理对,U+D800 ~ U+DBFF区间用于第一个代码单元,称为前导代理(lead surrogates),U+DC00 ~ U+DFFF区间用于第二个代码单元,称作后尾代理(trail surrogates)。
这样我们就可以迅速判断一个代码单元,到底是便是一个字符,还是表示前导代理或是后尾代理。
接下来我们通过八元数集的一个数学符号‘𝕆’来演示UTF-16的编码过程。
//𝕆 的码点为U+1D546,我们通过一个int值接收。
int symbol = 0x1D546;
//首先,将辅助平面的码点减去 0x10000,这样就可以用一个20位的二进制数来表示所有的辅助平面码点。
symbol -= 0x10000;
//上面已经将码点转化为一个20位的二进制数,我们取高10位,获得一个前导代理,并使其落入U+D800 ~ U+DBFF区间
short lead = (short) ((symbol >> 10) + 0xD800);
//然后取低10位获得一个后尾代理,并使其落入U+DC00 ~ U+DFFF区间
short trail = (short) ((symbol & 0x3FF) + 0xDC00);
//最后利用String类将两个代码单元组合起来,Java中一个char值描述一个UTF-16的代码单元
String result = new String(new char[]{(char) lead, (char) trail});
//输出为 𝕆
System.out.println(result);
UTF-8
UTF-8使用一至六个代码单元为每个字符编码:
- 128个US-ASCII字符只需一个代码单元编码(码点范围由U+0000至U+007F)
- 带有附加符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及它拿字母则需要两个代码单元编码(码点范围由U+0080至U+07FF)
- 其他基本多语言平面(BMP)中的字元(这包含了大部分常用字,如大部分的汉字)使用三个代码单元编码(码点范围由U+0800至U+FFFF)
- 其他极少使用的辅助平面的字元使用四至六代码单元编码(码点范围由U+10000至U+1FFFFF使用四字节,码点范围由U+200000至U+3FFFFFF使用五字节,码点范围由U+4000000至U+7FFFFFFF使用六字节)
每个使用UTF-8储存的字符,除了第一个代码单元外,其余代码单元的头两位都是以"10"开始,使文字处理器能够较快地找出每个字符的开始位置。如下表:
码点的位数 | 码点起值 | 码点终值 | 代码单元数 | 代码单元 1 | 代码单元 2 | 代码单元 3 | 代码单元 4 | 代码单元 5 | 代码单元 6 |
---|---|---|---|---|---|---|---|---|---|
7 | U+0000 | U+007F | 1 | 0xxxxxxx | |||||
11 | U+0080 | U+07FF | 2 | 110xxxxx | 10xxxxxx | ||||
16 | U+0800 | U+FFFF | 3 | 1110xxxx | 10xxxxxx | 10xxxxxx | |||
21 | U+10000 | U+1FFFFF | 4 | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx | ||
26 | U+200000 | U+3FFFFFF | 5 | 111110xx | 10xxxxxx | 10xxxxxx | 10xxxxxx | 10xxxxxx | |
31 | U+4000000 | U+7FFFFFFF | 6 | 1111110x | 10xxxxxx | 10xxxxxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
我们仍旧以数学符号‘𝕆’来演示UTF-8的编码过程。
//𝕆 的码点为U+1D546,我们通过一个int值接收。
int symbol = 0x1D546;
//根据转换关系表,判断该码点长21位,使用4个代码单元表示
byte[] utf8Bytes = new byte[4];
//代码单元1,1111表示需要4个代码单元,0表示长度标识结束。记录完所需的代码单元数,该代码单元还有三个二进制位可以记录码点
utf8Bytes[0] = (byte) ((0b11110 << 3) | (symbol >> 18));
//后续的代码单元均由10标记开始,每个代码单元记录6位码点。
utf8Bytes[1] = (byte) ((0b10 << 6) | ((symbol >> 12) & 0b111111));
utf8Bytes[2] = (byte) ((0b10 << 6) | ((symbol >> 6) & 0b111111));
utf8Bytes[3] = (byte) ((0b10 << 6) | (symbol & 0b111111));
//利用String记录结果
String result = new String(utf8Bytes, StandardCharsets.UTF_8);
//输出为𝕆
System.out.println(result);