Dart文字列の深淵:UTF-16の罠と`Runes`によるUnicode完全制覇
Dartの文字列(`String`)を、単なる「文字の配列」だと思って扱っていないだろうか。もしそうなら、あなたは本番環境で深刻なデータ破損、サロゲートペアの分断による文字化け、あるいは悪意ある入力によるセキュリティ脆弱性を引き起こすリスクを抱えている。
ランタイムエンジンの内部構造を知る者にとって、Dartの `String` は UTF-16 コードユニットのシーケンスに他ならない。本稿では、Dart VMにおける文字列のメモリ表現、サロゲートペアの仕組み、そして `Runes` を用いてUnicodeコードポイントを正確に制御する方法を、低レイヤの視点から徹底的に解剖する。
—
1. Dart VMにおける文字列のメモリ表現とUTF-16の現実
Dartの `String` オブジェクトは、内部的にはUTF-16エンコードされた文字の列として保持される。これは、歴史的経緯とJavaScript(ECMAScript)との相互運用性を考慮した設計判断に基づくものだが、C/C++やRustのUTF-8ファーストな世界観に慣れたエンジニアにとっては最初の罠となる。
Dart VMのヒープ内において、文字列は以下のいずれかの表現(Representation)でアロケートされる。
1. One-byte string (Latin-1 / ISO-8859-1): すべての文字がコードポイント `U+00FF` 以下に収まる場合、1文字=1バイト(8ビット)の連続領域として最適化され、メモリ消費を半減させる。
2. Two-byte string (UTF-16): 文字列内に `U+0100` 以上のコードポイントが含まれる場合、1文字=2バイト(16ビット)の固定長配列として確保される。
ここで重要なのは、「1コードユニット(16ビット)=1文字(Character)」ではないという点だ。
サロゲートペアの構造
BMP(基本多言語面、`U+0000` 〜 `U+FFFF`)を超える補助文字(Supplementary Characters、例: 多くの絵文字や歴史的漢字など)は、UTF-16では2つの16ビットコードユニット(上位サロゲートと下位サロゲート)のペアで表現される。
例えば、絵文字の「🔥」(U+1F525)を考えてみよう。
これをDartの通常の `length` プロパティで評価すると、「2」という値が返る。なぜなら、UTF-16のコードユニット数をカウントしているに過ぎないからだ。
void main() {
String fire = ‘🔥’;
print(fire.length); // 出力: 2 (コードユニットの数)
print(fire.codeUnits); // 出力: [55357, 56613] (上位/下位サロゲートの数値)
}
この仕様を無視して文字列の切り出し(`substring`)やインデックスアクセスを行うと、サロゲートペアの半分だけを切り出すという致命的なバグを引き起こし、不正なバイト列(孤立したサロゲート)を生成する原因となる。
—
2. `Runes` とコードポイントの厳密なマッピング
このUTF-16の抽象化レイヤを剥ぎ取り、Unicodeの真のコードポイント(Scalar Value)に直接アクセスするために提供されているのが `Runes` 型である。
`String.runes` プロパティは、サロゲートペアを自動的に合成(デコード)し、32ビットの整数値(Unicodeコードポイント)を要素とする `Iterable
void main() {
String text = ‘Dart 🎯’;
// runesを通じたイテレーション
Runes runes = text.runes;
for (int rune in runes) {
// 16進数表現でコードポイントを出力
print(‘U+${rune.toRadixString(16).padLeft(4, ‘0’).toUpperCase()}’);
}
}
実行結果:
U+0044 (D)
U+0061 (a)
U+0072 (r)
U+0074 (t)
U+0020 ( )
U+1F3AF (🎯 – Direct Hit)
`’🎯’`(Direct Hit)はBMP外の文字であるが、`Runes` を経由することで、1つの論理的な文字として `U+1F3AF` という単一のコードポイントとして扱われていることがわかる。
—
3. 実践:安全な文字列操作とイディオム
では、実務においてサロゲートペアや結合文字(Combining Characters)を含む文字列を安全に処理するにはどうすればよいか。
危険な例:通常のインデックスアクセス
void unsafeSubstring(String s, int start, int end) {
// 致命傷: サロゲートペアの途中でスライスされる可能性がある
print(s.substring(start, end));
}
安全な例:`Runes` をベースにしたスライスと再構築
真に安全な文字列の切り出しを行いたい場合、コードユニットベースではなく、コードポイント(Runes)ベースでインデックスを解決し、文字列に再変換する必要がある。
String safeSubstring(String input, int startRuneIndex, int endRuneIndex) {
// Runesのイテレータブルをリスト化し、コードポイント単位で安全にスライス
List
if (startRuneIndex < 0 || endRuneIndex > runesList.length || startRuneIndex > endRuneIndex) {
throw ArgumentError(‘Invalid rune indices’);
}
// 該当範囲のコードポイントから新しいStringを構築
return String.fromCharCodes(runesList.sublist(startRuneIndex, endRuneIndex));
}
void main() {
String message = ‘Hello 👋 World 🌍’;
// 文字数(コードポイント数)ベースで安全に切り出す
// 👋や🌍も含めて正確に位置が計算される
String extracted = safeSubstring(message, 6, 9);
print(extracted); // 出力: 👋 W
}
`String.fromCharCodes()` コンストラクタは、与えられた整数のリストがUTF-16のコードユニットなのか、あるいは完全なUnicodeコードポイント(32ビット)なのかを自動判別し、適切にUTF-16文字列へとエンコードし直してくれる。
—
4. セキュリティ上の脅威と防壁:文字正規化とインジェクション
セキュリティ研究者の視点において、Unicodeの複雑さはそのまま脆弱性の温床となる。
1. 同形異義文字攻撃 (Homograph Attack): 見た目は同じ「a」(ラテン文字)であっても、キリル文字の「а」が混入している場合、プログラムのルーティングやバリデーションをすり抜ける可能性がある。
2. 結合文字によるDoS / バッファ溢れ: 1つのベース文字に対して無数の結合文字(Combining Diacritical Marks)を付加し続けることで、文字列の長さ(メモリ消費量やレンダリング負荷)を意図的に爆発させる攻撃。
Dartの標準ライブラリ(`String`)単体では、Unicodeの正規化(NFC / NFDなど)を直接行う高度な機能は提供されていない。そのため、厳格なセキュリティが求められるバックエンドサービスや入力検証レイヤでは、`characters` パッケージ(Flutterフレームワークに内包、あるいはDartの `package:characters`)を活用し、Grapheme Cluster(書記素クラスター)単位で処理を行うことが極めて重要だ。
import ‘package:characters/characters.dart’;
void main() {
// ‘e’ と結合アクセント記号の組み合わせ
String complexStr = ‘e\u0301’; // é と表示されるが、データ構造上は2文字分
print(complexStr.length); // 出力: 2 (コードユニット)
print(complexStr.runes.length); // 出力: 2 (コードポイント)
// ユーザーが視認する「1文字(Grapheme Cluster)」単位でのカウント
print(complexStr.characters.length); // 出力: 1
}
—
アーキテクトからの提言
Dartで文字列を扱う際、単に `String` 型に依存するだけでは、グローバルなユーザーからの入力や特殊な絵文字データに対して脆弱なシステムを作り上げてしまう。
- 低レイヤのコード値(生の値)を操作・検証したい場合:`Runes` を使ってコードポイントを直に監視・制御する。
- ユーザーが視覚的に認識する「文字単位」で安全に操作したい場合:`characters` パッケージによる書記素クラスターの概念を導入する。
ランタイムの挙動を完全に掌握し、メモリとCPUの動作原理まで見通した上でコードを書くこと。それが、真に堅牢なDartアプリケーションを構築するための唯一の道である。