Go 博客
Go 语言中的文本规范化
引言
之前的一篇文章 讨论了 Go 中的字符串、字节和字符。我一直在为 go.text 仓库开发用于多语言文本处理的各种包。其中一些包值得单独撰写博客文章,但今天我想重点介绍 go.text/unicode/norm 包,它负责处理规范化,这个主题在字符串文章中有所涉及,也是本文的主题。规范化在比原始字节更高的抽象级别上工作。
要了解关于规范化的几乎所有你想知道的内容(以及更多),Unicode 标准的附件 15 是一篇不错的阅读材料。更易于理解的文章是相应的 维基百科页面。这里我们重点关注规范化与 Go 的关系。
什么是规范化?
同一个字符串通常有几种表示方式。例如,é (e-acute) 在字符串中可以表示为单个 rune("\u00e9"),也可以表示为字母 'e' 后面跟着一个尖音符(“e\u0301”)。根据 Unicode 标准,这两种表示方式“规范等价”,应该视为相等。
使用逐字节比较来判断这两个字符串是否相等显然无法得出正确结果。Unicode 定义了一组规范化形式,使得如果两个字符串是规范等价的,并且都被规范化到相同的规范化形式,它们的字节表示将是相同的。
Unicode 还定义了“兼容等价”,用于等同表示相同字符但视觉外观可能不同的字符。例如,上标数字 ‘⁹’ 和普通数字 ‘9’ 在这种形式下是等价的。
对于这两种等价形式,Unicode 都定义了合成形式和分解形式。前者将可以组合成单个 rune 的 rune 序列替换为该单个 rune。后者将 rune 分解为其组成部分。下表显示了 Unicode 联盟用于标识这些形式的名称,它们都以 NF 开头
合成 | 分解 | |
---|---|---|
规范等价 | NFC | NFD |
兼容等价 | NFKC | NFKD |
Go 的规范化方法
正如字符串博客文章中提到的,Go 不保证字符串中的字符是规范化的。但是,go.text 包可以弥补这一点。例如,能够以特定语言方式对字符串进行排序的 collate 包,即使对于未规范化的字符串也能正常工作。go.text 中的包并不总是需要规范化的输入,但一般来说,为了获得一致的结果,可能需要进行规范化。
规范化并非没有开销,但速度很快,特别是对于排序和搜索,或者当字符串处于 NFD 或 NFC 形式,并且可以通过分解转换为 NFD 而无需重新排序字节时。实际上,网络上 HTML 页面内容的 99.98% 处于 NFC 形式(不包括标记,如果包括则比例更高)。绝大多数 NFC 可以分解为 NFD,而无需重新排序(重新排序需要分配)。此外,检测何时需要重新排序是高效的,因此我们可以仅对极少数需要重新排序的片段进行处理,从而节省时间。
更好的是,collaction 包通常不直接使用 norm 包,而是使用 norm 包将其自身的表格与规范化信息交错。将这两个问题交错处理可以在运行时进行重新排序和规范化,对性能几乎没有影响。运行时规范化的开销通过无需事先规范化文本并在编辑时确保保持规范化形式来补偿。后者可能会很棘手。例如,连接两个 NFC 规范化字符串的结果不保证是 NFC。
当然,如果我们事先知道字符串已经规范化,通常情况下就是如此,那么我们完全可以避免开销。
为何要为此费心?
经过一番关于避免规范化的讨论后,你可能会问,这究竟有什么值得关注的。原因在于,在某些情况下规范化是必需的,并且理解这些情况以及如何正确执行规范化非常重要。
在讨论这些之前,我们必须首先澄清“字符”的概念。
什么是字符?
正如字符串博客文章中提到的,字符可以跨越多个 rune。例如,一个 'e' 和一个 '◌́' (尖音符 “\u0301”)可以组合形成 ‘é’(在 NFD 中是 “e\u0301”)。 这两个 rune 合在一起就是一个字符。字符的定义可能因应用而异。对于规范化,我们将其定义为以一个 starter 开头的 rune 序列(starter 是一个不修改或不向后与任何其他 rune 组合的 rune),后跟一个可能为空的 non-starters 序列(即会修改或组合的 rune,通常是变音符号)。规范化算法每次处理一个字符。
理论上,构成一个 Unicode 字符的 rune 数量没有限制。 事实上,字符后面可以跟随的修饰符数量没有限制,而且修饰符可以重复或堆叠。见过带三个尖音符的 'e' 吗?看这里:'é́́'。根据标准,这是一个完全有效的由 4 个 rune 组成的字符。
因此,即使在最低级别,文本也需要以无限制块大小为增量进行处理。这对于流式文本处理方法来说尤其棘手,正如 Go 的标准 Reader 和 Writer 接口所使用的那样,因为该模型可能要求任何中间缓冲区也具有无限制的大小。 此外,规范化的简单实现将具有 O(n²) 的运行时间。
对于实际应用而言,如此长的修饰符序列实际上没有有意义的解释。Unicode 定义了一种 Stream-Safe Text 格式,允许将修饰符(non-starters)的数量限制在最多 30 个,这对于任何实际目的来说都绰绰有足。随后的修饰符将放置在新插入的 Combining Grapheme Joiner (CGJ 或 U+034F) 之后。Go 对所有规范化算法都采用了这种方法。这个决定牺牲了一点符合性,但获得了一点安全性。
写入规范形式
即使你不需要在 Go 代码中对文本进行规范化,但在与外部世界通信时可能仍然希望这样做。例如,规范化为 NFC 可能会压缩你的文本,使其通过网络发送的成本更低。对于某些语言,如韩语,这种节省可能是巨大的。此外,一些外部 API 可能期望文本采用某种特定的规范形式。或者你可能只是想与他人保持一致,像世界其他地方一样将文本输出为 NFC。
要将文本写入为 NFC,可以使用 unicode/norm 包来包装你选择的 io.Writer
wc := norm.NFC.Writer(w)
defer wc.Close()
// write as before...
如果你有一个短字符串并想快速转换,可以使用这种更简单的形式
norm.NFC.Bytes(b)
norm 包提供了各种其他文本规范化方法。请选择最适合你需求的方法。
捕获形似字符
你能区分 ‘K’ (“\u004B”) 和 ‘K’ (开尔文符号 “\u212A”),或者 ‘Ω’ (“\u03a9”) 和 ‘Ω’ (欧姆符号 “\u2126”) 吗?有时很容易忽略同一个基础字符变体之间的微小差异。通常,最好在标识符或任何可能通过此类形似字符欺骗用户的场合禁用这些变体,因为这可能构成安全隐患。
兼容规范化形式 NFKC 和 NFKD 会将许多视觉上几乎相同的形式映射到单个值。注意,当两个符号看起来相似,但实际上来自两种不同的字母表时,它们不会这样做。例如,拉丁字母 ‘o’、希腊字母 ‘ο’ 和西里尔字母 ‘о’ 根据这些形式的定义仍然是不同的字符。
正确的文本修改
norm 包在需要修改文本时也可能派上用场。考虑一个你想将单词“cafe”替换为其复数形式“cafes”的情况。 代码片段可能如下所示。
s := "We went to eat at multiple cafe"
cafe := "cafe"
if p := strings.Index(s, cafe); p != -1 {
p += len(cafe)
s = s[:p] + "s" + s[p:]
}
fmt.Println(s)
这会如愿且预期地打印出“We went to eat at multiple cafes”。现在假设我们的文本包含法文拼写“café”,并且是 NFD 形式
s := "We went to eat at multiple cafe\u0301"
使用上面的相同代码,复数词尾“s”仍然会插入到 'e' 之后,但在尖音符之前,结果是 “We went to eat at multiple cafeś”。 这种行为是不可取的。
问题在于代码没有尊重多 rune 字符之间的边界,并在字符中间插入了一个 rune。 使用 norm 包,我们可以将这段代码重写如下
s := "We went to eat at multiple cafe\u0301"
cafe := "cafe"
if p := strings.Index(s, cafe); p != -1 {
p += len(cafe)
if bp := norm.FirstBoundary(s[p:]); bp > 0 {
p += bp
}
s = s[:p] + "s" + s[p:]
}
fmt.Println(s)
这可能是一个牵强的例子,但要点应该清楚。请注意字符可以跨越多个 rune 的事实。通常,使用尊重字符边界的搜索功能(例如计划中的 go.text/search 包)可以避免这类问题。
迭代
norm 包提供的另一个有助于处理字符边界的工具是其迭代器,norm.Iter
。它按照选择的规范形式逐个迭代字符。
实现奇妙功能
如前所述,大多数文本采用 NFC 形式,其中基础字符和修饰符在可能的情况下组合成单个 rune。 为了分析字符,分解成最小组件后的 rune 通常更容易处理。这就是 NFD 形式派上用场的地方。例如,以下代码片段创建了一个 transform.Transformer
,它将文本分解为最小部分,移除所有变音符号,然后将文本重新合成到 NFC 形式
import (
"unicode"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
isMn := func(r rune) bool {
return unicode.Is(unicode.Mn, r) // Mn: nonspacing marks
}
t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFC)
由此产生的 Transformer
可以按如下方式用于从你选择的 io.Reader
中移除变音符号
r = transform.NewReader(r, t)
// read as before ...
例如,这将把文本中所有提及的“cafés”转换为“cafes”,无论原始文本采用何种规范形式编码。
规范化信息
如前所述,一些包将其规范化预计算到表格中,以尽量减少运行时规范化的需求。norm.Properties
类型提供了这些包所需的每个 rune 的信息,最显著的是规范组合类和分解信息。如果你想深入了解,请阅读此类型的文档。
性能
为了了解规范化的性能,我们将其与 strings.ToLower 的性能进行比较。第一行的样本既是小写又是 NFC 形式,在任何情况下都可以原样返回。第二个样本既不是小写也不是 NFC 形式,需要生成一个新版本。
输入 | ToLower | NFC Append | NFC Transform | NFC Iter |
---|---|---|---|---|
nörmalization | 199 ns | 137 ns | 133 ns | 251 ns (621 ns) |
No\u0308rmalization | 427 ns | 836 ns | 845 ns | 573 ns (948 ns) |
迭代器结果列显示了初始化和未初始化迭代器的测量值,迭代器包含的缓冲区在重用时无需重新初始化。
正如你所见,检测字符串是否已规范化可以非常高效。第二行规范化的许多开销在于初始化缓冲区,这个成本在处理大字符串时会得到分摊。事实证明,这些缓冲区很少需要,因此我们可能会在某个时候改变实现,以进一步加快小字符串的常见情况处理速度。
结论
如果你在 Go 中处理文本,通常不必使用 unicode/norm 包来规范化你的文本。该包对于诸如在发送字符串之前确保其已规范化或进行高级文本操作等任务仍然可能有用。
本文简要提到了其他 go.text 包和多语言文本处理的存在,这可能会引发比解答更多的问题。然而,对这些主题的讨论将不得不留待以后进行。