所有権と借用
Rust のメモリ管理モデルで、GC なしでメモリ安全性を保証する仕組み
所有権と借用とは
所有権 (Ownership) と借用 (Borrowing) は、Rust のメモリ管理モデルで、ガベージコレクション (GC) なしでメモリ安全性をコンパイル時に保証する。検査を担うのはコンパイラの借用チェッカーで、実行時に参照を数えたりヒープを走査したりはしない。安全性のための実行時コストが乗らない代わりに、規則を満たすようにコードの書き方そのものを変える必要がある。言語全体の位置づけは Rust を参照。
所有権の 3 つのルール
公式ドキュメント (The Rust Programming Language) が挙げる規則は次の 3 つである。
- 各値には所有者 (owner) と呼ばれる変数がある
- 所有者は同時に 1 つだけ
- 所有者がスコープを抜けると値は破棄される (drop)
移動 (ムーブ) はこの 3 つから導かれる帰結で、規則そのものには含まれない。所有者が 1 つに限られるため、代入や関数への引き渡しで所有権が移った側は、元の変数を使えなくなる。
let s1 = String::from("hello");
let s2 = s1; // 所有権が s1 → s2 に移動
// println!("{}", s1); // ❌ コンパイルエラー: s1 はもう使えない
println!("{}", s2); // ✅ OK
s1 が使えなくなるのは、二重解放を避けるためである。String の実体はヒープにあり、s1 と s2 の両方が有効なままスコープを抜けると、同じメモリを 2 回解放しようとする。公式ドキュメントはこれを double free error と呼び、let s2 = s1; の時点で s1 を無効と見なすことで防いでいると説明する。
ただし、すべての代入でムーブが起きるわけではない。let x = 5; let y = x; の後も x は使える。整数のようにスタックだけで完結し Copy トレイトを実装している型は、代入で複製されるからだ。ムーブのコンパイルエラーに does not implement the Copy trait と出るのは、この境界を踏んだという知らせである。
借用 (参照)
値を読むだけの処理に所有権を渡してしまうと、呼び出し側で元の変数が使えなくなる。そこで所有権は動かさず、参照 (&T) だけを一時的に貸す。これが借用である。借りた側のスコープが終わっても値は解放されない (解放は所有者の役目だ)。
fn print_length(s: &String) { // 不変参照 (借用)
println!("Length: {}", s.len());
}
let s = String::from("hello");
print_length(&s); // 借用: 所有権は移動しない
println!("{}", s); // ✅ まだ使える
不変参照 vs 可変参照
参照は、読むだけの不変参照 (&T) と書き換えられる可変参照 (&mut T) に分かれる。同じ値に対して可変参照は同時に 1 つしか作れず、この制限はデータ競合を防ぐためにある。公式ドキュメントはデータ競合が起きる条件として、2 つ以上のポインタが同じデータへ同時にアクセスする、少なくとも一方が書き込みに使われる、アクセスを同期する仕組みが無い、の 3 つを挙げる。可変参照を 1 つに絞れば最初の条件が崩れるので、競合そのものが成立しない。
let mut s = String::from("hello");
// 不変参照: 複数同時に可能
let r1 = &s;
let r2 = &s;
println!("{r1} {r2}"); // ここで r1・r2 の借用は終わる
// 可変参照: 1 つだけ (不変参照と有効範囲が重なると取れない)
let r3 = &mut s;
r3.push_str(" world");
println!("{r3}");
借用が続く範囲は、参照が導入された場所から最後に使われた場所までである。上の例は r1・r2 を println! で使い切ってから可変参照を作るため、コンパイルが通る。逆に r3 を作った後で r1 を使うと、cannot borrow s as mutable because it is also borrowed as immutable (E0502) で弾かれる。「不変参照があるなら可変参照は作れない」と丸暗記すると、通るはずのコードまで避けることになる。
| ルール | 説明 |
|---|---|
| 不変参照は複数同時に可能 | 読むだけなら互いに干渉しない |
| 可変参照は同時に 1 つだけ | 書き換えている間は他から見せない |
| 不変と可変は有効範囲を重ねられない | 読んでいる途中での書き換えを防ぐ |
ライフタイム
参照を返す関数では、戻り値がどの引数に由来するのかコンパイラに判断できない場合がある。そこで注釈を付けて、参照同士の関係を宣言する。
// ライフタイム注釈: 参照の有効期間を明示
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'a は、戻り値が x と y のうち短い方の有効期間に結びつくという関係の宣言である。公式ドキュメントは、注釈が参照の生存期間そのものを変えるのではなく、複数の参照の生存期間の関係を記述するだけだと明言している。関係が分かればコンパイラは呼び出し側で有効性を検査でき、ダングリングポインタ (解放済みメモリへの参照) がコンパイル時に弾かれる。なお多くの関数では省略規則 (lifetime elision) が働くため、注釈を自分で書く場面は限られる。
なぜ重要か
所有権システムにより、Rust は以下をコンパイル時に防止する。
- ダングリングポインタ
- データ競合
- 二重解放
一方で、メモリリークはこの一覧に入らない。公式ドキュメントは、リークを完全に防ぐことは Rust の保証に含まれず、リークはメモリ安全の範囲内だと明記している。実際、参照カウント方式の Rc と内部可変性を持つ RefCell を組み合わせて互いを指す循環を作ると、カウントが 0 に落ちないまま値が破棄されずに残る。循環を断つには片方を弱い参照 (Weak) にするなど、設計側の判断が必要になる。「コンパイルが通ったのだからメモリの心配は要らない」とは読み替えないほうがよい。
C++ もデストラクタで資源を解放する RAII の考え方を持ち、手動解放が要らない点は似ている。違うのは、所有者の重複や解放後の参照を言語が止めてくれないところだ。所有権を型システムに載せてコンパイラの検査対象にしたのが Rust の選択で、その代償として書き方の自由度が下がっている。言語の性格の違いは C++ を参照。
実務では、借用チェッカーに叱られるたびに clone() を足して切り抜ける癖が付きやすい。これはヒープ確保とコピーを増やすだけで、たいていは「この値の所有者は誰であるべきか」を決め直すほうが正しい。
この記事は役に立ちましたか?