跳转至

1.5.字符编码

目前,大多数计算机的处理器的核心部分都是基于数字电路构建的。这些数字集成电路(IC)只能处理数字,并且使用二进制表示数字。换而言之,对于计算机来说,所有信息最终都是一些二进制的数字。计算机只能处理数字,如果要处理文本或图像,那么必须先将文本或图像转换为数字。

对于文本来说,如何用转换为数字有一个简单的思路:一个字符与一个数字一一对应。ASCII码标准的制定就是基于这种朴素的思想。

ASCII码

众所周知,第一台计算机是美国国籍,而ASCII码的标准也是由美国制定的。也就是说,ASCII码是针对英语制定的标准,所以ASCII码只需要包括26个小写字母、26个大写字母、33个英文标点符号、33个控制符以及10个数字就可以了。由于ASCII 码一共只有128个字符,只需要7位二进制数就可以完全表示了,而当时的人选择用一个字节(8 bit)表示一个ASCII字符,所以一个ASCII字符只占了一个字节的后7位,第一位规定为0。

ASCII码的局限

英语用ASCII码就够了,但后来其他国家也有了计算机,ACSII码的局限性也显现出来,非英语国家的语言无法用ASCII码表示。另外,对于印欧语系的语言,其所需字符数不止128个。因为一个字节有8位,而ASCII码只占用了后7位,于是一些欧洲国家就决定,利用字节中闲置的最高位编入新的字符。这样,,在兼容ASCII码后,还可以多添加128个字符。

虽然这样做解决了印欧语系的语言表示问题,但是又产生了新问题。不同国家使用不同的字母,即使这些国家的编码都兼容ASCII码,但超出ASCII码范围的码位不能相互兼容。比如,130在法语编码中代表了é,在希伯来语编码中却代表了字母Gimel (ג),在俄语编码中又会代表另一个符号。

虽然上述做法解决了印欧语系的语言编码问题,但还很多国家的语言编码不能只用一个字节表示,例如中文。一个字节最多表示258个字符,而汉字却可能多达10万个,一个字节完全不够用。于是,中国就制定了自己的编码标准——GB2312。GB2312用两个字节表示,并且把数学符号、罗马希腊的字母、日文的假名们都编进去了,连ASCII码里面本来就有的数字、标点、字母都用两个字节长的编码表示,这就是常说的全角字符。但很快,我们就发现GB2312也不够用了,因为中国的汉字实在太多了,于是我们在GB2312的基础上又制定了GBK标准。

可以想象,全世界有上百种语言,日本把日文编到Shift_JIS里,韩国把韩文编到Euc-kr里,各国有各国的标准,就会不可避免地出现冲突。在多语言混合的文本中,显示出来可能会有乱码。此时,人们意识到,必须制定一种包含全世界所有语言字符的编码标准,才能解决语言的表示问题。

同时,如果要制定更通用、包含更多字符的编码标准,那一个字符编码就需要占用更多的二进制位。在将文本储存到硬盘,或者将文本传输到网络时就会存在储存空间利用率低和带宽浪费的问题。例如,假设一个汉字用两个字节表示,而英文字符使用一个字节就够了,那么对于包含英文和中文的编码模型,如果表示一个英文字符也用两个字节的话,英文字符占用的空间就会比原来多出一倍。为了解决这个问题,编码模型的概念被提出,制定更复杂、通用的编码标准不能再使用ASCII码这样的朴素思想。

编码模型

计算机的编码模型负责将显示在屏幕的字符转换成内存中的二进制数值,或者将内存中的二进制数值转换为可读的字符。编码模型有两种:

  1. 简单编码模型,如ASCII码
  2. 现代编码模型

现代编码模型自底向上分为五个层次,每一层都是上一层的基础

  1. 抽象字符集(Abstract Character Repertoire)

  2. 编码字符集(Coded Character Set)

  3. 字符编码表(Character Encoding Form)

  4. 字符编码方案(Character Encoding Schema)

  5. 传输编码语法(Transfer Encoding Syntax)

抽象字符集是现代编码模型的最底层,它是一个集合,通过枚举指明了所属的所有抽象字符。因此,在逻辑上,抽象字符集又可以分为一个个抽象字符。要了解抽象字符集是什么,我们首先来了解什么是字符抽象字符

字符

字符(CHARACTER, CHAR)有两种含义:

  1. 字符是表示文本数据的信息单元1,具有某种视觉表示形式。
  2. 在 Unicode 文档的大多数语境中,字符和抽象字符被视为同义词(这意味着抽象字符库与字符库(character repertoire)也被视为同义词)。

基于第一种语义,我们知道一个字符具有某种视觉表示形式,所谓的视觉表示形式也就是字形

字符与字形的关系

字形:字的形体。例如一个字符可以有正体、斜体、手写体等等,即一个抽象字符可以结合不同的字形,从而生成不同的字符。可见,一个字符可以有多个字形。

一个字形也可能对应多个字符(该字符名为 fi ligature):

有时,甚至存在多个字形对应多个字符的情况。例如,fi ligature 的字形也可能是由一个字形序列组合而成的。

在显示某一字符时,是使用单个字形还是使用字形序列,是由字体和渲染软件决定的。

一个字形不仅可以对应一个字符,也可以对应一个字形序列。例如,重音(accented)字符可以由单个字形表示,也可以由一个字形序列来表示。另外,所要表示的内容本身也可能是由一个字符序列组成的,如下图中的第二行。

在有些国家的语言中,在绘制字符的具体形状时,还需要考虑其周围的字形,这种字形被称作 contextual forms 。例如,阿拉伯字符 heh 就具有四种上下文字形。

img

抽象字符

抽象字符 (Abstract Character):用于组织、控制或表示文本数据的信息单元。

在表示数据时,抽象字符是象征性的,没有具体的形式,即抽象字符不具有字形,同时抽象字符也区别于grapheme。

以上概念来自于:

  1. Glossary of Unicode Terms
  2. The Unicode® Standard Version 13.0 – Core Specification

从字符和抽象字符的定义可以看出,字符是抽象字符的一个子集,因为字符不包括用于组织、控制文本数据的信息单元。

换而言之,字符是可以显示出来的信息单元,而还有一些信息单元是空白的,甚至是不可打印的。这些不显示出来的字符属于抽象字符,例如ASCII字符集中的NULL。

无法显示的字符虽然不显示出来,但仍然具有某种表示形式,例如\x00\000NULL0可能用于表示 ASCII 字符集的 NULL。这些表示形式都是NULL的不同写法,而不是NULL本身。

抽象字符不一定与字符一一对应。因为有一些抽象字符没有被 Unicode 标准直接编码,这通常可以用字符序列组合起来表示。

图片来自 The Unicode Standard 的第8版 UnicodeStandard-8.0 P 65,参考自 Abstract Character (Unicode)

该图说明了抽象字符与 code points 的关系,抽象字符可以由一个字符编码,也可以由两个或多个字符编码而成。

抽象字符集

抽象字符集(Abstract Character Repertoire)是抽象字符的无序集合。抽象字符集决定了整个字符集能够使用的所有字符。已经有了很多标准的字符集定义。 比如 US-ASCII、UCS(Unicode)、GBK 都是(或者至少是)抽象字符集。

一个字母或文字可以同时属于多个字符集,例如英文字母A同时属于US-ASCII、UCS、GBK这三个字符集,而 😂 不属于US-ASCII与GBK字符集,但属于UCS字符集。

抽象字符集有开放与封闭之分。ASCII抽象字符集定义了128个抽象字符,再也不会增加,这就是一个封闭字符集。Unicode 尝试收纳所有的字符,一直在不断地扩张之中,这就是一个开放的字符集。

编码字符集 CCS

编码字符集(Coded Character Set):给抽象字符集中每个抽象字符都分配了码位字符集被称为编码字符集。

人们常常喜欢将抽象字符集叫做字符集,现在人们又喜欢将编码字符集简称字符集。因此,很多介绍字符编码的内容都存在很大的混乱与歧义,真是瞎叫名字害死人。下文尽量不使用带有歧义的简称,有使用“字符集”的地方,一律指代抽象字符集。

抽象字符集是无序的,无序的集合没有多大的作用,我们只能判断某个字符是否属于某个抽象字符集,但无法方便地引用抽象字符集的字符。所以为了更好的描述、操作字符,我们可以为抽象字符集中的每个字符关联一个数字编号,这个数字编号称之为码位(Code Point)。要注意的是,这个数字编码是抽象的,也就是说,它并没有规定计算机使用哪种进制系统、多少个字节表示这个数。此时,编码字符集更像一个字典(dictionary),而不是一个简单的集合。

习惯上,分配给字符的码位通常是非负整数,计算机一般用十六进制显示码位,并且字符和码位是双射的 。 至于码位是根据什么规则分配的,就不知道了,可能每个字符都有自己的想法。

最早的编码字符集是ASCII,但更准确来说,ASCII使用的是简单编码模型。简单编码模型并没有现代编码模型这么多概念。现在最常见的编码字符集就是 UCS(Universal Character Set)。

Universal Character Set

UCS(统一字符集)是由 ISO/IEC 10646 所定义的编码字符集。通常说的 “Unicode字符集“ 指的就是它。不过Unicode本身指的是一系列用于计算机表示所有语言字符的标准

世界上存在着多种编码方式,同一个二进制数字可以被解释成不同的符号。要想打开一个文本文件,就必须知道它的编码方式,否则用错误的编码方式解读,就会出现乱码。人们意识到了这个问题,于是,UCS 应运而生。UCS 把所有语言都统一到一套编码里,这样就不会再有乱码问题了。Unicode 是一个很大的集合,现在的规模可以容纳100多万个符号,而且还在不断发展。现代操作系统和大多数编程语言都直接支持Unicode。各个Unicode字符的码位可以在 unicode.org汉字对应表 查询。

如果按照 Unicode9.0.0 的标准,UCS 理论上收录了128237个字符,但实际能用的最大的码位点是128720。这是因为 UCS 的码位不是连续分配的,即中间有一部分码位没有分配对应的字符。Unicode 实际分配的码位是 0x0000~0x0xD7FF0xE000~0x10FFFF 这两段。中间0xD800~0xDFFF 这2048个码位留作他用,并不对应实际的字符。如果在 Python 中直接尝试去输出这个码位段的字符,Python 会告诉你这是个非法字符。

字符编码表 CEF

字符编码表:也称为"storage format",是将编码字符集的非负整数值(即抽象的码位)转换成 有限比特长度 的整型值(称为码元code units)的序列。

Code unit: The minimal bit combination that can represent a unit of encoded text for processing or interchange.

码元是能用于处理或交换编码文本的最小比特组合。通常计算机处理字符的码元为一字节,即8bit。

因为我们不仅需要编码,也要解码,所以编码字符集的整数到码元序列的映射必须是一一映射的。

字符编码表分为定长编码和变长编码两种。定长编码就是自身到自身的映射(null mapping);变长编码则比较复杂,把一些码位映射到一个码元,把另外一些码位映射到由多个码元组成的序列。ASCII 就是一种定长编码,这可以理解为编码字符集的直接表示。对于 Unicode 字符集来说,这种直接表示法不仅很没效率,还会有其他问题:

  1. 现在的Unicode字符集至少需要21位二进制数才能完全表示,如果英文字符都用21位表示,那么保存或传输英文文本就会比使用ASCII编码多出近乎2倍的空间或流量。
  2. 如果用定长编码,那么Unicode编码则无法与ASCII编码兼容,因为ASCII码只占用一个字节,而Unicode编码需要占用更多字节。
  3. Unicode字符集是开放的,未来可能有更多的字符加入进来。也就是说,理论上UCS需要无限的码位,但是计算机的整形能表示的整数范围是有限的。

变长编码可以解决以上三个问题。变长编码将一个“无限大”的整数集合映射位指定字宽的码元序列。在 ISO/IEC 10646 中,指定了三种标准的字符编码表:UTF-8、UTF-16、UTF-32,分别将 Unicode 标量值映射为比特数为8、16、32的码元的序列,其中 UTF-8 和 UTF-16 是变长表示,而 UTF-32 是定长表示。此外,还包括了两种定长编码的CEF:UCS-2 和 UCS-4,其中 UCS-4 和 UTF-32 可以认为是一样的。无论是 UTF-8 还是 UTF-16,本质思想都是通过预留标记位来指示码元序列的长度,从而实现变长编码的。UTF-8 是在互联网上使用最广的一种 Unicode 的实现方式,UTF-16 和 UTF-32 则用得很少。

Unicode定义了三种不同的CEF应该不是巧合,实际上,char在计算机中就是一种整型, 这三种CEF刚好对应了编程语言中最常见的三种整形长度(uint8、uint16、uint32)

因为对于包含很多字符的CCS来说,变长编码可以节省储存空间和网络带宽,所以在储存和网络传输时,会先使用变长编码对字符进行编码。仔细想一想,可能会觉得奇怪,因为储存和发送字符需要先编码,那么反过来,将字符加载到内存或接收字符时可能是需要解码的。那么,这是要解码成什么呢?而且,因为CEF和CCS是一一对应的,理论上直接使用CEF就行了,为什么还要解码和编码呢?其实,解释这两个问题就是这篇文章的主要目的。

如果在内存中直接使用字符串的变长编码,那么与字符串相关的算法(排版、排序等)的复杂度就会上升。因为变长编码是不定长的,一个字符可能是1个字节,也可能是2个或3个字节,那就没法办快速访问某个字符的位置,例如你没法确定第100个字符从第几个字节开始。如果你想找出第100个字符是什么,必须先用定长编码的解释规则,从前往后扫描整个字节流,直到扫到第100个字符的位置。因此,为了处理字符的高效,在内存中一般会使用定长编码。例如,对于UTF-32来说,要找出第100个字符,只需要取出第400字节至第404字节的数据就行了。

因为字符在内存中使用定长编码,而在储存和传输时使用变长编码,所以就需要编码转换,这就是需要编码和解码的原因。一般而言,我们将储存和传输时的编码转换过程称为“编码”(动词),将字符加载到内存时的编码转换过程称为“解码”。

实际上,编码转换不是必须的,因为有些程序在内存中就是直接使用变长编码的,只需付出性能代价即可。例如,Rust语言就是在内存中直接使用 UTF-8 编码,在调用其他库的 API 时再转换编码。另外,储存或传输字符也可以用定长编码,例如纯英文文本直接用ASCII就行,或者使用UCS-2。那能不能用UTF-32/UCS-4呢?绝对是不可以的,因为储存和流量都是需要成本的,为了经济利益,英语国家阵营会第一个拒绝使用 UTF-32/UCS-4,其他拉丁语系国家会是第二个。

其实一开始,Unicode组织想要推广的国际标准编码是UCS-2,但是UCS-2只使用16个bit,最多只能表示65536个字符。显然这连中文都没法完全表示,更别说还有其他国家的语言。因此,UCS-2的推广遭到了中日韩三国极力反对,主要是当时作为第二经济大国的日本正与美国在计算机领域竞争。由于中日韩三国经济体量还算可以,所以后来Unicode组织妥协,升级了Unicode标准,转而使用UCS-4,不然汉字文化圈的历史文化就和互联网说再见了。但正如上面所说,因为涉及经济利益,推广UCS-4困难重重。因此,后来又出现了UTF(Universal Transformation Format,通用传输格式)。之后直到互联网时代的到来,跨语言信息交互空前频繁,统一编码大势所趋,UTF-8 终于被广泛使用。实际上,使用UTF-8大受欢迎的主要原因是对欧美而言,UTF-8不仅保留Unicode的通用性,还避免了空间和流量的浪费,而如果编码纯中文,UTF-8往往比UTF-16更需要更多bit。

UTF-8

UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。

UTF-8 的编码规则很简单,只有二条:

  1. 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。

  2. 对于n字节的符号(n > 1),第一个字节的前n位都设为1,第n + 1位设为0,后面字节的前两位一律设为10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。

下表总结了编码规则,字母x表示可用编码的位。

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 编码。

的 16进制 Unicode 码位是 4E25,换算为二进制为 100111000100101,根据上表,可以发现 4E25 处在第三行的范围内(0000 0800 - 0000 FFFF),因此的 UTF-8 编码需要三个字节,即格式是1110xxxx 10xxxxxx 10xxxxxx。然后,从的最后一个二进制位开始,依次从后向前填入格式中的x,多出的位补0。这样就得到了,的 UTF-8 编码是11100100 10111000 10100101,转换成十六进制就是E4B8A5

字符编码方案 CES

字符编码方案(Character Encoding Scheme):也称作 serialization format。將定长的整型值(即码元)映射到8位字节序列,以便编码后的数据的文件存储或网络传输。

实际上,有了 CEF 可能还不可以直接传输文本数据,因为还存在大小端序的问题。CES 正是为了在 CEF 的基础上解决这个问题。一般会在编码字节序列开始处加入一段特殊的字节序列,即字节序标记BOM(Byte Order Mark),用于表示文本序列的大小端序。这样,不同的Unicode字符编码表由于使用不同的大小端序方案,又会细分为几个版本。因为UTF-8已经采用字节作为码元,所以UTF-8不需要额外的CES。

传输编码语法

传输编码语法是现代编码模型的最顶层。通过 CES,我们已经可以将一个字符表示为一个字节序列。但有时候,字节序列表示还不够,例如在 HTTP 协议中,一些字符是不允许出现在 URL 的。此时,就需要再次对字节流进行编码。另外,在 CES 中,字节序列以 8 bit 为单位,但有时候可能还需要将8位的字节压缩到7位,此时也需要再次编码。

Python 默认字符集

Python的默认字符集在几个大版本中有过改变,以下是各个版本的默认字符集列举:

  • Python 2.1 及以前:latin1
  • Python 2.3 及之后,Python 2.5 以前:latin1(但是会对非ASCII字符集字符提出WARNING)
  • Python 2.5 及以后:ASCII
  • Python 3 :Unicode(UTF-8)

参考

  1. 字符编码笔记:ASCII,Unicode 和 UTF-8
  2. 十分钟搞清字符集和字符编码
  3. 关于Python的默认字符集
  4. 现代编码模型
  5. 彻底弄懂Unicode编码
  6. Unicode 字符编码模型_抽象字符库(ACR)
  7. 字符编码掠影:现代编码模型
  8. 为什么Python内部实现要使用UCS2编码?
  9. UTF-16是固定两个字节长度吗? - 知乎
  10. unicode - How to find out if Python is compiled with UCS-2 or UCS-4? - Stack Overflow
  11. Unicode 和 UTF-8 有什么区别? - 知乎
  12. 彻底弄懂Unicode编码 - 李宇仓 | Li Yucang
  13. 字节码:ASCII编码:单字节编码,ANSI编码:多字节编码,UNICODE编码:宽字节编码_IT届的小学生-CSDN博客
  14. 为何微软不把 Windows 的默认字符集设置成 Unicode ? - 知乎
  15. 一文搞懂 Python 2 字符编码-云社区-华为云

  1. 信息单元即是指组成文本语言的最小单位,例如中文中的每一个汉字、标点,英文的每一个字母。