• 详解字符编码与 Unicode


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

    ASCII

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

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

    • 控制字符:0 - 31127 (0x00 - 0x1F0x7F)
    • 可显示字符:32 - 126 (0x20 - 0x7E)

    具体字符表可以参考: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。


    这些概念间的关系如下:

    因此,我们说 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 的值 (0x010000 - 0x10FFFF),减去 0x010000,得到 0x000000xFFFFF 范围内的一个数值,也就是最多 20 个比特位的数值
    2. 将前 10 位的值(范围在 0x00000x03FF),加上 0xD800,得到范围在 0xD8000xDBFF 的一个值,作为第一个 Code Unit,称作高位代理或前导代理
    3. 将后 10 位的值(范围在 0x00000x03FF),加上 0xDC00,得到范围在 0xDC000xDFFF 的一个只,作为第二个 Code Unit,称作低位代理或后尾代理

    基本平面中的 0xD800 - 0xDBFF0xDC00 - 0xDFFF 这两个区段,也分别叫做 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 短;而在 0x0800 - 0xFFFF 范围内,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) 可以相等;
        • (U+FB00) 和 ff (U+0066 U+0066) 不能相等。
      • 兼容等价:就是只要长得差不多就可以相等了,标准等价的一定也是兼容等价的,例如:
        • (U+FB00) 和 ff (U+0066 U+0066) 也可以相等;
        • ñ (U+00F1) 和 (U+006E U+0303) 更是可以相等了。

    示例:

    说明 显示 标准化形式 标准化后
    分解与重组的区别 ñ NFD/NFKD U+006E U+0303
    分解与重组的区别 NFC/NFKC U+00F1
    标准与兼容的区别 NFD/NFC U+FB00
    标准与兼容的区别 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

    主要涉及以下操作:

    这其中最关键的就是字符和 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')
    
  • 相关阅读:
    Flask入门学习教程
    基于ASP.NET的驾校管理系统设计与实现
    开源实时数仓 Apache Doris 毕业了,未来如何走得更远?
    Cpolar - 本地 WebUI 账号登录失败解决方案
    Docker快速极简配置nginx实现不同域名访问分流
    详细步骤讲解matlab代码通过Coder编译为c++并用vs2019调用
    【前端】CSS(3) —— CSS的盒模型与弹性布局
    C++知识精讲14 | 算法篇之二分查找算法
    企业网络安全:威胁情报解决方案
    修改了字符集,好多软件不能正常使用,所以,慎重。。。。
  • 原文地址:https://www.cnblogs.com/val3344/p/16706170.html