编码与乱码之追根溯源
乱码问题是不但是新手程序员之痛,也常常让许多资深 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
代表字符 a
、01100010
代表字符 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 编码打开一个 GBK 编码的文本文件会出现乱码:
- 用 UTF-8 编码打开一个 UTF-8 编码的文本文件不会乱码:
综上,乱码的根源就是编码与解码用的不是同一套规则。 但不管文件是否乱码,它里面保存的二进制数据总是不变的。通常情况下,乱码并不是文件本身有问题,而是打开方式(解码方式)不正确。
三、编程中出现乱码的原因与类型
我们在日常使用文本编辑器、IDE、命令行等编写和执行程序的过程中,常常会遇到乱码现象,而出现乱码的原因是多种多样的。这里试图从根源上理解乱码,并将其归类。
一般,我们编写和执行程序的流程如下:
- 编写代码并保存;
- 调用编译器编译代码,并执行程序;
- 查看输出结果。
在这短短的三步操作中,隐含着两次编码和解码过程,也就是下图中的过程 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 不变。执行结果将发生乱码,如下:
从这里我们也可以看出,Python 解释器的默认输入编码为 UTF-8。
过程 2 乱码。
这里又分为两种情况,一是编译器的输出编码错误;二是展示器的输入编码错误:
编译器输出编码不当。
打开 Python.sublime-build
文件(可借助 PackageResourceViewer 插件),其初始内容如下:
1 | { |
我们把末尾的行改为 "env": {"PYTHONIOENCODING": "gbk"},
,即把 Python 解释器的输出编码设为 UTF-8。执行脚本,再次得到乱码,如下:
注意:这里虽然也是乱码,但与类型 1 不同。
展示器输入编码不当。
我们首先撤销对 Python.sublime-build
的所有更改,然后在其末尾增加一行内容 "encoding": "gbk",
,即把 Sublime Text 控制台的编码设为 GBK。此时 Python.sublime-build
配置如下:
1 | { |
执行脚本,得到乱码,如下:
注意:这里的乱码与类型 1 相同,都是用 GBK 编码解释 UTF-8 字符串造成的。
过程 1 与过程 2 同时乱码。
乱码是可以叠加的,即乱码后的字符串可以再次被乱码,得到的乱码与叠加前的乱码均不同。
我们让 Python.sublime-build
文件保持上一步的状态,然后在 Python 脚本的开头重新加上一行 # -*- coding: gbk -*-
。执行脚本,会得到前两种完全不同的乱码,如下:
以上就是编程中出现乱码的 3 种典型情况。需要指出的是,以上采用 Sublime Text 的控制台作为展示器,其编码可以通过 Build System 中的 encoding
参数进行设置。如果你直接使用命令行如 cmd、bash、cmder 等来编译和运行程序,那就完全省去这些麻烦了,命令行一般会自动识别你的输出编码,因此总能使用正确解码方式,基本不会出现类型 2 乱码,但无法避免类型 1 乱码。
希望本文对你有所启发,如果你在编程中遇到了乱码,不妨对下图中的 2 个过程进行控制变量式的排除,如果能够解决你的问题,那便是本文最大的成功。