最近花了点时间了解了下Unicode及其编码的相关知识,总结如下:
一点历史
ASCII code和Unicode是世界上应用最广泛的两种编码方案。ASCII code范围从0-127,其中0-31是控制字节。ASCII code可以用一个字节表示,由于编码只需要低位的7个bit,所以各个国家地区对如何使用大于127的编码空间制定了大量不同的标准,比如中文的GB2312及其扩展编码GBK。随着互联网的出现和普及,各国不同的编码方案导致信息交流极其不便,于是出现了致力于统一编排全球各国语言的Unicode编码。同一时期有另外一个叫UCS的工作组也在开展类似工作,并推出了UCS-2编码,最后被Unicode合并,且Unicode修正了编码以兼容UCS。
Unicode和UTF-8,UTF-16, UTF-32的区别
Unicode只定义了code point(码点),也就是字符到整数的映射关系。UTF-8,UTF-16和UTF-32是三种实现Unicode的具体编码方案,解决的是如何用二进制表示code point的问题,也就是在内存或者文件中如何存储,以及在网络上以什么形式传输码点信息。所以说Unicode和UTF-x是两个完全不同的概念。
UTF-8,UTF-16和UTF-32之间的区别
首先它们都是Unicode的编码方案(coding scheme),但是具体的实现方式大不相同。整体上讲,UTF-32是定长编码,而UTF-8和UTF-16是变长编码方案。UTF-8是三者中唯一兼容ASCII的编码方案。
UTF-32
UTF-32固定四个字节表示一个code point,直接用二进制表示码点,高位补0。好处是编码解码特别简单,但是现在不用,原因是太占用存储空间,尤其对于简单的ASCII字符,也需要用四个字节表示,不太合理。
UTF-16
UTF-16编码方式相对比较复杂。早期UTF-16作为UCS-2的替代,也固定使用两个字节(16bit)表示一个码点,在字符集比较有限的情况下完全可以满足需求。但随着越来越多语言的加入,导致两字节的编码空间不再够用,于是Unicode Consortium提出了一个扩展UTF-16的方案,在原来0x0000-0xFFFF的基本语言平面(BMP)之外加入了16个新的扩展平面,每个平面都包含655356(2 ^ 16)个码点,这样一共有17个平面,编码空间从0x0000-0x10FFFF。UTF-16的基本编码单元是两字节,也就是说,一个Unicode的码点用UTF-16表示出来,要么是两个字节,要么是四个字节。那么问题来了,怎么知道该用一个还是两个编码单元进行编码呢?还有解码的时候如何知道某个编码单元对应的是一个码点,还是只是完整码点的其中一半呢?
解决这个问题的方案非常聪明,它从BMP中预留了0xD800-0xFFFF这部分高位空间,该空间有个专门的名字叫做surrogate zone,这部分空间不分配任何字符。然后将该空间平均分成两份,从0xD800和0xDC00开始,分别叫high-surrogates range和low-surrogates range。如果码点大于等于0xD8,编码一个code point就需要一对单元。
high-surrogates和low-surrogates range中的码点都可以用16bit表示。把oxD800和0xDC00转换成二进制就会发现,这16个bit中最高6位都是固定的,那么两者都余下10bit的自由空间,加起来是20bit,刚好能容纳16个扩展平面,其中这20个bit的最高4bit表示在哪个平面,剩下16bit表示位于平面中的什么位置。
假设某个字符码点为x,且x >= 0xD800, UTF-16编码算法用公式表达一下就是:
x -= 0x10000; // 0x10000刚好超出基本平面0xFFFF
highSurrogate = 0xD800 + (x >>> 10); // 将超出部分分配到high和low surrogate中
lowSurrogate = 0xDC00 + (x & 0x3FF);
也就是说,UTF-16是用一对surrogate来表示定义在基本语言平面之外的码点的。按照上面规则,比如对中日韩统一表意文字扩展区B列表中𠀖这个字进行UTF-16编码。
x = 0x20016
x -= 0x10000 -> x = 0x10016
highSurrogate = oxD800 + ox40 = 0xD840
lowSurrogate = oxDC00 + 0x16 = 0xDC16
所以𠀖的UTF-16编码是surrogate pair 0xD840 0xDC16。拿java程序验证一下结果:
String str = "𠀖";
byte[] bytes = str.getBytes(StandardCharsets.UTF_16);
for(byte b : bytes) {
System.out.printf("%3X", b);
}
结果显示FE FF D8 40 DC 16,和预期一致。开头的两个魔数字节FE FF是Byte Order Mark(BOM),用来标注字节序到底是Big Endian和Little Endian,也就是高位在前还是在后。比如x86架构的CPU采用LE,而网络字节序是BE。
UTF-8
UTF-8是现在采用最广泛的Unicode编码方式,其优点在于可扩展性强,节省空间,而且无缝兼容ASCII编码(这点非常重要)。上面的UTF-16方案,如果将来某天16个扩展平面都被占用了就没有办法按一样的思路继续扩展了,而UTF-8最多可以支持6个字节,编码空间非常大。下面讲讲UTF-8变长编码的具体规则。
首先第一个bit如果以0开头,说明总共只有一个字节,后面的7个bit直接对应code point。由于ASCII字符范围是0-127,最高位没用使用,所以按照该方案,ASCII字符的ASCII编码和UTF-8编码完全一致。
如果第一个bit是1,说明有多个字节,具体多少个由第一个字节中连续1的个数决定。比如11100000,说明编码总共有3个字节(包含第一个字节)。后续字节都以10开头,剩下的bit数用来表示字符的code point,最高位补0。还是拿上面的𠀖字举例,其code point是0x20016, 转换成二进制是
0010 0000 0000 0001 0110
大致算一下需要4字节才够,因为3字节最多只有16 (3 * 8 - 4 - 2 - 2)个自由的bit可以使用,而表示0x20016去掉前面的0至少需要18bit。按照上述规则,将code point排进下面4个字节:
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
结果是
11110000 10100000 10000000 10010110
用16进制表示就是0xF0 0xA0 0x80 0x96; 再用Java验证一下:
String str = "𠀖";
byte[] bytes = str.getBytes(StandardCharsets.UTF_8);
for(byte b : bytes) {
System.out.printf("%3X", b);
}
结果输出F0 A0 80 96,和预期完全一致。
值得一提,MySQL创建表的时候可以显式指定字符集,起初有utf8 character set,5.5版本的时候又推出了utf8mb4。原因在于早期的utf8实际上是utf8mb3,最多支持3字节来存储UTF-8编码。随着Unicode字符集的扩展原先的3字节不够用了,尤其在引入Emoji之后,比如🐳(U+1F433)占用4个字节,所以才在新版本中引入了对utf8mb4的支持。
Java处理字符串
Java中的char内部是用UTF-16表示的,固定占用两个字节,但是实际使用的字符、码点和char三者之间都不是一一对应的,在引入Emoji之后情况变得更为复杂。由于无法简单知道一个字符需要用多少个char表示,所以在计算String类型长度或者遍历String的时候并非想象中那么容易,简单以char为单位来遍历字符串可能产生乱码。(注:这里所说的“字符”均指对于人类来说有意义的最小单元)。
import java.text.BreakIterator;
import java.util.Arrays;
import java.util.List;
public class Test {
public static void main(String[] args) {
// Unicode code points of the last 3 elements:
// U+0031 U+FE0F U+20E3
// U+1F1E8 U+1F1F3
// U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466
List<String> l = Arrays.asList("abc", "😂😍👍", "क्तु", "1️⃣", "🇨🇳", "👨👩👧👦");
for (String text : l) {
describe(text);
for (CountType t : CountType.values()) {
parse(t, text);
}
}
}
enum CountType {
CHARS, CODEPOINTS, GRAPHEMES
}
public static void describe(String s) {
System.out.println("********************");
System.out.println("text: " + s);
System.out.println("char count: " + s.length());
System.out.println("code point count: " + s.codePointCount(0, s.length()));
int graphemeCount = 0;
BreakIterator it = BreakIterator.getCharacterInstance();
it.setText(s);
while (it.next() != BreakIterator.DONE) graphemeCount++;
System.out.println("grapheme cluster count: " + graphemeCount);
}
public static void parse(CountType type, String s) {
switch (type) {
case CHARS:
System.out.print(type + ": ");
for (int i = 0; i < s.length(); i++) {
System.out.print(s.charAt(i) + " ");
}
break;
case CODEPOINTS:
System.out.print(type + ": ");
s.codePoints().forEach(codePoint -> System.out.print(Character.toString(codePoint) + " "));
break;
case GRAPHEMES:
System.out.print(type + ": ");
BreakIterator it = BreakIterator.getCharacterInstance();
it.setText(s);
int start = it.current();
while (it.next() != BreakIterator.DONE) {
System.out.print(s.substring(start, it.current()) + " ");
start = it.current();
}
break;
default:
throw new IllegalArgumentException("" + type);
}
System.out.println();
}
}
运行环境macOS 11.0.1 + openjdk 14.0.2, 代码输出结果如下:
text: abc
char count: 3
code point count: 3
grapheme cluster count: 3
CHARS: a b c
CODEPOINTS: a b c
GRAPHEMES: a b c
********************
text: 😂😍👍
char count: 6
code point count: 3
grapheme cluster count: 3
CHARS: ? ? ? ? ? ?
CODEPOINTS: 😂 😍 👍
GRAPHEMES: 😂 😍 👍
********************
text: क्तु
char count: 4
code point count: 4
grapheme cluster count: 1
CHARS: क ् त ु
CODEPOINTS: क ् त ु
GRAPHEMES: क्तु
********************
text: 1️⃣
char count: 3
code point count: 3
grapheme cluster count: 1
CHARS: 1 ️ ⃣
CODEPOINTS: 1 ️ ⃣
GRAPHEMES: 1️⃣
********************
text: 🇨🇳
char count: 4
code point count: 2
grapheme cluster count: 2
CHARS: ? ? ? ?
CODEPOINTS: 🇨 🇳
GRAPHEMES: 🇨 🇳
********************
text: 👨👩👧👦
char count: 11
code point count: 7
grapheme cluster count: 7
CHARS: ? ? ? ? ? ? ? ?
CODEPOINTS: 👨 👩 👧 👦
GRAPHEMES: 👨 👩 👧 👦
Java标准库中的String类提供了以char和code point为单位进行遍历的支持,Character类可以对Unicode code point进行转化,BreakIterator可以按人类有意义的字符对String进行切割。但分析上面结果可以发现,即使是BreakIterator对部分Emoji字符也无能为力,比如中国国旗🇨🇳(U+1F1E8 U+1F1F3)和全家福👨👩👧👦(U+1F468 U+200D U+1F469 U+200D U+1F467 U+200D U+1F466)。我们希望它们的grapheme cluster count都是1,但结果和预期不同。👨👩👧👦的Unicode码点中U+200D叫做Zero-width Joiner,👨👩👧👦实际上是由四个Emoji通过U+200D拼接而成的。Java标准库没办法正确处理,想要得到正确结果考虑使用开源库icu4j。
另外值得一提的是,同一个字符在Unicode中可能有多种方式与之对应,比如Á这个字符,可以用U+00C1表示,也可以用U+0041 U+0301代表。这两种形式编码成UTF-16后结果显然不同,所以如果我们对两个字符串进行比较的话会得到false,但是我们又希望结果是true。Java标准库中的Normalizer类可以按照我们期望的对字符进行Unicode正规化。
推荐阅读
The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)简述了Unicode的发展史。
Java: a rough guide to character encoding列举了Java处理Unicode字符的常见错误。