ITエンジニアのための徹底解説:文字列の基礎から実践まで

文字列とは何か — 概念の整理

ITにおける「文字列」は、人間が読む文字をコンピュータ上で表現するためのデータ構造です。見た目は単なる文字の並びですが、内部ではバイト列、コードポイント、グラフェム(ユーザーが1つの文字と認識する単位)など複数のレイヤーで解釈が必要になります。正確に扱うためには、エンコーディング、正規化、長さやインデックスの意味、そしてセキュリティ上の注意点を理解することが重要です。

内部表現:コードポイントとエンコーディング

文字列の基礎は「コードポイント」です。Unicodeは世界中の文字に一意の番号(コードポイント)を割り当て、U+0000〜U+10FFFFの範囲を使います。コードポイント自体は抽象的な番号であり、実際にメモリやファイルで保存・伝送するにはエンコーディング(符号化方式)が必要です。

  • ASCII:7ビットで英数字や制御文字を表現。歴史的に重要。
  • UTF-8:可変長(1〜4バイト)。ASCIIと互換であり、現在インターネット上で最も広く使われる。RFC 3629に準拠。
  • UTF-16:16ビット単位。基本多言語面(BMP)の文字は1単位、補助文字は2単位(サロゲートペア)。JavaやWindows内部で多用。
  • UTF-32:固定長(4バイト)でコードポイントを1対1で表すが、メモリ効率が悪い。

重要な実装上の差は「バイト列としての長さ」と「人間が認識する文字数(Grapheme)」が一致しない点です。UTF-8では一文字が1〜4バイト、UTF-16では1〜2ワードになります。

長さ・インデックスの落とし穴:バイト・コードポイント・グラフェム

プログラミングで文字列長やインデックスを扱う際に陥りやすい誤解を整理します。

  • バイト長(bytes):メモリ/ファイル上のサイズ。UTF-8では可変。
  • コードユニット長(code units):UTF-16の16ビット単位など、実装依存の単位。
  • コードポイント長(code points):Unicodeの一意の番号の数。
  • グラフェムクラスタ(grapheme clusters):ユーザーが一文字として認識する最小単位(例:ベース文字+結合文字)。

例として「é」(小文字eと上付きアクセント結合文字)は、見た目は「é」と同じですが、コードポイントの並びは異なります。インデックスで1文字を取り出す、文字数を数える、文字列を分割するといった操作では、どの単位で数えているかに注意が必要です。

正規化(ノーマライゼーション) — NFC/NFD/NFKC/NFKD

Unicodeでは異なるコードポイント列が同じ見た目や意味を持つことがあり、比較や検索の前に正規化が推奨されます。主な正規化形式は次のとおりです。

  • NFC(Canonical Composition):可能なら合成して表す(合成形式)。
  • NFD(Canonical Decomposition):分解形式。結合文字に分ける。
  • NFKC/NFKD(互換分解/互換分解後合成):互換文字の正規化(例えば全角/半角や幅の違いを統一)。

例えば、ファイル名比較やログイン名比較などでは、事前に正規化(通常はNFCかNFKC)してから比較することで意図しない不一致やセキュリティ上の抜けを防げます。

サロゲートペアと絵文字 — 補助平面(サプリメント)への注意

UnicodeのコードポイントはU+10000以上も含み、これらはUTF-16ではサロゲートペアとして表現されます。絵文字や歴史文字などがこれに当たり、1つの見た目の文字が内部的には2つのコードユニットになるため、言語やランタイムによっては文字列操作で破壊的な切断が発生します。

さらに、絵文字には結合(ゼロ幅接合子)やスキンカラー修飾子があり、1つの表示単位が複数のコードポイントからなることもあります。正しくは「グラフェムクラスタ」を単位に処理する必要があります。

言語やランタイムごとの挙動

  • C/C++:伝統的なchar*はバイト列であり、ヌル終端。エンコーディングは実装/環境依存。バッファオーバーフローやヌル処理に注意。
  • Java:内部はUTF-16のchar配列(実装によってはcompact stringsの最適化あり)。charは16ビットでコードユニットを表す。
  • Python(3系):strはUnicode文字列で抽象的にはコードポイント列。内部はプラットフォームと文字の範囲で最適化される(PEP 393)。
  • JavaScript:文字列はUTF-16のコードユニット列として扱われる。単純なインデックスアクセスでサロゲートペアが分割される可能性あり。
  • Go:stringはバイト列(UTF-8)、runeはコードポイント(int32)。スライス操作はバイト単位なので注意。
  • Rust:StringはUTF-8。内部はバイト配列であり、文字単位のインデックスはO(n)で扱う。&strのスライスは必ずコードポイント境界である必要がある。

パフォーマンスと実装テクニック

文字列操作のコストはアプリケーションのパフォーマンスに直結します。主要なポイント:

  • 不変(immutable)文字列の連結:言語によっては連結が毎回コピーを生む。大量連結にはStringBuilder(Java)、joinパターン、bytes.Buffer(Go)、String::with_capacity(Rust)などを使う。
  • 部分文字列(substring):コピーが行われるか参照共有かは実装依存。大きな文字列の頻繁なスライスではメモリ消費を考慮する。
  • 正規表現:Unicode対応の正規表現エンジン(ICUなど)を使用することで多言語対応が容易になるが、複雑なパターンは性能を悪化させる。
  • エンコーディング変換:UTF-8⇄UTF-16変換などはCPUコストがかかる。I/Oやネットワークでの変換はまとめて行う。

セキュリティ上の注意点

文字列は攻撃ベクトルになりやすく、次の点に特に注意してください。

  • インジェクション(SQL/コマンド/HTML):常にパラメタライズドクエリや適切なエスケープを使用。生の文字列連結は危険。
  • 正規化回避攻撃:視覚的には同じでもコードポイントが異なる文字を使い、認証バイパスやフィッシング(IDNホモグリフ攻撃)を行う可能性がある。入力は検証と正規化を行う。
  • 制御文字とゼロ幅文字:ユーザー名や表示文字列にゼロ幅スペース等を混入されるとUIで欺かれる恐れがある。表示と内部比較のポリシーを明確にする。
  • バッファオーバーフロー:C/C++等でバイト長と想定長の不一致によりバッファを破壊するケース。常に長さチェックを行い、安全なライブラリを使用する。

データベースと照合順序(Collation)

文字列を保存・検索する際、データベースの文字セットと照合順序は重要です。UTF-8で保存するのが一般的ですが、照合順序によって大文字小文字比較、アクセントの扱い、ソート順が変わります。アプリ側で正規化してから保存する、またはDB側の照合順序を適切に設定することで意図しない検索結果や整合性の問題を避けられます。

国際化(i18n)とローカライズ(l10n)の観点

文字列処理は言語固有のルールに左右されます。例えばトルコ語の大文字・小文字変換(i → İ / I → ı)や、日本語のかな・カナ変換、ラテン文字のアクセント扱いなどは簡易的なケース変換では誤動作します。ICU(International Components for Unicode)や各言語のロケール機能を使い、ロケール依存の操作は専用APIで行うことが推奨されます。

実務でのベストプラクティス

  • UTF-8を第一選択とする:内部表現やI/OでUTF-8を標準化する。ウェブやAPIではUTF-8がデファクトスタンダード。
  • 入力の検証と正規化:比較や検索前にNFC/NFKCなど適切な正規化を実行。
  • エスケープとパラメタライズ:出力先に応じて正しいエスケープ(HTMLエスケープ、SQLパラメタ)を行う。
  • Unicode-awareなライブラリを利用:独自実装は避け、ICUやlanguage-specificの成熟したライブラリを利用する。
  • グラフェム単位の操作:ユーザーに見える単位でのトリミングや切り出しはグラフェムクラスタを使う。
  • ログと監査:入力エンコーディングの検証ログや正規化経路を記録し、不審な文字列を検出する。

高度な話題:正規表現と照合

正規表現でUnicodeを扱う場合、単純な\wや\bなどの挙動が言語やエンジンで異なります。ICUや各言語のUnicodeフラグを使って、Unicodeプロパティ(\p{L}など)を活用することで正確なマッチングが可能になります。また、ソートや検索はICUの照合ルールを使うことでロケールに即した順序が得られます。

まとめ

文字列は表面的には単純ですが、エンコーディング、正規化、グラフェム概念、ランタイムごとの内部表現、セキュリティリスク、そしてローカライズといった多くの側面を理解する必要があります。実務では「UTF-8を標準にする」「入力を検証・正規化する」「Unicode-awareなライブラリを使う」「表示と比較で扱いを分ける」といった基本ルールを守ることで、多くの問題を未然に防げます。

参考文献