📚 背景知識(読んでから問題へ)
Phase 3もここまで8つのテーマ(並行処理・チャンネル・Send/Sync・async/await・tokio・async trait)を通して、Rustが「借用チェッカーという静的解析器に、メモリ安全性の証明を肩代わりさせる」言語であることを見てきました。しかし借用チェッカーには限界があります。実際には安全なのに、コンパイラには安全だと証明できないパターンが存在するのです。
代表例は双方向連結リストです。ノードAがノードBを指し、ノードBもノードAを指す構造は、所有権が「木構造(1つの親から辿れる)」であることを前提にした借用チェッカーの解析モデルとは根本的に相性が悪く、安全な参照だけでは表現できません。同様に、Vec<T>自身の内部実装(ヒープ上の連続領域をメモリアロケータから直接確保し、要素を手動で配置する)も、Rustの安全な言語機能だけでは書けません。Vecは「安全なコードで使うために、安全でないコードで実装されている」代表例です。
ここでunsafeキーワードが登場します。重要なのは、unsafeは「借用チェッカーの電源を切る」機能ではないということです。unsafeブロックの中でも、借用規則やライフタイムの構文チェックは普通に働き続けます。unsafeが実際に解禁するのは、次の5つの操作だけです。
| # | unsafeでのみ許される操作 | 何が危険なのか |
|---|---|---|
| 1 | 生ポインタ(*const T / *mut T)のデリファレンス | ヌル・ダングリング・未初期化・不正アラインメントの可能性をコンパイラが検証できない |
| 2 | unsafe fnの呼び出し | 関数のシグネチャだけでは表現しきれない前提条件(安全性不変条件)を呼び出し側が守る責任を負う |
| 3 | 可変な静的変数(static mut)へのアクセス・変更 | 複数スレッドから同時に読み書きされるとデータ競合になりうる |
| 4 | unsafe traitの実装 | トレイト自体が「実装者が守るべき契約」を持ち、コンパイラはそれを検証できない(例: Send/Syncの手動実装) |
| 5 | unionのフィールドへのアクセス | どのフィールドとして解釈するかはプログラマの責任で、型安全性の外側にある |
今日はこの中でも最も基本的かつ最頻出の生ポインタのデリファレンスを扱います。
なぜ「生成」は安全で「デリファレンス」だけが危険なのか
Rustでは参照から生ポインタを作ること自体は安全な操作です。
let x = 42;
let r: &i32 = &x;
let p: *const i32 = r as *const i32; // 生成は safe
let p2: *const i32 = &raw const x; // Rust 2024 推奨の記法。これも safe
*const i32という「ただのアドレス値」を作るだけなら、メモリ上のどこも壊しようがないからです。危険なのは、その値をメモリの中身として読み書きしようとする瞬間です。生ポインタには、参照(&T/&mut T)が持つ次の保証が一切ありません。
- 常にnullでない
- 常に有効なメモリを指している(ダングリングでない)
- 常に型
Tとして正しくアラインメントされている - 常に初期化済みの値を指している
- 借用規則(同時に1つの
&mutか、複数の&のどちらか)に従っている
i32として読め」と命令するのがデリファレンスであり、ここでコンパイラは「あなたが上の5条件をすべて手動で確認した」ことを人間に委ねます。これがunsafeブロックの正体です。unsafeはコンパイラへの「信じてくれ」という宣言ではなく、開発者自身が「借用チェッカーの代わりに、自分の頭でこの5条件を証明した」という表明なのです。📝 問題
以下の4つに答えてください。
- 概念: 「
unsafeブロックを使うと借用チェッカーが無効になる」という理解は誤りです。何が正しい理解か、上記の5つの操作のリストを踏まえて1〜2文で説明してください。 - 実装:
Vec<i32>の中身を、イテレータや添字アクセス(v[i])を一切使わず、生ポインタと.add()によるポインタ演算だけで合計する関数unsafe_sum(v: &[i32]) -> i32を実装してください。as_ptr()で先頭アドレスを取得し、ptr.add(i)でi番目の要素のアドレスを計算し、*でデリファレンスして値を取り出してください。cargo testで、通常の.iter().sum()と一致することを検証するテストも書いてください。 - デバッグ: 次のコードは意図的にコンパイルエラーになります。どのエラーが出るか予想し、理由を説明してください。
fn read_first(v: &[i32]) -> i32 { let p = v.as_ptr(); *p // unsafeブロックの外でデリファレンスしている } - 安全性不変条件: (2)で書いた
unsafeブロックの直前に、なぜこのデリファレンスが安全だと言えるのかを説明する// SAFETY:コメントを書いてください(iが範囲内であること・vが生きている間であること、など何を根拠にしているかを具体的に)。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
&[i32]のスライスは、内部的には「先頭アドレス(ポインタ)」と「長さ」の組です。as_ptr()はこの先頭アドレスだけを*const i32として取り出すメソッドで、これ自体はメモリの読み書きを一切行わないためunsafeではありません。危険が始まるのは、そのアドレスに対して「型Tのサイズ分だけ加算する」演算と「その場所を読む」演算を行うときです。
ヒント2 — アプローチ
*const T型には.add(count)というメソッドがあり、count * size_of::<T>()バイトだけポインタを進めた新しい*const Tを返します(これ自体もポインタの生成なのでsafe)。返ってきたポインタを*でデリファレンスする箇所だけをunsafe { }で囲みます。ループの中でfor i in 0..v.len()のように添字iを回し、unsafe { *p.add(i) }を毎回呼び出す形になります。
ヒント3 — コード骨格
fn unsafe_sum(v: &[i32]) -> i32 {
let p = v.as_ptr();
let mut total = 0;
for i in 0..v.len() {
// SAFETY: ここに(4)の説明を書く
total += unsafe { *p.add(i) };
}
total
}
✅ 模範解答
// unsafe_sum.rs
// cargo run または cargo test でそのまま実行できます。
// Cargo.toml に追加のクレート依存は不要です(標準ライブラリのみ)。
/// スライスの合計を、生ポインタとポインタ演算だけで計算する。
/// イテレータ版の `v.iter().sum::()` と等価な結果を返す。
fn unsafe_sum(v: &[i32]) -> i32 {
// as_ptr() はスライスの先頭要素へのアドレスを *const i32 として取得するだけで、
// メモリを読みも書きもしないため safe な操作。
let p: *const i32 = v.as_ptr();
let mut total: i32 = 0;
for i in 0..v.len() {
// SAFETY:
// 1. `p` は `v`(引数の借用)由来のアドレスであり、`v` はこの関数の呼び出し中
// 生存しているため、`p` はダングリングポインタではない。
// 2. `i` は `0..v.len()` の範囲でしか動かないため、`p.add(i)` が指す
// アドレスは常に `v` が確保済みの領域の内側にある(境界外を指さない)。
// 3. `p` は `&[i32]`(`v`)から得たアドレスであり、`i32` として正しく
// アラインメントされ、かつ初期化済みの値を指している
// (スライスは未初期化要素を持てないため)。
// 4. `v` は `&[i32]`(共有参照)であり、このループ中に他の場所から
// ミュータブルに書き換えられることはない(借用規則により保証)。
total += unsafe { *p.add(i) };
}
total
}
fn main() {
let data = vec![1, 2, 3, 4, 5, 100, -20];
println!("unsafe_sum = {}", unsafe_sum(&data));
println!("iterator sum = {}", data.iter().sum::());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_iterator_sum_on_typical_input() {
let data = vec![1, 2, 3, 4, 5, 100, -20];
assert_eq!(unsafe_sum(&data), data.iter().sum::());
}
#[test]
fn matches_iterator_sum_on_empty_slice() {
let data: Vec = vec![];
assert_eq!(unsafe_sum(&data), data.iter().sum::());
assert_eq!(unsafe_sum(&data), 0);
}
#[test]
fn matches_iterator_sum_on_single_element() {
let data = vec![42];
assert_eq!(unsafe_sum(&data), data.iter().sum::());
}
#[test]
fn matches_iterator_sum_with_negative_values() {
let data = vec![-1, -2, -3, 10];
assert_eq!(unsafe_sum(&data), data.iter().sum::());
}
}
(1) の回答
unsafeブロックの中でも、借用規則・型検査・パターンマッチの網羅性チェックなど、通常の静的解析はすべてそのまま働き続ける。unsafeが解禁するのは「生ポインタのデリファレンス」「unsafe fnの呼び出し」「static mutへのアクセス」「unsafe traitの実装」「unionフィールドへのアクセス」の5つの操作だけであり、それ以外の場所では普段どおり借用チェッカーにエラーを出される。
(3) の回答
下記「コンパイルエラーが出た場合」の節にあるとおりE0133が出ます。生ポインタのデリファレンスは上記の5操作の1つ目そのものなので、unsafeで明示的に囲まれていない限りコンパイラはコンパイルを拒否します。修正は*pをunsafe { *p }に置き換えるだけですが、置き換えた瞬間に「本当にpが有効なアドレスを指しているか」を証明する責任が呼び出し元からその場に移ることに注意が必要です。
(4) の回答
模範解答中の// SAFETY:コメント(4項目)を参照。
🪜 Step-by-Step 解説
&[i32]は「ファットポインタ」と呼ばれ、実行時には(ポインタ, 長さ)という2ワードのペアとしてメモリ上に存在します。as_ptr()はこのペアから前半のポインタ部分だけを取り出す操作で、長さの情報はそのメソッドの戻り値には残りません。だからこそ、後続のループで0..v.len()という元のvから取得した長さを使って境界を管理する必要があります。生ポインタ自身は「自分がどこまで有効か」を一切知らないからです。
p.add(i)はpのアドレスにi * size_of::<i32>()バイトを加算した新しいポインタを返します。i32は4バイトなので、p.add(1)はpより4バイト先を指しますが、これはRustが型情報を保持したまま演算してくれるからです(C言語のポインタ演算と同じ設計)。この加算自体はメモリを触らないため、ここまではsafeな操作です。
unsafe { *p.add(i) }のように、実際に危険な操作(デリファレンス)だけをunsafeブロックで囲みます。ブロックの範囲を必要最小限にすることが実務での鉄則です。範囲を広げすぎると、「このunsafeブロックのどの行が本当に危険なのか」をレビュアーが特定しづらくなります。
unsafeブロックを書いたら、その直前に何を根拠に安全だと判断したかを// SAFETY:コメントとして残します。これはRustコミュニティの強い慣習(Rust標準ライブラリやclippyのundocumented_unsafe_blocks lintでも推奨)であり、次回(Day 052)で扱う「安全性不変条件の証明」の土台になります。
💡 設計思想・なぜこう書くのか
Rustの設計思想は「デフォルトで安全、必要な箇所だけ明示的に危険を引き受ける」という非対称性にあります。C/C++はすべてのポインタ操作がデフォルトで無検証(=常に危険)であるのに対し、Rustは安全な参照をデフォルトにし、生ポインタという「檻の外」の操作をunsafeという明示的なキーワードで囲むことを強制します。これにより、コードベース全体をgrep unsafeするだけで「メモリ安全性のバグが起こりうる場所」を機械的に列挙できます。これは「安全性の監査対象を最小化する」という、Rustが目指すプロフェッショナルな運用のための設計そのものです。
また、unsafeブロックが「借用チェッカーを止める」のではなく「5つの操作だけを追加で許可する」という設計になっている点も重要です。もしunsafeが借用チェッカー全体を無効化する機能だったなら、unsafeブロックの中では型安全性さえ怪しくなり、C言語と大差なくなってしまいます。Rustは「本当にコンパイラが検証できない5点だけ」を切り出すことで、unsafeコードの中でもその他すべての安全性保証を維持したまま、最小限の箇所だけ人間の証明に委ねています。
🛑 コンパイルエラーが出た場合
問題(3)のコードをそのままビルドすると次のエラーが出ます。
--> src/main.rs:3:5
|
3 | *p
| ^^ dereference of raw pointer
|
= note: raw pointers may be null, dangling or unaligned; they can violate
aliasing rules and cause data races: all of these are undefined
behavior
note:の行がそのまま「生ポインタが持たない5つの保証」の要約になっている点に注目してください。コンパイラはここで「危険だから直せ」としか言いませんが、その危険の中身(null・ダングリング・未アラインメント・エイリアシング違反・データ競合)をunsafeを書く前に自分の頭で1つずつ潰しておくのが、このスキルで身につけるべき態度です。*pをunsafe { *p }に置き換えるだけですが、置き換えた瞬間に「本当にpが有効なアドレスを指しているか」を証明する責任が呼び出し元からその場に移ることに注意してください。🌐 他言語との比較
| 観点 | C言語 | Go | Rust |
|---|---|---|---|
| 生ポインタ操作のデフォルト状態 | 常に許可(unsafeという概念自体が無い) | unsafeパッケージ経由で可能だが、通常コードでは事実上使わない設計 | 安全な参照がデフォルト。生ポインタ操作はunsafeブロックでのみ許可 |
| 危険な箇所の可視性 | ソースコード上ではポインタ操作と安全な操作が区別されない | import "unsafe"でファイル単位では分かるが行単位では強制されない | unsafeキーワードにより行・ブロック単位で危険地帯が構文的に強制的に囲まれる |
| 境界チェック | 呼び出し側が手動で管理(言語機能としての保証なし) | スライスは実行時に境界チェックされる(安全側がデフォルト) | 安全なスライスアクセスは実行時境界チェックあり。生ポインタ経由だけがチェックを迂回できる |
| 監査のしやすさ | プロジェクト全体がポインタ操作の対象になりうるため困難 | 該当ファイルの絞り込みは容易 | grep -rn "unsafe" --include=*.rsだけでメモリ安全性リスクのある箇所を機械的に完全列挙できる |
C言語では「安全なポインタ操作」と「危険なポインタ操作」の区別が言語仕様上存在しないため、レビュアーはコード全体を疑ってかからなければなりません。Rustではunsafeという1つのキーワードが「ここから先だけ人間が保証する」という境界線を構文的に引くため、監査対象を機械的に絞り込めます。これはcargo geigerのような「プロジェクト内のunsafe使用量を数える」ツールが実際に存在し、依存クレートの信頼性評価に使われている実務的な理由でもあります。
🏆 実務での使いどころ
- 標準ライブラリのコレクション実装:
Vec<T>・String・HashMapはすべて内部で生ポインタとメモリアロケータAPI(std::alloc)を直接操作するunsafeコードで実装されている。ユーザーが安全なAPIだけでこれらを使えるのは、実装者が境界の外側で安全性を保証しているから - FFI(外部関数インターフェース): C言語のライブラリを呼び出す際、Cの関数が返すポインタは
*mut Tとして受け取らざるを得ない。Day 111(FFI)で扱うが、今日の生ポインタの基礎知識がそのまま前提になる - パフォーマンスクリティカルなパーサ・シリアライザ:
serdeや高速なバイナリパーサの内部実装では、境界チェックのオーバーヘッドを避けるために意図的に生ポインタでバッファを走査するコードが書かれることがある(ただし安全なAPIでラップして公開する) - カスタムアロケータ・データ構造の自作: 双方向連結リストやスキップリスト、独自のロックフリーキュー(Phase 3後半・Phase 4で扱うテーマ)は、生ポインタ操作なしには実装できない
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
unsafeブロックの中では借用チェッカーが完全に無効になると思い込む | 「unsafe」という単語の字面から「何でもあり」を連想してしまう | unsafeは5つの特定操作を追加で許可するだけで、それ以外の型検査・借用規則はunsafeブロックの中でも通常どおり適用される |
unsafeブロックを可能な限り広く取ってしまう(関数全体をunsafe fnにする等) | 「危険な操作が1つでもあるなら念のため全部囲んでおこう」という安全志向が裏目に出る | unsafeブロックは実際に5操作のいずれかを行う最小の範囲に絞るのが実務の鉄則。範囲が広いとレビュー時にどこが本当に危険かを特定できなくなる |
ポインタ演算の.add(i)をバイトオフセットだと誤解し、i * 4のように手動でバイト計算してから渡してしまう | C言語のvoid*経験や、他言語での「オフセット=バイト数」という直感を引きずる | Rustの.add(count)は型Tのサイズを自動で乗算した「要素単位」のオフセット。p.add(1)は常に「次の要素」を指す |
// SAFETY:コメントを「一応書いておくもの」程度に軽視し、具体的な根拠を書かず「大丈夫なはず」とだけ書く | 借用チェッカーがある言語に慣れていると、コメントを検証の代替物として真剣に扱う習慣がない | // SAFETY:コメントはコンパイラが検証しない証明を人間が肩代わりする唯一の記録であり、根拠が具体的でなければ将来のレビュアー(未来の自分を含む)が安全性を再検証できない |
🚀 次のステップ
- 発展:
unsafe_sumを、範囲外のi(例えばv.len() + 1)を意図的に渡すよう改造し、cargo miri run(Miriという未定義動作検出ツール)でどのようなエラーが報告されるかを確認する。Miriは今日紹介した「5条件が満たされているか」を実行時に検証してくれる公式ツールであり、次回以降の発展課題として残す(本日はMiriの導入手順の解説までは範囲に含めていない。意図的な絞り込み) - 次回予告: Phase 3の次テーマunsafeの正当化(安全性不変条件の証明)に進む。今日書いた
// SAFETY:コメントをさらに厳密化し、「関数の呼び出し側が満たすべき前提条件」をunsafe fnのドキュメントコメントとして明文化する書き方を学ぶ