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 的四年
部落格索引