找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

2725

积分

0

好友

379

主题
发表于 昨天 19:28 | 查看: 2| 回复: 0

如果你需要让计算机处理文本,就必须了解“编码(encode)”这件事。这是一个绕不开的话题。即使只是发送或接收邮件,也同样会涉及编码问题。你无需深究所有细节,但至少要明白“编码”究竟是在讲什么。好消息是,虽然这个话题深入下去会变得复杂,但它的基本思想其实非常简单。本文主要面向程序开发人员,但即便不是开发者,任何计算机用户也都能从中受益。更多关于此类计算机底层原理的探讨,欢迎在 云栈社区 交流分享。

重要术语

在 ASCII 编码中,编码的过程可以理解为:根据一张字符表,从字符出发,查找并用对应的比特序列来替换它;而解码则恰恰相反,是从一串比特出发,按照同一张表,把比特序列还原为人类可读的字符。

简单来说,encode 就是用一种东西表示另一种东西;而 encoding,就是一套把一种表示形式转换成另一种表示形式的规则。在这个语境下,还有几个相关术语需要提前说明:

  • 字符集(character set,charset):指能够被编码的一组字符集合。例如,“ASCII 编码包含一个由 128 个字符组成的字符集。”在很多场景下,它常常与“编码(encoding)”这个词混用。
  • 代码页(code page):一张将字符映射到数字或比特序列的“表”,本质上,它描述的也是一种编码规则。
  • 字符串(string):位串是由多个比特组成的,例如 01010011。字符串是由多个字符组成的,例如 like this。从抽象角度看,它们都可以理解为一种“序列(sequence)”。

数字的表示方式

数字本身只有“数值”这一层含义,但在计算机中,它可以用多种不同的方式来表示。比如一个十进制数 237,用二进制表示是 10011111,用八进制表示是 237,而用十六进制表示则是 9F。这些表示方式虽然写法不同,但表达的都是同一个数值。

相比二进制,十六进制更加简短易读,不过在本文中我会始终使用二进制来表示数字,这有助于直接理解编码的核心概念,避免引入额外的抽象。因此,当你在其他地方看到用不同进制来表示字符编码时也不必感到困惑——无论采用哪种进制,本质上它们都是同一回事。

把基础问题先理清楚

这一点大家在某种程度上其实都知道,但一旦讨论到“文本”,这种常识往往就被忽略了。我们先把它明确说清楚:计算机并不能直接存储“字母”“数字”“图片”或任何其他具体的事物。它唯一能存储、也唯一能处理的,只有比特(bit)。

一个比特只有两种状态:是或否、真或假、1 或 0。由于计算机是靠电来工作的,现实中的一个“比特”其实就是一小段电信号,要么存在,要么不存在。对人来说,通常用 1 和 0 来表示这两种状态。

既然计算机只能处理比特,那么问题就来了:如何用比特来表示比特之外的东西,比如字母、数字,甚至图片呢?这就必须事先约定一套规则,用来把一串比特解释成字母、数字或图片之类有意义的内容。这套规则就叫作编码方案,简称编码。例如:

01100010 01101001 01110100 01110011
bits

在这套编码中,01100010 表示字母 b01101001 表示 i01110100 表示 t01110011 表示 s。也就是说,某一段固定的比特序列对应一个字母,而一个字母也对应一段固定的比特序列。

上面这个编码方案,实际上就是 ASCII。在 ASCII 中,一串由 1 和 0 组成的比特序列,会被切分成每 8 个比特一组,也就是一个字节。ASCII 编码定义了一张对照表,用来把这些字节翻译成人类可读的字符。下面展示的是这张表中的一小部分。

ASCII 字符与比特对应表示例

ASCII 表中一共定义了 95 个可直接阅读的字符,其中包括大写和小写的英文字母 A~Z、数字 0~9,以及一些常见的标点符号和特殊字符,例如美元符号 $、与号 & 等。

除此之外,ASCII 还定义了 33 个控制字符,例如空格、换行(line feed)、制表符(tab)、退格(backspace)等。这些字符本身并不能“打印”出来,但通常能以某种形式体现出来,并且在文本处理中对人类来说是直接有用的。

还有一些取值只对计算机有意义,比如用来表示文本开始或结束的控制码。这样算下来,ASCII 编码总共定义了 128 个字符。对于计算机领域的人来说,这是一个非常“舒服”的数字,因为它正好用尽了 7 个比特的所有可能组合(从 000000000000010000010 一直到 1111111)。

到这里,我们知道了只用 0 和 1,就可以表示人类可读的文本,例如,下面这一串比特:

01001000 01100101 01101100 01101100 01101111 00100000
01010111 01101111 01110010 01101100 01100100

按照 ASCII 编码进行解码,就可以得到:

"Hello World"

然而,95 个字符对于一种语言来说其实并不多,它基本上只够用来书写英文。但如果你想用法语写一封稍微暧昧一点的信、用德语写一条 Straßen­übergangs­änderungs­gesetz?或者用瑞典语发一份 smörgåsbord 的邀请函?答案是——不行,ASCII 做不到。

在 ASCII 中,根本没有规定如何表示 éßüäöå 这些字母,也就是说,你无法用 ASCII 对它们进行编码。

这时,欧洲人站出来说:“等等,在计算机的世界中,一个字节是 8 个比特,而 ASCII 只用了其中的 7 个!最高位那个比特永远是 0,完全被浪费了。既然如此,我们完全可以利用它,把字符表里再塞进另外 128 个取值。”他们也真的这么做了。

但问题依然存在。即便多出了这 128 个取值,各种带重音、变音、划线、加点的元音字母,其组合方式也远远不止 128 种。欧洲各国语言中使用的字母及其符号变化,根本不可能在一个最多只有 256 个取值的字符表中全部表示出来。

于是,世界最终变成了这样一种局面:编码方案层出不穷。有正式标准,也有事实标准,还有各种半标准;而且每一种编码方案,通常都只能覆盖字符集合中的某一部分。也许有一天,有人想用捷克语写一篇关于瑞典语的文档,却发现没有任何一种编码能够同时覆盖这两种语言,于是干脆自己发明了一套——类似的事情,恐怕已经发生过无数次。

而且,这还仅仅是欧洲语言的问题。别忘了还有俄语、印地语、阿拉伯语、希伯来语、韩语,以及今天仍在世界各地使用的所有语言,更不用说那些已经不再使用的语言了。当你好不容易解决了多种语言混合书写的问题,再来试试中文,或者日文吧——它们都包含成千上万的字符。而你手里,一个 8 比特的字节,只有 256 种可能的取值。来吧,试试看。

多字节编码

如果一种语言中使用的字符数量超过 256 个,那么只用 一个字节 显然是不够的。一个直接的想法是:增加字节数。例如,改用两个字节(16 比特),这样就可以表示 65,536 种不同的取值。BIG-5 正是一种这样的双字节编码。

在这类编码中,比特串不再按 8 位一组来拆分,而是按 16 位一组进行解释,并通过一张(真的很“大”的)对照表来规定:每一种比特组合对应哪个字符。BIG-5 在其基本形式下,主要覆盖的是繁体中文字符。

GB18030 是另一种多字节编码方案,本质上做的是同一件事,但它同时包含了繁体和简体中文字符。至于你可能会想到的那个问题——是的,确实还存在只支持简体中文的编码。看来,我们始终没法只靠一种编码就解决所有问题,对吧。

下面是 GB18030 编码表中的一小段示例:

比特                     字符
10000001 01000000        丂
10000001 01000001        丄
10000001 01000010        丅
10000001 01000011        丆
10000001 01000100        丏

GB18030 覆盖了相当广泛的字符范围(其中也包括大量拉丁字符),但说到底,它仍然只是众多特定用途编码格式中的一种而已。

Unicode:混乱中的终结者?

终于,有人受够了这种混乱,决定“铸造一枚戒指,把它们全部统一起来”——也就是创建一个统一所有编码标准的体系。这个标准就是 Unicode

从本质上看,Unicode 定义了一张极其庞大的字符表,其中包含 1,114,112 个码位(code point),用于表示各种字母和符号。这一数量之大,足以覆盖人类已知的几乎所有字符:欧洲的、中东的、远东的、南方的、北方的、西方的,甚至包括史前文字和未来可能出现的字符。

有了 Unicode,你几乎可以在同一个文档中混合使用任何语言,只要这些字符能在计算机上输入。这样的事情,在 Unicode 出现之前,要么根本不可能,要么极其容易出错。

Unicode 的设计甚至考虑到了扩展性:它还预留了非官方的私有使用区,用于放置不属于正式标准的字符。例如,克林贡语(Klingon)就曾被放入这样的区域。没错,Unicode 大到可以容纳非官方的、私有用途的字符区。

那么问题来了:Unicode 用多少比特来编码这些字符?答案是:一个都不用。因为 Unicode 本身并不是一种编码。

是不是有点懵?很多人第一次看到这里都会感到困惑。原因在于,Unicode 首先做的事是定义了一张字符到码位的映射表,换句话说,它只是规定:“65 表示 A,66 表示 B,而 9731 表示 ☃。至于这些码位最终如何被转换成 0 和 1 比特序列,那是另外一个问题。

Unicode 编码表片段示例

既然 Unicode 定义了 1,114,112 个不同的码位,那么在真正编码时,至少要能表示这么多不同的值。显然,两个字节肯定不够;三个字节是够的,但在实际处理时往往不太方便;于是,四个字节就成了一个比较舒适的下限。

但问题随之而来:除非你真的在频繁使用中文或其他码位很大的字符,否则这四个字节中的绝大部分空间其实都会被浪费掉,例如,如果字母 A 总是被编码成:

00000000 00000000 00000000 01000001

字母 B 总是被编码成:

00000000 00000000 00000000 01000010

以此类推,那么任何一份以英文为主的文档,其体积都会膨胀到原本所需的四倍。

为了解决空间浪费的问题,人们提出了多种将 Unicode 码位编码成比特的方式。UTF-32 就是其中之一。它使用 32 个比特来表示一个 Unicode 码位,也就是说,每个字符固定占 4 个字节。这种方式非常简单、直观,但在实际使用中,往往会浪费大量存储空间。

与之相比,UTF-16UTF-8 都属于可变长度编码。如果某个字符的码位很小,只需要较少的比特就能表示,那么编码时就会使用更少的字节;需要更多空间时,再使用更多字节。

其中,UTF-8 的粒度最细:它最少使用 1 个字节,最多可以扩展到多个字节。UTF-8 通过在字节的高位设置特定的标志位,来表明一个字符由多少个字节组成。这种方式能够有效节省空间,但同时也引入了一定的额外开销。

UTF-16 则介于两者之间:它至少使用 2 个字节,在必要时可以扩展到 4 个字节,在空间利用和实现复杂度之间取得了一种折中。

下面是同一个字符在不同编码方式下的表示示例:

字符   编码方式   比特表示
A       UTF-8      01000001
A       UTF-16     00000000 01000001
A       UTF-32     00000000 00000000 00000000 01000001
あ      UTF-8      11100011 10000001 10000010
あ      UTF-16     00110000 01000010
あ      UTF-32     00000000 00000000 00110000 01000010

事情到这里,其实就已经很清楚了:Unicode 是一张把字符映射为数字的“大表”,而各种 UTF 编码 则规定了这些数字该如何被转换成比特。

从整体来看,Unicode 依然可以被视为一种完整的编码体系。它并没有什么神秘之处,只是在尽量覆盖所有字符的同时,又努力在空间效率和实现复杂度之间取得平衡。

码位(Code points)

在 Unicode 中,每个字符都是通过一个 Unicode 码位(code point) 来标识的。Unicode 码位通常使用 十六进制表示(这样数字更短一些),并且统一以 U+ 作为前缀——这只是一个约定,用来明确说明“这是一个 Unicode 码位”,本身并没有其他特殊含义。

例如,字符 的 Unicode 码位是 U+1E00。如果换成十进制表示,它对应的是 Unicode 表中的第 7680 个字符。在 Unicode 的官方命名中,这个字符被称为 “LATIN CAPITAL LETTER A WITH RING BELOW”(带下环的大写拉丁字母 A)。

字符 A 的 Unicode 码位示例

理解了码位的概念之后,我们可以对前面的内容做一个总结:字符、码位和比特序列并不是一一对应的。

同一个字符,可以在不同编码方式下被表示为不同的比特序列;反过来,同一串比特,在不同编码方式下,也可能会被解释成完全不同的字符。这一切,取决于读写它们时所采用的编码规则。

下面的示例可以直观地说明这一点。

相同比特序列,在不同编码方式下对应的字符
比特序列                编码方式            对应字符
11000100 01000010      Windows Latin 1    ÄB
11000100 01000010      Mac Roman          ƒB
11000100 01000010      GB18030            腂
相同字符,在不同编码方式下的比特表示
字符                   编码方式            比特序列
Føö                   Windows Latin 1    01000110 11111000 11110110
Føö                   Mac Roman          01000110 10111111 10011010
Føö                   UTF-8              01000110 11000011 10111000 11000011 10110110

这些例子清楚地说明了相同的比特在不同编码下可能代表不同的字符,而相同的字符在不同编码下其比特表示也可能完全不同。

常见误解、困惑与问题

说了这么多之后,我们终于要回到用户和程序员每天都会遇到的问题:这些问题是如何产生的,又该如何解决。其中最常见的问题就是:为什么字符会变成乱码?

如果你打开一个文档,看到的内容类似下面这样:

ÉGÉìÉRÅ[ÉfÉBÉìÉOÇÕìÔǵÇ≠ǻǢ

那么原因只有一个:正在读取这个文档的文本编辑器、浏览器、文字处理软件,或者其他工具,使用了错误的编码方式。仅此而已。

文档本身并没有“坏掉”(当然,除非它真的发生了物理或逻辑损坏,这个稍后再说),也不需要什么“神秘操作”。你需要做的,只是选择正确的编码方式来显示这个文档而已。

上面这个文档,实际存储的是下面这样一串比特序列:

10000011 01000111 10000011 10010011 10000011 01010010 10000001 01011011
10000011 01100110 10000011 01000010 10000011 10010011 10000011 01001111
10000010 11001101 10010011 11101111 10000010 10110101 10000010 10101101
10000010 11001000 10000010 10100010

当这些比特被用错误的编码规则去解读时,原本正常的文字,就会变成一堆看不懂的“鬼画符”。

接下来,我们可以简单做一次排除。先试试用 ASCII 来解释:这些字节大部分都以 1 开头,如果你还记得的话,ASCII 根本不会用到这个比特位,所以它不可能是 ASCII。

那 UTF-8 呢?再仔细看看就会发现也不行,其中大部分字节序列都不是合法的 UTF-8 编码,所以 UTF-8 也可以排除。

那我们试试 Mac Roman(一种主要为欧洲语言设计的编码方案)。嘿,这下有意思了:这些字节在 Mac Roman 里全都是合法的。例如,10000011 对应 “É”,01000111 对应 “G”,以此类推,如果用 Mac Roman 来解读这串比特,得到的结果如下:

ÉGÉìÉRÅ[ÉfÉBÉìÉOÇÕìÔǵÇ≠ǻǢ

从形式上看,这似乎是一段“合理”的字符串,对吧?

但问题是——计算机怎么知道这是不是“正确”的结果??也许,文档的作者原本就打算写的就是 ÉGÉìÉRÅ[ÉfÉBÉìÉOÇÕìÔǵÇ≠ǻǢ。在我看来,它甚至也可以被解释成一段 DNA 序列。只要你给不出更好的理由,我们完全可以武断地认定:这个文档使用的是 Mac Roman 编码,然后心安理得地收工。

当然,这样的结论显然是胡扯。

真正的答案是:这段文本使用的是日文的 Shift-JIS 编码,它原本想表达的是:

「エンコーディングは難しくない」

这谁能想到呢?

乱码产生的根本原因只有一个:有人在用错误的编码方式去读取一段字节序列。

当计算机在读取文本时,必须明确知道文本使用的是什么编码。否则它根本不可能凭空判断这些字节究竟应该被解释成什么字符。

不同类型的文档,都有各自用于声明编码方式的机制,而这些机制本来就应该被正确使用。脱离了编码信息,一段“裸露”的比特序列永远都是一个黑盒——它可能代表任何东西。

正因为如此,大多数浏览器都会允许用户在“查看(View)”菜单中,通过“文本编码(Text Encoding)”选项手动选择编码方式,从而让浏览器用新的编码规则重新解释当前页面。在其他程序中,也常常可以在“文件(File)”菜单里找到类似“使用指定编码重新打开……”的选项,或者在“导入(Import)”数据时,让用户手动指定编码。

文本编码选择下拉菜单

我的文档在任何编码下都看不懂!

如果一段比特序列在任何编码方式下都无法让人读懂,那么几乎可以确定:这个文档在某个环节被错误地转换过。

我们继续以前面的那段文本为例:“ÉGÉìÉRÅ[ÉfÉBÉìÉOÇÕìÔǵÇ≠ǻǢ”,由于我们并不清楚它的真实来历,便把它当成一段“正常文本”,直接保存成 UTF-8,文本编辑器以为自己正确地读入了一份 Mac Roman 编码的文本,而你只是想把它“另存”为一种不同的编码。

问题恰恰出在这里:这些字符本身都是合法的 Unicode 字符。Unicode 中确实有码位表示 É,也有码位表示 G,以及后面的每一个字符。因此,从编辑器的角度来看,把这段文本保存成 UTF-8 完全没有任何问题。于是,就得到了下面这串比特序列:

11000011 10001001 01000111 11000011 10001001 11000011 10101100 11000011
10001001 01010010 11000011 10000101 01011011 11000011 10001001 01100110
11000011 10001001 01000010 11000011 10001001 11000011 10101100 11000011
10001001 01001111 11000011 10000111 11000011 10010101 11000011 10101100
11000011 10010100 11000011 10000111 11000010 10110101 11000011 10000111
11100010 10001001 10100000 11000011 10000111 11000010 10111011 11000011
10000111 11000010 10100010

这正是字符串 ÉGÉìÉRÅ[ÉfÉBÉìÉOÇÕìÔǵÇ≠ǻǢ 在 UTF-8 下的表示形式。但请注意:这串比特,已经和最初那个文档毫无关系了。

从这一刻开始,不管你再用什么编码方式去打开它,都不可能再得到原本的那句日文:「エンコーディングは難しくない」。原始信息已经被彻底丢失。

理论上,如果你恰好知道:这原本是一个 Shift-JIS 编码的文档,被误当成 Mac Roman 解读,又被错误地保存成 UTF-8,那么你可以反向推回去,从而恢复原文。但这种情况基本只能算是撞大运。

还有一种常见但同样致命的情况是:某些比特序列在特定编码中本身就是非法的。

例如,如果我们试图用 ASCII 去打开最初的那个文档,结果会非常不完整,原因在于:其中一部分字节在 ASCII 中是合法的,能够被映射成字符;而另一部分字节则完全不在 ASCII 的定义范围内。面对这些“非法字节”,不同的程序会采取不同的处理策略:有的会直接将它们丢弃,有的会用 ? 之类的占位符进行替换。在 Unicode 的世界里,还有一个专门的“替换字符”——(U+FFFD),当解码器无法正确解码某个字节序列时,往往就会用它来代替原本无法识别的内容。

关键的问题在于:一旦文档被保存时,这些字节已经被丢弃或替换掉了,那么对应的原始信息就真的永远消失了。此时,已经不存在任何可供逆向推理的线索,也就不可能再把原文恢复出来。

因此,只要一个文档曾经被错误解读并转换成了另一种编码,它在本质上就已经“坏掉”了。后续再尝试去“修复”它,绝大多数情况下都会失败。

那么,如何正确地处理编码?

答案其实非常简单:明确一段文本(也就是一串字节)使用的是什么编码,然后严格按照这个编码去解读它。 只要做到这一点,几乎所有乱码问题都会消失。

如果你在编写一个允许用户输入文本的应用程序,那么编码这件事,原则上应该由程序来负责,而不是交给用户猜。

对于各种文本输入框,所使用的编码通常是由程序员在系统内部统一决定的;用户只是在输入字符,而不是在输入“字节”。只要整个系统在内部保持一致,这一部分通常不会出问题。

真正容易出错的,是用户上传或导入的外部文件。对于这类场景,必须有明确的编码约定:

  • 要么,文件格式本身就包含了编码信息;
  • 要么,就需要通过一种方式让用户告诉程序这个文件使用的是什么编码(当然,除非用户读过这篇文章,否则大多数人其实并不知道“编码”是什么)。

当确实需要在不同编码之间进行转换时,一定要使用专门的工具或库,并且一次性、干净地完成这件事。

编码转换本质上是一项枯燥的工作:对照两张编码表,判断“编码 A 中的第 152 个字符,等同于编码 B 中的第 4122 个字符”,然后据此重新生成对应的比特序列。这件事既没有创造性,也没有技巧可言,因此完全没有必要自己重新造轮子。几乎所有的主流编程语言都内置了编码转换的能力,在使用这些工具时,你甚至不需要关心码位、编码表或具体的比特细节。你只需要做一件事:告诉它输入是什么编码,输出要什么编码。

举个具体的例子:假设你的应用需要接收 GB18030 编码的文件,而在程序内部,你选择统一使用 UTF-32 来处理所有文本数据。在这种情况下,应该明确地告诉程序:输入是什么编码,输出要变成什么编码。像 iconv 这样的工具,正是为这种需求而设计的,完成转换只需要一行代码:

iconv('GB18030', 'UTF-32', $string)

这行代码的含义非常明确:按照 GB18030 的规则读取这串字节,然后把同样的字符内容,用 UTF-32 的方式重新编码一遍。

也就是说,它会在保持字符内容不变的前提下,改变其 底层的比特表示:

字符   GB18030 编码           UTF-32 编码
縧     10111111 01101100      00000000 00000000 01111110 00100111

在 GB18030 中,这个字符可能只需要两个字节;而在 UTF-32 中,每个字符都固定占用四个字节。这正是 UTF-32 的设计取舍:用空间换取处理上的简单和确定性。

不过,正如一开始所强调的那样,并不是所有编码都能表示所有字符。像“縧”这样的汉字,在为欧洲语言设计的编码方案中根本无法表示。如果你试图把它转换成这些编码之一,那么就会出错——要么转换直接失败,要么字符被替换成问号、方框,或者 Unicode 的替换字符。

一路使用 Unicode

正因为前面提到的这些原因,在当今这个时代,几乎没有任何借口不从头到尾使用 Unicode

确实,对于某些特定语言来说,某些专用编码在空间或效率上可能比 Unicode 编码更有优势。但这种优势只有在极端情况下才有实际意义——比如你需要存储的是 TB 级别、内容高度同质的文本数据。而对绝大多数应用而言,这种差异完全不值得为之付出复杂度上的代价。

相比之下,由编码不兼容带来的问题,远比浪费一两 GB 存储空间要严重得多。而且,随着存储和带宽变得越来越大、越来越便宜,这种“空间上的顾虑”只会越来越不重要。

因此,一个简单而稳妥的原则是:系统内部统一使用 Unicode。如果确实需要与其他编码格式交互,那么就在输入阶段将所有数据转换为 Unicode,在输出阶段再根据需要转换回目标编码即可。

反过来说,如果你选择在系统内部混用多种编码,那么你就必须始终清楚地知道:在系统的每一个环节,当前使用的究竟是哪一种编码,并且确保在编码切换时不丢失任何信息。这不仅困难,而且极其容易出错。

记住,一路使用 Unicode,不是因为它完美,而是因为它能够让问题变少。

意外的“巧合”

我有一个网站连接着数据库。应用程序内部统一使用 UTF-8 处理文本,数据也以 UTF-8 的形式写入数据库,业务运行一直很正常。但当我打开数据库管理界面时,文本却是乱码。

这种情况并不少见:编码处理是错的,但系统却“看起来还能用”。一个非常典型的场景是:数据库被设置成 latin-1,而应用程序内部使用的是 UTF-8(或其他编码)。

在 latin-1 这种单字节编码中,几乎所有 8 位的比特组合都是合法字符。因此,当数据库从应用那里接收到这样一串比特序列时:

11100111 10111000 10100111

它不会产生任何怀疑,直接把这段数据存下来,并理所当然地认为应用想存的是三个 latin-1 字符:“縧”。从数据库的角度看,这完全说得通。

之后,当数据库把这串比特原封不动地返回给应用时,应用又会按照 UTF-8 的规则去解读它,于是得到了字符“縧”——也正是它一开始想存的那个字符。一来一回,数据竟然“对上了”。

但数据库的管理界面却不这么想,管理工具会根据数据库的配置,识别数据库使用的是 latin-1,于是它会用 latin-1 去解释这些比特。结果就是:只有在管理界面里,文本看起来是乱码。

这其实是一种愚蠢的运气。系统之所以没有立刻出问题,并不是因为编码处理是正确的,而只是因为这些比特序列在两种编码下“恰好都说得通”。实际上,数据库始终在用错误的编码假设来理解这些文本,对文本做的任何操作,都可能偶然正确,也可能悄无声息地出错。

最糟糕的情况是:系统上线运行了两年,一切看似风平浪静,直到某一次看似普通的数据库操作中,因为始终基于错误的编码假设,数据库不小心把所有文本都彻底毁掉了。

UTF-8 与 ASCII

UTF-8 最巧妙的一点在于:它与 ASCII 在二进制层面完全兼容,而 ASCII 又是几乎所有编码的事实基础标准。

所有 ASCII 字符在 UTF-8 中仍然只占用一个字节,并且使用的字节值完全相同。换句话说,ASCII 可以一对一地映射到 UTF-8。那些不属于 ASCII 的字符,则会在 UTF-8 中占用两个或更多字节。

这意味着,对于大多数原本只打算解析 ASCII 的程序,你可以直接在代码中写入 UTF-8 文本,而无需做额外处理。例如:

$string = "漢字";

如果将这段代码以 UTF-8 编码保存下来,对应的比特序列大致如下:

00100100 01110011 01110100 01110010 01101001 01101110 01100111 00100000
00111101 00100000 00100010 11100110 10111100 10100010 11100101 10101101
10010111 00100010 00111011

其中,第 12 到第 17 个字节(也就是那些以 1 开头的字节)才是真正的 UTF-8 字符——两个汉字,每个字符各占三个字节。其余部分全部是完全合法的 ASCII。

解析器看到这段代码时,会把引号内的内容视作一串原封不动的字节序列:

$string = "11100110 10111100 10100010 11100101 10101101 10010111";

它只负责按字节顺序读取,直到遇到下一个引号为止。如果直接输出这段字节序列,输出的自然就是 UTF-8 文本,无需解析器对 UTF-8 本身有任何“理解”。

也就是说,即使解析器原本只支持 ASCII,也可以在“无感知”的情况下支持 Unicode。这也是 UTF-8 得以广泛应用的关键原因。当然,如今多数现代编程语言都已经对 Unicode 提供了原生支持,能够直接处理多字节字符。

编码和 PHP

最后这一部分,我们来讨论 Unicode 与 PHP 之间的关系。其中有些结论适用于大多数编程语言,而另一些则是 PHP 特有的。

人们常说 “PHP 不原生支持 Unicode”。这句话本身并不算错,但如果不加解释,就很容易产生误解。事实上,PHP 对 Unicode 的支持状况,并没有很多人想象的那么糟糕。

前面已经提到过,UTF-8 在二进制层面与 ASCII 完全兼容。而 PHP 的语言解析器只需要理解 ASCII 即可完成语法解析。因此,只要 PHP 源代码文件本身是以 UTF-8 编码保存的,UTF-8 字符就可以毫无问题地直接写进 PHP 程序中。

当然,“PHP 不原生支持 Unicode”这句话本身并没有错,但正是这句话在 PHP 社区里引发了大量误解。

虚假的承诺

你经常能看到类似这样的说法:“要在 PHP 里使用 Unicode,必须在输入时对文本调用 utf8_encode,在输出时再调用 utf8_decode。”

这两个函数看起来好像在承诺一种“自动魔法”,只要调用一下,就能把文本“变成 UTF-8”,而这一步又被认为是“必要的”,因为“PHP 不支持 Unicode”。

但如果你一直在认真读这篇文章,到这里应该已经很清楚两件事了:

  • UTF-8 本身并没有什么特殊或神奇的地方
  • 文本不可能在事后再被“编码成 UTF-8”

先澄清第二点:任何文本,在任何时刻,都已经处在某种编码之中。你在源代码里输入文本时,它就已经有编码了——具体取决于你用文本编辑器以什么编码保存这个文件。从数据库中取出来的文本,也已经是某种编码;从文件里读取的文本,同样如此。

一段文本要么是 UTF-8 编码的,要么就不是。如果不是 UTF-8,那它可能是 ASCII、ISO-8859-1、UTF-16,或者其他某种编码形式。

因此,如果一段文本不是 UTF-8 编码,却又“声称”自己包含“UTF-8 字符”,那只能说明这是一个逻辑上的自相矛盾。

反过来说,如果一段文本确实包含了用 UTF-8 表示的字符,那么它本身就已经是 UTF-8 编码的文本了。不存在“不使用 Unicode 编码,却包含 Unicode 字符”的文本。

那么,utf8_encode 到底是干什么的呢?

它的官方说明是:“将一个 ISO-8859-1 字符串编码为 UTF-8”,也就是说,它真正做的事情只是:把文本的编码从 ISO-8859-1 转换为 UTF-8,仅此而已。

utf8_encode 这个名字大概是某位欧洲人随手起的,几乎没有任何前瞻性,可以说是一个相当糟糕的命名,utf8_decode 也存在同样的问题。它们的功能并不是“把任意文本变成 UTF-8”,而只是在 ISO-8859-1 与 UTF-8 之间做双向转换。如果你需要在任意编码之间进行转换,直接用 iconv 就够了。

也正因为名字极具误导性,再加上很多开发者对编码本身缺乏理解,utf8_encode 常常被当成一种“通用解决方案”——仿佛因为 PHP 不支持 Unicode,就必须对所有文本先调用它一遍。但事实恰恰相反:在实际使用中,它制造的问题往往比解决的问题还要多。

所谓“原生支持”与否

那么,一门语言“原生支持 Unicode”或“不原生支持 Unicode”,到底是什么意思呢?从本质上看,这个说法关注的并不是语言能不能处理 Unicode,而是:它是否默认把“一个字符”等同于“一个字节”。

以 PHP 为例,它允许你通过数组下标的方式直接访问字符串中的“字符”:

echo $string[0];

如果 $string 使用的是单字节编码,那么这样写确实能够得到第一个字符。但这并不是因为 PHP 真正理解了“字符”这个概念,而是因为在单字节编码中,“字符”和“字节”恰好是一一对应的。

换句话说,PHP 在这里做的事情其实非常简单:它只是返回了字符串中的第一个字节,并不会去思考这个字节在语义上是否构成了一个完整的字符。

对 PHP 来说,字符串本质上只是一段字节序列,所谓“可读字符”,完全是人类赋予的语义概念,PHP 本身并不关心。

例如,下面这段比特序列:

01000100 01101111 01101110 00100111 01110100
D        o        n         '        t
01100011 01100001 01110010 01100101 00100001
c        a        r        e         !

在单字节编码的前提下,每一个字节都恰好对应一个字符,因此无论是按“字符”还是按“字节”来理解,结果都是一致的。

PHP 中的许多标准字符串函数——比如 substrstrpostrim 等——本质上都是在按字节工作。一旦“一个字符”不再等于“一个字节”,问题就会立刻显现出来,这也正是人们常说 PHP “不支持 Unicode”的真正含义。

再看一个多字节编码的例子:

11100110 10111100 10100010 11100101 10101101 10010111
漢                        字

在 UTF-8 中,这两个汉字各自由三个字节组成。如果对这样的字符串使用 $string[0],得到的仍然只是第一个字节,也就是 11100110。换句话说,这只是三字节字符“漢”的三分之一。

单独来看,11100110 并不是一个合法的 UTF-8 编码序列。因此,从 UTF-8 的角度说,这一步操作已经把字符串破坏了。如果你愿意,也可以尝试用其他编码去解释这个字节——在某些编码中,它可能刚好对应一个合法字符,于是结果就会变成某个看似“随机”的符号。

汉字“漢”的图片说明

事实上,这就是“PHP 不原生支持 Unicode”的全部含义。它指的只是这样一件事:大多数 PHP 字符串函数在默认情况下认为“一个字节就等于一个字符”。

一旦把这些函数直接用在多字节字符串上,就可能出现问题——比如把一个字符截成两半,或者在计算字符串长度时得到错误的结果。这并不意味着 PHP 不能使用 Unicode,也不意味着每个 Unicode 字符串都必须经过 utf8_encode 之类的“加持”。

好在 PHP 提供了 Multibyte String(多字节字符串) 扩展,这个扩展以支持多字节字符的方式,重新实现了所有重要的字符串处理函数。以刚才的示例为例,如果对同一个字符串使用:

mb_substr($string, 0, 1, 'UTF-8')

就能正确返回 11100110 10111100 10100010,也就是完整的“漢”字。

正因为 mb 系列函数需要真正理解“字符”这一概念,它们就必须知道当前处理的文本使用的是什么编码。因此,每一个 mb 函数都允许显式指定 $encoding 参数;同时,也可以通过 mb_internal_encoding 为所有的 mb_ 函数统一设置一个默认编码。

利用与“滥用” PHP 对编码的处理方式

围绕 PHP 是否“支持” Unicode 的争论,其根本原因在于:PHP 本身并不关心编码问题。对 PHP 来说,对 PHP 来说,字符串只是一段字节序列,仅此而已。至于这些字节具体是什么、代表什么字符,PHP 并不在意。它对字符串所做的事情,基本只是把这些字节存放在内存中而已。

换句话说,PHP 并没有“字符”或“编码”的概念,只要它不去操作字符串内容,就完全不需要理解这些概念;至于这些字节在将来是否、以及如何被解释成人类可读的字符,那是发生在 PHP 之外的事情。

PHP 对编码的唯一要求是:PHP 源代码必须使用与 ASCII 兼容的编码保存。

原因很简单:PHP 解析器需要识别一些具有特殊含义的字符,才能正确理解程序的结构和行为。例如:

  • $00100100)表示变量开始
  • =00111101)表示赋值
  • "00100010)表示字符串的开始和结束

这些字符都属于 ASCII 范围,解析器正是通过识别它们来完成语法解析。

而对于解析器来说,凡是不具有特殊语法意义的内容,都会被当作字面字节序列原样处理,其中最典型的例子,就是引号之间的所有内容。正如前面所讨论的那样,这些字节会被完整地保留下来,而不会被 PHP 解释为“字符”或“文本”。

这也就意味着:

  • 首先,你不能使用与 ASCII 不兼容的编码来保存 PHP 源代码。举例来说,在 UTF-16 中,一个 " 会被编码为 00000000 00100010。而 PHP 在按 ASCII 方式读取源码时,会把它理解为一个 NUL 字节后跟一个 "。如果源码中几乎每隔一个字符就出现一个 NUL 字节,PHP 很可能会直接“懵掉”,无法正常完成语法解析。
  • 其次,你可以使用任何与 ASCII 兼容的编码来保存 PHP 源代码。只要某种编码的前 128 个码点与 ASCII 完全一致,PHP 就能够正确解析源码。原因在于,所有对 PHP 解析器具有特殊意义的字符,都位于 ASCII 定义的这 128 个码点之内。至于字符串字面量中是否包含超出这 128 个码点的字符,PHP 并不关心。因此,PHP 源代码可以保存为 ISO-8859-1、Mac Roman、UTF-8,或者任何其他与 ASCII 兼容的编码格式。脚本中字符串字面量采用的编码,实际上就是你保存源代码时所使用的编码。
  • 对于 PHP 处理的外部文件来说,编码则完全不受限制。只要 PHP 不需要解析这些文件,就不需要满足任何额外的编码要求。下面这段代码所做的事情非常简单:把 bar.txt 中的原始字节读入变量 $foo。PHP 不会尝试去解释、转换、重新编码或以任何方式“加工”这些内容。这个文件甚至可以是图片之类的二进制数据,PHP 也依然毫不在意。
$foo = file_get_contents('bar.txt');
  • 如果内部编码和外部编码需要保持一致,那么这种一致性就必须是真正意义上的一致。一个常见的场景是本地化(Localization):源代码中可能会有类似 echo localize('Foobar') 这样的调用,而外部的本地化文件中则包含如下内容:
msgid  "Foobar"
msgstr "フーバー"

在这个过程中,两个 "Foobar" 在进行匹配时,它们在比特层面的表示必须完全相同,才能正确找到对应的本地化结果。换句话说,字符串的匹配并不是基于“看起来是否一样”,而是基于它们在内存中的字节序列是否一致。

如果源代码是以 ASCII 编码保存的,而本地化文件却使用了 UTF-16 编码,那么这两个字符串在内存中的字节表示就不会相同,匹配自然也就无法成功。要解决这个问题,要么在中间进行一次编码转换,要么使用能够感知编码差异的字符串匹配方式。

细心的读者这时可能会问:是否可以在一个以 ASCII 编码保存的源代码文件中,在字符串字面量里放入一段 UTF-16 的字节序列?答案是:完全可以。

echo "UTF-16";

只要能够让文本编辑器将 echo ""; 这些部分以 ASCII 编码保存,而仅将中间的内容以 UTF-16 编码保存,这段代码就可以正常工作。从字节层面来看,它对应的二进制表示大致如下:

01100101 01100011 01101000 01101111 00100000 00100010
e        c        h        o                        "
11111110 11111111 00000000 01010101 00000000 01010100
(UTF-16 marker)            U        T
00000000 01000110 00000000 00101101 00000000 00110001
F        -                 1
00000000 00110110 00100010 00111011
6                        "        ;

其中,第一行以及最后两个字节是 ASCII 编码,其余部分则是 UTF-16,每个字符占用两个字节。第二行开头的 11111110 11111111 是 UTF-16 文本所要求的标记(这是 UTF-16 标准的规定,与 PHP 本身无关)。

这个 PHP 脚本会毫无问题地输出一个以 UTF-16 编码的字符串 "UTF-16",原因在于 PHP 只是原样输出双引号之间的字节序列,而这些字节恰好表示的是用 UTF-16 编码的文本 "UTF-16"

当然,这个源代码文件既不是纯粹的 ASCII,也不是合法的 UTF-16,因此用普通文本编辑器来编辑它,体验恐怕不会太好。

结论

总的来说,PHP 对 Unicode——事实上,对任何编码——都能很好地支持,前提是满足一些基本条件:既不让解析器陷入困惑,程序员自己也清楚在做什么。真正需要格外小心的地方,主要在于对字符串的操作,比如截取、裁剪、计算长度等,这些操作关注的是“字符”层面,而不是“字节”层面。

编码感知型语言

那么,一门语言“支持 Unicode”到底意味着什么呢?以 JavaScript 为例,它通常被认为是支持 Unicode 的。事实上,JavaScript 中的所有字符串都采用 UTF-16 编码,而且只能是 UTF-16:你不可能在 JavaScript 里拥有一个不是 UTF-16 编码的字符串。可以说,JavaScript 对 Unicode 的“信仰”非常彻底,语言本身根本不提供处理其他编码的能力。由于 JavaScript 大多运行在浏览器环境中,而浏览器会负责在输入和输出阶段完成各种编码的解码与转换,这些“脏活累活”并不需要由语言本身承担,因此这种设计在实践中并不会带来明显问题。

除此之外,还有一些语言可以归类为编码感知型语言(encoding-aware)。这类语言在内部使用某种固定编码来存储字符串,常见的也是 UTF-16;但与此同时,它们需要被明确告知,或自行尝试判断:源代码文件采用的是什么编码、读取的外部文件是什么编码、输出时又希望使用什么编码。随后,这些语言会以 Unicode 作为中间表示,在内部自动完成不同编码之间的转换。

本质上,这些语言在幕后完成的工作,和你在 PHP 中本该、也必须手动完成的事情并没有区别,只不过它们将这一过程做成了半自动化。这种设计并不能简单地说更好或更差,而只是取向不同。它的优势在于:语言自带的大多数字符串处理函数都能直接正常工作;而在 PHP 中,开发者则需要多留一份心,判断字符串里是否可能包含多字节字符,并据此选择合适的字符串处理函数。

Unicode 的奥秘

由于 Unicode 需要处理多种文字体系以及各种复杂问题,它本身的内容非常深奥。例如,Unicode 标准中就包含了关于 CJK 表意文字统一 的相关信息,也就是说,它定义了哪些中文、日文、韩文字符在本质上表示的是同一个字符,只是在书写形式上存在差异。

再比如大小写转换的问题。在大多数源自西欧拉丁字母的文字中,从小写转换为大写、再转换回来通常都很直观,但在其他文字体系中,这个过程并不总是那么简单。针对这些情况,Unicode 同样制定了相应的规则,以保证转换行为的一致性。

除此之外,Unicode 还允许某些字符存在多种等价的表示方式。例如,字母 “ö” 既可以用码点 U+00F6(“带分音符的小写拉丁字母 o”)来表示,也可以通过两个码点的组合来表示:U+006F(小写字母 o)加上 U+0308(组合分音符),也就是“o”加上“¨”。在 UTF-8 中,前者对应一个两字节序列 11000011 10110110,而后者则对应一个三字节序列 01101111 11001100 10001000,但它们在人类看来表示的是同一个字符。

正是由于这种“同形不同码”的情况,Unicode 标准中还定义了规范化(Normalization) 规则,用于在这些不同的表示形式之间进行转换。类似的细节还有很多,已经超出了本文的讨论范围。

总结

  • 文本从本质上将始终是一串 比特序列,只有通过查表(编码表)才能被解释成人类可读的文字。如果使用了错误的编码表,就会得到错误的字符。
  • 事实上,你从来都不是在直接处理“字符”或“文本”,而是在通过多层抽象间接地处理比特。只要其中任何一层出现偏差,最终的结果就会出错。
  • 只要两个系统之间需要交换文本,就必须明确约定所使用的编码。最简单的例子就是:网站需要明确告诉浏览器,它返回的内容采用的是 UTF-8 编码。
  • 在如今,UTF-8 已经成为事实上的标准编码:它几乎可以表示所有有意义的字符,与事实标准 ASCII 向后兼容,并且在绝大多数实际场景中都具备很高的空间效率。
  • 其他编码在某些特定情况下仍然有存在价值,但如果你要使用那些只能表示 Unicode 子集的字符集,就必须有非常明确的理由,并且愿意承担由此带来的各种复杂性和潜在风险。
  • “一个字节等于一个字符”的时代已经结束了,程序员和程序本身都必须正视并适应这一现实。

阅读完本文后,下次你再把文本弄成乱码的时候,真的已经没有任何借口了。




上一篇:Kimi Code 编程辅助工具正式发布:支持VSCode/IDEA集成与多模态交互
下一篇:Andrej Karpathy亲述AI编程巨变:几周内代码生成率从20%跃至80%
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-31 06:26 , Processed in 0.276536 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表