文字列内部表現(イミュータブル・インターン・SSO)
同じ文字列の==が速い言語と遅い言語の差、短い文字列でヒープ確保が起きない理由がわかる。不変・インターン・SSO・ロープという処理系の文字列実装を押さえ、性能とメモリの勘所を掴める。
- 1.多くの言語で文字列はイミュータブル(不変)。これにより共有・コピーオンライト・ハッシュ値キャッシュ・スレッド安全が可能になるが、連結のたびに新オブジェクトを作るため繰り返し連結は二乗コストになりやすい。
- 2.文字列インターンは同一内容の文字列を1個に正規化する仕組み。等値比較がポインタ比較(O(1))になりメモリも節約できるが、解放されにくく無制限なインターンはリーク源になる。
- 3.小文字列最適化(SSO)は短い文字列をヒープでなくオブジェクト本体に直接埋め込む手法。ロープは長文字列を木構造で持ち連結・挿入をO(log n)にする。実装ごとに狙う負荷が違う。
文字列は「ただのバイト列」ではない
ソースコード上では "hello" は素朴な文字の並びに見えますが、処理系の内部では、文字データ本体に加えて長さ・容量・参照カウントやハッシュ値・エンコーディング情報などを束ねた構造体として表現されます。同じ「文字列型」でも、言語によってこの構造体の中身と方針はまるで違います。可変か不変か、コピーをいつ作るか、短い文字列をどこに置くか——この設計選択が、== の速さ、連結の計算量、メモリ使用量を決めます。
文字列の中身がコードポイントとバイト列の二段の写像であること自体は「文字コードとUnicodeの内部」で扱った通りで、本稿はその「容れ物」側、つまり処理系がメモリ上にどう置くかを掘り下げます。
イミュータブル文字列とコピーオンライト
Java・C#・Python・JavaScript・Go など多くの言語で、文字列は**イミュータブル(不変)**です。一度作った文字列の中身は書き換えられず、変更したい場合は新しい文字列を作ります。不変にする理由は、不変性一般の利点(「イミュータビリティ」参照)に文字列特有の事情が加わるからです。
- 安全な共有 — 中身が変わらないので、同じ実体を複数の変数・スレッドが参照しても破壊されない。防御的コピーが不要になる。
- ハッシュ値のキャッシュ — 内容が固定なのでハッシュ値を一度計算して構造体に保持できる。文字列キーの辞書(ハッシュテーブル)で再ハッシュを避けられる。
- 部分文字列の共有(実装次第) — 元の文字データを共有し、オフセットと長さだけ持つ「ビュー」を返せる。
かつてのJava(Java 6まで)の substring は元配列を共有し、オフセットと長さだけ持つO(1)実装でした。しかし巨大文字列から短い部分を切り出して保持すると、元の巨大配列が解放されずに残るリーク(意図せぬ強参照)が起きます。Java 7以降は substring がコピーを作るO(n)実装に変わりました。共有はメモリ効率と「巨大データを道連れにしない」安全性のトレードオフです。
**コピーオンライト(COW)**は、不変の見た目を保ちつつコピーを遅らせる手法です。代入時はデータ本体を共有(参照カウントを増やすだけ)し、いずれかが書き換えようとした瞬間に初めて実体を複製します。読み取りだけなら一度もコピーが起きません。
COWの参照カウント更新はスレッド間で競合します。C++の std::string はかつてCOW実装が許されていましたが、マルチスレッドでのカウント操作のコストと正しさの問題から、C++11はCOW文字列を事実上禁止しました(read操作がデータ競合を起こしてはならない要件を満たせない)。現在の主要実装はCOWでなく後述のSSOを採用しています。
連結の罠——不変ゆえの二乗コスト
不変文字列の最大の落とし穴が繰り返し連結です。s = s + x は毎回「長さ len(s)+len(x) の新文字列を確保し、両方をコピーする」ため、ループでn回連結すると総コストは 1+2+...+n、すなわち O(n²) に膨らみます。
# アンチパターン:n回の連結で O(n^2)
s = ""
for x in parts:
s = s + x # 毎回全体をコピーして作り直す
# 正攻法:可変バッファに溜めて最後に一度だけ確定
buf = []
for x in parts:
buf.append(x) # 追記は償却 O(1)
s = "".join(buf) # 連結は一度きり O(n)
このため各言語は可変文字列バッファを別に用意します。Javaの StringBuilder、C#の StringBuilder、Goの strings.Builder がそれで、内部に伸長可能な配列(容量を倍々に増やす動的配列)を持ち、追記を**償却O(1)**で行います。償却コストの考え方自体は「償却計算量」と同じく、たまの再確保コストを多数の安価な追記でならす発想です。
Javaでは a + b + c 程度の定数個の連結はコンパイラが StringBuilder(または invokedynamic 経由の結合)に最適化しますが、ループ内の連結は最適化されません。Pythonの CPython は s += x のループを参照カウントが1のとき限定で in-place 拡張する小細工を持ちますが、移植性に頼るべきでなく join が定石です。
文字列インターン
インターン(interning)は、同一内容の文字列をプログラム全体でただ1個のインスタンスに正規化する仕組みです。インターン済みプール(ハッシュテーブル)に同じ内容があればそれを返し、なければ登録します。狙いは二つです。
- 等値比較の高速化 — 同じ内容が必ず同じアドレスになるので、
equals(中身の逐文字比較、最悪O(n))の代わりに**ポインタ比較(O(1))**で判定できる。 - メモリ削減 — 重複する文字列を1個に畳み込む。識別子・キー・列挙値のように同じ語が大量に現れる場面で効く。
String a = "tech"; // リテラルは自動でインターンされる
String b = new String("tech"); // newは別インスタンス(プール外)
String c = b.intern(); // 明示的にプールの代表を取得
a == b // false(参照が違う)
a == c // true (cはインターン済み代表=aと同一参照)
a.equals(b) // true (内容比較なら当然一致)
多くの言語がコンパイル時の文字列リテラルを自動インターンします(Java・C#・Pythonの一部リテラル)。一方で実行時に生成した文字列まで無条件にインターンすると問題が生じます。
インターンプールは「生きている文字列の集合」であり、登録された文字列は容易に解放されません。ユーザー入力やネットワーク受信など外部由来の文字列を無制限に intern すると、プールが際限なく膨張してメモリリークになります。Java 7でインターンプールがPermGenからヒープへ移ったのもこの管理問題が背景です。インターンは「種類が有限で頻出する文字列」に限定するのが鉄則です。
== がポインタ比較になる前提は、ハッシュテーブルのキー設計とも噛み合います。等値が速いキーは探索の定数項を縮めるからで、この発想は「ハッシュテーブルの内部実装」のキー比較コストと地続きです。
小文字列最適化(SSO)
文字列を素直にヒープ上の配列で持つと、"id" のような数文字の文字列でもヒープ確保(malloc)とポインタ間接参照が発生します。短い文字列が大量に飛び交うプログラムでは、この確保コストとキャッシュミスが効いてきます。
小文字列最適化(SSO, Small String Optimization)は、短い文字列をヒープに置かず、文字列オブジェクト本体の領域に直接埋め込む手法です。std::string は典型的に「ポインタ+長さ+容量」で24バイト程度を占めますが、SSOではこの領域そのものを文字バッファとして流用し、しきい値(libstdc++で15バイト、MSVCで15バイト程度)以下ならヒープ確保ゼロで済ませます。
通常(ヒープ)の string レイアウト SSO で短い文字列を埋め込んだ場合
+----------+----------+----------+ +------------------------------+
| ptr ─────┼─→ heap | len | cap | | "id\0..." を本体に直接格納 |
+----------+----------+----------+ +------------------------------+
間接参照あり・malloc あり 間接参照なし・malloc なし
| 手法 | 短い文字列の置き場所 | 狙う効果 | 代償 |
|---|---|---|---|
| 素朴なヒープ実装 | 常にヒープ確保 | 実装が単純 | 短文字列でも malloc とキャッシュミス |
| SSO(小文字列最適化) | オブジェクト本体に埋め込み | 短文字列で malloc ゼロ・局所性向上 | オブジェクトが大きくなる・分岐が増える |
| インターン | 共有プールに1個だけ | 等値O(1)・重複削減 | プール管理・解放されにくい |
SSOがメモリ局所性を改善するのは、文字データがオブジェクトと同じキャッシュラインに乗り、ポインタ追跡(pointer chasing)が消えるからです。この効きどころは「メモリレイアウトとキャッシュ局所性」のキャッシュラインの議論そのものです。判定は文字列ごとに「埋め込みかヒープか」を分岐するため、わずかな分岐コストと引き換えに確保コストを消す設計といえます。
Rustの String はSSOを持ちませんが(Vec<u8> ベース)、smallstr などのライブラリで補えます。一方で文字列ビュー(C++の string_view、Goの文字列、Rustの &str)は所有せず「ポインタ+長さ」だけを持つ別アプローチで、コピーせず部分参照する用途に使います。SSOは「所有する短い文字列を安く持つ」、ビューは「借りた文字列をコピーせず指す」と狙いが異なります。
ロープ——長文字列を木で持つ
ふつうの連続配列表現は、先頭付近への挿入・削除・連結が苦手です。長さnの配列の途中に文字を挿入すると、後続を全部ずらすO(n)が発生します。テキストエディタのバッファのように巨大な文字列へ頻繁に編集を加える場面では、これが致命的になります。
ロープ(rope)は、文字列を二分木の葉に短い断片として分割保持するデータ構造です。内部ノードは左部分木の長さ(重み)を持ち、これを使って「i番目の文字」を**O(log n)で辿れます。連結は2本のロープを新しい根の下にぶら下げるだけ、挿入・分割も木の組み替えで済むため、いずれもO(log n)**になります。
| 操作 | 連続配列(通常文字列) | ロープ(木構造) |
|---|---|---|
| i番目アクセス | O(1) | O(log n) |
| 連結 | O(n)(全体コピー) | O(1)〜O(log n) |
| 途中への挿入・削除 | O(n) | O(log n) |
| 全走査・局所性 | 良い(連続) | やや劣る(ノード散在) |
代償は、ランダムアクセスがO(1)でなくなること、ノードがメモリ上に散在してキャッシュ局所性が落ちること、実装が複雑になることです。木構造で重みを持ちlog nで辿る発想は「平衡木」と共通で、ロープは「重み付き平衡木の文字列版」と捉えると見通しが良くなります。
「不変文字列ゆえ繰り返し連結はO(n²)、可変バッファ(StringBuilder/Builder)で回避」「インターンは等値をO(1)にするが無制限だとリーク」「SSOは短文字列をオブジェクト本体に埋め込みmallocを消す」「ロープは木で連結・挿入をO(log n)にする代わりランダムアクセスがO(log n)」——この4点が文字列内部表現の定番です。
まとめ
文字列は内部的に、文字データ本体にメタ情報を束ねた構造体で、設計の核心は四つです。イミュータブルは安全な共有・ハッシュキャッシュ・スレッド安全を生む一方、繰り返し連結のO(n²)を招くため可変バッファで回避します。インターンは同一内容を1個に畳み込み等値比較をポインタ比較(O(1))にしますが、無制限なインターンはリーク源です。SSOは短い文字列をオブジェクト本体に埋め込んでヒープ確保とキャッシュミスを消し、ロープは長文字列を木で持って連結・挿入をO(log n)に変えます。どれも万能ではなく、「不変で安全に共有」か「可変で速く編集」か、「重複を畳む」か「確保を消す」か——扱う負荷に応じて処理系が選んだトレードオフの結果なのです。
プログラミング Article
文字列内部表現(イミュータブル・インターン・SSO)を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
文字列
比較で見る軸
難易度: advanced / カテゴリ: プログラミング / タグ数: 5
導入後に効く点
文字列インターンは同一内容の文字列を1個に正規化する仕組み。等値比較がポインタ比較(O(1))になりメモリも節約できるが、解放されにくく無制限なインターンはリーク源になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- プログラミング
- タグ数
- 5
判断チェックリスト
- 自社の用途が「文字列 / メモリ」に近いか確認する。
- 強みである「多くの言語で文字列はイミュータブル(不変)。これにより共有・コピーオンライト・ハッシュ値キャッシュ・スレッド安全が可能になるが、連結のたびに新オブジェクトを作るため繰り返し連結は二乗コストになりやすい。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。