说说 Python 字符编码的二三事

2017-02-08 23:51:10 +08:00
 lzjun

不论你是有着多年经验的 Python 老司机还是刚入门 Python 不久的新贵,你一定遇到过 UnicodeEncodeError 、 UnicodeDecodeError 错误,每当遇到错误我们就拿着 encode 、 decode 函数翻来覆去的转换,有时试着试着问题就解决了,有时候怎么试都没辙,只有借用 Google 大神帮忙,但似乎很少去关心问题的本质是什么,下次遇到类似的问题重蹈覆辙,那么你有没有想过一次性彻底把 Python 字符编码给搞懂呢?

完全理解字符编码 与 Python 的渊源前,我们有必要把一些基础概念弄清楚,虽然有些概念我们每天都在接触甚至在使用它,但并不一定真正理解它。比如:字节、字符、字符集、字符码、字符编码。

字节

字节( Byte )是计算机中数据存储的基本单元,一字节等于一个 8 位的比特,计算机中的所有数据,不论是保存在磁盘文件上的还是网络上传输的数据(文字、图片、视频、音频文件)都是由字节组成的。

字符

你正在阅读的这篇文章就是由很多个字符( Character )构成的,字符一个信息单位,它是各种文字和符号的统称,比如一个英文字母是一个字符,一个汉字是一个字符,一个标点符号也是一个字符。

字符集

字符集( Character Set )就是某个范围内字符的集合,不同的字符集规定了字符的个数,比如 ASCII 字符集总共有 128 个字符,包含了英文字母、阿拉伯数字、标点符号和控制符。而 GB2312 字符集定义了 7445 个字符,包含了绝大部分汉字字符。

字符码

字符码( Code Point )指的是字符集中每个字符的数字编号,例如 ASCII 字符集用 0-127 连续的 128 个数字分别表示 128 个字符,例如 "A" 的字符码编号就是 65 。

字符编码

字符编码( Character Encoding )是将字符集中的字符码映射为字节流的一种具体实现方案,常见的字符编码有 ASCII 编码、 UTF-8 编码、 GBK 编码等。某种意义上来说,字符集与字符编码有种对应关系,例如 ASCII 字符集对应 有 ASCII 编码。 ASCII 字符编码规定使用单字节中低位的 7 个比特去编码所有的字符。例如"A" 的编号是 65 ,用单字节表示就是 0×41 ,因此写入存储设备的时候就是 b'01000001'。

编码、解码

编码的过程是将字符转换成字节流,解码的过程是将字节流解析为字符。


理解了这些基本的术语概念后,我们就可以开始讨论计算机的字符编码的演进过程了。

从 ASCII 码说起

说到字符编码,要从计算机的诞生开始讲起,计算机发明于美国,在英语世界里,常用字符非常有限, 26 个字母(大小写)、 10 个数字、标点符号、控制符,这些字符在计算机中用一个字节的存储空间来表示绰绰有余,因为一个字节相当于 8 个比特位, 8 个比特位可以表示 256 个符号。于是美国国家标准协会 ANSI 制定了一套字符编码的标准叫 ASCII(American Standard Code for Information Interchange),每个字符都对应唯一的一个数字,比如字符 "A" 对应数字是 65 ,"B" 对应 66 ,以此类推。最早 ASCII 只定义了 128 个字符编码,包括 96 个文字和 32 个控制符号,一共 128 个字符只需要一个字节的 7 位就能表示所有的字符,因此 ASCII 只使用了一个字节的后 7 位,剩下最高位 1 比特被用作一些通讯系统的奇偶校验。下图就是 ASCII 码字符编码的十进制、二进制和字符的对应关系表

扩展的 ASCII : EASCII(ISO/8859-1)

然而计算机慢慢地普及到其他西欧地区时,发现还有很多西欧字符是 ASCII 字符集中没有的,显然 ASCII 已经没法满足人们的需求了,好在 ASCII 字符只用了字节的 7 位 0×00~0x7F 共 128 个字符,于是他们在 ASCII 的基础上把原来的 7 位扩充到 8 位,把 0×80-0xFF 这后面的 128 个数字利用起来,叫 EASCII ,它完全兼容 ASCII ,扩展出来的符号包括表格符号、计算符号、希腊字母和特殊的拉丁符号。然而 EASCII 时代是一个混乱的时代,各个厂家都有自己的想法,大家没有统一标准,他们各自把最高位按照自己的标准实现了自己的一套字符编码标准,比较著名的就有 CP437, CP437 是 始祖 IBM PC 、 MS-DOS 使用的字符编码,如下图:

众多的 ASCII 扩充字符集之间互不兼容,这样导致人们无法正常交流,例如 200 在 CP437 字符集表示的字符是 È ,在 ISO/8859-1 字符集里面显示的就是 ╚,于是国际标准化组织( ISO )及国际电工委员会( IEC )联合制定的一系列 8 位字符集的标准**ISO/8859-1(Latin-1)**,它继承了 CP437 字符编码的 128-159 之间的字符,所以它是从 160 开始定义的, ISO-8859-1 在 CP437 的基础上重新定义了 160~255 之间的字符。

多字节字符编码 GBK

ASCII 字符编码是单字节编码,计算机进入中国后面临的一个问题是如何处理汉字,对于拉丁语系国家来说通过扩展最高位,单字节表示所有的字符已经绰绰有余,但是对于亚洲国家来说一个字节就显得捉襟见肘了。于是中国人自己弄了一套叫 GB2312 的双字节字符编码,又称 GB0 , 1981 由中国国家标准总局发布。 GB2312 编码共收录了 6763 个汉字,同时他还兼容 ASCII , GB 2312 的出现,基本满足了汉字的计算机处理需要,它所收录的汉字已经覆盖中国大陆 99.75%的使用频率,不过 GB2312 还是不能 100%满足中国汉字的需求,对一些罕见的字和繁体字 GB2312 没法处理,后来就在 GB2312 的基础上创建了一种叫 GBK 的编码, GBK 不仅收录了 27484 个汉字,同时还收录了藏文、蒙文、维吾尔文等主要的少数民族文字。同样 GBK 也是兼容 ASCII 编码的,对于英文字符用 1 个字节来表示,汉字用两个字节来标识。

Unicode 的问世

GBK 仅仅只是解决了我们自己的问题,但是计算机不止是美国人和中国人用啊,还有欧洲、亚洲其他国家的文字诸如日文、韩文全世界各地的文字加起来估计也有好几十万,这已经大大超出了 ASCII 码甚至 GBK 所能表示的范围了,虽然各个国家可以制定自己的编码方案,但是数据在不同国家传输就会出现各种各样的乱码问题。如果只用一种字符编码就能表示地球甚至火星上任何一个字符时,问题就迎刃而解了。是它,是它,就是它,我们的小英雄,统一联盟国际组织提出了 Unicode 编码, Unicode 的学名是"Universal Multiple-Octet Coded Character Set",简称为 UCS 。它为世界上每一种语言的每一个字符定义了一个唯一的字符码, Unicode 标准使用十六进制数字表示,数字前面加上前缀 U+,比如字母『 A 』的 Unicode 编码是 U+0041 ,汉字『中』的 Unicode 编码是 U+4E2D

Unicode 有两种格式: UCS-2 和 UCS-4 。 UCS-2 就是用两个字节编码,一共 16 个比特位,这样理论上最多可以表示 65536 个字符,不过要表示全世界所有的字符显示 65536 个数字还远远不过,因为光汉字就有近 10 万个,因此 Unicode4.0 规范定义了一组附加的字符编码, UCS-4 就是用 4 个字节(实际上只用了 31 位,最高位必须为 0 )。理论上完全可以涵盖一切语言所用的符号。

Unicode 的局限

但是 Unicode 有一定的局限性,一个 Unicode 字符在网络上传输或者最终存储起来的时候,并不见得每个字符都需要两个字节,比如字符“ A “,用一个字节就可以表示的字符,偏偏还要用两个字节,显然太浪费空间了。

第二问题是,一个 Unicode 字符保存到计算机里面时就是一串 01 数字,那么计算机怎么知道一个 2 字节的 Unicode 字符是表示一个 2 字节的字符呢,例如“汉”字的 Unicode 编码是 U+6C49 ,我可以用 4 个 ascii 数字来传输、保存这个字符;也可以用 utf-8 编码的 3 个连续的字节 E6 B1 89 来表示它。关键在于通信双方都要认可。因此 Unicode 编码有不同的实现方式,比如: UTF-8 、 UTF-16 等等。 Unicode 就像英语一样,做为国与国之间交流世界通用的标准,每个国家有自己的语言,他们把标准的英文文档翻译成自己国家的文字,这是实现方式,就像 utf-8 。

具体实现: UTF-8

UTF-8 ( Unicode Transformation Format )作为 Unicode 的一种实现方式,广泛应用于互联网,它是一种变长的字符编码,可以根据具体情况用 1-4 个字节来表示一个字符。比如英文字符这些原本就可以用 ASCII 码表示的字符用 UTF-8 表示时就只需要一个字节的空间,和 ASCII 是一样的。对于多字节( n 个字节)的字符,第一个字节的前 n 为都设为 1 ,第 n+1 位设为 0 ,后面字节的前两位都设为 10 。剩下的二进制位全部用该字符的 unicode 码填充。

以『好』为例,『好』对应的 Unicode 是 597D ,对应的区间是 0000 0800--0000 FFFF ,因此它用 UTF-8 表示时需要用 3 个字节来存储, 597D 用二进制表示是: 0101100101111101 ,填充到 1110xxxx 10xxxxxx 10xxxxxx 得到 11100101 10100101 10111101 ,转换成 16 进制是 e5a5bd ,因此『好』的 Unicode 码 U+597D 对应的 UTF-8 编码是 "E5A5BD"。你可以用 Python 代码来验证:

>>> a = u"好"
>>> a
u'\u597d'
>>> b = a.encode('utf-8')
>>> len(b)
3
>>> b
'\xe5\xa5\xbd'

现在总算把理论说完了。再来说说 Python 中的编码问题。 Python 的诞生时间比 Unicode 要早很多, Python2 的默认编码是 ASCII ,正因为如此,才导致很多的编码问题。

>>> import sys
>>> sys.getdefaultencoding()
'ascii'

所以在 Python2 中,源代码文件必须显示地指定编码类型,否则但凡代码中出现有中文就会报语法错误

# coding=utf-8
或者是:
# -*- coding: utf-8 -*-

Python2 字符类型

在 python2 中和字符串相关的数据类型有 str 和 unicode 两种类型,它们继承自 basestring ,而 str 类型的字符串的编码格式可以是 ascii 、 utf-8 、 gbk 等任何一种类型。

对于汉字『好』,用 str 表示时,它对应的 utf-8 编码 是'\xe5\xa5\xbd',对应的 gbk 编码是 '\xba\xc3',而用 unicode 表示时,他对应的符号就是 u'\u597d',与 u"好" 是等同的。

str 与 unicode 的转换

在 Python 中 str 和 unicode 之间是如何转换的呢?这两种类型的字符串之间的转换就是靠 decode 和 encode 这两个函数。 encode 负责将 unicode 编码成指定的字符编码,用于存储到磁盘或传输到网络中。而 decode 方法是根据指定的编码方式解码后在应用程序中使用。

 #从 unicode 转换到 str 用 encode

>>> b  = u'好'
>>> c = b.encode('utf-8')
>>> type(c)
<type 'str'>
>>> c
'\xe5\xa5\xbd'

#从 str 类型转换到 unicode 用 decode

>>> d = c.decode('utf-8')
>>> type(d)
<type 'unicode'>
>>> d
u'\u597d'

UnicodeXXXError 错误的原因

在字符编码转换操作时,遇到最多的问题就是 UnicodeEncodeError 和 UnicodeDecodeError 错误了,这些错误的根本原因在于 Python2 默认是使用 ascii 编码进行 decode 和 encode 操作,例如:

case 1

>>> s = '你好'
>>> s.decode()
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)

当把 s 转换成 unicode 类型的字符串时, decode 方法默认使用 ascii 编码进行解码,而 ascii 字符集中根本就没有中文字符『你好』,所以就出现了 UnicodeDecodeError ,正确的方式是显示地指定 UTF-8 字符编码。

>>> s.decode('utf-8')
u'\u4f60\u597d'

同样地道理,对于 encode 操作,把 unicode 字符串转换成 str 类型的字符串时,默认也是使用 ascii 编码进行编码转换的,而 ascii 字符集找不到中文字符『你好』,于是就出现了 UnicodeEncodeError 错误。

>>> a = u'你好'
>>> a.encode()
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)

case 2

str 类型与 unicode 类型的字符串混合使用时, str 类型的字符串会隐式地将 str 转换成 unicode 字符串,如果 str 字符串是中文字符,那么就会出现 UnicodeDecodeError 错误,因为 python2 默认会使用 ascii 编码来进行 decode 操作。

>>> s = '你好'  # str 类型
>>> y = u'python'  # unicode 类型
>>> s + y    # 隐式转换,即 s.decode('ascii') + u
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)

正确地方式是显示地指定 UTF-8 字符编码进行解码

>>> s.decode('utf-8') +y
u'\u4f60\u597dpython'

乱码

所有出现乱码的原因都可以归结为字符经过不同编码解码在编码的过程中使用的编码格式不一致,比如:

# encoding: utf-8

>>> a='好'
>>> a
'\xe5\xa5\xbd'
>>> b=a.decode("utf-8")
>>> b
u'\u597d'
>>> c=b.encode("gbk")
>>> c
'\xba\xc3'
>>> print c
��

utf-8 编码的字符‘好’占用 3 个字节,解码成 Unicode 后,如果再用 gbk 来解码后,只有 2 个字节的长度了,最后出现了乱码的问题,因此防止乱码的最好方式就是始终坚持使用同一种编码格式对字符进行编码和解码操作。

声明:本文同时发布在微信公众号『一个程序员的微站』( id:VTtalk )公众号主要分享一些自己平常工作中总结的内容,考虑到公众号覆盖范围非常窄,因此发布在 V 站,希望更多的朋友可以看到,感谢你阅读到这里。

4628 次点击
所在节点    Python
23 条回复
PythonAnswer
2017-02-09 00:04:15 +08:00
好文

同样地道理 同样的道理
Newyorkcity
2017-02-09 00:09:10 +08:00
支持。
hizcn
2017-02-09 00:11:42 +08:00
好文!
sylecn
2017-02-09 00:49:39 +08:00
我也写过一篇文章,可以一起参考。

https://blog.emacsos.com/unicode-in-python.html

多出来的内容包括:
- 在 REPL 打印 Unicode 字符
- 常见的需要做编码的场景示范代码
- 系统环境变量对 python 编码的影响
aprilandjan
2017-02-09 01:53:34 +08:00
写的很赞,明了易懂,赞!
cdwyd
2017-02-09 08:29:52 +08:00
写的好清楚,能再补充下 python2 和 3 的不同点吗?主要用 3😁
lzjun
2017-02-09 09:16:51 +08:00
@cdwyd 好的,下次补充一个关于 2 与 3 对字符处理的区别
lzjun
2017-02-09 09:18:20 +08:00
@sylecn 厉害了,我的哥。纯英文,方便老外
keisuu
2017-02-09 09:21:04 +08:00
谢谢,分析全面。学习了
koebehshian
2017-02-09 09:22:48 +08:00
我也研究过字符编码,不过是在 C/C++, Java, JS 中。下面说一下我的理解:
在 C/C++中的 char 类型或 wchar_t 类型,都是“假字符类型”,除了 ASCII 码是靠得住的,超过部分就取决于源文件的编码。
在 Java,JS 中是“真字符类型”,无论源文件用什么编码存储,其内部都是用 UCS-2 存储的,当然也有局限性,就是汉字扩展 B,C,D,E 区的字被当作两个字了。
另外乱不乱码还要看操作系统环境, Windows 设置 Codepage, Linux 默认是 UTF-8.

关于汉字的编码, UTF-8,UTF-16LE 等编码之间都是有规律可以转化的,而要转成 GB,GBK 则没有规律,可以用 iconv 转化
kuntang
2017-02-09 09:32:27 +08:00
@cdwyd 其实 python3 默认编码改成 utf-8 之后,就少了很多 unicodexxxError 的东西了,主要问题集中在 windows 平台下 gbk 编码与 python 的 utf-8 编码转换的时候可能会出现乱码的情况,就像楼主最后一个例子中所说的。
irenicus
2017-02-09 09:34:34 +08:00
谢谢,学习一下
kuntang
2017-02-09 09:35:12 +08:00
@koebehshian Java 遇到的乱码最多的就是刚开始学 javaweb 的时候,各种乱,归根到底还是对字符编码理解不透,只知道所有地方( IDE 的设置, jsp 文件,数据库编码设置,还有浏览器网页等等)保持一致的编码就不会有问题了
est
2017-02-09 10:01:40 +08:00
@kuntang 对的。主要问题是 win 下 cmd 默认编码 gbk (其实是 mbcs )。
cdwyd
2017-02-09 10:01:55 +08:00
@kuntang
3 确实少了很多编码问题,最近升级到 3.6 好像以前 powershell 下乱码的现在也能正常输出
lululau
2017-02-09 10:09:21 +08:00
GBK 并未收录少数民族语言文字,推荐在可能的情况下使用 GB18030 代替 GBK
cszeus
2017-02-09 12:10:11 +08:00
最后一个例子解释得不对吧?觉得应该是 unicode 的 b 通过 gbk 得到了 c ,但是文件声明用 utf-8,导致在 print 的时候,试图用 utf-8 去解码一个 gbk 的字符串,导致了错误
lzjun
2017-02-09 12:16:43 +08:00
@cszeus 谢谢指正
keisuu
2017-02-09 18:21:42 +08:00
请求楼主,爬虫如何避免乱码
TJT
2017-02-09 18:48:24 +08:00
自从不用 Windows 之后,就再也没有遇到过字符集的问题了,除了写爬虫的时候少数网站网页字符集不是用 utf-8 的。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/339142

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX