流畅的python读书笔记④:文本和字节序列

人类使用文本,计算机使用字节序列。
——Esther Nam 和 Travis Fischer
“Character Encoding and Unicode in Python”


4.1 字符问题

字符是什么?

在 2015 年,“字符”的最佳定义是 Unicode 字符。因此,从 Python 3 的 str 对象中获取的元素是 Unicode 字符。这相对于python 2的 str 对象中获取的原始字节序列来说无异于更加方便——你看见几个字符就能获取到几个对象。

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

  • 字符的标识,即码位,是 0-1 114 111 的数字(十进制),在 Unicode 标准中以 4-6个十六进制数字表示,而且加前缀“U+”。
  • 字符的具体表述取决于所用的编码。编码是在码位和字节序列之间转换时使用的算法。

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

>>> s = '流畅的python'
>>> len(s)
9
>>> b = s.encode('utf8')
>>> b
b'\xe6\xb5\x81\xe7\x95\x85\xe7\x9a\x84python'
>>> len(b)
15
>>> b.decode('utf8')
'流畅的python'

4.2 字节概要

Python 内置了两种基本的二进制序列类型:不可变 bytes 类型和可变 bytearray 类型。bytes 或 bytearray 对象的各个元素是介于 0~255(含)之间的整数,而非单个字符(这与ASCII码十分相似)。然而,二进制序列的切片始终是同一类型的二进制序列,包括长度为 1 的切片。这说明在处理其时务必要将其当成整体来看待。

>>> s = bytes('流畅的python', encoding='utf8')
>>> s
b'\xe6\xb5\x81\xe7\x95\x85\xe7\x9a\x84python'
>>> s[0]
230
>>> s[:1]
b'\xe6'
>>> s_arr = bytearray(s)
>>> s_arr
bytearray(b'\xe6\xb5\x81\xe7\x95\x85\xe7\x9a\x84python')
>>> s_arr[-1:]
bytearray(b'n')

除了格式化方法(format 和 format_map)和几个处理 Unicode 数据的方法(包括casefold、isdecimal、isidentifier、isnumeric、isprintable 和 encode)之外,str 类型的其他方法都支持 bytes 和 bytearray 类型。这意味着,我们可以使用熟悉的字符串方法处理二进制序列,如 endswith、replace、strip、translate、upper等,只有少数几个其他方法的参数是 bytes 对象,而不是 str 对象。

二进制序列有一个特有的方法,fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的),构建二进制序列:
>>> bytes.fromhex('31 4B CE A9') b'1K\xce\xa9'

二进制序列对象还有其本身的构造方法,传入:

  • 一个 str 对象和一个 encoding 关键字参数。

  • 一个可迭代对象,提供 0~255 之间的数值。

  • 一个实现了缓冲协议的对象(如bytes、bytearray、memoryview、array.array);此时,把源对象中的字节序列复制到新建的二进制序列中。

4.3 基本的编解码器

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

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

在这里插入图片描述

4.4 处理编解码错误

4.4.1 处理UnicodeEncodeError(编码错误)

我们通过传入errors参数来改变处理错误的方法

>>> msg = '北京2022'
>>> msg.encode('utf8')  # utf?格式可以处理任意字符
b'\xe5\x8c\x97\xe4\xba\xac2022'
>>> msg.encode('utf16')
b'\xff\xfe\x17S\xacN2\x000\x002\x002\x00'
>>> msg.encode('cp437')  # 'cp437'无法编码中文,默认处理方式'strict'抛出UnicodeEncodeError
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "D:\py\Anaconda\lib\encodings\cp437.py", line 12, in encode
    return codecs.charmap_encode(input,errors,encoding_map)
UnicodeEncodeError: 'charmap' codec can't encode characters in position 0-1: character maps to <undefined>
>>> msg.encode('cp437', errors='ignore')  # errors='ignore'跳过无法编码的字符
b'2022'
>>> msg.encode('cp437', errors='replace')  # errors='replace'将无法编码的字符替换成'?' 
b'??2022'
>>> msg.encode('cp437', errors='xmlcharrefreplace')  # errors='xmlcharrefreplace'将无法编码的字符替换成 XML 实体。
b'&#21271;&#20140;2022'

这种方法是可扩展的,详见:参考文档

4.4.2 处理 UnicodeDecodeError(解码错误)

不同的编码方式所得的同一编码,其解码肯定不相同。

乱码字符称为鬼符(gremlin)或 mojibake(文字化け,“变形文本”的日文)。演示了使用错误的编解码器可能出现鬼符或抛出UnicodeDecodeError。

>>> octets = b'Montr\xe9al'   # 使用 latin1 编码的“Montréal”
>>> octets.decode('cp1252')   # 'cp1252'能正确处理
'Montréal'
>>> octets.decode('iso8859_7')  # iso8559_7用以编码希腊文,无法正确处理
'Montrιal'
>>> octets.decode('koi8_r')  # koi8_r编码俄文
'MontrИal'
>>> octets.decode('utf_8')  # 抛出UnicodeDecodeError错误
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
>>> octets.decode('utf_8', errors='replace')  # 将无法处理的编码替换成�(码位是 U+FFFD)
'Montr�al'

4.4.5 BOM 有用的鬼符

UTF-16编码在小端设备和大端设备中,编码的储存顺序不同。为了区分,UTF-16 编码在要编码的文本前面加上特殊的不可见字符 ZERO WIDTH NO-BREAK SPACE(U+FEFF)。在小字节序系统中,这个字符编码为 b’\xff\xfe’(十进制数 255, 254)。U+FEFF编码不能被打印,在小字节序编码中,字节序列b’\xff\xfe’ 必定是 ZERO WIDTH NO-BREAK SPACE。

UTF-16 有两个变种:UTF-16LE,显式指明使用小字节序;UTF-16BE,显式指明使用大字节序。如果使用这两个变种,不会生成 BOM。

UTF-8的一大优势是,不管设备使用哪种字节序,生成的字节序列始终一致,因此不需要 BOM。尽管如此,有些应用依旧会在UTF-8文件开头添加BOM。

4.5 处理文本文件

目前处理文本的最佳实践是“Unicode三明治”。
在这里插入图片描述
在不同的系统中使用的默认编码(博主的电脑是cp936)不一样,而python如果读取文件时未指定默认编码。这就是说输出可能不如你意。

open('cafe.txt', 'w', encoding='utf_8').write('café')
file = open('cafe.txt')
print(file)
# <_io.TextIOWrapper name='cafe.txt' mode='r' encoding='cp936'>
print(file.read())
# caf茅
file.close()
file = open('cafe.txt', encoding='utf8')
print(file.read())
# café

如果你的代码只运行在你自己的电脑中,那肯定不需要注意编码问题,然而需要在多台设备中或多种场合下运行的代码,一定不能依赖默认编码。打开文件时始终应该明确传入 encoding= 参数,因为不同的设备使用的默认编码可能不同,有时隔一天也会发生变化。

4.6 规范化Unicode字符串

由于Unicode中组合字符的存在,相同的Unicode字面量可能有不同的编码,如

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

可以看到,s1, s2打印的结果是一样的,但是表达式s1 == s2却是不成立的。在 Unicode 标准中,'é’和 ‘e\u0301’ 这样的序列叫“标准等价物”(canonical equivalent),应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。

此时应该使用unicodedata.normalize函数提供的Unicode规范化。其有四种模式。

  • NFC 使用最少的码位构成等价的字符串
  • NFD 把组合字符分解成基字符和单独的组合字符
  • NFKC 和 NFKD 形式中,各个兼容字符会被替换成一个或多个“兼容分解”字符,即便这样有些格式损失,但仍是“首选”表述。二分之一’½’(U+00BD)经过兼容分解后得到的是
    三个字符序列 ‘1/2’。

需要注意的是除非特殊情况,否则不用后两种规范化进行储存,原因是其可能会出现数据丢失,而仅将其用于搜索和索引。

4.6.1 大小写折叠

py3新增的str.casefold()方法将会将所有的文本转换为小写。与str.lower()不同,str.casefold()是py对Unicode的专门的解决方案,其进行转换的结果将于前者有些许不同。但是这只占Unicode字符的0.11%(116/110 122)。

4.6.3 极端的规范化:从Unicode到ASCII

利用上面提到的一些函数和方法,我们可以自定义实现一些函数,使得字符串被极端的规范化——甚至使其与最简单的字符集之一,ASCII码对应。

4.7 对Unicode排序

原生python下虽然能对非ASCII码进行排序,但是其设置比较复杂,且容易更改全局的字符编码方案,条件比较苛刻。

对Unicode排序存在一个第三方库:PyUCA。其能比较方便的实现排序。

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

4.8 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')

参考链接


声明

本文来自《流畅的python》以及笔者自己的思考,如有错误,欢迎指正。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值