人类使用文本,计算机使用字节序列。
——Esther Nam 和 Travis Fischer
“Character Encoding and Unicode in Python”
文章目录
4.1 字符问题
字符是什么?
在 2015 年,“字符”的最佳定义是 Unicode 字符。因此,从 Python 3 的 str 对象中获取的元素是 Unicode 字符。这相对于python 2的 str 对象中获取的原始字节序列来说无异于更加方便——你看见几个字符就能获取到几个对象。
Unicode 标准把字符的标识和具体的字节表述进行了如下的明确区分。
- 字符的标识,即码位,是 0-1 114 111 的数字(十进制),在 Unicode 标准中以 4-6个十六进制数字表示,而且加前缀“U+”。
- 字符的具体表述取决于所用的编码。编码是在码位和字节序列之间转换时使用的算法。
把码位转换成字节序列的过程是编码;把字节序列转换成码位的过程是解码。
>>> s = '流畅的python'
>>> len(s)
9
>>> b = s.encode('utf8')
>>> b
b'\xe6\xb5\x81\xe7\x95\x85\xe7\x9a\x84python'
>>> len(b)
15
>>> b.decode('utf8')
'流畅的python'
4.2 字节概要
Python 内置了两种基本的二进制序列类型:不可变 bytes 类型和可变 bytearray 类型。bytes 或 bytearray 对象的各个元素是介于 0~255(含)之间的整数,而非单个字符(这与ASCII码十分相似)。然而,二进制序列的切片始终是同一类型的二进制序列,包括长度为 1 的切片。这说明在处理其时务必要将其当成整体来看待。
>>> s = bytes('流畅的python', encoding='utf8')
>>> s
b'\xe6\xb5\x81\xe7\x95\x85\xe7\x9a\x84python'
>>> s[0]
230
>>> s[:1]
b'\xe6'
>>> s_arr = bytearray(s)
>>> s_arr
bytearray(b'\xe6\xb5\x81\xe7\x95\x85\xe7\x9a\x84python')
>>> s_arr[-1:]
bytearray(b'n')
除了格式化方法(format 和 format_map)和几个处理 Unicode 数据的方法(包括casefold、isdecimal、isidentifier、isnumeric、isprintable 和 encode)之外,str 类型的其他方法都支持 bytes 和 bytearray 类型。这意味着,我们可以使用熟悉的字符串方法处理二进制序列,如 endswith、replace、strip、translate、upper等,只有少数几个其他方法的参数是 bytes 对象,而不是 str 对象。
二进制序列有一个特有的方法,fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的),构建二进制序列:
>>> bytes.fromhex('31 4B CE A9') b'1K\xce\xa9'
二进制序列对象还有其本身的构造方法,传入:
-
一个 str 对象和一个 encoding 关键字参数。
-
一个可迭代对象,提供 0~255 之间的数值。
-
一个实现了缓冲协议的对象(如bytes、bytearray、memoryview、array.array);此时,把源对象中的字节序列复制到新建的二进制序列中。
4.3 基本的编解码器
Python 自带了超过 100 种编解码器(codec, encoder/decoder),用于在文本和字节之间相互转换。每个编解码器都有一个名称,如 ‘utf_8’,而且经常有几个别名,如’utf8’、‘utf-8’ 和 ‘U8’。这些名称可以传给open()、str.encode()、bytes.decode() 等函数的 encoding 参数。
>>> for codec in ['latin_1', 'utf_8', 'utf_16']:
... print(codec, 'El Niño'.encode(codec), sep='\t')
...
latin_1 b'El Ni\xf1o'
utf_8 b'El Ni\xc3\xb1o'
utf_16 b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
4.4 处理编解码错误
4.4.1 处理UnicodeEncodeError(编码错误)
我们通过传入errors参数来改变处理错误的方法
>>> msg = '北京2022'
>>> msg.encode('utf8') # utf?格式可以处理任意字符
b'\xe5\x8c\x97\xe4\xba\xac2022'
>>> msg.encode('utf16')
b'\xff\xfe\x17S\xacN2\x000\x002\x002\x00'
>>> msg.encode('cp437') # 'cp437'无法编码中文,默认处理方式'strict'抛出UnicodeEncodeError
Traceback (most recent call last):
File "<input>", line 1, in <module>
File "D:\py\Anaconda\lib\encodings\cp437.py", line 12, in encode
return codecs.charmap_encode(input,errors,encoding_map)
UnicodeEncodeError: 'charmap' codec can't encode characters in position 0-1: character maps to <undefined>
>>> msg.encode('cp437', errors='ignore') # errors='ignore'跳过无法编码的字符
b'2022'
>>> msg.encode('cp437', errors='replace') # errors='replace'将无法编码的字符替换成'?'
b'??2022'
>>> msg.encode('cp437', errors='xmlcharrefreplace') # errors='xmlcharrefreplace'将无法编码的字符替换成 XML 实体。
b'北京2022'
这种方法是可扩展的,详见:参考文档
4.4.2 处理 UnicodeDecodeError(解码错误)
不同的编码方式所得的同一编码,其解码肯定不相同。
乱码字符称为鬼符(gremlin)或 mojibake(文字化け,“变形文本”的日文)。演示了使用错误的编解码器可能出现鬼符或抛出UnicodeDecodeError。
>>> octets = b'Montr\xe9al' # 使用 latin1 编码的“Montréal”
>>> octets.decode('cp1252') # 'cp1252'能正确处理
'Montréal'
>>> octets.decode('iso8859_7') # iso8559_7用以编码希腊文,无法正确处理
'Montrιal'
>>> octets.decode('koi8_r') # koi8_r编码俄文
'MontrИal'
>>> octets.decode('utf_8') # 抛出UnicodeDecodeError错误
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5:
invalid continuation byte
>>> octets.decode('utf_8', errors='replace') # 将无法处理的编码替换成�(码位是 U+FFFD)
'Montr�al'
4.4.5 BOM 有用的鬼符
UTF-16编码在小端设备和大端设备中,编码的储存顺序不同。为了区分,UTF-16 编码在要编码的文本前面加上特殊的不可见字符 ZERO WIDTH NO-BREAK SPACE(U+FEFF)。在小字节序系统中,这个字符编码为 b’\xff\xfe’(十进制数 255, 254)。U+FEFF编码不能被打印,在小字节序编码中,字节序列b’\xff\xfe’ 必定是 ZERO WIDTH NO-BREAK SPACE。
UTF-16 有两个变种:UTF-16LE,显式指明使用小字节序;UTF-16BE,显式指明使用大字节序。如果使用这两个变种,不会生成 BOM。
UTF-8的一大优势是,不管设备使用哪种字节序,生成的字节序列始终一致,因此不需要 BOM。尽管如此,有些应用依旧会在UTF-8文件开头添加BOM。
4.5 处理文本文件
目前处理文本的最佳实践是“Unicode三明治”。
在不同的系统中使用的默认编码(博主的电脑是cp936)不一样,而python如果读取文件时未指定默认编码。这就是说输出可能不如你意。
open('cafe.txt', 'w', encoding='utf_8').write('café')
file = open('cafe.txt')
print(file)
# <_io.TextIOWrapper name='cafe.txt' mode='r' encoding='cp936'>
print(file.read())
# caf茅
file.close()
file = open('cafe.txt', encoding='utf8')
print(file.read())
# café
如果你的代码只运行在你自己的电脑中,那肯定不需要注意编码问题,然而需要在多台设备中或多种场合下运行的代码,一定不能依赖默认编码。打开文件时始终应该明确传入 encoding= 参数,因为不同的设备使用的默认编码可能不同,有时隔一天也会发生变化。
4.6 规范化Unicode字符串
由于Unicode中组合字符的存在,相同的Unicode字面量可能有不同的编码,如
>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False
可以看到,s1, s2打印的结果是一样的,但是表达式s1 == s2
却是不成立的。在 Unicode 标准中,'é’和 ‘e\u0301’ 这样的序列叫“标准等价物”(canonical equivalent),应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。
此时应该使用unicodedata.normalize函数提供的Unicode规范化。其有四种模式。
- NFC 使用最少的码位构成等价的字符串
- NFD 把组合字符分解成基字符和单独的组合字符
- NFKC 和 NFKD 形式中,各个兼容字符会被替换成一个或多个“兼容分解”字符,即便这样有些格式损失,但仍是“首选”表述。二分之一’½’(U+00BD)经过兼容分解后得到的是
三个字符序列 ‘1/2’。
需要注意的是除非特殊情况,否则不用后两种规范化进行储存,原因是其可能会出现数据丢失,而仅将其用于搜索和索引。
4.6.1 大小写折叠
py3新增的str.casefold()方法将会将所有的文本转换为小写。与str.lower()不同,str.casefold()是py对Unicode的专门的解决方案,其进行转换的结果将于前者有些许不同。但是这只占Unicode字符的0.11%(116/110 122)。
4.6.3 极端的规范化:从Unicode到ASCII
利用上面提到的一些函数和方法,我们可以自定义实现一些函数,使得字符串被极端的规范化——甚至使其与最简单的字符集之一,ASCII码对应。
4.7 对Unicode排序
原生python下虽然能对非ASCII码进行排序,但是其设置比较复杂,且容易更改全局的字符编码方案,条件比较苛刻。
对Unicode排序存在一个第三方库:PyUCA。其能比较方便的实现排序。
>>> import pyuca
>>> coll = pyuca.Collator()
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted_fruits = sorted(fruits, key=coll.sort_key)
>>> sorted_fruits
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
4.8 Unicode数据库
import unicodedata
import re
re_digit = re.compile(r'\d')
sample = '1\xbc\xb2\u0969\u136b\u216b\u2466\u2480\u3285'
for char in sample:
print('U+%04x' % ord(char),
char.center(6),
're_dig' if re_digit.match(char) else '-',
'isdig' if char.isdigit() else '-',
'isnum' if char.isnumeric() else '-',
format(unicodedata.numeric(char), '5.2f'),
unicodedata.name(char),
sep='\t')
声明
本文来自《流畅的python》以及笔者自己的思考,如有错误,欢迎指正。