Rust所有権と借用を徹底解説 -- メモリ安全性の仕組みと実践パターン

Rustの所有権、借用、ライフタイムの仕組みをコード例とともに詳しく解説します。ボローチェッカーのエラー対処法や実務で使えるパターンも紹介します。

Rustの所有権と借用の仕組みを解説するガイドのカバー画像

Rustの所有権と借用は、Rustが提供するメモリ安全性保証の基盤となる仕組みです。ガベージコレクションを採用する言語とは異なり、Rustはコンパイル時にボローチェッカーを通じて厳格なルールを適用します。これにより、ヌルポインタ参照、データ競合、解放後使用(use-after-free)といったバグの発生を、ランタイムオーバーヘッドなしで排除できます。

システムプログラミングの面接では、所有権と借用の理解が必須スキルとして問われます。本記事では、ムーブセマンティクスから借用ルール、ライフタイムの注釈まで、具体的なコード例を用いて体系的に解説します。

The Three Rules of Ownership

Every value in Rust has exactly one owner. When the owner goes out of scope, the value is dropped. Ownership can be transferred (moved) or temporarily lent (borrowed). These three rules replace garbage collection entirely.

ムーブセマンティクスがガベージコレクションを置き換える仕組み

多くのプログラミング言語では、複数の変数がヒープ上の同一データを参照できます。Rustは異なるアプローチを採用しています。ヒープ上の値を別の変数に代入すると、所有権が**移動(ムーブ)**し、元の変数は無効化されます。コンパイラはこのルールをゼロコストで強制します。

move_semantics.rsrust
fn main() {
    let original = String::from("interview prep");
    let moved = original; // ownership transfers here

    // println!("{}", original); // compile error: value moved
    println!("{}", moved); // works fine
}

この仕組みにより、ダブルフリーのバグが防止されます。String型はヒープ上にメモリを確保するため、Rustはその割り当てを所有する変数が常に1つだけであることを保証します。一方、i32boolのようなスタック専用の型はCopyトレイトを実装しており、ムーブではなく複製されます。

copy_vs_move.rsrust
fn main() {
    let x: i32 = 42;
    let y = x; // copy, not move -- i32 is Copy
    println!("x = {}, y = {}", x, y); // both valid

    let s1 = String::from("hello");
    let s2 = s1.clone(); // explicit deep copy
    println!("s1 = {}, s2 = {}", s1, s2); // both valid after clone
}

CopyCloneの違いは面接で頻繁に問われるポイントです。Copyは暗黙的かつ低コスト(ビット単位のコピー)であるのに対し、Cloneは明示的で、ヒープ割り当てを伴うため高コストになり得ます。この区別を正確に説明できることが、Rustの基礎理解を示す重要な指標となります。

不変参照による借用

所有権の移動をあらゆる場面で行うと、コードが非実用的になります。Rustの借用機能は、所有権を移動させずに値へのアクセスを一時的に貸し出すことで、この問題を解決します。不変参照(&T)は読み取り専用のアクセスを許可し、複数の不変参照が同時に存在できます。

immutable_borrowing.rsrust
fn calculate_length(s: &String) -> usize {
    s.len() // read access through the reference
} // s goes out of scope, but doesn't drop the String (not the owner)

fn main() {
    let greeting = String::from("hello, Rust");
    let len = calculate_length(&greeting); // borrow, don't move
    println!("'{}' has {} characters", greeting, len); // greeting still valid
}

&記号は値を借用する参照を作成します。関数シグネチャの&Stringは、calculate_lengthが所有権を取得せずに借用することを宣言しています。関数が返った後も、呼び出し元は完全な所有権を保持します。

このパターンは、関数間でデータを受け渡す際の基本的な手法です。所有権を移動する必要がない場合は、常に借用を優先するのがRustの慣用的なスタイルとなります。

Borrowing Rules at a Glance

At any given time, a value can have either: many immutable references (&T), OR exactly one mutable reference (&mut T). Never both simultaneously. This rule prevents data races at compile time.

可変参照と排他性ルール

可変参照(&mut T)は書き込みアクセスを付与しますが、排他性を強制します。あるスコープ内で、ある値に対する可変参照は1つしか存在できません。この制約により、2つのコードが同時に同じデータを変更することが防止されます。

mutable_borrowing.rsrust
fn append_greeting(s: &mut String) {
    s.push_str(", welcome to Rust!"); // modify through mutable ref
}

fn main() {
    let mut message = String::from("Hello");
    append_greeting(&mut message);
    println!("{}", message); // "Hello, welcome to Rust!"
}

mutキーワードは3箇所に記述する必要があります。変数束縛(let mut)、参照型(&mut)、そして関数パラメータです。3つすべてが揃っていないとコンパイルエラーになります。同じスコープ内で2つ目の可変参照を作成しようとすると、コンパイラがエラーを報告します。

exclusivity_rule.rsrust
fn main() {
    let mut data = String::from("shared state");

    let r1 = &mut data;
    // let r2 = &mut data; // compile error: second mutable borrow
    println!("{}", r1);

    // After r1's last usage, a new mutable borrow is allowed
    let r3 = &mut data; // this works -- non-lexical lifetimes
    r3.push_str(" updated");
    println!("{}", r3);
}

Rust 2021エディションでは、非レキシカルライフタイム(NLL)が採用されています。借用は、スコープブロックの終端ではなく、最後に使用された時点で終了します。これにより、安全性を犠牲にすることなく、排他性ルールがより扱いやすくなっています。

NLLの導入により、以前はコンパイルエラーとなっていた多くの正当なコードパターンが受け入れられるようになりました。ボローチェッカーの振る舞いを理解する上で、この仕組みの把握は欠かせません。

Rustの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

ライフタイム:参照の有効期間をコンパイラに伝える

ライフタイムは、参照が指し示すデータよりも長く生存しないことを保証するRustの仕組みです。多くの場合、コンパイラはライフタイム省略規則によって自動的にライフタイムを推論します。複数の参照が相互作用する場合に、明示的な注釈が必要となります。

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

fn main() {
    let result;
    let string1 = String::from("Rust ownership");
    {
        let string2 = String::from("borrowing");
        result = longest(string1.as_str(), string2.as_str());
        println!("Longest: {}", result);
    }
}

'a構文はライフタイムパラメータであり、新しい概念ではなく、既に存在する関係性を注釈するものです。この関数シグネチャは「出力の参照は、いずれの入力参照よりも長く生存できない」ということを表現しています。コンパイラはこの情報を使って、ダングリング参照を防止します。

ライフタイムパラメータは制約を「作成」するのではなく、コードに既に存在する制約を「記述」するという点が重要です。この理解が、ライフタイムに関する混乱を解消する鍵となります。

構造体の借用とライフタイム境界

参照を保持する構造体は、ライフタイムパラメータを宣言する必要があります。これにより、構造体が参照先のデータよりも長く生存できないことが保証されます。C/C++ではダングリングポインタの一般的な原因となる問題ですが、Rustではコンパイル時に防止されます。

struct_lifetime.rsrust
struct Excerpt<'a> {
    text: &'a str,
}

impl<'a> Excerpt<'a> {
    fn summary(&self) -> &str {
        let end = self.text.len().min(20);
        &self.text[..end]
    }
}

fn main() {
    let article = String::from("Rust ownership model eliminates memory bugs");
    let excerpt = Excerpt {
        text: article.as_str(),
    };
    println!("Summary: {}", excerpt.summary());
}

Excerpt<'a>のライフタイム'aは、構造体の有効性を基となる文字列に紐付けています。excerptよりも先にarticleがドロップされると、コンパイルエラーが発生します。

このパターンは、構造体がデータを所有するのではなく参照する場合に不可欠です。所有権を持つStringフィールドを使用する方が単純ですが、パフォーマンスが重要な場面では参照を保持する構造体が有効な選択肢となります。

Common Interview Pitfall

Dangling reference questions appear frequently in Rust interviews. The answer is always: Rust prevents them at compile time through lifetime analysis. No runtime checks, no null pointers.

実務で使われる所有権パターン

本番環境のRustコードでは、いくつかの定番となる所有権パターンが繰り返し使用されます。これらのパターンを認識できることが、開発速度と面接でのパフォーマンスの両方を向上させます。

ownership_patterns.rsrust
fn process_and_return(mut input: String) -> String {
    input.push_str(" -- processed");
    input
}

fn contains_keyword(text: &str, keyword: &str) -> bool {
    text.to_lowercase().contains(&keyword.to_lowercase())
}

fn sanitize(input: &mut String) {
    *input = input.trim().to_string();
}

fn main() {
    let raw = String::from("user input");
    let processed = process_and_return(raw);

    let found = contains_keyword(&processed, "input");
    println!("Contains 'input': {}", found);

    let mut padded = String::from("  spaces everywhere  ");
    sanitize(&mut padded);
    println!("Sanitized: '{}'", padded);
}

これらのパターンの使い分けには、シンプルなヒューリスティックがあります。デフォルトでは不変参照で借用し、変更が必要な場合は可変参照で借用し、呼び出し元がその値を必要としなくなった場合にのみ所有権を移動します。このアプローチはRust基礎ガイドで詳しく解説されています。

process_and_returnは所有権を受け取って返すパターン、contains_keywordは不変借用のみで読み取るパターン、sanitizeは可変借用で変更するパターンです。実務では、この3つのパターンを適切に組み合わせることが求められます。

ボローチェッカーのエラーと対処法

ボローチェッカーは特定のエラーコードを出力します。よく発生するエラーを理解することで、イライラするコンパイルエラーを素直な修正に変えることができます。

common_fixes.rsrust
fn main() {
    let mut scores = vec![90, 85, 78];
    let first = scores[0];
    scores.push(95);
    println!("First: {}, All: {:?}", first, scores);

    let name = String::from("Alice");
    let greeting = format!("Hello, {}", name);
    println!("{} says {}", name, greeting);

    let outer;
    {
        let inner = String::from("temporary");
        outer = inner;
    }
    println!("{}", outer);
}

すべての修正は同じ原則に従います。借用と所有権がRustのルールに沿うようにコードを再構成することです。ボローチェッカーと戦うことは、通常、他の言語ではバグの原因となる設計上の問題があることを示しています。並行処理と借用に関するより高度なパターンでは、ArcMutexのような共有所有権型が不可欠になります。

エラーメッセージを注意深く読むことが重要です。Rustコンパイラのエラーメッセージは非常に詳細で、多くの場合、修正方法の提案も含まれています。エラーコード(例:E0382、E0502)をドキュメントで調べることで、問題の本質を深く理解できます。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

まとめ

  • Rustのすべての値には1つの所有者があり、代入時に所有権が移動(ムーブ)します。ただし、Copyトレイトを実装する型は例外です
  • 不変参照(&T)は共有読み取りアクセスを許可し、可変参照(&mut T)は排他的書き込みアクセスを強制します
  • ボローチェッカーは、データ競合とダングリング参照をコンパイル時に防止し、ランタイムコストはゼロです
  • ライフタイムは参照の関係性を注釈するものであり、新しい制約を作成するのではなく、既存の制約を記述します
  • ボローチェッカーがコードを拒否した場合は、unsafeに頼るのではなく、所有権の流れを再構成するのが正しいアプローチです
  • Rust面接問題集でこれらのパターンを練習し、技術面接に向けた理解を深めることをお勧めします

タグ

#rust
#ownership
#borrowing
#memory-management
#systems-programming

共有

関連記事