python进阶书目串烧(八)

第四章 文本和字节序列

人类使用文本,计算机使用字节序列。——Esther Nam 和 Travis Fischer1

Python3明确区分了人类可读的文本字符串和原始的字节序列。隐式地把字节序列转换成Unicode文本已成过去。本章将要讨论Unicode字符串、二进制序列,以及在二者之间转换时使用的编码。

深入理解Unicode对你可能十分重要,也可能无关紧要,这取决于Python编程的场景。说到底,本章涵盖的问题对只处理ASCII文本的程序员没有影响。但是即便如此,也不能避而不谈字符串和字节序列的区别。此外,你会发现专门的二进制序列类型所提供的功能,有些Python 2中“全功能”的str类型不具有的。

  • 字符、码位和字节表述
  • bytesbytearraymemoryview 等二进制序列的独特特性
  • 全部 Unicode 和陈旧字符集的编解码器
  • 避免和处理编码错误
  • 处理文本文件的最佳实践
  • 默认编码的陷阱和标准 I/O 的问题
  • 规范化 Unicode 文本,进行安全的比较

字符问题

“字符串”是个相当简单的概念:一个字符串是一个字符序列。问题出在“字符”的定义上。

在 2015 年,“字符”的最佳定义是 Unicode 字符。因此,从 Python 3 的str对象中获取的元素是Unicode字符,这相当于从Python 2的unicode对象中获取的元素,而不是从Python 2的 str对象中获取的原始字节序列。

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

  • 字符的标识,即码位,是 0~1 114 111 的数字(十进制),在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

把码位转换成字节序列的过程是编码;把字节序列转换成码位的过程是解码。

编码和解码:

s = 'café'
print(len(s))

b = s.encode('utf8')
print(b)
b'caf\xc3\xa9'
print(len(b))

b.decode('utf8')
print('café')

如果想帮助自己记住 .decode().encode() 的区别,可以把字节序列想成晦涩难懂的机器磁芯转储,把 Unicode 字符串想成“人类可读”的文本。那么,把字节序列变成人类可读的文本字符串就是解码,而把字符串变成用于存储或传输的字节序列就是编码。

虽然 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 类型不同。)

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

包含 5 个字节的 bytesbytearray 对象

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

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

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

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

除了格式化方法(formatformat_map)和几个处理 Unicode 数据的方法(包括casefoldisdecimalisidentifierisnumericisprintableencode)之外,str 类型的其他方法都支持 bytesbytearray 类型。这意味着,我们可以使用熟悉的字符串方法处理二进制序列,如endswithreplacestriptranslateupper 等,只有少数几个其他方法的参数是 bytes 对象,而不是 str 对象。此外,如果正则表达式编译自二进制序列而不是字符串,re 模块中的正则表达式函数也能处理二进制序列。3

a = bytes.fromhex('31 4B CE A9')
print(a)  # b'1K\xce\xa9'

构建 bytesbytearray 实例还可以调用各自的构造方法,传入下述参数。

  • 一个 str 对象和一个 encoding 关键字参数。
  • 一个可迭代对象,提供 0~255 之间的数值。
  • 一个整数,使用空字节创建对应长度的二进制序列。[Python 3.5 会把这个构造方法标记为“过时的”,Python 3.6 会将其删除。参见“PEP 467—Minor API improvements for binary sequences”。]
  • 一个实现了缓冲协议的对象(如bytesbytearraymemoryviewarray.array);此时,把源对象中的字节序列复制到新建的二进制序列中。

使用缓冲类对象构建二进制序列是一种低层操作,可能涉及类型转换。

使用数组中的原始数据初始化 bytes 对象

import array

numbers = array.array('h', [-2, -1, 0, 1, 2])
octets = bytes(numbers)
print(octets)  # b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'

使用缓冲类对象创建 bytesbytearray 对象时,始终复制源对象中的字节序列。与之相反,memoryview 对象允许在二进制数据结构之间共享内存。如果想从二进制序列中提取结构化信息,struct 模块是重要的工具。下一节会使用这个模块处理 bytesmemoryview 对象。

结构体和内存视图

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

memoryview 类不是用于创建或存储字节序列的,而是共享内存,让你访问其他二进制序列、打包的数组和缓冲中的数据切片,而无需复制字节序列,例如 Python Imaging Library(PIL) 就是这样处理图像的。4

如何使用 memoryviewstruct 提取一个 GIF 图像的宽度和高度。
在这里插入图片描述

import struct

fmt = '<3s3sHH'
with open('filter.gif', 'rb') as fp:  # gif为上方的大拇指
    img = memoryview(fp.read())
header = img[:10]
print(bytes(header))  # b'GIF89a\x16\x00\x16\x00'
print(struct.unpack(fmt, header))  # (b'GIF', b'89a', 22, 22)
del header
del img

注意,memoryview 对象的切片是一个新 memoryview 对象,而且不会复制字节序列。[ 如果使用 mmap 模块把图像打开为内存映射文件,那么会复制少量字节。本文不会讨论 mmap,如果你经常读取和修改二进制文件,可以阅读“mmap—Memory-mapped file support”来进一步学习。]

本文不会深入介绍 memoryviewstruct 模块,如果要处理二进制数据,可以阅读它们的文档:“Built-in Types » Memory Views”“struct—Interpret bytes as packed binary data”

基本的编解码器

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

使用 3 个编解码器编码字符串“El Niño”,得到的字节序列差异很大:

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'

下图展示了不同编解码器对“A”高音谱号等字符编码后得到的字节序列。注意,后 3 种是可变长度的多字节编码。

12个字符,它们的码位及不同编码的字节表述(十六进制,星号表明该编码不支持表示该字符)
在这里插入图片描述
上图星号表明,某些编码(如 ASCII 和多字节的 GB2312)不能表示所有 Unicode 字符。然而,UTF 编码的设计目的就是处理每一个Unicode 码位。

了解编解码问题

虽然有个一般性的 UnicodeError 异常,但是报告错误时几乎都会指明具体的异常:UnicodeEncodeError(把字符串转换成二进制序列时)或 UnicodeDecodeError(把二进制序列转换成字符串时)。如果源码的编码与预期不符,加载 Python 模块时还可能抛出 SyntaxError。接下来的几节说明如何处理这些错误。

处理UnicodeEncodeError

多数非 UTF 编解码器只能处理 Unicode 字符的一小部分子集。把文本转换成字节序列时,如果目标编码中没有定义某个字符,那就会抛出UnicodeEncodeError 异常,除非把 errors 参数传给编码方法或函数,对错误进行特殊处理。5

编码成字节序列:成功和错误处理

city = 'São Paulo'

print(city.encode('utf_8'))  # b'S\xc3\xa3o Paulo'
# 'utf_?'编码能处理任何字符串。

print(city.encode('utf_16'))  # b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00'

print(city.encode('iso8859_1'))  # b'S\xe3o Paulo'
# 'iso8859_1'编码也能处理字符串'São Paulo'。

print(city.encode('cp437'))
# 'cp437' 无法编码 'ã'(带波形符的“a”),抛出异常
# Traceback (most recent call last):
#   File "<stdin>", line 1, in <module>
#   File "/.../lib/python3.4/encodings/cp437.py", line 12, in encode
#     return codecs.charmap_encode(input,errors,encoding_map)
# UnicodeEncodeError: 'charmap' codec can't encode character '\xe3' in
# position 1: character maps to <undefined>

print(city.encode('cp437', errors='ignore'))  # b'So Paulo'
# 悄无声息地跳过无法编码的字符

print(city.encode('cp437', errors='replace'))  # b'S?o Paulo'
# 把无法编码的字符替换成 '?'

print(city.encode('cp437', errors='xmlcharrefreplace'))  # b'S&#227;o Paulo'
# 把无法编码的字符替换成 XML 实体

处理UnicodeDecodeError

不是每一个字节都包含有效的 ASCII 字符,也不是每一个字符序列都是有效的 UTF-8UTF-16。因此,把二进制序列转换成文本时,如果假设是这两个编码中的一个,遇到无法转换的字节序列时会抛出UnicodeDecodeError

另一方面,很多陈旧的 8 位编码——如 'cp1252''iso8859_1''koi8_r'——能解码任何字节序列流而不抛出错误,例如随机噪声。因此,如果程序使用错误的 8 位编码,解码过程悄无声息,而得到的是无用输出。6

把字节序列解码成字符串:成功和错误处理:

octets = b'Montr\xe9al'
# 使用latin1编码的“Montréal”;'\xe9' 字节对应“é”。

print(octets.decode('cp1252'))  # Montréal
# 可以使用'cp1252'(Windows 1252)解码,因为它是latin1的有效超集。

print(octets.decode('iso8859_7'))  # Montrιal
# ISO-8859-7用于编码希腊文,因此无法正确解释'\xe9'字节,而且没有抛出错误。

print(octets.decode('koi8_r'))  # MontrИal
# KOI8-R用于编码俄文;这里,'\xe9' 表示西里尔字母“И”。

print(octets.decode('utf_8'))
# 'utf_8' 编解码器检测到octets不是有效的UTF-8字符串,抛出异常
# 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

print(octets.decode('utf_8', errors='replace'))  # Montr�al
# 使用'replace'错误处理方式,\xe9替换成了“�”

使用预期之外的编码加载模块时抛出的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/LinuxOS X系统大都使用 UTF-8,因此打开在 Windows 系统中使用cp1252编码的 .py 文件时可能发生这种情况。注意,这个错误在Windows 版Python中也可能会发生,因为 Python3为所有平台设置的默认编码都是 UTF-87

为了修正这个问题,可以在文件顶部添加一个神奇的 coding 注释:

# ola.py:“你好,世界!”的葡萄牙语版

# coding: cp1252
print('Olá, Mundo!')

Python 3 允许在源码中使用非 ASCII 标识符:

ação = 'PBR'  # ação = stock
ε = 10**-6    # ε = epsilon

有些人不喜欢这么做。支持始终使用 ASCII 标识符的人认为,这样便于所有人阅读和编辑代码。这些人没切中要害:源码应该便于目标群体阅读和编辑,而不是“所有人”。如果代码属于跨国公司,或者是开源的,想让来自世界各地的人作贡献,那么标识符应该使用英语,也就是说只能使用 ASCII 字符。

但是,如果你是巴西的一位老师,那么使用葡萄牙语正确拼写变量和函数名更便于学生阅读代码。而且,这些学生在本地化的键盘中不难打出变音符号和重音元音字母。

现在,Python 能解析 Unicode 名称,而且源码的默认编码是 UTF-8,我觉得没有任何理由使用不带重音符号的葡萄牙语编写标识符。在 Python 2 中确实不能这么做,除非你也想使用 Python 2 运行代码,否则不必如此。如果使用葡萄牙语命名标识符却不带重音符号的话,这样写出的代码对任何人来说都不易阅读。

选择对团队而言易于阅读的人类语言,然后使用正确的字符拼写。


  1. PyCon 2014,“Character Encoding and Unicode in Python”演讲的第 12 张幻灯片。 ↩︎

  2. my_bytes[0] 获取的是一个整数,而 my_bytes[:1] 返回的是一个长度为 1 的 bytes 对象——这一点应该不会让人意外。s[0] == s[:1] 只对 str 这个序列类型成立。不过,str 类型的这个行为十分罕见。对其他各个序列类型来说,s[i] 返回一个元素,而 s[i:i+1] 返回一个相同类型的序列,里面是 s[i] 元素。 ↩︎

  3. Python 3.0~3.4 不能使用%运算符处理二进制序列,但是根据“PEP 461—Adding % formatting to bytes and bytearray”,Python 3.5 应该会支持。 ↩︎

  4. PillowPIL 最活跃的派生库。 ↩︎

  5. 编解码器的错误处理方式是可扩展的。你可以为 errors 参数注册额外的字符串,方法是把一个名称和一个错误处理函数传给codecs.register_error 函数。参见codecs.register_error函数的文档↩︎

  6. 乱码字符称为鬼符(gremlin)或 mojibake(文字化け,“变形文本”的日文)。 ↩︎

  7. 现在,Python 3 的源码不再限于使用 ASCII,而是默认使用优秀的 UTF-8 编码,因此要修正源码的陈旧编码(如 'cp1252')问题,最好将其转换成 UTF-8,别去麻烦 coding 注释。如果你用的编辑器不支持 UTF-8,那么是时候换一个了。 ↩︎

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值