Unicode 正規化形式 NFC / NFD / NFKC / NFKD の使い分け:ファイル名比較・検索・識別子の同一性
「同じ が のはずなのに比較が一致しない」「macOS で作ったファイル名を Linux に持っていったら表示はそのままなのに ls | grep が引っかからない」——どれも Unicode 正規化形式(NF*)が原因です。本記事では 4 種の形式が何をするか、ユースケースごとにどれを選ぶか、現実のシステム間でどこに差が出るかを整理します。
4 形式の構造
Unicode は同じ「見た目の文字」を複数の異なるコードポイント列で表現できる仕様を持ちます。例えば「が」は 2 通りで表せます:
が (U+304C) ← 1 コードポイント(合成済み)
が = か (U+304B) + ◌゙ (U+3099) ← 2 コードポイント(分解形) Unicode 正規化はこの曖昧さを解消する操作で、4 形式が定義されています(UAX #15):
| 形式 | 意味 | 上の例 | 入力長との関係 |
|---|---|---|---|
| NFC | 正準分解 → 正準合成 | が (1 cp) | ほとんど短くなる |
| NFD | 正準分解(合成しない) | が = か + ◌゙ (2 cp) | 多くの場合長くなる |
| NFKC | 互換分解 → 正準合成 | NFC + 互換変種畳み込み | NFC と同じか短い |
| NFKD | 互換分解(合成しない) | NFD + 互換変種畳み込み | 最も長くなりやすい |
軸は 2 つ:
- 正準(canonical) vs 互換(compatibility):互換は全角 A → ASCII A、㈱ → (株) のように書字上は別の字を統一する
- 合成するかしないか:合成版 (C) はコードポイント数が少ない、分解版 (D) は多いが構成要素が見える
NFC:保存と転送のデフォルト
ほとんどの場面でこれを使います。
- W3C は HTML / XML / URL などすべてのコンテンツに NFC を推奨
- 多くのデータベース(PostgreSQL、SQL Server)の collation がデフォルトで NFC を期待
- JSON 文字列や HTTP ヘッダの値にも NFC が無難
書き込む側は NFC で出すと相手が正規化済みとして扱える可能性が高い、という慣習。
NFD:macOS のファイル名
macOS HFS+ / APFS は NFD でファイル名を保存 します(厳密には NFD の Apple 拡張版「NFD-MP」)。これが最大のクロスプラットフォーム互換性問題です:
macOS で「がっこう.txt」を作る
→ ファイルシステム上のバイト列:か + ◌゙ + っ + こ + う (NFD)
Linux に rsync で持っていく
→ ext4 はファイル名をバイト列でそのまま保存
→ "がっこう.txt" を入力(IME 経由、NFC 出力)すると別ファイル名と判定される この問題は GitHub・rsync・zip / tar / Docker image などファイル名を持ち回るあらゆる経路で発生します。
対策
- ビルドや CI で
git config core.precomposeUnicode true(Git の macOS 設定) - rsync には
--iconv=utf-8-mac,utf-8オプション - 意図的に変換するスクリプト:
find . -depth -execdir convmv -f utf8-mac -t utf8 --notest {} \;
NFKC:検索インデックスと識別子比較
互換変種を畳み込む形式で、書字上の差を無視して同一視したい場面に使います。
Café (NFC: 4 cp)
Cafe + ◌́ (NFD: 5 cp、分解形)
Café ← 全角の「cafe」も入力されるかもしれない 検索インデックス(Elasticsearch、MeiliSearch、Algolia)は通常 NFKC で正規化したトークンで索引を作ります。「Café」「Cafe」「cafe」のすべてがヒットしてほしい挙動。
NFKC が必要な具体例
- ユーザー名・メールアドレスのホモグラフ攻撃対策(特殊な Unicode で見た目だけ似せた偽アカウントを防ぐ)
- ハッシュタグ・slug の重複検出
- 漢字の異体字(IVS)を含むテキストの検索(NFKC は完全には統合しないが、典型的な互換ペアは畳み込まれる)
NFKC の罠:情報損失
NFKC は 元の表記情報を破壊します。「㈱」と「(株)」が同一視されますが、それは「㈱」と書きたかった人の意図を消すことでもあります。保存用にはうかつに使わない。検索・比較などの派生データを作るときに限定するのが安全です。
NFKD:アクセント除去・大文字小文字無視検索の前段
Café を Cafe に変換したい場合の典型処理:
str
.normalize('NFKD') // 'Cafe' + '◌́' に分解
.replace(/p{M}/gu, '') // 結合マークを除去
.toLowerCase(); // 'cafe' NFKD で結合マークが切り離された状態にしてから、\p{M}(Mark カテゴリ)を削除するのが定石。ASCII 範囲しか扱わない検索(昔ながらの like 検索など)の前処理として使います。
比較表(同じ入力での結果)
入力 Café㈱がA を 4 形式で正規化した結果:
| 形式 | 結果 | 文字数 | UTF-8 byte |
|---|---|---|---|
| NFC | Café㈱がA | 6 | 12 |
| NFD | Café㈱がA(é と が を分解) | 8 | 14 |
| NFKC | Café(株)がA | 8 | 11 |
| NFKD | Café(株)がA(さらに分解) | 10 | 13 |
NFKC で「㈱」が「(株)」、全角 A が ASCII A に変わっています。NFD/NFKD は文字数が増え、NFKC で UTF-8 バイト数が減るのは ASCII 化の効果。
用途別の選び方(早見表)
| 場面 | 推奨形式 |
|---|---|
| ユーザー入力の 保存 | NFC |
| HTTP body / JSON 値の生成 | NFC |
| 検索インデックスのトークン化 | NFKC |
| ユーザー名・slug の重複検出 | NFKC |
| アクセント除去 | NFKD + \p{M} 削除 |
| macOS と Linux のファイル名比較 | 両方を NFC にしてから比較 |
正規表現で \p{...} を使う前処理 | NFD(合成済みだと結合マークが見えない) |
| 暗号ハッシュを計算する文字列 | 仕様で固定(NFC が無難) |
言語ごとの API
// JavaScript
'café'.normalize('NFC'); # Python
import unicodedata
unicodedata.normalize('NFC', 'café') // Java
java.text.Normalizer.normalize(s, Normalizer.Form.NFC); // Go (golang.org/x/text/unicode/norm)
norm.NFC.String("café") // Rust (unicode-normalization crate)
use unicode_normalization::UnicodeNormalization;
"café".nfc().collect::<String>() 各言語の標準ライブラリに含まれているかどうかは注意ポイント。Go は golang.org/x/text の付属モジュール、Rust は外部 crate です。
まとめ
正規化形式の選択は「書字情報を残すか、同一視するか」「合成済みか、分解か」の 2 つの判断です。保存系は NFC、検索系は NFKC、アクセント除去は NFKD が定型。クロスプラットフォーム(特に macOS)で「同じはずの文字列が一致しない」事故は、ほぼ NFC vs NFD の不一致が原因です。
Unicode 正規化ツール で、実際のテキストを 4 形式に並べて比較できます。