Unicode 正規化形式 NFC / NFD / NFKC / NFKD の使い分け:ファイル名比較・検索・識別子の同一性

約8分

「同じ のはずなのに比較が一致しない」「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
NFCCafé㈱がA612
NFDCafé㈱がA(é と が を分解)814
NFKCCafé(株)がA811
NFKDCafé(株)がA(さらに分解)1013

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 形式に並べて比較できます。