Python与编码不得不说的故事

在使用python进行自然语言处理(NLP)时, 常常需要接触到中文字符的读写, 不同数据源使用的编码不同, 经常会导致脚本在A数据源上有效而在B数据源上失效, 为了快速的应对这些问题, 我查阅了很多博客, 借鉴了一些经验, 自己总结如下.

1 编码与解码

计算机对数据的存储, 归根结底都是通过二进制来存储, 任何数据最终都会对应到一个二进制串.
将需要存储和表示的字符(汉字或字母), 用一个二进制串来表示的过程, 就称为编码.
编码, 实际上是一种字符到二进制码的映射.
从存储设备中读一个字符, 即将二进制串转化为字符显示在输出设备上的过程, 就称为解码.

一个字节(byte)在计算机中由8位(bit)二进制数表示.
每一位有0,1两种状态, 所以1个字节能表示2^8种状态, 每一种状态对应一个字符, 所以说1个字节能给2^8=256个字符进行编码.

  • ASCII码

ASCII码是为了存储字母和一些简单的字符(比如空格,逗号等)而出现的编码.
当时由于需要编码的字符数量较少, 所以只需要1个字节进行编码就足够了.
实际上, 当时所需要编码的字符数量比256小, 并没有用完8位, 只用了7位, 最高位剩余, 置为0.

例:
字母'A'的ASCII编码为0100 0001, 在解码这个字母时看到一个字节是>0100 0001, 计算机就能够"知道"这个字符是'A'.

ASCII码只对英文进行了编码, 但是由于其他的一些国家和地区的字符都有编码的需求, 于是不同国家在早期都自己进行编码, 国家内部使用还好, 当不同国家的文字(比如中文+英文)出现在同一篇文档里, 就需要使用两种编码, 那整篇文章就无法正常显示了.

例:
字母'A'的ASCII编码为0100 0001, 假设汉字'大'的某种编码也是0100 0001.
当一篇文章中出现 'AA' 这个字符串时, 计算机内部实际存储的是 '0100 0001 0100 0001', 放到中国的机器上是 '大大', 但是在美国的机器上就是 'AA'.

  • Unicode

为了解决不同编码标准冲突的问题, Unicode(统一码)出现了.
它为全世界的字符提供了编码, 为了容纳全世界所有的字符, 可以预计Unicode
将会非常庞大. 事实上目前Unicode的规模已经达到百万级别, 以汉字为例, 汉字对应
的unicode可以在汉字编码表中查到.

我们从汉字编码表中选一个汉字"一"进行分析
对应的Unicode为4E00, 转换为二进制为0100 1110 0000 0000, 它由两个字节(2*8 = 16bit)表示, 如果是更加复杂的汉字, 可能会由3-4个字节表示.

为了兼容ASCII码, 对于ASCII码表中的字符, Unicode采用了相同的编码. 这就导致一个问题: 如果Unicode采用4个字节进行编码, 原来1字节就足够进行编码的字符就有3个字节被浪费掉了 相同内容的英文文档在Unicode编码下的大小就会成倍增加, 这是一个极其严重的缺点.

为了解决这个问题, 就需要使用不同的存储方式来表达Unicode编码.

  • UTF-8

UTF-8就是其中一种Unicode的表现形式. 是一种解决上述问题的方法之一.

UTF-8编码是一种基于Unicode的规则,它通过下列规则, 将原本较长的Unicode变为不同长度的编码,使得所使用的存储空间尽可能的少.

下表总结了UTF-8的编码规则(从Unicode映射到UTF-8)

----Unicode符号范围--- |---------- UTF-8编码方式--------------
--------十六进制----------+-------------------二进制-----------------
------------------------------+---------------------------------------------
0000 0000-0000 007F | 0xxxxxxx
0000 0080-0000 07FF | 110xxxxx 10xxxxxx
0000 0800-0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx
0001 0000-0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx

以ASCII的字母A和一个汉字为例,看UTF-8是如何通过变长编码的形式达到同时兼容ASCII和节省存储空间的.

字母'A'
ASCII编码为0100 0001
Unicode编码为0000 0000 0100 0001(十六进制为0x41)
在上面的表格中查询, 0x41在第一行的范围中, 所以它的编码转为二进制的UTF-8就是 0xxxxxxx 的形式, 其中x指将原Unicode编码从右往左补充上去.
补充上以后, 'A'的UTF-8编码就是01000001

可以看到'A'的UTF-8编码和ASCII编码是一模一样的. 体现出UTF-8对ASCII码的兼容.


汉字'严'
'严'的Unicode编码为100111000100101(十六进制为0x4E25)
在上面的表格中查询, 发现0x4E25在第三行的范围内. 同样的,将非x的部分照搬, 然后将原Unicode编码从右到左填上去, 没填完的补零
则UTF-8编码为 11100100 10111000 10100101
其16进制为0xE4B8A5

[1] '严'的UTF-8编码.png

通过'A'的例子和'严'的例子, 可以看到UTF-8这种变长的编码实现方式, 能够兼容ASCII码, 又能够根据文字的复杂程度进行合理的字节长度选择.
整个过程如下图所示


[2] 从 '严' 到 UTF-8.png

可以直观的认为, UTF-8是对原始Unicode的进一步映射.

2 python2.X与编码

  • python2.x中的类型

为了便于理解,先回顾一下图2:一个字符,从产生到存储到计算机中需要经过两次映射.
第一次映射: 字符→Unicode
第二次映射: Unicode→UTF-8
一个字符要想存储在计算机中, 需要要有一种具体的编码实现(如UTF-8)

在python2.x中, 一个字符可能有两种类型(或者说两种状态), 对应上面所说的两种映射.
即Unicode 和 str, 分别对应第一次映射的结果, 和第二次映射的结果.

[3] 两种字符类型

为什么会有一种Unicode作为中间类型呢?
是为了便于不同编码之间的转换, 比如现在有GB2312, UTF-8, UTF-16, GB18030四种不同编码的字符同时存在, 相互转换就需要构建两两互相转换的规则. 就需要6*2=12种规则, 如果有一种中间编码, 就只需要4*2=8种规则.

  • decode 与 encode

decode 和 encode 是作为上述两种类型(str 和 unicode)转换的工具, 即处理第二次映射.
decode: 将 str 解码 为 unicode.
encode: 将 unicode 编码为 str.

演示通过运用这两个函数将'严'的编码改为GBK

[4] str类型的'严'.png

  1. 因为编码转换必须先经过unicode, 所以先把它解码.


    [5] 解码.png
  2. 有了中间的unicode, 再把它编码为GBK.


    [6] 编码.png
  3. 因为Ipython的控制台是UTF-8编码, 所以输出的时候GBK会乱码.


    [7] 输出.png
  • sys.setdefaultencoding('utf-8')用途

为了完成UTF-8 → GB2312的编码转换, 标准的做法是这样↓


[8] 编码转换.png

正常情况下,如果想直接用encode来编码, 会报错↓


[9]直接encode报错.png

这是因为没有转换成中间编码Unicode的缘故.
如果想直接用encode, 可以加上
import sys 
reload(sys)
sys.setdefaultencoding('utf-8') 

这样可以转换成功, 是因为加了上面的代码后, 相当于默认编码为UTF-8, 省去了显式从UTF-8转为Unicode的这一步, 直接用encode就能够转换(但是原sstr的编码不是UTF-8, 就会报错).


[10] 转换成功.png

3 python3.X与编码

python2中, 有Unicode, str两种类型, str又会有不同的编码, 需要encode和decode, 在编程时候需要时刻记住变量的类型和编码, 虽然能保证work, 但会显得十分混乱.
python3很好的解决了python2中的编码问题.

可以从一个比较宏观的角度来审视编码这个问题. 极端点说, 字符可以分为用和不用两个状态, 不用的时候需要转换成01这种序列存储, 用的时候同样也是需要01, 但是不需要存储.

python3提供的类型就是使用时候的类型和存储时候的类型.
str类型: 全都是Unicode编码
bytes类型(字节流): 将Unicode编码为其他存储时所用的类型(UTF-8, GB2312)等.

[11] python3中的两种类型.png

尝试将'严'字的bytes打印出来, 可以看到和我们上述计算的UTF-8编码是一致的. 这个字如果用UTF-8保存在硬盘中, 如果去观察硬盘里的01序列, 那就是E4B8A5了.


[12] '严'的字节码.png

那该如何证明原字符串是Unicode类型呢?
python3提供了ord()函数查看某一字符串的整数表达(十进制).

[13] str类型'严'的十进制表达.png

转为二进制看看:
[14] str类型'严'的二进制表达.png

在上面1 编码与解码一节中可以看到, 严的Unicode编码就是s 0100111000100101. 可以证明在使用时, 字符串都是Unicode编码.

所以,在python3中, 只需要在读取和存储的时候考虑源文件或目标文件的编码应该是什么就可以了. 在使用过程中(如果不自己用encode编码为字节码的话)基本都是Unicode, 不用像python2中一样考虑各种编码转换的问题.

日常乱码场景

(To be updated)

REF:
字符编码及Python中文处理精解
字符编码笔记:ASCII,Unicode和UTF-8
廖雪峰-字符串和编码
python CGI模块获取中文编码问题解决- 部分方案

推荐阅读更多精彩内容