浅谈Unicode编码格式和代码中的应用

ASCII码

ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是基于拉丁字母的一套计算机编码系统。它主要用于显示现代英语,一共定义了 128 个字符,其中33个字符无法显示(一些终端提供了扩展,使得这些字符可显示为诸如笑脸、扑克牌花式等8-bit符号),且这33个字符多数都已是陈废的控制字符。例如大写的字母 A 是 65(这是十进制数,对应二进制是0100 0001)。

ASCII的局限在于只能显示26个基本拉丁字母、阿拉伯数目字和英式标点符号,因此只能用于显示现代美国英语(而且在处理英语当中,即使会违反拼写规则,外来词如naïve、café、élite等等时,所有重音符号都必须去掉)。虽然EASCII解决了部分西欧语言的显示问题,但对更多其他语言依然无能为力。因此,现在的软件系统大多采用Unicode

Unicode码

Unicode(中文:万国码、国际码、统一码、单一码)是计算机科学领域里的一项业界标准。它对世界上大部分的文字系统进行了整理、编码,使得计算机可以用更为简单的方式来呈现和处理文字。

Unicode早期版本中,CJK统一汉字区的范围是0x4E00-0x9FA5,包含20902个汉字。后来增加了22个字符,码位是0x9FA6-0x9FBB。所以我们在百度判断中文的
代码一般都是判断在 0x4E00-0x9FA5 的范围内,这是不完全正确的,因为在最新的Unicode 5.0的99089个字符中,有71226个字符与汉字有关,包括了很多兼容汉字和扩充汉字,对于多语言版本的APP可能要注意这些。

Unicode 没有规定字符对应的二进制码如何存储。比如汉字“中”,它的 Unicode 码点是 0x4E2D,对应的二进制数是 100111000101101,二进制数有 15 位,这也就说明了它至少需要 2 个字节来表示。还有字母“A”,Unicode 码点是 0x0041,对应的二进制数是 0100 0001,二进制数有8位,用1个字节就可以表示了。

那么计算机是如何知道1个字节表示一个字符还是2个字节表示一个字符呢?也许有人会觉得可以用Unicode 中最大的字符用 4 字节来表示每一个字符,但是这样肯定会造成了空间的极大浪费,如果是一个英文文档,那文件大小就大出了 3 倍多了。为了解决 Unicode 的编码问题, UTF-8 和 UTF-16 两种当前比较流行的编码方式诞生了。

UTF-8

UTF-8 是目前互联网上使用最广泛的一种 Unicode 编码方式,它的最大特点就是可变长。它可以使用 1 - 4 个字节表示一个字符,根据字符的不同变换长度。编码规则如下:

  • 对于单个字节的字符,第一位设为 0,后面的 7 位对应这个字符的 Unicode 码点。因此,对于英文中的 0 - 127 号字符,与 ASCII 码完全相同。这意味着 ASCII 码那个年代的文档用 UTF-8 编码打开完全没有问题。
  • 对于需要使用 N 个字节来表示的字符(N > 1),第一个字节的前 N 位都设为 1,第 N + 1 位设为0,剩余的 N - 1 个字节的前两位都设位 10,剩下的二进制位则使用这个字符的 Unicode 码点来填充。

    点的位数 | 码点起值 | 码点终值 | 字节序列 | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Byte 5 | Byte 6
    –|–|–|–|–|–|–|–|–|–
    7 | U+0000 | U+007F | 1 | 0xxxxxxx
    1 | U+0080 |U+07FF | 2 | 110xxxxx | 10xxxxxx
    6 | U+0800 |U+FFFF | 3 | 1110xxxx |10xxxxxx | 10xxxxxx |
    1 | U+10000 |U+1FFFFF | 4 | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx
    6 | U+200000 |U+3FFFFFF | 5 | 111110xx | 10xxxxxx | 10xxxxxx | 10xxxxxx | 10xxxxxx
    1 | U+4000000 |U+7FFFFFFF | 6 | 1111110x | 10xxxxxx | 10xxxxxx |10xxxxxx | 10xxxxxx | 10xxxxxx

根据上面编码规则对照表,就很容易进行 UTF-8 编码和解码。以汉字“中”为例,“中”的 Unicode 码点是 0x4E2D100 1110 0010 1101),通过上面的对照表可以发现,0x0000 4E2D 位于第三行的范围,那么得出其格式为 1110xxxx 10xxxxxx 10xxxxxx。接着,从“中”的二进制数最后一位开始,从后向前依次填充对应格式中的 x,多出的 x 用 0 补上。这样,就得到了“汉”的 UTF-8 编码为 11100100 10111000 10101101,转换成十六进制就是 0xE4 0xB8 0xAD

解码的过程也十分简单:如果一个字节的第一位是 0 ,则说明这个字节对应一个字符;如果一个字节的第一位1,那么连续有多少个 1,就表示该字符占用多少个字节,知道了该字符占用N个字节就可以篇历后面以10开头的N个字符,最终解析出该字符。

UTF-16

Unicode的编码空间从U+0000到U+10FFFF,共有 2^17 个码位可用来映射字符. Unicode的编码空间可以划分为17个平面(plane),每个平面包含2^16(65,536)个码位。17个平面的码位可表示为从U+xx0000到U+xxFFFF,其中xx表示十六进制值从0x000x10,共计17个平面。第一个平面称为基本多语言平面(Basic Multilingual Plane, BMP),或称第零平面(Plane 0)。其他平面称为辅助平面(Supplementary Planes)。

基本平面(BMP)的字符位共有 2^16 个,从U+0000到U+FFFF,包含了最常用的字符。其中从 U+D800 到 U+DFFF 是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符。

辅助平面(Supplementary Planes)的字符位共有 2^20 个,从U+10000到U+10FFFF,因此表示这些字符至少需要 20 个二进制位。UTF-16 将这 20 个二进制位分成两半,前 10 位映射在 U+D800 到 U+DBFF,称为高位(H),后 10 位映射在 U+DC00 到 U+DFFF,称为低位(L)。这意味着,一个辅助平面的字符,被拆成两个基本平面的字符表示。

因此,当我们遇到两个字节,发现它的码位在 U+D800 到 U+DBFF 之间,就可以断定,紧跟在后面的两个字节的码位,应该在 U+DC00 到 U+DFFF 之间,这四个字节必须放在一起解读。

以汉字”𠮷”为例,汉字”𠮷”的 Unicode 码点为 0x20BB7,该码点显然超出了基本平面的范围(0x0000 - 0xFFFF),因此需要使用四个字节表示。首先用 0x20BB7 - 0x10000 = 0x10BB7 计算出超出的部分,然后将其用 20 个二进制位表示(不足前面补 0 ),结果为0001000010 1110110111。接着,将前 10 位映射到 U+D800 到 U+DBFF 之间,后 10 位映射到 U+DC00 到 U+DFFF 即可。前10位是0001000010,对应十六进制是0x42,映射到U+D800后就是 0xD842,同理后10位是1110110111,对应十六进制是0x03B7,映射到U+DC00 后就是 0xDFB7。因此得出汉字”𠮷”的 UTF-16 编码为 0xD842 0xDFB7

代码中处理Unicode码

java默认是使用UTF-16编码处理字符的,很多字符处理的函数都定义在Character类中,以上文中的汉字“中”、“𠮷”为例,获取字符的码位 :

1
2
3
4
5
6
val str = "中" // str.length = 1
val str2 = "𠮷" // str2.length = 2
// 获取码位 :
println(Character.codePointAt(str, 0).toString(16)) // 4E2D
println(Character.codePointAt(str2, 0).toString(16)) // 20BB7

打印结果与预想的一致,我们也可以用Character. charCount来判断一个码位占用的字符数,通过上方分析可知汉字”𠮷”占用四个字节,这里要注意函数名是charCount,说明计算的是char的数量,一个char占两个字节,所以”𠮷”的 Character.charCount 打印的是2,而 “中”的 Character.charCount 打印的是1。

1
2
3
// 判断字符字符数
println(Character.charCount(Character.codePointAt("中", 0))) // 1
println(Character.charCount(Character.codePointAt("𠮷", 0))) // 2

那么Character这个类又是怎样知道一个字符占两个字节还是四个字节呢?我们看看Character.charCount 函数的源码 :

1
2
3
4
5
// Character.java
public static final int MIN_SUPPLEMENTARY_CODE_POINT = 0x010000;
public static int charCount(int codePoint) {
return codePoint >= MIN_SUPPLEMENTARY_CODE_POINT ? 2 : 1;
}

从中可以发现 ,当码位大于等于0x010000时就是2个char(四个字节),否则是1个char(两个字节),而通过上文分析我们知道码位大于等于0x010000就是在辅助平面,这时候是由一个高位和一个低位组合的,在辅助平面上的字符至少占用四个字节,我们可以打印汉字”𠮷”的每一个char :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"中".map {
String.format("%x ", it.toInt()).toUpperCase()
}.reduce { acc, s ->
acc + s
}.run {
println(this) // 4E2D
}
"𠮷".map {
String.format("%x ", it.toInt()).toUpperCase()
}.reduce { acc, s ->
acc + s
}.run {
println(this) // D842 DFB7
}

通过打印结果,可知汉字”中”的UTF-16就是0x4E2D,汉字”𠮷”的UTF-16就是0xD842 0xDFB7。另外,Character也提供了两个函数来判断一个字符是高位还是低位,因为“中”由两个字节组成,不需要高位低位,所以Character.isHighSurrogateCharacter.isLowSurrogate均打印false。 :

1
2
3
4
5
6
7
8
9
10
11
12
13
// 判断高位、低位 :
"中".forEachIndexed { index, c ->
println("中[$index] isHighSurrogate = ${Character.isHighSurrogate(c)} , isLowSurrogate = ${Character.isLowSurrogate(c)}")
}
"𠮷".forEachIndexed { index, c ->
println("𠮷[$index] isHighSurrogate = ${Character.isHighSurrogate(c)} , isLowSurrogate = ${Character.isLowSurrogate(c)}")
}
// 打印结果 :
// 中[0] isHighSurrogate = false , isLowSurrogate = false
// 𠮷[0] isHighSurrogate = true , isLowSurrogate = false
// 𠮷[1] isHighSurrogate = false , isLowSurrogate = true

另外如果我们要把UTF-16编码转化成UTF-8编码,可以这样做 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// UTF-16转化成UTF-8编码 :
"中".toByteArray().map {
String.format("%x ", it).toUpperCase()
}.reduce { acc, s ->
acc + s
}.run {
println(this) // E4 B8 AD
}
"𠮷".toByteArray().map {
String.format("%x ", it).toUpperCase()
}.reduce { acc, s ->
acc + s
}.run {
println(this) // F0 A0 AE B7
}

通过打印结果,可知汉字”𠮷”的UTF-8就是0xE4 0xB8 0xAD,汉字”𠮷”的UTF-8就是0xF0 0xA0 0xAE 0xB7 。同理把UTF-8编码也可以转化成UTF-16编码,如下所示:

1
2
3
4
5
6
// UTF-8转化成UTF-16编码 :
val bytes1 = byteArrayOf(0xE4.toByte(), 0xB8.toByte(), 0xAD.toByte())
println(String(bytes1, Charset.forName("utf-8"))) // "中"
val bytes2 = byteArrayOf(0xF0.toByte(), 0xA0.toByte(), 0xAE.toByte(), 0xB7.toByte())
println(String(bytes2, Charset.forName("utf-8"))) // "𠮷"

我们可以直接用\u的前缀来显示一个UTF-16编码,从而直接打印我们所要的字符,比如汉字”中”的UTF-16就是0x4E2D,汉字”𠮷”的UTF-16就是0xD842 0xDFB7,我们可以直接用\u4E2D来打印“中”,用\uD842\uDFB7来打印“𠮷”,代码如下:

1
2
println("\u4E2D") // "中"
println("\uD842\uDFB7") // "𠮷"

总结

本文介绍了ASCII码,但是ASCII只能最多表示128个字符,所以有了Unicode编码,Unicode编码有多种编码格式,比较流行的有UTF-8和UTF-16。UTF-8使用 1 - 4 个字节表示一个字符,而UTF-16则通过高位、低位的形式来表示Unicode所有平面的字符,UTF-16最多占4个字节。最后介绍了在代码中处理unicode编码的主要函数,实际工作中很实用。

参考