流畅的python学习笔记(三):数据结构(3:文本和字节序列)

大纲

  • 本章将讨论下述话题
    • 字符、码位和字节表述
    • bytes、bytearray 和 memoryview 等二进制序列的独特特性
    • 全部 Unicode 和陈旧字符集的编解码器
    • 避免和处理编码错误
    • 处理文本文件的最佳实践
    • 默认编码的陷阱和标准 I/O 的问题
    • 规范化 Unicode 文本,进行安全的比较
    • 规范化、大小写折叠和暴力移除音调符号的实用函数
    • 使用 locale 模块和 PyUCA 库正确地排序 Unicode 文本
    • Unicode 数据库中的字符元数据
    • 能处理字符串和字节序列的双模式 API

1. 字符问题

  • “字符串”是个相当简单的概念:一个字符串是一个字符序列。问题出在“字符”的定义上。
  • “字符”的最佳定义是 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。
    • 字符的具体表述取决于所用的编码。编码是在码位字节序列之间转换时使用的算法。在 UTF-8 编码中,A(U+0041)的码位编码成单个字节 \x41,而在 UTF-16LE 编码中编码成两个字节\x41\x00。
  • 把码位转换成字节序列的过程是编码;把字节序列转换成码位的过程是解码。示例如下:
'''
	示例 1 编码和解码
'''
if __name__ == '__main__':
    s = "cafe"
    b = s.encode(encoding="utf-8")  # 使用 UTF-8 把 str 对象编码成 bytes 对象
    print(b)  # b'cafe'
    s2 = b.decode(encoding="utf-8")  # 使用 UTF-8 把 bytes 对象解码成 str 对象
    print(s2)  # cafe
  • 在讨论编码和解码的问题之前,有必要先来介绍一下二进制序列类型。

2. 字节概要

  • Python 内置了两种基本的二进制序列类型:Python 3 引入的不可变bytes 类型和 Python 2.6 添加的可变 bytearray 类型。
  • bytes 或 bytearray 对象的各个元素是介于 0~255(含)之间的整数,而不像 Python 2 的 str 对象那样是单个的字符。然而,二进制序列的切片始终是同一类型的二进制序列,包括长度为 1 的切片。如下示例 2 :
'''
	示例 2 bytes 和 bytearray 对象
'''
if __name__ == '__main__':
    # bytes 对象可以从 str 对象使用给定的编码构建
    cafe = bytes('cafe', encoding='utf-8')
    # 各个元素是 range(256) 内的整数
    print(cafe[0])  # 99
    # bytes 对象的切片还是 bytes 对象,即使是只有一个字节的切片
    print(cafe[:1])  # b'c'

    cafe_arr = bytearray(cafe)
    # bytearray 对象没有字面量句法,而是以 bytearray() 和字节序列字面量参数的形式显示。
    print(cafe_arr)  # bytearray(b'cafe')
    # bytearray 对象的切片还是 bytearray 对象。
    print(cafe_arr[-1:])  # bytearray(b'e')
  • 除了格式化方法(format 和 format_map)和几个处理 Unicode 数据的方法(包括casefold、isdecimal、isidentifier、isnumeric、isprintable和 encode)之外,str 类型的其他方法都支持 bytes 和 bytearray类型。这意味着,我们可以使用熟悉的字符串方法处理二进制序列,如endswith、replace、strip、translate、upper 等。
  • 二进制序列有个类方法是 str 没有的,名为 fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的),构建二进制序列:
hex = bytes.fromhex('31 4B CE A9')
print(hex)  # b'1K\xce\xa9'
  • 构建 bytes 或 bytearray 实例还可以调用各自的构造方法,传入下述参数:
    • 一个 str 对象和一个 encoding 关键字参数。
    • 一个可迭代对象,提供 0~255 之间的数值。
    • 一个实现了缓冲协议的对象(如bytes、bytearray、memoryview、array.array);此时,把源对象中的字节序列复制到新建的二进制序列中。
  • 使用缓冲类对象构建二进制序列是一种低层操作,可能涉及类型转换。
'''
	示例 3 使用数组中的原始数据初始化 bytes 对象
'''
import array

if __name__ == '__main__':
	# 指定类型代码 h,创建一个短整数(16 位)数组。
    numbers = array.array('h', [-2, -1, 0, 1, 2])
    # b 保存组成 numbers 的字节序列的副本。
    b = bytes(numbers)
    #  5 个短整数的 10 个字节
    print(b)  # b'\xfe\xff\xff\xff\x00\x00\x01\x00\x02\x00'
  • 使用缓冲类对象创建 bytes 或 bytearray 对象时,始终复制源对象中的字节序列。与之相反,memoryview 对象允许在二进制数据结构之间共享内存。如果想从二进制序列中提取结构化信息,struct 模块是重要的工具。下一节会使用这个模块处理 bytes 和 memoryview 对象。
2.1 结构体和内存视图
  • struct 模块提供了一些函数,把打包的字节序列转换成不同类型字段组成的元组,还有一些函数用于执行反向转换,把元组转换成打包的字节序列。struct 模块能处理 bytes、bytearray 和 memoryview对象。
  • memoryview 类不是用于创建或存储字节序列的,而是共享内存,让你访问其他二进制序列、打包的数组和缓冲中的数据切片,而无需复制字节序列。
import struct
'''
	使用 memoryview 和 struct 查看一个 GIF 图像的首部
	注意,memoryview 对象的切片是一个新 memoryview 对象,而且不会复制字节序列
'''
if __name__ == '__main__':
    # 结构体的格式:< 是小字节序,3s3s 是两个 3 字节序列,HH 是两个16 位二进制整数。
    fmt = '<3s3sHH'
    with open("a.gif", 'rb') as fp:
    	# 使用内存中的文件内容创建一个 memoryview 对象
        img = memoryview(fp.read())
    print(img)  # <memory at 0x0000023603A37D00>
    
    # 使用切片再创建一个 memoryview 对象,这里不会复制字节序列
    header = img[:10]
    print(header)  # <memory at 0x000001D2EACE7880>
    print(bytes(header))  # b'GIF89a \x03X\x02'
    
    # 拆包 memoryview 对象,得到一个元组,包含类型、版本、宽度和高度
    unpack = struct.unpack(fmt, bytes(header))
    print(unpack)  # (b'GIF', b'89a', 800, 600)
    
    del header
    del img
  • 简要探讨 Python 的二进制序列类型之后,下面说明如何在它们和字符串之间转换。

3. 基本的编解码器

  • Python 自带了超过 100 种编解码器(codec, encoder/decoder),用于在文本和字节之间相互转换。每个编解码器都有一个名称,如’utf_8’,而且经常有几个别名,如 ‘utf8’、‘utf-8’ 和 ‘U8’。这些名称可以传给 open()、str.encode()、bytes.decode() 等函数的 encoding 参数。如下示例:
'''
    用 3 个编解码器把相同的文本编码成不同的字节序列
'''
if __name__ == '__main__':
    for code in ['latin_1', 'utf_8', 'utf_16']:
        print(code, 'El Niño'.encode(code), 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'
  • 一些典型编码,介绍如下:
    • latin1(即 iso8859_1):一种重要的编码,是其他编码的基础,例如 cp1252 和Unicode(注意,latin1 与 cp1252 的字节值是一样的,甚至连码位也相同)。
    • cp1252:Microsoft 制定的 latin1 超集,添加了有用的符号,例如弯引号和€(欧元);有些 Windows 应用把它称为“ANSI”,但它并不是 ANSI标准。
    • cp437:IBM PC 最初的字符集,包含框图符号。与后来出现的 latin1 不兼容。
    • gb2312:用于编码简体中文的陈旧标准;这是亚洲语言中使用较广泛的多字节编码之一。
    • utf-8:目前 Web 中最常见的 8 位编码; 与 ASCII 兼容(纯 ASCII 文本是有效的 UTF-8 文本)。
    • utf-16le:UTF-16 的 16 位编码方案的一种形式;所有 UTF-16 支持通过转义序列(称为“代理对”,surrogate pair)表示超过 U+FFFF 的码位。
  • 概述常规的编码之后,下面要处理编码和解码过程中存在的问题。

4. 了解编解码问题

  • 虽然有个一般性的UnicodeError异常,但是报告错误时几乎都会指明具体的异常:UnicodeEncodeError(把字符串转换成二进制序列时)或UnicodeDecodeError(把二进制序列转换成字符串时)。如果源码的编码与预期不符,加载 Python 模块时还可能抛出SyntaxError。接下来的几节说明如何处理这些错误。
4.1 处理UnicodeEncodeError
  • 多数非 UTF 编解码器只能处理 Unicode 字符的一小部分子集。把文本转换成字节序列时,如果目标编码中没有定义某个字符,那就会抛出UnicodeEncodeError 异常,除非把 errors 参数传给编码方法或函数,对错误进行特殊处理。如下示例:
'''
	编码成字节序列:成功和 UnicodeEncodeError 错误处理
	error='ignore' 处理方式悄无声息地跳过无法编码的字符;这样做通常很是不妥
	编码时指定 error='replace',把无法编码的字符替换成 '?';数据损坏了,但是用户知道出了问题。
	'xmlcharrefreplace' 把无法编码的字符替换成 XML 实体。
'''
if __name__ == '__main__':
    city = 'São Paulo'
    c_utf8 = city.encode('utf-8')
    c_utf16 = city.encode('utf-16')
    c_iso = city.encode('iso8859_1')
    print(c_utf8)  # b'S\xc3\xa3o Paulo'
    print(c_utf16)  # b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00'
    print(c_iso)  # b'S\xe3o Paulo'

    try:
        c_cp437 = city.encode('cp437')
        print(c_cp437)
    except UnicodeEncodeError as e:
        print(e) # 'charmap' codec can't encode character '\xe3' in position 1: character maps to <undefined>
    
    c_cp437_ignore = city.encode(encoding='cp437', errors='ignore')
    c_cp437_replace = city.encode(encoding='cp437', errors='replace')
    c_cp437_xcr = city.encode(encoding='cp437', errors='xmlcharrefreplace')
    print(c_cp437_ignore)  # b'So Paulo'
    print(c_cp437_replace)  # b'S?o Paulo'
    print(c_cp437_xcr)  # b'S&#227;o Paulo'    
4.2 处理UnicodeDecodeError
  • 不是每一个字节都包含有效的 ASCII 字符,也不是每一个字符序列都是有效的 UTF-8 或 UTF-16。因此,把二进制序列转换成文本时,如果假设是这两个编码中的一个,遇到无法转换的字节序列时会抛出UnicodeDecodeError
  • 另一方面,很多陈旧的 8 位编码——如 ‘cp1252’、‘iso8859_1’ 和’koi8_r’——能解码任何字节序列流而不抛出错误,例如随机噪声。因此,如果程序使用错误的 8 位编码,解码过程悄无声息,而得到的是无用输出。如下示例:
'''
	把字节序列解码成字符串:成功和 UnicodeDecodeError 错误处理
'''
if __name__ == '__main__':
    octets = b'Montr\xe9al'
    d_cp1252 = octets.decode(encoding='cp1252')
    d_iso = octets.decode(encoding='iso8859_7')
    d_koi8 = octets.decode(encoding='koi8_r')
    print(d_cp1252)  # Montréal
    print(d_iso)  # Montrιal
    print(d_koi8)  # MontrИal

    try:
    	# 'utf_8' 编解码器检测到 octets 不是有效的 UTF-8 字符串,抛出 UnicodeDecodeError。
        d_utf8 = octets.decode(encoding='utf-8')
        print(d_utf8)
    except UnicodeDecodeError as e:
        print(e)  # 'utf-8' codec can't decode byte 0xe9 in position 5: invalid continuation byte

    d_utf8_replace = octets.decode(encoding='utf-8', errors="replace")
    print(d_utf8_replace)  # Montr�al

4.3 使用预期之外的编码加载模块时抛出的 SyntaxError
  • Python 3 默认使用 UTF-8 编码源码。如果加载的 .py 模块中包含 UTF-8 之外的数据,而且没有声明编码,会得到如下报错:SyntaxError: Non-UTF-8 code starting with '\xe1' in file ola.py on line1, but no encoding declared; see http://python.org/dev/peps/pep-0263/for details
  • 为了修正这个问题,可以在文件顶部添加一个神奇的 coding 注释,如:
# coding: cp1252
'''
	“你好,世界!”的葡萄牙语版
'''
print('Olá, Mundo!')
  • 现在,Python 3 的源码不再限于使用 ASCII,而是默认使用优秀的 UTF-8 编码,因此要修正源码的陈旧编码(如 ‘cp1252’)问题,最好将其转换成 UTF-8,别去麻烦 coding 注释。如果你用的编辑器不支持 UTF-8,那么是时候换一个了。
  • 假如有个文本文件,里面保存的是源码或诗句,但是你不知道它的编码。如何查明真正的编码呢?
4.4 如何找出字节序列的编码
  • 如何找出字节序列的编码?简单来说,不能。必须有人告诉你。
  • 就像人类语言也有规则和限制一样,只要假定字节流是人类可读的纯文本,就可能通过试探和分析找出编码。例如,如果 b'\x00'字节经常出现,那么可能是 16 位或 32 位编码,而不是 8 位编码方案,因为纯文本中不能包含空字符。
  • 统一字符编码侦测包 Chardet(https://pypi.python.org/pypi/chardet)就是这样工作的,它能识别所支持的 30 种编码。Chardet 是一个 Python 库,可以在程序中使用,不过它也提供了命令行工具chardetect。如下示例使用chardetect命令行识别本地的app.py文件编码:
$ chardetect app.py
app.py: ascii with confidence 1.0
4.5 BOM:有用的鬼符
  • 你可能注意到了,UTF-16 编码的序列开头有几个额外的字节,如下所示:
if __name__ == '__main__':
    u16 = 'El Niño'.encode('utf_16')
    print(u16)  # b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
  • 我指的是 b'\xff\xfe'。这是 BOM,即字节序标记(byte-ordermark),指明编码时使用 Intel CPU 的小字节序。
  • 在小字节序设备中,各个码位的最低有效字节在前面:字母 ‘E’ 的码位是 U+0045(十进制数 69),在字节偏移的第 2 位和第 3 位编码为 69和 0。
print(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')
    u16be = 'El Niño'.encode('utf_16be')
    print(u16le)  # b'E\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
    print(list(u16le))  # [69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111, 0]
    print(u16be)  # b'\x00E\x00l\x00 \x00N\x00i\x00\xf1\x00o'
    print(list(u16be))  # [0, 69, 0, 108, 0, 32, 0, 78, 0, 105, 0, 241, 0, 111]
  • 如果有 BOM,UTF-16 编解码器会将其过滤掉,为你提供没有前导ZERO WIDTH NO-BREAK SPACE 字符的真正文本。
  • 与字节序有关的问题只对一个字(word)占多个字节的编码(如 UTF-16 和 UTF-32)有影响。UTF-8 的一大优势是,不管设备使用哪种字节序,生成的字节序列始终一致,因此不需要 BOM。

5. 处理文本文件

  • 处理文本的最佳实践是“Unicode 三明治”(如图 所示)。 意思是,要尽早把输入(例如读取文件时)的字节序列解码成字符串。这种三明治中的“肉片”是程序的业务逻辑,在这里只能处理字符串对象。在其他处理过程中,一定不能编码或解码。对输出来说,则要尽量晚地把字符串编码成字节序列。多数 Web 框架都是这样做的,使用框架时很少接触字节序列。例如,在 Django 中,视图应该输出 Unicode 字符串;Django 会负责把响应编码成字节序列,而且默认使用 UTF-8 编码。
    图 4-2:Unicode 三明治——目前处理文本的最佳实践
  • 内置的 open函数会在读取文件时做必要的解码,以文本模式写入文件时还会做必要的编码,所以调用my_file.read()方法得到的以及传给my_file.write(text)方法的都是字符串对象。
  • 可以看出,处理文本文件很简单。但是,如果依赖默认编码,你会遇到麻烦。
# -*- encoding: utf-8 -*-
'''
	一个平台上的编码问题(如果在你的机器上运行,它可能会发生,也可能不会)
'''
if __name__ == '__main__':
    open("cafe.txt", "w", encoding='utf-8').write("café")
    print(open('cafe.txt').read())  # caf茅

问题是:写入文件时指定了 UTF-8 编码,但是读取文件时没有这么做,因此 Python 假定要使用系统默认的编码,于是文件的最后一个字节解码成了字符 ‘茅’,而不是 ‘é’。

  • 所以最好不用使用默认编码,除非你确定默认编码是你最终想要的编码
print(open('cafe.txt', encoding='utf-8').read())  # café
  • 探索编码默认值:
import locale, sys
if __name__ == '__main__':
    expressions = """
                    locale.getpreferredencoding()
                    type(my_file)
                    my_file.encoding
                    sys.stdout.isatty()
                    sys.stdout.encoding
                    sys.stdin.isatty()
                    sys.stdin.encoding
                    sys.stderr.isatty()
                    sys.stderr.encoding
                    sys.getdefaultencoding()
                    sys.getfilesystemencoding()
                """
    my_file = open('dummy', 'w')
    for expression in expressions.split():
        value = eval(expression)
        print(expression.rjust(30), '->', repr(value))
  • output:
 locale.getpreferredencoding() -> 'cp936'
                 type(my_file) -> <class '_io.TextIOWrapper'>
              my_file.encoding -> 'cp936'
           sys.stdout.isatty() -> False
           sys.stdout.encoding -> 'utf-8'
            sys.stdin.isatty() -> False
            sys.stdin.encoding -> 'utf-8'
           sys.stderr.isatty() -> False
           sys.stderr.encoding -> 'utf-8'
      sys.getdefaultencoding() -> 'utf-8'
   sys.getfilesystemencoding() -> 'utf-8'
  • 因此,关于编码默认值的最佳建议是:别依赖默认值

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

  • 因为 Unicode 有组合字符(变音符号和附加到前一个字符上的记号,打印时作为一个整体),所以字符串比较起来很复杂。
  • 例如,“café”这个词可以使用两种方式构成,分别有 4 个和 5 个码位,但是结果完全一样:
if __name__ == '__main__':
    s1 = 'café'
    s2 = 'cafe\u0301'
    print(s1, s2)  # café café́
    print(len(s1), len(s2))  # 4 5
    print(s1 == s2)  # False
  • U+0301 是 COMBINING ACUTE ACCENT,加在“e”后面得到“é”。在Unicode 标准中,‘é’ 和 ‘e\u0301’ 这样的序列叫“标准等价物”,应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。
  • 这个问题的解决方案是使用 unicodedata.normalize 函数提供的Unicode规范化。该函数的第一个参数是这 4 个字符串其中一个:'NFC'、'NFD'、'NFKC' 和 'NFKD'。下面先说明前两个。
  • NFC(Normalization Form C)使用最少的码位构成等价的字符串,而NFD 把组合字符分解成基字符和单独的组合字符。这两种规范化方式都能让比较行为符合预期:
from unicodedata import normalize

if __name__ == '__main__':
    s1 = 'café'
    # 分解成"e"和重音符
    s2 = 'cafe\u0301'
    print(len(s1), len(s2))
    print(len(normalize('NFC', s1)), len(normalize('NFC', s2)))
    print(len(normalize('NFD', s1)), len(normalize('NFD', s2)))
    print(normalize('NFC', s1) == normalize('NFC', s2))
    print(normalize('NFD', s1) == normalize('NFD', s2))
    # 4 5
    # 4 4
    # 5 5
    # True
    # True
  • 西方键盘通常能输出组合字符,因此用户输入的文本默认是 NFC 形式。不过,安全起见,保存文本之前,最好使用 normalize(‘NFC’,user_text) 清洗字符串。
  • 使用 NFC 时,有些单字符会被规范成另一个单字符。例如,电阻的单位欧姆(Ω)会被规范成希腊字母大写的欧米加。这两个字符在视觉上是一样的,但是比较时并不相等,因此要规范化,防止出现意外:
from unicodedata import normalize, name

if __name__ == '__main__':
    ohm = '\u2126'
    ohm_c = normalize("NFC", ohm)
    print(name(ohm))
    print(name(ohm_c))
    print(ohm == ohm_c)
    print(normalize("NFC", ohm) == normalize("NFC", ohm_c))
    # OHM SIGN
    # GREEK CAPITAL LETTER OMEGA
    # False
    # True
  • 在另外两个规范化形式(NFKC 和 NFKD)的首字母缩略词中,字母 K表示“compatibility”(兼容性)。这两种是较严格的规范化形式,对“兼容字符”有影响。
  • 虽然 Unicode 的目标是为各个字符提供“规范的”码位,但是为了兼容现有的标准,有些字符会出现多次。例如,虽然希腊字母表中有“μ”这个字母(码位是 U+03BC,GREEK SMALL LETTERMU),但是 Unicode 还是加入了微符号’µ’(U+00B5),以便与latin1 相互转换。因此,微符号是一个“兼容字符”。
  • 在 NFKC 和 NFKD 形式中,各个兼容字符会被替换成一个或多个“兼容分解”字符,即便这样有些格式损失,但仍是“首选”表述。二分之一 ‘½’(U+00BD)经过兼容分解后得到的是三个字符序列’1/2’。
from unicodedata import normalize

if __name__ == '__main__':
    half = '½'
    four_squared = '4²'
    micro = 'μ'
    print(normalize("NFKC", half))
    print(normalize("NFKC", four_squared))
    print(micro, normalize("NFKC", micro))
    # 1⁄2
    # 42
    # μ μ
  • 因此,NFKC 或 NFKD 可能会损失或曲解信息,但是可以为搜索和索引提供便利的中间表述:用户搜索 ‘1 / 2 inch’ 时,如果还能找到包含 ‘½ inch’ 的文档,那么用户会感到满意。
  • 为搜索或索引准备文本时,还有一个有用的操作,即下一节讨论的大小写折叠。
6.1 大小写折叠
  • 大小写折叠其实就是把所有文本变成小写,再做些其他转换。这个功能由 str.casefold() 方法(Python 3.3 +)支持。
  • 对于只包含 latin1 字符的字符串 s,s.casefold() 得到的结果与s.lower() 一样,只有极个别例外。自 Python 3.4 起,str.casefold() 和 str.lower() 得到不同结果的有 116 个码位。Unicode 6.3 命名了 110 122 个字符,这只占 0.11%。
  • 接下来的几节将使用这些规范化知识来开发几个实用的函数。
6.2 规范化文本匹配实用函数

由前文可知,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()


if __name__ == '__main__':
    s1 = 'café'
    s2 = 'cafe\u0301'
    s3 = 'Straße'
    s4 = 'strasse'
    print(s1 == s2)  # False
    print(nfc_equal(s1, s2))  # True
    print(nfc_equal("A", 'a'))  # False
    print(s3 == s4)  # False
    print(nfc_equal(s3, s4))  # False
    print(fold_equal(s3, s4))  # True
    print(fold_equal("A", "a"))  # True
6.3 极端“规范化”:去掉变音符号
  • 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
  • 如果想把字符串中的所有变音符号都去掉, 可以采用如下函数:
from unicodedata import normalize, combining


def shave_marks(txt):
    """去掉全部变音符号"""
    # 把所有字符分解成基字符和组合记号。
    norm_txt = normalize("NFD", txt)
    # 过滤掉所有组合记号
    shaved = "".join(c for c in norm_txt if not combining(c))
    # 重组所有字符
    return normalize("NFC", shaved)


if __name__ == '__main__':
    order = '“Herr Voß: • ½ cup of OEtker™ caffè latte • bowl of açaí.”'
    Greek = 'Zέφupoς, Zéfiro'
    print(shave_marks(order))
    print(shave_marks(Greek))
    # “Herr Voß: • ½ cup of OEtker™ caffe latte • bowl of acai.”
    # Zεφupoς, Zefiro
  • 通常,去掉变音符号是为了把拉丁文本变成纯粹的 ASCII,但是 shave_marks 函数还会修改非拉丁字符(如希腊字母),而只去掉重音符并不能把它们变成 ASCII 字符。因此,我们应该分析各个基字符,仅当字符在拉丁字母表中时才删除附加的记号,如下示例:
from unicodedata import normalize, combining
from string import ascii_letters


def shave_marks_latin(txt):
    """把拉丁基字符中所有的变音符号删除"""
    norm_txt = normalize("NFD", txt)
    Latin_base = False
    keepers = []
    for c in norm_txt:
        if combining(c) and Latin_base:
            continue  # 忽略拉丁基字符上的变音符号
        keepers.append(c)
        # 如果不是组合字符,那就是新的基字符
        if not combining(c):
            Latin_base = c in ascii_letters

    shaved = ''.join(keepers)
    return normalize("NFC", shaved)
  • 不同语言删除变音符号的规则也有所不同。例如,德语把’ü’ 变成 ‘ue’。只有知道目标语言、目标用户群和转换后的用途,才能确定要不要做这么深入的规范化。
  • 接下来要解决的 Unicode 问题是……排序

7. Unicode文本排序

  • Python 比较任何类型的序列时,会一一比较序列里的各个元素。对字符串来说,比较的是码位。可是在比较非 ASCII 字符时,得到的结果不尽如人意。
  • 下面对一个生长在巴西的水果的列表进行排序:
if __name__ == '__main__':
    fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
    print(sorted(fruits))  # ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
  • 不同的区域采用的排序规则有所不同,葡萄牙语等很多语言按照拉丁字母表排序,重音符号和下加符对排序几乎没什么影响。因此,排序时“cajá”视作“caja”,应该排在“caju”前面。
  • 在 Python 中,非 ASCII 文本的标准排序方式是使用locale.strxfrm,这 个函数会把字符串转换成适合所在区域进行比较的形式”。
  • 使用 locale.strxfrm 函数之前,必须先为应用设定合适的区域设置,还要保证操作系统支持这项设置。区域设为 pt_BR 的GNU/Linux(Ubuntu 14.04)使用示例:
import locale

if __name__ == '__main__':
    fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
    print(sorted(fruits))
    locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8')
    # 使用 locale.strxfrm 函数做排序键
    print(sorted(fruits, key=locale.strxfrm))
    # ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']
    # ['açaí', 'acerola', 'atemoia', 'cajá', 'caju']
  • 使用 setlocale 会有诸多限制,区域设置是全局的,使用 setlocale 就显得不太合适。而且操作系统必须支持此设置,还要知道如何拼区域名称。标准库提供的国际化排序方案可用,但是似乎只支持GNU/Linux(可能也支持 Windows,但你得是专家)。即便如此,还要依赖区域设置,而这会为部署带来问题。
  • 幸好,有个较为简单的方案:PyPI 中的 PyUCA 库
使用Unicode排序算法排序
  • James Tauber,一位高产的 Django 贡献者,开发了 PyUCA 库。这是Unicode 排序算法(Unicode Collation Algorithm,UCA)的纯 Python 实现。如下示例展示了它的简单用法:
import pyuca

if __name__ == '__main__':
    coll = pyuca.Collator()
    fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
    print(sorted(fruits, key=coll.sort_key))
    # ['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

8. Unicode数据库

  • Unicode 标准提供了一个完整的数据库(许多格式化的文本文件),不仅包括码位与字符名称之间的映射,还有各个字符的元数据,以及字符之间的关系。例如,Unicode 数据库记录了字符是否可以打印、是不是字母、是不是数字,或者是不是其他数值符号。字符串的isidentifier、isprintable、isdecimal 和 isnumeric 等方法就是靠这些信息作判断的。 str.casefold 方法也用到了 Unicode表中的信息。
  • unicodedata 模块中有几个函数用于获取字符的元数据。示例展示了
    unicodedata.name() 和 unicodedata.numeric() 函数,以及字符串的 .isdecimal() 和 .isnumeric() 方法的用法。
import unicodedata, re

if __name__ == '__main__':
    re_digit = re.compile(r'\d')
    sample = '1\xbc\xb2\u0969\u136b\u216b\u2466\u2480\u3285'
    for char in sample:
        print(
        	# U+0000 格式的码位。
            'U+%04x' % ord(char),
            # 在长度为 6 的字符串中居中显示字符。
            char.center(6),
            # 如果字符匹配正则表达式 r'\d',显示 re_dig。
            're_dig' if re_digit.match(char) else '-',
            # 如果 char.isdigit() 返回 True,显示 isdig。
            'isdig' if char.isdigit() else '-',
            # 如果 char.isnumeric() 返回 True,显示 isnum。
            'isnum' if char.isnumeric() else '-',
            # 使用长度为 5、小数点后保留 2 位的浮点数显示数值。
            format(unicodedata.numeric(char), '5.2f'),
            # Unicode 标准中字符的名称。
            unicodedata.name(char),
            sep='\t'
        )
  • output:
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
  • 结果表明,Unicode 知道表示数字的符号的数值。正则表达式 r’\d’ 能匹配数字“1”和梵文数字 3,但是不能匹配 isdigit 方法判断为数字的其他字符。re 模块对 Unicode 的支持并不充分。PyPI 中有个新开发的 regex模块,它的最终目的是取代re 模块,以提供更好的 Unicode 支持。
  • 在结束对字符串和字节序列的讨论之前,我们还要简要说明一个新的趋势——双模式 API,即提供的函数能接受字符串或字节序列为参数,然后根据类型进行特殊处理。

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

  • 标准库中的一些函数能接受字符串或字节序列为参数,然后根据类型展
    现不同的行为。re 和 os 模块中就有这样的函数。
  • 如果使用字节序列构建正则表达式,\d 和 \w 等模式只能匹配 ASCII 字符;相比之下,如果是字符串模式,就能匹配 ASCII 之外的 Unicode 数字或字母。
  • 比较简单的字符串正则表达式和字节序列正则表达式的行为:
import re

if __name__ == '__main__':
    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+')

    txt_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef as 1729 = 1³ + 12³ = 9³ + 10³.")
    txt_bytes = txt_str.encode("utf-8")

    print('Text', repr(txt_str), sep="\n ")
    print("Numbers")
    print(" str ", re_numbers_str.findall(txt_str))
    print(" bytes ", re_numbers_bytes.findall(txt_bytes))
    print("Words")
    print(" str ", re_words_str.findall(txt_str))
    print(" bytes ", re_words_bytes.findall(txt_bytes))
  • output:
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']
  • 示例说明了一个问题:可以使用正则表达式搜索字符串和字节序列,但是在后一种情况中,ASCII 范围外的字节不会当成数字和组成单词的字母。字符串正则表达式有个 re.ASCII 标志,它让\w、\W、\b、\B、\d、\D、\s 和 \S 只匹配 ASCII 字符。
  • 另一个重要的双模式模块是 os。
os函数中的字符串和字节序列
  • GNU/Linux 内核不理解 Unicode,因此你可能发现了,对任何合理的编码方案来说,在文件名中使用字节序列都是无效的,无法解码成字符串。在不同操作系统中使用各种客户端的文件服务器,在遇到这个问题时尤其容易出错。
  • 为了规避这个问题,os 模块中的所有函数、文件名或路径名参数既能使用字符串,也能使用字节序列。如果这样的函数使用字符串参数调用,该参数会使用 sys.getfilesystemencoding() 得到的编解码器自动编码,然后操作系统会使用相同的编解码器解码。这几乎就是我们想要的行为,与 Unicode 三明治最佳实践一致。
  • 但是,如果必须处理(也可能是修正)那些无法使用上述方式自动处理的文件名,可以把字节序列参数传给 os 模块中的函数,得到字节序列返回值。这一特性允许我们处理任何文件名或路径名,不管里面有多少鬼符
  • 字符串和字节序列参数传给 listdir 函数得到的结果:
 # 获取当前目录所有文件名,组成列表
os.listdir('.') 
# 获取当前目录所有文件名的二进制形式,组成列表
os.listdir(b'.')  
  • fsencode(filename)
    如果 filename 是 str 类型(此外还可能是 bytes 类型),使用
    sys.getfilesystemencoding()返回的编解码器把 filename 编码成字节序列;否则,返回未经修改的 filename 字节序列。
  • fsdecode(filename)
    如果 filename 是 bytes 类型(此外还可能是 str 类型),使用
    sys.getfilesystemencoding() 返回的编解码器把 filename 解码成字符串;否则,返回未经修改的 filename 字符串。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:大白 设计师:CSDN官方博客 返回首页
评论

打赏作者

pullgon

你的鼓励将是我创作的最大动力

¥2 ¥4 ¥6 ¥10 ¥20
输入1-500的整数
余额支付 (余额:-- )
扫码支付
扫码支付:¥2
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值