概要
Zigを使う際に、非常に序盤の疑問として、この4つの使い分けがあるかと思います。
var arr: [4]u8 = .{1, 2, 3, 4}; // 配列
var ptr: *u8 = &x; // ポインタ
var many: [*]u8 = some_ptr; // many-timeポインタ
var slice: []u8 = &arr; // スライス
C/C++やRustを経験しているひとには、この記述の微妙な違いに戸惑うかもしれません。
ここでは
- 4つの型が何を保証するのか
- なぜ見た目が似ているのか
- 他言語との比較
- 実用的な使い分け
を解説していこうと思います。
結論:メモリを見る方法の違い
Zigでは、同じメモリを、どこまで安全に・どこまで正確に知っているかを型で表します。
4つの型の違い
| 型 | 記述 | 何を保証する? | 長さ | 所有 | 境界チェック |
|---|---|---|---|---|---|
| 配列 | [N]T |
N個のTがここにある | compile-time | ✅ | ✅ |
| ポインタ | *T |
1個のTがある | 1 | ❌ | - |
| many-timeポインタ | [*]T |
Tが連続している | 不明 | ❌ | ❌ |
| スライス | []T |
N個のTが連続している | runtime | ❌ | ✅ |
| つまり、 |
- 配列:すべてを知っている(コンパイル時)
- ポインタ:単一要素だけを知っている
- many-timeポインタ:何も知らない
- スライス:長さを知っている(実行時)
配列 [N]T
var arr: [4]u8 = .{1, 2, 3, 4};
- メモリを所有する唯一の型
- サイズ
Nはコンパイル時定数 - 値型(代入すると全体がコピーされる)
- スタックに確保される
実例
const std = @import("std");
pub fn main() void {
var arr: [4]u8 = .{1, 2, 3, 4};
// コンパイル時にサイズが確定
std.debug.print("Length: {}\n", .{arr.len}); // 4
// 境界チェックあり
std.debug.print("{}\n", .{arr[0]}); // 1
// arr[10] = 0; // コンパイルエラー
// 値型なのでコピーされる
var arr2 = arr;
arr2[0] = 99;
std.debug.print("{} {}\n", .{arr[0], arr2[0]}); // 1 99
}
ポインタ *T
var x: u8 = 10;
var ptr: *u8 = &x;
- ちょうど1個の
Tがあることを保証 -
ptr.*でデリファレンス(1要素アクセス) - 配列のような添字アクセスはできない
実例
pub fn main() void {
var x: u8 = 10;
var ptr: *u8 = &x;
// デリファレンスで値を取得
std.debug.print("{}\n", .{ptr.*}); // 10
// 値を変更
ptr.* = 20;
std.debug.print("{}\n", .{x}); // 20
// これはコンパイルエラー
// _ = ptr[0]; // 添字アクセスは不可
}
many-timeポインタ [*]T
var buf: [*]u8 = some_ptr;
buf[10] = 3; // 範囲外でも通る(危険)
- 連続メモリであることだけを保証
- 長さを知らない
- Cの
T*とほぼ同等 - 境界チェックなし
実例
pub fn main() void {
var arr: [10]u8 = undefined;
var many: [*]u8 = &arr;
// 添字アクセス可能(境界チェックなし)
many[0] = 1;
many[5] = 2;
// これも通ってしまう(未定義動作)
many[100] = 3; // 範囲外だがコンパイルエラーにならない
// C FFIで使う
const c_str: [*:0]const u8 = "hello";
}
C FFIでの使用例
// Cの関数シグネチャ
// void* memcpy(void* dest, const void* src, size_t n);
extern fn memcpy(dest: [*]u8, src: [*]const u8, n: usize) [*]u8;
pub fn copyBytes(dest: []u8, src: []const u8) void {
_ = memcpy(dest.ptr, src.ptr, @min(dest.len, src.len));
}
スライス []T
fn sum(xs: []const u8) u32 {
var s: u32 = 0;
for (xs) |x| s += x;
return s;
}
-
pointer + lengthの組み合わせ - 境界チェックあり
- メモリは所有しない(ビュー)
- 配列・多項目ポインタから作れる
実際の構造
// スライスの内部構造(概念的)
struct {
ptr: [*]T,
len: usize,
}
実例
pub fn main() void {
var arr: [4]u8 = .{1, 2, 3, 4};
// 配列からスライスを作成
var slice: []u8 = &arr;
// 長さを知っている
std.debug.print("Length: {}\n", .{slice.len}); // 4
// 境界チェックあり
std.debug.print("{}\n", .{slice[0]}); // 1
// slice[10] = 0; // panic(実行時エラー)
// 部分スライス
var sub = slice[1..3];
std.debug.print("{any}\n", .{sub}); // [2, 3]
// イテレート
for (slice) |item| {
std.debug.print("{} ", .{item});
}
}
なぜ似てるのか
Cの問題
Cでは、ポインタが何を指しているか曖昧になることもあります。
void process(int* p);
Zigの解決
Zigでは、意図を型で表現します。
fn process1(p: *i32) void // 1個のi32
fn process2(p: [*]i32) void // 長さ不明の配列
fn process3(p: []i32) void // 長さ既知の配列
fn process4(p: *[4]i32) void // 4個固定の配列
型を見ることで設計意図を見えるようにしています。
見た目が似ている理由
arr[i]
many[i]
slice[i]
全部同じ[i]アクセスに見えますね。
| 式 | 実際に起きていること | 安全性 |
|---|---|---|
arr[i] |
コンパイル時に範囲確定 | ✅ |
ptr[i] |
❌ そもそも使えない | - |
many[i] |
生ポインタ計算 | ❌ |
slice[i] |
境界チェック付きアクセス | ✅ |
学習コストは低く、保証するレベルを明確に分けています。
型の変換
配列からの変換
var arr: [4]u8 = .{1, 2, 3, 4};
// 配列 → スライス
var slice: []u8 = &arr;
// 配列 → 配列ポインタ
var ptr: *[4]u8 = &arr;
// 配列 → many-timeポインタ
var many: [*]u8 = &arr;
スライスからの変換
var slice: []u8 = &arr;
// スライス → many-timeポインタ
var many: [*]u8 = slice.ptr;
// スライスの長さ
var len: usize = slice.len;
部分スライス
var arr: [10]u8 = .{0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
var slice: []u8 = &arr;
// 範囲指定
var sub1 = slice[2..5]; // [2, 3, 4]
var sub2 = slice[5..]; // [5, 6, 7, 8, 9]
var sub3 = slice[0..3]; // [0, 1, 2]
## 他言語との比較
C/C++ との比較
| Zig | C/C++ | 違い |
|---|---|---|
[N]T |
T[N] |
Zigは値型、Cは配列名がポインタに退化 |
*T |
T* |
Zigは1個を保証、Cは曖昧 |
[*]T |
T* |
ほぼ同じ(危険) |
[]T |
std::span<T> (C++20) |
似ている |
C/C++の問題例:
void func(int* arr) {
// arrは配列?ポインタ?
// 長さは?
// sizeof(arr)は常にポインタのサイズ
}
Zigの明確さ:
fn func(arr: []i32) void {
// 配列であることが明確
// arr.lenで長さが分かる
}
Rust との比較
| Zig | Rust | 概念 |
|---|---|---|
[N]T |
[T; N] |
所有された配列 |
*T |
&T |
不変参照(1個) |
*T (mut) |
&mut T |
可変参照(1個) |
[]T |
&[T] |
スライス(不変) |
[]T (mut) |
&mut [T] |
スライス(可変) |
[*]T |
*const T |
生ポインタ(unsafe) |
Zig的な理解
配列 → 所有
スライス → 安全な借用
多項目ポインタ → unsafe借用
ポインタ → 単一要素参照
Python との比較
Pythonはすべて動的なので、Zigの静的な型システムとは根本的に異なります。
実用的な実装
関数の引数
// スライスを使う
fn sum(values: []const i32) i32 {
var total: i32 = 0;
for (values) |v| total += v;
return total;
}
// 配列を直接渡す(サイズ固定)
fn sum_fixed(values: [10]i32) i32 { ... }
// many-timeポインタ(C FFI以外では避ける)
fn sum_unsafe(values: [*]i32, len: usize) i32 { ... }
- スライスは柔軟(任意の長さ)
- 境界チェックがある(安全)
- 長さ情報を含む
ローカル変数
pub fn main() void {
// サイズが固定なら配列
var buffer: [1024]u8 = undefined;
// 可変長ならスライス
var slice: []u8 = buffer[0..512];
// ローカルで多項目ポインタは不要
}
C FFI
// C関数: int read(int fd, void *buf, size_t count);
extern fn read(fd: i32, buf: [*]u8, count: usize) isize;
// Zigラッパー(安全化)
fn readSafe(fd: i32, buf: []u8) !usize {
const n = read(fd, buf.ptr, buf.len);
if (n < 0) return error.ReadFailed;
return @intCast(n);
}
C FFIでは多項目ポインタを使い、Zigの公開APIではスライスに
センチネル終端
// C文字列(ヌル終端)
const c_str: [*:0]const u8 = "hello";
// Zig文字列(長さ既知)
const zig_str: []const u8 = "hello";
// 変換
pub fn toCString(s: []const u8) ![*:0]u8 {
var buf = try allocator.alloc(u8, s.len + 1);
@memcpy(buf[0..s.len], s);
buf[s.len] = 0;
return buf[0.. :0];
}
まとめ
Zigの配列・ポインタ・many-timeポインタ・スライスが似ているのは、同じメモリモデルを段階的に制限しているといえるからです。
Zigにおいては型が何を保証するかという観点が大切で、非常に理にかなっていることが分かります。
使い分けの原則
所有する → 配列 [N]T
安全に借用 → スライス []T
C FFI → many-timeポインタ [*]T
単一要素参照 → ポインタ *T
他言語経験者への対応表
| 概念 | C/C++ | Rust | Zig |
|---|---|---|---|
| 所有配列 | T[N] |
[T; N] |
[N]T |
| 配列ビュー | std::span<T> |
&[T] |
[]T |
| 生ポインタ | T* |
*const T |
[*]T |
| 単一参照 | T* |
&T |
*T |