转载自 zhuanlan.zhihu.com/p/51202412
本文用非常便于理解的方式和语言介绍了 UNICODE 编码及其实现,包含 UTF-32, UTF-16 和 UTF-8。这是我以前记得一篇笔记,我将其通俗、细化了,以方便大家理解。此文章中的描述,很多都是我自己想出来的。还有,大家看的时候,不要纠结名词的翻译,名词后边,都是带上英文了的。
目 录
字符集编码(Character Encoding) 平面(Plane) Unicode 的实现 UTF-32 UTF-16 UTF-8 字节顺序标记(BOM) Unicode 的其他属性 查看三种实现的 JavaScript 代码 字符集编码(Character Encoding) 计算机的位只有两种状态,1 和 0,也就是说,在计算机中,只有数字。这些数字,要执行成代码,就得对命令编码;要显示出颜色,就得对颜色编码;要显示成文字,就得对文字编码。
对命令编码:比如汇编语言;对颜色编码:比如 CSS 用的 24 位色 RGB。对字符编码:通俗的讲,就是规定哪个数字代表哪个字符。比如在 GB 18030 中,规定 B0A1 代表字符“啊”。
这些字符都是一个一个给编出来的,工作量是相当庞大的。而 Unicode 就是一个更庞大,面向全球的字符集。
平面(Plane)
Unicode 使用的数字是从 0 到 0x10ffff,这些数字都对有相对应的字符(当然,有的还没有编好,有的用作私人自定义)。每一个数字,就是一个代码点(Code Point)。
这些代码点,分为 17 个平面(Plane)。其实就是17 组,只是名字高大上而已:
Plane 3 到 Plane 14 还没有使用,TIP(Plane 3) 准备用来映射甲骨文、金文、小篆等表意文字。PUA-A, PUA-B 为私人使用区,是用来给大家自己玩儿的——存储自定义的一些字符。
Plane 0,习惯上称作基本平面(Basic Plane);剩余的称作扩展平面(Supplementary Plane)。
顺便说下字体文件。通俗的讲,字体文件中存放的就是代码点对应的图形,以便计算机将代码点渲染成该对应的图形,然后人就可以阅读了。有的字体,里边没有存储中文,这些字体就渲染不了中文。
文字通常为点阵图形,就是说把图片分成很多点阵(小正方形),对每一个点,可以上色(1)和不上色(0),从而显示成不同的字符。
Unicode 的实现
通过上边的描述,可以知道,在文本中,存储的只是字符的代码点。而 Unicode 标准只规定了代码点对应的字符,而没有规定代码点怎么存储。
Unicode 的不同的实现,用了不同的存储方式。UTF-8, UTF-16, UTF-32 就是 Unicode 不同的实现。当然,还有其他的实现,这儿不作描述(其实那些我也没学习,大多是些抢不过标准的东东)。
计算机的最小存储单位是字节,也就是 8 位。为了方便描述,我得先来个凡例:
1.由于二进制太长,通常记作十六进制,可以方便阅读。四位二进制可以记作一位十六进制位,0xf = 0b1111, 0x0 = 0b0000,一个字节记作两位十六进制数。
2.写二进制数时,如无特殊情况我统一采用 8 个一组,也就是一字节。
3.我写了两个 JAVA 方法,用于转换代码点和二进制编码,看得懂就看,看不懂也不影响阅读,只要知道方法的作用就行了。
// 引用了 Apache Commons Lang3
/**
* 把代码点转换为相应的字符
*/
static String codePoint2String(int codePoint) {
return new String(Character.toChars(codePoint));
}
/**
* 传入字符和相应的编码,返回计算机使用的二进制编码
* 只为演示,未优化方法,未处理 RuntimeException
*/
static String binStr(String str, String encoding)
throws UnsupportedEncodingException {
var bytes = ArrayUtils.toObject(str.getBytes(encoding));
if (Byte.toUnsignedInt(bytes[0]) == 0xfe
&& Byte.toUnsignedInt(bytes[1]) == 0xff) {
bytes = Arrays.copyOfRange(bytes, 2, bytes.length)
}
return Arrays.stream(bytes).collect(
StringBuilder::new,
(sb, b) -> {
if (sb.length() > 0) {
sb.append(' ');
}
var s = Integer.toBinaryString(Byte.toUnsignedInt(b));
sb.append(StringUtils.leftPad(s, 8, '0'));
},
(sb1, sb2) -> { sb1.append(' ').append(sb2); }
).toString();
}
UTF-32 先来说 UTF-32,这个比较简单。
UTF-32 使用四个字节来表示存储代码点:把代码点转换为 32 位二进制,位数不够的左边充 0。
示例:
var s1 = "A"; // Plane 0
var s2 = codePoint2String(0x10000); // Plane 1
var s3 = codePoint2String(0x10ffff); // Plane 16
binStr(s1, "UTF-32"); // => `00000000 00000000 00000000 01000001`
binStr(s2, "UTF-32"); // => `00000000 00000001 00000000 00000000`
binStr(s3, "UTF-32"); // => `00000000 00010000 11111111 11111111`
可以发现,空间的浪费极大,在 Plane 0,利用率那是少得可怜,就算是 Plane 16,利用率也不到 3/4。而我们使用的大多数字符,都在 Plane 0。连存储都非常不划算,更不用说网络传输了。所以这种实现用得极少。
UTF-16
通过上表,可以发现,UTF-16 用二个字节来表示基本平面,用四个字节来表示扩展平面。
但是,上面的编码可能出现一个问题,比如一个字符编码:xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx,计算机可也不会知道它是二个基本平面的字符,还是一个扩展平面的字符。
为解决这个问题,Unicode 将基本平面的两段代码点保留,不表示任意字符。110110xx xxxxxxxx(0xd800 - 0xdbff)为高位代理(High Surrogate),110111xx xxxxxxxx(0xdc00 - 0xdfff) 为低位代理(Low Surrogate)。他们的作用,就是告诉计算机,这是代理,是用来构建扩展平面的字符的。计算机只要碰着了代理,就是道扩展平面的字符来了。
这儿所谓的代理,其实就是一种特殊的字符,不要被名字所迷惑。
一个高位代理和一个低位代理可以组成一个代理对(Surrogate Pair)。如果 y 和 x 全为 0,则为 0x010000 的代码点,全为 1 则为 0x10ffff 的代码点,刚好能把所有扩展平面全部编码。
代码点的编码方法:
如果代码点位于 0x000000 - 0x00ffff,直接进行二进制编码,位数不够的左边充 0。
如果代码点位于 0x010000 - 0x10ffff,则:
代码点减去 0x10000,会得到一个位于 0x000000 和 0x0fffff 之间的数字。
这个数字转换为 12 位二进制数,位数不够的,左边充 0,记作:yyyy yyyy yyxx xxxx xxxx。
取出 yy yyyyyyyy,并加上 11011000 00000000(0xD800),得到高位代理。
取出 xx xxxxxxxx,并加上 11011100 00000000(0xDC00),得到低位代理。
高位代理和低位代理相连,得到 110110yy yyyyyyyy 110111xx xxxxxxxx。
解析方法反过来就是。解析时如果代理不成对,计算机通常不显示该代理字符。
同样用上面的 Java 方法来一次
var s1 = "A"; // Plane 0
var s2 = codePoint2String(0x10000); // Plane 1
var s3 = codePoint2String(0x10ffff); // Plane 16
binStr(s1, "UTF-16"); // => `00000000 01000001`
binStr(s2, "UTF-16"); // => `11011000 00000000 11011100 00000000`
binStr(s3, "UTF-16"); // => `11011011 11111111 11011111 11111111`
可以看出,一、对于 0x0000 - 0xff 字符,空间的浪费也很大。二、扩展平面字符代理对的实现。
UTF-8
编码方法,将代码点转为二进制,依次填入,位数不够的,左边充 0。
可以看出,不同段的代码点会以不同的长度存储,计算机解析时,只用读取前面若干位,就知道该字符占几个字节,位于哪一段。
对于西文,该编码方式非常节约空间,因为西文的编码通常都小于 0x0007ff,尤其是 ASCII 字符,更是一个字符只占一个字节的程度。对于中文,常用的汉字通常位于 0x000800 - 0x00ffff 这一段,需要三个字节的存储,比起 UTF-16 的存储消耗要大一些。
var s1 = codePoint2String(0x7f);
var s2_1 = codePoint2String(0x80);
var s2_2 = codePoint2String(0x7ff);
var s3_1 = codePoint2String(0x800);
var s3_2 = codePoint2String(0xffff);
var s4_1 = codePoint2String(0x10000);
var s4_2 = codePoint2String(0x10ffff);
binStr(s1, "UTF-8"); // => "01111111"
binStr(s2_1, "UTF-8"); // => "11000010 10000000"
binStr(s2_2, "UTF-8"); // => "11011111 10111111"
binStr(s3_1, "UTF-8"); // => "11100000 10100000 10000000"
binStr(s3_2, "UTF-8"); // => "11101111 10111111 10111111"
binStr(s4_1, "UTF-8"); // => "11110000 10010000 10000000 10000000"
binStr(s4_2, "UTF-8"); // => "11110100 10001111 10111111 10111111"
需要特别说明的是,UNICDOE 中的前 0x7f 个字符编码,和 ANSI 编码的前 0x7f 个字符编码是完全相同的。
字节顺序标记(BOM) Byte Order Mark(BOM),即字节顺序标记,通常叫做大小端。位于文件开始的地方。用于标记高位在前,还是低位在前。
BOM 有两种形式: BE: Big-Endian, 高位在前,低位在后 LE: Little-Endian, 低位在前,高位在后 其中,UTF-8 的 BOM 可有可无,但如果读到 EF BB BF,好了,这就是 UTF-8 的文件。