Python 3 明确区分了人类可读的文本字符串和原始的字节序列。隐式地把字节序列转换成 Unicode 文本已成过去。
深入理解 Unicode 对你可能十分重要,也可能无关紧要,这取决于Python 编程的场景。说到底,本章涵盖的问题对只处理 ASCII 文本的程序员没有影响。但是即便如此,也不能避而不谈字符串和字节序列的区别。此外,你会发现专门的二进制序列类型所提供的功能,有些是Python 2 中“全功能”的 str 类型不具有的。
字符问题
在 2015 年,“字符”的最佳定义是 Unicode 字符。因此,从 Python 3 的str 对象中获取的元素是 Unicode 字符,这相当于从 Python 2 的unicode 对象中获取的元素,而不是从 Python 2 的 str 对象中获取的原始字节序列。
举个🌰 编码和解码
>>> s = 'café' >>> len(s) 4 >>> b = s.encode('utf-8') >>> b b'caf\xc3\xa9' >>> len(b) 5 >>> b.decode('utf-8') 'café'
如果想帮助自己记住 .decode() 和 .encode() 的区别,可以把字节序列想成晦涩难懂的机器磁芯转储,把 Unicode 字符串想成“人类可读”的文本。那么,把字节序列变成人类可读的文本字符串就是解码,而把字符串变成用于存储或传输的字节序列就是编码。
字节概要
新的二进制序列类型在很多方面与 Python 2 的 str 类型不同。首先要知道,Python 内置了两种基本的二进制序列类型:Python 3 引入的不可变bytes 类型和 Python 2.6 添加的可变 bytearray 类型。(Python 2.6 也引入了 bytes 类型,但那只不过是 str 类型的别名,与 Python 3 的bytes 类型不同。)
bytes 或 bytearray 对象的各个元素是介于 0~255(含)之间的整数,而不像 Python 2 的 str 对象那样是单个的字符。然而,二进制序列的切片始终是同一类型的二进制序列,包括长度为 1 的切片,如示例:
>>> cafe = bytes('café', encoding='utf_8') >>> cafe b'caf\xc3\xa9' >>> cafe[0] 99 >>> cafe[:1] b'c' >>> cafe_arr = bytearray(cafe) >>> cafe_arr bytearray(b'caf\xc3\xa9') >>> cafe_arr[-1:] bytearray(b'\xa9')
二进制序列有个类方法是 str 没有的,名为 fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的),构建二进制序列:
>>> bytes.fromhex('31 4B CE A9') b'1K\xce\xa9' >>> bytes.fromhex('31 4B CE A9').decode('utf-8') '1KΩ'
使用数组中的原始数据初始化 bytes 对象
>>> import array >>> numbers = array.array('h', [-2, -1, 0, 1, 2]) >>> octets = bytes(numbers) >>> octets b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'
结构体和内存视图
struct 模块提供了一些函数,把打包的字节序列转换成不同类型字段组成的元组,还有一些函数用于执行反向转换,把元组转换成打包的字节序列。struct 模块能处理 bytes、bytearray 和 memoryview 对象。
使用 memoryview 和 struct 查看一个 GIF 图像的首部
>>> import struct >>> fmt = '<3s3sHH' # ➊ >>> with open('filter.gif', 'rb') as fp: ... img = memoryview(fp.read()) # ➋ ... >>> header = img[:10] # ➌ >>> bytes(header) # ➍ b'GIF89a+\x02\xe6\x00' >>> struct.unpack(fmt, header) # ➎ (b'GIF', b'89a', 555, 230) >>> del header # ➏ >>> del img
- 结构体的格式:< 是小字节序,3s3s 是两个 3 字节序列,HH 是两个16 位二进制整数
- 使用内存中的文件内容创建一个memoryview对象
- 然后使用它的切片在创建一个memoryview对象,这里不会复制字节序列
- 转换成字节序列,这里只是为了显示,这里复制了是个字节
- 拆包memoryview对象,得到一个元祖,包含类型、版本、宽度和高度
- 删除引用,释放memoryview实例所占用的内存
处理UnicodeEncodeError
多数非 UTF 编解码器只能处理 Unicode 字符的一小部分子集。把文本转换成字节序列时,如果目标编码中没有定义某个字符,那就会抛出UnicodeEncodeError 异常,除非把 errors 参数传给编码方法或函数,对错误进行特殊处理。
举个🌰 编码成字节序列:成功和错误处理
1 city = 'São Paulo' 2 3 #'utf_?' 编码能处理任何字符串 4 u8 = city.encode('utf_8') 5 print('utf-8:', u8) 6 7 u16 = city.encode('utf_16') 8 print('utf-16:', u16) 9 10 #'iso8859_1' 编码也能处理字符串 'São Paulo 11 iso = city.encode('iso8859_1') 12 print('iso:', iso) 13 14 #报错咯,'cp437' 无法编码 'ã'(带波形符的“a”) 15 #city.encode('cp437') 16 17 #解决方法如下 18 cp_ig = city.encode('cp437', errors='ignore') 19 print('cp ignore:', cp_ig) 20 21 cp_rp = city.encode('cp437', errors='replace') 22 print('cp replace:', cp_rp)
以上代码执行的结果为:
utf-8: b'S\xc3\xa3o Paulo' utf-16: b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00' iso: b'S\xe3o Paulo' cp ignore: b'So Paulo' cp replace: b'S?o Paulo'
注意:
- error='ignore' 处理方式悄无声息地跳过无法编码的字符;这样做通常很是不妥
- 编码时指定error='replace',把无法编码的字符替换成'?';数据损坏了,但是用户知道出现了问题
处理文本文件
处理文本的最佳实践是“Unicode 三明治”(如图下图所示)。 意思是,要尽早把输入(例如读取文件时)的字节序列解码成字符串。这种三明治中的“肉片”是程序的业务逻辑,在这里只能处理字符串对象。在其他处理过程中,一定不能编码或解码。对输出来说,则要尽量晚地把字符串编码成字节序列。多数 Web 框架都是这样做的,使用框架时很少接触字节序列。例如,在 Django 中,视图应该输出 Unicode 字符串;Django 会负责把响应编码成字节序列,而且默认使用 UTF-8 编码。
处理文本文件很简单。但是,如果依赖默认编码,你会遇到麻烦。举个🌰
1 #打开一个文件cafe.txt并写入内容,w是对文件的模式操作(写操作), encoding是对文件操作的编码 2 fp = open('cafe.txt', 'w', encoding='utf_8') 3 fp_len = fp.write('café') 4 print('fp的io信息:', fp) 5 print('写入到文件中内容的长度:', fp_len) 6 fp.close() 7 8 #获取文件的内容 9 fp2 = open('cafe.txt') 10 print('fp2的io信息:', fp2) 11 ''' 12 因为和上面的写入的编码不同,所以直接以默认的编码打开,无法处理é而引发异常 13 ''' 14 #print(fp2.read()) 15 fp2.close() 16 17 #解决fp2无法或许文件内容的方法指定打开的时候编码 18 fp3 = open('cafe.txt', encoding='utf-8') 19 print('fp3的io信息:', fp3) 20 print('fp3中的文件内容:', fp3.read()) 21 fp3.close() 22 23 fp4 = open('cafe.txt', 'rb') 24 print('fp4的io信息:', fp4) 25 print('fp4的文件内容:', fp4.read().decode('utf-8')) 26 fp4.close() 27 28 #另外一种不太可取的解决方案, errors可以设置成replace或者ignore 29 fp5 = open('cafe.txt', 'r', errors='ignore') 30 print('fp5的io信息:', fp5) 31 print('fp5的文件内容:', fp5.read())
以上代码执行的结果为:
fp的io信息: <_io.TextIOWrapper name='cafe.txt' mode='w' encoding='utf_8'> 写入到文件中内容的长度: 4 fp2的io信息: <_io.TextIOWrapper name='cafe.txt' mode='r' encoding='US-ASCII'> fp3的io信息: <_io.TextIOWrapper name='cafe.txt' mode='r' encoding='utf-8'> fp3中的文件内容: café fp4的io信息: <_io.BufferedReader name='cafe.txt'> fp4的文件内容: café fp5的io信息: <_io.TextIOWrapper name='cafe.txt' mode='r' encoding='US-ASCII'> fp5的文件内容: caf
探索编码默认值
1 import sys, locale 2 3 4 expressions = """ 5 locale.getpreferredencoding() 6 type(my_file) 7 my_file.encoding 8 sys.stdout.isatty() 9 sys.stdout.encoding 10 sys.stdin.isatty() 11 sys.stdin.encoding 12 sys.stderr.isatty() 13 sys.stderr.encoding 14 sys.getdefaultencoding() 15 sys.getfilesystemencoding() 16 """ 17 18 with open('dummy', 'w') as my_file: 19 for expression in expressions.split(): 20 value = eval(expression) 21 print('{:>30}'.format(expression), '->', repr(value)) 22 23 ''' 24 locale.getpreferredencoding() 是最重要的设置 25 文本文件默认使用 locale.getpreferredencoding() 26 输出到控制台中,因此 sys.stdout.isatty() 返回 True 27 因此,sys.stdout.encoding 与控制台的编码相同 28 '''
以上代码执行的结果为(终端运行):
ocale.getpreferredencoding() -> 'UTF-8' type(my_file) -> <class '_io.TextIOWrapper'> my_file.encoding -> 'UTF-8' sys.stdout.isatty() -> True sys.stdout.encoding -> 'UTF-8' sys.stdin.isatty() -> True sys.stdin.encoding -> 'UTF-8' sys.stderr.isatty() -> True sys.stderr.encoding -> 'UTF-8' sys.getdefaultencoding() -> 'utf-8' sys.getfilesystemencoding() -> 'utf-8'
为了正确比较而规范化Unicode字符串
因为 Unicode 有组合字符(变音符号和附加到前一个字符上的记号,打印时作为一个整体),所以字符串比较起来很复杂。
🌰 例如,“café”这个词可以使用两种方式构成,分别有 4 个和 5 个码位,但是结果完全一样:
>>> s1 = 'café' >>> s2 = 'cafe\u0301' >>> s1, s2 ('café', 'café') >>> len(s1), len(s2) (4, 5) >>> s1 == s2 False
'é' 和 'e\u0301' 这样的序列叫“标准等价物”(canonical equivalent),应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。
解决方案是使用 unicodedata.normalize 函数提供的Unicode 规范化。这个函数的第一个参数是这 4 个字符串中的一个:'NFC'、'NFD'、'NFKC' 和 'NFKD'。下面先说明前两个。
NFC(Normalization Form C)使用最少的码位构成等价的字符串,而NFD 把组合字符分解成基字符和单独的组合字符。这两种规范化方式都能让比较行为符合预期:
1 from unicodedata import normalize 2 3 4 s1 = 'café' # 把"e"和重音符组合在一起 5 s2 = 'cafe\u0301' # 分解成"e"和重音符 6 print('s1和s2的长度:', len(s1), len(s2)) 7 8 print('NFC标准化处理以后的s1,s2的长度:', len(normalize('NFC', s1)), len(normalize('NFC', s2))) 9 print('NFD标准化处理以后的s1,s2的长度:', len(normalize('NFD', s1)), len(normalize('NFD', s2))) 10 print(normalize('NFC', s1), normalize('NFC', s2))
以上代码执行的结果为:
s1和s2的长度: 4 5 NFC标准化处理以后的s1,s2的长度: 4 4 NFD标准化处理以后的s1,s2的长度: 5 5 café café
在另外两个规范化形式(NFKC 和 NFKD)的首字母缩略词中,字母 K表示“compatibility”(兼容性)。这两种是较严格的规范化形式,对“兼容字符”有影响。虽然 Unicode 的目标是为各个字符提供“规范的”码位,但是为了兼容现有的标准,有些字符会出现多次。例如,虽然希腊字母表中有“μ”这个字母(码位是 U+03BC,GREEK SMALL LETTER MU),但是 Unicode 还是加入了微符号 'μ'(U+00B5),以便与 latin1 相互转换。因此,微符号是一个“兼容字符”。
NFC的具体应用🌰
>>> from unicodedata import normalize >>> half = '½' >>> normalize('NFKC', half) '1⁄2' >>> four_squared = '4²' >>> normalize('NFKC', four_squared) '42' >>> micro = 'μ' >>> micro_kc = normalize('NFKC', micro) >>> micro, micro_kc ('μ', 'μ') >>> ord(micro), ord(micro_kc) (956, 956)
使用 '1/2' 替代 '½' 可以接受,微符号也确实是小写的希腊字母'μ',但是把 '4²' 转换成 '42' 就改变原意了。某些应用程序可以把'4²' 保存为 '4<sup>2</sup>',但是 normalize 函数对格式一无所知。因此,NFKC 或 NFKD 可能会损失或曲解信息,但是可以为搜索和索引提供便利的中间表述:用户搜索 '1 / 2 inch' 时,如果还能找到包含 '½ inch' 的文档,那么用户会感到满意。
注意:
使用 NFKC 和 NFKD 规范化形式时要小心,而且只能在特殊情况中使用,例如搜索和索引,而不能用于持久存储,因为这两种转换会导致数据损失。
规范化文本匹配实用函数
由前文可知,NFC 和 NFD 可以放心使用,而且能合理比较 Unicode 字符串。对大多数应用来说,NFC 是最好的规范化形式。不区分大小写的比较应该使用 str.casefold()。
如果要处理多语言文本,工具箱中应用nfc_equal 和fold_equal 函数。
🌰 比较规范化 Unicode 字符串
1 from unicodedata import normalize 2 3 4 def nfc_equal(str1, str2): 5 return normalize('NFC', str1) == normalize('NFC', str2) 6 7 def fold_equal(str1, str2): 8 return (normalize('NFC', str1).casefold() == 9 normalize('NFC', str2).casefold()) 10 11 s1 = 'café' 12 s2 = 'cafe\u0301' 13 print('s1 equal s2:',nfc_equal(s1, s2)) 14 15 print(nfc_equal('A', 'a')) 16 17 s3 = 'Straße' 18 s4 = 'strasse' 19 20 print('s3 equal s4', nfc_equal(s3, s4)) 21 #转换字符成小写 22 print(fold_equal(s3, s4))
以上代码的执行结果为:
s1 equal s2: True
False
s3 equal s4 False
True
极端“规范化”:去掉变音符号
去掉变音符号还能让 URL 更易于阅读,至少对拉丁语系语言是如此。下面是维基百科中介绍圣保罗市(São Paulo)的文章的URL:
http://en.wikipedia.org/wiki/S%C3%A3o_Paulo
其中,“%C3%A3”是 UTF-8 编码“ã”字母(带有波形符的“a”)转义后得到的结果。下述形式更友好,尽管拼写是错误的:
http://en.wikipedia.org/wiki/Sao_Paulo
如果想把字符串中的所有变音符号都去掉,看 🌰
1 import unicodedata 2 3 4 def shave_marks(txt): 5 """去掉全部变音符号""" 6 7 norm_txt = unicodedata.normalize('NFD', txt) #把所有字符分解成基字符和组合记号 8 shaved = ''.join(c for c in norm_txt 9 if not unicodedata.combining(c)) #过滤掉所有组合记号 10 return unicodedata.normalize('NFC', shaved) #重组所有字符 11 12 13 order = '“Herr Voß: • ½ cup of OEtker™ caffè latte • bowl of açaí.”' 14 print(shave_marks(order)) 15 16 Greek = 'Zέφupoς, Zéfiro' 17 print(shave_marks(Greek))
以上代码执行的结果为:
“Herr Voß: • ½ cup of OEtker™ caffe latte • bowl of acai.”
Zεφupoς, Zefiro
Unicode文本排序
Python 比较任何类型的序列时,会一一比较序列里的各个元素。对字符串来说,比较的是码位。可是在比较非 ASCII 字符时,得到的结果不尽如人意。
🌰 来了~,对一个生长在 🇧🇷 的水果排序
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola'] >>> sorted(fruits) ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
不同的区域采用的排序规则有所不同,葡萄牙语等很多语言按照拉丁字母表排序,重音符号和下加符对排序几乎没什么影响。 因此,排序时“cajá”视作“caja”,必定排在“caju”前面。
排序后的 fruits 列表应该是:
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
在 Python 中,非 ASCII 文本的标准排序方式是使用 locale.strxfrm函数,根据 locale 模块的文档(https://docs.python.org/3/library/locale.html?highlight=strxfrm#locale.strxfrm),这 个函数会“把字符串转换成适合所在区域进行比较的形式”。
使用 locale.strxfrm 函数之前,必须先为应用设定合适的区域设置,还要祈祷操作系统支持这项设置。在区域设为 pt_BR 的GNU/Linux(Ubuntu 14.04)中,可以使用示例中的命令:
使用 locale.strxfrm 函数做排序键
1 import locale 2 3 #设置时区 4 print(locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')) 5 6 fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola'] 7 fruits_sort = sorted(fruits, key=locale.strxfrm) 8 print('搞定:', fruits_sort)
以上代码的执行结果为:
pt_BR.UTF-8 搞定: ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
使用Unicode排序算法排序
🌰 使用 pyuca.Collator.sort_key 方法
>>> 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']
支持字符串和字节序列的双模式API
标准库中的一些函数能接受字符串或字节序列为参数,然后根据类型展现不同的行为。re 和 os 模块中就有这样的函数。
正则表达式中的字符串和字节序列
🌰 ramanujan.py:比较简单的字符串正则表达式和字节序列正则表达式的行为
1 import re 2 3 4 re_numbers_str = re.compile(r'\d+') #编译匹配字符串的数字的正则,连续数字,至少出现一次 5 re_words_str = re.compile(r'\w+') 6 re_numbers_bytes = re.compile(rb'\d+') #编译匹字节序列配数字的正则,连续数字,至少出现一次 7 re_words_bytes = re.compile(rb'\w+') 8 9 text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef" 10 " as 1729 = 1³ + 12³ = 9³ + 10³.") 11 12 text_bytes = text_str.encode('utf_8') 13 14 print('Text', repr(text_str), sep='\n ') 15 print('Numbers') 16 print(' str :', re_numbers_str.findall(text_str)) 17 print(' bytes:', re_numbers_bytes.findall(text_bytes)) 18 print('Words') 19 print(' str :', re_words_str.findall(text_str)) 20 print(' bytes:', re_words_bytes.findall(text_bytes))
以上代码执行的结果为:
Text 'Ramanujan saw ௧௭௨௯ as 1729 = 1³ + 12³ = 9³ + 10³.' Numbers str : ['௧௭௨௯', '1729', '1', '12', '9', '10'] bytes: [b'1729', b'1', b'12', b'9', b'10'] Words str : ['Ramanujan', 'saw', '௧௭௨௯', 'as', '1729', '1³', '12³', '9³', '10³'] bytes: [b'Ramanujan', b'saw', b'as', b'1729', b'1', b'12', b'9', b'10']