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%を理解できます:

  1. Rustのすべての値には、ownerと呼ばれる変数が1つあります。
  2. 任意の時点で、唯一つの所有者のみが存在します。
  3. 所有者がスコープを抜けるとき、値は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はコンパイラと「議論」が多くなるかもしれませんが、一度慣れると、バックエンドで見張りがなくても、ずっと少ないバグを持つコードを書くことができます。

rustownershipmemory-safetysystems-programmingborrow-checker