Go 中的文本标准化
Marcel van Lohuizen
2013 年 11 月 26 日
介绍#
较早的一篇 文章 中谈到了 Go 中的字符串,字节和字符。我一直在研究用于 go.text 存储库的多语言文本处理的各种程序包。其中几个软件包值得单独撰写博客文章,但今天我要重点关注 go.text/unicode/norm(负责规范化),这是 字符串文章 中涉及的主题以及本文的主题。规范化比原始字节具有更高的抽象级别。
要学习几乎所有您想知道的有关规范化的知识 (然后是其他知识),Unicode 标准的附件 15 是一本好书。更为平易近人的文章是相应的 Wikipedia 页面。在这里,我们关注规范化与 Go 的关系。
什么是规范化?#
通常有几种方法可以表示相同的字符串。例如,é(e-acute) 可以字符串形式表示为单个符文 (".00e9") 或后跟尖音符号 ("e.0301") 的 'e'. 根据 Unicode 标准,这两个是 "规范等效" 的,应视为相等.
使用字节之间的比较确定相等性显然不会为这两个字符串给出正确的结果. Unicode 定义了一组规范形式,这样如果两个字符串在规范上等效,并且被规范化为相同的规范形式,则它们的字节表示形式是相同的.
Unicode 还定义了 "兼容性等效项", 以等同于表示相同字符但可能具有不同外观的字符。例如,上标数字 "⁹" 和常规数字 "9" 以这种形式等效.
对于这两种等价形式中的每一种,Unicode 定义了一个合成形式和一个分解形式。前者用此单个符文替换可以组合成单个符文的符文。后者将符文分解成各个组成部分。下表显示了所有以 NF 开头的名称,Unicode 联盟通过这些名称来标识以下形式:
组成 | 分解 | |
---|---|---|
规范对等 | NFC | NFD |
兼容性等效 | NFKC | NFKD |
Go 的规范化方法#
如字符串博客文章中所述,Go 不保证字符串中的字符被规范化。但是,go.text 包可以补偿。例如,可以按特定语言对字符串进行排序的 collate 包,即使使用非规范化字符串也可以正常工作. go.text 中的包并不总是需要规范化的输入,但是通常为了获得一致的结果可能需要规范化.
规范化不是毫无损耗的,但是它很快,特别是对于排序规则和搜索,或者字符串是否在 NFD 或 NFC 中,并且可以通过分解而转换为 NFD 而无需重新排序其字节,这尤其适用。实际上,网络 HTML 页面内容的 99.98% 为 NFC 格式 (不计入标记,在这种情况下将更多). 到目前为止,大多数 NFC 都可以分解为 NFD, 而无需重新排序 (需要重新分配). 而且,它可以有效地检测到何时需要重新排序,因此我们可以通过仅对需要它的稀有段进行处理来节省时间.
为了使情况变得更好,排序规则包通常不直接使用 norm 包,而是使用 norm 包将规范化信息与其自己的表进行交织。将这两个问题交织在一起,即可即时进行重新排序和规范化,而几乎不影响性能。通过不必预先对文本进行标准化并确保在编辑时保持标准格式,可以实时进行标准化损耗,后者可能很棘手。例如,串联两个 NFC 标准化字符串的结果不能保证在 NFC 中.
当然,还有常见的情况是,如果我们事先知道一个字符串已经被标准化,我们也可以规避开销.
何必呢?#
在所有关于避免规范化的讨论之后,您可能会问,为什么值得担心。原因是在某些情况下需要进行规范化,因此重要的是要了解它们是什么,以及随后如何正确进行.
在讨论这些内容之前,我们必须首先阐明 ' 字符 ' 的概念.
什么是字符?#
正如字符串博客文章中提到的那样,字符可以跨越多个符文。例如,一个 'e' 和 '◌́' (aciye ".0301") 可以组合形成一个 'é' (在 NFD 中为 "e.0301"). 这两个符文一起是一个角色。字符的定义可能会因应用程序而异。为了进行规范化,我们将其定义为以起动器开头的一串符文,该起动器不修改或与其他任何起动器向后组合,然后再加上可能为空的非起动器序列,即以 (通常重音). 规范化算法一次处理一个字符.
从理论上讲,组成 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()
// 像之前一样写...
如果您的字符串很小,并且想进行快速转换,则可以使用以下简单形式:
norm.NFC.Bytes(b)
包规范提供了各种其他用于规范文本的方法。选择最适合您的.
相似#
您能分辨出 'K' (".004B") 和 'K' (开氏符号 ".212A") 或 'Ω'(".03a9") 和 'Ω' (欧姆符号 ".2126") 之间的区别吗?容易忽略相同基础字符的变体之间有时细微的差异。通常,最好不要在标识符中使用此类变体,或使用此类外观可能构成安全隐患的任何事物造成欺骗用户.
兼容性正常形式 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". 现在考虑我们的文本包含 NFD 形式的法语拼写 "café":
s := "We went to eat at multiple cafe.0301"
使用上面相同的代码,复数 "s" 仍会插入到 "e" 之后,但在急转符之前插入,导致的 "We went to eat at multiple cafeś". 此行为是不符合预期的.
问题在于代码不遵守多符文字符之间的边界,而是在字符中间插入了符文。使用 norm 包,我们可以如下重写这段代码:
s := "We went to eat at multiple cafe.0301"
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)
// 跟之前一样读取...
例如,这会将文本中任何提及 "cafés" 的内容转换为 "cafes", 而不管原始文本的编码形式如何.
规范化信息#
如前所述,某些软件包将规范化预计算到其表中,以最大程度地减少运行时对规范化的需求.
类型 norm.Properties
提供对这些软件包所需的每个符文信息的访问权,其中最著名的是 Canonical Combining Class 和分解信息。如果您想深入了解此类型,可参阅 文档.
性能#
为了让您了解规范化的性能,我们将其与 string.ToLower 的性能进行了比较。第一行中的样本既是小写字母又是 NFC, 并且在每种情况下都可以照原样返回。第二个示例都不是,并且需要编写新版本.
Input | ToLower | NFC Append | NFC Transform | NFC Iter |
---|---|---|---|---|
nörmalization | 199 ns | 137 ns | 133 ns | 251 ns (621 ns) |
No.0308rmalization | 427 ns | 836 ns | 845 ns | 573 ns (948 ns) |
包含迭代器结果的列将显示在有和没有初始化迭代器的情况下进行的测量,其中包含不需要在重用时重新初始化的缓冲区.
如你所见,检测字符串是否已规范化可能非常有效。在第二行中进行规范化的大量成本是用于缓冲区的初始化,当处理较大的字符串时,将分摊其成本。事实表明,很少需要这些缓冲区,因此我们可能会在某个时候更改实现,以进一步加快小字符串的常见处理速度.
结论#
如果您要在 Go 中处理文本,通常不必使用 unicode/norm 包对文本进行规范化。该软件包对于诸如确保在发送字符串之前对其进行规范化或进行高级文本操作之类的操作仍然有用.
本文简要地提到了其他 go.text 软件包以及多语言文本处理的存在,并且它可能提出的问题多于给出的答案。然而,这些主题的讨论只能等下一回咯.
本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
推荐文章: