详解字符编码与 Unicode

人类交流使用 ABC中 等字符,但计算机只认识 01。因此,就需要将人类的字符,转换成计算机认识的二进制编码。这个过程就是字符编码。

ASCII

最简单、常用的字符编码就是 ASCII(American Standard Code for Information Interchange,美国信息交换标准代码),它将美国人最常用的 26 个英文字符的大小写和常用的标点符号,编码成 0127 的数字。例如 A 映射成 65 (0x41),这样计算机中就可以用 0100 0001 这组二进制数据,来表示字母 A 了。

ASCII 编码的字符可以分成两类:

  • 控制字符: 031127 (0x000x1F0x7F)
  • 可显示字符: 32126 (0x200x7E)

具体字符表可以参考:ASCII – 维基百科,自由的百科全书

Unicode

ASCII 只编码了美国常用的 128 个字符。显然不足以满足世界上这么多国家、这么多语言的字符使用。于是各个国家和地区,就都开始对自己需要的字符设计其他编码方案。例如,中国有自己的 GB2312,不够用了之后又扩展了 GBK,还是不够用,又有了 GB18030。欧洲有一系列的 ISO-8859 编码。这样各国人民就都可以在计算机上处理自己的语言文字了。

但每种编码方案,都只考虑了自己用到的字符,没办法跨服交流。如果一篇文档里,同时使用了多种语言的字符,总不能分别指定哪个字符使用了那种编码方式。

如果能统一给世界上的所有字符分配编码,就可以解决跨服交流的问题了,Unicode 就是来干这个事情的。

Unicode 统一编码了世界上大部分的字符,例如将 A 编码成 0x00A1,将 中 编码成 0x4E2D,将 α 编码成 0x03B1。这样,中国人、美国人、欧洲人,就可以使用同一种编码方式交流了。

一个 Unicode 字符可以使用 U+ 和 4 到 6 个十六进制数字来表示。例如 U+0041 表示字符 AU+4E2D 表示字符 中U+03B1 表示字符 α

Unicode 最初编码的范围是 0x00000xFFFF,也就是两个字节,最多 65536 (2^16) 个字符。但随着编码的字符越来越多,两个字节的编码空间已经不够用,因此又引入了 16 个辅助平面,每个辅助平面同样最多包含 65536 个字符。原来的编码范围称为基本平面,也叫第 0 平面。

各平面的字符范围和名称如下表:

平面 字符范围 名称 0 号平面 U+0000 U+FFFF

基本多文种平面 (Basic Multilingual Plane, BMP) 1 号平面 U+10000 U+1FFFF

多文种补充平面 (Supplementary Multilingual Plane, SMP) 2 号平面 U+20000 U+2FFFF

表意文字补充平面 (Supplementary Ideographic Plane, SIP) 3 号平面 U+30000 U+3FFFF

表意文字第三平面 (Tertiary Ideographic Plane, TIP) 14 号平面 U+E0000 U+EFFFF

特别用途补充平面 15 号平面 U+F0000 U+FFFFF

保留作为私人使用区(A 区)(Private Use Area-A, PUA-A) 16 号平面 U+100000 U+10FFFF

保留作为私人使用区(B 区)(Private Use Area-B, PUA-B)

每个平面内还会进一步划分成不同的区段。每个平面和区段具体说明参考 Unicode字符平面映射 – 维基百科,自由的百科全书;汉字相关的区段说明参考 中日韩统一表意文字 – 维基百科,自由的百科全书。Unicode 所有字符按平面和区段查找,可以参考 Roadmaps to Unicode;按区域和语言查找可以参考 Unicode Character Code Charts

字符编码的基本概念

“字符编码”是一个模糊、笼统的概念,为了进一步说明字符编码的过程,需要将其拆解为一些更加明确的概念:

字符 (Character)

人类使用的字符。例如:

  • A
  • 中 等。

编码字符集 (Coded Character Set, CCS)

把一些字符的集合 (Character Set) 中的每个字符 (Character),映射成一个编号或坐标。例如:

  • 在 ASCII 中,把 A 编号为 65 (0x41);
  • 在 Unicode 中,把 中 编号为 0x4E2D
  • 在 GB2312 中,把 中 映射到第 54 区第 0 位。

这个映射的编号或坐标,叫做 Code Point。

Unicode 就是一个 CCS。

字符编码表 (Character Encoding Form, CEF)

把 Code Point 转换成特定长度的整型值的序列。这个特定长度的整型值叫做 Code Unit。例如:

  • 在 ASCII 中, 0x41 这个 Code Point 会被转换成 0x41 这个 Code Unit;
  • 在 UTF-8 中, 0x4E2D 这个 Code Point 会被转换成 0xE4 B8 AD 这三个 Code Unit 的序列。

我们常用的 UTF-8、UTF-16 等,就是 CEF。

字符编码方案 (Character Encoding Scheme, CES)

把 Code Unit 序列转换成字节序列(也就是最终编码后的二进制数据,供计算机使用)。例如 :

  • 0x0041 这个 Code Unit,使用大端序会转换成 0x00 41 两个字节;
  • 使用小端序会转换成 0x41 00 两个字节。

UTF-16 BE、UTF-32 LE 等,就是 CES。

这些概念间的关系如下:

详解字符编码与 Unicode

因此,我们说 ASCII 是”字符编码”时,”字符编码”指的是上面从 Character 到字节数组的整个过程。因为 ASCII 足够简单,中间的 Code Point 到 Code Unit,再到字节数组,都是一样的,没必要拆开说。

而我们说 Unicode 是”字符编码”时,”字符编码”其实指的仅是上面的 CCS 部分。

同理,ASCII、Unicode、UTF-8、UTF-16、UTF-16 LE,都可以笼统的叫做”字符编码”,但每个”字符编码”表示的含义都是不同的。可能是 CCS、CEF、CES,也可能是整个过程。

Unicode 转换格式

Unicode 只是把字符映射成了 Code Point (字符编码表,CCS)。将 Code Point 转换成 Code Unit 序列(字符编码表,CEF),再最终将 Code Unit 序列转换成字节序列(字符编码方案,CES),有多种不同的实现方式。这些实现方式叫做 Unicode 转换格式 (Unicode Transformation Format, UTF)。主要包括:

  • UTF-32
  • UTF-16
  • UTF-8

UTF-32

UTF-32 将每个 Unicode Code Point 转换成 1 个 32 位长的 Code Unit。

UTF-32 是固定长度的编码方案,每个 Code Unit 的值就是其 Code Point 的值。例如 0x00 00 00 41 这个 Code Unit,就表示了 0x0041 这个 Code Point。

UTF-32 的一个 Code Unit,需要转换成 4 个字节的序列。因此,有大端序 (UTF-32 BE) 和小端序 (UTF-32 LE) 两种转换方式。

例如 0x00 00 00 41 这个 Code Unit,使用 UTF-32 BE 最终会编码为 0x00 00 00 41;使用 UTF-32 LE 最终会编码为 0x41 00 00 00

UTF-16

UTF-16 将每个 Unicode Code Point 转换成 1 到 2 个 16 位长的 Code Unit。

对于基本平面的 Code Point( 0x00000xFFFF),每个 Code Point 转换成 1 个 Code Unit,Code Unit 的值就是其对应 Code Point 的值。例如 0x0041 这个 Code Unit,就表示了 0x0041 这个 Code Point。

对于辅助平面的 Code Point( 0x0100000x10FFFF),每个 Code Point 转换成 2 个 Code Unit 的序列。如果还是直接使用 Code Point 数值转换成 Code Unit,就有可能和基本平面的编码重叠。例如 U+010041 如果转换成 0x00010x0041 这两个 Code Unit,解码的时候没办法知道这是 U+010041 一个字符,还是 U+0001U+0041 两个字符。

为了让辅助平面编码的两个 Code Unit,都不与基本平面编码的 Code Unit 重叠,就需要利用基本平面中一个特殊的区段了。基本平面中规定了从 0xD8000xDFFF 之间的区段,是永久保留不映射任何字符的。UTF-16 将辅助平面的 Code Point,编码成一对在这个范围内的 Code Unit,叫做代理对。这样解码的时候,如果解析到某个 Code Unit 在 0xD8000xDFFF 范围内,就知道他不是基本平面的 Code Unit,而是要两个 Code Unit 组合在一起去表示 Code Point。

具体转换方式是:

  1. 将辅助平面的 Code Point 的值 (0x0100000x10FFFF),减去 0x010000,得到 0x000000xFFFFF 范围内的一个数值,也就是最多 20 个比特位的数值
  2. 将前 10 位的值(范围在 0x00000x03FF),加上 0xD800,得到范围在 0xD8000xDBFF 的一个值,作为第一个 Code Unit,称作高位代理或前导代理
  3. 将后 10 位的值(范围在 0x00000x03FF),加上 0xDC00,得到范围在 0xDC000xDFFF 的一个只,作为第二个 Code Unit,称作低位代理或后尾代理

基本平面中的 0xD8000xDBFF0xDC000xDFFF 这两个区段,也分别叫做 UTF-16 高半区 (High-half zone of UTF-16) 和 UTF-16 低半区 (Low-half zone of UTF-16)。

UTF-16 的一个 Code Unit,需要转换成 2 个字节的序列。因此,有大端序 (UTF-16 BE) 和小端序 (UTF-16 LE) 两种转换方式。

例如 0x0041 这个 Code Unit,使用 UTF-16 BE 最终会编码为 0x0041;使用 UTF-16 LE 最终会编码为 0x4100

UTF-8

UTF-8 将每个 Unicode Code Point 转换成 1 到 4 个 8 位长的 Code Unit。

UTF-8 是不定长的编码方案,使用前缀来标识 Code Unit 序列的长度。解码时,根据前缀,就知道该将哪几个 Code Unit 组合在一起解析成一个 Code Point 了。

具体编码方式是:

Code Point 范围 Code Unit 个数 每个 Code Unit 前缀 示例 Code Point 示例 Code Unit 序列 7 位以内 (0 0xEF

) 1 0b0 0b0zzz zzzz 0b0zzz zzzz

8 到 11 位 (0x80 0x07FF

) 2 第一个 0b110

,剩下的 0b10 0b0yyy yyzz zzzz 0b110y yyyy 10zz zzzz

12 到 16 位 (0x0800 0xFFFF

) 3 第一个 0b1110

,剩下的 0b10 0bxxxx yyyy yyzz zzzz 0b1110 xxxx 10yy yyyy 10zz zzzz

17 到 21 位 (0x10000 10FFFF

) 4 第一个 0b11110

,剩下的 0b10 0b000w wwxx xxxx yyyy yyzz zzzz 0b1111 0www 10xx xxxx 10yy yyyy 10zz zzzz

解码时,拿到每个 Code Unit 的前缀,就知道这是对应第几个 Code Unit:

  • 前缀是 0b0,说明这个 Code Point 是一个 Code Unit 组成
  • 前缀是 0b110,说明这个 Code Point 是两个 Code Unit 组成,后面还会有 1 个 0b10 前缀的 Code Unit
  • 前缀是 0b1110,说明这个 Code Point 是三个 Code Unit 组成,后面还会有 2 个 0b10 前缀的 Code Unit
  • 前缀是 0b11110,说明这个 Code Point 是四个 Code Unit 组成,后面还会有 3 个 0b10 前缀的 Code Unit

UTF-8 的一个 Code Unit,刚好转换成 1 个字节,因此不需要考虑字节序。

参考上表,对于 ASCII 范围内的字符,使用 ASCII 和 UTF-8 编码的结果是一样的。所以 UTF-8 是 ASCII 的超集,使用 ASCII 编码的字节流也可以使用 UTF-8 解码。

UTF-8 与 UTF-16 对比

Code Point 范围 UTF-8 编码长度 UTF-16 编码长度 7 位以内 (0x00 0xEF

) 1 2 8 到 11 位 (0x0080 0x07FF

) 2 2 12 到 16 位 (0x0800 0xFFFF

) 3 2 17 到 21 位 (0x10000 10FFFF

) 4 4

可以看出只有在 0x000xEF 范围的字符,UTF-8 编码比 UTF-16 短;而在 0x08000xFFFF 范围内,UTF-8 编码是比 UTF-16 长的。

而中文主要在 0x4E000x9FFF,如果写一篇文档,全都是中文,一个英文字母和符号都没有。那使用 UTF-8 编码,可能比 UTF-16 编码还要多占用一半的空间。

字节顺序标记

UTF-32 和 UTF-16 的一个 Code Unit,需要转换成多个字节的序列,因此存在字节序的问题。

可以在 UTF-32 或 UTF-16 编码的字节流开头,添加字节顺序标记 (byte-order mark, BOM),来标识字节序。

BOM 是 U+FEFF 字符的名称。编码时,将 U+FEFF 编码在字节流的开头。解码时,读取前几个字节,就知道编码时的字节序了。

例如 UTF-16 的大端序, U+FEFF 会被编码成 0xFEFF,而小端序则会编码成 0xFFFE。这样根据开头是 0xFEFF 还是 0xFFFE,就知道编码时使用的大端序还是小端序了。

同理 UTF-32 的大端序, U+FEFF 会被编码成 0x00 00 FE FF,而小端序则会编码成 0xFF FE 00 00。这样根据开头,不光能区分出字节序,还能区分出是 UTF-32 还是 UTF-16。

UTF-8 的一个 Code Unit 只需要转换为 1 个字节,因此不存在字节序的问题,也就不需要 BOM。而且 0xFEFF0xFFFE 字节序列,在 UTF-8 中都是不可能出现的。所以根据 BOM,也能区分出编码方式是不是 UTF-8。

如果硬要给 UTF-8 加 BOM,那就是将 0xFEFF (0b1111 1110 1111 1111) 进行 UTF-8 编码,得到 0xEF BB BF (0b1110 1111 1011 1011 1011 1111),放在字节流的最前面。

之所以使用 U+FEFF 这个字符来标识字节序,可能是因为这个字符本身就表示”零宽非断空格”的含义。把他放在最前面,解码的时候支持 BOM,就把他按照字节序去理解;不支持的就把他解析成一个”零宽非断空格”,展示起来也没有任何影响。当然这是我瞎猜的,而且从 Unicode 3.2 开始, U+FEFF 已经专门用来标记字节序,没有其他含义了。

Unicode 标准化

Unicode 中有些特殊的字符,可以由其他不同的特殊字符组合出来。例如 ñ (U+00F1) 和 ñ (U+006E U+0303)。这两个字符在展现和含义上是完全等价的,但其编码却是不同的。为了对这种字符进行比较,就需要在比较前先进行标准化 (Normalization) 处理。

Unicode 定义了四种标准化形式 (Unicode Normalization Form):

分解 分解再重组 标准等价 NFD (Normalization Form Canonical Decomposition) NFC (Normalization Form Canonical Composition) 兼容等价 NFKD (Normalization Form Compatibility Decomposition) NFKC (Normalization Form Compatibility Composition)

说明:

  • 分解与重组:
  • 分解:就是把字符能拆的全拆开,例如:
    • ñ (U+00F1) 拆成 U+006E U+0303。
  • 重组:就是把拆开的字符能组的再全组起来,例如:
    • ñ (U+006E U+0303) 组合成 U+00F1。
  • 标准与兼容:
  • 标准等价:就是只有含义和长得完全相同的两个字符才相等,例如:
    • ñ (U+00F1) 和 ñ (U+006E U+0303) 可以相等;
    • ff (U+FB00) 和 ff (U+0066 U+0066) 不能相等。
  • 兼容等价:就是只要长得差不多就可以相等了,标准等价的一定也是兼容等价的,例如:
    • ff (U+FB00) 和 ff (U+0066 U+0066) 也可以相等;
    • ñ (U+00F1) 和 ñ (U+006E U+0303) 更是可以相等了。

示例:

说明 显示 标准化形式 标准化后 分解与重组的区别 ñ NFD/NFKD U+006E U+0303 分解与重组的区别 ñ NFC/NFKC U+00F1 标准与兼容的区别 ff NFD/NFC U+FB00 标准与兼容的区别 ff NFKD/NFKC U+0066 U+0066 标准与兼容的区别 ff NFD/NFC/NFKD/NFKC U+0066 U+0066

Unicode 与 UCS

通用字符集 (Universal Character Set, UCS) 和 Unicode 可以理解就是两个组织干的相同的事情,他们都想给世界上的所有字符统一编码。现在他们也都相互兼容,就是说对于同一个字符,UCS 和 Unicode 都会把他们映射成同一个 Code Point,反过来也一样。所以可以把他们当成是一回事。

有一些不同的地方,UCS 的编码空间本来是 00x7F FF FF FF (32 位,第一位固定为 0)。但因为 UTF-16 代理对的实现方式,只能编码到 0x10 FF FF 范围。所以 UCS 标准也规定了只使用 0x10 FF FF 范围内的编码。

UCS-4 与 UCS,类似于 UTF-32 与 Unicode 的关系。因为 UCS 也规定了只使用 0x10 FF FF 范围内的编码,所以它两实际就是一回事。

UCS-2 与 UCS,类似于 UTF-16 与 Unicode 的关系。但不同的是,UCS-2 是固定两字节的,没有考虑辅助平面。可以把 UCS-2 当做是不支持辅助平面的 UTF-16。

Unicode 与编程语言

编程语言中的 Unicode

因为 Unicode 可以给世界上大部分字符编码,因此大部分编程语言内部,都是使用 Unicode 来处理字符的。例如在 Java 中定义一个字符 char c = '中',这个字符实际是使用两个字节在内存中存储着他的 UTF-16 编码。所以如果将这个字符强转成整型 int i = (int) c,得到的结果 20013 (0x4E2D),就是 中 在 Unicode 中的 Code Point 值。

这个说法不完全准确,因为大部分编程语言定义的时候,Unicode 还没有辅助平面,所以一般都是固定的用两个字节来存储一个字符。

在有了辅助平面以后,辅助平面的字符,会被 UTF-16 编码成两个 Code Unit,需要 4 个字节来存储。而编程语言为了兼容性,不太可能将原有的 char 类型长度改为 4 个字节。所以就有可能需要用两个 char 来存储一个实际的字符。而原有的获取字符串长度的 API,实际获取到的是字符串中 Code Unit 的个数,不是实际字符的个数。获取某个位置字符的 API 也是同理,获取到的可能是一对 Code Unit 中的一个。因此需要使用编程语言提供的新的 API 或者通过额外的代码,来正确处理辅助平面的字符。

在编程语言中使用 Unicode

主要涉及以下操作:

详解字符编码与 Unicode

这其中最关键的就是字符和 Code Point 之间的转换。因为这里涉及字符集的映射,如果编程语言不支持,我们就要自己外挂编码表才能实现,否则无论如何都是没办法通过枚举实现的。

而有了 Code Point 以后,根据 UTF 系列编码的规则,我们自己也可以通过代码来实现 Code Point 和字节序列的转换。当然如果编程语言内置了相关的 API,那就更方便了。

这里省略了 Code Unit 的概念,因为一般在代码中,不会有这个中间过程,直接就编码成字节序列了。

Java

char 和 String 中可以使用 \uXXXX 来表示一个 Unicode 字符。String 中可以使用两个 \uXXXX 表示一个辅助平面的字符,但 char 中不行,因为一个辅助平面字符需要用两个 char 存储:

char c = '\u4E2D';
String s = "\uD840\uDC21";

String to Code Point count:

int count = "𠀡".codePointCount(0, "𠀡".length());

String/char to CodePoint:

int i1 = Character.codePointAt(new char[] {0xD840, 0xDC21}, 0);
int i2 = "𠀡".codePointAt(0);

Code Point to String/char:

String s = new String(new int[] {0x20021}, 0, 1);
char[] c = Character.toChars(0x20021);

String to byte array:

byte[] bytes = "𠀡".getBytes(StandardCharsets.UTF_8);

Byte array to String:

String s = new String(new byte[] {(byte) 0xF0, (byte) 0xA0, (byte) 0x80, (byte) 0xA1}, StandardCharsets.UTF_8);

Normalize:

String s = Normalizer.normalize("ñ", Normalizer.Form.NFD);

JavaScript

String 中可以使用 \uXXXX 来表示一个 Unicode 字符。对于辅助平面的字符,可以使用 \u{XXXXXX} 来表示:

'\u{20021}'

String to Code Point count:

Array.from('𠀡').length

String to Code Point:

'𠀡'.codePointAt(0).toString(16)

Code Point to String:

String.fromCodePoint(0x20021)

String to byte array:

new TextEncoder().encode('𠀡')

只支持 UTF-8,其他编码方式需要自己写代码根据 Code Point 转换。

Byte array to String:

new TextDecoder('utf-8').decode(new Uint8Array([0xF0, 0xA0, 0x80, 0xA1]))

Normalize:

'ñ'.normalize('NFD')

原文链接:详解字符编码与 Unicode
版权声明:CC BY-NC-ND 4.0

Original: https://www.cnblogs.com/val3344/p/16706170.html
Author: val3344
Title: 详解字符编码与 Unicode

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/802409/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球