Python 3 明确区分了人类可读的文本字符串和原始的字节序列。隐式地把字节序列转换成 Unicode 文本已成过去。

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

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

举个🌰 编码和解码

>>> s = 'café'
>>> len(s)
>>> b = s.encode('utf-8')
b'caf\xc3\xa9'
>>> len(b)
>>> b.decode('utf-8')
'café'

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

新的二进制序列类型在很多方面与 Python 2 的 str 类型不同。首先要知道,Python 内置了两种基本的二进制序列类型:Python 3 引入的不可变bytes 类型和 Python 2.6 添加的可变 bytearray 类型。(Python 2.6 也引入了 bytes 类型,但那只不过是 str 类型的别名,与 Python 3 的bytes 类型不同。)

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

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

二进制序列有个类方法是 str 没有的,名为 fromhex,它的作用是解析十六进制数字对(数字对之间的空格是可选的),构建二进制序列:

>>> bytes.fromhex('31 4B CE A9')
b'1K\xce\xa9'
>>> bytes.fromhex('31 4B CE A9').decode('utf-8')
'1KΩ'

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

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

结构体和内存视图

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

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

>>> 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
  • 结构体的格式:< 是小字节序,3s3s 是两个 3 字节序列,HH 是两个16 位二进制整数
  • 使用内存中的文件内容创建一个memoryview对象
  • 然后使用它的切片在创建一个memoryview对象,这里不会复制字节序列
  • 转换成字节序列,这里只是为了显示,这里复制了是个字节
  • 拆包memoryview对象,得到一个元祖,包含类型、版本、宽度和高度
  • 删除引用,释放memoryview实例所占用的内存
  • 处理UnicodeEncodeError

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

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

     1 city = 'São Paulo'
     3 #'utf_?' 编码能处理任何字符串
     4 u8 = city.encode('utf_8')
     5 print('utf-8:', u8)
     7 u16 = city.encode('utf_16')
     8 print('utf-16:', u16)
    10 #'iso8859_1' 编码也能处理字符串 'São Paulo
    11 iso = city.encode('iso8859_1')
    12 print('iso:', iso)
    14 #报错咯,'cp437' 无法编码 'ã'(带波形符的“a”)
    15 #city.encode('cp437')
    17 #解决方法如下
    18 cp_ig = city.encode('cp437', errors='ignore')
    19 print('cp ignore:', cp_ig)
    21 cp_rp = city.encode('cp437', errors='replace')
    22 print('cp replace:', cp_rp)

    以上代码执行的结果为:

    utf-8: b'S\xc3\xa3o Paulo'
    utf-16: b'\xff\xfeS\x00\xe3\x00o\x00 \x00P\x00a\x00u\x00l\x00o\x00'
    iso: b'S\xe3o Paulo'
    cp ignore: b'So Paulo'
    cp replace: b'S?o Paulo'
  • error='ignore' 处理方式悄无声息地跳过无法编码的字符;这样做通常很是不妥
  • 编码时指定error='replace',把无法编码的字符替换成'?';数据损坏了,但是用户知道出现了问题
  • 处理文本文件

    处理文本的最佳实践是“Unicode 三明治”(如图下图所示)。 意思是,要尽早把输入(例如读取文件时)的字节序列解码成字符串。这种三明治中的“肉片”是程序的业务逻辑,在这里只能处理字符串对象。在其他处理过程中,一定不能编码或解码。对输出来说,则要尽量晚地把字符串编码成字节序列。多数 Web 框架都是这样做的,使用框架时很少接触字节序列。例如,在 Django 中,视图应该输出 Unicode 字符串;Django 会负责把响应编码成字节序列,而且默认使用 UTF-8 编码。

    处理文本文件很简单。但是,如果依赖默认编码,你会遇到麻烦。举个🌰

     1 #打开一个文件cafe.txt并写入内容,w是对文件的模式操作(写操作), encoding是对文件操作的编码
     2 fp = open('cafe.txt', 'w', encoding='utf_8')
     3 fp_len = fp.write('café')
     4 print('fp的io信息:', fp)
     5 print('写入到文件中内容的长度:', fp_len)
     6 fp.close()
     8 #获取文件的内容
     9 fp2 = open('cafe.txt')
    10 print('fp2的io信息:', fp2)
    11 '''
    12 因为和上面的写入的编码不同,所以直接以默认的编码打开,无法处理é而引发异常
    13 '''
    14 #print(fp2.read())
    15 fp2.close()
    17 #解决fp2无法或许文件内容的方法指定打开的时候编码
    18 fp3 = open('cafe.txt', encoding='utf-8')
    19 print('fp3的io信息:', fp3)
    20 print('fp3中的文件内容:', fp3.read())
    21 fp3.close()
    23 fp4 = open('cafe.txt', 'rb')
    24 print('fp4的io信息:', fp4)
    25 print('fp4的文件内容:', fp4.read().decode('utf-8'))
    26 fp4.close()
    28 #另外一种不太可取的解决方案, errors可以设置成replace或者ignore
    29 fp5 = open('cafe.txt', 'r', errors='ignore')
    30 print('fp5的io信息:', fp5)
    31 print('fp5的文件内容:', fp5.read())

    以上代码执行的结果为:

    fp的io信息: <_io.TextIOWrapper name='cafe.txt' mode='w' encoding='utf_8'>
    写入到文件中内容的长度: 4
    fp2的io信息: <_io.TextIOWrapper name='cafe.txt' mode='r' encoding='US-ASCII'>
    fp3的io信息: <_io.TextIOWrapper name='cafe.txt' mode='r' encoding='utf-8'>
    fp3中的文件内容: café
    fp4的io信息: <_io.BufferedReader name='cafe.txt'>
    fp4的文件内容: café
    fp5的io信息: <_io.TextIOWrapper name='cafe.txt' mode='r' encoding='US-ASCII'>
    fp5的文件内容: caf

    探索编码默认值

     1 import  sys, locale
     4 expressions = """
     5     locale.getpreferredencoding()       
     6     type(my_file)                       
     7     my_file.encoding                    
     8     sys.stdout.isatty()                 
     9     sys.stdout.encoding                 
    10     sys.stdin.isatty()                  
    11     sys.stdin.encoding
    12     sys.stderr.isatty()
    13     sys.stderr.encoding
    14     sys.getdefaultencoding()
    15     sys.getfilesystemencoding()
    16 """
    18 with open('dummy', 'w') as my_file:
    19     for expression in expressions.split():
    20         value = eval(expression)
    21         print('{:>30}'.format(expression), '->', repr(value))
    23 '''
    24 locale.getpreferredencoding() 是最重要的设置
    25 文本文件默认使用 locale.getpreferredencoding()
    26 输出到控制台中,因此 sys.stdout.isatty() 返回 True
    27 因此,sys.stdout.encoding 与控制台的编码相同
    28 '''

    以上代码执行的结果为(终端运行):

    ocale.getpreferredencoding() -> 'UTF-8'
                     type(my_file) -> <class '_io.TextIOWrapper'>
                  my_file.encoding -> 'UTF-8'
               sys.stdout.isatty() -> True
               sys.stdout.encoding -> 'UTF-8'
                sys.stdin.isatty() -> True
                sys.stdin.encoding -> 'UTF-8'
               sys.stderr.isatty() -> True
               sys.stderr.encoding -> 'UTF-8'
          sys.getdefaultencoding() -> 'utf-8'
       sys.getfilesystemencoding() -> 'utf-8'

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

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

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

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

    'é' 和 'e\u0301' 这样的序列叫“标准等价物”(canonical equivalent),应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。

    解决方案是使用 unicodedata.normalize 函数提供的Unicode 规范化。这个函数的第一个参数是这 4 个字符串中的一个:'NFC'、'NFD'、'NFKC' 和 'NFKD'。下面先说明前两个。

    NFC(Normalization Form C)使用最少的码位构成等价的字符串,而NFD 把组合字符分解成基字符和单独的组合字符。这两种规范化方式都能让比较行为符合预期:

     1 from unicodedata import normalize
     4 s1 = 'café' # 把"e"和重音符组合在一起
     5 s2 = 'cafe\u0301' # 分解成"e"和重音符
     6 print('s1和s2的长度:', len(s1), len(s2))
     8 print('NFC标准化处理以后的s1,s2的长度:', len(normalize('NFC', s1)), len(normalize('NFC', s2)))
     9 print('NFD标准化处理以后的s1,s2的长度:', len(normalize('NFD', s1)), len(normalize('NFD', s2)))
    10 print(normalize('NFC', s1), normalize('NFC', s2))

    以上代码执行的结果为:

    s1和s2的长度: 4 5
    NFC标准化处理以后的s1,s2的长度: 4 4
    NFD标准化处理以后的s1,s2的长度: 5 5
    café café

    在另外两个规范化形式(NFKC 和 NFKD)的首字母缩略词中,字母 K表示“compatibility”(兼容性)。这两种是较严格的规范化形式,对“兼容字符”有影响。虽然 Unicode 的目标是为各个字符提供“规范的”码位,但是为了兼容现有的标准,有些字符会出现多次。例如,虽然希腊字母表中有“μ”这个字母(码位是 U+03BC,GREEK SMALL LETTER MU),但是 Unicode 还是加入了微符号 'μ'(U+00B5),以便与 latin1 相互转换。因此,微符号是一个“兼容字符”。

    NFC的具体应用🌰

    >>> from unicodedata import normalize
    >>> half = '½'
    >>> normalize('NFKC', half)
    '1⁄2'
    >>> four_squared = ''
    >>> normalize('NFKC', four_squared)
    '42'
    >>> micro = 'μ'
    >>> micro_kc = normalize('NFKC', micro)
    >>> micro, micro_kc
    ('μ', 'μ')
    >>> ord(micro), ord(micro_kc)
    (956, 956)

    使用 '1/2' 替代 '½' 可以接受,微符号也确实是小写的希腊字母'μ',但是把 '4²' 转换成 '42' 就改变原意了。某些应用程序可以把'4²' 保存为 '4<sup>2</sup>',但是 normalize 函数对格式一无所知。因此,NFKC 或 NFKD 可能会损失或曲解信息,但是可以为搜索和索引提供便利的中间表述:用户搜索 '1 / 2 inch' 时,如果还能找到包含 '½ inch' 的文档,那么用户会感到满意。

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

    规范化文本匹配实用函数

    由前文可知,NFC 和 NFD 可以放心使用,而且能合理比较 Unicode 字符串。对大多数应用来说,NFC 是最好的规范化形式。不区分大小写的比较应该使用 str.casefold()。

    如果要处理多语言文本,工具箱中应用nfc_equal 和fold_equal 函数。

    🌰 比较规范化 Unicode 字符串

     1 from unicodedata import normalize
     4 def nfc_equal(str1, str2):
     5     return normalize('NFC', str1) == normalize('NFC', str2)
     7 def fold_equal(str1, str2):
     8     return (normalize('NFC', str1).casefold() ==
     9             normalize('NFC', str2).casefold())
    11 s1 = 'café'
    12 s2 = 'cafe\u0301'
    13 print('s1 equal s2:',nfc_equal(s1, s2))
    15 print(nfc_equal('A', 'a'))
    17 s3 = 'Straße'
    18 s4 = 'strasse'
    20 print('s3 equal s4', nfc_equal(s3, s4))
    21 #转换字符成小写
    22 print(fold_equal(s3, s4)) 

    以上代码的执行结果为:

    s1 equal s2: True
    False
    s3 equal s4 False
    

    极端“规范化”:去掉变音符号

    去掉变音符号还能让 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

    如果想把字符串中的所有变音符号都去掉,看 🌰

     1 import unicodedata
     4 def shave_marks(txt):
     5     """去掉全部变音符号"""
     7     norm_txt = unicodedata.normalize('NFD', txt)        #把所有字符分解成基字符和组合记号
     8     shaved = ''.join(c for c in norm_txt
     9                      if not unicodedata.combining(c))   #过滤掉所有组合记号
    10     return unicodedata.normalize('NFC', shaved)         #重组所有字符
    13 order = '“Herr Voß: • ½ cup of OEtker™ caffè latte • bowl of açaí.”'
    14 print(shave_marks(order))
    16 Greek = 'Zέφupoς, Zéfiro'
    17 print(shave_marks(Greek))

    以上代码执行的结果为:

    “Herr Voß: • ½ cup of OEtker™ caffe latte • bowl of acai.”
    Zεφupoς, Zefiro

    Unicode文本排序

      Python 比较任何类型的序列时,会一一比较序列里的各个元素。对字符串来说,比较的是码位。可是在比较非 ASCII 字符时,得到的结果不尽如人意。

    🌰 来了~,对一个生长在 🇧🇷 的水果排序

    >>> fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
    >>> sorted(fruits)
    ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']

      不同的区域采用的排序规则有所不同,葡萄牙语等很多语言按照拉丁字母表排序,重音符号和下加符对排序几乎没什么影响。 因此,排序时“cajá”视作“caja”,必定排在“caju”前面。

      排序后的 fruits 列表应该是:

    ['açaí', 'acerola', 'atemoia', 'cajá', 'caju']

      在 Python 中,非 ASCII 文本的标准排序方式是使用 locale.strxfrm函数,根据 locale 模块的文档(https://docs.python.org/3/library/locale.html?highlight=strxfrm#locale.strxfrm),这 个函数会“把字符串转换成适合所在区域进行比较的形式”。

       使用 locale.strxfrm 函数之前,必须先为应用设定合适的区域设置,还要祈祷操作系统支持这项设置。在区域设为 pt_BR 的GNU/Linux(Ubuntu 14.04)中,可以使用示例中的命令:

      使用 locale.strxfrm 函数做排序键

    1 import locale
    3 #设置时区
    4 print(locale.setlocale(locale.LC_COLLATE, 'pt_BR.UTF-8'))
    6 fruits = ['caju', 'atemoia', 'cajá', 'açaí', 'acerola']
    7 fruits_sort = sorted(fruits, key=locale.strxfrm)
    8 print('搞定:', fruits_sort)

    以上代码的执行结果为:

    pt_BR.UTF-8
    搞定: ['acerola', 'atemoia', 'açaí', 'caju', 'cajá']

    使用Unicode排序算法排序

    🌰  使用 pyuca.Collator.sort_key 方法

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

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

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

    正则表达式中的字符串和字节序列

    🌰 ramanujan.py:比较简单的字符串正则表达式和字节序列正则表达式的行为

     1 import re
     4 re_numbers_str = re.compile(r'\d+')     #编译匹配字符串的数字的正则,连续数字,至少出现一次
     5 re_words_str = re.compile(r'\w+')
     6 re_numbers_bytes = re.compile(rb'\d+')  #编译匹字节序列配数字的正则,连续数字,至少出现一次
     7 re_words_bytes = re.compile(rb'\w+')
     9 text_str = ("Ramanujan saw \u0be7\u0bed\u0be8\u0bef" 
    10             " as 1729 = 1³ + 12³ = 9³ + 10³.")
    12 text_bytes = text_str.encode('utf_8')
    14 print('Text', repr(text_str), sep='\n ')
    15 print('Numbers')
    16 print(' str :', re_numbers_str.findall(text_str))
    17 print(' bytes:', re_numbers_bytes.findall(text_bytes))
    18 print('Words')
    19 print(' str :', re_words_str.findall(text_str))
    20 print(' bytes:', re_words_bytes.findall(text_bytes))

    以上代码执行的结果为:

    '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', '', '12³', '', '10³'] bytes: [b'Ramanujan', b'saw', b'as', b'1729', b'1', b'12', b'9', b'10']