「ポインタで挫折した」——プログラミング学習で最もよく聞く言葉の一つだ。だが、前章までを読んだあなたには、もうポインタを恐れる理由がない。
ポインタの正体は、拍子抜けするほど単純だ。メモリ上の「住所(アドレス)」を表す、ただの数値。それだけ。この章で、その一点を徹底的に解体する。
メモリは巨大な番地付きロッカー
第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 の住所)
つまり:
xは5&xはxの住所(0x1000)pは0x1000(住所を持っている)*pは「pが指す住所の中身」=5
「*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[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言語は参照管理で、この事故を防ごうとしている。それらの仕組みも、根っこには「ポインタ=住所」という同じ物理がある。
まとめ — 「おまじない」の消し方
- ポインタ=住所を持つ変数:メモリの番地を値とする、ただの数値
&は住所、*は中身:&xで住所を得て、*pで住所の先を辿る(間接参照)- なぜ必要か:関数に変更を届ける/巨大データをコピーせず共有する
- 配列=先頭アドレス:
arr[i]は*(arr + i)。添字はアドレス計算の糖衣構文 - 危険の正体:ポインタは無害。無効な住所を辿ることが事故を生む
ポインタは「難しい概念」ではなく、第5章のメモリ地図を「住所」という視点で見直しただけだった。これでPart 2——人間の言葉が機械語になり、メモリ上で実行される世界——を一巡した。
次のPart 3では、いよいよOS(カーネル)の領域へ。これまで「メモリを自由に使える」前提で話してきたが、実はその裏で、カーネルがメモリとハードウェアを管理し、幻想を作り出している。その舞台裏へ降りていく。
「ポインタを恐れる者は、地図上の住所を恐れているのと同じだ。住所はただの記号。問題は、存在しない家を訪ねてしまうことにある。」