文本规范化本质是信息交换,必须在索引和查询时用同一函数。调用lower()看似简单,却让Apple和apple、咖啡和café无法区分,文中给出了结构化决策框架。
每一步规范化都会以丢弃信息为代价,换来让两个原本不同的事物变得相等。这就是全部的取舍,而 bug 往往来自于意外地做了这桩取舍——通常就是调用一个 .lower() 然后就不管了。
问题从来不是"我应该规范化吗"。而是"我选择抹掉哪些区别,而下游业务是否需要它们?"消除大小写差异意味着 Apple 和 apple 会匹配,但你也就再也无法区分一家公司和一种水果了。消除变音符号意味着 café 和 cafe 会匹配,但也会导致西班牙语中的 año 和 ano 混为同一个词——而这是母语者能够察觉的区别。
一个结构层面的规则能够防止大多数灾难:无论你决定什么,索引时和查询时必须运行相同的函数。一旦不匹配,对于明显正确的查询会返回零结果,这在单元测试中完全看不见,因为两边单独运行都没问题。始终保留原始文本不做修改,这样任何决定都可以重新审视,而无需重新获取整个语料库。
字符 é 存在两种合法的编码方式:单个码位 U+00E9,或者字母 e 后面跟着 U+0301,一个组合尖重音符。两者在所有字体中渲染效果完全相同。在 Python 中,"café" == "café" 返回 False,长度相差一。从 Mac 文件系统复制的文本往往以分解形式到达;从 Web 表单获取的文本往往以组合形式到达。两者最终都会进入同一个索引。
Unicode 标准附录 #15 定义了四种规范化形式,它们之间的区别来自两个独立的选择:
与真实数据接触后仍能存活的实用策略:存储 NFC,并基于 NFKD 生成一个折叠键用于匹配,两者都保留。将兼容形式用于存储会悄无声息地破坏数学符号和化学公式,因为其中的上标确实有含义。
表格中真正起作用的是 canonical 和 compatibility 这两个词,值得弄清楚它们之间的区别,因为这是可逆修复和刻意丢失之间的差异。规范等价(NFC 和 NFD)涉及 Unicode 认为相同的字符以不同编码方式呈现的序列——比如 é 的两种拼写——它们之间的转换不会改变读者看到的任何东西。兼容等价(NFKC 和 NFKD)涉及只是在使用中相似的字符:上标二和数字二、全角和半角字母、连字及其组成字母。这种转换会刻意丢弃信息,而这正是它为什么应该出现在匹配键中而绝不应该出现在存储内容中的原因。
lower()小写化是一个对区域敏感、不可逆的操作,有明确的异常情况,将其视为简单的字符映射会产生真正的缺陷。
德语 sharp s。 ß 的大写在历史上是 SS,所以 "Straße".upper() 得到 "STRASSE",再小写化得到 "strasse"——这就不再等于 "straße" 了。Python 为这类问题专门提供了 str.casefold():它将 ß 映射为 ss,使两种拼写都折叠到相同的键。用于匹配时,应该用 casefold(),而不是 lower()。
土耳其语的有点 i 和无点 i。 土耳其语有四个 i 字母:带点的 i/İ 和不带点的 ı/I。按照土耳其语区域规则,I 的小写是 ı,不是 i。因此,语言中立的小写化会破坏土耳其语单词,而区域感知的小写化会破坏英语单词。更糟糕的是,对 İ (U+0130) 进行语言中立的小写化会产生两个码位——i 加一个组合点在上方——所以字符串变长了,这会破坏任何假设小写化保持长度或偏移不变的代码。
偏移量的一般性问题。 任何规范化都可能改变字符串长度。如果你正在存储用于高亮或实体范围的字符偏移量,要根据存储的原始文本计算,绝不要根据规范化后的副本来计算,否则高亮会在那些恰好需要规范化的文档上出现偏移。
不可见字符。 零宽空格、零宽连接符、字节顺序标记、软连字符和不间断空格。它们来自 PDF、字处理器和复制粘贴,它们在所有日志和终端中都是不可见的,这使得两个表面上完全相同的字符串实际上不相等。要显式地剥离或规范化它们;不要等到发现它们才处理。
智能标点符号。 文字处理器会将 ' 转换为 ' ,将 -- 转换为破折号。用户在搜索 don't 时使用直撇号什么也找不到。在匹配键中将引号和破折号折叠为 ASCII。
易混淆字符。 西里尔字母 а、希腊字母 ο 和拉丁字母 a/o 看起来完全相同。Unicode 技术标准 #39 正确定义了易混淆字符映射用于检测这种情况,这对任何安全相关的事物都很重要——域名、用户名、收款人姓名。
长度并不像你想的那样。 一个带有肤色和连接序列的表情符号可能包含多个码位。任何以字符表达的限