Rustの所有権:GCなしでメモリ安全性を確保する
1. 概要
所有権(Ownership)はRustのコア機構で、JavaやGoのようなガベージコレクタ(GC)を必要とせず、また、C言語のように手動でmalloc/freeを呼び出す必要もなく、メモリ安全性を保証します。このテキストでは、所有権の3つのルール、borrowingの概念、lifetime、そしてコンパイル時によく見られるエラーについて説明します。読み終わる頃には、Rustがなぜコンパイル時に「厳しい」のか、その代わりにruntime時にdangling pointer、double free、data raceを回避できるのかが理解できるようになります。
2. 問題点:GCなしでのメモリ管理
JavaなどのプログラミングといったGCを使って自動的にメモリを管理する言語では、便利な反面、CPUを消費し、予測不可能な一時停止(pause)を引き起こします。C/C++では完全な制御が得られますが、double free、use-after-free、memory leakが起こりやすくなります。Rustは第3の道を選びました。コンパイラがどの変数がデータの「所有者」かを追跡し、所有者がスコープを抜けると自動的にメモリを解放します。runtimeのオーバーヘッドがなく、GCスレッドも必要ありません。
fn main() {
let s = String::from("Non La Dev");
println!("{s}");
} // sはここでscopeを抜けます。Rustは自動的にdrop(s)を呼び出し、heap memoryを解放します
String::fromの行はheap上にメモリを割り当てます。変数sが}を抜けるとき、Rustは自動的にdropの呼び出しを挿入します。これはfinally { free(s) }に似ていますが、コンパイラによって生成されるため、手で書く必要はありません。
3. 所有権の3つのルール
Rustにはちょうど3つのルールがあり、これらを覚えれば機構の80%を理解できます:
- Rustのすべての値には、ownerと呼ばれる変数が1つあります。
- 任意の時点で、唯一つの所有者のみが存在します。
- 所有者がスコープを抜けるとき、値はdrop(解放)されます。
3.1. Moveセマンティクス
fn main() {
let s1 = String::from("hello");
let s2 = s1; // 所有権がs1からs2に移動します
println!("{s2}");
// println!("{s1}"); // エラー: s1はmoveされています
}
Java(変数を代入することは参照をコピーするだけで、両方が同じオブジェクトを指し、GCが後を見る)とは異なり、Rustはlet s2 = s1を所有権の移動と見なします。その行の後、s1はもう有効ではありません。最後の行のコメントを外すと、エラーが表示されます:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:20
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{s1}");
| ^^^^ value borrowed here after move
これはdouble freeエラーを根本的に防ぎます。s1とs2の両方が同じheap領域へのポインタを保持していると、両者がスコープを抜けるときに、Rustは2回dropしようとするため、クラッシュします。コンパイラはコンパイル時点で直ちにこれをブロックします。
3.2. コピーが必要な場合のClone
実際に2つの独立したコピーが必要な場合は、明示的に.clone()を呼び出します:
fn main() {
let s1 = String::from("hello");
let s2 = s1.clone(); // heap領域の深いコピー
println!("{s1} - {s2}"); // 両方とも有効です
}
.clone()は新しいheap領域を割り当て、内容をコピーします。runtimeのコストは明確で、Rustは明示的な呼び出しを強制するため、誰も知らないうちに巨大なデータ構造をクローンしてしまうことはありません。多くの言語が暗黙的にコピーするのとは異なります。
3.3. シンプルなデータ型のCopyトレイト
fn main() {
let x = 5;
let y = x; // copy、moveではない
println!("x = {x}, y = {y}"); // 両方とも使用できます
}
i32、bool、char、またはこれらの型のみを含むtupleなどのプリミティブ型は、Copyトレイトを実装しています。これらはサイズが固定され、完全にstack上に存在するため、moveよりもcopyの方が安価なので、Rustは自動的にcopyを行い、元の変数を無効にしません。
注意: あるtype が
CopyとDropの両方を実装することはできません。型がクリーンアップ対象のリソース(heap、file handle…)を持っている場合、Copyを実装することはできません。
4. 関数にOwnershipを渡す
関数に変数を渡すことも、変数を代入するのと同じmove semanticsに従います:
fn print_order(order: String) {
println!("Order: {order}");
} // orderはここでdropされます、callersの場所ではなく
fn main() {
let order = String::from("ORDER-001");
print_order(order);
// println!("{order}"); // エラー: orderは関数内にmoveされています
}
print_order(order)を呼び出すと、orderの所有権が関数のパラメータに移動します。関数が終了すると、値は関数内でdropされます。mainはもうorderを使用する権限がありません。これがborrowing(次のセクション)が、所有権を移さずにデータを「読み取り」たいときにほぼ常に必要とされる理由です。
5. Borrowing:所有権を取らずに借用する
5.1. イミュータブル参照(&T)
fn print_order(order: &String) {
println!("Order: {order}");
}
fn main() {
let order = String::from("ORDER-001");
print_order(&order); // 借用、moveではない
println!("まだ使用できます:{order}");
}
&符号は参照を作成します。これはデータを借用する方法で、所有権は取得しません。関数print_orderは読み取りのみで、orderを所有しません。関数の実行が完了した後、mainは元の変数を完全に使用できます。
5.2. ミュータブル参照(&mut T)
fn add_discount(total: &mut f64) {
*total -= 10.0;
}
fn main() {
let mut total = 100.0;
add_discount(&mut total);
println!("合計: {total}"); // 合計: 90
}
&mutは借用して変更を許可します。呼び出すには、元の変数はmutとして宣言される必要があります。*記号はdereference用で、参照が指す値に新しい値を割り当てます。
5.3. Borrowingのルール
Rustはコンパイル時に2つの借用ルールを適用します。これらはborrow checkerと総称されます:
- 任意の時点で、1つのミュータブル参照、または複数のイミュータブル参照のいずれかを持つことができます。混在させることはできません。
- 参照はデータ元よりも長く生存することはできません(dangling referenceを回避するため)。
fn main() {
let mut total = 100.0;
let r1 = &total;
let r2 = &total; // OK: 複数のイミュータブル参照が同時に存在できます
println!("{r1} {r2}");
let r3 = &mut total; // r1、r2がこの後で使用される場合はエラーになります
println!("{r3}");
}
r1とr2が最初のprintln!行の後で使用されなくなった場合、コンパイラ(Non-Lexical Lifetimesの助けを借りて)は下のr3にミュータブル借用を許可します。しかし、r3が出現した後にr1を使用しようとすると、次のエラーが表示されます:
error[E0502]: cannot borrow `total` as mutable because it is also borrowed as immutable
このルールはdata raceをコンパイル時で完全に排除します。同じメモリ領域に対して、同時に読み取りと書き込みが行われることはできません。
6. Lifetime:参照が無効なメモリを指さないことを保証する
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
fn main() {
let order1 = String::from("ORDER-001");
let order2 = String::from("ORDER-002-LONG");
let result = longest(order1.as_str(), order2.as_str());
println!("長い順: {result}");
}
'a記号はlifetime annotationで、コンパイラに次のように伝えます:「返される参照はxとyの両方より長く生存しません」。Rustはこの場合、複数の参照間の生存-死亡の関係を自動的に推測することはできないため、明示的に宣言する必要があります。ほとんどの場合、Rustは自動的にlifetimeを推論する(lifetime elision)ため、'aを手で書く必要があります。関数が複数の参照を受け取り、その1つを返すときのみです。
error[E0106]: missing lifetime specifier
--> src/main.rs:1:33
|
1 | fn longest(x: &str, y: &str) -> &str {
| ^ expected named lifetime parameter
'aが欠けている場合、コンパイラは返される参照がxまたはyのどちらに関連しているかを知らないため、エラーを報告します。
7. 複数の所有者が必要な場合:RcとRefCell
単一所有権の所有者は時々厳しすぎることがあります。たとえば、複数のノードが同じ値を参照する必要があるツリーやグラフのようなデータ構造を構築する場合です。
<!-- 依存する必要はありません。Rc/RefCellはstdに含まれています -->
use std::cell::RefCell;
use std::rc::Rc;
struct Customer {
name: String,
balance: RefCell<f64>,
}
fn main() {
let customer = Rc::new(Customer {
name: String::from("Nón Lá"),
balance: RefCell::new(0.0),
});
let c1 = Rc::clone(&customer);
let c2 = Rc::clone(&customer);
*c1.balance.borrow_mut() += 50.0;
*c2.balance.borrow_mut() += 30.0;
println!("{} balance = {}", customer.name, customer.balance.borrow());
println!("参照数: {}", Rc::strong_count(&customer));
}
Rc<T>(Reference Counted)は、複数の所有者が同じ不変データを指し、参照の数をruntime時に数え、countが0になるとdropします。RefCell<T>はruntime時のミュータブル借用をコンパイル時の代わりに許可します(interior mutability)。ただし、借用ルールに違反した場合(たとえば、borrow_mut()を2回同時に呼び出す場合)、プログラムはコンパイルエラーの代わりにpanicします。
Nón Lá balance = 80
参照数: 3
注意:
Rc/RefCellはシングルスレッドのみで使用されます。複数のthread間でデータを共有する必要がある場合は、Arc(atomic reference count)をMutexまたはRwLockと組み合わせて使用します。Rc<RefCell<T>>を乱用しないでください。そうすると、Rustの最大の強みであるborrow checkerがコンパイル時でエラーをキャッチする利点が失われます。
8. Ownershipの動作を検証するテストの記述
#[derive(Clone)]
struct Order {
id: String,
amount: f64,
}
fn apply_discount(order: &mut Order, percent: f64) {
order.amount -= order.amount * percent / 100.0;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_keep_original_order_untouched_when_cloned() {
let original = Order { id: "ORD-1".into(), amount: 100.0 };
let mut copy = original.clone();
apply_discount(&mut copy, 10.0);
assert_eq!(original.amount, 100.0); // 元のコピーは変わらず
assert_eq!(copy.amount, 90.0);
}
#[test]
fn should_mutate_through_mutable_borrow() {
let mut order = Order { id: "ORD-2".into(), amount: 200.0 };
apply_discount(&mut order, 25.0);
assert_eq!(order.amount, 150.0);
}
}
cargo testを実行すると、結果は次のようになります:
running 2 tests
test tests::should_keep_original_order_untouched_when_cloned ... ok
test tests::should_mutate_through_mutable_borrow ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured
最初のテストは.clone()が独立したコピーを作成することを証明しています。copyを変更してもoriginalに影響を与えません。2番目のテストは、&mutが参照を通じてデータ元を実際に変更することを確認しています。
9. よくあるエラー
- Use after move:別の場所(関数、新しい変数、struct)にmoveされた後、変数を使用しようとすること。2つのコピーが必要な場合は
.clone()で修正するか、参照(&T)の渡し方に変更してください。 - Cannot borrow as mutable more than once:1つの
&mutを保持しながら、別の&または&mutを同時に作成すること。古い参照のスコープを縮小(ブロック{}を使用するか、早く「終了」させる)して解決します。 - Dangling reference:関数の終了時にdropされた局所変数を指す参照を返すこと。Rustはこのエラーをコンパイル時で完全にブロックします。
missing lifetime specifierまたはborrowed value does not live long enoughエラーが表示されます。 .clone()を乱用してborrow checkerを回避する:コンパイルには有効ですが、不要なパフォーマンス低下を招きます。各.clone()を質問と考えてください:「本当に独立したコピーが必要か、それともownershipを理解することから逃げているだけか?」
10. 厳密なOwnershipが不要な場合
短いスクリプトを書いたり、プロトタイプを迅速に作成したり、データが常に小さく、パフォーマンス/安全性の要件が高くない場合、borrow checkerを学んで従うことのコストは正当ではないかもしれません。PythonやGoなどの言語は、より迅速な開発速度を提供します。Ownershipが最も輝くのは、高いパフォーマンスと厳密なメモリ安全性が必要なシステムです:オペレーティングシステム、組み込みシステム、ゲームエンジン、またはGC pauseを許容できない低遅延が必要なバックエンドサービス。
11. まとめ
所有権はRustが矛盾していると思われることを達成するための基礎です。GCを持つ言語のようなメモリ安全性を持ちながら、C/C++と同等のパフォーマンスを実現しています。owner-move-dropの3つのルール、1つのミュータブルまたは複数のイミュータブルという規則を持つborrow機構、およびlifetime annotationが連携して、コンパイラはmemory-unsafeエラーをruntime時のクラッシュの代わりにコンパイル時にキャッチします。最初、borrow checkerはコンパイラと「議論」が多くなるかもしれませんが、一度慣れると、バックエンドで見張りがなくても、ずっと少ないバグを持つコードを書くことができます。