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 包和多語言文字處理的存在,這可能會引發比解答更多的問題。然而,對這些主題的討論將不得不留待以後進行。