Go 部落格

Go 中的文字正規化

Marcel van Lohuizen
2013 年 11 月 26 日

引言

先前一篇 文章 討論了 Go 中的字串、位元和字元。我一直在為 go.text 儲存庫處理多語系文字。其中有幾個套件值得寫成部落格文章,但今天我想專注於 go.text/unicode/norm,它處理正規化。這個主題在 字串文章 中已簡單提到,並將在本篇文章中深入探討。正規化的運作層級高於原始位元。

要學習有關正規化的一切資訊(甚至更多),Unicode 標準附錄 15 是不錯的閱讀材料。較容易理解的文章則對應於 Wikipedia 網頁。本文將專注於正規化與 Go 的關係。

什麼是正規化?

代表同一個字串通常有許多種方法。例如,一個 é(é)可以在字串中用單一符號("\u00e9")表示,或接著一個 é 跟一個銳音符號("e\u0301")表示。根據 Unicode 標準,這兩個是「正規等價」的,應該視為相等。

使用逐位元組比較來判定相等性,顯然無法獲得這兩個字串的正確結果。Unicode 定義了一組正規形式,若兩個字串正規相當且 正規化為相同的正規形式,則其位元組表示形式相同。

Unicode 還定義了一種「相容性相等性」,用於將表示相同字元,但可能具有不同視覺外觀的字元視為等效。例如,上標數字「⁹」和一般數字「9」在這種形式下是等效的。

對於這兩種等效形式,Unicode 定義了組成形式和分解形式。前者以單一符號取代可以組合成單一符號的符號。後者將符號細分成其組成部分。此表格顯示 Unicode 聯盟用於識別這些形式的名稱,所有名稱都以 NF 起頭:

  組成 分解
正規相等性 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」和「◌́」(銳音符「\u0301」)可以結合形成「é」(「e\u0301」以 NFD 表示)。這兩個盧恩合起來構成一個字元。字元的定義可能因應用程式而異。對於正規化,我們將其定義為從起始者開始的盧恩序列,起始者是沒有修改或反向結合到任何其他盧恩的盧恩,接著是可能為空的非起始者序列,也就是(通常是重音符)確實會結合的盧恩。正規化演算法一次處理一個字元。

理論上,組成 Unicode 字元的盧恩數量沒有限制。事實上,沒有任何限制可以限制字元後可以跟隨的修飾符數量,而一個修飾符可以重複出現,或是堆疊出現。你是否看過有三個銳音符的「e」?這樣寫就可以了:「é́́」。按照標準來看,它是一個完全有效的四盧恩字元。

結果,即使在最低層級,文字也需要以無限制區塊大小的增量進行處理。對於使用 Go 標準讀取器和寫入器介面的文字處理串流方式來說,這特別令人尷尬,因為該模型潛在會要求任何中間緩衝區也必須具有無限制大小。此外,正規化的直接實作會具有 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」。現在考慮我們的文字包含 NFD 形式的法文拼字「café」

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,在任何情況下都可以原樣傳回。第二個範例都不是,需要撰寫新版本。

輸入 ToLower NFC Append NFC Transform NFC Iter
正規化 199 奈秒 137 奈秒 133 奈秒 251 奈秒 (621 奈秒)
No\u0308rmalization 427 奈秒 836 奈秒 845 奈秒 573 奈秒 (948 奈秒)

顯示指標重複運算器結果的欄位同時包含初始化和未初始化指標重複運算器的測量,其中包含不需要在再利用時重新初始化的緩衝區。

正如您所見,偵測字串是否正規化可以非常有效率。第二列正規化的許多成本來自於緩衝區的初始化,此成本在處理較大字串時會轉嫁。

結論

如果您在 Go 內處理文字,通常不需要使用 unicode/norm 套件來正規化您的文字。此套件仍然可能對某些事情有用,例如在將字串發出或執行進階文字操作之前,確保該字串已經正規化。

本文簡短提到其他 go.text 套件的存在,以及多語言文字處理,它可能提出的問題多於提供的答案。然而,這些主題的討論必須等到改日。

下一篇文章:Go 1.2 發布
前一篇文章:Go 四週年
部落格索引