CodeGym /コース /C++ SELF /std::vectorにおける無効化: reallocation, erase

std::vectorにおける無効化: reallocation, erase

C++ SELF
レベル 41, レッスン 1
使用可能

1. 無効化

std::vector を初めて使い始めると、とても扱いやすいものに見えます。要素を入れて、要素を取り出して、すべて筋が通っているように思えます。ところがポインタ Task* p = &tasks[0]; を取り、新しいタスクを追加すると、突然 p は「かつては正しかったアドレス」に変わります。これがまさに 無効化 です。

コンテナの文脈での無効化(invalidate)とは、要素へのあなたの 結び付き(参照、ポインタ、イテレータ)が、以前と同じ要素を指し続ける義務を失う状況のことです。別の要素を指し始めることもあれば、「どこも指さない」こともあります。そして C++ における「どこも指さない」は、きれいなエラーではなく、しばしば潜在的な UB を意味します。奇妙な値、クラッシュ、「自分のノート PC では動くのに」、そして本番での突然の破綻です。

くだけて言えば、引き出しへの道順(「これがアドレスだ」)を保存していたのに、vector が突然 別の部屋へ引っ越し(あるいは家具を並べ替え)してしまい、その道順が今では壁に通じている、というイメージです。

2. なぜ std::vector は「引っ越す」のか

std::vector には強みがあります。要素が 1 つの連続したメモリ領域 に並ぶことです(配列のように)。だからこそ vector はしばしば高速です。CPU は連続したデータを読みやすく、キャッシュにも優しく、全体として都合がいいのです。 標準やその周辺の議論でも、vector は contiguous コンテナであることが強調されており、vector、array、string のために contiguous iterators というカテゴリまで個別に言及されています。

ただし、この速さの代償として、vector はときどき高価な操作である 再確保(reallocation)を行わなければなりません。 仕組みは単純で、vector は 2 つの値、size()(実際の要素数)と capacity()(引っ越しなしで現在のバッファに入る要素数)を持っています。要素を追加して size() が capacity() を超えると、vector はより大きな新しいバッファを確保し、要素をそこへ移して、古いバッファを解放します。

あなたのポインタ/参照の視点では、かなり乱暴に言えばこうです。「昨日はレーニン通り 10 番地に住んでいたけれど、今日はプーシキン通り 42 番地だ。どうか古い住所に手紙を送り続けないでほしい」。

3. reallocation: いつ起きて、何が壊れるのか

reallocation のいちばん厄介な点は、たいてい目に見えないことです。push_back を書くと、コードはコンパイルされ、テストも通る……。ところが別のデータサイズでは capacity() が小さく、vector が引っ越します。するとそこで「魔法」が始まります(つまり「深夜にデバッグする人向けの魔法」です)。

重要な考え方はこれです。reallocation の後では、要素の古いアドレスはすべて無効になる。これは要素へのポインタ、参照、イテレータすべてに当てはまります。

これを目で確かめる一番簡単な方法は、追加の前後で先頭要素のアドレスを出力することです。

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    std::cout << "addr before: " << &v[0] << "\n";
    v.push_back(40); // reallocation を引き起こす可能性がある
    std::cout << "addr after:  " << &v[0] << "\n";
}

アドレスが同じこともあります(reallocation が起きなかった場合)。違うこともあります(起きた場合)。この「ときどき」が、無効化まわりのバグを厄介にする理由です。こうしたバグは、入力データのサイズや、その日にリンカ/アロケータがどうメモリを配置したかにまで左右されることが多いからです。

data() を比べると、さらに分かりやすくなります。

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    v.push_back(1);

    std::cout << "data: " << v.data() << "\n";
    v.push_back(2); // 引っ越しが起きるかもしれない
    std::cout << "data: " << v.data() << "\n";
}

data() が変わっていたら、それは古い要素への「結び付き」を使ってはいけないという、ほぼ確実なシグナルです。

ミニ図解: reallocation で何が起きるか

前 (capacity = 3):
[10][20][30]
 ^ アドレスはこのあたり

後 (capacity が増え、たとえば 6):
[10][20][30][40][  ][  ]
 ^ 別の場所にある新しいバッファ
(古いバッファは解放済み)

古いポインタは、いまや解放済みメモリを指しています。言語の視点では、そこは「立ち入り禁止区域」です。たまたま動くこともあれば、落ちることもあり、データを壊すこともあります。

4. erase: 要素のシフトと無効化

erase の厄介さは別の形で現れます。vector の途中から要素を削除すると、std::vector は連続した格納を維持しなければなりません。つまり、通常の配列と同じように、穴を埋めるために後ろの要素を左へずらす のです。

これは、削除された要素 より後ろ にある要素のアドレスが変わることを意味します。インデックスも「ずれ」ます。

#include <vector>

int main() {
    std::vector<int> v{10, 20, 30, 40};

    int* p30 = &v[2];          // 要素 30 のアドレス
    v.erase(v.begin() + 1);    // 20 を削除、30 は左にずれる

    // *p30 = 999; // 潜在的な UB: p30 は無効になったかもしれない
    (void)p30;
}

ここで重要なのは心理的な罠です。「でも 30 は vector の中に残っているでしょう?」――はい、値としては残っています。しかし 同じアドレスに残る保証はありません。あなたが結び付いていたのは、数値「30」という概念ではなく、そのアドレスなのです。

標準に関する資料でも、erase と妥当性の定式化の精度について議論が見られます([vector.modifiers] における erase の仕様に関する個別の issue にまで及びます)。これは、この話題が本当に繊細で重要であることを示す良い目印です。

ミニ図解: erase は何をするか

前:
index: 0   1   2   3
      [10][20][30][40]

erase(1) で 20 を削除

後:
index: 0   1   2
      [10][30][40]
          ^
       "30" のアドレスはもう別物 (位置 1 に移動した)

5. なぜ const では救えないのか

ここでは典型的な混乱がよく起こります。学生はまじめに const int& r = v[0]; を取り、「でもこれは const だから安全だ」と考えます。残念ながら、const が言っているのは 1 つだけです。「r を通して値を変更してはいけない」。しかし、アドレスの安定性、引っ越し、寿命、vector がメモリ再配置を決めないことについては、何も保証しません。

#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};

    const int& r = v[0];
    v.push_back(4); // r を無効化する可能性がある

    // int x = r; // 潜在的な UB
    (void)r;
}

これは、建物への「閲覧専用パス」を渡された状況に似ています。建物が別の都市へ移転してしまえば、そのパスが古い住所を再び現実のものにしてくれるわけではありません。

6. 実践例: タスクと「選択された要素」

「これは抽象的な数字の話にすぎない」と感じないように、学習用アプリの小さな断片を見てみましょう。たとえば、タスクのモデルと、std::vector に入ったタスクリストがあるとします。

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, "Read C++ book", false});
}

ここで、検索関数を作って、見つかったら タスクへのポインタ を返すことにしたとします。

#include <vector>

Task* FindTaskById(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) {
        if (t.id == id) return &t;
    }
    return nullptr;
}

このポインタを「今この場で」使うだけなら問題ありません。問題が始まるのは、それを 保存して、その後も tasks を変更し続けるときです。

罠になるシナリオ:

#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, "A", false});

    Task* selected = &tasks[0];
    tasks.push_back(Task{2, "B", false}); // reallocation を引き起こす可能性がある

    // selected->done = true; // 潜在的な UB
    (void)selected;
}

初心者プログラマの感覚では、これは「筋が通っている」ように見えます。タスクを選び、次にもう 1 つ追加し、そのあと選ばれたタスクを完了済みにする。しかしメモリの視点では、これは「アドレスを覚えたあとで vector が引っ越し、それでも古い住所に書き込み続けている」ことを意味し得ます。

しかも厄介なのは、ときどきこれが「いちおう動いているように見える」ことです。なぜなら reallocation が起きなかった(capacity() に余裕があった)だけかもしれないからです。だからこの手のバグは長生きし、いちばん困るタイミングで表面化します。

何がまずいのか、なぜデバッグしにくいのか

無効化された参照/ポインタ/イテレータを使っても、C++ はたいてい注意してくれません。コンパイラは、実行時にあなたが push_back をして結び付きを「壊した」ことを知りませんし、プログラムもすぐ落ちる義務はありません。古い値を見せることもあります(メモリがまだ再利用されていないから)。ゴミを見せることもあります(すでに別のものに使われているから)。落ちることもあります(保護領域に踏み込んだ場合)。あるいは他のデータを静かに壊し、ずっと後になってから落ちることもあります(これが特に「楽しい」パターンです)。

これが、C++ のまわりでこんなミームが生まれる理由の 1 つです。「Undefined Behavior とは、プログラムがあなたの予想しなかったことを、しかも自信満々にやることだ」。

良い知らせもあります。単純なモデルを保っていれば、std::vector の振る舞いは予測可能です。vector を 引っ越させる もの(reallocation)は、要素への結び付きをすべて壊します。vector に 要素のシフト をさせるもの(途中での erase)は、ずらされた要素への結び付きを壊します。

std::vector の操作ごとのリスクマップ

以下は、「歩く標準仕様」にしようとするのではなく、実用のための頭の中の表です。物理学の説明ではありませんが、交通標識のように事故の数は大きく減らしてくれます。

std::vector に対する操作 内部で起こり得ること 要素への T* / T& / iterator に対するリスク
push_back
,
emplace_back
ときどき reallocation ときどき すべて の結び付きを壊す
reserve(n)
その場で reallocation を起こすことがある 引っ越しが起きたなら すべて の結び付きを壊す
増加方向への
resize
reallocation を起こすことがある ときどき すべて の結び付きを壊す
erase(pos)
pos
より後ろの要素をシフト
シフトした要素と削除された要素への結び付きを壊す
erase(first, last)
範囲削除 + 末尾側のシフト 同様に「その範囲より後ろ」を壊す
clear()
論理的に要素を削除する 要素への結び付きはもはや意味を持たない
読み取り:
operator[]
, range-for
何も変えない vector が変更されない限り安全

ここでの微妙な点に注意してください。「ときどき壊れる」は「気にしなくていい」という意味ではありません。これは「データサイズによってコードの振る舞いが変わる」という意味です。

無効化を踏まないためのコードの書き方

役に立つ習慣があります。vector の要素への結び付きは、できるだけ短命であるべき ということです。vector の要素へのポインタ/参照/イテレータの理想的な寿命は、「vector を変更しない小さなコード区間の中だけ」です。

たとえば、次のようなコードは、心理的にも設計上もふつうより安全です。

#include <vector>

void MarkFirstDone(std::vector<Task>& tasks) {
    if (tasks.empty()) return;

    Task& t = tasks[0];  // 参照を取得
    t.done = true;       // すぐに使う
    // ... ここではもう push_back/erase をしない
}

一方で、次のようなパターンを見たら、頭の中で必ず「警報」を鳴らすべきです。参照/ポインタが長く生きていて、その近くに push_back や erase があるなら、脳内で「待て、ここは無効化が起こり得る」と言わなければいけません。

#include <vector>

void Suspicious(std::vector<Task>& tasks) {
    if (tasks.empty()) return;

    Task* p = &tasks[0];                   // 結び付き
    tasks.push_back(Task{99, "X", false}); // 潜在的な引っ越し

    // p->done = true; // 危険地帯: p は無効になったかもしれない
    (void)p;
}

この講義では、これを「設計上の宗教論争」に変えて、長生きする要素アドレスを何で置き換えるべきかまでは扱いません。今大事なのは原因を固定することです。std::vector の要素アドレスとは「いまこの瞬間のアドレス」であって、永続的な同一性ではない のです。

7. std::vector の無効化でよくあるミス

ミス 1: vector の要素への T*/T&/iterator を「操作の合間」に保持したまま、その後で push_back する。
これは定番です。「要素を選んだ」「その要素へのポインタを保存した」「もう 1 つ要素を追加した」「なぜ宇宙が壊れた?」。問題は、起こり得る reallocation にあります。vector が引っ越し、ポインタがゴミになります。見た目にはちゃんとしたコードに見えるので、このミスは特にしぶといです。

ミス 2: const なら結び付きは安全だと思い込む。
const T& や const T* は、そのアクセス経由での書き込みを禁止するだけで、アドレスの安定性を保証せず、バッファの引っ越しも防ぎません。要素への const 参照を握っていて、その近くに vector の成長操作があるなら、それでも潜在的に危険です。

ミス 3: erase の後で、削除された要素より右側にあった要素の「古いアドレス」を使い続ける。
erase では要素がシフトします。必要なオブジェクトが「論理的には」コレクション内に残っていても、物理的には別のアドレスに移っている可能性があります。これはポインタだけでなく、イテレータや参照も壊します。

ミス 4: 「reserve してあるから、アドレスは永遠に安定だ」と考える。
reserve はたしかに引っ越し回数を減らす助けになりますが、要素アドレスを生涯保証に変えてくれるわけではありません。予約した容量を 1 回でも超えれば、やはり引っ越しは起こります。しかも、erase はそもそも reserve では治りません。アドレスが変わる別の原因だからです。

ミス 5: 「小さいデータでバグを試したら問題なかった。だから正しい」と結論する。
小さいサイズでは capacity() が足りていることが多く、push_back は reallocation を起こさず、ポインタも「たまたま壊れません」。しかし実際の入力を与えると、振る舞いは一変します。無効化の話題では特にこれが典型的です。バグはすぐには出ず、いつも出るわけでもありません。

1
タスク
C++ SELF, レベル 41, レッスン 1
ロック未解除
リュックの空き
リュックの空き
1
タスク
C++ SELF, レベル 41, レッスン 1
ロック未解除
プレイリストからの選択
プレイリストからの選択
1
タスク
C++ SELF, レベル 41, レッスン 1
ロック未解除
スパムの除去
スパムの除去
1
タスク
C++ SELF, レベル 41, レッスン 1
ロック未解除
やることリストのコマンド
やることリストのコマンド
コメント
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION