四、文本和字节序列
Unicode 字符串、二进制序列
一个字符串是一个字符序列。从python3 的str对象中获取的元素是 Unicode字符,这相当于从Python2的 Unicode对象中获取的元素,而不是从Python2的str对象中获取的原始字节序列。
字符的标识,即码位,是0-1114111的十进制数,在Unicode标准中以4-6个十六进制数表示,而且增加前缀 'U+',例如,A 的码位:U+0041,欧元符号的码位:U+20AC,高音符号的码位:U+1D11E。在 Unicode 6.3 中(这是 Python 3.4使用的标准),约 10% 的有效码位有对应的字符。
字符的具体表述取决于所用的编码。编码是 码位 和 字节序列 之间转换时使用的算法。
在 UTF-8 编码中,A(U+0041)的码位编码成单个字节
\x41
,而在 UTF-16LE编码中编码成两个字节
\x41\x00
。再举个例子,欧元符号(U+20AC)在 UTF-8 编码中是三个字节
\xe2\x82\xac
,而在 UTF-16LE 中编码成两个字节:
\xac\x20
。
把 Unicode 码位 转成 字节序列 的过程是编码;把 字节序列 转为 Unicode 码位 的过程是解码。
字节序列:bytes 对象,字面量以 b 开头。
虽然 Python 3 的 str 类型基本相当于 Python 2 的 unicode 类型,只不过是换了个新名称,但是 Python 3 的 bytes 类型却不是把 str 类型换个名称那么简单,而且还有关系紧密的 bytearray 类型。因此,在讨论编码和解码的问题之前,有必要先来介绍一下二进制序列类型。
新的二进制序列类型在很多方面与 Python 2 的 str 类型不同。首先要知道,Python 内置了两种基本的二进制序列类型:Python 3 引入的不可变 bytes 类型和 Python 2.6 添加的可变bytearray 类型。(Python 2.6 也引入了 bytes 类型,但那只不过是 str 类型的别名,与Python 3 的 bytes 类型不同。)
python3 str 是 Unicode字符,python2 str 是 字节序列
bytes 或 bytearray 对象的各个元素是介于 0~255(含)之间的整数,而不像 Python 2的 str 对象那样是单个的字符。然而,二进制序列的切片始终是同一类型的二进制序列,包括长度为 1 的切片,如示例 4-2 所示。
In [1]: c = bytes('cafe',encoding='utf-8') # 构建 bytes 字节序列
In [2]: c
Out[2]: b'cafe'
In [3]: c[0] # 各个元素是 range(256) 的数字
Out[3]: 99
In [4]: c[:1] # 切片是 bytes对象,即使切片只有一个字节
Out[4]: b'c'
In [5]: c1 = bytearray(c)
In [6]: c1 # bytearray 没有字面量句法
Out[6]: bytearray(b'cafe')
In [8]: c1[0] # 各个元素是 range(256) 的数字
Out[8]: 99
In [7]: c1[-1:] # 切片是 bytearray 对象
Out[7]: bytearray(b'e')
my_bytes[0] 获取的是一个整数,而 my_bytes[:1] 返回的是一个长度为 1的 bytes 对象——这一点应该不会让人意外。s[0] == s[:1] 只对 str 这个序列类型成立。不过,str 类型的这个行为十分罕见。对其他各个序列类型来说,s[i] 返回一个元素,而 s[i:i+1] 返回一个相同类型的序列,里面是 s[i] 元素。
In [10]: c[0] == c[:1] # 序列类型 bytes 不成立
Out[10]: False
In [9]: c1[0] == c1[:1] # 可变序列类型 bytearray 不成立
Out[9]: False
In [11]: cc = 'cafe'
In [12]: cc[0] == cc[:1] # 字符串类型 str 成立
Out[12]: True
虽然二进制序列其实是整数序列,但是它们的字面量表示法表明其中有 ASCII 文本。因此,各个字节的值可能会使用下列三种不同的方式显示。
可打印的 ASCII 范围内的字节(从空格到 ~),使用 ASCII 字符本身。
制表符、换行符、回车符和 \ 对应的字节,使用转义序列 \t、\n、\r 和 \。
其他字节的值,使用十六进制转义序列(例如,\x00 是空字节)。
除了格式化方法(format 和 format_map)和几个处理 Unicode 数据的方法(包括casefold、isdecimal、isidentifier、isnumeric、isprintable 和 encode)之外,str 类型的其他方法都支持 bytes 和 bytearray 类型。这意味着,我们可以使用熟悉的字符串方法处理二进制序列,如 endswith、replace、strip、translate、upper等,只有少数几个其他方法的参数是 bytes 对象,而不是 str 对象。此外,如果正则表达式编译自二进制序列而不是字符串,re 模块中的正则表达式函数也能处理二进制序列。Python 3.0~3.4 不能使用 % 运算符处理二进制序列,但是根据“PEP 461—Adding %formatting to bytes and bytearray”(https://www.python.org/dev/peps/pep-0461/),Python 3.5应该会支持。
二进制序列有个类方法是 str 没有的,名为 fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的),构建二进制序列:
>>> bytes.fromhex('31 4B CE A9')
b'1K\xce\xa9'
构建 bytes 或 bytearray 实例还可以调用各自的构造方法,传入下述参数
一个 str 对象和一个 encoding 关键字参数。
一个可迭代对象,提供 0~255 之间的数值。
一个整数,使用空字节创建对应长度的二进制序列。[Python 3.5 会把这个构造方法标记为“过时的”,Python 3.6 会将其删除。参见“PEP 467—Minor API improvements for binary sequences”(https://www.python.org/dev/peps/pep-0467/)。]
一个实现了缓冲协议的对象(如bytes、bytearray、memoryview、array.array);此时,把源对象中的字节序列复制到新建的二进制序列中。
使用缓冲类对象构建二进制序列是一种低层操作,可能涉及类型转换。
In [13]: import array
In [14]: n = array.array('h',[-2,-1,0]) # 指定类型代码 h,创建一个短整数(16 位)数组。
In [15]: n
Out[15]: array('h', [-2, -1, 0])
In [16]: o = bytes(n)
In [17]: o # 这些是表示那 3 个短整数的 6 个字节。
Out[17]: b'\xfe\xff\xff\xff\x00\x00'
使用缓冲类对象创建 bytes 或 bytearray 对象时,始终复制源对象中的字节序列。与之相反,memoryview 对象允许在二进制数据结构之间共享内存。如果想从二进制序列中提取结构化信息,struct 模块是重要的工具。下一节会使用这个模块处理 bytes 和 memoryview 对象。
结构体和内存视图
struct 模块提供了一些函数,把打包的字节序列转换成不同类型字段组成的元组,还有一些函数用于执行反向转换,把元组转换成打包的字节序列。struct 模块能处理bytes、bytearray 和 memoryview 对象。
memoryview 类不是用于创建或存储字节序列的,而是共享内存,让你访问其他二进制序列、打包的数组和缓冲中的数据切片,而无需复制字节序列,例如Python Imaging Library(PIL)2 就是这样处理图像的。
>>> 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
基本的编解码器
Python 自带了超过 100 种编解码器(codec, encoder/decoder),用于在文本和字节之间相互转换。每个编解码器都有一个名称,如 'utf_8',而且经常有几个别名,如'utf8'、'utf-8' 和 'U8'。这些名称可以传给open()、str.encode()、bytes.decode() 等函数的 encoding 参数。示例 4-5 使用 3个编解码器把相同的文本编码成不同的字节序列。
某些编码(如 ASCII 和多字节的 GB2312)不能表示所有 Unicode字符。然而,UTF 编码的设计目的就是处理每一个 Unicode 码位。
各种编码:
latin1(即 iso8859_1)
一种重要的编码,是其他编码的基础,例如 cp1252 和 Unicode(注意,latin1 与cp1252 的字节值是一样的,甚至连码位也相同)。
cp1252
Microsoft 制定的 latin1 超集,添加了有用的符号,例如弯引号和€(欧元);有些Windows 应用把它称为“ANSI”,但它并不是 ANSI 标准。
cp437
IBM PC 最初的字符集,包含框图符号。与后来出现的 latin1 不兼容。
gb2312
用于编码简体中文的陈旧标准;这是亚洲语言中使用较广泛的多字节编码之一。
utf-8
目前 Web 中最常见的 8 位编码;3 与 ASCII 兼容(纯 ASCII 文本是有效的 UTF-8 文本)。
3W3Techs 发布的“Usage of character encodings for websites”(https://w3techs.com/technologies/overview/character_encoding/all)报告指出,截至 2014 年 9 月,81.4% 的网站使用 UTF-8;而 Built With 发布的“Encoding Usage Statistics”(http://trends.builtwith.com/encoding)估计的比例则是79.4%。
utf-16le
UTF-16 的 16 位编码方案的一种形式;所有 UTF-16 支持通过转义序列(称为“代理对”,surrogate pair)表示超过 U+FFFF 的码位。
UTF-16 取代了 1996 年发布的 Unicode 1.0 编码(UCS-2)。这个编码在很多系统中仍在使用,但是支持的最大码位是 U+FFFF。从 Unicode 6.3 起,分配的码位中有超过 50% 在 U+10000 以上,包括逐渐流行的表情符号(emoji pictograph)。
了解编解码问题
虽然有个一般性的 UnicodeError 异常,但是报告错误时几乎都会指明具体的异常:UnicodeEncodeError(把字符串转换成二进制序列时)或UnicodeDecodeError(把二进制序列转换成字符串时)。如果源码的编码与预期不符,加载 Python 模块时还可能抛出 SyntaxError。接下来的几节说明如何处理这些错误。
出现与 Unicode 有关的错误时,首先要明确异常的类型。导致编码问题的是UnicodeEncodeError、UnicodeDecodeError,还是如 SyntaxError 的其他错误?解决问题之前必须清楚这一点。
处理 UnicodeEncodeError
In [30]: city = 'São Paulo'
In [31]: city.encode('utf_8') # utf_? 覆盖任何字符
Out[31]: b'S\xc3\xa3o Paulo'
In [33]: city.encode('utf16')
Out[33]: b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00'
In [34]: city.encode('iso8859_1')
Out[34]: b'S\xe3o Paulo'
In [35]: city.encode('cp437') # cp437 不能编码,报错
---------------------------------------------------------------------------
UnicodeEncodeError Traceback (most recent call last)
<ipython-input-35-064a572fd5b6> in <module>
----> 1 city.encode('cp437')
c:\users\wanglin\appdata\local\programs\python\python36\lib\encodings\cp437.py in encode(self, input, errors)
11 def encode(self,input,errors='strict'):
---> 12 return codecs.charmap_encode(input,errors,encoding_map)
14 def decode(self,input,errors='strict'):
UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>
In [36]: city.encode('cp437',errors='ignore') # 跳过 不能编码的字符
Out[36]: b'So Paulo'
In [37]: city.encode('cp437',errors='replace') # 用 ? 替换 不能编码的字符,可以得知不能编码的问题
Out[37]: b'S?o Paulo'
In [38]: city.encode('cp437',errors='xmlcharrefreplace') # 替换为 XML实体 不能编码的字符
Out[38]: b'São Paulo'
编解码器的错误处理方式是可扩展的。你可以为 errors 参数注册额外的字符串,方法是把一个名称和一个错误处理函数传给 codecs.register_error 函数。参见 codecs.register_error 函数的文档
(https://docs.python.org/3/library/codecs.html#codecs.register_error)。
处理 UnicodeDecodeError
In [39]: octets = b'Montr\xe9al' # 这些字节序列是使用 latin1 编码的 “Montréal”;'\xe9' 字节对应“é”。
In [40]: octets.decode('cp1252') # 可以使用 'cp1252'(Windows 1252)解码,因为它是 latin1 的有效超集。
Out[40]: 'Montréal'
In [41]: octets.decode('iso8859_7') # ISO-8859-7 用于编码希腊文,因此无法正确解释 '\xe9' 字节,而且没有抛出错误。
Out[41]: 'Montrιal'
In [42]: octets.decode('koi8_r') # KOI8-R 用于编码俄文;这里,'\xe9' 表示西里尔字母“И”。
Out[42]: 'MontrИal'
In [43]: octets.decode('utf_8') # 'utf_8' 编解码器检测到 octets 不是有效的 UTF-8 字符串,抛出 UnicodeDecodeError。
---------------------------------------------------------------------------
UnicodeDecodeError Traceback (most recent call last)
<ipython-input-43-afaa3d3916c5> in <module>
----> 1 octets.decode('utf_8')
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5: invalid continuation byte
In [44]: octets.decode('utf_8', errors='replace') # 使用 'replace' 错误处理方式,\xe9 替换成了“ ”(码位是 U+FFFD),这是官方指定的 REPLACEMENT CHARACTER(替换字符),表示未知字符。
Out[44]: 'Montr�al'
In [44]: octets.decode('utf_8', errors='replace')
Out[44]: 'Montr�al'
In [45]: octets.decode('utf_8', errors='ignore')
Out[45]: 'Montral'
In [46]: octets.decode('utf_8', errors='xmlcharrefreplace')
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-46-b0e050422a60> in <module>
----> 1 octets.decode('utf_8', errors='xmlcharrefreplace')
TypeError: don't know how to handle UnicodeDecodeError in error callback
使用预期之外的编码加载模块时抛出的 SyntaxError
Python 3 默认使用 UTF-8 编码源码,Python 2(从 2.5 开始)则默认使用 ASCII。如果加载的 .py 模块中包含 UTF-8 之外的数据,而且没有声明编码,会得到类似下面的消息:
SyntaxError: Non-UTF-8 code starting with '\xe1' in file ola.py on line
1, but no encoding declared; see http://python.org/dev/peps/pep-0263/
for details
GNU/Linux 和 OS X 系统大都使用 UTF-8,因此打开在 Windows 系统中使用 cp1252 编码的 .py 文件时可能发生这种情况。注意,这个错误在 Windows 版 Python 中也可能会发生,因为 Python 3 为所有平台设置的默认编码都是 UTF-8。
用 coding 解决
示例 4-8 ola.py:“你好,世界!”的葡萄牙语版
# coding: cp1252
print('Olá, Mundo!')
现在,Python 3 的源码不再限于使用 ASCII,而是默认使用优秀的 UTF-8 编码,因此要修正源码的陈旧编码(如 'cp1252')问题,最好将其转换成 UTF-8,别去麻烦 coding 注释。如果你用的编辑器不支持 UTF-8,那么是时候换一个了。
源码中能不能使用非 ASCII 名称
Python 3 允许在源码中使用非 ASCII 标识符:
>>> ação = 'PBR' # ação = stock
>>> ε = 10**-6 # ε = epsilon
选择不同的标识符,要因地域而异,选择最适合的标识符,不同地方键盘不同,可以打出一些本地的字符,代码可能更容易阅读和编写。
如何找出字节序列的编码
如何找出字节序列的编码?简单来说,不能。必须有人告诉你。
有些通信协议和文件格式,如 HTTP 和 XML,包含明确指明内容编码的首部。可以肯定的是,某些字节流不是 ASCII,因为其中包含大于 127 的字节值,而且制定 UTF-8 和UTF-16 的方式也限制了可用的字节序列。不过即便如此,我们也不能根据特定的位模式来 100% 确定二进制文件的编码是 ASCII 或 UTF-8。
然而,就像人类语言也有规则和限制一样,只要假定字节流是人类可读的纯文本,就可能通过试探和分析找出编码。例如,如果 b'\x00' 字节经常出现,那么可能是 16 位或 32位编码,而不是 8 位编码方案,因为纯文本中不能包含空字符;如果字节序列b'\x20\x00' 经常出现,那么可能是 UTF-16LE 编码中的空格字符(U+0020),而不是鲜为人知的 U+2000 EN QUAD 字符——谁知道这是什么呢!
统一字符编码侦测包 Chardet(https://pypi.python.org/pypi/chardet)就是这样工作的,它能识别所支持的 30 种编码。Chardet 是一个 Python 库,可以在程序中使用,不过它也提供了命令行工具 chardetect。下面是它对本章书稿文件的检测报告:
$ chardetect 04-text-byte.asciidoc
04-text-byte.asciidoc: utf-8 with confidence 0.99
二进制序列编码文本通常不会明确指明自己的编码,但是 UTF 格式可以在文本内容的开头添加一个字节序标记。
BOM:有用的鬼符
UTF-16 编码的序列开头有几个额外的字节,如下所示:
>>> u16 = 'El Niño'.encode('utf_16')
b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
In [53]: u161 = 'E'.encode('utf_16')
In [54]: u16 = 'l'.encode('utf_16')
In [55]: u161
Out[55]: b'\xff\xfeE\x00'
In [56]: u16
Out[56]: b'\xff\xfel\x00'
BOM 是 b'\xff\xfe' ,即字节序标记(byte-order mark),指明编码时使用 Intel CPU 的小字节序。
在小字节序设备中,各个码位的最低有效字节在前面:字母 'E' 的码位是 U+0045(十进制数 69),在字节偏移的第 2 位和第 3 位编码为 69 和 0。
>>> list(u16)
[255, 254, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
在大字节序 CPU 中,编码顺序是相反的;'E' 编码为 0 和 69。
为了避免混淆,UTF-16 编码在要编码的文本前面加上特殊的不可见字符 ZERO WIDTH NO-BREAK SPACE(U+FEFF)
。在小字节序系统中,这个字符编码为 b'\xff\xfe'(十进制数 255, 254)。因为按照设计,U+FFFE 字符不存在,在小字节序编码中,字节序列 b'\xff\xfe' 必定是 ZERO WIDTH NO-BREAK SPACE,所以编解码器知道该用哪个字节序。
UTF-16 有两个变种:UTF-16LE,显式指明使用小字节序;UTF-16BE,显式指明使用大字节序。如果使用这两个变种,不会生成 BOM:
>>> u16le = 'El Niño'.encode('utf_16le')
>>> list(u16le)
[69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
>>> u16be = 'El Niño'.encode('utf_16be')
>>> list(u16be)
[0, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111]
如果有 BOM,UTF-16 编解码器会将其过滤掉,为你提供没有前导 ZERO WIDTH NO BREAK SPACE 字符的真正文本。根据标准,如果文件使用 UTF-16 编码,而且没有BOM,那么应该假定它使用的是 UTF-16BE(大字节序)编码。然而,Intel x86 架构用的是小字节序,因此有很多文件用的是不带 BOM 的小字节序 UTF-16 编码。
与字节序有关的问题只对一个字(word)占多个字节的编码(如 UTF-16 和 UTF-32)有影响。UTF-8 的一大优势是,不管设备使用哪种字节序,生成的字节序列始终一致,因此不需要 BOM。尽管如此,某些 Windows 应用(尤其是 Notepad)依然会在 UTF-8 编码的文件中添加 BOM;而且,Excel 会根据有没有 BOM 确定文件是不是 UTF-8 编码,否则,它假设内容使用 Windows 代码页(codepage)编码。UTF-8 编码的 U+FEFF 字符是一个三字节序列:b'\xef\xbb\xbf'。因此,如果文件以这三个字节开头,有可能是带有 BOM的 UTF-8 文件。然而,Python 不会因为文件以 b'\xef\xbb\xbf' 开头就自动假定它是UTF-8 编码的。
In [64]: u8 = 'El Niño'.encode('utf_8')
In [65]: u8
Out[65]: b'El Ni\xc3\xb1o'
处理文本文件
处理文本的最佳实践是“Unicode 三明治”。
意思是,要尽早把输入(例如读取文件时)的字节序列解码成字符串。
这种三明治中的“肉片”是程序的业务逻辑,在这里只能处理字符串对象。在其他处理过程中,一定不能编码或解码。
对输出来说,则要尽量晚地把字符串编码成字节序列。
多数 Web 框架都是这样做的,使用框架时很少接触字节序列。例如,在 Django 中,视图应该输出 Unicode 字符串;Django 会负责把响应编码成字节序列,而且默认使用 UTF-8 编码。
在 Python 3 中能轻松地采纳 Unicode 三明治的建议,因为内置的 open 函数会在读取文件时做必要的解码,以文本模式写入文件时还会做必要的编码,所以调用 my_file.read() 方法得到的以及传给 my_file.write(text) 方法的都是字符串对象。
Python 2.6 或 Python 2.7 用户要使用 io.open() 函数才能得到读写文件时自动执行的解码和编码操作。
可以看出,处理文本文件很简单。但是,如果依赖默认编码,你会遇到麻烦。
没有指定编码参数,会使用区域设置中的默认编码
In [67]: open(r'D:\cafe.txt', 'w', encoding='utf_8').write('café') # 注意 不是 e
Out[67]: 4
In [68]: open(r'D:\cafe.txt').read()
Out[68]: 'caf茅'
需要在多台设备中或多种场合下运行的代码,一定不能依赖默认编码。打开文件时始终应该明确传入 encoding= 参数,因为不同的设备使用的默认编码可能不同,有时隔一天也会发生变化。
>>> fp = open('cafe.txt', 'w', encoding='utf_8')
>>> fp # 默认情况下,open 函数采用文本模式,返回一个 TextIOWrapper 对象。
<_io.TextIOWrapper name='cafe.txt' mode='w' encoding='utf_8'>
>>> fp.write('café') # 写入的 Unicode 字符数,注意不是 e
>>> fp.close()
>>> import os
>>> os.stat('cafe.txt').st_size # os.stat 报告文件中有 5 个字节;UTF-8 编码的 'é' 占两个字节,0xc3 和 0xa9。
>>> fp2 = open('cafe.txt')
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='cp1252'>
>>> fp2.encoding
'cp1252'
>>> fp2.read()
'café'
>>> fp3 = open('cafe.txt', encoding='utf_8')
<_io.TextIOWrapper name='cafe.txt' mode='r' encoding='utf_8'>
>>> fp3.read()
'café'
>>> fp4 = open('cafe.txt', 'rb')
>>> fp4 # rb 模式,返回的是 BufferedReader 对象,而不是 TextIOWrapper 对象。
<_io.BufferedReader name='cafe.txt'>
>>> fp4.read() # 这是 5个字节,c, a, f, \xc3, \xa9
b'caf\xc3\xa9'
除非想判断编码,否则不要在二进制模式中打开文本文件;即便如此,也应该使用 Chardet,而不是重新发明轮子。常规代码只应该使用二进制模式打开二进制文件,如光栅图像。
Unicode 字符串 规范化
因为 Unicode 有组合字符(变音符号和附加到前一个字符上的记号,打印时作为一个整体),所以字符串比较起来很复杂。
例如,“café”这个词可以使用两种方式构成,分别有 4 个和 5 个码位,但是结果完全一样:
U+0301 是 COMBINING ACUTE ACCENT,加在“e”后面得到“é”。在 Unicode 标准中,'é'和 'e\u0301' 这样的序列叫“标准等价物”(canonical equivalent),应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。
这个问题的解决方案是使用 unicodedata.normalize 函数提供的 Unicode 规范化。这个函数的第一个参数是这 4 个字符串中的一个:'NFC'、'NFD'、'NFKC' 和 'NFKD'。下面先说明前两个。
NFC(Normalization Form C)使用最少的码位构成等价的字符串,而 NFD 把组合字符分解成基字符和单独的组合字符。这两种规范化方式都能让比较行为符合预期:
In [95]: s1 = 'café'
In [96]: s2 = 'cafe\u0301'
In [97]: len(s1)
Out[97]: 4
In [98]: len(s2)
Out[98]: 5
In [82]: from unicodedata import normalize
In [83]: s1 == s2
Out[83]: False
In [84]: normalize('NFC',s1) == normalize('NFC',s2)
Out[84]: True
In [89]: len(normalize('NFC',s1))
Out[89]: 4
In [90]: len(normalize('NFD',s1))
Out[90]: 5
西方键盘通常能输出组合字符,因此用户输入的文本默认是 NFC 形式。不过,安全起见,保存文本之前,最好使用 normalize('NFC', user_text) 清洗字符串。NFC 也是W3C 的“Character Model for the World Wide Web: String Matching and Searching”规范(https://www.w3.org/TR/charmod-norm/)推荐的规范化形式。
使用 NFC 时,有些单字符会被规范成另一个单字符。例如,电阻的单位欧姆(Ω)会被规范成希腊字母大写的欧米加。这两个字符在视觉上是一样的,但是比较时并不相等,因此要规范化,防止出现意外:
In [99]: from unicodedata import normalize,name
In [100]: ohm = '\u2126'
In [101]: ohm
Out[101]: 'Ω'
In [102]: name(ohm)
Out[102]: 'OHM SIGN'
In [103]: ohm_c = normalize('NFC',ohm)
In [104]: ohm_c
Out[104]: 'Ω'
In [105]: name(ohm_c)
Out[105]: 'GREEK CAPITAL LETTER OMEGA'
In [106]: ohm == ohm_c
Out[106]: False
In [107]: normalize('NFC',ohm_c)
Out[107]: 'Ω'
In [108]: name(normalize('NFC',ohm_c))
Out[108]: 'GREEK CAPITAL LETTER OMEGA'
In [109]: normalize('NFC',ohm) == normalize('NFC',ohm_c)
Out[109]: True
在另外两个规范化形式(NFKC 和 NFKD)的首字母缩略词中,字母 K 表 示“compatibility”(兼容性)。这两种是较严格的规范化形式,对“兼容字符”有影响。虽然 Unicode 的目标是为各个字符提供“规范的”码位,但是为了兼容现有的标准,有些字符会出现多次。例如,虽然希腊字母表中有“μ”这个字母(码位是 U+03BC,GREEK SMALL LETTER MU),但是 Unicode 还是加入了微符号 'µ'(U+00B5),以便与 latin1 相互转换。因此,微符号是一个“兼容字符”。
在 NFKC 和 NFKD 形式中,各个兼容字符会被替换成一个或多个“兼容分解”字符,即便这样有些格式损失,但仍是“首选”表述——理想情况下,格式化是外部标记的职责,不应该由 Unicode 处理。下面举个例子。二分之一 '½'(U+00BD)经过兼容分解后得到的是三个字符序列 '1/2';微符号 'µ'(U+00B5)经过兼容分解后得到的是小写字母'μ'(U+03BC)。
微符号是“兼容字符”,而欧姆符号不是,这还真是奇怪。因此,NFC 不会改动微符号,但是会把欧姆符号改成大写的欧米加;而 NFKC 和 NFKD 会把欧姆和微符号都改成其他字符。
下面是 NFKC 的具体应用:
>>> from unicodedata import normalize, name
>>> half = '½'
>>> normalize('NFKC', half)
'1⁄2'
>>> four_squared = '4²'
>>> normalize('NFKC', four_squared)
>>> micro = 'μ'
>>> micro_kc = normalize('NFKC', micro)
>>> micro, micro_kc
('μ', 'μ')
>>> ord(micro), ord(micro_kc)
(181, 956)
>>> name(micro), name(micro_kc)
('MICRO SIGN', 'GREEK SMALL LETTER MU')
使用 '1/2' 替代 '½' 可以接受,微符号也确实是小写的希腊字母 'µ',但是把 '4²' 转换成 '42' 就改变原意了。某些应用程序可以把 '4²' 保存为 '42',但是normalize 函数对格式一无所知。因此,NFKC 或 NFKD 可能会损失或曲解信息,但是可以为搜索和索引提供便利的中间表述:用户搜索 '1 / 2 inch' 时,如果还能找到包含 '½ inch' 的文档,那么用户会感到满意。
使用 NFKC 和 NFKD 规范化形式时要小心,而且只能在特殊情况中使用,例如搜索和索引,而不能用于持久存储,因为这两种转换会导致数据损失。
为搜索或索引准备文本时,还有一个有用的操作,即下一节讨论的大小写折叠。
大小写折叠
大小写折叠其实就是把所有文本变成小写,再做些其他转换。这个功能由 str.casefold() 方法(Python 3.3 新增)支持。
对于只包含 latin1 字符的字符串 s,s.casefold() 得到的结果与 s.lower() 一样,唯有两个例外:微符号 'µ' 会变成小写的希腊字母“μ”(在多数字体中二者看起来一样);德语 Eszett(“sharp s”,ß)会变成“ss”。
自 Python 3.4 起,str.casefold() 和 str.lower() 得到不同结果的有 116 个码位。Unicode 6.3 命名了 110 122 个字符,这只占 0.11%。
IIn [118]: m1 = 'Μ'
In [119]: m1.casefold()
Out[119]: 'μ'
In [121]: m1.casefold().casefold()
Out[121]: 'μ'
In [122]: name(m1)
Out[122]: 'GREEK CAPITAL LETTER MU'
In [123]: m1.lower()
Out[123]: 'μ'
规范化文本匹配实用函数
由前文可知,NFC 和 NFD 可以放心使用,而且能合理比较 Unicode 字符串。对大多数应用来说,NFC 是最好的规范化形式。不区分大小写的比较应该使用 str.casefold()。
如果要处理多语言文本,工具箱中应该有示例中的 nfc_equal 和 fold_equal 函数。
from unicodedata import normalize
def nfc_equal(str1, str2):
return normalize('NFC', str1) == normalize('NFC', str2)
def fold_equal(str1, str2):
return (normalize('NFC', str1).casefold() ==
normalize('NFC', str2).casefold())
极端“规范化”:去掉变音符号
Google 搜索涉及很多技术,其中一个显然是忽略变音符号(如重音符、下加符等),至少在某些情况下会这么做。去掉变音符号不是正确的规范化方式,因为这往往会改变词的意思,而且可能误判搜索结果。但是对现实生活却有所帮助:人们有时很懒,或者不知道怎么正确使用变音符号,而且拼写规则会随时间变化,因此实际语言中的重音经常变来变去。
除了搜索,去掉变音符号还能让 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
如果想把字符串中的所有变音符号都去掉,可以使用示例中的函数
import unicodedata
import string
def shave_marks(txt):
"""去掉全部组合符号"""
norm_txt = unicodedata.normalize('NFD', txt) # 分解为:基字符 和 组合符号
# 过滤 所有组合符号
shaved = ''.join(c for c in norm_txt if not unicodedata.combining(c))
return unicodedata.normalize('NFC', shaved) # 重组所有字符
删除拉丁字母中组合记号的函数(import 语句省略了,因为这是示例中定义的 sanitize.py 模块的一部分)
def shave_marks_latin(txt):
"""把拉丁基字符中所有的变音符号删除"""
norm_txt = unicodedata.normalize('NFD', txt)
latin_base = False
keepers = []
for c in norm_txt:
if unicodedata.combining(c) and latin_base:
continue # 忽略拉丁基字符上的变音符号
keepers.append(c)
# 如果不是组合字符,那就是新的基字符
if not unicodedata.combining(c):
latin_base = c in string.ascii_letters
shaved = ''.join(keepers)
return unicodedata.normalize('NFC', shaved)
single_map = str.maketrans(""",ƒ,,†ˆ‹‘’“”•––˜›""", """'f"*^<''""---~>""")
str.maketrans 方法
In [4]: single_map = str.maketrans('aaa','123')
In [5]: single_map
Out[5]: {97: 51}
In [6]: chr(51)
Out[6]: '3'
In [7]: single_map = str.maketrans({'a':'1'})
In [8]: single_map
Out[8]: {97: '1'}
In [9]: single_map = str.maketrans({'aaa':'231'})
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
<ipython-input-9-3c86e2fcdb8e> in <module>
----> 1 single_map = str.maketrans({'aaa':'231'})
ValueError: string keys in translate table must be of length 1
In [10]: txt = 'abc'
In [11]: single_map
Out[11]: {97: '1'}
In [12]: txt.translate(single_map)
Out[12]: '1bc'
示例 把一些西文印刷字符转换成 ASCII 字符
single_map = str.maketrans(""",ƒ,,†ˆ‹‘’“”•––˜›""",
"""'f"*^<''""---~>""")
multi_map = str.maketrans({
'€': '<euro>',
'…': '...',
'OE': 'OE',
'™': '(TM)',
'oe': 'oe',
'‰': '<per mille>',
'‡': '**',
multi_map.update(single_map)
def dewinize(txt):
"""把Win1252符号替换成ASCII字符或序列"""
return txt.translate(multi_map)
def asciize(txt):
no_marks = shave_marks_latin(dewinize(txt))
no_marks = no_marks.replace('ß', 'ss')
return unicodedata.normalize('NFKC', no_marks)
Unicode 文本排序
python 比较任何类型的序列时,会一一比较序列中的各个元素。对字符串来说,比较的是码位。但是在比较 非ASCII 字符时,得到的结果不尽如人意。
In [13]: fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
In [14]: sorted(fruits)
Out[14]: ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
In [15]: fruits
Out[15]: ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
不同的区域采用的排序规则有所不同,葡萄牙语等很多语言按照拉丁字母表排序,重音符号和下加符对排序几乎没什么影响。9 因此,排序时“cajá”视作“caja”,必定排在“caju”前面。
变音符号对排序有影响的情况很少发生,只有两个词之间唯有变音符号不同时才有影响。此时,带有变音符号的词排在常规词的后面。
然而,python3.6
In [19]: fruits = ['cajuf', 'atemoia', 'cajáe', 'açaí', 'acerola']
In [20]: sorted(fruits)
Out[20]: ['acerola', 'atemoia', 'açaí', 'cajuf', 'cajáe']
在 Python 中,非 ASCII 文本的标准排序方式是使用 locale.strxfrm 函数,根据 locale模块的文档(https://docs.python.org/3/library/locale.html?highlight=strxfrm#locale.strxfrm),这 个函数会“把字符串转换成适合所在区域进行比较的形式”。
使用 locale.strxfrm 函数之前,必须先为应用设定合适的区域设置,还要祈祷操作系统支持这项设置。
>>> import locale
>>> locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')
'pt_BR.UTF-8'
>>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
>>> sorted_fruits = sorted(fruits, key=locale.strxfrm)
>>> sorted_fruits
['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
因此,使用 locale.strxfrm 函数做排序键之前,要调用 setlocale(LC_COLLATE, «your_locale»)。
不过,有几点要注意。
区域设置是全局的,因此不推荐在库中调用 setlocale 函数。应用或框架应该在进程启动时设定区域设置,而且此后不要再修改。
操作系统必须支持区域设置,否则 setlocale 函数会抛出 locale.Error: unsupported locale setting 异常。
必须知道如何拼写区域名称。它在 Unix 衍生系统中几乎已经形成标准,要通过'language_code.encoding' 获取。10 但是在 Windows 中,句法复杂一些:Language Name-Language Variant_Region Name.codepage。注意,“Language Name”(语言名称)、“Language Variant”(语言变体)和“RegionName”(区域名)中可以包含空格;除了第一部分之外,其他部分的前面是不同的字符:一个连字符、一个下划线和一个点号。除了语言名称之外,其他部分好像都是可选的。例如,English_United States.850,它的语言名称是“English”,区域是“United States”,代码页是“850”。Windows 能理解的语言名称和区域名见于 MSDN中的文章“Language Identifier Constants and Strings”(https://msdn.microsoft.com/en-us/library/dd318693.aspx),还有“Code Page Identifiers”(https://msdn.microsoft.com/en-us/library/windows/desktop/dd317756(v=vs.85).aspx)一文列出了最后一部分的代码页数字。
操作系统的制作者必须正确实现了所设的区域。我在 Ubuntu 14.04 中成功了,但在OS X(Mavericks 10.9)中却失败了。在两台 Mac 中,调用setlocale(LC_COLLATE, 'pt_BR.UTF-8') 返回的都是字符串 'pt_BR.UTF-8',没有任何问题。但是,sorted(fruits, key=locale.strxfrm) 得到的结果与sorted(fruits) 一样,是错误的。我还在 OS X 中尝试了 fr_FR、es_ES 和de_DE,但是 locale.strxfrm 并未起作用。
在 Linux 操作系统中,中国大陆的读者可以使用 zh_CN.UTF-8,简体中文会按照汉语拼音顺序进行排序,它也能对葡萄牙语进行正确排序。——编者注
使用Unicode排序算法排序
James Tauber,一位高产的 Django 贡献者,他一定是感受到了这一痛点,因此开发了PyUCA 库(https://pypi.python.org/pypi/pyuca/),这是 Unicode 排序算法(UnicodeCollation Algorithm,UCA)的纯 Python 实现。
In [1]: fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
In [2]: sorted(fruits)
Out[2]: ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
In [3]: import pyuca
In [4]: coll = pyuca.Collator()
In [5]: sorted(fruits,key=coll.sort_key)
Out[5]: ['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
PyUCA 没有考虑区域设置。如果想定制排序方式,可以把自定义的排序表路径传给 Collator() 构造方法。PyUCA 默认使用项目自带的 allkeys.txt(https://github.com/jtauber/pyuca),这就是 Unicode 6.3.0 的“Default Unicode Collation Element Table”(http://www.unicode.org/Public/UCA/6.3.0/allkeys.txt)的副本。
Unicode 数据库
Unicode 标准提供了一个完整的数据库(许多格式化的文本文件),不仅包括码位与字符名称之间的映射,还有各个字符的元数据,以及字符之间的关系。例如,Unicode 数据库记录了字符是否可以打印、是不是字母、是不是数字,或者是不是其他数值符号。字符串的 isidentifier、isprintable、isdecimal 和 isnumeric 等方法就是靠这些信息作判断的。 str.casefold 方法也用到了 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')
U+0031 1 re_dig isdig isnum 1.00 DIGIT ONE
U+00bc ¼ - - isnum 0.25 VULGAR FRACTION ONE QUARTER
U+00b2 ² - isdig isnum 2.00 SUPERSCRIPT TWO
U+0969 ३ re_dig isdig isnum 3.00 DEVANAGARI DIGIT THREE
U+136b ፫ - isdig isnum 3.00 ETHIOPIC DIGIT THREE
U+216b Ⅻ - - isnum 12.00 ROMAN NUMERAL TWELVE
U+2466 ⑦ - isdig isnum 7.00 CIRCLED DIGIT SEVEN
U+2480 ⒀ - - isnum 13.00 PARENTHESIZED NUMBER THIRTEEN
U+3285 ㊅ - - isnum 6.00 CIRCLED IDEOGRAPH SIX
表明,正则表达式 r'\d' 能匹配数字“1”和梵文数字 3,但是不能匹配 isdigit 方法判断为数字的其他字符。re 模块对 Unicode 的支持并不充分。PyPI 中有个新开发的 regex 模块,它的最终目的是取代 re 模块,以提供更好的 Unicode 支持。
支持字符串和字节序列的双模式API
1 正则表达式中的字符串和字节序列
使用字节序列构建正则表达式,\d和\w等模式只能匹配ASCII字符。
使用字符串模式,就能匹配ASCII之外的Unicode数字或字母。
text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef" " as 1729 = 1³ + 12³ = 9³ + 10³.")
import re
re_numbers_str = re.compile(r'\d+') # 字符串
re_words_str = re.compile(r'\w+')
re_numbers_bytes = re.compile(rb'\d+') # 字节序列
re_words_bytes = re.compile(rb'\w+')
text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef" # 1729
" as 1729 = 1³ + 12³ = 9³ + 10³.") # 编译时,两个字符串拼接起来
text_bytes = text_str.encode('utf_8')
print('Text', repr(text_str), sep='\n ')
print('Bytes', repr(text_bytes), sep='\n ')
print('Numbers')
print(' str :', re_numbers_str.findall(text_str))
print(' bytes:', re_numbers_bytes.findall(text_bytes))
print('Words')
print(' str :', re_words_str.findall(text_str))
print(' bytes:', re_words_bytes.findall(text_bytes))
'Ramanujan saw ௧௭௨௯ as 1729 = 1³ + 12³ = 9³ + 10³.'
Bytes
b'Ramanujan saw \xe0\xaf\xa7\xe0\xaf\xad\xe0\xaf\xa8\xe0\xaf\xaf as 1729 = 1\xc2\xb3 + 12\xc2\xb3 = 9\xc2\xb3 + 10\xc2\xb3.'
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']
print(' str match bytes :', re_numbers_str.findall(text_bytes))
Traceback (most recent call last):
File ".\aaa.py", line 17, in <module>
print(' str match bytes :', re_numbers_str.findall(text_bytes))
TypeError: cannot use a string pattern on a bytes-like object
print(' bytes match str :', re_numbers_bytes.findall(text_str))
Traceback (most recent call last):
File ".\aaa.py", line 18, in <module>
print(' bytes match str :', re_numbers_bytes.findall(text_str))
TypeError: cannot use a bytes pattern on a string-like object
字节序列:不能匹配泰米尔数字,也不能匹配上标。只能匹配 ASCII 字符。
不能混合搜索。
字符串模式 r'\d+'
能匹配泰米尔数字和 ASCII 数字。
字节序列模式 rb'\d+'
只能匹配 ASCII 字节中的数字。
字符串模式 r'\w+'
能匹配字母、上标、泰米尔数字和 ASCII 数字。
字节序列模式 rb'\w+'
只能匹配 ASCII 字节中的字母和数字。
ASCII 范围外的字节不会当成数字和组成单词的字母。
字符串正则表达式有个 re.ASCII 标志,它让 \w、\W、\b、\B、\d、\D、\s 和 \S 只匹配 ASCII 字符。详情参阅 re 模块的文档(https://docs.python.org/3/library/re.html)。
2 os 函数中的字符串和字节序列
GNU/Linux 内核不理解 Unicode,因此你可能发现了,对任何合理的编码方案来说,在文件名中使用字节序列都是无效的,无法解码成字符串。在不同操作系统中使用各种客户端的文件服务器,在遇到这个问题时尤其容易出错。
为了规避这个问题,os 模块中的所有函数、文件名或路径名参数既能使用字符串,也能使用字节序列。如果这样的函数使用字符串参数调用,该参数会使用 sys.getfilesystemencoding() 得到的编解码器自动编码,然后操作系统会使用相同的编解码器解码。这几乎就是我们想要的行为,与 Unicode 三明治最佳实践一致。
>>> os.listdir('.')
['abc.txt', 'digits-of-π.txt']
>>> os.listdir(b'.')
[b'abc.txt', b'digits-of-\xcf\x80.txt']
为了便于手动处理字符串或字节序列形式的文件名或路径名,os 模块提供了特殊的编码和解码函数。
fsencode(filename)
如果 filename 是 str 类型(此外还可能是 bytes 类型),使用 sys.getfilesystemencoding() 返回的编解码器把 filename 编码成字节序列;否则,返回未经修改的 filename 字节序列。
fsdecode(filename)
如果 filename 是 bytes 类型(此外还可能是 str 类型),使用 sys.getfilesystemencoding() 返回的编解码器把 filename 解码成字符串;否则,返回未经修改的 filename 字符串。
处理 解码 时的 鬼符:乱码的符号
在 Unix 衍生平台中,这些函数使用 surrogateescape 错误处理方式(参见下述附注栏)以避免遇到意外字节序列时卡住。Windows 使用的错误处理方式是 strict。
使用 surrogateescape 处理鬼符
Python 3.1 引入的 surrogateescape 编解码器错误处理方式是处理意外字节序列或未知编码的一种方式,它的说明参见“PEP 383 — Non-decodable Bytes in SystemCharacter Interfaces”(https://www.python.org/dev/peps/pep-0383/)。
这种错误处理方式会把每个**无法解码的字节 ** 替换成 Unicode 中 U+DC00 到 U+DCFF 之间的码位(Unicode 标准把这些码位称为“Low Surrogate Area”),这些码位是保留的,没有分配字符,供应用程序内部使用。编码时,这些码位会转换成被替换的字节值。
>>> os.listdir('.')
['abc.txt', 'digits-of-π.txt']
>>> os.listdir(b'.')
[b'abc.txt', b'digits-of-\xcf\x80.txt']
>>> pi_name_bytes = os.listdir(b'.')[1]
>>> pi_name_str = pi_name_bytes.decode('ascii', 'surrogateescape')
>>> pi_name_str
'digits-of-\udccf\udc80.txt'
>>> pi_name_str.encode('ascii', 'surrogateescape')
b'digits-of-\xcf\x80.txt
In [43]: b_bytes.decode('ascii','strict')
---------------------------------------------------------------------------
UnicodeDecodeError Traceback (most recent call last)
<ipython-input-43-356ed8582aca> in <module>
----> 1 b_bytes.decode('ascii','strict')
UnicodeDecodeError: 'ascii' codec can't decode byte 0xcf in position 10: ordinal not in range(128)
使用'ascii' 编解码器和 'surrogateescape' 错误处理方式把它解码成字符串。
各个非 ASCII 字节替换成代替码位:'\xcf\x80' 变成了'\udccf\udc80'。
编码成 ASCII 字节序列:各个代替码位还原成被替换的字节。
我们必须把文本字符串与它们在文件中的二进制序列表述区分开,而python3中这个区分是强制的。
文本比较是个异常复杂的任务,因为 Unicode为某些字符提供了不同的表示,所以匹配文本之前一定要先规范化。尤其是有特殊字符的时候。
还有字符串的排序, locale 和 Unicode排序算法 PyUCA 包。
人类使用文本,计算机使用字节序列。
——Esther Nam 和 Travis Fischer “Character Encoding and Unicode in Python”
“纯文本”是什么
对于经常处理非英语文本的人来说,“纯文本”并不是指“ASCII”。Unicode 词汇表(http://www.unicode.org/glossary/#plain_text)是这样定义纯文本的:
只由特定标准的码位序列组成的计算机编码文本,其中不含其他格式化或结构化信息。
这个定义的前半句说得很好,但是我不同意后半句。HTML 就是包含格式化和结构化信息的纯文本格式,但它依然是纯文本,因为 HTML 文件中的每个字节都表示文本字符(通常使用 UTF-8 编码),没有任何字节表示文本之外的信息。.png 或 .xsl 文档则不同,其中多数字节表示打包的二进制值,例如 RGB 值和浮点数。在纯文本中,数字使用数字符号序列表示。
在 RAM 中如何表示字符串
Python 官方文档对字符串的码位在内存中如何存储避而不谈。毕竟,这是实现细节。理论上,怎么存储都没关系:不管内部表述如何,输出时每个字符串都要编码成字节序列。
在内存中,Python 3 使用固定数量的字节存储字符串的各个码位,以便高效访问各个字符或切片。
在 Python 3.3 之前,编译 CPython 时可以配置在内存中使用 16 位或 32 位存储各个码位。16 位是“窄构建”(narrow build),32 位是“宽构建”(wide build)。如果想知道用的是哪个,要查看 sys.maxunicode 的值:65535 表示“窄构建”,不能透明地处理 U+FFFF 以上的码位。“宽构建”没有这个限制,但是消耗的内存更多:每个字符占 4个字节,就算是中文象形文字的码位大多数也只占 2 个字节。这两种构建没有高下之分,应该根据自己的需求选择。
从 Python 3.3 起,创建 str 对象时,解释器会检查里面的字符,然后为该字符串选择最经济的内存布局:如果字符都在 latin1 字符集中,那就使用 1 个字节存储每个码位;否则,根据字符串中的具体字符,选择 2 个或 4 个字节存储每个码位。这是简述,完整细节参阅“PEP 393—Flexible String Representation”(https://www.python.org/dev/peps/pep-0393/)。
灵活的字符串表述类似于 Python 3 对 int 类型的处理方式:如果一个整数在一个机器字中放得下,那就存储在一个机器字中;否则解释器切换成变长表述,类似于Python 2 中的 long 类型。这种聪明的做法得到推广,真是让人欢喜!