编码
1. 什么是编码?
2. 常见的编码格式
3. Python 中的编码与解码
1. 什么是编码?
为什么要编码?
不知道大家有没有想过一个问题,那就是为什么要编码?我们能不能不编码?要回答这个问题必须要回到计算机是如何表示我们人类能够理解的符号的问题,这些符号也就是我们人类使用的语言。
由于人类的语言有太多,因而表示这些语言的符号太多,无法用计算机中一个基本的存储单元—— byte 来表示,因而必须要经过拆分或一些翻译工作,才能让计算机能理解。
我们可以把计算机能够理解的语言假定为英语,其它语言要能够在计算机中使用必须经过一次翻译,把它翻译成英语,这个翻译的过程就是编码。所以可以想象只要不是说英语的国家要能够使用计算机就必须要经过编码。
这看起来有些霸道,但是这就是现状,这也和我们国家现在在大力推广汉语一样,希望其它国家都会说汉语,以后其它的语言都翻译成汉语,我们可以把计算机中存储信息的最小单位改成汉字,这样我们就不存在编码问题了。
所以总的来说,编码的原因可以总结为:
- 计算机中存储信息的最小单元是一个字节即 8 个 bit,所以能表示的字符范围是 0~255 个;
- 人类要表示的符号太多,无法用一个字节来完全表示;
- 要解决这个矛盾必须需要一个新的数据结构 char,从 char 到 byte 必须编码。
如何“翻译”?
明白了各种语言需要交流,经过翻译是必要的,那又如何来翻译呢?计算中提拱了多种翻译方式,常见的有 ASCII、ISO-8859-1、GB2312、GBK、UTF-8、UTF-16 等。它们都可以被看作为字典,它们规定了转化的规则,按照这个规则就可以让计算机正确的表示我们的字符。
目前的编码格式很多,例如 GB2312、GBK、UTF-8、UTF-16 这几种格式都可以表示一个汉字,那我们到底选择哪种编码格式来存储汉字呢?这就要考虑到其它因素了,是存储空间重要还是编码的效率重要,根据这些因素来正确选择编码格式。
2. 常见的编码格式
ASCII 编码
我们知道,计算机内部,所有信息最终都是一个二进制值。每一个二进制位(bit)有0和1两种状态,因此八个二进制位就可以组合出256种状态,这被称为一个字节(byte)。也就是说,一个字节一共可以用来表示256种不同的状态,每一个状态对应一个符号,就是256个符号,从00000000到11111111。
上个世纪60年代,美国制定了一套字符编码,对英语字符与二进制位之间的关系,做了统一规定。这被称为 ASCII 码,一直沿用至今。
ASCII 码一共规定了128个字符的编码,用一个字节的低 7 位表示,0~31 是控制字符如换行回车删除等;32~126 是打印字符,可以通过键盘输入并且能够显示出来,比如空格 SPACE 是 32(二进制00100000),大写的字母 A 是 65(二进制01000001)。
这 128 个符号(包括32个不能打印出来的控制符号),只占用了一个字节的后面7位,最前面的一位统一规定为0。
非 ASCII 编码
英语用 128 个符号编码就够了,但是用来表示其他语言,128 个符号是不够的。比如,在法语中,字母上方有注音符号,它就无法用 ASCII 码表示。于是,一些欧洲国家就决定,利用字节中闲置的最高位编入新的符号。比如,法语中的 é 的编码为 130(二进制10000010)。这样一来,这些欧洲国家使用的编码体系,可以表示最多 256 个符号。
但是,这里又出现了新的问题。不同的国家有不同的字母,因此,哪怕它们都使用256个符号的编码方式,代表的字母却不一样。比如,130 在法语编码中代表了 é,在希伯来语编码中却代表了字母 Gimel (ג),在俄语编码中又会代表另一个符号。但是不管怎样,所有这些编码方式中,0--127 表示的符号是一样的,不一样的只是 128--255 的这一段。
至于亚洲国家的文字,使用的符号就更多了,汉字就多达10万左右。一个字节只能表示 256 种符号,肯定是不够的,就必须使用多个字节表达一个符号。比如,简体中文常见的编码方式是 GB2312,使用两个字节表示一个汉字,所以理论上最多可以表示 256 x 256 = 65536 个符号。
虽然都是用多个字节表示一个符号,但是GB类的汉字编码与后文的 Unicode 和 UTF-8 是毫无关系的。
ISO-8859-1
128 个字符显然是不够用的,于是 ISO 组织在 ASCII 码基础上又制定了一些列标准用来扩展 ASCII 编码,它们是 ISO-8859-1~ISO-8859-15,其中 ISO-8859-1 涵盖了大多数西欧语言字符,所有应用的最广泛。ISO-8859-1 仍然是单字节编码,它总共能表示 256 个字符。
GB2312
它的全称是《信息交换用汉字编码字符集 基本集》,它是双字节编码,总的编码范围是 A1-F7,其中从 A1-A9 是符号区,总共包含 682 个符号,从 B0-F7 是汉字区,包含 6763 个汉字。
GBK
全称叫《汉字内码扩展规范》,是国家技术监督局为 windows95 所制定的新的汉字内码规范,它的出现是为了扩展 GB2312,加入更多的汉字,它的编码范围是 8140~FEFE(去掉 XX7F)总共有 23940 个码位,它能表示 21003 个汉字,它的编码是和 GB2312 兼容的,也就是说用 GB2312 编码的汉字可以用 GBK 来解码,并且不会有乱码。
GB18030
全称是《信息交换用汉字编码字符集》,是我国的强制标准,它可能是单字节、双字节或者四字节编码,它的编码与 GB2312 编码兼容,这个虽然是国家标准,但是实际应用系统中使用的并不广泛。
Unicode
如上所说,世界上存在着多种编码方式,同一个二进制数字可以被解释成不同的符号。因此,要想打开一个文本文件,就必须知道它的编码方式,否则用错误的编码方式解读,就会出现乱码。为什么电子邮件常常出现乱码?就是因为发信人和收信人使用的编码方式不一样。
可以想象,如果有一种编码,将世界上所有的符号都纳入其中。每一个符号都给予一个独一无二的编码,那么乱码问题就会消失。这就是 Unicode(Universal Code 统一码),就像它的名字都表示的,这是一种所有符号的编码。
Unicode 当然是一个很大的集合,现在的规模可以容纳 100 多万个符号。每个符号的编码都不一样,比如,U+0639 表示阿拉伯字母 Ain,U+0041 表示英语的大写字母 A,U+4E25 表示汉字严。具体的符号对应表,可以查询unicode.org,或者专门的(中日韩)汉字对应表。
Unicode 的问题
需要注意的是,Unicode 只是一个符号集,它只规定了符号的二进制代码,却没有规定这个二进制代码应该如何存储。
比如,汉字“严”的 Unicode 是十六进制数 4E25,转换成二进制数足足有 15 位(100111000100101),也就是说,这个符号的表示至少需要 2 个字节。表示其他更大的符号,可能需要 3 个字节或者 4 个字节,甚至更多。
这里就有两个严重的问题:
- 如何才能区别 Unicode 和 ASCII ?计算机怎么知道三个字节表示一个符号,而不是分别表示 3 个符号呢?
- 我们已经知道,英文字母只用 1 个字节表示就够了,如果 Unicode 统一规定,每个符号用 3 个或 4 个字节表示,那么每个英文字母前都必然有 2 到 3 个字节是 0,这对于存储来说是极大的浪费,文本文件的大小会因此大出二三倍,这是无法接受的。
它们造成的结果是:
- 出现了 Unicode 的多种存储方式,也就是说有许多种不同的二进制格式,可以用来表示 Unicode。
- Unicode 在很长一段时间内无法推广,直到互联网的出现。
UTF-8
互联网的普及,强烈要求出现一种统一的编码方式。UTF-8 就是在互联网上使用最广的一种 Unicode 的实现方式。其他实现方式还包括 UTF-16(字符用两个字节或四个字节表示)和 UTF-32(字符用四个字节表示),不过在互联网上基本不用。重复一遍,这里的关系是,UTF-8 是 Unicode 的实现方式之一。
UTF-8 最大的一个特点,就是它是一种变长的编码方式。它可以使用 1~6 个字节表示一个符号,根据不同的符号而变化字节长度。
UTF-8 的编码规则很简单,只有二条:
- 对于单字节的符号,字节的第一位设为0,后面7位为这个符号的 Unicode 码。因此对于英语字母,UTF-8 编码和 ASCII 码是相同的。
- 对于n字节的符号(n > 1),第一个字节的前n位都设为1,第 n+1 位设为 0,后面字节的前两位一律设为 10。剩下的没有提及的二进制位,全部为这个符号的 Unicode 码。
从下面的表格可以看出,ASCII 编码实际上可以被看成是 UTF-8 编码的一部分,所以,大量只支持 ASCII 编码的历史遗留软件可以在 UTF-8 编码下继续工作。
字符 | ASCII | Unicode | UTF-8 |
---|---|---|---|
A | 01000001 | 00000000 01000001 | 01000001 |
中 | x | 01001110 00101101 | 11100100 10111000 10101101 |
现在,捋一捋 ASCII 编码和 Unicode 编码的区别:ASCII 编码是 1 个字节,而 Unicode 编码通常是 2 个字节。
如果统一成 Unicode 编码,乱码问题从此消失了。但是,如果你写的文本基本上全部是英文的话,用 Unicode 编码比 ASCII 编码需要多一倍的存储空间,在存储和传输上就十分不划算。
所以,本着节约的精神,又出现了把 Unicode 编码转化为“可变长编码”的 UTF-8 编码。UTF-8 编码把一个 Unicode 字符根据不同的数字大小编码成 1-6 个字节,常用的英文字母被编码成 1 个字节,汉字通常是 3 个字节,只有很生僻的字符才会被编码成 4-6 个字节。如果你要传输的文本包含大量英文字符,用 UTF-8 编码就能节省空间。
下表总结了编码规则,其中字母 x 表示可用编码的位:
根据上表,解读 UTF-8 编码非常简单:
- 如果一个字节的第一位是 0,则这个字节单独就是一个字符;
- 如果第一位是 1,则连续有多少个 1,就表示当前字符占用多少个字节。
下面,还是以汉字“严”为例,演示如何实现 UTF-8 编码:
- “严”的 Unicode 是 4E25(100111000100101),根据上表,可以发现4E25处在第三行的范围内(0000 0800 - 0000 FFFF),因此严的 UTF-8 编码需要三个字节,即格式是 1110xxxx 10xxxxxx 10xxxxxx。
- 然后,从“严”的最后一个二进制位开始,依次从后向前填入格式中的 x,多出的位补 0。这样就得到了,“严”的 UTF-8 编码是 11100100 10111000 10100101,转换成十六进制就是 E4B8A5。
Unicode 和 UTF-8 之间的转换
通过上述例子,可以看到“严”的 Unicode 码是 4E25,UTF-8 编码是 E4B8A5,两者是不一样的。它们之间的转换可以通过程序实现。
Windows 平台,有一个最简单的转化方法,就是使用内置的记事本小程序 notepad.exe。打开文件后,点击文件菜单中的另存为命令,会跳出一个对话框,在最底部有一个编码的下拉条:
里面有四个选项,分别是 ANSI,Unicode,Unicode big endian 和 UTF-8:
- ANSI 是默认的编码方式。对于英文文件是 ASCII 编码,对于简体中文文件是 GB2312 编码(只针对 Windows 简体中文版,如果是繁体中文版会采用 Big5 码)。
- Unicode 编码这里指的是 notepad.exe 使用的 UCS-2 编码方式,即直接用两个字节存入字符的 Unicode 码,这个选项用的 little endian 格式。
- Unicode big endian 编码与上一个选项相对应。在下一节会解释 little endian 和 big endian 的涵义。
- UTF-8 编码,也就是上一节谈到的编码方法。
选择完“编码方式”后,点击"保存"按钮,文件的编码方式就立刻转换好了。
Little endian 和 Big endian
上一节已经提到,UCS-2 格式可以存储 Unicode 码(码点不超过0xFFFF)。以汉字严为例,Unicode 码是 4E25,需要用两个字节存储,一个字节是 4E,另一个字节是 25。存储的时候,4E 在前,25 在后,这就是 Big endian 方式;25在前,4E在后,这是 Little endian 方式。
这两个古怪的名称来自英国作家斯威夫特的《格列佛游记》。在该书中,小人国里爆发了内战,战争起因是人们争论,吃鸡蛋时究竟是从大头(Big-endian)敲开还是从小头(Little-endian)敲开。为了这件事情,前后爆发了六次战争,一个皇帝送了命,另一个皇帝丢了王位。
第一个字节在前,就是"大头方式"(Big endian),第二个字节在前就是"小头方式"(Little endian)。那么很自然的,就会出现一个问题:计算机怎么知道某一个文件到底采用哪一种方式编码?
Unicode 规范定义,每一个文件的最前面分别加入一个表示编码顺序的字符,这个字符的名字叫做"零宽度非换行空格"(zero width no-break space),用 FE FF 表示。这正好是两个字节,而且 FF 比 FE 大 1。
- 如果一个文本文件的头两个字节是 FE FF,就表示该文件采用大头方式;
- 如果头两个字节是 FF FE,就表示该文件采用小头方式。
下面,举一个实例:
打开"记事本"程序 notepad.exe,新建一个文本文件,内容就是一个严字,依次采用 ANSI、Unicode、Unicode big endian 和 UTF-8 编码方式保存。
然后,用文本编辑软件 UltraEdit 中的“十六进制功能”,观察该文件的内部编码方式。
- ANSI:文件的编码就是两个字节 D1 CF,这正是严的 GB2312 编码,这也暗示 GB2312 是采用大头方式存储的。
- Unicode:编码是四个字节 FF FE 25 4E,其中 FF FE 表明是小头方式存储,真正的编码是 4E25。
- Unicode big endian:编码是四个字节 FE FF 4E 25,其中 FE FF 表明是大头方式存储。
- UTF-8:编码是六个字节 EF BB BF E4 B8 A5,前三个字节 EF BB BF 表示这是UTF-8编码,后三个 E4B8A5 就是严的具体编码,它的存储顺序与编码顺序是一致的。
3. Python 中的编码与解码
计算机系统通用的字符编码工作方式
搞清楚了 ASCII、Unicode 和 UTF-8 的关系,我们就可以总结一下目前的计算机系统通用的字符编码工作方式:
1)在计算机内存中,统一使用 Unicode 编码,当需要保存到硬盘或者需要传输的时候,就转换为 UTF-8 编码。
从上图可以看出不同字节编码之间是可以通过 Unicode 来实现相互转换的。
- 编码(encode):在 Unicode 中,每一个字符都有一个唯一的数字表示,那么将 Unicode 字符串转换为特定字符编码(ASCII、UTF-8、GBK)对应的字节串的过程和规则就是编码。
- 解码(decode):将特定字符编码(ASCII、UTF-8、GBK)的字节串转换为对应的 Unicode 字符串的过程和规则就是解码。
简单理解:编码是给计算机底层用的,解码是显示给人看的。
编码的常见应用场景
涉及到编码的地方一般都在字符到字节或者字节到字符的转换上,而需要这种转换的场景主要是在操作 I/O 的时候,这个 I/O 包括磁盘 I/O 和网络 I/O(以 Web 应用为例介绍)。
1)如下图所示,用记事本编辑的时候,从文件读取的 UTF-8 字符被转换为 Unicode 字符到内存里,编辑完成后,保存的时候再把 Unicode 转换为 UTF-8 保存到文件。
2)如下图所示,浏览网页的时候,服务器会把动态生成的 Unicode 内容转换为 UTF-8 再传输到浏览器。
所以我们会看到很多网页的源码上会有类似<meta charset="UTF-8" />
的信息,表示该网页正是用的 UTF-8 编码。
Python 源代码文件的执行过程
我们都知道,磁盘上的文件都是以二进制格式存放的,其中文本文件都是以某种特定编码的字节形式存放的。对于程序源代码文件的字符编码是由编辑器指定的,比如我们使用 Pycharm 来编写 Python 程序时会指定工程编码和文件编码为 UTF-8,那么 Python 代码被保存到磁盘时就会被转换为 UTF-8 编码对应的字节(encode过程)后写入磁盘。
当执行 Python 代码文件中的代码时,Python 解释器在读取 Python 代码文件中的字节串之后,需要将其转换为 Unicode 字符串(decode 过程)之后才执行后续操作。
python 中的默认编码
如果我们没有在代码文件指定字符编码,Python 解释器会使用哪种字符编码把从代码文件中读取到的字节转换为 Unicode 字符串呢?就像我们配置某些软件时,有很多默认选项一样,需要在 Python 解释器内部设置默认的字符编码来解决这个问题,这就是“默认编码”。
Python2 和 Python3 的解释器使用的默认编码是不一样的,我们可以通过 sys.getdefaultencoding() 来获取默认编码:
1 >>> # Python2 2 >>> import sys 3 >>> sys.getdefaultencoding() 4 'ascii' 5 6 >>> # Python3 7 >>> import sys 8 >>> sys.getdefaultencoding() 9 'utf-8'
- 对于 Python2 来讲,Python 解释器在读取到中文字符的字节码时,会先查看当前代码文件头部是否指明字符编码是什么。如果没有指定,则使用默认字符编码 ASCII 进行解码,导致中文字符解码失败。
- 对于 Python3 来讲,执行过程是一样的,只是 Python3 的解释器以 UTF-8 作为默认编码,但是这并不表示可以完全兼容中文问题。比如我们在 Windows 上进行开发时,Python 工程及代码文件都使用的是默认的 GBK 编码,也就是说 Python 代码文件是被转换成 GBK 格式的字节码保存到磁盘中的。Python3 的解释器执行该代码文件时,试图用 UTF-8 进行解码操作时,同样会解码失败。
Python2/3 对字符串的支持
Python2
Python2中对字符串的支持由以下三个类提供:
class basestring(object) class str(basestring) class unicode(basestring)
str 和 unicode 都是 basestring 的子类。严格意义上说:
- str 其实是字节串,它是 unicode 经过编码后的字节组成的序列。
- 对 UTF-8 编码的 str “汉”使用 len() 函数时,结果是 3,因为 UTF-8 编码的“汉” == “\xE6\xB1\x89”。
- unicode 才是真正意义上的字符串,对字节串 str 使用正确的字符编码进行解码后获得,并且 len(u'汉') == 1。
- 从上图中也可看出,ASCII 编码实际上可以被看成是 UTF-8 编码的一部分。
因此,Python2 中的字符串进行字符编码转换过程是:
- 字节串(Python2的str默认是字节串) --> decode('原来的字符编码') --> Unicode字符串 --> encode('新的字符编码') --> 字节串
1 #!/usr/bin/env python2 2 #-*- coding:utf-8 -*- 3 4 a = '你好' 5 b = u'你好' 6 print(type(a), len(a)) # output:(<type'str'>, 6) 7 print(type(b), len(b)) # output:(<type'unicode'>, 2) 8 9 utf_8_a = '我爱中国' 10 gbk_a = utf_8_a.decode('utf-8').encode('gbk') 11 print(gbk_a.decode('gbk')) # 输出结果:我爱中国
Python3
Python3 中对字符串的支持进行了实现类层次的上简化,去掉了 unicode 类,添加了一个 bytes 类。从表面上来看,可认为 Python3 中的 str 和 unicode 合二为一了。
class bytes(object) class str(object)
实际上,Python3 中已经意识到之前的错误,开始明确区分字符串与字节,因此 Python3 中的 str 已经是真正的字符串,而字节是用单独的 bytes 类来表示。
也就是说,Python3 默认定义的就是字符串,实现了对 Unicode 的内置支持,减轻了程序员对字符串处理的负担。
1 a = '你好' 2 b = u'你好' 3 c = '你好'.encode('gbk') 4 print(type(a), len(a)) # output:<class'str'> 2 5 print(type(b), len(b)) # output:<class'str'> 2 6 print(type(c), len(c)) # output:<class'bytes'> 4
由于 Python3 中定义的字符串默认就是 unicode,因此不需要先解码,可以直接编码成新的字符编码:
- 字符串(str 就是 Unicode 字符串) --> encode('新的字符编码') --> 字节串
Python 中的字符编码转换函数
对于单个字符的编码,Python 提供了 ord() 函数获取字符的整数表示,chr() 函数把编码转换为对应的字符:
>>> ord("A") 65 >>> ord("Z") 90 >>> chr(97) 'a' >>> chr(122) 'z' >>> ord("中") 20013
如果知道字符的整数编码,还可以用十六进制这么写 str:
>>> '\u4e2d\u6587' '中文'
两种写法完全是等价的。
由于 Python 的字符串类型是 str,在内存中以 Unicode 表示,一个字符对应若干个字节。如果要在网络上传输,或者保存到磁盘上,就需要把 str 变为以字节为单位的 bytes。
Python 对 bytes 类型的数据用带 b 前缀的单引号或双引号表示,例如 x = b'ABC' 。要注意区分 'ABC' 和 b'ABC',前者是 str,后者虽然内容显示得和前者一样,但 bytes 的每个字符都只占用一个字节。
以 Unicode 表示的 str 通过 encode() 方法可以编码为指定的 bytes,例如:
1 >>> 'ABC'.encode('ascii') 2 b'ABC' 3 >>> '中文'.encode('utf-8') 4 b'\xe4\xb8\xad\xe6\x96\x87' 5 >>> '中文'.encode('ascii') 6 Traceback (most recent call last): 7 File "<stdin>", line 1, in <module> 8 UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)
检查编码格式
1 >>> import chardet 2 >>> 3 >>> chardet.detect("中国".encode("utf-8")) 4 {'encoding': 'utf-8', 'confidence': 0.7525, 'language': ''} 5 >>> chardet.detect("中国".encode("gbk")) 6 {'encoding': 'IBM855', 'confidence': 0.7679697235616183, 'language': 'Russian'} 7 >>> 8 >>> # 以二进制格式打开文件 9 >>> with open("e:\\pra_file.txt", "rb") as f: 10 ... chardet.detect(f.read()) 11 ... 12 {'encoding': 'utf-8', 'confidence': 0.99, 'language': ''}