コンテンツへスキップ

Unicode正規化(NFC/NFD)とは — 同じに見える日本語文字列が一致しない理由

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の違い」、書き込みの途中中断を防ぐ方法は「アトミックな書き込み」も参照してください。