Skip to content

文本与字节序列

此笔记记录于《流畅的python》,大部分为其中的摘要,少部分为笔者自己的理解;笔记为jupyter转的markdown,原始版jupyter笔记在这个仓库

字符问题

Unicode标准把字符的标识和具体的字节表述进行了如下的明确区分:

  • 字符的标识,即码位,是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与utf-8之间的关系

python
s = 'café' # 这里有4个Unicode字符
len(s) # 4
4
python
b = s.encode('utf8') # 使用UTF-8把str编码成bytes
b
b'caf\xc3\xa9'
python
len(b) # 字节序列b有5个字节(在UTF-8中,“é”的码位编码成两个字节)
5
python
b.decode('utf8') # 用UTF-8解码成str
'café'

可以把字节序列想成晦涩难懂的机器磁芯转储,把Unicode字符串想成“人类可读”的文本。

字节概要

Python内置了两种基本的二进制序列类型:Python 3引入的不可变bytes类型和Python 2.6添加的可变bytearray类型。(Python 2.6也引入了bytes类型,但那只不过是str类型的别名,与Python 3的bytes类型不同。)

bytes或bytearray对象的各个元素是介于0~255(含)之间的整数,而不像Python 2的str对象那样是单个的字符。二进制序列的切片始终是同一类型的二进制序列,包括长度为1的切片

python
cafe = bytes('café', encoding='utf_8')
cafe
b'caf\xc3\xa9'
python
cafe[0] # 99
99
python
cafe[:1] # b'c'
b'c'
python
cafe_arr = bytearray(cafe)
cafe_arr
bytearray(b'caf\xc3\xa9')
python
cafe_arr[-1:] # bytearray(b'é')
bytearray(b'\xa9')

my_bytes[0]获取的是一个整数,而my_bytes[:1]返回的是一个长度为1的bytes对象——这一点应该不会让人意外。s[0]==s[:1]只对str这个序列类型成立。

虽然二进制序列其实是整数序列,但是它们的字面量表示法表明其中有ASCII文本。因此,各个字节的值可能会使用下列三种不同的方式显示。

  • 可打印的ASCII范围内的字节(从空格到~),使用ASCII字符本身。
  • 制表符、换行符、回车符和\对应的字节,使用转义序列\t、\n、\r和\。
  • 其他字节的值,使用十六进制转义序列(例如,\x00是空字节)。

我们看到的是b'caf\xc3\xa9':前3个字节b'caf'在可打印的ASCII范围内,后两个字节则不然。

除了格式化方法(format和format_map)和几个处理Unicode数据的方法(包括casefold、isdecimal、isidentifier、isnumeric、isprintable和encode)之外,str类型的其他方法都支持bytes和bytearray类型

除了格式化方法(format和format_map)和几个处理Unicode数据的方法(包括casefold、isdecimal、isidentifier、isnumeric、isprintable和encode)之外,str类型的其他方法都支持bytes和bytearray类型

python
bytes.fromhex('31 4B CE A9') # b'1KΩ'
b'1K\xce\xa9'

使用缓冲类对象创建bytes或bytearray对象时,始终复制源对象中的字节序列。与之相反,memoryview对象允许在二进制数据结构之间共享内存。

结构体和内存视图

struct模块提供了一些函数,把打包的字节序列转换成不同类型字段组成的元组,还有一些函数用于执行反向转换,把元组转换成打包的字节序列。struct模块能处理bytes、bytearray和memoryview对象。

python
# 使用memoryview和struct查看一个GIF图像的首部
import struct

fmt = '<3s3sHH' # 结构体的格式:<表示小字节序,3s3s是两个3字节序列,HH是两个16位二进制整数
with open('filter.gif', 'rb') as fp:
    img = memoryview(fp.read())
    
header = img[:10]
bytes(header) # b'GIF89a+\x02\x0f\x00'
python
struct.unpack(fmt, header) # (b'GIF', b'89a', 555, 15)
python
del header
del img

基本的编解码器

Python自带了超过100种编解码器(codec, encoder/decoder),用于在文本和字节之间相互转换。每个编解码器都有一个名称,如'utf_8',而且经常有几个别名,如'utf8'、'utf-8'和'U8'。这些名称可以传给open( )、str.encode( )bytes.decode( )`等函数的encoding参数。

python
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'

处理编码问题:略(遇到了再说)

有些通信协议和文件格式,如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就是这样工作的,它能识别所支持的30种编码。

处理文本文件

处理文本的最佳实践是“Unicode三明治”

需要在多台设备中或多种场合下运行的代码,一定不能依赖默认编码。打开文件时始终应该明确传入encoding=参数,因为不同的设备使用的默认编码可能不同,有时隔一天也会发生变化。

为了正确比较而规范化Unicode字符串

因为Unicode有组合字符(变音符号和附加到前一个字符上的记号,打印时作为一个整体),所以字符串比较起来很复杂。

“café”这个词可以使用两种方式构成,分别有4个和5个码位,但是结果完全一样

python
s1 = 'café'
s2 = 'cafe\u0301'
s1, s2
('café', 'café')
python
len(s1), len(s2)
(4, 5)
python
s1 == s2
False

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把组合字符分解成基字符和单独的组合字符。

使用NFKC和NFKD规范化形式时要小心,而且只能在特殊情况中使用,例如搜索和索引,而不能用于持久存储,因为这两种转换会导致数据损失。

大小写折叠其实就是把所有文本变成小写,再做些其他转换。这个功能由str.casefold( )方法(Python 3.3新增)支持。对于只包含latin1字符的字符串s,s.casefold( )得到的结果与s.lower( )一样,唯有两个例外:微符号'µ'会变成小写的希腊字母“μ”(在多数字体中二者看起来一样);德语Eszett(“sharp s”,ß)会变成“ss”。

略一些

支持字符串和字节序列的双模式API

标准库中的一些函数能接受字符串或字节序列为参数,然后根据类型展现不同的行为。re和os模块中就有这样的函数。

如果使用字节序列构建正则表达式,\d\w等模式只能匹配ASCII字符;相比之下,如果是字符串模式,就能匹配ASCII之外的Unicode数字或字母。

python
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"
            " as 1729 = 1³+12³ = 9³+10³.")
text_bytes = text_str.encode('utf_8')
print('Text', repr(text_str), 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))
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']

GNU/Linux内核不理解Unicode,因此你可能发现了,对任何合理的编码方案来说,在文件名中使用字节序列都是无效的,无法解码成字符串。在不同操作系统中使用各种客户端的文件服务器,在遇到这个问题时尤其容易出错。

为了规避这个问题,os模块中的所有函数、文件名或路径名参数既能使用字符串,也能使用字节序列。如果这样的函数使用字符串参数调用,该参数会使用sys.getfilesystemencoding( )得到的编解码器自动编码,然后操作系统会使用相同的编解码器解码。这几乎就是我们想要的行为,与Unicode三明治最佳实践一致。

但是,如果必须处理(也可能是修正)那些无法使用上述方式自动处理的文件名,可以把字节序列参数传给os模块中的函数,得到字节序列返回值。这一特性允许我们处理任何文件名或路径名,不管里面有多少鬼符