编码与乱码之追根溯源

乱码问题是不但是新手程序员之痛,也常常让许多资深 coder 束手无策。本文探讨编码的概念、乱码的原理,以及乱码问题的分析与解决。

注:本文的所有截图和实验均以 Sublime Text 为例,其他编辑器或 IDE 在原理上类似。

一、什么是编码?

什么是编码?这要从「文件」的概念说起。根据呈现形式,文件可分为两种类型:「文本文件」和「二进制文件」。

二者的区别非常明显,文本文件中保存的是各种字符,包括英文字母如 abc、汉字如 你好、日文如 こんにちは 等;而二进制文件中保存的则是 0101 等二进制数值。如果你用 Sublime Text 分别打开文本文件和二进制文件,那么它们呈现的样子大致如下:

文本文件与二进制文件

注:我们习惯采用十六进制的方式简化二进制数据的显示,这样对人类用户稍微友好一些,避免了过长的 0-1 串使得人们眼花缭乱。

为什么会产生这两种类型的文件呢?一个非常直接的原因是,文本文件主要是给人类用户看的,例如我们常使用的 txt、markdown 文件,各种代码文件如 .cpp.java.py.js 等,以及各种配置文件如 .ini.json 等;而二进制文件则是给操作系统或应用程序看的,如 .exe 交给 Windows 系统执行、Word 文档交给 Office Word 软件打开、.class 文件交给 java 虚拟机执行,许多应用程序都会设计自己专用的二进制文件格式。

尽管我们把文件分为文本文件和二进制文件两种类型,但从计算机硬件层面上来看,它只能存储 0101 这样的二进制数据,不可能直接存储 abc 这样的字符。那么该如何解释文本文件的存在呢?

事实上,从存储方式上来看,文件确实只有一种类型,那就是二进制文件。至于文本文件,它只是二进制文件的一种特殊情况。在计算机最初发明的时候,确实只有二进制文件,那时的人们通过「打孔的纸带」作为存储程序的载体,而纸带上小孔的有无就代表二进制的 1 和 0。那时候的计算机根本没有字符的概念,更不要说文本文件。

后来,人们为了方便就制定了一套规则,规定二进制数值 01100001 代表字符 a01100010 代表字符 b、……、01111010 代表字符 z。于是,最早的编码「ASCII 编码」就产生了。现在,如果我在一个文件中写入二进制数据 011000010110001001100011,从表面上看,它就是一个常规的二进制文件,没有任何特殊之处,但如果我用 ASCII 编码的规则去解释它,就会看到一串字符 abc。这时候,我们就可以认为这个文件是文本文件。

从上面的描述中,你应该已经发现:

  • 所谓的「编码」就是一种规则,它规定了二进制数值与字符之间的映射关系
  • 所谓的「文本文件」就是一种二进制文件,只不过能用某种编码解释得通

说回到 ASCII 编码,它使用 8 个二进制位——也就是 1 个字节来映射一个字符,这意味着它最多只能映射 2^8=256 个字符。256 个字符对于纯英文来说已经足够了,但世界上的语言太多了,要囊括英文、德文、法文、中文、日文、韩文、阿拉伯文、希伯来文等所有语言文字,至少需要十几万的字符量。随着各种文字不断被引入计算机,字符编码的长度也不断扩张,从 1 个字节逐渐增加到 2 个、3 个、4 个字节。同时,各个组织、各个国家都在制定自己的编码体系,形成了错综复杂的编码“方言”。最终,到了 1994 年,人们终于制定出了一套统一的、无所不包的编码——Unicode 编码,成为编码界的“世界语”,因此也被称为万国码。

Unicode 编码使用 4 个字节来保存字符映射关系,因此共支持 2^(4*8)=4294967296 个字符,远远超出了地球上所有文字的总量。这彻底解决了字符数量不够用的担忧,但也带来了存储空间的浪费:即使仅仅保存一个简单的英文字母 a,Unicode 编码也需要 4 个字节,但事实上只需要 1 个字节(ASCII 编码)。如果一个文本文件中绝大部分字符都是英文字母,那么 Unicode 就浪费了 75% 的存储空间。鉴于上述问题,人们又制定了一系列“改良版”的 Unicode 编码,包括 UTF-8、UTF-16、UTF-32 等,它们同样能够编码所有已知的字符,但占用更少的空间。

以 UTF-8 为例,对于常见的英文字符,它采用 1 个字节编码,常见的中文、日文等字符采用 2 个字节,不常见的中文字符等采用 3 到 4 个字节,对于极不常见的字符,它会采用 6 个字节进行编码。因此,在通常情况下,UTF-8 编码要比 Unicode 编码节省超过一半的空间。UTF-8 编码无所不包、节省空间,且具有良好的跨平台性,因此推荐一切文本文件都使用 UTF-8 编码。目前,主流的文本编辑器都把 UTF-8 作为默认编码方式。

最后解释一下所谓的「ANSI 编码」。ANSI 编码常被称为标准编码,但它并不是指某种明确的编码方式。为了更容易地理解 ANSI 编码,我们不妨把它与「官方语言」的概念做类比。正如中国的官方语言是汉语,日本的官方语言是日语一样,中文 Windows 系统的 ANSI 编码为 GBK 编码,而日文 Windows 系统的 ANSI 编码为 Shift_JIS 编码。正如「官方语言」不是某种语言,「ANSI 编码」也不是某种编码,它是另一个维度的概念,与国家和地区有关,不同国家和地区的 ANSI 编码是不兼容的。可想而知,如果都采用 ANSI 编码,那么不同国家的开发者在互相交换代码时将非常糟糕。因此,不推荐以 ANSI 作为 coding 编码。

二、什么是乱码?

什么是乱码?用某种编码方式去解读一个文件,得到了无意义的字符,这就是乱码。打个通俗的比方:我写了一段英文,你非要把它当作拼音来读,那么得到的解释就是无意义的,就相当于乱码;反过来,我写了一段拼音,你非要用英语的语法去解释它,也是解释不通的。

举几个实际的例子:

  • 用 UTF-8 编码打开一个二进制文件会出现乱码:

用 UTF-8 编码打开一个二进制文件

  • 用 UTF-8 编码打开一个 GBK 编码的文本文件会出现乱码:

用 UTF-8 编码打开一个 GBK 编码的文本文件

  • 用 UTF-8 编码打开一个 UTF-8 编码的文本文件不会乱码:

用 UTF-8 编码打开一个 UTF-8 编码的文本文件

综上,乱码的根源就是编码与解码用的不是同一套规则。 但不管文件是否乱码,它里面保存的二进制数据总是不变的。通常情况下,乱码并不是文件本身有问题,而是打开方式(解码方式)不正确

三、编程中出现乱码的原因与类型

我们在日常使用文本编辑器、IDE、命令行等编写和执行程序的过程中,常常会遇到乱码现象,而出现乱码的原因是多种多样的。这里试图从根源上理解乱码,并将其归类。

一般,我们编写和执行程序的流程如下:

  1. 编写代码并保存;
  2. 调用编译器编译代码,并执行程序;
  3. 查看输出结果。

在这短短的三步操作中,隐含着两次编码和解码过程,也就是下图中的过程 1 和过程 2:

代码编写和执行过程中的编码和解码

在过程 1 和过程 2 中,任意一个过程两端的编码方式都必须一致,否则就会出现乱码。其中,对于「代码文件的编码」以及「展示器的编码」,我们可以在编辑器和控制台中进行设置。最不可控的是编译器的输入编码和输出编码,常见编译器/解释器的默认输入输出编码如下表所示:

编译器/解释器 默认输入编码 默认输出编码 设置输入编码 设置输出编码
python UTF-8 ANSI # coding=xxx 环境变量 PYTHONIOENCODING
gcc/g++ UTF-8 UTF-8 未知 未知
javac ANSI ANSI -encoding 参数 未知
matlab ANSI ANSI 修改配置文件 未知

注:该结果是笔者在自己的 Windows 10 家庭中文版上测试得到的,不同的平台可能有差异。


接下来,我们将以 Sublime Text 执行一段 Python 脚本为例来展示这 2 种乱码,通过设置编译器输入编码、输出编码、展示器编码来探究乱码产生的不同原因。

这段 Python 脚本非常简单,只有一句话:print('你好'),以 UTF-8 编码保存。正常执行的结果如下:

正常无乱码

从上上图中不难看出,过程 1 和过程 2 均能导致乱码,其组合可形成如下三种乱码类型:

过程 1 乱码

我们在 Python 脚本头部添加一行 # -*- coding: gbk -*-,即把 Python 解释器的输入编码指定为 GBK,但脚本的编码保持 UTF-8 不变。执行结果将发生乱码,如下:

乱码类型 1

从这里我们也可以看出,Python 解释器的默认输入编码为 UTF-8。

过程 2 乱码。

这里又分为两种情况,一是编译器的输出编码错误;二是展示器的输入编码错误:

编译器输出编码不当。

打开 Python.sublime-build 文件(可借助 PackageResourceViewer 插件),其初始内容如下:

1
2
3
4
5
6
{
"shell_cmd": "python -u \"$file\"",
"file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
"selector": "source.python",
"env": {"PYTHONIOENCODING": "utf-8"},
}

我们把末尾的行改为 "env": {"PYTHONIOENCODING": "gbk"},,即把 Python 解释器的输出编码设为 UTF-8。执行脚本,再次得到乱码,如下:

乱码类型 2-1

注意:这里虽然也是乱码,但与类型 1 不同。

展示器输入编码不当。

我们首先撤销对 Python.sublime-build 的所有更改,然后在其末尾增加一行内容 "encoding": "gbk",,即把 Sublime Text 控制台的编码设为 GBK。此时 Python.sublime-build 配置如下:

1
2
3
4
5
6
7
{
"shell_cmd": "python -u \"$file\"",
"file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
"selector": "source.python",
"env": {"PYTHONIOENCODING": "utf-8"},
"encoding": "gbk",
}

执行脚本,得到乱码,如下:

乱码类型 2-2

注意:这里的乱码与类型 1 相同,都是用 GBK 编码解释 UTF-8 字符串造成的。

过程 1 与过程 2 同时乱码。

乱码是可以叠加的,即乱码后的字符串可以再次被乱码,得到的乱码与叠加前的乱码均不同。

我们让 Python.sublime-build 文件保持上一步的状态,然后在 Python 脚本的开头重新加上一行 # -*- coding: gbk -*-。执行脚本,会得到前两种完全不同的乱码,如下:

乱码类型 3

以上就是编程中出现乱码的 3 种典型情况。需要指出的是,以上采用 Sublime Text 的控制台作为展示器,其编码可以通过 Build System 中的 encoding 参数进行设置。如果你直接使用命令行如 cmd、bash、cmder 等来编译和运行程序,那就完全省去这些麻烦了,命令行一般会自动识别你的输出编码,因此总能使用正确解码方式,基本不会出现类型 2 乱码,但无法避免类型 1 乱码

希望本文对你有所启发,如果你在编程中遇到了乱码,不妨对下图中的 2 个过程进行控制变量式的排除,如果能够解决你的问题,那便是本文最大的成功。

代码编写和执行过程中的编码和解码