Unicode正規化(NFC/NFD)とは — 同じに見える日本語文字列が一致しない理由
画面ではまったく同じ「ガイド資料.txt」なのに、プログラムで比較すると一致しない。検索しても見つからない。Mac で作ったファイル名を別の環境に持っていくと、こうしたずれが起きることがあります。
原因の多くは Unicode 正規化にあります。この記事では、同じ文字を表す方法が複数あること、NFC と NFD の違い、実際にどうずれるのかを、Python 3.12.8 で動かした結果を交えて整理します。
補足: 検証は macOS 26.4.1(APFS)上の Python 3.12.8 で行いました。環境が違うと挙動が異なる部分があるため、結果はあくまで一例として読んでください。
同じ「が」を表す2通りの方法
濁点つきの「が」は、Unicode では次の2通りで表せます。
- 1文字で表す: 「が」(U+304C)
- 2文字の組み合わせで表す: 「か」(U+304B)+ 結合用の濁点(U+3099)
見た目は同じですが、中身のコードは別物です。前者に揃える形式を NFC(合成済み)、後者に揃える形式を NFD(分解)と呼びます。
実際に「ガイド資料.txt」を両方の形式にして、長さを確かめました。
| 形式 | 文字数 | UTF-8 のバイト数 |
|---|---|---|
| NFC | 9 | 19 |
| NFD | 11 | 25 |
濁点のある「ガ」と「ド」が2文字ぶん増え、バイト数も増えています。そして、この2つの文字列は == で比べると一致しません。
import unicodedata as u
a = u.normalize("NFC", "がく")
b = u.normalize("NFD", "がく")
print(a == b) # False
print(len(a), len(b)) # 2 3
print(u.normalize("NFC", b) == a) # True
比較する前に、どちらかの形式に揃えれば一致します。これが「正規化」です。
ファイル名で何が起きるか
ファイルシステムによって、ファイル名の扱いが異なります。
- 古い macOS のファイルシステム(HFS+)は、ファイル名を NFD 系の形式に変換して保存する仕様でした。
- 現在の APFS は、保存時には形式を変換せず、そのまま保持します。一方で、名前の照合は正規化の違いを区別しません。
- Linux の一般的なファイルシステムは、名前をバイト列として扱い、正規化しません。
APFS 上で試すと、次のようになりました。NFD で作ったファイルに対し、NFC の名前でも存在確認が通ります。
import os, tempfile, unicodedata as u
d = tempfile.mkdtemp()
nfc = u.normalize("NFC", "がく")
nfd = u.normalize("NFD", "がく")
open(os.path.join(d, nfd), "w").write("x")
names = os.listdir(d)
print([(len(n), n == nfc, n == nfd) for n in names])
# [(3, False, True)] ← 保存されたのは NFD のまま
print(os.path.exists(os.path.join(d, nfc)))
# True ← NFC で問い合わせても見つかる
つまり、OS は同じファイルとして扱ってくれるのに、プログラムが os.listdir() で得た文字列を自前で比較すると一致しない、という状況が起こります。
ずれが問題になる場面
- 一覧から取得したファイル名と、設定やログに書いた名前を文字列で比較する
- 別の OS(Linux サーバーや Windows)へファイルを転送し、名前を照合する
- 名前をキーにして辞書やデータベースを引く
- 重複検出で、同じ名前のファイルが2つあるように見える
どれも、比較や検索の前に形式を揃えていないことが共通の原因です。
対策: 境界で一度だけ正規化する
考え方は単純です。外から入ってくる文字列(ファイル名、入力、設定値)を受け取った時点で、どちらかの形式に揃えることです。
import unicodedata
def norm(s: str) -> str:
return unicodedata.normalize("NFC", s)
- 揃える形式は NFC にするのが一般的です。多くの Web やデータの規約が NFC を前提にしているためです。
- 正規化するのは比較・照合・キーにする場面に限ります。実際にファイルを開くときは、OS から得た元の名前をそのまま使うほうが安全です。
- 保存したい名前と、照合用の名前を分けて持つと、元の名前を壊しません。
NFC と NFKC の違いに注意
正規化には、さらに NFKC / NFKD という「互換」形式があります。こちらは見た目が似た別の文字も寄せます。
print(u.normalize("NFKC", "パ") == "パ") # True(半角カナが全角に)
print(u.normalize("NFC", "パ") == "パ") # False
print(u.normalize("NFKC", "①㈱")) # 1(株)
半角カナを全角にそろえたい場面では便利ですが、「①」が「1」に、「㈱」が「(株)」になるなど、元の情報が変わります。ファイル名のように元の名前を保ちたいものには NFC、検索用の入力ゆれを吸収したいときには NFKC、と目的で使い分けます。
まとめ
- 同じ見た目の文字でも、1文字(NFC)と組み合わせ(NFD)の2通りの表し方がある。
- 文字数もバイト数も違うので、そのままでは
==で一致しない。 - APFS は名前を保持しつつ照合では区別しないため、OS では見つかるのに自前の比較では一致しない、という差が出る。
- 外から入る文字列は、比較・照合の前に NFC に揃える。元の名前は別に保持する。
- NFKC は元の情報が変わるため、用途を選んで使う。
自分のツールで、ファイル名を文字列として比較している箇所がないか、一度確認してみてください。文字エンコーディングの基礎は「UTF-8とcp932の違い」、書き込みの途中中断を防ぐ方法は「アトミックな書き込み」も参照してください。