Go 博客

Go 语言中的文本规范化

Marcel van Lohuizen
2013 年 11 月 26 日

介绍

一篇早期的文章讨论了 Go 语言中的字符串、字节和字符。我一直在为 go.text 存储库开发各种多语言文本处理包。其中一些包值得单独撰写博文,但今天我想重点介绍go.text/unicode/norm,它处理规范化,这是字符串文章中涉及到的主题,也是本文的主题。规范化在比原始字节更高的抽象级别上工作。

要了解关于规范化您想知道的一切(以及更多),Unicode 标准的附录 15是一个不错的阅读材料。一篇更易于理解的文章是相应的维基百科页面。在这里,我们重点关注规范化与 Go 的关系。

什么是规范化?

通常有几种方法可以表示相同的字符串。例如,é(带重音的 e)可以在字符串中表示为单个字符(“\u00e9”)或一个’e’后跟一个重音符号(“e\u0301”)。根据 Unicode 标准,这两个是“规范等价”的,应该被视为相等。

使用逐字节比较来确定这两个字符串是否相等显然不会得到正确的结果。Unicode 定义了一组标准形式,以便如果两个字符串是规范等价的并且被规范化为相同的标准形式,则它们的字节表示形式相同。

Unicode 还定义了一个“兼容等价”来等价表示相同字符但可能具有不同视觉外观的字符。例如,上标数字‘⁹’和普通数字‘9’在此形式下是等价的。

对于这两种等价形式中的每一种,Unicode 都定义了组合形式和分解形式。前者用单个字符替换可以组合成单个字符的字符。后者将字符分解成其组成部分。此表显示了 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。

当然,如果我们事先知道字符串已经规范化,我们也可以完全避免开销,这在很多情况下都是如此。

为什么要费心?

在经过所有关于避免规范化的讨论之后,您可能会问为什么还要为此担心。原因是在某些情况下需要规范化,并且了解这些情况以及如何正确执行规范化非常重要。

在讨论这些问题之前,我们必须首先澄清“字符”的概念。

什么是字符?

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

理论上,构成 Unicode 字符的字符数没有限制。事实上,对跟随字符的修饰符数量没有限制,并且可以重复或堆叠修饰符。见过带三个重音的’e’吗?给你看:’é́́’。根据标准,这是一个完全有效的 4 个字符的字符。

因此,即使在最低级别,文本也需要以无限块大小的增量进行处理。这对于文本处理的流式方法(如 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 包也可能派上用场。考虑一个您想搜索并替换单词“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ś”。这种行为是不可取的。

问题在于代码没有尊重多字符字符之间的边界,并在字符中间插入了一个字符。使用 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)

这可能是一个人为的例子,但要点应该很清楚。请注意字符可以跨越多个字符这一事实。通常,可以通过使用尊重字符边界的搜索功能(例如计划中的 go.text/search 包)来避免这些问题。

迭代

norm 包提供的另一个可能有助于处理字符边界的工具是其迭代器,norm.Iter。它一次迭代一个字符,按照选择的标准形式。

执行魔法

如前所述,大多数文本都采用 NFC 形式,其中基础字符和修饰符尽可能组合成单个字符。为了分析字符,通常更容易在将字符分解成其最小组件后处理字符。这就是 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 类型提供了这些软件包所需的每个字符的信息,最值得注意的是规范组合类和分解信息。如果您想深入了解,请阅读此类型的文档

性能

为了了解规范化的性能,我们将它与 strings.ToLower 的性能进行了比较。第一行中的示例同时为小写且为 NFC 格式,因此在任何情况下都可以直接返回。第二个示例既不是小写也不是 NFC 格式,需要重新生成一个新的版本。

输入 ToLower NFC 追加 NFC 转换 NFC 迭代
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 软件包以及多语言文本处理的存在,并且可能引发的问题比提供的答案更多。但是,这些主题的讨论将不得不留待以后。

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