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)
>>> b = s.encode('utf-8')
b'caf\xc3\xa9'
>>> len(b)
>>> 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')
b'caf\xc3\xa9'
>>> cafe[0]
>>> 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'
3 #'utf_?' 编码能处理任何字符串
4 u8 = city.encode('utf_8')
5 print('utf-8:', u8)
7 u16 = city.encode('utf_16')
8 print('utf-16:', u16)
10 #'iso8859_1' 编码也能处理字符串 'São Paulo
11 iso = city.encode('iso8859_1')
12 print('iso:', iso)
14 #报错咯,'cp437' 无法编码 'ã'(带波形符的“a”)
15 #city.encode('cp437')
17 #解决方法如下
18 cp_ig = city.encode('cp437', errors='ignore')
19 print('cp ignore:', cp_ig)
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()
8 #获取文件的内容
9 fp2 = open('cafe.txt')
10 print('fp2的io信息:', fp2)
11 '''
12 因为和上面的写入的编码不同,所以直接以默认的编码打开,无法处理é而引发异常
13 '''
14 #print(fp2.read())
15 fp2.close()
17 #解决fp2无法或许文件内容的方法指定打开的时候编码
18 fp3 = open('cafe.txt', encoding='utf-8')
19 print('fp3的io信息:', fp3)
20 print('fp3中的文件内容:', fp3.read())
21 fp3.close()
23 fp4 = open('cafe.txt', 'rb')
24 print('fp4的io信息:', fp4)
25 print('fp4的文件内容:', fp4.read().decode('utf-8'))
26 fp4.close()
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
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 """
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))
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
4 s1 = 'café' # 把"e"和重音符组合在一起
5 s2 = 'cafe\u0301' # 分解成"e"和重音符
6 print('s1和s2的长度:', len(s1), len(s2))
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
4 def nfc_equal(str1, str2):
5 return normalize('NFC', str1) == normalize('NFC', str2)
7 def fold_equal(str1, str2):
8 return (normalize('NFC', str1).casefold() ==
9 normalize('NFC', str2).casefold())
11 s1 = 'café'
12 s2 = 'cafe\u0301'
13 print('s1 equal s2:',nfc_equal(s1, s2))
15 print(nfc_equal('A', 'a'))
17 s3 = 'Straße'
18 s4 = 'strasse'
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
极端“规范化”:去掉变音符号
去掉变音符号还能让 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
4 def shave_marks(txt):
5 """去掉全部变音符号"""
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) #重组所有字符
13 order = '“Herr Voß: • ½ cup of OEtker™ caffè latte • bowl of açaí.”'
14 print(shave_marks(order))
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
3 #设置时区
4 print(locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8'))
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
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+')
9 text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef"
10 " as 1729 = 1³ + 12³ = 9³ + 10³.")
12 text_bytes = text_str.encode('utf_8')
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))
以上代码执行的结果为:
'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']