ポインタ
BasisSimple Variable でポインタ型を簡単に紹介した。*T は型 T の値へのポインタ、&x は x へのポインタを返し、p.* でそのポインタが指す値を読み出す。このチェックポイントでは、それがメモリ上でどういう意味を持つかを深掘りし、ポインタが単に便利というだけでなく、唯一の解決策になる場面を明らかにする。
ポインタはメモリアドレスだ
Memory Layout で、RAM は連続したバイト列であり、それぞれのバイトが固有の数値アドレス(address)を持つと学んだ。ポインタ(pointer)は、そのアドレスのひとつを格納する変数にすぎない。データを直接保持するのではなく、「そっちを見ろ」と告げる。
var x: i32 = 42;
const p: *i32 = &x; // p は x のアドレスを持つ(例: 0x7ffe_dc8a_1234)
std.debug.print("address: {}\n", .{p}); // アドレスを数値として表示
std.debug.print("value: {}\n", .{p.*}); // p を参照解除 → 42 を表示
p.* と書くと、CPU は「p に格納されたアドレスに行き、そこから i32 を読め」と解釈する。ミュータブル(mutable)なポインタはそこへの書き込みも可能だ:
var y: i32 = 10;
const q: *i32 = &y;
q.* = 99; // q に格納されたアドレスへ 99 を書き込む
std.debug.print("{}\n", .{y}); // 99 を表示
ポインタを通じて読むだけで変更したくない場合は *const T を使う — これは *T の読み取り専用版だ。
ポインタはすべて同じサイズ
初学者がよく驚く事実がある。ポインタは指す型に関わらず、64 ビットマシンでは常に 8 バイトを占める。
std.debug.print("{}\n", .{@sizeOf(*u8)}); // 8
std.debug.print("{}\n", .{@sizeOf(*i32)}); // 8
std.debug.print("{}\n", .{@sizeOf(*f64)}); // 8
std.debug.print("{}\n", .{@sizeOf(*[100]i32)}); // やはり 8
型はコンパイラに、ターゲットアドレスのバイトをどう解釈するかを伝えるものであり、ポインタ自体のサイズには影響しない。ポインタの本質は 64 ビット整数でアドレスを保持するだけ — 常に 8 バイトだ。この事実が次の節で重要になる。
再帰的構造体にポインタが必要な理由
整数ノードのチェーンをモデル化したいとしよう。各ノードは次のノードへのリンクを持てる。自然な最初の試みとして、次のノードを構造体に直接埋め込む方法がある:
const Node = struct {
value: i32,
next: Node, // ❌ コンパイルエラー: 構造体 'Node' が自分自身に依存している
};
コンパイラは Node が何バイトを占めるかを、メモリを確保する前に知る必要がある。しかし Node は別の Node を含み、それもまた Node を含み……と終わりなく続くため、サイズが無限大になる。Zig はこれをコンパイルしない。他のすべてのコンパイル言語も同様だ。
解決策は、今学んだ事実から直接導ける。ポインタは指す型に関わらず常に 8 バイトだ。ノード自体を埋め込む代わりに、次のノードへのポインタを格納すればよい:
const Node = struct {
value: i32,
next: ?*Node, // 次の Node へのポインタ、または null
};
?*Node の ? はポインタをオプショナル(optional)にする。有効なアドレスか null かのどちらかを保持できる。こうすればコンパイラはサイズを一発で計算できる:
i32→ 4 バイト?*Node→ 8 バイト(Zig はnullをゼロアドレスで表現するため、余分なバイトは不要)- 合計: 12 バイト(アライメントのパディングを除く)
null はチェーンの末尾ノードを示す — 次がないため、その next フィールドは null だ。
スタック上に 3 つのノードを持つ完全な動作例を示す:
const std = @import("std");
const Node = struct {
value: i32,
next: ?*Node,
};
pub fn main() void {
var third = Node{ .value = 30, .next = null };
var second = Node{ .value = 20, .next = &third };
var first = Node{ .value = 10, .next = &second };
var current: ?*Node = &first;
while (current) |node| {
std.debug.print("{}\n", .{node.value});
current = node.next;
}
// 表示: 10 20 30
}
while (current) |node| という構文はオプショナルをアンラップする。current が非 null であれば node にその中の *Node ポインタが束縛されてループ本体が実行される。current が null になるとループが終わる。
自分自身をポインタ経由で参照する構造体を再帰的データ構造(recursive data structure)と呼ぶ。上の Node チェーンは、連結リスト(linked list)の背後にあるパターンそのものだ。木(tree)やグラフ(graph)も同じ考え方で構築される — すべての自己参照エッジがポインタになる。
スタック割り当てとその限界
上の例のノードはローカル変数なので、スタック(stack)上に存在する。ノードがちょうど 3 つだとコンパイル時にわかっていれば、それで問題ない。
しかし、ノードの数がユーザーの入力によって決まる場合はどうだろう。関数のスタックフレーム(stack frame)はコンパイル時にサイズが決まる — プログラムの実行時にしかわからない数のアイテムのためにスペースを確保する方法はない。長さがユーザー入力やファイルから来る配列でも同じ問題が起きる。コンパイラは持っていないサイズを要求される。
スタックが使えないときは、実行時にヒープ(heap)からメモリを要求する。ヒープ割り当ては、新しく確保したブロックへのポインタ(あるいは Zig のイディオマティックなスタイルでは、ポインタと長さを束ねたスライス(slice))を返す。Allocate Heap Memory チェックポイントで、その要求の仕方と、使い終わったメモリの返却方法を詳しく扱う。
まとめ
- ポインタ(
*T)は型Tの値のメモリアドレスを格納する。 &variableでポインタを作成し、pointer.*で指す値を読み書きする。- 読み取り専用ポインタには
*const T、ミュータブルなポインタには*Tを使う。 - 64 ビットマシンでは、指す型に関わらずすべてのポインタは 8 バイトだ。
- 自分自身を直接埋め込む構造体はサイズが無限大になりコンパイルできない。代わりにポインタ(
*Self)を格納する。 - オプショナルポインタ(
?*T)はnullにもなれる。「ここには何もない」を表現するのに最適だ。 - ポインタを通じて自分自身を参照する構造体を再帰的データ構造と呼ぶ — 連結リスト、木、グラフはすべてこの方法で構築される。
- ヒープ割り当て(Allocate Heap Memory で扱う)は常に確保したメモリへのポインタまたはスライスを返す。