DIVE

Part 2: アセンブリ・実行モデル

第 6 章

ポインタの真実 — それはただの「住所」だった

「ポインタで挫折した」——プログラミング学習で最もよく聞く言葉の一つだ。だが、前章までを読んだあなたには、もうポインタを恐れる理由がない。

ポインタの正体は、拍子抜けするほど単純だ。メモリ上の「住所(アドレス)」を表す、ただの数値。それだけ。この章で、その一点を徹底的に解体する。


メモリは巨大な番地付きロッカー

第5章で見たメモリ空間を、別の角度から見よう。メモリは、1バイトごとに連番の住所(アドレス)が振られた、巨大なロッカーの列だ。

アドレス     内容
0x1000    [  5  ]   ← 変数 x はここに住んでいる
0x1004    [  0  ]
0x1008    [ 'A' ]   ← 変数 c はここ
0x100C    [ ... ]

変数 int x = 5; と書くと、コンパイラはメモリのどこか(例:0x1000)に4バイトの区画を割り当て、そこに 5 を書き込む。変数名 x は、人間のための「住所のあだ名」に過ぎない。CPUにとっては、すべてアドレスで管理されている。


ポインタ=住所を格納する変数

ポインタとは、「別の変数の住所」を値として持つ変数だ。数値 5 を持つ変数があるように、住所 0x1000 を持つ変数があってもいい。それがポインタだ。

int x = 5;          // x は 0x1000 番地に住み、値 5 を持つ
int *p = &x;        // p は「x の住所(0x1000)」を持つポインタ

// &  … アドレス演算子:「〜の住所」
// *  … 間接参照演算子:「その住所の中身」

図で見ると関係は明快だ:

   p                      x
┌────────┐            ┌────────┐
│ 0x1000 │ ────────▶  │   5    │
└────────┘            └────────┘
アドレス0x2000        アドレス0x1000
(pが住む場所)         (xが住む場所)

p     の中身は 0x1000   (x の住所)
*p    の中身は 5        (その住所を辿った先=x の値)
&x    は 0x1000         (x の住所)

つまり:

「*p」は「そのアドレスに行って、中身を見てこい」という命令だ。これを間接参照(デリファレンス)と呼ぶ。

#include <stdio.h>

int main(void) {
    int x = 5;
    int *p = &x;

    printf("x  = %d\n",  x);    // 5
    printf("&x = %p\n",  &x);   // 0x... (xの住所)
    printf("p  = %p\n",  p);    // 0x... (同じ住所)
    printf("*p = %d\n",  *p);   // 5     (住所を辿った中身)

    *p = 99;                    // 「pが指す先」を書き換える
    printf("x  = %d\n",  x);    // 99 ← x自体が変わった!
    return 0;
}

最後がポインタの威力だ。*p = 99 は、p が指すアドレス(= x の住所)の中身を書き換える。だから x 自身が 99 になる。ポインタを通じて、離れた場所の変数を操作できる。


なぜポインタが必要なのか

「わざわざ住所を経由せず、直接変数を使えばいい」と思うかもしれない。だがポインタが不可欠な場面がある。

1. 関数に「変更」を持ち帰らせる

C言語の関数は、引数を値のコピーで受け取る。だから普通は関数内で引数を変えても、呼び出し元には反映されない。

void broken_swap(int a, int b) {   // コピーを受け取るので…
    int t = a; a = b; b = t;       // 入れ替えても呼び出し元は変わらない
}

void swap(int *a, int *b) {        // 住所を受け取れば…
    int t = *a; *a = *b; *b = t;   // 住所の先を直接書き換える → 呼び出し元に反映
}

int main(void) {
    int x = 1, y = 2;
    swap(&x, &y);                  // x と y の住所を渡す
    // x=2, y=1 に入れ替わっている
}

住所を渡せば、関数は「本体そのもの」を操作できる。第5章の「関数はスタックにコピーを積む」を思い出すと、なぜコピーでは変更が届かず、住所なら届くのかが腑に落ちる。

2. 巨大なデータをコピーせず共有する

100万要素の配列を関数に渡すたびにコピーしていたら、時間もメモリも浪費する。先頭の住所1つを渡せば、コピーゼロで共有できる。第5章のヒープに確保した領域も、ポインタ(住所)を通じてやり取りする。


配列の正体もアドレスだった

Cの配列とポインタが「似ている」と感じたことはないだろうか。それは偶然ではない。配列名は、その先頭要素の住所だからだ。

int arr[4] = {10, 20, 30, 40};

// arr は &arr[0](先頭の住所)とほぼ同じ
// arr[i] は *(arr + i) と等価

arr[2] という記法の正体は、「先頭アドレス + 2要素分ずらして、そこの中身を見る」という計算だ。

arr[i]≡∗(arr+i)≡∗(先頭アドレス+i×要素のバイト数)\texttt{arr[i]} \equiv *(\texttt{arr} + i) \equiv *(\text{先頭アドレス} + i \times \text{要素のバイト数})
arr[0]    arr[1]    arr[2]    arr[3]
┌────┐   ┌────┐   ┌────┐   ┌────┐
│ 10 │   │ 20 │   │ 30 │   │ 40 │
└────┘   └────┘   └────┘   └────┘
0x1000   0x1004   0x1008   0x100C
  ▲                 ▲
  arr              arr+2 → *(arr+2) = 30

int は4バイトなので、arr + 2 は住所を 2 × 4 = 8 バイト進める。添字アクセスは、アドレス計算の糖衣構文だったのだ。「配列の添字が0から始まる理由」も、これで分かる。先頭は「先頭から0ずらした場所」だからだ。

文字列も同じで、C言語の文字列は「文字の配列の先頭アドレス」に過ぎない。char *s = "hello"; の s は、h が置かれた住所を持つ。


ヌルポインタと「危険」の正体

ポインタが「難しい・危険」とされるのは、無効な住所を指したまま中身を触ると事故るからだ。

int *p = NULL;    // どこも指していない(住所0)
*p = 5;           // 💥 セグメンテーション違反:無効な住所への書き込み

int *q;           // 初期化していない → ゴミの住所を持つ
*q = 5;           // 💥 どこを壊すか分からない(未定義動作)

これは第5章の「free した後のポインタ」問題とも繋がる。解放済みの領域を指すポインタ(ダングリングポインタ)を触るのも同じ類の事故だ。ポインタ自体は単なる住所。危険なのは「無効な住所を辿る」こと。だからこそ、後発のRustは所有権システムで、GC言語は参照管理で、この事故を防ごうとしている。それらの仕組みも、根っこには「ポインタ=住所」という同じ物理がある。


まとめ — 「おまじない」の消し方

  1. ポインタ=住所を持つ変数:メモリの番地を値とする、ただの数値
  2. & は住所、* は中身:&x で住所を得て、*p で住所の先を辿る(間接参照)
  3. なぜ必要か:関数に変更を届ける/巨大データをコピーせず共有する
  4. 配列=先頭アドレス:arr[i] は *(arr + i)。添字はアドレス計算の糖衣構文
  5. 危険の正体:ポインタは無害。無効な住所を辿ることが事故を生む

ポインタは「難しい概念」ではなく、第5章のメモリ地図を「住所」という視点で見直しただけだった。これでPart 2——人間の言葉が機械語になり、メモリ上で実行される世界——を一巡した。

次のPart 3では、いよいよOS(カーネル)の領域へ。これまで「メモリを自由に使える」前提で話してきたが、実はその裏で、カーネルがメモリとハードウェアを管理し、幻想を作り出している。その舞台裏へ降りていく。


「ポインタを恐れる者は、地図上の住所を恐れているのと同じだ。住所はただの記号。問題は、存在しない家を訪ねてしまうことにある。」