Go 博客

Go 语言中的文本正规化

Marcel van Lohuizen
2013 年 11 月 26 日

引言

一篇更早的文章讨论了 Go 中的字符串、字节和字符。我一直在为 go.text 仓库开发各种用于多语言文本处理的包。其中一些包值得单独发一篇博客文章,但今天我想专注于go.text/unicode/norm,它处理正规化,这是在字符串文章中提到过的主题,也是本文的主题。正规化的抽象级别比原始字节要高。

要了解关于正规化的一切(以及更多),Unicode 标准的附录 15 是一个不错的选择。一篇更易于理解的文章是对应的维基百科页面。这里我们重点介绍正规化与 Go 的关系。

什么是正规化?

通常有多种方法可以表示同一个字符串。例如,一个 é(带尖音符的 e)可以在字符串中表示为一个单独的 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 而无需重新排序其字节时。实际上,网上 99.98% 的 HTML 页面内容都是 NFC 格式(不包括标记,否则比例会更高)。绝大多数 NFC 可以分解为 NFD 而无需重新排序(这需要分配)。此外,检测何时需要重新排序是高效的,因此我们可以只为需要它的少数片段节省时间。

为了让事情变得更好,排序包通常不直接使用 norm 包,而是使用 norm 包将正规化信息与其自己的表交错。将这两个问题交错处理,可以在几乎不影响性能的情况下实现即时重新排序和正规化。即时正规化的成本是通过避免提前正规化文本并确保在编辑时保持正规形式来补偿的。后者可能很棘手。例如,连接两个 NFC 正规化字符串的结果不保证是 NFC。

当然,如果我们提前知道一个字符串已经正规化,这通常是这种情况,我们也可以完全避免开销。

为什么要费事?

在讨论了避免正规化的所有内容之后,您可能会问,为什么还要费心进行正规化。原因是有些情况需要正规化,并且了解这些情况以及如何正确进行正规化很重要。

在讨论这些之前,我们必须先弄清楚“字符”的概念。

什么是字符?

如字符串博客文章中所述,字符可能跨越多个 rune。例如,一个“e”和一个“◌́”(尖音符“\u0301”)可以组合形成“é”(NFD 中的“e\u0301”)。这两个 rune 一起构成一个字符。字符的定义可能因应用程序而异。对于正规化,我们将它定义为一个 rune 序列,该序列以一个起始符(一个不修改或向后组合的 rune)开头,后跟一个可能为空的非起始符序列(即,通常是例如重音符号的 rune)。正规化算法一次处理一个字符。

理论上,组成 Unicode 字符的 rune 数量没有上限。实际上,后面可以跟的修饰符数量没有限制,并且可以重复或堆叠修饰符。您是否见过带三个尖音符的“e”?这就是:“é́́”。根据标准,这是一个完全有效的 4-rune 字符。

因此,即使在最低级别,文本也需要以任意大小的块进行处理。这对于流式文本处理尤其尴尬,Go 的标准 Reader 和 Writer 接口就使用了这种方法,因为该模型可能会要求任何中间缓冲区也具有任意大小。此外,正规化的直接实现将具有 O(n²) 的运行时间。

对于实际应用来说,如此长的修饰符序列并没有什么有意义的解释。Unicode 定义了一个流安全文本格式,它允许将修饰符(非起始符)的数量限制为最多 30 个,这对于任何实际用途来说都绰绰有余。随后的修饰符将放置在一个新插入的组合字形连接符(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 包也可能派上用场。考虑一个情况,您想搜索并用其复数形式“cafes”替换单词“cafe”。代码片段可能如下所示。

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 信息,最值得注意的是组合类别(Canonical Combining Class)和分解信息。如果您想深入研究,请阅读该类型的文档

性能

为了说明正规化的性能,我们将其与 strings.ToLower 的性能进行了比较。第一行中的样本既是小写又是 NFC,并且在所有情况下都可以原样返回。第二个样本既不是小写也不是 NFC,需要编写新版本。

输入 ToLower NFC 追加 NFC 转换 NFC 迭代
nörmalization 199 纳秒 137 纳秒 133 纳秒 251 纳秒(621 纳秒)
No\u0308rmalization 427 纳秒 836 纳秒 845 纳秒 573 纳秒(948 纳秒)

迭代结果列显示了迭代器初始化测量值和未初始化测量值,迭代器包含不需要在重复使用时重新初始化的缓冲区。

如您所见,检测字符串是否已正规化可能非常高效。第二行中正规化的许多成本都用于初始化缓冲区,当处理更大的字符串时,这些缓冲区的成本会被摊销。结果发现,这些缓冲区很少需要,因此我们可能会在某个时候更改实现,以进一步加快小字符串的常见情况。

结论

如果您在 Go 中处理文本,通常不需要使用 unicode/norm 包来正规化您的文本。该包对于确保字符串在发送出去之前是正规化的,或者进行高级文本操作等事情仍然有用。

本文简要提到了其他 go.text 包的存在以及多语言文本处理,并且可能提出了更多问题而不是给出答案。然而,对这些主题的讨论将不得不留待以后。

下一篇文章:Go 1.2 发布
上一篇文章:Go 的四年
博客索引