プログラム上の1文字と、画面に見える1文字は同じではありません。
Unicodeでは、サロゲートペア(UTF-16)や結合文字が関わることで、文字の数え方や切り方に注意が必要になります。
この記事では、サロゲートペアと結合文字の基礎を、初心者向けにやさしく説明し、JavaScriptやPythonで安全に扱うコツを紹介します。
Unicodeの基礎と文字の単位
Unicodeとは
Unicodeとは、世界中の文字に番号を割り当てる国際標準です。
英字や日本語、記号、絵文字までを一つの体系で扱えるようにします。
文字と番号の対応表を定めているのがUnicodeであり、データの保存や送受信の方法(エンコーディング)は別の話題になります。
ここがポイント
Unicodeは「文字の辞書」、UTF-8やUTF-16は「辞書の番号をどう並べて保存するか」という方式です。
コードポイントとは
コードポイントは、Unicodeが文字に割り当てた番号です。
形式はU+XXXXのように表します。
代表例
| 文字 | 名前 | コードポイント |
|---|---|---|
| A | ラテン大文字A | U+0041 |
| あ | ひらがな「あ」 | U+3042 |
| é | ラテン小文字eにアキュート | U+00E9 |
| 😀 | grinning face | U+1F600 |
プログラムで「文字の正体」を確認したい時は、このコードポイントを見ると理解が進みます。
コードユニットとUTF-16
UTF-16は、16ビットの単位(コードユニット)で文字を表す方式です。
BMP(基本多言語面)の文字は1ユニット、BMP外の文字は2ユニットの組み合わせ(サロゲートペア)で表します。
ひとまず覚えること
UTF-16では一部の文字が「2ユニット」になるため、単純な長さ計測や切り出しがズレる可能性があります。
特にJavaScriptの文字列は内部的にUTF-16です。
見た目の1文字(グラフェム)とは
見た目の1文字はグラフェム(ユーザーがひとかたまりに見える単位)です。
1つのグラフェムは、1個のコードポイントで構成されることも、複数のコードポイントの並びで構成されることもあります。
例で理解する
| 見た目 | 内部構成 | コードポイント数 | UTF-16のコードユニット数 |
|---|---|---|---|
| é | U+00E9 | 1 | 1 |
| é | U+0065 + U+0301(結合記号) | 2 | 2 |
| 👍🏻 | U+1F44D + U+1F3FB | 2 | 4 |
| 🇯🇵 | U+1F1EF + U+1F1F5 | 2 | 4 |
見た目の1文字の数(グラフェム数)は、コードポイント数やUTF-16の長さと一致しない場合があります。
Unicodeのサロゲートペア(UTF-16)の基礎
サロゲートペアとは
BMP外(U+10000以上)の文字を、2つの16ビット値で表すUTF-16の仕組みです。
前半はハイサロゲート、後半はローサロゲートと呼び、範囲が決まっています。
範囲
| 名称 | 範囲 |
|---|---|
| ハイサロゲート | U+D800〜U+DBFF |
| ローサロゲート | U+DC00〜U+DFFF |
この2つが正しい組み合わせになって初めて1文字になります。
対象文字
サロゲートペアになるのは、主に次のような文字です。
絵文字の多く、歴史的文字、音楽記号、CJK拡張漢字などが該当します。
- 😀(U+1F600)、👍(U+1F44D)
- 𠮷(古い字体の「吉」などのCJK拡張漢字)
- 𝄞(ト音記号 U+1D11E)
文字数カウントの落とし穴
JavaScriptのlengthはUTF-16のコードユニット数を返します。
つまり、サロゲートペアの文字は2として数えられます。
Python 3のlenはコードポイント数です。
const s = 'A😀';
console.log(s.length); // 3 (A=1, 😀=2)
console.log([...s].length); // 2 (コードポイント数)
s = 'A😀'
print(len(s)) # 2 (コードポイント数)
「length=文字数」と思い込むとバグの原因になります。
部分切りの危険
UTF-16の1ユニットだけを切ってしまうと、サロゲートペアが壊れて不正な文字列になります。
また、結合文字の途中で切ると、意図しない表示(単独の結合記号が表に出るなど)になります。
具体例
// 悪い例: サロゲートの途中で切れる可能性
const x = '😀X';
console.log(x.slice(0, 1)); // 空文字になる環境も。安全ではない
部分切りは文字化けや表示崩れ、検索不一致を引き起こします。
JavaScriptでの扱い
JavaScriptはUTF-16ベースです。
以下の基本を押さえると安全です。
安全な数え方・走査
const s = '👍🏻e\u0301'; // 親指+肌色、e+結合アキュート
// コードポイント単位で扱う
console.log([...s]); // スプレッド or Array.from でコードポイント配列
console.log(Array.from(s).length);
// グラフェム(見た目の1文字)単位で数える
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const graphemes = [...seg.segment(s)].map(it => it.segment);
console.log(graphemes); // ['👍🏻','é']
console.log(graphemes.length); // 2
文字取得・生成のAPI
- コードポイント取得: s.codePointAt(index)
- コードポイントから生成: String.fromCodePoint(cp)
- 正規化: s.normalize(‘NFC’)
正規表現はuフラグを使う(例: /./gu はコードポイント単位で1文字を扱う)と安全性が上がります。
ただしグラフェム単位は正規表現だけでは難しく、Intl.Segmenterの使用が実用的です。
Pythonでの扱い
Python 3の文字列(str)はコードポイントの列として扱われます。
lenはコードポイント数です。
よく使う標準API
- 正規化: unicodedata.normalize(‘NFC’, s)
- 大文字小文字を超えた比較: s.casefold()
グラフェム単位のカウント
標準のreはグラフェムクラスタを直接扱いません。
サードパーティのregexモジュールを使うと簡単です。
import regex as re
text = '👍🏻e\u0301'
graphemes = re.findall(r'\X', text)
print(graphemes) # ['👍🏻', 'é']
print(len(graphemes)) # 2
UIの「文字数制限」などはグラフェム単位で数えると安心です。
安全な処理
目的に応じて、単位を選びます。
目安
- ユーザーに見える長さ: グラフェム単位
- 文字を壊さない最小単位: コードポイント単位
- UTF-16互換や低レベル操作: コードユニット単位(必要な時だけ)
実用サンプル
// JS: 先頭からnグラフェムだけ取得
function takeGraphemes(str, n) {
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
const it = seg.segment(str)[Symbol.iterator]();
let out = '', count = 0, cur;
while (count < n && !(cur = it.next()).done) {
out += cur.value.segment;
count++;
}
return out;
}
# Python: 先頭からnグラフェムだけ取得
import regex as re
def take_graphemes(s, n):
return ''.join(re.findall(r'\X', s)[:n])
これらの方法なら、サロゲートペアや結合文字を途中で壊しません。
Unicodeの結合文字と合成の基礎
結合文字とは
結合文字は、直前の文字に重ねて使う記号です。
たとえばU+0301(結合アキュート)はeに重ねてéの見た目を作れます。
ベース文字+結合文字で1つの見た目の文字になることがあります。
近い存在
- バリエーションセレクタ(U+FE0Fなど): 絵文字表示への切り替えに使われます。
- ZWJ(ゼロ幅接合子 U+200D): 絵文字同士をつなぎ、1つの見た目に合成します。
- 肌色修飾(例: U+1F3FB〜): 親指などに肌の色を付けます。
| 見た目 | 内部構成 | 説明 |
|---|---|---|
| é | U+0065 + U+0301 | ベースeに結合アキュート |
| ガ | U+30AB + U+3099 または U+30AC | 濁点は合成でも1文字でも表せる |
| ❤️ | U+2764 + U+FE0F | ハートに絵文字スタイルを指示 |
| 👍🏻 | U+1F44D + U+1F3FB | 親指に肌色を追加 |
同じ見た目でも内部構成が違うことがある点が重要です。
グラフェムクラスタの考え方
ユーザーが「1文字」と感じる最小単位がグラフェムクラスタです。
ベース文字と結合記号、絵文字の修飾やZWJでつないだ並びも1グラフェムとして扱われます。
実用上のルール
見た目の文字数を制御したい時はグラフェム単位で処理するのが基本です。
比較と検索の注意
同じ見た目でも、NFCのé(U+00E9)と、e+結合アキュート(U+0065 U+0301)は内部が異なります。
単純な比較や検索では一致しない場合があります。
対策
- 保存や比較の前に正規化する(NFCなど)。
- 大文字小文字を無視するならcasefoldを使う(Python)。
- JavaScriptではnormalize(‘NFC’)を使ってから比較する。
正規化(NFC/NFD)の基本
- NFC(正規合成形): 可能なら合成済み1文字にまとめます。多くの場面で扱いやすいです。
- NFD(正規分解形): ベース文字+結合記号に分解します。検索や学術用途で使われることがあります。
簡単な例
const nfd = 'e\u0301'; // 分解形
const nfc = nfd.normalize('NFC'); // 合成形 'é'
import unicodedata as ud
nfd = 'e\u0301'
nfc = ud.normalize('NFC', nfd) # 'é'
保管はNFC、比較時にNFCへそろえると混乱が減ります。
絵文字の並び
絵文字は、複数のコードポイントがZWJや修飾などで連結され、1つの見た目になります。
代表例
| 見た目 | 内部構成の例 | 説明 |
|---|---|---|
| 👨👩👧👦 | 👨 + ZWJ + 👩 + ZWJ + 👧 + ZWJ + 👦 | 家族の合成 |
| 🇯🇵 | U+1F1EF + U+1F1F5 | 国旗は地域指示記号の2文字 |
| ❤️ | U+2764 + U+FE0F | テキスト→絵文字スタイル |
| 👩💻 | 👩 + ZWJ + 💻 | 職業の合成 |
途中で切ると別の絵文字になったり、バラバラに見えてしまいます。
プログラミング実装のコツとテスト
文字数カウントはグラフェム単位
UI上の制限(例えば「最大140文字」)はグラフェム数で数えます。
JavaScriptならIntl.Segmenter、Pythonならregexモジュールやgraphemeパッケージを使うと簡単です。
サンプル
function graphemeLength(str) {
const seg = new Intl.Segmenter('ja', { granularity: 'grapheme' });
return [...seg.segment(str)].length;
}
import regex as re
def grapheme_length(s):
return len(re.findall(r'\X', s))
スライスはコードポイント単位
内部処理でどうしても分割が必要な時、最低限コードポイント単位で分割します。
JavaScriptではArray.fromやスプレッドでコードポイント配列にしてから操作します。
Pythonのスライスはコードポイント単位です。
サンプル
const cps = Array.from('A😀👍'); // ['A','😀','👍']
const head = cps.slice(0, 2).join('');
UI表示用のテキストは、できればグラフェム単位でスライスしてください。
正規表現はUnicode対応を使う
JavaScriptでは<u>uフラグ</u>とUnicodeプロパティエスケープを使います。
Pythonでは内蔵reもUnicode対応ですが、グラフェムにはregexが便利です。
// 文字(文字カテゴリ=Letter)だけにマッチ
const m = 'café'.match(/\p{Letter}+/gu); // ['café']
# 単語境界や文字種判定にUnicode前提で取り組む
import re
re.findall(r'\w+', 'café') # 実際の要件に応じて調整
正規表現の1文字(.)は、uフラグでコードポイント単位になりますが、グラフェムではありません。
入力検証でのUnicode対応
入力をむやみにASCIIに限定せず、正規化(NFC)や長さの上限をグラフェム数でチェックします。
必要に応じて制御文字の除去や制限も検討します。
JSの例
function sanitizeInput(s) {
const t = s.normalize('NFC');
// 制御文字(一般カテゴリC)を除去する例
return t.replace(/\p{C}+/gu, '');
}
ゼロ幅スペースやZWJは有用ですが、用途により混乱の元になる場合があります。
要件に合わせて扱いを決めましょう。
テストデータに絵文字と合成文字
テストでは、次のような文字を混ぜると安心です。
実運用で起こりやすいパターンをカバーできます。
- e\u0301 と é (同じ見た目の分解形と合成形)
- 👍 と 👍🏻 (肌色修飾あり/なし)
- 国旗 🇯🇵、家族 👨👩👧👦 (ZWJや地域指示記号)
- CJK拡張漢字 𠮷 (BMP外)
- 長音・濁点の合成例 ガ(合成)とカ+゙(結合)
チェックリスト
- 保存・比較前に正規化(NFC)していますか。
- 見た目の文字数制限はグラフェム単位で数えていますか。
- スライスは最低でもコードポイント単位、できればグラフェム単位ですか。
- JavaScriptの正規表現にuフラグを付けていますか。
- サロゲートペアや結合文字を含むテストデータで動作確認しましたか。
まとめ
Unicodeでは、文字の単位が「コードユニット」「コードポイント」「グラフェム」で異なるため、長さの計測や切り出し、検索や比較で思わぬ不具合が起きます。
サロゲートペアはUTF-16に特有の落とし穴を生み、結合文字や絵文字の合成は「見た目の1文字」と「内部の並び」のズレをもたらします。
対策として、UIの文字数はグラフェム単位、内部処理はコードポイント単位、保存や比較前にNFC正規化を基本にすると安全です。
JavaScriptではIntl.Segmenterやuフラグ、Pythonではregexやunicodedataを活用し、テストに絵文字と合成文字を必ず含めましょう。
これらを押さえれば、複雑に見えるUnicodeの世界も、初心者の方でも安心して扱えるようになります。
