一文理解字符串编码

作者:Windson Yang
更新时间:May 11, 2018
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明 www.enginego.org。

在打开网页或者文件的时候,你一定会遇过像这样的字符串乱码问题:

É��OÇ��,文件包括了各式各样的编码...

同时或多或少也遇到Unicode, UTF-8, ASCII, Latin-1这些编码术语。编码问题可以说是新人必踩坑,虽然从最后的解决方案来看,可能两三句代码就能解决,但是实际大部分开发者,包括我以前,也没有真正地理解它。原因并不是因为它复杂,而是它涉及了计算机科学中一个常见的问题,理论与工程实现的区别。理论上我们只需要按照A方案就可以解决问题,但是实际上,由于不同语言,不同系统的历史原因,实现的方案就变成多个,许多编程语言的编码实现都不同。所以要真正地理解字符串编码,首先需要了解计算机的一些基础知识,包括字符串如何存储在计算机硬盘中。如果只是希望靠运气来解决或者避开它,反而会在一次次盲目的尝试中浪费更多的时间。如果你不熟悉Python代码的话,完全可以跳过这篇文章所有的代码段,它不会影响你对这篇文章的影响。

  1. 基础术语
  2. 计算机如何存储数据
  3. ASCII编码
  4. GBK编码
  5. Unicode
  6. UTF-8编码
  7. HTML实体编码
  8. URL编码
  9. 常见问题
  10. 总结

基础术语

  1. 字符
  2. 字符串
  3. 键值表
  4. 字符串编码与解码

字符

A B C 天 气 エ ン コ 😁

上面的用空格分割的都是单个字符(Character),它代表对人类能看懂的有意义的语言文字。

字符串

Hello 天气 Hola

字符串(Strings)就是多个字符组成的集合

键值表

一一对应的表,函数

y = x * 2

中每个x都对应着唯一的一个y值,x与y组成的集合就是键值表(Hash Table),例如:

1 -> 2
2 -> 4
3 -> 6

这里的每一个x(1, 2, 3)都有对应的y(2, 4, 6)

字符串编码与解码

编码指将字符串按照一定的模式(按照键值表的转换)转换成二进制数字,然后显示或者存储。如果按照上面的键值表,要把字符串”123”存储起来的话,先要转换成对应的”246”。因为计算机实际存储的是二进制数据,所以计算机实际存储的值是

0010 0100 0110
   2    4    6

与编码相反,字符串解码(decode)就是把二进制数据按照一定的模式转换成字符串显示。

计算机如何存储数据

早期的计算机存储资源非常宝贵。所以计算机科学家们希望用最少的空间来存储字符。同时,计算机是使用二进制存储数据的,无论是文字,图片,数字还是其他数据,都是以数字”0”或者”1”存储起来的。举个例子,如果计算机要存储”BEE”这个字符串,它会先根据一个字母与数字的转换表把字母转换成二进制然后存储。这里我们使用一个简单的对应表叫做EngineGo表,EngineGo表用3位的二进制数字就能表示8种不同的字符。

EngineGo表:

二进制 字符 二进制 字符
000 A 100 E
001 B 101 F
010 C 110 G
011 D 111 H

当我们打开文件编辑器,添加”BEE”这3个字母并保存的时候,计算机会根据EngineGo表存储数据

001 100 100
  B   E   E 

当读取文件的时候,计算机会猜测应该使用哪个表来把二进制数据还原为字符串,如果它猜对了,使用EngineGo表还原的话,就会重新得到”BEE”这个字符串。不过如果计算机猜错了,使用其他键值表来打开这个文件的话,最终可能会报错,也可能是乱码,大部分的乱码都是因为这样而出现。理解了这个之后,其实就很容易理解字符串编码和解码了!

unicode

ASCII编码

计算机最初由西方国家设计以及发展,理所当然他们使用了英文作为常用的字符集,字符集包括大小写字母,数字加上一些标点符号和运算符号大概120个。3位二进制数字只能表达8个不同的字符,明显不够,最简单的解决方案就是使用更多的位来保存,7位能表示128个字符,加多1位用作错误检查。最后选择使用8位来存储字符,称为一个字节。8位数字对应的表就更长了(为了方便阅读把二进制转换成了十进制):

编号 字符 编号 字符
(省略)… 64 @
48 0 65 A
49 1 66(1000010) B
50 2 67 C
51 3 68 D
52 4 69(1000101) E
53 5 70 F
54 6 71 G
55 7 72 H
56 8 73 I
57 9 74 J
58 : 75 K
59 ; 76 L
60 < 77 M
61 = 78 N
62 > 79 O
63 ? (省略)…

早期计算机科学家们统一用这张表作字符串编码,称为ASCII编码。存储和读取也是像使用EngineGo表一样简单。这时候存储”BEE”就会存储为:

1000010 1000101 1000101
      B       E       E

非常简单吧。

GBK编码

ASCII编码只能表示128个字符。遇到中文,常用字就几千个肯定应付不来,其他亚洲语言也遇到这样的问题。所以一开始国内使用的并不是ASCII编码,而是GBK编码。本质其实也一样,一张更大的表,用更多的位来表示字符。GBK的编码方式比较有趣,是可变长度的编码,它为了兼容ASCII编码,使用了单字节编码和双字节编码。 如果遇到一个小于127的字符,那么编码方式就与ASCII表一样,遇到大于127的字符,就用两个字节表示一个汉字。当我们把”BEE”用GBK编码存储的时候,它和使用ASCII表存储的二进制数据是一样的。

1000010 1000101 1000101 (无论使用ASCII编码还是GBK编码都得到这个结果)
      B       E       E

# 以Python2为例,encode是Python中编码的方法
>>> foo = 'BEE'.encode('ascii')
>>> bar = 'BEE'.encode('gb2312')
>>> foo == bar
True
# 虽然bar使用gb2312编码保存,但是因为只包含ASCII中的字符,所以可以用ASCII来解码
>>> bar.decode('ascii')
u'BEE'
>>> u'BEE' == 'BEE'
True

从上面的输出可以看到,如果只是存储ASCII表出现的字符,那么大部分编码表保存的结果都是一样。都能被ASCII解码,因为它们都需要兼容ASCII表

不过如果我们要存储中文的时候,就不一样啦,例如存储”你好”,GBK编码会把这个字符串编码成

11000100 11100011 10111010 11000011

“你”和”好”这两个字符是用两个字节保存的。 这时候使用ASCII编码保存的话就会报错,因为ASCII编码中没有”你”和”好”对应的值:

>>> u'你好'.encode('ascii')
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)

使用gbk编码就没有问题了。

>>> u'你好'.encode('gbk')
'\xc4\xe3\xba\xc3'

要注意这里的’你好’带有’u’前缀,代表这是一个unicode字符串。这与Python语言有关,如果有兴趣可以从常见问题中找到解答。当你使用GBK编码保存文件,而文件里面包含了中文字符,那么别人使用ASCII表就无法解码。不过,如果GBK编码的文件只存储ASCII编码出现的字符,那么解码的时候也能正确解码,其实大多数编码问题都很好解决,只需要在文档的信息上面添加这是用什么编码的,打开的时候选择对应的解码就好,但是当文档中没有包含编码信息的时候,计算机就会猜测这是什么编码。很多国家都有自己的编码表,例如日本使用Shift JIS,韩国使用KS X 1001。即使是中文,还有繁体中文,简体中文不同的类别。即使你知道文档里面存的是中文,也不知道用哪个中文编码才能正确打开。

>>> hello = '你好'
>>> hello_gbk = hello.decode('utf-8').encode("gb2312") # 使用gb2312编码保存
'\xc4\xe3\xba\xc3'
>>> hello_gbk.decode("Shift-JIS") # 使用Shift-JIS解码
u'\uff84\u7fb2\uff83' -> 'ト羲テ' 
>>> hello_gbk.decode('ascii')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xc4 in position 0: ordinal not in range(128)

“你好”经过GBK编码变成4个字节,从这4个字节,计算机无法知道它原本是什么编码的,它尝试用日本的Shift JIS编码来解码,这个情况下没有报错,因为Shift JIS中刚好有这4个字节对应的字符串。所以计算机就会猜这个文件是使用Shift JIS编码保存的。如果我们用ASCII表来解码,就会因为找不到对应的字符串而报错。

Unicode编码

如果你足够聪明的话,最简单解决的方法就是,大家都用统一的表达方式,并且用足够多的二进制位来存储世界上所有字符串。这并不是痴人说梦,Unicode就是为此而生,它把每个国家的每个字符都编进去,最新的版本已经有 136,755个字符串了。例如你好,对应的是 U+4F60 U+597D。不同于之前的其他编码,Unicode不是一种存储方式,只是一个标准,它规定了表现形式,至于如何编码和解码则根据不同的方式,这是什么意思呢?

Unicode中把每个字符串都定义了对应的表现形式:

4f60 597d
   你   好

Unicode特别的地方,它只指定了表示形式,而存储形式则可以根据需要而选择,如果根据我们之前的方法,每个字符都保存4个字节的二进制(把你好保存为00004f60,0000597d),那么这就称为UTF-32编码。很直观,既然能用4个字节表达所有字符串了,这没有任何技术上的问题。如果大家都使用UTF-32进行编码和解码的话其实就已经解决我们上面的问题了。

happy

UTF-8编码

新的问题其实是传输量的问题,原本传输”A”这个字符串,使用UTF-32编码的话需要用4个字节。而使用ASCII则只需要一个字节,如果日常生活只需要用英文,加上以前的存储空间非常贵,网络传输速度也慢。西方的国家存储或者传输中平白无故增加三倍的存储量当然不划算。如果把UTF-8编码和Unicode编码直接保存(UTF-32)作对比,UTF-8为了减少存储量,把常用的字符(例如英文字符)用一个字节来表示(其实就是ASCII编码),其他用两到四个字节表示。

character encoding bits
A ASCII 01000001
A Unicode 10011110 01000001(U+0041)
A UTF-32 00000000 00000000 00000000 01000001
A UTF-8 01000001
ASCII 无法表示
Unicode 1001111 01100000(U+4f60)
UTF-32 00000000 00000000 10011110 01100000
UTF-8 11100100 10111101 10100000(e4bda0)

从上表可以看到,使用UTF-8编码之后,”A”还是只需要一个字节存储,而汉字”你”从4个字节减少为3个字节。把Unicode编码转换成UTF-8编码非常简单,我们首先需要一张转换表:

Unicode范围 转换规则
0x00000000 - 0x0000007F 0xxxxxxx
0x00000080 - 0x000007FF 110xxxxx 10xxxxxx
0x00000800 - 0x0000FFFF 1110xxxx 10xxxxxx 10xxxxxx
0x00010000 - 0x001FFFFF 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
  1. 找出要转换的字符的Unicode范围,我们以”你”为例子,它的Unicode编码是U+4f60,对应的是第三列(07FF<4f60<FFFF),找到右边的转换规则。
  2. 把4f60的二进制1001111 01100000从右到左填入转换规则的x中,空的填0。1001111 01100000 -> 1110xxxx 10xxxxxx 10xxxxxx -> 11100100 10111101 10100000
  3. 最后得到的11100100 10111101 10100000(e4bda0)也就是”你”的UTF-8编码了。

很可惜,大部分国内的在线转码工具都把Unicode和UTF-8混淆了,如果你尝试汉字转换成UTF-8编码,工具返回的是它的HTML实体编码。

example example2

HTML实体编码

编码在其他地方也有非常多的应用,日常接触到的还有HTML实体编码,什么时候需要用到HTML实体编码呢?你可以尝试新建一个后缀为.html的文件,内容为

<!doctype html>
<html>
    <p></div></p>
</html>

保存,然后打开。奇怪,怎么没有把”</div>“这个字符串显示出来呢?😱😱,因为浏览器把”</div>“中的”<“和”>“当成标签的开始和结束解析。要解决这个问题呢?只需要把文件内容更改为:

<!doctype html>
<html>
    <p>&#60;/div&#62;</p>
</html>

一切正常, :D。浏览器解析HTML的时候会把特殊的字符串理解成非字面的含义,所以当需要显示这些特殊字符串的时候,需要经过下表的转换:

显示结果 描述 实体名称 实体编号
空格 &nbsp; &#160;
< 小于号 &lt; &#60;
> 大于号 &gt; &#62;
& &amp; &#38;
双引号 &quot; &#34;
单引号 &apos; &#39;
重音符 &acute; &#96;
© 版权 &copy; &#169;
® 注册商标 &reg; &#174;
商标 &trade; &#8482;
中文 &#x4F60; &#x4F60;&#20320
中文 &#x597D; &#x597D;&#22909

从上表可以看到,特殊字符串是强制需要转换的,而且实际上,所有字符都可以经过转换表示,只需要用

  • &#x加上其16进制Unicode编码或者
  • &#加上其10进制Unicode编码

作为实体编号即可,浏览器既能解析字符串本身,也能解析其UTF-8编码。

URL编码

URL编码其实也简单易懂,因为RFC3986中规定了URI中不能出现

: / ? # [ ] @ ! $ & ' ( ) * + , ; = 

当要表示这些字符的时候,使用%加上该字符的16进制UTF-8编码来表示,出现非ASCII表的字符也一样,例如在浏览器输入

https://www.example.com/你好

浏览器会自动把不合规定的字符转换成%加UTF-8编码再进行请求(但是在地址栏还是会显示原本的字符”你好”),当你粘贴在文本编辑器的时候就可以看到原本的URL变成:

https://www.example.com/%E4%BD%A0%E5%A5%BD

这里可以看到”你好”使用了它的UTF-8编码表示

常见问题

Python

python2在保存字符串的时候,是直接用终端的默认编码保存的。一般Linux或者苹果系统的默认终端编码是UTF-8,Windows中文版的默认终端编码是cp936 (gbk)。当前终端的默认编码可以通过以下命令查看:

>>> import sys 
>>> sys.stdin.encoding 
'UTF-8' # 'cp936'在Windows系统下

我们可以测试一下:

>>> "你好"
'\xe4\xbd\xa0\xe5\xa5\xbd' # Linux/mac系统使用"你好"的utf-8编码保存
'\xc4\xe3\xba\xc3' # Windows系统使用"你好"的cp936编码保存

‘u’前缀是Python语言的特色,加上’u’前缀的字符串会被当成unicode编码,进行字符串相加的时候要注意,非ASCII编码的字符,unicode编码和utf-8编码不能直接相加:

>>> u'你好' + '费曼'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe8 in position 0: ordinal not in range(128)
>>> u'hello ' + 'world'
u'hello world'

另外一个常见的问题是:

>>> '你好'.encode('gb2312')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128)

留意报错的信息,居然说的是ASCII编码无法解码字符串,为什么呢?其实在Python2中,当它要执行encode方法的时候,需要先把字符串转变成unicode编码,所以:

>>> '你好'.encode('gb2312') # 实际python会这样处理
            |
>>>'你好'.decode('ascii').encode('gb2312') 

解决这个问题也很容易,把’你好’改成u’你好’就可以了。

总结

UTF-8编码是现在标准的解决方案,当遇到乱码或者编码出错的时候,先想想原本数据是用什么编码存储的,然后使用对应的方式解码就好。单纯从二进制数据是无法判断它是用什么编码存储的。