Categories: 雑記

【Rust最大の壁を乗り越えろ!】所有権と借用を徹底解説!安全なメモリ管理の仕組み

前回は、Rustのプロジェクト管理ツール「Cargo」の基本と、変数の定義、そして「不変性」の概念について学びました。

さて、今回のテーマは、Rust学習者が最初にぶつかるであろう「最大の壁」と言われる概念です。その名も「所有権(Ownership)」と「借用(Borrowing)」

「変数を使おうとしたら『値が移動しています』とコンパイラに怒られた…」「なんだかよく分からないエラーが出る!」

もしあなたがそんな経験をしているなら、それはまさに所有権と借用の概念に触れている証拠です。

ここがRust学習の最大の難所であることは間違いありませんが、ご安心ください。この記事を最後まで読めば、以下の「学習のゴール」が達成され、コンパイラはあなたの強力な味方になります!

今回の学習のゴール

  • 「値が移動しました」エラーの正体を理解し、解決策(参照)を学ぶ
  • ガベージコレクション(GC)がないのに、なぜRustが安全な言語なのか腹落ちさせる

それでは、Rustの奥深さを一緒に探求していきましょう!

1. 所有権(Ownership)とは?Rustのメモリ管理の根幹

Rustが他の多くの言語と大きく異なる点、それは「所有権システム」にあります。これは、メモリを安全に管理するためのRust独自の仕組みで、コンパイル時にメモリの安全性を保証する画期的なアプローチです。

他の言語では、ガベージコレクション(GC)が自動的に不要なメモリを解放したり、プログラマが手動でメモリを確保・解放したりします。しかしRustでは、所有権システムによって、コンパイル時にどのタイミングでメモリが解放されるかを決定します。これにより、GCによる実行時コストなしに、メモリリークやヌルポインタ参照といったメモリ関連のエラーを未然に防ぎます。

所有権システムには、以下の3つのシンプルなルールがあります。

  1. Rustの各値は、それを所有する変数を持つ。
  2. 一度に所有者は一つだけ。
  3. 所有者がスコープから外れたら、値はドロップされる(メモリが解放される)。

これらのルールが、一見するとシンプルでありながら、Rustの強力な安全性を支えています。

具体的なコードで見ていきましょう。

fn main() {
    // 1. "hello"という文字列データは、変数s1に「所有」される
    let s1 = String::from("hello"); 

    // s1がスコープを抜けるとき、"hello"のメモリは解放される
} // ここでs1はスコープを抜けるので、s1が所有していた"hello"のメモリはドロップされる

Rustでは、String::from() のようにヒープ領域にデータを確保する型(String、Vecなど)に対して、この所有権の概念が特に重要になります。スタック領域に確保されるプリミティブ型(i32、boolなど)とは少し挙動が異なりますが、その違いは後述します。

2. ムーブ(Move)を理解する:値の移動とコンパイラの警告

所有権のルールの中でも特に重要なのが「一度に所有者は一つだけ」という原則です。これが、Rustでしばしば遭遇する「値が移動しました」エラーの原因となります。

まずはコードを見てみましょう。

fn main() {
    let s1 = String::from("hello"); // s1が"hello"の所有者

    let s2 = s1; // ★ここで何が起こるか?

    println!("s1: {}", s1); // コンパイルエラーになる!
    println!("s2: {}", s2); // これはOK
}

このコードを実行しようとすると、println!(“s1: {}”, s1); の行でコンパイラが以下のようなエラーメッセージを出します。

error[E0382]: borrow of moved value: `s1`
 --> src\main.rs:6:24
  |
2 |     let s1 = String::from("hello"); // s1が"hello"の所有者
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |
4 |     let s2 = s1; // ★ここで何が起こるか?
  |              -- value moved here
5 |
6 |     println!("s1: {}", s1); // コンパイルエラーになる!
  |                        ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
4 |     let s2 = s1.clone(); // ★ここで何が起こるか?
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `study_variables` (bin "study_variables") due to 1 previous error

「s1が移動された後で使われているよ!」というエラーです。これはまさに、「ムーブ(Move)」という現象が起きているためです。

Rustでは、s1 のような String 型の値を別の変数 s2 に代入すると、s1 が持っていた「所有権」が s2 に移動します。これにより、s1 はもうその値を「所有」していない状態になるため、以降 s1 を使おうとするとコンパイラに怒られるのです。

なぜ「ムーブ」が必要なのか?

もし s1 の所有権が s2 に移動せず、両方が同じデータを所有している状態(浅いコピーのような状態)になったらどうなるでしょうか?

s1 と s2 の両方がスコープを抜ける際に、それぞれが同じメモリを解放しようとします。これは「二重解放(Double Free)」という深刻なメモリバグを引き起こす可能性があります。

Rustは、この二重解放のようなメモリ安全性の問題をコンパイル時に防ぐために、一度に一つの所有者しか存在しないというルールを徹底し、所有権の移動(ムーブ)を行うのです。

プリミティブ型はなぜムーブしない?(Copyトレイト)

ここで、前回の記事で触れたi32のようなプリミティブ型の変数を思い出してください。

fn main() {
    let x = 5;
    let y = x; // ここでは何が起こる?

    println!("x: {}, y: {}", x, y); // 両方とも問題なく使える
}

このコードは問題なく動作し、xもyも両方使えます。なぜでしょうか?

これは、i32のような単純な値は、値を代入する際に「ムーブ」ではなく「コピー(Copy)」されるためです。これらの型は、Copyトレイトを実装しており、データをスタック上に保存するため、コピーが非常に高速で安価に行えます。

つまり、let y = x; の行で、xの値5がまるごとコピーされ、yという新しい変数に格納されます。そのため、xもyもそれぞれの5という値の所有者となるため、両方とも問題なく使えるのです。

一般的に、固定サイズでスタックに格納できるようなシンプルな型(整数型、浮動小数点型、ブール型、char、Tupleにプリミティブ型しか含まれない場合など)はCopyトレイトを実装しています。一方、サイズが不定でヒープにデータを保持するStringやVecなどはCopyトレイトを実装していません(実装できない)。

3. 所有権と関数:値の受け渡しで何が起こる?

所有権の概念は、関数への値の受け渡しにも影響します。関数に値を渡す際、その値の所有権がどのように移動するかを見ていきましょう。

fn main() {
    let s = String::from("hello"); // sが"hello"の所有者

    takes_ownership(s); // ★ここでsの所有権が関数に移動する

    println!("{}", s); // ここでコンパイルエラーになる!
    // sはもう所有権を持たないため使えない
}

fn takes_ownership(some_string: String) { // some_stringがsの所有権を受け取る
    println!("{}", some_string);
} // ここでsome_stringはスコープを抜けるので、
  // some_stringが所有していた"hello"のメモリはドロップされる

このコードも、println!(“{}”, s); の行で「sが移動された後で使われているよ!」というエラーになります。
takes_ownership 関数に s を渡した時点で、s の所有権は関数の引数 some_string に移動してしまいます。そのため、関数呼び出し後には s は無効となり、使えなくなるのです。

関数から値を返す場合も同様に所有権が移動します。

fn main() {
    let s1 = gives_ownership(); // gives_ownershipから返された値の所有権をs1が受け取る

    let s2 = String::from("world"); // s2が"world"の所有者

    let s3 = takes_and_gives_back(s2); // s2の所有権が関数に移動し、
                                     // 関数から返された値の所有権をs3が受け取る

    println!("s1: {}, s3: {}", s1, s3);
    println!("s2: {}", s2); // s2は所有権を失っているのでエラー
}

fn gives_ownership() -> String {
    let some_string = String::from("hello");
    some_string // 所有権を呼び出し元に返す
}

fn takes_and_gives_back(a_string: String) -> String { // a_stringが所有権を受け取る
    a_string // 受け取った所有権をそのまま呼び出し元に返す
}

このように、Rustの所有権システムは、関数呼び出しのたびに値の所有権の移動を強制します。これにより、メモリのライフサイクルがコンパイル時に明確になり、実行時のメモリ管理のオーバーヘッドをなくしているのです。

しかし、これは同時にプログラマにとっては不便な点でもあります。「ただ値を見たいだけなのに、所有権を渡してしまうと元の変数が使えなくなる…」という問題が発生します。

そこで登場するのが、次のセクションで学ぶ「借用」です。

4. 借用(Borrowing)の登場!所有権を移動させずに値を使う方法

「値を参照したいだけで、所有権は渡したくない!」そんなときに使うのが「借用(Borrowing)」です。
借用とは、所有権を移動させずに、値への「参照(Reference)」を関数に渡すことです。

参照は、& 演算子を使って作成します。

fn main() {
    let s = String::from("hello"); // sが"hello"の所有者

    // &s を渡すことで、sの所有権は移動しない
    // calculate_lengthはsへの「参照」を受け取る
    let len = calculate_length(&s); 

    println!("文字列 '{}' の長さは {} です。", s, len); // sは引き続き使える!
}

fn calculate_length(some_string: &String) -> usize { // 引数は&String型(Stringへの参照)
    some_string.len() // 参照を通して値を使う
} // some_stringは参照なので、スコープを抜けても何もドロップしない

このコードは問題なくコンパイル、実行されます!
&s とすることで、s の所有権はそのまま main 関数内に残り、calculate_length 関数には s の値への「参照」が渡されます。関数内でその参照を使って値にアクセスできますが、所有権は持っていないため、関数を抜けても元の s は無効になりません。

これで「値が移動しました」エラーから解放され、コンパイラと和解の一歩を踏み出しましたね!

参照はデフォルトで不変

先ほどの例で使った &String は「不変参照(Immutable Reference)」と呼ばれます。不変参照を通しては、参照している値を変更することはできません。

fn main() {
    let s = String::from("hello");

    change(&s); // エラーになる!
}

fn change(some_string: &String) {
    // some_string.push_str(", world!"); // コンパイルエラー!
    // `some_string` は不変参照なので変更できない
}

値を変更したい場合は、「可変参照(Mutable Reference)」を使います。可変参照は &mut 演算子を使って作成します。

fn main() {
    let mut s = String::from("hello"); // s自体も可変にしておく必要がある

    change(&mut s); // 可変参照を渡す

    println!("{}", s); // "hello, world!"
}

fn change(some_string: &mut String) { // 引数も&mut String型
    some_string.push_str(", world!"); // 可変参照を通して値を変更できる
}

ポイント:

  • 可変参照を作るには、参照元の変数も mut で可変にしておく必要があります。
  • 関数側も、引数で &mut 型名 と明示的に可変参照を受け取る必要があります。

5. 借用ルールをマスターする:データ競合を防ぐ強力な仕組み

可変参照を使えば値を変更できるようになりましたが、ここには重要な制限があります。それが、Rustのメモリ安全性を強力に保証する「借用ルール」です。

借用ルールは以下の通りです。

  1. いつでも好きなだけ不変参照(&T)をいくつでも持つことができる。
  2. ただし、可変参照(&mut T)は一度に一つしか持てない。
  3. 不変参照がある間は、可変参照は作れない。逆もまた然り。

このルールは、データ競合(Data Race)を防ぐために非常に重要です。データ競合とは、以下の3つの条件が全て揃ったときに発生する状況で、未定義の動作を引き起こす深刻なバグの原因となります。

  • 2つ以上のポインタが同じデータに同時にアクセスする。
  • 少なくとも1つのポインタがデータを書き込んでいる。
  • データへのアクセスが同期されていない。

Rustの借用ルールは、コンパイル時にこれらのデータ競合を完全に排除します。

具体的なコード例を見てみましょう。

複数の不変参照はOK

fn main() {
    let s = String::from("hello");

    let r1 = &s; // 不変参照1
    let r2 = &s; // 不変参照2

    println!("{}, {}", r1, r2); // 問題なく両方使える

    // r1とr2はここより先は使われない
}

不変参照は、データを読み取るだけで変更しないため、同時にいくつ存在しても安全です。

複数の可変参照はNG

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s; // 可変参照1
    let r2 = &mut s; // ★コンパイルエラー!
    // 同時に複数の可変参照は作れない

    println!("{}, {}", r1, r2); // エラーになる
    println!("{}", r1);
}

コンパイルエラー: cannot borrowsas mutable more than once at a time
「sを同時に複数回可変で借用することはできません」という明確なメッセージです。
もし複数の可変参照が同時に存在すると、どちらか一方がデータを変更している間に、もう一方がそのデータを読み書きしてしまうデータ競合のリスクが生じるため、Rustはこれを許しません。

不変参照と可変参照の混在はNG

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // 不変参照
    let r2 = &s; // 不変参照 (r1とr2はOK)

    // let r3 = &mut s; // ★コンパイルエラー!
    // 不変参照(r1, r2)が存在する間に可変参照は作れない

    println!("{}, {}", r1, r2); // r1とr2はここで使われなくなる

    let r3 = &mut s; // ここではOK (r1, r2はもう使われないため)
    println!("{}", r3);
}

コンパイルエラー: cannot borrowsas mutable because it is also borrowed as immutable
「sが不変で借用されているため、可変で借用することはできません」というエラーです。
不変参照が存在するということは、そのデータは変更されないと保証されている状態です。その保証がある間に可変参照が作られ、データが変更されてしまうと、不変参照を使っている側が意図しない動作をする可能性があるため、Rustはこれも許しません。

これらの借用ルールによって、Rustは実行時に発生しがちなメモリ関連のバグ(特に並行処理におけるデータ競合)をコンパイル時に確実に取り除くことができます。これがRustの最大の特徴であり、他の言語にはない強力な安全性と信頼性を生み出す秘訣なのです。

6. スライス(Slice)で部分参照を安全に扱う

所有権と借用の概念を理解したところで、もう一つ便利な概念である「スライス(Slice)」についても見ていきましょう。

スライスは、コレクション(配列や文字列など)の連続した一部分への参照です。所有権を持たずに、データの一部を安全に参照したい場合に非常に役立ちます。

Stringスライス(&str)

Rustにおける文字列は大きく分けて String と &str の2種類があります。

  • String: ヒープに格納され、所有権を持つ可変な文字列。
  • &str (文字列スライス): 文字列リテラルや String の一部を指す不変参照。

String から &str を作成してみましょう。

fn main() {
    let s = String::from("hello world");

    // "hello" の部分へのスライス
    let hello = &s[0..5]; 
    // "world" の部分へのスライス
    let world = &s[6..11]; 

    println!("hello: {}", hello); // hello: hello
    println!("world: {}", world); // world: world

    // s全体へのスライスも作れる
    let whole_string = &s[..]; 
    println!("whole_string: {}", whole_string); // whole_string: hello world
}

スライスは &[開始インデックス..終了インデックス] の形式で指定します。終了インデックスは含まれません。
インデックスはバイト単位なので、日本語のようなマルチバイト文字を含む文字列をスライスする場合は注意が必要です(UTF-8文字境界を考慮しないとパニックする可能性があります)。

なぜスライスが安全なのか?

スライスを使うことで、文字列や配列の一部分を安全に扱えます。特に便利なのは、スライスが参照している元のデータが変更されたり解放されたりしないことをRustコンパイラが保証してくれる点です。

例えば、文字列の最初の単語を返す関数を考えてみましょう。

fn first_word(s: &String) -> &str { // Stringへの参照を受け取り、Stringスライスを返す
    let bytes = s.as_bytes(); // Stringをバイト配列に変換

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' { // スペースが見つかったら
            return &s[0..i]; // その位置までのスライスを返す
        }
    }

    &s[..] // スペースが見つからなければ文字列全体のスライスを返す
}

fn main() {
    let mut s = String::from("hello world");

    let word = first_word(&s); // wordはsへのスライスを参照

    // s.clear(); // ★もしここでsをクリアしたらどうなる?
                  // wordが指すデータが無くなってしまう。
                  // しかし、Rustはこれを許さない!

    println!("最初の単語: {}", word); // s.clear()がなければ "最初の単語: hello"
}

s.clear() のコメントアウトを外すと、コンパイルエラーが発生します。

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
  --> src/main.rs:20:5
   |
17 |     let word = first_word(&s);
   |                          -- immutable borrow occurs here
...
20 |     s.clear(); // ここでsをクリアすると
   |     ^^^^^^^^^ mutable borrow occurs here
21 |     println!("最初の単語: {}", word);
   |                                ---- immutable borrow later used here

これはまさに借用ルール「不変参照がある間は、可変参照は作れない」が適用されている例です。
word が s への不変参照 (&str) を持っている間は、s を変更する操作 (s.clear()) は許可されません。これにより、スライスが無効なデータを指してしまう「ダングリングポインタ(Dangling Pointer)」のような問題をコンパイル時に防いでくれるのです。

この安全性が、Rustが他の言語と一線を画す強力な特徴の一つです。

配列スライス(&[T])

文字列スライスと同様に、配列の一部への参照である配列スライスも使えます。

fn main() {
    let a = [1, 2, 3, 4, 5];

    let slice = &a[1..3]; // インデックス1から3(2と3番目の要素)へのスライス

    assert_eq!(slice, &[2, 3]); // スライスは配列リテラルと比較可能
    println!("配列スライス: {:?}", slice); // 配列スライス: [2, 3]
}

7. なぜRustはGCなしで安全なのか?所有権と借用が織りなす魔法

これまでの内容を振り返ると、Rustがガベージコレクション(GC)なしにメモリ安全性を保証できる理由がはっきりと見えてきます。

  • 所有権: 各値は常に明確な所有者を持ちます。所有者がスコープを抜けるときに自動的にメモリが解放されるため、メモリリーク(解放忘れ)を防ぎます。
  • ムーブ: 値の代入や関数への受け渡しで所有権が移動することで、一つのリソースを複数のポインタが同時に管理し、二重解放のようなバグが発生するのを防ぎます。
  • 借用と借用ルール: 所有権を移動させずに値への参照を渡すことで、データへのアクセスを可能にします。そして、「一度に可変参照は一つだけ」「不変参照と可変参照は共存できない」という厳格なルールによって、コンパイル時にデータ競合を完全に排除します。
  • スライス: コレクションの一部を安全に参照する仕組みで、参照先のデータが不整合な状態にならないようコンパイラが保証します(例: スライスが指している間に元のデータが変更されることを防ぐ)。

これら全ての仕組みが連携し、コンパイル時にプログラムのメモリ安全性を静的に検証します。もしルールに違反するコードを書けば、コンパイラが容赦なくエラーを出し、バグの芽を摘んでくれます。

まるで、コンパイラが最高のコードレビューアのように、常にあなたのコードの安全性をチェックしてくれるのです。最初は厳しく感じるかもしれませんが、一度この仕組みを理解してしまえば、Rustがいかに強力で信頼性の高い言語であるかを実感できるはずです。

「Rustコンパイラは、あなたの最強の味方!」

8. まとめ:コンパイラはあなたの最強の味方!

今回はRust学習の最大の壁と言われる「所有権」と「借用」について、徹底的に掘り下げてきました。

  • 所有権: 各値は一つの変数に所有され、スコープを抜けるときにメモリが解放される。
  • ムーブ: Stringのようなヒープデータは、代入や関数渡しで所有権が移動し、元の変数は使えなくなる。これにより二重解放を防ぐ。
  • 借用: & を使って所有権を移動させずに値への参照を渡す。
  • 可変参照(&mut): 参照先の値を変更できるが、「一度に一つだけ」という厳格なルールがある。
  • 借用ルール: データ競合を防ぐため、「不変参照は複数OK」「可変参照は一つだけ」「不変参照と可変参照は排他的」というルールが適用される。
  • スライス: コレクションの一部への安全な参照。スライスの有効期間中は元のデータが不整合な状態にならないことをコンパイラが保証。

これらの概念は、Rustの性能と安全性を両立させるための核心であり、使いこなせるようになれば、あなたはRustプログラマとして大きく成長したと言えるでしょう。

最初はコンパイラのエラーメッセージに戸惑うかもしれませんが、それはRustがあなたをより良い、より安全なコードへと導いてくれている証拠です。諦めずに、ぜひこの強力なシステムをマスターしてください!

にいやん

出身 : 関西 居住区 : 関西 職業 : 組み込み機器エンジニア (エンジニア歴13年) 年齢 : 38歳(2022年11月現在) 最近 業務の効率化で噂もありPython言語に興味を持ち勉強しています。 そこで学んだことを記事にして皆さんとシェアさせていただければと思いブログをはじめました!! 興味ある記事があれば皆さん見ていってください!! にほんブログ村