二つの変数が同じデータを指すと、一方からの変更が他方にも見えます。逆に、データが消えた後もその場所を指し続けると、不正なアクセスにつながります。メモリの理解では「どこにあるか」「誰から参照されているか」「いつまで有効か」の三つを分けて追います。
アドレスとポインタ
メモリはアドレスで場所を指定して読み書きします。Cのポインタは、特定の型のオブジェクトなどを指す値として扱われます。アドレスを得る&と、その場所の値へアクセスする*は別の操作です。ポインタ変数自体も値を保存する場所を持つため、「ポインタの値」と「指している先の値」を混同しないようにします。
int x = 10;
int *p = &x;
int *q = p;
*p = 20;
// x == 20、*q == 20pとqは同じxを指すため、*pへの代入は*qから見ても分かります。q=pはxの複製ではありません。このように複数の経路から同じデータへアクセスできることをエイリアシングと呼びます。変更する関数へポインタを渡す場合、どの呼出し元の状態に影響するかまで考えます。
コピーと参照の共有
高水準言語でも参照の共有は重要です。Pythonのa=[1,2]、b=aでは同じリストを共有します。b=a.copy()なら外側のリストは別になりますが、要素がさらにリストなら、その内側の参照は共有されます。浅いコピーと深いコピーの違いは、どの深さまで別のオブジェクトを作るかという違いです。
a = [[1], [2]]
b = a.copy()
b[0].append(9)
print(a) # [[1, 9], [2]]
b.append([3])
print(len(a), len(b)) # 2 3最初の変更は共有する内側リストへ届きます。二つ目はbの外側リストだけを変更します。「コピーしたのに変わった」と感じたら、変数名だけでなく、外側のコンテナと各要素の参照を分けて書き出すと原因が見えます。すべて深いコピーにする方法は、時間とメモリを余計に使う場合があります。
スタックとヒープは寿命と結び付けて考える
典型的なネイティブ実装では、関数呼出しに伴う情報や一部のローカル変数をスタックに置き、必要に応じて確保する領域をヒープで管理します。スタックは後から積んだ呼出しから戻る構造と相性がよく、ヒープは呼出しの入れ子とは異なる寿命のデータにも使えます。変数の名前がローカルであることだけで、実際の保存場所が必ず決まるわけではありません。
例えば、最適化で値がレジスタに置かれたり、割当てが省略されたりすることがあります。言語が保証する寿命と、処理系が選ぶ物理的な配置を区別します。「ヒープは常に遅い」「ローカル変数は必ずスタック」といった言い切りでは、最適化されたコードや高水準言語を説明できません。
有効なアドレスでも寿命を過ぎれば使えない
int *bad(void) {
int x = 42;
return &x; // 関数の終了でxの寿命が終わる
}この関数から戻った後、呼出し元で返されたポインタを間接参照してはいけません。実行した直後は42が読めるように見えても、正しい動作の保証にはなりません。寿命が終わったデータを指すダングリングポインタです。動的に確保した領域も、解放した後に使えばuse-after-freeとなります。
対策は言語ごとに異なります。手動管理では確保と解放の責任を決め、所有権を明確にします。ガベージコレクションは到達不能になったメモリを回収し、Rustは所有権と借用の規則で多くの不正な参照をコンパイル時に防ぎます。どの方式でも、開いたファイルや通信接続などの資源管理まで同じ仕組みで自動解決するとは限りません。
理解を確かめる
pとqが同じxを指しているとき、q=null相当の値に変えるとxは書き換わるか。ポインタの結び付きを変える操作なので、それだけでxは変わりません。
浅いコピーをした入れ子リストの内側を書き換えると、元にも影響する理由は何か。外側は複製されても、内側のオブジェクトへの参照を共有しているためです。
寿命が終わった場所から期待した値が一度読めたら安全か。安全とは言えません。たまたま以前の内容が残っていることと、言語がアクセスを保証することは別です。
この単元の概念:エイリアシング ライフタイム ポインタ
続けて読む:仮想記憶・ページテーブル・TLB