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

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

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

U+0301 是 COMBINING ACUTE ACCENT,加在“e”后面得到“é”。在 Unicode 标准中,'é''e\u0301' 这样的序列叫“标准等价物”(canonical equivalent),应用程序应该把它们视作相同的字符。但是,Python 看到的是不同的码位序列,因此判定二者不相等。

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

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

>>> from unicodedata import normalize
>>> s1 = 'café'  # 把"e"和重音符组合在一起
>>> s2 = 'cafe\u0301'  # 分解成"e"和重音符
>>> len(s1), len(s2)
(4, 5)
>>> len(normalize('NFC', s1)), len(normalize('NFC', s2))
(4, 4)
>>> len(normalize('NFD', s1)), len(normalize('NFD', s2))
(5, 5)
>>> normalize('NFC', s1) == normalize('NFC', s2)
True
>>> normalize('NFD', s1) == normalize('NFD', s2)
True

西方键盘通常能输出组合字符,因此用户输入的文本默认是 NFC 形式。不过,安全起见,保存文本之前,最好使用 normalize('NFC', user_text) 清洗字符串。NFC 也是 W3C 的“Character Model for the World Wide Web: String Matching and Searching”规范(https://www.w3.org/TR/charmod-norm/)推荐的规范化形式。

使用 NFC 时,有些单字符会被规范成另一个单字符。例如,电阻的单位欧姆(Ω)会被规范成希腊字母大写的欧米加。这两个字符在视觉上是一样的,但是比较时并不相等,因此要规范化,防止出现意外:

>>> from unicodedata import normalize, name
>>> ohm = '\u2126'
>>> name(ohm)
'OHM SIGN'
>>> ohm_c = normalize('NFC', ohm)
>>> name(ohm_c)
'GREEK CAPITAL LETTER OMEGA'
>>> ohm == ohm_c
False
>>> normalize('NFC', ohm) == normalize('NFC', ohm_c)
True

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

在 NFKC 和 NFKD 形式中,各个兼容字符会被替换成一个或多个“兼容分解”字符,即便这样有些格式损失,但仍是“首选”表述——理想情况下,格式化是外部标记的职责,不应该由 Unicode 处理。下面举个例子。二分之一 '½'(U+00BD)经过兼容分解后得到的是三个字符序列 '1/2';微符号 'µ'(U+00B5)经过兼容分解后得到的是小写字母 'μ'(U+03BC)。8

8微符号是“兼容字符”,而欧姆符号不是,这还真是奇怪。因此,NFC 不会改动微符号,但是会把欧姆符号改成大写的欧米加;而 NFKC 和 NFKD 会把欧姆和微符号都改成其他字符。

下面是 NFKC 的具体应用:

>>> from unicodedata import normalize, name
>>> half = '½'
>>> normalize('NFKC', half)
'1⁄2'
>>> four_squared = '4²'
>>> normalize('NFKC', four_squared)
'42'
>>> micro = 'μ'
>>> micro_kc = normalize('NFKC', micro)
>>> micro, micro_kc
('μ', 'μ')
>>> ord(micro), ord(micro_kc)
(181, 956)
>>> name(micro), name(micro_kc)
('MICRO SIGN', 'GREEK SMALL LETTER MU')

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

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

为搜索或索引准备文本时,还有一个有用的操作,即下一节讨论的大小写折叠。

大小写折叠其实就是把所有文本变成小写,再做些其他转换。这个功能由 str.casefold() 方法(Python 3.3 新增)支持。

对于只包含 latin1 字符的字符串 ss.casefold() 得到的结果与 s.lower() 一样,唯有两个例外:微符号 'µ' 会变成小写的希腊字母“μ”(在多数字体中二者看起来一样);德语 Eszett(“sharp s”,ß)会变成“ss”。

>>> micro = 'μ'
>>> name(micro)
'MICRO SIGN'
>>> micro_cf = micro.casefold()
>>> name(micro_cf)
'GREEK SMALL LETTER MU'
>>> micro, micro_cf
('μ', 'μ')
>>> eszett = 'ß'
>>> name(eszett)
'LATIN SMALL LETTER SHARP S'
>>> eszett_cf = eszett.casefold()
>>> eszett, eszett_cf
('ß', 'ss')

自 Python 3.4 起,str.casefold()str.lower() 得到不同结果的有 116 个码位。Unicode 6.3 命名了 110 122 个字符,这只占 0.11%。

与 Unicode 相关的任何问题一样,大小写折叠是个复杂的问题,有很多语言上的特殊情况,但是 Python 核心团队尽力提供了一种方案,能满足大多数用户的需求。

接下来的几节将使用这些规范化知识来开发几个实用的函数。

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

如果要处理多语言文本,工具箱中应该有示例 4-13 中的 nfc_equalfold_equal 函数。

示例 4-13 normeq.py:比较规范化 Unicode 字符串

"""
Utility functions for normalized Unicode string comparison.

Using Normal Form C, case sensitive:

    >>> s1 = 'café'
    >>> s2 = 'cafe\u0301'
    >>> s1 == s2
    False
    >>> nfc_equal(s1, s2)
    True
    >>> nfc_equal('A', 'a')
    False

Using Normal Form C with case folding:

    >>> s3 = 'Straße'
    >>> s4 = 'strasse'
    >>> s3 == s4
    False
    >>> nfc_equal(s3, s4)
    False
    >>> fold_equal(s3, s4)
    True
    >>> fold_equal(s1, s2)
    True
    >>> fold_equal('A', 'a')
    True

"""

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())

除了 Unicode 规范化和大小写折叠(二者都是 Unicode 标准的一部分)之外,有时需要进行更为深入的转换,例如把 'café' 变成 'cafe'。下一节说明何时以及如何进行这种转换。

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

如果想把字符串中的所有变音符号都去掉,可以使用示例 4-14 中的函数。

示例 4-14 去掉全部组合记号的函数(在 sanitize.py 模块中)

import unicodedata
import string


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)  ➌

➊ 把所有字符分解成基字符和组合记号。

➋ 过滤掉所有组合记号。

➌ 重组所有字符。

示例 4-15 是 shave_marks 函数的两个使用示例。

示例 4-15 示例 4-14 中 shave_marks 函数的两个使用示例

>>> order = '“Herr Voß: • ½ cup of OEtker™ caffè latte • bowl of açaí.”'
>>> shave_marks(order)
'“Herr Voß: • ½ cup of OEtker™ caffe latte • bowl of acai.”'  ➊
>>> Greek = 'Zέφupoς, Zéfiro'
>>> shave_marks(Greek)
'Ζεφupoς, Zefiro'  ➋

➊ 只替换了“蔓ç”和“í”三个字符。

➋ “έ”和“é”都被替换了。

示例 4-14 中定义的 shave_marks 函数使用起来没问题,但是也许做得太多了。通常,去掉变音符号是为了把拉丁文本变成纯粹的 ASCII,但是 shave_marks 函数还会修改非拉丁字符(如希腊字母),而只去掉重音符并不能把它们变成 ASCII 字符。因此,我们应该分析各个基字符,仅当字符在拉丁字母表中时才删除附加的记号,如示例 4-16 所示。

示例 4-16 删除拉丁字母中组合记号的函数(import 语句省略了,因为这是示例 4-14 中定义的 sanitize.py 模块的一部分)

def shave_marks_latin(txt):
    """把拉丁基字符中所有的变音符号删除"""
    norm_txt = unicodedata.normalize('NFD', txt)  ➊
    latin_base = False
    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)      ➎

➊ 把所有字符分解成基字符和组合记号。

➋ 基字符为拉丁字母时,跳过组合记号。

➌ 否则,保存当前字符。

➍ 检测新的基字符,判断是不是拉丁字母。

➎ 重组所有字符。

更彻底的规范化步骤是把西文文本中的常见符号(如弯引号、长破折号、项目符号,等等)替换成 ASCII 中的对等字符。示例 4-17 中的 asciize 函数就是这么做的。

示例 4-17 把一些西文印刷字符转换成 ASCII 字符(这个代码片段也是示例 4-14 中 sanitize.py 模块的一部分)

single_map = str.maketrans(""",ƒ,,†ˆ‹‘’“”•––˜›""",  ➊
                           """'f"*^<''""---~>""")

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

multi_map.update(single_map)  ➌


def dewinize(txt):
    """把Win1252符号替换成ASCII字符或序列"""
    return txt.translate(multi_map)  ➍

def asciize(txt):
    no_marks = shave_marks_latin(dewinize(txt))     ➎
    no_marks = no_marks.replace('ß', 'ss')          ➏
    return unicodedata.normalize('NFKC', no_marks)  ➐


❶ 构建字符替换字符的映射表。

❷ 构建字符替换字符串的映射表。

❸ 合并两个映射表。

dewinize 函数不影响 ASCIIlatin1 文本,只替换 Microsoft 在 cp1252 中为 latin1 额外添加的字符。

❺ 调用 dewinize 函数,然后去掉变音符号。

❻ 把德语 Eszett 替换成“ss”(这里没有使用大小写折叠,因为我们想保留大小写)。

❼ 使用 NFKC 规范化形式把字符和与之兼容的码位组合起来。

示例 4-18 是 asciize 函数的使用示例。

示例 4-18 示例 4-17 中 asciize 函数的使用示例

>>> order = '“Herr Voß: • ½ cup of OEtker™ caffè latte • bowl of açaí.”'
>>> dewinize(order)
'"Herr Voß: - ½ cup of OEtker(TM) caffè latte - bowl of açaí."'  ➊
>>> asciize(order)
'"Herr Voss: - 1⁄2 cup of OEtker(TM) caffe latte - bowl of acai."'  ➋

dewinize 函数替换弯引号、项目符号和™(商标符号)。

asciize 函数调用 dewinize 函数,去掉变音符号,还会替换 'ß'

 不同语言删除变音符号的规则也有所不同。例如,德语把 'ü' 变成 'ue'。我们定义的 asciize 函数没这么精确,因此可能适合你的语言,也可能不适合。不过,它对葡萄牙语的处理是可接受的。

综上,sanitize.py 中的函数做的事情超出了标准的规范化,而且会对文本做进一步处理,很有可能会改变原意。只有知道目标语言、目标用户群和转换后的用途,才能确定要不要做这么深入的规范化。

我们对 Unicode 文本规范化的讨论到此结束。

接下来要解决的 Unicode 问题是……排序。