The Go Blog
Goにおけるテキストの正規化
はじめに
以前の記事では、Goにおける文字列、バイト、文字について説明しました。私はgo.textリポジトリ向けに多言語テキスト処理用の様々なパッケージに取り組んできました。これらのパッケージのいくつかは個別のブログ記事に値しますが、今日はgo.text/unicode/normに焦点を当てたいと思います。これは、文字列の記事でも触れられた正規化を扱い、本記事の主題でもあります。正規化は生バイトよりも高い抽象度で機能します。
正規化について知りたかったことのほとんどすべて(そしてそれ以上)を学ぶには、Unicode標準の付属書15が参考になります。より親しみやすい記事としては、対応するWikipediaのページがあります。ここでは、正規化がGoとどのように関連するかに焦点を当てます。
正規化とは?
同じ文字列を表現する方法はしばしば複数あります。例えば、「é」(e-アキュート)は、文字列として単一のルーン("\u00e9")として表現することも、'e'の後にアキュートアクセント("e\u0301")が続く形で表現することもできます。Unicode標準によれば、これら2つは「正準等価」であり、等しいものとして扱われるべきです。
これら2つの文字列の等価性を判断するためにバイト単位の比較を用いると、明らかに正しい結果は得られません。Unicodeは、2つの文字列が正準等価であり、同じ正規形式に正規化されている場合、それらのバイト表現が同じになるような一連の正規形式を定義しています。
Unicodeはまた、同じ文字を表すが、異なる視覚的表現を持つ文字を等価にするための「互換等価」も定義しています。例えば、上付き数字の「⁹」と通常の数字の「9」はこの形式では等価です。
これら2つの等価形式それぞれについて、Unicodeは結合形式と分解形式を定義しています。前者は、単一のルーンに結合できるルーンをその単一のルーンに置き換えます。後者は、ルーンをその構成要素に分解します。この表は、Unicode Consortiumがこれらの形式を識別する、NFで始まる名前を示しています。
| 結合 | 分解 | |
|---|---|---|
| 正準等価 | NFC | NFD |
| 互換等価 | NFKC | NFKD |
Goの正規化へのアプローチ
文字列に関するブログ記事で述べたように、Goは文字列内の文字が正規化されていることを保証しません。しかし、go.textパッケージで補うことができます。例えば、言語固有の方法で文字列をソートできるcollateパッケージは、非正規化された文字列でも正しく機能します。go.textのパッケージは常に正規化された入力を必要とするわけではありませんが、一般的には一貫した結果を得るために正規化が必要となる場合があります。
正規化は無料ではありませんが、特に照合や検索の場合、または文字列がNFDまたはNFCであり、バイトを並べ替えることなく分解によってNFDに変換できる場合は高速です。実際には、WebのHTMLページコンテンツの99.98%がNFC形式です(マークアップを除いた場合、さらに多くなります)。NFCの大部分は、並べ替え(これはアロケーションを必要とします)を必要とせずにNFDに分解できます。また、並べ替えが必要な場合を効率的に検出できるため、必要なごくまれなセグメントのみに実行することで時間を節約できます。
さらに良いことに、collateパッケージは通常、normパッケージを直接使用するのではなく、normパッケージを使用して正規化情報を独自のテーブルに織り交ぜます。これら2つの問題を織り交ぜることで、ほとんどパフォーマンスに影響を与えることなく、その場で並べ替えと正規化を行うことができます。その場での正規化のコストは、テキストを事前に正規化する必要がなく、編集時に正規形式が維持されることを保証することで相殺されます。後者は難しい場合があります。例えば、NFC正規化された2つの文字列を連結した結果がNFCになることは保証されません。
もちろん、文字列がすでに正規化されていることを事前に知っていれば、オーバーヘッドを完全に回避することもできます。これはよくあることです。
なぜわざわざ?
正規化を避けることについてのこれらすべての議論の後で、なぜそもそも正規化を気にする価値があるのか疑問に思うかもしれません。理由は、正規化が必要な場合があり、それらが何であるか、そして正しく行う方法を理解することが重要だからです。
それらを議論する前に、まず「文字」の概念を明確にする必要があります。
文字とは?
文字列に関するブログ記事で述べたように、文字は複数のルーンにまたがることができます。例えば、'e'と'◌́'(アキュート「\u0301」)は結合して'é'(NFDでは「e\u0301」)を形成することができます。これら2つのルーンは合わせて1つの文字です。文字の定義はアプリケーションによって異なる場合があります。正規化のために、私たちはそれを、スターター(他のルーンと結合したり、後ろに修飾したりしないルーン)で始まり、その後に(通常アクセントである)非スターターの、おそらく空のシーケンスが続くルーンのシーケンスと定義します。正規化アルゴリズムは一度に1文字を処理します。
理論的には、Unicode文字を構成できるルーンの数には上限がありません。実際、文字に続く修飾子の数に制限はなく、修飾子を繰り返したり、積み重ねたりすることもできます。3つのアキュートが付いた「e」を見たことがありますか?これです:「é́́」。これは標準によれば完全に有効な4ルーン文字です。
その結果、最も低いレベルでさえ、テキストは無制限のチャンクサイズで処理される必要があります。これは、Goの標準ReaderおよびWriterインターフェースが使用するようなストリーミングアプローチのテキスト処理では特に厄介です。なぜなら、そのモデルは潜在的にすべての中間バッファも無制限のサイズを持つことを要求するからです。また、正規化の直接的な実装はO(n²)の実行時間を持ちます。
実用的なアプリケーションでは、そのような大規模な修飾子シーケンスには意味のある解釈は本当にありません。UnicodeはStream-Safe Text形式を定義しており、修飾子(非スターター)の数を最大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は、多くの視覚的にほぼ同一の形式を単一の値にマッピングします。ただし、2つの記号が似ているが、実際には2つの異なるアルファベットに属する場合はそうしません。例えば、ラテン文字の「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パッケージが提供する、文字境界の処理に役立つもう1つのツールは、そのイテレーター、norm.Iterです。これは、選択された正規形式で一度に1文字ずつ反復処理します。
魔法を実行する
前述のとおり、ほとんどのテキストはNFC形式で、基本文字と修飾子が可能な限り単一のルーンに結合されます。文字を分析する目的では、最小の構成要素に分解された後でルーンを処理する方が簡単な場合が多く、ここでNFD形式が役立ちます。例えば、次のコードは、テキストを最小の部品に分解し、すべてのアクサンを除去し、その後テキストをNFCに再結合するtransform.Transformerを作成します。
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は、これらのパッケージが必要とするルーンごとの情報、特にCanonical Combining Classと分解情報へのアクセスを提供します。さらに深く掘り下げたい場合は、この型のドキュメントをお読みください。
パフォーマンス
正規化のパフォーマンスを把握するために、strings.ToLowerのパフォーマンスと比較します。最初の行のサンプルは小文字かつNFCであり、いずれの場合もそのまま返されます。2番目のサンプルはどちらでもなく、新しいバージョンを作成する必要があります。
| 入力 | 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) |
イテレーターの結果の列は、イテレーターの初期化ありとなし両方の測定値を示しており、これらには再利用時に再初期化する必要のないバッファーが含まれています。
ご覧のように、文字列が正規化されているかどうかを検出することは非常に効率的です。2番目の行での正規化のコストの多くはバッファの初期化にかかるものであり、そのコストはより大きな文字列を処理する際に償却されます。実際には、これらのバッファが必要とされることはめったにないため、ある時点で実装を変更して、小さな文字列の一般的なケースをさらに高速化する可能性があります。
まとめ
Go内でテキストを扱う場合、通常、テキストを正規化するためにunicode/normパッケージを使用する必要はありません。このパッケージは、文字列を送信する前に正規化されていることを確認したり、高度なテキスト操作を行う場合など、依然として役立つことがあります。
この記事では、他のgo.textパッケージや多言語テキスト処理の存在について簡単に言及しましたが、答えよりも多くの疑問を投げかけたかもしれません。しかし、これらのトピックの議論は、また別の機会に持ち越すことにします。
次の記事: Go 1.2がリリースされました
前の記事: Goの4年間
ブログインデックス