Rustの秘訣:ガベージコレクタなしで所有権と借用をマスターする

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

メモリ管理の綱引き

数十年の間、開発者はもどかしいトレードオフに直面してきました。CやC++のような言語は生のスピードと手動制御を提供しますが、メモリリークやセグメンテーションフォールトが発生しやすいことで知られています。

実際、MicrosoftとGoogleの両社は、セキュリティ脆弱性の約70%がメモリ安全性に関連する問題に起因していると報告しています。その一方で、JavaやGoのような言語は、クリーンアップを自動化するためにガベージコレクタ(GC)を使用します。GCは便利ですが、ハイパフォーマンスなアプリケーションにおいてレイテンシを急増させる「Stop-the-World」の一時停止を引き起こすことがよくあります。

Rust所有権(Ownership)を通じて、このジレンマを完全に回避します。管理言語の安全性と手動管理のパフォーマンスを両立させているのです。バックグラウンドプロセスがゴミを探す代わりに、Rustはコンパイル時のルールセットを使用して、メモリが必要なくなった瞬間に確実に解放されるようにします。

クイックスタート:憲法の3つのルール

所有権は、Rustプログラムの「憲法」のようなものだと考えてください。それは、譲れない3つのルールによって支配されています:

  • すべての値には、その所有者(owner)となる変数がある。
  • 一度に存在できる所有者は1人だけである。
  • 所有者がスコープ(scope)から外れると、メモリは即座に破棄(drop)される。

Stringの例を見てみましょう。単純な整数とは異なり、文字列はヒープ上に存在し、慎重な取り扱いが必要です:

{
    let s = String::from("こんにちは"); // sがヒープメモリの所有権を取得
    // sを使って何かを行う
} // sがスコープ外になります。Rustは 'drop' を呼び出し、メモリが解放されます

ほとんどの初心者がつまずく概念は、ムーブ(Move)です。多くの言語では、ある変数に別の変数を代入すると参照がコピーされます。しかしRustでは、権利が譲渡されます:

let s1 = String::from("こんにちは");
let s2 = s1; // データはコピーされません。所有権がs2に移動しただけです。

// println!("{}", s1); // エラー:s1は「無効」になっています
println!("{}", s2);    // これは正常に動作します

s1を無効にすることで、Rustは「二重解放(double-free)」エラーを防ぎます。1つの変数だけが常に鍵を握っているため、誤って同じメモリを2回削除することはありません。

ディープダイブ:所有権を手放さずに「借用」する

すべての関数に所有権を渡すのは、まるでホットポテトゲームのようです。使い続けるために、常に値を返し続けなければならなくなります。Rustはこれを借用(Borrowing)で解決します。鍵を受け取る代わりに、関数は単に参照(Reference)&)を要求します。

fn main() {
    let s1 = String::from("こんにちは");
    let len = calculate_length(&s1); // s1を貸し出しているだけ

    println!("'{}' の長さは {} です。", s1, len); // s1はまだ私たちのものです!
}

fn calculate_length(s: &String) -> usize {
    s.len()
} // sはスコープ外になりますが、単なる貸し出しだったので、何も削除されません

参照の黄金律

スレッドセーフを維持し、データ競合を防ぐために、Rustは厳格な借用ポリシーを強制しています。**1つの**可変参照(&mut)を持つか、あるいは**任意の数**の不変参照(&)を持つかのどちらかです。両方を同時に持つことはできません。

あるスレッドが500MBのバッファを読み込んでいる最中に、別のスレッドがそのサイズを変更しようとするシナリオを想像してみてください。それはクラッシュの元です。Rustのコンパイラは、実行ボタンを押す前にこれをキャッチします。

let mut s = String::from("こんにちは");

let r1 = &s; 
let r2 = &s; 
// let r3 = &mut s; // コンパイラはこれを即座にブロックします。

println!("{}, {}", r1, r2);

ライフタイム:司書の仕事を手伝う

ボローチェッカー(Borrow Checker)は厳格な司書のようなものです。本(参照)が図書館(所有者)が存在する期間よりも長く貸し出されないことを確認する必要があります。通常、ボローチェッカーは自分でこれを判断できます。しかし、関数が参照を返す場合、ヒントが必要になることがあります。ここでライフタイム(Lifetimes)<'a>)の出番です。

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

この構文は、データの生存期間を変えるものではありません。単にコンパイラに対して「返される参照は、xy の両方が存在する限り有効である」という約束を伝えているだけです。これは、恐ろしい「ダングリングポインタ」を防ぐ契約なのです。

実践的なサバイバルヒント

もしコンパイラと戦っているように感じたら、以下の4つの戦略が勝利の助けになるでしょう:

1. .clone() を使いすぎない

.clone() を呼び出すのは「イージーボタン」です。データのディープコピーを作成するためコンパイラは満足しますが、アプリの速度は低下します。プロトタイプ作成には使い、ロジックが固まったら参照にリファクタリングしましょう。

2. スタックとヒープを理解する

整数(i32)やブール値のような小さく固定サイズのデータは、スタック(Stack)に保存されます。これらは Copy トレイトを実装しているため、ムーブされるのではなく自動的にコピーされます。ムーブセマンティクスに従うのは、ヒープに割り当てられたデータ(String、Vector、カスタム構造体など)だけです。

3. 複雑な構造にはスマートポインタを使う

共有キャッシュやグラフ構造のように、どうしても複数の所有者が必要な場合があります。Rustは、シングルスレッドの参照カウント用に Rc<T> を、スレッドセーフな共有用に Arc<T> を提供しています。これらは特定のデータ片に対してミニGCのように機能します。

4. スコープを小さくする

変数が波括弧の外に出た瞬間にメモリは解放されます。1GBの巨大なデータセットの処理が終わったら、そのロジックをブロックで囲み、関数の残りの部分のためにRAMを解放しましょう。

{
    let huge_data = load_file("config.json");
    parse(&huge_data);
} // ここで1GBが解放されます
// 他のタスクを続行

Rustへの移行には、マインドセットの切り替えが必要です。メモリを「いつ」削除するかを心配する代わりに、データがシステム内を「どのように」流れるかに集中します。コツを掴めば、驚くほど高速で、かつ事実上クラッシュしないコードを書けるようになるでしょう。

Share: