Python学习之路23-文本和字节序列

《流畅的Python》笔记。

本篇主要讲述不同编码之间的转换问题,比较繁杂,如果平时处理文本不多,或者语言比较单一,没有多语言文本处理的需求,则可以略过此篇。

1. 前言

本篇主要讲述Python对文本字符串的处理。主要内容如下:

  • 字符集基本概念以及Unicode;
  • Python中的字节序列;
  • Python对编码错误的处理以及BOM;
  • Python对文本文件的编解码,以及对Unicode字符的比较和排序,而这便是本篇的主要目的
  • 双模式API和Unicode数据库

如果对字符编码很熟悉,也可直接跳过第2节。

2. 字符集相关概念

笔者在初学字符集相关内容的时候,对这个概念并没有什么疑惑:字符集嘛,就是把我们日常使用的字符(汉子,英文,符号,甚至表情等)转换成为二进制嘛,和摩斯电码本质上没啥区别,用数学的观点就是一个函数变换,这有什么好疑惑的?直到后来越来也多地接触字符编码,终于,笔者被这堆概念搞蒙了:一会儿Unicode编码,一会儿又Unicode字符集,UTF-8编码,UTF-16字符集还有什么字符编码、字节序列。到底啥时候该叫“编码”,啥时候该叫“字符集”?这些概念咋这么相似呢?既然这么相似,干嘛取这么多名字?后来仔细研究后发现,确实很多学术名次都是同义词,比如“字符集”和“字符编码”其实就是同义词;有的译者又在翻译外国的书的时候,无意识地把一个概念给放大或者给缩小了。

说到这不得不吐槽一句,我们国家互联网相关的图书质量真的低。国人自己写的IT方面的书,都不求有多经典,能称为好书的都少之又少;而翻译的书,要么翻译得晦涩难懂,还不如直接看原文;要么故作风骚,非得体现译者的文学修养有多“高”;要么生造名词,同一概念同一单词,这本书里你翻译成这样,另一本书里我就偏要翻译成那样(你们这是在翻译小说吗)。所以劝大家有能力的话还是直接看原文吧,如果要买译本,还请大家认真比较比较,否则读起来真的很痛苦。

回到主题,我们继续讨论字符集相关问题。翻阅网上大量资料,做出如下总结。

2.1 基本概念

始终记住编码的核心思想:就是给每个字符都对应一个二进制序列,其他的所有工作都是让这个过程更规范,更易于管理。

现代编码模型将这个过程分了5个层次,所用的术语列举如下(为了避免混淆,这里不再列出它们的同义词):

  1. 抽象字符表(Abstract character repertoire):

    系统支持的所有抽象字符的集合。可以简单理解为人们使用的文字、符号等。

    这里需要注意一个问题:有些语系里面的字母上方或者下方是带有特殊符号的,比如一点或者一撇;有的字符表里面会将字母和特殊符号组合成一个新的字符,为它单独编码;有的则不会单独编码,而是字母赋予一个编码,特殊符号赋予一个编码,然后当这俩在文中相遇的时候再将这俩的编码组合起来形成一个字符。后面我们会谈到这个问题,这也是以前字符编码转换常出毛病的一个原因。

    提醒:虽然这里扯到了编码,但抽象字符表这个概念还和编码没有联系。

  2. 编码字符集(Coded Character Set,CCS):字符 –> 码位

    首先给出总结:编码字符集就是用数字代替抽象字符集中的每一个字符!

    将抽象字符表中的每一个字符映射到一个坐标(整数值对:(x, y),比如我国的GBK编码)或者表示为一个非负整数N,便生成了编码字符集。与之相应的还有两个抽象概念:编码空间(encoding space)、码位(code point)和码位值(code point value)。

    简单的理解,编码空间就相当于许多空位的集合,这些空位称之为码位,而这个码位的坐标通常就是码位值。我们将抽象字符集中的字符与码位一一对应,然后用码位值来代表字符。以二维空间为例,相当于我们有一个10万行的表,每一行相当于一个码位,二维的情况下,通常行号就是码位值(当然你也可以设置为其他值),然后我们把每个汉字放到这个表中,最后用行号来表示每一个汉字。一个编码字符集就是把抽象字符映射为码位值。这里区分码位和码位值只是让这个映射的过程更形象,两者类似于座位和座位号的区别,但真到用时,并不区分这两者,以下两种说法是等效的:

    1
    2
    字符A的码位是123456
    字符A的码位值是123456(很少这么说,但有这种说法)

    编码空间并不只能是二维的,它也可以是三维的,甚至更高,比如当你以二维坐标(x, y)来编号字符,并且还对抽象字符集进行了分类,那么此时的编码空间就可能是三维的,z坐标表示分类,最终使用(x, y, z)在这个编码空间中来定位字符。不过笔者还没真见过(或者见过但不知道……)三维甚至更高维的编码,最多也就见过变相的三维编码空间。但编码都是人定的,你也可以自己定一个编码规则~~

    并不是每一个码位都会被使用,比如我们的汉字有8万多个,用10万个数字来编号的话还会剩余1万多个,这些剩余的码位则留作扩展用。

    注意:到这一步我们只是将抽象字符集进行了编号,但这个编号并不一定是二进制的,而且它一般也不是二进制的,而是10进制或16进制。该层依然是个抽象层。

    而这里之所以说了这么多,就是为了和下面这个概念区分。

  3. 字符编码表(Character Encoding Form,CEF):码位 –> 码元

    将编码字符集中的码位转换成有限比特长度的整型值的序列。这个整型值的单位叫码元(code unit)。即一个码位可由一个或多个码元表示。而这个整型值通常就是码位的二进制表示。

    到这里才完成了字符到二进制的转换。程序员的工作通常到这里就完成了。但其实还有后续两步。

    注意:直到这里都还没有将这些序列存到存储器中!所以这里依然是个抽象,只是相比上面两步更具体而已。

  4. 字符编码方案(Character Encoding Scheme,CES):码元 –> 序列化

    也称为“serialization format”(常说的“序列化”)。将上面的整型值转换成可存储或可传输8位字节序列。简单说就是将上面的码元一个字节一个字节的存储或传输。每个字节里的二进制数就是字节序列。这个过程中还会涉及大小端模式的问题(码元的低位字节里的内容放在内存地址的高位还是低位的问题,感兴趣的请自行查阅,这里不再赘述)。

    直到这时,才真正完成了从我们使用的字符转换到机器使用的二进制码的过程。 抽象终于完成了实例化。

  5. 传输编码语法(transfer encoding syntax):

    这里则主要涉及传输的问题,如果用计算机网络的概念来类比的话,就是如何实现透明传输。相当于将上面的字节序列的值映射到一个更受限的值域内,以满足传输环境的限制。比如Email的Base64或quoted-printable协议,Base64是6bit作为一个单位,quoted-printable是7bit作为一个单位,所以我们得想办法把8bit的字节序列映射到6bit或7bit的单位中。另一个情况则是压缩字节序列的值,如LZW或进程长度编码等无损压缩技术。

综上,整个编码过程概括如下:

字符 –> 码位 –> 码元 –> 序列化,如果还要在特定环境传输,还需要再映射。从左到右是编码的过程,从右到左就是解码的过程。

下面我们以Unicode为例,来更具体的说明上述概念。

2.2 统一字符编码Unicode

每个国家每个地区都有自己的字符编码标准,如果你开发的程序是面向全球的,则不得不在这些标准之间转换,而许多问题就出在这些转换上。Unicode的初衷就是为了避免这种转换,而对全球各种语言进行统一编码。既然都在同一个标准下进行编码,那就不存在转换的问题了呗。但这只是理想,至今都没编完,所以还是有转换的问题,但已经极大的解决了以前的编码转换的问题了。

Unicode编码就是上面的编码字符集CCS。而与它相伴的则是经常用到的UTF-8,UTF-16等,这些则是上面的字符编码表CEF。

最新版的Unicode库已经收录了超过10万个字符,它的码位一般用16进制表示,并且前面还要加上U+,十进制表示的话则是前面加&#,例如字母“A”的Unicode码位是U+0041,十进制表示为&#065

Unicode目前一共有17个Plane(面),从U+0000U+10FFFF,每个Plane包含65536(=2^16^)个码位,比如英文字符集就在0号平面中,它的范围是U+0000 ~ U+FFFF。这17个Plane中4号到13号都还未使用,而15、16号Plane保留为私人使用区,而使用的5个Plane也并没有全都用完,所以Unicode还没有很大的未编码空间,相当长的时间内够用了。

注意:自2003年起,Unicode的编码空间被规范为了21bit,但Unicode编码并没有占多少位之说,而真正涉及到在存储器中占多少位时,便到了字符编码阶段,即UTF-8,UTF-16,UTF-32等,这些字符编码表在编程中也叫做编解码器

UTF-n表示用n位作为码元来编码Unicode的码位。以UTF-8为例,它的码元是1字节,且最多用4个码元为Unicode的码位进行编码,编码规则如下表所示:

表中的×用Unicode的16进制码位的2进制序列从右向左依次替换,比如U+07FF的二进制序列为 :00000,11111,111111(这里的逗号位置只是为了和后面作比较,并不是正确的位置);

那么U+07FF经UTF-8编码后的比特序列则为110 11111,10 111111,暂时将这个序列命名为a

至此已经完成了前3步工作,现在开始执行序列化:如果CPU是大端模式,那么序列a就是U+07FF在机器中的字节序列,但如果是小端模式,序列a的这两个字节需要调换位置,变为10 111111,110 11111,这才是实际的字节序列。

3. Python中的字节序列

Python3明确区分了人类可读的字符串和原始的字节序列。Python3中,文本总是Unicode,由str类型表示,二进制数据由bytes类型表示,并且Python3不会以任何隐式的方式混用strbytes。Python3中的str类型基本相当于Python2中的unicode类型。

Python3内置了两种基本的二进制序列类型:不可变bytes类型和可变bytearray类型。这两个对象的每个元素都是介于0-255之间的整数,而且它们的切片始终是同一类型的二进制序列(而不是单个元素)。

以下是关于字节序列的一些基本操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
>>> "China".encode("utf8")  # 也可以 temp = bytes("China", encoding="utf_8")
b'China'
>>> a = "中国"
>>> utf = a.encode("utf8")
>>> utf
b'\xe4\xb8\xad\xe5\x9b\xbd'
>>> a
'中国'
>>> len(a)
2
>>> len(utf)
6
>>> utf[0]
228
>>> utf[:1]
b'\xe4'
>>> b = bytearray("China", encoding="utf8") # 也可以b = bytearray(utf)
>>> b
bytearray(b'China')
>>> b[-1:]
bytearray(b'a')

二进制序列实际是整数序列,但在输出时为了方便阅读,将其进行了转换,以b开头,其余部分:

  • 可打印的ASCII范围内的字节,使用ASCII字符本身;
  • 制表符、换行符、回车符和\对应的字节,使用转义序列\t\n\r\\
  • 其他字节的值,使用十六进制转义序列,以\x开头。

bytesbytesarray的构造方法如下:

  • 一个str对象和一个encoding关键字参数;
  • 一个可迭代对象,值的范围是range(256)
  • 一个实现了缓冲协议的对象(如bytesbytearraymemoryviewarray.array),此时它将源对象中的字节序列复制到新建的二进制序列中。并且,这是一种底层操作,可能涉及类型转换。

除了格式化方法(formatformat_map)和几个处理Unicode数据的方法外,bytesbytearray都支持str的其他方法,例如bytes. endswithbytes.replace等。同时,re模块中的正则表达式函数也能处理二进制序列(当正则表达式编译自二进制序列时会用到)。

二进制序列有个str没有的方法fromhex,它解析十六进制数字对,构件二进制序列:

1
2
>>> bytes.fromhex("31 4b ce a9")
b'1K\xce\xa9'

补充:struct模块提供了一些函数,这些函数能把打包的字节序列转换成不同类型字段组成的元组,或者相反,把元组转换成打包的字节序列。struct模块能处理bytesbytearraymemoryview对象。这个不是本篇重点,不再赘述。

4. 编解码器问题

如第2节所述,我们常说的UTF-8,UTF-16实际上是字符编码表,在编程中一般被称为编解码器。本节主要讲述关于编解码器的错误处理:UnicodeEncodeErrorUnicodeDecodeErrorSyntaxError

Python中一般会明确的给出某种错误,而不会笼统地抛出UnicodeError,所以,在我们自行编写处理异常的代码时,也最好明确错误类型。

4.1 UnicodeEncodeError

当从文本转换成字节序列时,如果编解码器没有定义某个字符,则有可能抛出UnicodeEncodeError

1
2
3
4
5
6
7
8
9
10
11
12
>>> country = "中国"
>>> country.encode("utf8")
b'\xe4\xb8\xad\xe5\x9b\xbd'
>>> country.encode("utf16")
b'\xff\xfe-N\xfdV'
>>> country.encode("cp437")
Traceback (most recent call last):
File "<input>", line 1, in <module>
File "E:\Code\Python\Study\venv\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>

可以指定错误处理方式:

1
2
3
4
5
6
>>> country.encode("cp437", errors="ignore")  # 跳过无法编码的字符,不推荐
b''
>>> country.encode("cp437", errors="replace") # 把无法编码的字符替换成“?”
b'??'
>>> country.encode("cp437", errors="xmlcharrefreplace") # 把无法编码的字符替换成XML实体
b'&#20013;&#22269;'

4.2 UnicodeDecodeError

相应的,当从字节序列转换成文本时,则有可能发生UnicodeDecodeError

1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> octets.decode("cp1252")
'Montréal'
>>> octets.decode("iso8859_7")
'Montrιal'
>>> octets.decode("utf_8")
Traceback (most recent call last):
File "<input>", line 1, in <module>
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 5:
invalid continuation byte

# 解码错误的处理与4.1类似
>>> octets.decode("utf8", errors="replace")
# "�"字符是官方指定的替换字符(REPLACEMENT CHARACTER),表示未知字符,码位U+FFFD
'Montr�al'

4.3 SyntaxError

当加载Python模块时,如果源码的编码与文件解码器不符时,则会出现SyntaxError。比如Python3默认UTF-8编码源码,如果你的Python源码编码时使用的是其他编码,而代码中又没有声明编解码器,那么Python解释器可能就会发出SyntaxError。为了修正这个问题,可在文件开头指明编码类型,比如表明编码为UTF-8,则应在源文件顶部写下此行代码:#-*- coding: utf8 -*- ”(没有引号!)

补充:Python3允许在源码中使用非ASCII标识符,也就是说,你可以用中文来命名变量(笑。。。)。如下:

1
2
3
>>> 甲="abc"
>>>
'abc'

但是极不推荐!还是老老实实用英文吧,哪怕拼音也行。

4.4 找出字节序列的编码

有时候一个文件并没有指明编码,此时该如何确定它的编码呢?实际并没有100%确定编码类型的方法,一般都是靠试探和分析找出编码。比如,如果b"\x00"字节经常出现,就很有可能是16位或32位编码,而不是8位编码。Chardet就是这样工作的。它是一个Python库,能识别所支持的30种编码。以下是它的用法,这是在终端命令行中,不是在Python命令行中:

1
2
$ chardetect 04-text-byte.asciidoc
04-text-byte.asciidoc: utf-8 with confidence 0.99

4.5 字节序标记BOM(byte-order mark)

当使用UTF-16编码时,字节序列前方会有几个额外的字节,如下:

1
2
>>> 'El Niño'.encode("utf16")
b'\xff\xfeE\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00' # 注意前两个字节b"\xff\xfe"

BOM用于指明编码时使用的是大端模式还是小端模式,上述例子是小端模式。UTF-16在要编码的文本前面加上特殊的不可见字符ZERO WIDTH NO-BREAK SPACE(U+FEFF)。UTF-16有两个变种:UTF-16LE,显示指明使用小端模式;UTF-16BE,显示指明大端模式。如果显示指明了模式,则不会生成BOM:

1
2
3
4
>>> 'El Niño'.encode("utf_16le")
b'E\x00l\x00 \x00N\x00i\x00\xf1\x00o\x00'
>>> 'El Niño'.encode("utf_16be")
b'\x00E\x00l\x00 \x00N\x00i\x00\xf1\x00o'

根据标准,如果文件使用UTF-16编码,且没有BOM,则应假定它使用的是UTF-16大端模式编码。然而Intel x86架构用的是小端模式,因此很多文件用的是不带BOM的小端模式UTF-16编码。这就容易造成混淆,如果把这些文件直接用在采用大端模式的机器上,则会出问题(比较老的AMD也有大端模式,现在的AMD也是x86架构了)。

由于大小端模式(字节顺序)只对一个字(word)占多个字节的编码有影响,所以对于UTF-8来说,不管设备使用哪种模式,生成的字节序列始终一致,因此不需要BOM。但在Windows下就比较扯淡了,有些应用依然会添加BOM,并且会根据有无BOM来判断是不是UTF-8编码。

补充:笔者查资料时发现有“显示指明BOM”一说,刚看到的时候笔者以为是在函数中传递一个bom关键字参数来指明BOM,然而不是,而是传入一个带有BOM标识的编解码器,如下:

1
2
3
4
5
# 默认UTF-8不带BOM,如果想让字节序列带上BOM,则应传入utf_8_sig
>>> 'El Niño'.encode("utf_8_sig")
b'\xef\xbb\xbfEl Ni\xc3\xb1o'
>>> 'El Niño'.encode("utf_8")
b'El Ni\xc3\xb1o'

5. 处理文本文件

处理文本的最佳实践是”Unicode三明治”模型。图示如下:

此模型的意思是:

  1. 对输入的字节序列应尽早解码为字符串;
  2. 第二层相当于程序的业务逻辑,这里应该保证只处理字符串,而不应该有编码或解码的操作存在;
  3. 对于输出,应尽晚地把字符串编码为字节序列

当我们用Python处理文本时,我们实际对这个模型并没有多少感觉,因为Python在读写文件时会为我们做必要的编解码工作,我们实际处理的是这个三明治的中间层。

5.1 Python编解码

Python中调用open函数打开文件时,默认使用的是编解码器与平台有关,如果你的程序将来要跨平台,推荐的做法是明确传入encoding关键字参数。其实不管跨不跨平台,这都是推荐的做法。

对于open函数,当以二进制模式打开文件时,它返回一个BufferedReader对象;当以文本模式打开文件时,它返回的是一个TextIOWrapper对象:

1
2
3
4
5
6
>>> fp = open("zen.txt", "r", encoding="utf8")
>>> fp
<_io.TextIOWrapper name='zen.txt' mode='r' encoding='utf8'>
>>> fp2 = open("zen.txt", "rb") # 当以二进制读取文件时,不需要指定编解码器
>>> fp2
<_io.BufferedReader name='zen.txt'>

这里有几个点

  • 除非想判断编码方式,或者文件本身就是二进制文件,否则不要以二进制模式打开文本文件;就算想判断编码方式,也应该使用Chardet,而不是重复造轮子。
  • 如果打开文件时未传入encoding参数,默认值将由locale.getpreferredencoding()提供,但从这么函数名可以看出,其实它返回的也不一定是系统的默认设置,而是用户的偏好设置。用户的偏好设置在不同系统中不一定相同,而且有的系统还没法设置偏好,所以,正如官方文档所说,该函数返回的是一个猜测的值;
  • 如果设定了PYTHONENCODING环境变量,sys.stdout/stdin/stderr的编码则使用该值,否则继承自所在的控制台;如果输入输出重定向到文件,编码方式则由locale.getpreferredencoding()决定;
  • Python读取文件时,对文件名(不是文件内容!)的编解码器由sys.getfilesystemencoding()函数提供,当以字符串作为文件名传入open函数时就会调用它。但如果传入的文件名是字节序列,则会直接将此字节序列传给系统相应的API。

总之:别依赖默认值

如果遵循Unicode三明治模型,并且始终在程序中指定编码,那将避免很多问题。但Unicode也有不尽人意的地方,比如文本规范化(为了比较文本)和排序。如果你只在ASCII环境中,或者语言环境比较固定单一,那么这两个操作对你来说会很轻松,但如果你的程序面向多语言文本,那么这两个操作会很繁琐。

5.2 规范化Unicode字符串

由于Unicode有组合字符,所以字符串比较起来比较复杂。

补充:组合字符指变音符号和附加到前一个字符上的记号,打印时作为一个整体。

1
2
3
4
5
6
7
8
>>> s1 = 'café'
>>> s2 = 'cafe\u0301'
>>> s1, s2
('café', 'café')
>>> len(s1), len(s2)
(4, 5)
>>> s1 == s2
False

在Unicode标准中,'é''e\u0301'叫做标准等价物,应用程序应该将它们视为相同的字符,但从上面代码可以看出,Python并没有将它们视为等价物,这就给Python中比较两个字符串添加了麻烦。

解决的方法是使用unicodedata.normalize函数提供的Unicode规范化。它有四个标准:NFCNFDNFKCNFKD

5.2.1 NFC和NFD

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

1
2
3
4
5
6
7
8
9
>>> from unicodedata import normalize
>>> len(normalize("NFC", s1)), len(normalize("NFC", s2))
(4, 4)
>>> len(normalize("NFD", s1)), len(normalize("NFD", s2))
(5, 5)
>>> normalize("NFD", s1) == normalize("NFD", s2)
True
>>> normalize("NFC", s1) == normalize("NFC", s2)
True

NFC是W3C推荐的规范化形式。西方键盘通常能输出组合字符,因此用户输入的文本默认是NFC形式。我们对变音字符用的不多。但还是那句话,如果你的程序面向多语言文本,为了安全起见,最好还是用normalize(”NFC“, user_text)清洗字符串

使用NFC时,有些单字符会被规范成另一个单字符,例如电阻的单位欧姆(Ω,U+2126\u2126)会被规范成希腊字母大写的欧米伽(U+03A9, \u03a9)。这俩看着一样,现实中电阻欧姆的符号也就是从希腊字母来的,两者应该相等,但在Unicode中是不等的,因此需要规范化,防止出现意外。

5.2.2 NFKC和NFKD

NFKCNFKD(K表示“compatibility”,兼容性)是比较严格的规范化形式,对“兼容字符”有影响。为了兼容现有的标准,Unicode中有些字符会出现多次。比如希腊字母'μ'U+03BC),Unicode除了有它,还加入了微符号'µ'(U+00B5),以便和latin1标准相互转换,所以微符号是个“兼容字符”(上述的欧姆符号不是兼容字符!)。这两个规范会将兼容字符分解为一个或多个字符,如下:

1
2
3
4
5
6
7
>>> from unicodedata import normalize, name
>>> half = '½'
>>> normalize("NFKC", half)
'1/2'
>>> four_squared = '4²'
>>> normalize("NFKC", four_squared)
'42'

从上面的代码可以看出,这两个标准可能会造成格式损失,甚至曲解信息,但可以为搜索和索引提供便利的中间表述。比如用户在搜索1/2 inch时,可能还会搜到包含½ inch的文章,这便增加了匹配选项。

5.2.3 大小写折叠

对于搜索或索引,大小写是个很有用的操作。同时,对于Unicode来说,大小写折叠还是个复杂的问题。对于此问题,如果是初学者,首先想到的一定是str.lower()str.upper()。但在处理多语言文本时,str.casefold()更常用,它将字符转换成小写。自Python3.4起,str.casefold()str.lower()得到不同结果的有116个码位。对于只包含latin1字符的字符串ss.casefold()得到的结果和s.lower()一样,但有两个例外:微符号'µ'会变为希腊字母'μ';德语Eszett(“sharp s”,ß)为变成'ss'

5.2.4 规范化文本匹配使用函数

下面给出用以上内容编写的几个规范化匹配函数。对大多数应用来说,NFC是最好的规范形式。不区分大小写的比较应该使用str.casefold()。对于处理多语言文本,以下两个函数应该是必不可少的:

1
2
3
4
5
6
7
8
# 两个多语言文本中的比较函数
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()

有时我们还想把变音符号去掉(例如“caf锓cafe”),比如谷歌在搜索时就有可能去掉变音符号;或者想让URL更易读时,也需要去掉变音符号。如果想去掉文本中的全部变音符号,则可用如下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 去掉多语言文本中的变音符号
import unicodedata

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

order = '“Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí.”'
print(shave_marks(order))
greek = 'Ζέφυρος, Zéfiro'
print(shave_marks(greek))

# 结果:
“Herr Voß: • ½ cup of Œtker™ caffe latte • bowl of acai.”
Ζεφυρος, Zefiro

上述代码去掉了所有的变音字符,包括非拉丁字符,但有时我们想只去掉拉丁字符中的变音字符,为此,我们还需要对基字符进行判断,以下这个版本只去掉拉丁字符中的变音字符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 仅去掉拉丁文中的变音符号
import unicodedata
import string

def shave_marks_latin(txt):
"""去掉拉丁基字符中的所有变音符号"""
norm_txt = unicodedata.normalize("NFD", txt)
latin_base = unicodedata.combining(norm_txt[0]) # <1>
keepers = []
for c in norm_txt:
if unicodedata.combining(c) and latin_base:
continue
keepers.append(c)
if not unicodedata.combining(c):
latin_base = c in string.ascii_letters
shaved = "".join(keepers)
return unicodedata.normalize("NFC", shaved)

# '́' 这是提取出来的变音符号
t = '́cafe'
print(shave_marks_latin(t))

# 结果
cafe

注意<1>处,如果一开始直接latin_base = False,那么遇到刁钻的人,该程序的结果将是错误的:大家可以试一试,把<1>处改成latin_base = False,然后运行该程序,看c上面的变音符号去掉了没有。之所以第7行写成上述形式,就是考虑到可能有的人闲着没事,将变音符号放在字符串的开头。

更彻底的规范化步骤是把西文中的常见符号替换成ASCII中的对等字符,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 将拉丁文中的变音符号去掉,并把西文中常见符号替换成ASCII中的对等字符
single_map = str.maketrans("""‚ƒ„†ˆ‹‘’“”•–—˜›""",
"""'f"*^<''""---~>""")

multi_map = str.maketrans({
'€': '<euro>',
'…': '...',
'Œ': 'OE',
'™': '(TM)',
'œ': 'oe',
'‰': '<per mille>',
'‡': '**',
})

multi_map.update(single_map)

# 该函数不影响ASCII和latin1文本,只替换微软在cp1252中为latin1额外添加的字符
def dewinize(txt):
"""把win1252符号替换成ASCII字符或序列"""
return txt.translate(multi_map)

def asciize(txt):
no_mark = shave_marks_latin(dewinize(txt))
no_mark = no_mark.replace('ß', 'ss')
return unicodedata.normalize("NFKC", no_mark)

order = '“Herr Voß: • ½ cup of Œtker™ caffè latte • bowl of açaí.”'
print(asciize(order))

# 结果:
"Herr Voss: - 1⁄2 cup of OEtker(TM) caffe latte - bowl of acai."

5.3 Unicode文本排序

Python中,非ASCII文本的标准排序方式是使用locale.strxfrm函数,该函数“把字符串转换成适合所在地区进行比较的形式”,即和系统设置的地区相关。在使用locale.strxfrm之前,必须先为应用设置合适的区域,而这还得指望着操作系统支持用户自定义区域设置。比如以下排序:

1
2
3
4
5
6
7
8
9
10
11
12
>>> fruits = ["香蕉", "苹果", "桃子", "西瓜", "猕猴桃"]
>>> sorted(fruits)
['桃子', '猕猴桃', '苹果', '西瓜', '香蕉']
>>> import locale
>>> locale.setlocale(locale.LC_COLLATE, "zh_CN.UTF-8") # 设置后能按拼音排序
Traceback (most recent call last):
File "<input>", line 1, in <module>
File "locale.py", line 598, in setlocale
return _setlocale(category, locale)
locale.Error: unsupported locale setting
>>> locale.getlocale()
(None, None)

笔者是Windows系统,不支持区域设置,不知道Linux下支不支持,大家可以试试。

5.3.1 PyUCA

想要正确实现Unicode排序,可以使用PyPI中的PyUCA库,这是Unicode排序算法的纯Python实现。它没有考虑区域设置,而是根据Unicode官方数据库中的排序表排序,只支持Python3。以下是它的简单用法:

1
2
3
4
5
6
>>> import pyuca
>>> coll = pyuca.Collator()
>>> sorted(["cafe", "caff", "café"])
["cafe", "caff", "café"]
>>> sorted(["cafe", "caff", "café"], key=coll.sort_key)
["cafe", "café", "caff"]

如果想定制排序方式,可把自定义的排序表路径传给Collator()构造方法。

6. 补充

6.1 Unicode数据库

Unicode标准提供了一个完整的数据库(许多格式化的文本文件),它记录了字符是否可打印、是不是字母、是不是数字、或者是不是其它数值符号等,这些数据叫做字符的元数据。字符串中的isidentifierisprintableisdecimalisnumeric等方法都用到了该数据库。unicodedata模块中有几个函数可用于获取字符的元数据,比如unicodedata.name()用于获取字符的官方名称(全大写),unicodedata.numeric()得到数值字符(如“1”)的浮点数值。

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

目前为止,我们一般都将字符串作为参数传递给函数,但Python标准库中有些函数既支持字符串也支持字节序列作为参数,比如re和os模块中就有这样的函数。

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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+')

# 要搜索的Unicode文本,包括“1729”的泰米尔数字
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)) # 字符串模式r'\d+'能匹配多种数字
print(' bytes:', re_numbers_bytes.findall(text_bytes)) # 只能匹配ASCII中的数字
print('Words')
print(' str :', re_words_str.findall(text_str)) # 能匹配字母、上标、泰米尔数字和ASCII数字
print(' bytes:', re_words_bytes.findall(text_bytes)) # 只能匹配ASCII字母和数字

# 结果:
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']

6.2.2 os模块中的字符串和字节序列

Python的os模块中的所有函数、文件名或操作路径参数既能是字符串,也能是字节序列。如下:

1
2
3
4
5
6
7
8
>>> os.listdir(".")
['π.txt']
>>> os.listdir(b".")
[b'\xcf\x80.txt']
>>> os.fsencode("π.txt")
b'\xcf\x80.txt'
>>> os.fsdecode(b'\xcf\x80.txt')
'π.txt'

在Unix衍生平台中,这些函数编解码时使用surrogateescape错误处理方式以避免遇到意外字节序列时卡住。surrogateescape把每个无法解码的字节替换成Unicode中U+DC00U+DCFF之间的码位,这些码位是保留位,未分配字符,共应用程序内部使用。Windows使用的错误处理方式是strict

7. 总结

本节内容较多。本篇首先介绍了编码的基本概念,并以Unicode为例说明了编码的具体过程;然后介绍了Python中的字节序列;随后开始接触实际的编码处理,如Python编解码过程中会引发的错误,以及Python中Unicode字符的比较和排序。最后,本篇简要介绍了Unicode数据库和双模式API。

VPointer wechat
欢迎大家关注我的微信公众号"代码港"~~
您的慷慨将鼓励我继续创作~~