文字の見た目は同じでも、内部のコードが違うと比較や検索、保存の場面で思わぬ不具合につながります。
そこで役に立つのがUnicode正規化です。
本記事では、正規化の必要性と4つの形式、実際にどのタイミングでどう使うかを、初心者の方にもわかりやすく実例とコードで解説します。
なぜUnicode正規化が必要か
文字化けの原因と基本
同じ文字に見えて内部のコードが違う状態は、見た目では気づきにくく、不具合の根本原因になります。
エンコーディングの不一致(UTF-8とShift_JISなど)によるクラシックな文字化けだけでなく、UTF-8で統一していても起きる「別コード同士の不一致」があります。
たとえばアクセント付き文字や濁点付きの仮名は、1文字としてまとめられた形と、素の文字に記号を重ねた形の2通りがあり得ます。
正規化は、このばらつきを一定のルールで揃える処理です。
同じ見た目でも別コードの例
以下の文字列は見た目が同じでも内部が異なる代表例です。
比較や検索、ソートで結果が変わることがあります。
見た目 | コードポイント列 | 説明 | 備考 |
---|---|---|---|
é | U+00E9 | 事前合成の「é」 | 1文字 |
é | U+0065 U+0301 | 「e」に合成用アクセント | 2文字 |
バ | U+30D0 | 事前合成の「バ」 | 1文字 |
バ | U+30CF U+3099 | 「ハ」に結合濁点 | 2文字 |
ガ | U+FF76 U+FF9E | 半角カナと半角濁点 | 2文字、互換文字 |
① | U+2460 | 囲み数字 | 互換文字 |
このような差異は、ユーザー入力、他システムからの取り込み、OSやツールの違いで容易に混在します。
正規化しないと同一視できないため、早い段階で揃えるのが安全です。
合成文字と分解文字の違い
Unicodeでは、1文字分の意味を持つ記号が事前合成(プリコンポーズド)として1コードにまとまっている場合と、分解(デコンポーズド)として素の文字と結合記号の組み合わせで表現される場合があります。
例えば「é」はU+00E9(事前合成)と「e」+U+0301(分解)があり、どちらも見た目は同じです。
正規化は、こうした等価な表現を一定の軸に揃える処理です。
比較や検索で起こるズレ
等価なはずの文字列が一致しない、長さが違う、辞書順が変わる、といったズレが起きます。
例えば「é」と「é」はプログラム上では異なる配列要素数となり、単純な比較(s1 == s2)で不一致になります。
検索のインデックスが別々に作られてヒット漏れが起きることもあります。
「比較前に正規化」や「保存時に統一」というルールがないと、運用中に原因不明の不整合が発生します。
正規化形式
NFC(正準合成)の特徴と用途
NFCは正準等価な文字を可能な限り合成して1文字にまとめる形式です。
分解表現(「e」+アクセント)を事前合成(「é」)に揃えますが、互換文字(半角カナや囲み数字など)は変えません。
表示の安定性が高く、多くのアプリやWebで事実上の標準的な形として扱われます。
文章や本文データの保存に向いています。
NFCの簡単な例
- 「é」→「é」
- 「バ」→「バ」
- 「ガ」→「ガ」のまま(半角は互換なので対象外)
NFD(正準分解)の特徴と用途
NFDは正準等価な文字を分解して、素の文字+結合記号へ広げる形式です。
レンダリングや索引化で都合が良い場合があり、macOSのファイルシステムはNFD系の形に近い形式を内部で用います(例外あり)。
濁点やアクセントを別扱いにしたい処理や、学術用途の正規表現などで使われます。
NFDの簡単な例
- 「é」→「e」+U+0301
- 「バ」→「ハ」+U+3099
NFKC(互換合成)の特徴と用途
NFKCは互換等価まで含めて合成する、より強い正規化です。
半角カナや囲み数字、全角英数などを意味的に同じものへ正規化します。
本文データの保持では情報が失われる可能性がありますが、識別子や検索キーを揃える用途(ログインID、タグ、ラベルなど)に向きます。
NFKCの簡単な例
- 「ガ」→「ガ」
- 「①」→「1」
- 「ABC」→「ABC」
NFKD(互換分解)の特徴と用途
NFKDは互換等価まで含めて分解する形式です。
検索でアクセント無視をしたい時などに使われ、NFKDで分解した後に結合記号を除去する方法がよく用いられます。
本文保存には不向きです。
NFKDの簡単な例
- 「é」→「e」+U+0301、その後アクセント除去で「e」
- 「①」→「1」
どの形式を選ぶかの目安
基本はNFCが安全です。
本文や一般的なテキスト保存、比較の基準として採用することで、表示と等価性の両立がしやすくなります。
IDやタグ、ユーザー名などのキー値はNFKCで揃えてから大文字小文字の統一まで行うと混乱が減ります。
一方でパスワードは正規化しない方が一般的です(ユーザーの意図を変えないため)。
ファイル名はOS差に注意し、比較時に同一形式へ揃えます。
正規化のタイミングと選び方
入力時にNFCへ統一する
ユーザー入力、API取り込み、CSV/JSONインポートなど、外部から来たテキストは最初にNFCへ揃えると後続処理が安定します。
エディタやOSによってNFDの文字が混ざっても、保存前にNFCへ変換しておけば、比較や重複チェックでの不一致が起きにくくなります。
保存前と比較前の正規化ルール
保存時のルールを決めておくと、運用の手戻りが減ります。
本文カラムはNFCで保存し、検索用カラムは別途NFKCやNFKDベースで加工して持つと効率的です。
比較や重複判定の直前には双方を同じ形式で正規化してから比較します。
外部システム連携では、受け渡し時の形式を契約として明文化すると安全です。
検索・ソートでの注意点
アクセントや濁点を無視したい検索は、NFKDで分解して結合記号を除去すると実現しやすいです。
ただし言語ごとの差異があるため、厳密な並び順はロケール対応の照合(例: ICU、Intl.Collator)を検討してください。
ソートキーの生成時にも正規化を揃えておくと、結果が安定します。
ファイル名とURLの落とし穴
ファイルシステムはOSごとに扱いが異なります。
macOSは内部的にNFD系に近い形で保持する一方、Windowsや多くのLinuxは変換しません。
同じ見た目でも別コードになる可能性があるため、ファイル名の比較や重複チェックはNFCへ正規化してから行うと安全です。
URLについては、URLオブジェクトの組み立て前に正規化し、パーセントエンコード後には正規化しないことが重要です。
ドメイン名はIDNA規則に従うため、必ず専用のライブラリに任せます。
絵文字と結合文字の注意点
絵文字はスキントーン、性別、ゼロ幅接合子(ZWJ)、異体字選択子(U+FE0F)の組み合わせで1つの見た目になります。
正規化はこれらの並びを基本的に変えません。
つまり、NFCにしても絵文字の複雑な連結はそのまま残ります。
見た目の1文字は「書記素クラスタ」という単位で、プログラム上の文字数とは一致しないことがあります。
部分削除や切り出しは誤って絵文字を壊す可能性があるため、初心者の段階では正規化+全体単位での扱いを心がけると安全です。
コード例(Python/JavaScript)と実践
Python(unicodedata.normalize)の例
Pythonでは標準ライブラリのunicodedataを使います。
基本の等価比較と、用途別の正規化例を示します。
import unicodedata
def show(s, label=""):
cps = " ".join(f"U+{ord(ch):04X}" for ch in s)
names = ", ".join(unicodedata.name(ch, "?") for ch in s)
print(f"{label}{s!r} -> {cps} | {names}")
# 同じ見た目でも異なる例
s1 = "é" # U+00E9
s2 = "e\u0301" # U+0065 U+0301
print(s1 == s2) # False
print(unicodedata.normalize("NFC", s1) == unicodedata.normalize("NFC", s2)) # True
show(s1, "s1: ")
show(s2, "s2: ")
show(unicodedata.normalize("NFC", s2), "NFC(s2): ")
show(unicodedata.normalize("NFD", s1), "NFD(s1): ")
# 半角カナはNFKCで揃える
half = "ガ" # U+FF76 U+FF9E
show(half, "half: ")
show(unicodedata.normalize("NFC", half), "NFC: ")
show(unicodedata.normalize("NFKC", half), "NFKC: ")
# アクセント無視検索用の正規化(NFKD + 結合記号除去)
def strip_marks(text: str) -> str:
tmp = unicodedata.normalize("NFKD", text)
return "".join(ch for ch in tmp if unicodedata.combining(ch) == 0)
print(strip_marks("Café") == strip_marks("Café")) # True
# Python 3.8+ では正規化済みか確認も可能
print(unicodedata.is_normalized("NFC", "ば")) # True など
ポイントは、比較や保存時に同一形式へ正規化し、互換文字を揃えたい場合のみNFKCを使うことです。
JavaScript(String.prototype.normalize)の例
JavaScriptはString.prototype.normalizeで同様に扱えます。
// 基本の等価比較
const a = "é"; // U+00E9
const b = "e\u0301"; // U+0065 U+0301
console.log(a === b); // false
console.log(a.normalize("NFC") === b.normalize("NFC")); // true
// 半角カナを互換合成で揃える
const half = "ガ";
console.log(half.normalize("NFKC")); // "ガ"
// コードポイントの可視化(初心者向け)
function codePoints(s) {
return Array.from(s).map(ch => "U+" + ch.codePointAt(0).toString(16).toUpperCase().padStart(4, "0"));
}
console.log(codePoints("バ")); // ["U+30CF", "U+3099"]
console.log(codePoints("バ")); // ["U+30D0"]
// URLは組み立て前に正規化し、エンコード後は触らない
const rawPath = "café"; // ユーザー入力
const safePath = rawPath.normalize("NFC");
const url = new URL("https://example.com/" + encodeURIComponent(safePath));
console.log(url.toString()); // 正しくエンコードされたURL
URLやドメインは必ず標準APIや専用ライブラリに任せ、正規化は組み立て前に行うのが安全です。
入力バリデーションの基本パターン
入力値の処理は、順序を決めて機械的に行うと失敗しにくいです。
以下はログインIDのような「識別子」を受け付けるときの初歩的な例です。
- 先頭末尾の空白を除去します。
- NFKCで正規化し、互換文字のばらつきを揃えます。
- 必要なら大小文字を統一します(lowercase)。
- 許可する文字集合で正規表現チェックをします。
Pythonの例:
import re
import unicodedata
allowed = re.compile(r"^[a-z0-9_-]{3,32}$") # 初学者向け: ASCIIのみに限定
def normalize_identifier(s: str) -> str:
s = s.strip()
s = unicodedata.normalize("NFKC", s)
s = s.lower()
if not allowed.match(s):
raise ValueError("IDは英小文字・数字・-_ の3〜32文字で入力してください")
return s
print(normalize_identifier(" ABC ")) # "abc"
JavaScriptの例:
function normalizeIdentifier(s) {
s = s.trim();
s = s.normalize("NFKC").toLowerCase();
if (!/^[a-z0-9_-]{3,32}$/.test(s)) {
throw new Error("IDは英小文字・数字・-_ の3〜32文字で入力してください");
}
return s;
}
console.log(normalizeIdentifier(" ABC ")); // "abc"
パスワードは正規化や大小文字統一を行わないことが多いです。
ユーザーの意図を変えないためです。
テストケースの作り方と確認ツール
正規化は目で見て判断しにくいため、コードポイントを表示して検証するのが実践的です。
等価性のテストは「生文字列」「期待する正規化結果」「一致判定」をセットで用意します。
Pythonのテスト例:
import unicodedata
cases = [
("e\u0301", "NFC", "é"),
("ハ\u3099", "NFC", "バ"),
("ガ", "NFKC", "ガ"),
("①", "NFKC", "1"),
]
for raw, form, expected in cases:
got = unicodedata.normalize(form, raw)
assert got == expected, f"{raw} -> {form} -> {got} (expected {expected})"
print("All normalization tests passed.")
JavaScriptのテスト例:
const cases = [
{ raw: "e\u0301", form: "NFC", expected: "é" },
{ raw: "ハ\u3099", form: "NFC", expected: "バ" },
{ raw: "ガ", form: "NFKC", expected: "ガ" },
{ raw: "①", form: "NFKC", expected: "1" },
];
for (const { raw, form, expected } of cases) {
const got = raw.normalize(form);
if (got !== expected) {
throw new Error(`${raw} -> ${form} -> ${got} (expected ${expected})`);
}
}
console.log("All normalization tests passed.");
確認に役立つツールとしては、Unicodeコードポイントビューアや文字列インスペクタなどのオンラインツール、Pythonのunicodedata.nameで名称を表示する方法が便利です。
コードポイントと名称を確認できると、原因切り分けが格段にやりやすくなります。
まとめ
Unicode正規化は、「見た目は同じだが中身が違う」問題を早期に解消する基盤技術です。
本文データはNFCで揃え、識別子や検索キーはNFKCやNFKDを用途に応じて使い分けます。
入力の最初で正規化し、保存・比較・検索の直前にも同じ形式に揃えるルールを徹底すると、文字化けやヒット漏れ、重複判定の不具合が大幅に減ります。
絵文字やファイル名、URLなどの落とし穴も、「正規化のタイミング」と「形式の選択」を意識すれば怖くありません。
まずは小さなテストから始め、困ったらコードポイントを確認する習慣をつけて、安定した文字処理を身につけていきましょう。