前言

今天在《Java 核心技术 卷1》书中看到码点和代码单元的概念,讲的不是特别清楚。下面是我参考网上的一些资料,整理的文章内容。

版本约定

我们知道,计算机内部,所有信息最终都是一个二进制值。每一个二进制位(bit)有 0 和 1 两种状态,因此八个二进制位就可以组合出 256 种状态,这被称为一个字节(byte)。也就是说,一个字节一共可以用来表示 256 种不同的状态,每一个状态对应一个符号,就是 256 个符号,从 00000000 到 11111111。

上个世纪 60 年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定。这被称为 ASCII 码,一直沿用至今。

ASCII 码一共规定了 128 个字符的编码,比如空格 SPACE 是 32(二进制 00100000),大写的字母 A 是 65(二进制 01000001)。这 128 个符号(包括 32 个不能打印出来的控制符号),只占用了一个字节的后面 7 位,最前面的一位统一规定为 0。

非 ASCII 编码

英语用 128 个符号编码就够了,但是用来表示其他语言,128 个符号是不够的。比如,在法语中,字母上方有注音符号,它就无法用 ASCII 码表示。于是,一些欧洲国家就决定,利用字节中闲置的最高位编入新的符号。比如,法语中的 é 的编码为 130(二进制 10000010)。这样一来,这些欧洲国家使用的编码体系,可以表示最多 256 个符号。

但是,这里又出现了新的问题。不同的国家有不同的字母,因此,哪怕它们都使用 256 个符号的编码方式,代表的字母却不一样。比如,130 在法语编码中代表了 é,在希伯来语编码中却代表了字母 Gimel (ג),在俄语编码中又会代表另一个符号。但是不管怎样,所有这些编码方式中,0 ~ 127 表示的符号是一样的,不一样的只是 128 ~ 255 的这一段。

至于亚洲国家的文字,使用的符号就更多了,汉字就多达 10 万左右。一个字节只能表示 256 种符号,肯定是不够的,就必须使用多个字节表达一个符号。比如,简体中文常见的编码方式是 GB2312,使用两个字节表示一个汉字,所以理论上最多可以表示 256 x 256 = 65536 个符号。

这些不同的字符集互不兼容,相同的编码可能代表不同的字符,同一个字符在不同的字符集中的编码也往往不同。当信息从一台计算机移植到另一台计算机时,接收方若以错误的编码方式解读,就会出现乱码。不同编码文件之间的交流成为一个亟待解决的问题。

Unicode

令人欣慰的是,“统一码联盟”和 ISO 两个国际组织为了解决这一问题,双方开始协同工作。他们采用的方法很简单:废了所有的地区性编码方案,重新搞一个包括了地球上所有文化、所有字母和符号的编码,这种编码被命名为 “Universal Character Set”,简称“UCS”,俗称 “Unicode”。

Unicode 开始制订时,计算机的存储器容量已经有了极大地发展,空间已不再是重点考虑的问题。由于设计者天真地以为 16 个比特就可以表示地球上所有仍具活力的文字,于是就直接规定用两个字节来统一表示所有的字符。其实,16 个比特仅仅可以表示 65536 个字符,UCS-2 就是 Unicode 字符集的一种定长的 2 字节编码(现在已废弃)。

十分遗憾,经过一段时间的发展,不可避免的事情发生了。Unicode 字符超过了 65536 个,其主要原因是增加了大量的汉语、日语和韩语中的表意文字。

Unicode 从 0 开始,为每个字符指定一个编号,这叫做码点(Code Point),比如,码点 0 的符号就是 null,表示所有二进制位都是 0。

  1. U+0000 = null

上式中,U+ 表示紧跟在后面的十六进制数是 Unicode 的码点。

这么多符号,Unicode 不是一次性定义的,而是分区定义。每个区可以存放 65536 个(2^16)字符,称为一个平面(Plane)。目前,一共有 17 个平面,也就是说,整个 Unicode 字符集的大小现在是 2^21。

最前面的 65536 个字符位,称为基本多语言平面(Basic Multilingual Plane,BMP),或称第 0 平面或 0 号平面(Plane 0),码点从 U+0000 到 U+FFFF。所有最常见的字符都放在这个平面,这是 Unicode 最先定义和公布的一个平面。

其余的 16 个平面都是辅助平面,Plane 1 到 Plan 16,码点范围从 U+10000 到 U+10FFFF。

编码方式

需要注意的是,Unicode 只是一个字符集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。这些编码值如何在网络中传输是 UTF(UCS Transformation Format)规范规定的。

以汉字“汉”为例,它的 Unicode 码点是 0x6c49,对应的二进制数是 110110001001001,二进制数有 15 位,这也就说明了它至少需要 2 个字节来表示。可以想象,在 Unicode 字典中往后的字符可能就需要 3 个字节或者 4 个字节,甚至更多字节来表示了。

这就导致了一些问题,计算机怎么知道你这个 2 个字节表示的是一个字符,而不是分别表示两个字符呢?这里我们可能会想到,那就取个最大的,假如 Unicode 中最大的字符用 4 字节就可以表示了,那么我们就将所有的字符都用 4 个字节来表示,不够的就往前面补 0。这样确实可以解决编码问题,但是却造成了空间的极大浪费,如果是一个英文文档,那文件大小就大出了 3 倍,这显然是无法接受的。

于是,为了较好的解决 Unicode 的编码问题, UTF-8 和 UTF-16 两种当前比较流行的编码方式诞生了。当然还有一个 UTF-32 的编码方式,也就是上述那种定长编码,字符统一使用 4 个字节,虽然看似方便,但是却不如另外两种编码方式使用广泛。

这里引出了代码单元的概念,它表示编码时使用的最小单元。这个和如何存储一个字符的编码有关,比如 UTF-8,它的代码单元是 8 bit,UTF-16 的代码单元是 16 bit,它们都是变长编码方式,所以单个代码单元可能表示的是一个完整的码点也有可能表示的是码点的一部分。

UTF-8

UTF-8 是一个非常惊艳的编码方式,漂亮的实现了对 ASCII 码的向后兼容,以保证 Unicode 可以被大众接受。

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

  1. 对于单个字节的字符,第一位设为 0,后面的 7 位对应这个字符的 Unicode 码点。因此,对于英文中的 0 ~ 127 号字符,与 ASCII 码完全相同。这意味着 ASCII 码那个年代的文档用 UTF-8 编码打开完全没有问题;
  2. 对于需要使用 N 个字节来表示的字符(N > 1),第一个字节的前 N 位都设为 1,第 N + 1 位设为 0,剩余的 N - 1 个字节的前两位都设位 10,剩下的二进制位则使用这个字符的 Unicode 码点来填充。 | Unicode 十六进制码点范围 | UTF-8 二进制 | | —- | —- | | 0000 0000 - 0000 007F | 0xxxxxxx | | 0000 0080 - 0000 07FF | 110xxxxx 10xxxxxx | | 0000 0800 - 0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx | | 0001 0000 - 0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |

得益于 UTF-8 这种变长的编码方式,有效的节省了空间的使用,所以 UTF-8 编码被广泛的使用。

UTF-16

UTF-16 也是 Unicode 的一种编码方式,每个字符使用 1 ~ 2 个 16 位元素组成。

UTF-16 编码介于 UTF-32 与 UTF-8 之间,同时结合了定长和变长两种编码方式的特点。它的编码规则很简单:基本多语言平面(Basic Multilingual Plane,BMP)的字符占用 2 个字节,辅助平面的字符占用 4 个字节。

那么问题来了,当我们遇到两个字节时,到底是把这两个字节当作一个字符还是与后面的两个字节一起当作一个字符呢?

这里有一个很巧妙的地方,在基本平面内,从 U+D800 到 U+DFFF 是一个空段,即这些码点不对应任何字符。因此,这个空段可以用来映射辅助平面的字符,通常这个空段被称为替代区域(Surrogate Area)。

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

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

例如,𝕆 是八元数集的一个数学符号,码点为 U+1D546,编码为两个代码单元 U+D835 和 U+DD46。

因为 UTF-8 编码可变长,一会儿一个字符是占用一个字节,一会儿一个字符占用两个字节,还有的占用三个字节,导致在内存中的格式不统一,运算效率低。而 UTF-16 编码虽然占用更多的内存空间,但是在编程过程中或者在内存处理的时候会比 UTF-8 编码更为简单,所以,通常将 UTF-8 编码作为外码,用在网络传输和文件保存时;而将 UTF-16 编码作为内码,用在文件内容读取到内存中时。

比如 Windows 内核、Java、Objective-C、JavaScript 中都会将字符的代码单元定为两个字节的数据类型,也就是我们在 C / C++ 中遇到的 wchar_t 类型或 Java 中的 char 类型等等,这些类型占内存两个字节,因为 Unicode 中常用的字符都处于 U+0000 ~ U+FFFF 的范围之内,因此两个字节几乎可以覆盖大部分的常用字符。

Java 与 Unicode

Java 语言设计之初就认识到统一字符集(Unicode)的重要性,并积极拥抱了问世不久的 Unicode 标准。比如,Java 基本数据类型中 char 最初描述的就是 UCS-2 编码中的代码单元。与其他使用 8 比特字符的语言相比,这是Java 的主要优势。

但尴尬的是,随着更多字符的引入,尤其是汉语、韩语和日文中的表意文字的引入,使得 Unicode 远远超出 16 比特编码的范围。现在,Unicode 需要 21 个比特,表示范围从 0x0 ~ 0x10FFFF。当单元固定长度 16 位的 UCS-2 到达容量上限不能支持更多的 Unicode 字符的时候,Unicode 协会放弃了 UCS-2。取而代之的是 16 位的变长编码 UTF-16。

在 Unicode 从 16 比特向 21 比特过渡时期,Java 语言深受其苦。其中,Java 5.0 版本既要支持 Unicode 4.0 同时又要保证向后兼容性,所以 Java 开始使用 UTF-16 作为其内部编码方式,同时引入码点和代码单元两个概念,在上面有提到这两个概念。

在 Java 中,char 类型描述了 UTF-16 编码中的一个代码单元。

码点和代码单元操作

Java 字符串是由 char 值序列组成。char 数据类型是一个采用 UTF-16 编码表示 Unicode 码点的代码单元。大多数的常用 Unicode 字符使用一个代码单元就可以表示,而辅助字符需要一对代码单元表示。 接下来演示 String 类中码点和代码单元的操作,帮助我们更好的理解这两个概念。

代码单元数量与码点数量

String 类中的 length 方法将返回采用 UTF-16 编码表示的给定字符串所需要的代码单元数量。即它返回的是代码单元的数量,并不是码点的数量,当字符串中存在一个辅助字符时,返回的数量将不会是我们看到的字符个数。

比如下面的例子:

  1. public static void main(String[] args) {
  2. String name = "james";
  3. System.out.println(name + " length: " + name.length());
  4. String message = "𝕆";
  5. System.out.println(message + " length: " + message.length());
  6. }

运行程序,输出:

  1. james length: 5
  2. 𝕆 length: 2

要想得到实际的长度,即码点数量,调用 codePointCount 方法。

  1. public static void main(String[] args) {
  2. String name = "james";
  3. System.out.println(name + " length: " + name.codePointCount(0, name.length()));
  4. String message = "𝕆";
  5. System.out.println(message + " length: " + message.codePointCount(0, message.length()));
  6. }

运行程序,输出:

  1. james length: 5
  2. 𝕆 length: 1

位置 n 的代码单元与位置 n 的码点

调用 s.charAt(n) 将返回位置 n 的代码单元,n 介于 0 ~ s.length()-1 之间。

  1. public static void main(String[] args) {
  2. String name = "james";
  3. System.out.println(name + " 返回位置 0 的代码单元: " + name.charAt(0));
  4. String message = "𝕆";
  5. System.out.println(message + " 返回位置 0 的代码单元: " + message.charAt(0));
  6. }

运行程序,输出:

  1. james 返回位置 0 的代码单元: j
  2. 𝕆 返回位置 0 的代码单元: ?

辅助字符需要 2 个代码单元才可以表示,所以通过 charAt 获得的可能不是某个字符,而是辅助字符的一个代码单元。比如上面的 message.charAt(0) 返回的是 𝕆 的第一个代码单元。为了避免这个问题,不要使用 char 类型,它太底层了。

要想获取字符串的第 i 个码点,应该使用下列语句。

  1. public static void main(String[] args) {
  2. String name = "james";
  3. int nameIndex0 = name.offsetByCodePoints(0, 0);
  4. System.out.println(name + " 返回位置 0 的码点: " + name.codePointAt(nameIndex0));
  5. String message = "𝕆";
  6. int messageIndex0 = message.offsetByCodePoints(0, 0);
  7. System.out.println(message + " 返回位置 0 的码点: " + message.codePointAt(messageIndex0));
  8. }

运行程序,输出:

  1. james 返回位置 0 的码点: 106
  2. 𝕆 返回位置 0 的码点: 120134

判断是否是辅助字符

  • Character 类提供方法 isSupplementaryCodePoint(int codePoint) 用于判断传入的码点是否是辅助字符。
  • Character 类还提供方法 isSurrogate(char ch) 用于判断传入的代码单元是否是辅助字符的一部分。

    字符串与码点之间的转换

我们可以使用 Character 的提供的两个方法来辅助遍历字符串的码点,也可以使用更加简单的办法,通过codePoints 方法生成一个 int 值的“流”,每个 int 值对应一个码点。

代码如下所示:

  1. public class ConvertBetweenCodePointAndString {
  2. private static String str = "james𝕆";
  3. public static void main(String[] args) {
  4. positiveOrderTraversalAllCodePoint(str);
  5. negativeOrderTraversalAllCodePoint(str);
  6. intStreamTraversalAllCodePoint(str);
  7. convertCodePointToString(str);
  8. }
  9. /**
  10. * 正序遍历字符串的所有码点
  11. *
  12. * @param str
  13. */
  14. public static void positiveOrderTraversalAllCodePoint(String str) {
  15. System.out.print(str + " length: " + str.length() + ". ");
  16. for (int index = 0; index < str.length(); ) {
  17. int cp = str.codePointAt(index);
  18. System.out.print(cp + " ");
  19. if (Character.isSupplementaryCodePoint(cp)) {
  20. index += 2;
  21. } else {
  22. index++;
  23. }
  24. }
  25. System.out.println();
  26. }
  27. /**
  28. * 逆序遍历字符串的所有码点
  29. *
  30. * @param str
  31. */
  32. public static void negativeOrderTraversalAllCodePoint(String str) {
  33. System.out.print(str + " length: " + str.length() + ". ");
  34. int index = str.length();
  35. while (--index >= 0) {
  36. if (Character.isSurrogate(str.charAt(index))) {
  37. index--;
  38. }
  39. System.out.print(str.codePointAt(index) + " ");
  40. }
  41. System.out.println();
  42. }
  43. /**
  44. * int流-遍历字符串的所有码点
  45. *
  46. * @param str
  47. */
  48. public static void intStreamTraversalAllCodePoint(String str) {
  49. System.out.print(str + " length: " + str.length() + ". ");
  50. int[] codePoints = str.codePoints().toArray();
  51. for (int ele : codePoints) {
  52. System.out.print(ele + " ");
  53. }
  54. System.out.println();
  55. }
  56. public static void convertCodePointToString(String str) {
  57. System.out.print(str + " length: " + str.length() + ". ");
  58. int[] codePoints = str.codePoints().toArray();
  59. String newStr = new String(codePoints, 0, codePoints.length);
  60. System.out.print("new String value: " + newStr);
  61. }
  62. }

运行程序,输出:

  1. james𝕆 length: 7. 106 97 109 101 115 120134
  2. james𝕆 length: 7. 120134 115 101 109 97 106
  3. james𝕆 length: 7. 106 97 109 101 115 120134
  4. james𝕆 length: 7. new String value: james𝕆

转载

作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/on45tw 来源:殷建卫 - 架构笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。