Day 052 — unsafe fnの契約を「# Safety」として明文化し、precondition と safety invariant を区別する

2026-09-18 🟣 上級者 / Phase 3 概念理解 unsafeの正当化(安全性不変条件の証明)

📚 背景知識(読んでから問題へ)

Day 051では「生ポインタのデリファレンス」を扱い、unsafe { *p.add(i) }の直前に// SAFETY:コメントを書きました。あのコメントは「この呼び出し箇所で、なぜ安全と言えるか」を呼び出し側自身が証明するものでした。

今日はもう一段階上の話をします。unsafe fnという関数そのものを自分で定義するとき、その関数を使うすべての呼び出し元に対して「これを満たさなければ未定義動作(undefined behavior、以下UB)になる」という契約を宣言する必要があります。これがRust API Guidelinesが定める# Safetyセクションです。

/// # Safety
///
/// 呼び出し元は `i < v.len()` を保証しなければならない。
unsafe fn get_unchecked(v: &[i32], i: usize) -> i32 { /* ... */ }

重要なのは、この2つのコメントは書く人も読む人も役割が違うという点です。

書く場所書く人の役割読む人
// SAFETY:(Day051)unsafeブロックの直前「私はこの1箇所で、既知の前提条件を満たしていることを確認した」という証明未来のレビュアー・自分自身
# Safety(今日)unsafe fnのdocコメント「この関数を呼ぶ全員が満たすべき前提条件」という契約(contract)の宣言この関数を呼ぶすべての将来の呼び出し元

unsafe fnを呼び出すコード自体はunsafe {}ブロックで囲む必要がありますが、それだけではコンパイラは何もチェックしません。unsafe fnの呼び出しが許可される5つのunsafe操作の1つである以上、コンパイラが要求するのは「囲んだ」という構文的な事実だけです。呼び出し元が本当に# Safetyに書かれた前提条件(例: i < v.len())を満たしているかどうかは、人間が# Safetyセクションを読んで、自分の目で確認する以外に検証手段がありません。これが今日のテーマである「安全性不変条件の証明」です。

さらにRustのnomicon(公式のunsafe解説書)では、条件を2種類に分けています。

  • precondition(事前条件): その関数を呼ぶ瞬間だけ満たせばよい条件。例: get_unchecked(v, i)を呼ぶ瞬間にi < v.len()であること
  • safety invariant(安全性不変条件): ある型の値が存在している間ずっと満たし続けなければならない条件。例: NonNull<T>は「一度作られたら二度とnullにならない」ことを型の性質として保証し続ける
📌
この2つを区別できないと、「関数を呼ぶ瞬間だけチェックすれば十分」と誤解し、後から型の状態が壊れて別の場所でUBを引き起こすケースを見逃します。clippyにはmissing_safety_docというlintがあり、pub unsafe fnに# Safetyセクションが無いと警告します。これは「unsafeの契約をドキュメントとして残すこと」がRustコミュニティの強制力を伴う規範であることを示しています。

📝 問題

以下の4つに答えてください。

  1. 概念: // SAFETY:コメント(Day051、呼び出し箇所に書く)と# Safetyセクション(今日、unsafe fnの宣言に書く)は何が違うか。「誰が」「何に対して」書くものかという観点で1〜2文で説明してください。
  2. 実装: unsafe fn get_unchecked_i32(v: &[i32], i: usize) -> i32を実装してください。境界チェックを一切行わず、生ポインタ演算だけでi番目の要素を取り出します。関数には# Safetyセクションを持つdocコメントを書き、呼び出し元が満たすべき前提条件を明記してください。さらに、このunsafe fnを内部で呼び出す安全な公開API fn get_i32(v: &[i32], i: usize) -> Option<i32>を実装してください(範囲チェックをしてから呼び出す)。cargo testで、範囲内・範囲外・空スライス・境界値(i == v.len() - 1)の4パターンを検証してください。
  3. デバッグ・コードレビュー: 次のコードはコンパイルが通ります。しかし重大な問題を抱えています。何が問題か、なぜコンパイラがこれを検出できないのかを説明してください。
    fn risky_last(v: &[i32]) -> i32 {
        // v.len() 番目 = 末尾の「1つ次」を読もうとしている(オフバイワン)
        unsafe { get_unchecked_i32(v, v.len()) }
    }
  4. 安全性不変条件の分類: 次の2つの条件は、それぞれ「precondition(事前条件)」と「safety invariant(安全性不変条件)」のどちらに分類されるか、理由とともに答えてください。
    • (a) get_unchecked_i32(v, i)を呼ぶときのi < v.len()
    • (b) String::from_utf8_unchecked(bytes)で構築した後のStringが、その後ずっとUTF-8として妥当なバイト列であり続けること

🔍 ヒント(段階的開示)

ヒント1 — 方向性

(2)のget_unchecked_i32は、Day051のunsafe_sumで使ったv.as_ptr().add(i)と*によるデリファレンスの組み合わせをそのまま1要素分だけ切り出したものです。実装のロジック自体はDay051の延長線上にあります。今日の主眼は「その関数の契約をdocコメントとして明文化する」ことと、「契約を守った上で安全なラッパーを書く」ことです。

ヒント2 — アプローチ

# SafetyセクションはRustの標準的なdocコメント記法で、///の中に# Safetyという見出しを書くだけです(Markdownの#見出し)。cargo docでビルドすると、この見出しが強調表示されます。安全なラッパーget_i32では、if i < v.len() { ... } else { None }という通常のif文の中でunsafe { get_unchecked_i32(v, i) }を呼び出し、その直前に「なぜこの呼び出しが# Safetyの契約を満たすか」を// SAFETY:コメントで書きます。

ヒント3 — コード骨格
/// # Safety
///
/// 呼び出し元は以下を保証しなければならない:
/// - `i < v.len()` (ここに理由も書く)
///
/// 満たされない場合の動作: (何が起きるか書く)
unsafe fn get_unchecked_i32(v: &[i32], i: usize) -> i32 {
    unsafe { /* Day051と同じ形のポインタ演算 */ }
}

fn get_i32(v: &[i32], i: usize) -> Option {
    if i < v.len() {
        // SAFETY: 直前のif文で確認済み
        Some(unsafe { get_unchecked_i32(v, i) })
    } else {
        None
    }
}

✅ 模範解答

// safety_contract.rs
// cargo run または cargo test でそのまま実行できます。
// Cargo.toml に追加のクレート依存は不要です(標準ライブラリのみ)。

/// スライス `v` の `i` 番目の要素を、境界チェックなしで取得する。
///
/// # Safety
///
/// 呼び出し元は次を保証しなければならない:
/// - `i < v.len()` であること。
///
/// この条件が満たされない場合、`v` が確保している領域の外側のメモリを
/// 読み出すことになり、動作は未定義(undefined behavior)である。
/// 具体的には、たまたま隣接するメモリの値が返る・プロセスがクラッシュする・
/// 最適化によって全く別の結果になる、のいずれもあり得る。
unsafe fn get_unchecked_i32(v: &[i32], i: usize) -> i32 {
    // SAFETY: この関数自身の `# Safety` 契約により、呼び出し元が
    // `i < v.len()` を保証している前提でのみこの関数は呼ばれる。
    // その前提のもとでは `v.as_ptr().add(i)` は `v` の確保済み領域内の
    // 有効な `i32` を指しており、`v` がこの呼び出しの間生存しているため
    // ダングリングでもない。
    unsafe { *v.as_ptr().add(i) }
}

/// `get_unchecked_i32` を範囲チェック付きでラップした安全な公開API。
/// `unsafe` の契約を内部に隠蔽し、外部からは決してUBを起こせない形で公開する。
fn get_i32(v: &[i32], i: usize) -> Option {
    if i < v.len() {
        // SAFETY: 直前の `if i < v.len()` により、`get_unchecked_i32` の
        // `# Safety` に書かれた前提条件(i < v.len())を満たしていることを
        // この時点で確認済み。
        Some(unsafe { get_unchecked_i32(v, i) })
    } else {
        None
    }
}

fn main() {
    let data = vec![10, 20, 30, 40, 50];
    println!("get_i32(&data, 2) = {:?}", get_i32(&data, 2));
    println!("get_i32(&data, 99) = {:?}", get_i32(&data, 99));
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn in_range_index_returns_correct_value() {
        let data = vec![10, 20, 30, 40, 50];
        assert_eq!(get_i32(&data, 2), Some(30));
    }

    #[test]
    fn out_of_range_index_returns_none() {
        let data = vec![10, 20, 30];
        assert_eq!(get_i32(&data, 3), None);
        assert_eq!(get_i32(&data, 100), None);
    }

    #[test]
    fn empty_slice_returns_none() {
        let data: Vec = vec![];
        assert_eq!(get_i32(&data, 0), None);
    }

    #[test]
    fn boundary_last_valid_index_returns_value() {
        let data = vec![10, 20, 30];
        // 最後の有効なインデックスは len() - 1 == 2
        assert_eq!(get_i32(&data, 2), Some(30));
        // len() そのものは範囲外
        assert_eq!(get_i32(&data, 3), None);
    }
}

(1) の回答

// SAFETY:はある1つの呼び出し箇所で「私はこの場で前提条件を満たしていることを確認した」と呼び出し元が書く証明であるのに対し、# Safetyはunsafe fnの宣言そのものに、その関数を呼ぶすべての将来の呼び出し元が満たすべき契約を関数の作者が書く仕様である。前者は「証明」、後者は「契約の公開」という役割の違いがある。

(3) の回答

get_unchecked_i32(v, v.len())はi == v.len()であり、# Safetyが要求するi < v.len()を満たしていません(オフバイワンエラー)。しかしコンパイラはこれを検出できません。なぜならunsafe fnの呼び出しが許可されるかどうかをコンパイラがチェックするのは「unsafe {}ブロックで囲まれているか」という構文的な事実だけであり、# Safetyに書かれたi < v.len()という意味的な契約を検証する能力はコンパイラには無いからです(そもそも# Safetyはただのdocコメント=Markdownテキストであり、コンパイラはコメントの中身を実行時条件として解釈しません)。この関数はcargo buildもcargo test(呼ばなければ)も通り、実行すればvの直後のメモリをi32として読み出す未定義動作になります。デバッグビルドでは偶然それらしい値が返ることもありますが、それは「動いたから正しい」を意味しません。

(4) の回答

  • (a) precondition(事前条件)。i < v.len()はget_unchecked_i32を呼ぶそのタイミングだけ満たせばよい条件で、呼び出しごとにiもvも変わりうる。関数呼び出しの前に確認し、呼び出しの後にはその条件自体は意味を持たない(次にまた呼ぶときは改めて確認が要る)。
  • (b) safety invariant(安全性不変条件)。String::from_utf8_uncheckedの# Safetyが要求するのは構築時点でのUTF-8妥当性というpreconditionに見えるが、実際にはその結果生まれるStringという型そのものが「内部バイト列は常にUTF-8として妥当」という不変条件を持ち続けることを、Stringのあらゆるメソッド(push_str・chars()・as_str()等)が無条件に前提にしている。つまり一度構築した後も、そのStringが生きている間ずっとこの条件が破れてはならない。破ると、後から全く無関係に見える別の場所(例えばchars()でUTF-8をデコードする箇所)でUBが発生する。

🪜 Step-by-Step 解説

1
unsafe fnの宣言と# Safetyセクションを書く
unsafe fnと宣言した時点で、Rustはこの関数の呼び出しを5つのunsafe操作の1つとして扱います。関数の作者は、その関数が要求する前提条件を///のdocコメント内に# Safetyという見出しで明文化します。これはコンパイラには一切解釈されないただのテキストですが、Rust API Guidelines(C-SAFETY)とclippyのmissing_safety_doclintが「公開unsafe関数には必須」として強制する慣習です。
2
関数の内部実装はDay051のパターンをそのまま使う
get_unchecked_i32の中身はunsafe { *v.as_ptr().add(i) }で、Day051のunsafe_sumのループ内と全く同じ形です。ここでの// SAFETY:コメントは「この関数自身の# Safety契約が、呼び出し元によって満たされている前提のもとで」という書き方になる点がポイントです。つまり関数内部の安全性の根拠を、関数の外側(呼び出し元の責任)に委譲していることを明示します。
3
安全なラッパーで契約を満たしてから呼び出す
get_i32はif i < v.len()で# Safetyの前提条件を先に満たしてからunsafe fnを呼びます。ここでの// SAFETY:コメントは「直前のif文でこの契約を満たしたことを確認済み」という、Day051と同じ形の呼び出し箇所の証明です。これによりget_i32自体はunsafeを一切含まない安全なAPIとして公開でき、get_unchecked_i32という危険な部品を安全に隠蔽できます。
4
契約違反はコンパイラでは検出できないことを確認する
問題(3)のrisky_lastは、unsafe {}で正しく囲んではいるものの、契約(i < v.len())を満たさずに呼び出しています。これは構文的には完全に正しいコードなので、コンパイラは何のエラーも警告も出しません。この「構文は正しいが契約違反」という状態こそが、unsafeコードのレビューが人間の仕事として残り続ける理由です。

💡 設計思想・なぜこう書くのか

Rustの型システムと借用チェッカーは「証明可能な安全性」を自動化する仕組みですが、unsafe fnの契約はその自動化の限界を、意図的にドキュメントという形で人間に押し返す装置です。ここで重要なのは、Rustが「証明できないなら諦めてC言語のように無検証にする」のではなく、「証明できない部分の境界線を明示的な構文(unsafeキーワード)とドキュメント規約(# Safety)で区切り、そこだけ人間の責任にする」という設計を取っていることです。

これは「ゼロコスト抽象化」の思想とも一致します。もしget_unchecked_i32が常に境界チェックを行うなら実行時コストが発生しますが、# Safetyで契約を明文化した上でチェックを省略すれば、契約を守れる呼び出し元にとってはコストゼロになります。標準ライブラリのget_unchecked系メソッド(slice::get_unchecked等)が広く使われているのはまさにこの理由からで、ホットパス(既に別の場所で範囲チェック済みであることが分かっている箇所)でのみ意図的に使われます。

🛑 コンパイルエラーが出た場合

問題(3)のrisky_lastはコンパイルエラーを出しません。これは今日のテーマの核心です。参考までに、もしunsafe {}で囲むこと自体を忘れた場合は次のエラーになります(Day051と同種)。

error[E0133]: call to unsafe function is unsafe and requires unsafe function or block
  --> src/main.rs:3:14
  |
3  |    get_unchecked_i32(v, v.len())
  |    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ call to unsafe function
  |
  = note: consult the function's documentation for information on how to
        avoid undefined behavior
⚠️
note:の行に注目してください。「関数のドキュメント(=# Safety)を読んで、UBを避ける方法を確認しろ」とコンパイラ自身が案内しています。つまりコンパイラは「unsafeで囲め」という構文チェックはしても、「囲んだ中身が本当に契約を満たしているか」の意味チェックは最初から自分の責任範囲外だと宣言しているのです。

🌐 他言語との比較

観点C言語GoRust
「この関数を呼ぶための前提条件」の記述場所ヘッダファイルのコメント(慣習のみ、強制力なし)ドキュメントコメント(強制力なし、unsafeパッケージ使用箇所自体が少ない)# Safetyセクション(clippyのmissing_safety_docがpub unsafe fnに対してlintで強制)
契約違反の検出主体誰も検出しない(レビュー頼み)誰も検出しないコンパイラは検出しない。ただしcargo miriという公式ツールが実行時に多くの契約違反(範囲外アクセス・未初期化読み取り等)を検出できる
precondition と safety invariant の言語的な区別概念自体が存在しない概念自体が存在しないnomicon(公式ドキュメント)が明確に定義し、コミュニティの語彙として定着している

C言語やGoにも「関数を正しく使うための暗黙の前提」は当然存在しますが、それを構文レベルでunsafe扱いにする仕組みも、契約をdocコメントの決まった見出しとして強制する仕組みもありません。Rustは「危険性そのもの」をゼロにはできない代わりに、「危険性の所在」をunsafeキーワードと# Safety見出しという検索可能な形に固定した点が設計上の差です。

🏆 実務での使いどころ

  • 標準ライブラリのget_unchecked系API: slice::get_unchecked・str::get_uncheckedなど、境界チェック済みであることが構造的に保証されているホットパスで、境界チェックのオーバーヘッドを避けるために使われる
  • FFI関数のラッピング: Day111で扱うが、C言語の関数をunsafe extern "C"として受け入れた後、Rust側の安全なAPIでラップする際、C側の関数が要求する前提条件(ポインタがnullでない・呼び出し順序等)を# Safetyとして文書化するのが標準的なパターン
  • パフォーマンスクリティカルなクレートのAPI設計: bytesクレートやndarrayクレートなど、内部でunsafeを使いつつ安全なAPIを公開する著名クレートは、内部のunsafe fnに対してほぼ例外なく# Safetyセクションを備えている(クレートのソースコードを見れば実例として確認できる)
  • コードレビューの実務プロセス: unsafeコードを含むPull Requestでは、レビュアーが「# Safetyに書かれた契約と、実際の呼び出し箇所の// SAFETY:コメントが本当に対応しているか」を突き合わせて確認するのが標準的なレビュー観点になる

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
unsafe {}で囲んでコンパイルが通れば、契約を満たしていると思い込むコンパイラがエラーを出さないことを「正しさの証明」と混同するコンパイラが検証するのは構文(unsafeで囲まれているか)だけで、# Safetyの契約を満たしているかどうかは一切検証しない。問題(3)がまさにこの罠の具体例
precondition と safety invariant を区別せず、「呼ぶ瞬間だけ気をつければいい」と思い込む通常の関数の引数バリデーションの感覚(呼ばれた瞬間だけ有効ならよい)を持ち込んでしまうsafety invariantは値が生きている間ずっと保たれなければならない。String::from_utf8_uncheckedのように、構築時のpreconditionが結果の型の一生涯にわたるinvariantに転化するケースがある
# Safetyセクションを「関数の使い方の説明」程度に軽く書き、UBの具体的な内容(何が起きるか)を書かない通常のdocコメント(# Examples等)と同じ温度感で書いてしまう# Safetyは「満たさなければ何が起こるか」まで含めて書くのが実務標準。「動作未定義」とだけ書くより、「境界外メモリを読む」「解放済み領域にアクセスする」等、具体的な帰結を書く方が呼び出し元の注意を引ける
安全なラッパー(get_i32のような関数)を書かず、unsafe fnをそのまま公開APIとして晒してしまう「境界チェックを省略したい」という目的と「公開APIとして誰でも呼べる」ことの両立を混同する危険なunsafe fnは原則crate内部に留め、安全な条件分岐で契約を満たしてから呼ぶラッパーだけを公開するのが標準パターン。標準ライブラリのget_uncheckedも、多くの安全なメソッド(Index実装等)の内部実装として使われ、直接公開されるのは「意図的に境界チェックを省きたい」と明言する上級者向けAPIとしてのみ

🚀 次のステップ

  • 発展: 問題(3)のrisky_lastを実際にcargo miri runで実行し、Miriが「memory access out of bounds」のようなUB検出メッセージを出すことを確認する(本日はMiriの導入手順そのものは範囲外とし、Day051に続きここでも意図的に割愛する)
  • 次回予告: Phase 3の次テーマFFI(C言語との相互運用)に進む。今日学んだ「# Safetyで契約を明文化し、安全なラッパーで隠蔽する」パターンが、C言語の関数を呼ぶ際にそのまま応用される

🎯 自己評価

自分の回答

気づき・メモ