前言
在做 markdown 编译相关工作时,常常会使用到正则表达式,在对特殊符号、中文等处理时,对字符编码规则产生好奇,为什么会有这样一套规范呢?带着这个问题,简单探讨下字符编码等相关问题,做了一些笔记整理。
本文主要分为:
- 字符编码概览
a. 基础概念
b. 电报
c. 字符串
d. 发展概览 - ASCII
- Unicode
- UTF
- UTF-8
- 总结
- 参考文档
字符编码概览
基础概念
- 字符(character):文字与符号的总称,可以是各国的文字、标点符号、图形符号、数字等。
- 字符集 (character Set):多个字符的集合。
- 编码(encoding):信息从一种形式格式转换为另一种形式的过程。
- 解码(decoding):编码的逆过程。
- 字符编码(character encoding):按照何种规则存储字符。
这些基础概念你可以觉得似懂非懂,没关系,下面我将举 电报 的具体例子来说明。
电报
1827年,世界第一条电报诞生,当时美国科学家摩尔斯尝试用一些“点”、“划”来表示不同字母、数字、标点符号,这套表示字符的方式就是大名鼎鼎的“摩尔斯电码”。
- 这里的每一个符号(A-Z, 1-9等)就是上面提到的「字符」;
- 图右部分也就是「字符集」
- 「字符」与「电码符号」的对应关系,或者说规则,就是「字符编码」。
在实际使用过程中
- 发送员想要发送字符: 1,根据「字符编码」,转换为 点 划 划 划 划,就是「编码」。
- 译码员接受到:点 划 划 划,根据「字符编码」,转换为 1,就是「解码」。
笔者认为,字符编码,本质上就是同一份数据以不同的形式表示出来,如:点、划是电脉冲信号的表示,字符是人类所能理解的表示方式。数字的不同进制其实也是比较典型的同一份数据不同表示形式,二进制的 1111 1111,对应十进制的 255。不同表示形式的转换,关键在于制定一套「转换规则」,既要使得数据的在转换过程中不会失真,又要兼顾转换的数据提取效率。
在计算机的世界里,推而广之,点、划就是我们熟悉的 0、1。自然而然,字符编码也被应用于计算机的各种领域。
字符串
字符串作为一种数据类型,有一个比较特殊的问题,在于如何进行编码。因为计算机只能处理数字,如果要处理文本,就必须转换为对应的数字才能进行处理。
计算机在设计时采用 8 个比特作为一个字节,所以一个字节能表示的最大正数就是 255 (2 的 8 次方 - 1)。其实本质上,这就是一种编码,我们将 11111111 与 255 做了一种映射。将这个方法迁移到字符上,就有了「字符编码」。
举个🌰 :我们可以将 11111111 定义为 +,11111110 定义为 -,这其实就是一种简单的字符编码。
发展概况
在实际的工作中,需要处理到各种各样的字符情况,所以陆续诞生了很多字符编码规则,如:早期美国人使用到的大小写字母,数字,处理符号(回车键,退格,换行键等),用 1 个字节做映射,于是就有了 ASCII 编码;计算机在全球普及后,各个国家要开始处理本国文字,比如中文时,发现 1 个字节远远不够,同时还需要兼容已使用的 ASCII 编码,所以,中国制定了 GB2312 编码,日本制定了 Shift_JIS 编码,韩国制定了 Euc-kr 编码,笔者称之为轰轰烈烈造轮子 ,我真的没有在影射什么。
不可避免地,各国各自制定的标准出现了冲突,浅显易懂的情况就是:相同的编码各自对应不同的字符,从而产生乱码。本质原因其实就是使用了不同的字符编码(轮子长得不一样你还想车子能跑?),古人云:分久必合,各自造轮子的时代终将结束。
为了避免各国之间的编码冲突,Unicode 应运而生。Unicode 把所有语言都统一到一套编码规范中,这样就从本质上解决了乱码的问题。
在这之后有了 UTF-8 ,也就是对于 Unicode 的一种实现,简单来说它是把 Unicode 编码转换成 “可变长编码” 的编码规范。UTF-8 可以说是当前最推崇的一种编码方式,前端最熟悉的 hmtl 中:
<meta charset="utf-8">
与之相关的还有:UTF-16,UTF-32 等,它们也是 Unicode 的实现方式之一。
以上发展历史总结起来就是:
- 最初只有 ASCII 编码,只包含 英文字母、符号,总共 128个。
- 计算机快速发展,各国在 ASCII 编码的基础上,各自设定了支持各国语言的编码规范。
- 各国编码无法兼容,大一统的 Unicode 出现,也称为「万国码」。
- Unicode 存在存储性能问题,UTF-8 在这个方面进行优化,成为了当前最通用的编码。
ASCII
ASCII 全称 American Standard Code for Information Interchange,美国信息交换标准代码,在 1967 年正式发布。ASCII 编码使用了 1 个字节用于存储,即最多可以表示 256 个字符,这对于当时的美国来说是完全足够的。
ASCII 涵盖了 33 种终端特殊动作、33种标点符号、10个阿拉伯数字、52个大小写字母,共计 128 个字符。
Unicode
前文所说,ASCII 只使用 1 个字节,只能表示 256 个字符,要用来表示中文等语言时远远不够。如何解决呢?很简单,就是单纯的加字节用于表示。加到 2 个字节时,可以表示 65536(2 的 16 次方)个字符,如果不够的话,那么就继续添加字节数(简单地烧钱,简单的快乐)。
当每个国家各自制定了相应的编码规则导致冲突后,Unicode 诞生了。
Unicode 整体划分为两个区域:
- 0x0000 - 0xFFFF,也就是 2 个字节,称为 基本多文种平面(Basic Multilingual Plane, BMP),这部分存放常用字符。
- 从 0x010000 - 0x10FFFF 再划分为其他平面。
Unicode 类似于一本电话本,标记着 「字符」 和 「数字」之间的关系。这个 「数字」也就是 「码点」(Code Point),官方用 U+ 开头。理论上每种语言中的每种字符都被 Unicode 协会指定了一个码点。例如字母 A 是U+0041(16进制),也就是 65。
值得一提的是,Unicode 只是规定哪个字符对应哪个数字,并不具体规定怎么在内存中进行存储。有点类似于它只定义了接口,具体怎么实现就要在其他地方进行,也就是大家熟悉的 UTF (Unicode Transformation Formats) 定义的。
UTF
我们先按照最简单的思路来:固定 3 个字节表示,即 24 位。又因为 CPU 寄存器都是 8, 16, 32, 64位这样,所以其实做不到单纯的 24 位存储,那就 32 位吧。
是不是简单明了,码点值多少,内存就存多少。不过,这样的存储虽然简单,但缺点也很明显:字母 A 原本只需要 1 个字节存储,但现在却用去了 4 个字节,而且大部分位置都是 0,造成了很大的浪费。
UTF-8
这个时候,就应该反问一句:为什么需要存这么多 0 呢?啊不是你说的用 3 个字节去存吗,三个字节存比较小的值的话肯定很多 0 在前面呀!
话是这么说,但我们要跳出思维局限,首先来想想,能不能把前面的 0 都丢掉,也就是 A 用 0x41 ,亮 用 0x4eae 表示,可以看出,这样存储的话,每个字符用来表示的字节数是不一致的,也就是 「可变长度」。
很明显地,这样是会有问题的,如果有个字符用 0x4111 表示,也就是前缀等于 A 的 0x41,那计算机怎么区分它是一个字符,还是由 0x41 还有 0x11 组成的两个字符呢?
于是,就需要一个转换过程,私以为这就是为什么 UTF 中带有 T (Transformation) 的原因,UTF 是重点就在于怎么进行转换。
UTF 主流的实现就是大名鼎鼎的 UTF-8,这里照搬 阮一峰 老师的讲解:
根据上表:
- 如果一个字节的第一位是 0,则这个字节单独就是一个字符。
- 如果第一位是 1,则连续有多少个 1,就表示当前字符占用多少个字节。
以 「严」为例,「严」的 Unicode 是 4E25 (100111000100101),根据上表,可以发现 4E25 处在第三行范围内(0000 0800 - 0000 FFFF),因此 「严」的 UTF-8 编码需要 3 个字节,即格式为 1110xxxx 10xxxxxx 10xxxxxx。然后,从「严」的最后一个二进制位开始,依次从后向前填入格式中的 x,多出的位补 0。这样就得到来「严」的 UTF-8 编码是 11100100 10111000 10100101,转换成十六进制就是 E4B8A5。
这一块总结起来就是:因为 多个字节 表示 某个字符 存储时有很多空余的 0,那么与其让它“荒废”着,不如来做点事情:表示这个字符总共需要多个字节存储。
这样虽然这个字符还是需要那么多字节存储(比如 「严」还是需要 3 个字节),但当所有的字符都按照这套规范,就可以做到用少字节来表示字符(如 1 个字节表示 A 这样),而且不会出现冲突,简直完美,典型的「我为人人,人人为我」!
由于大部分情况下,常用的字符都是 1 或 2 个字节,相对于统一使用 3 字节进行存储来说,存储消耗得到了明显的降低,所以现在的 html 等普遍使用 UTF-8 进行编码传输。
事物都是有两面性的,这种「变长编码」的规范难道就没有缺点了吗?(那肯定是有了)
对于一个字符串,如果我们知道它的总共大小就是 19 字节,那么在变长编码的情况下,我们很难直接算出它有多少个字符。为了知道字符数,我们不得不进行遍历,从而确定多少个字符。同时,当我们想取第 3 个字符时,同样需要从字符串开头进行遍历。
此时是不是很怀念「固定长度」编码呢?(还是很怀念刚认识时的简简单单)。
其实对于上述情况,可以做权衡,大概思路是统一使用 2 个字节与 4 个字节进行存储,然后对每个字符进行简单的分类,这种思路下去也会遇到一些问题,诸如 4 个字节存储的值前缀不能和 2 个字节一样等,不过不要怕,事情肯定会有办法解决的,不行的话就加 if-else (误)。话又说回来,程序员的天职不就是遇到问题然后想办法解决吗?
总结
是时候祭出这张图了:
上文涉及到的可以说只是字符编码的很小一部分,因为历史与地理原因,字符编码所涉及到的问题与解决思路繁杂多样(其实一开始写笔记只是想找找 Unicode 呀摔~),不得不感叹一句学无止境。
从 ASCII 到 UTF-8,小到每套具体的字符编码如何在保证兼容性等情况下,又维持着可拓展性与灵活性,大到 Unicode 如何对「万国」语言进行切分组合等规范,大处小处,无一不透露着设计的智慧。仔细品读下,会深深感叹前辈们解决问题的能力,这也是我一直在追求学习的品质,共勉~
由于水平有限,行文中难免会有些错误出现,有纰漏之处恳请各位大佬不吝赐教~