【Rust入門】所有権と借用の基本を分かりやすく解説

Rust の大きな特徴の1つである所有権(Ownership)と借用(Borrowing)について基本的な考え方を初心者にも分かりやすく解説します。
Rust の所有権(Ownership)
所有権の規則
Rust の公式ドキュメントである The Book の所有権の章で書かれている基本原則は以下の3つです。
- Rust の各値は、所有者と呼ばれる変数と対応している。
- 所有権を持てる変数は、ある時点で必ず1つだけである。
- 所有権がスコープから外れたら、値は破棄される。
Rust では、各値に対して「誰が責任をもつか(所有権をもつか)」を明確にします。所有者となる変数は必ず1つであり、その変数がスコープから外れると、所有する値は自動的に破棄されます。これにより、メモリの手動解放が不要になり、メモリリークを防ぐことができます。
また、所有者の変数がスコープを抜けたときに1度だけ破棄処理が行われるため、C/C++ のように手動で開放する必要がありません。これにより、メモリの二重解放といったバグも防ぐことができます。
所有権の移動(ムーブ)
所有権を持っている変数は、所有者である間は自由に使うことができます。しかし、変数への代入などが行われる際に、所有権が移ります。所有権が移動することをムーブ(move)と言います。ただし、所有権を移動せずにコピーとして処理される例もありますので、そのパターンについては後述します。
所有権が移動される代表的なパターンは以下になります。
- 他の変数への代入
- 関数に引数として渡すとき
- 関数から戻り値として返すとき
それぞれを簡単な例で見ていきましょう。
他の変数への代入
以下は、String
型の変数 s1
の値を s2
に代入している例です。
fn main() { let s1 = String::from("Hello World!"); // 所有権が s1 から s2 に移動(ムーブ)する let s2 = s1; // ここから s1 は使用できなくなる println!("{}", s1); }
上記の例でコンパイルをしようとすると「borrow of moved value: s1
」というコンパイルエラーとなります。これは「let s2 = s1;
」のタイミングで所有権が s2 へ移動してしまっているためです。
関数に引数として渡すとき
以下は、main
関数で定義した s1
という String
型の変数を関数 takes_ownership
へ実引数として渡しています。
// 所有権が呼び出し元の変数から s に移動(ムーブ)してくる fn takes_ownership(s: String) { println!("{}", s); } fn main() { let s1 = String::from("Hellow World"); // 所有権が関数の引数に移動(ムーブ)する takes_ownership(s1); // ここから s1 は使用できなくなる。 println!("{}", s1); }
上記についてもコンパイルしようとすると「borrow of moved value: s1
」というコンパイルエラーとなります。この例では、関数の仮引数 s
に渡された際に所有権が s
に移っています。そのため、main
関数では s1
は使用できなくなります。
また、println!
の部分をコメントアウトすればコンパイルはできますが、関数側に所有権が渡されていることにより関数ブロックが終わったタイミングで s
は既に破棄されます。そのため、関数から戻った時点で文字列は既に破棄されています。
関数から戻り値として返すとき
以下は、関数 gives_ownership
関数内で定義した s
を呼び出し元の main
関数へ返している例です。
fn gives_ownership() -> String { let s = String::from("Hello World"); // 所有権が呼び出し元に移動(ムーブ)する s } fn main() { // 関数で定義した変数の所有権が移動(ムーブ)してくる let s1 = gives_ownership(); println!("{}", s1); }
上記の例では、gives_ownership
関数内で定義された変数ですが、main
関数側に戻ってきた際に、s1
変数に所有権が移っています。
ムーブ と コピー
上記では、所有権の移動が起こる例を見てきました。Rust では、変数の代入や関数への引数渡しの際には「ムーブ」または「コピー」が行われます。
コピーでは、主にスタック上に保持されているような小さなデータ型の値が、ビット列ごと複製され、両方の変数が独立してメモリ上に同じ値を保持できます。一方でムーブでは、元の変数から新しい変数へ所有権だけが移り、実体はメモリ上に1つだけです。
Rust の型においては、以下のような違いがあります。
- 基本型の
i32
などのスタックに格納される変数はコピーとなる。 String
、Vec<T>
などのヒープに格納される変数はムーブとなる。
Rustでは、データがメモリ上のどこに置かれるか(スタック or ヒープ)によって、コピーされるかムーブされるかが決まる傾向があります。
ヒープで確保される型は、サイズを変更することができ、容量が大きくなる可能性もあることから、コピー自体の負荷も高くなる可能性があるためです。一方で、スタックに配置される型は、サイズも小さく決まっており、コピーは素早くできるため、全体の効率が良くなります。
スタックとヒープ
スタックとヒープという言葉を出しましたので、簡単に触れておきましょう。メモリ領域の中には「テキスト領域」「静的領域」「スタック領域」「ヒープ領域」という領域が分かれています。それぞれの領域の使われ方は以下の通りです。
- テキスト領域:プログラムの実行コードが格納されます。
- 静的領域:
static
変数など、プログラム全体を通じて使われる値が格納されます。 - スタック領域:関数の変数(
i32
、bool
など)の小さな値が置かれます。サイズが決まっているため出し入れが速く処理効率が高いです。 - ヒープ領域:
String
やVec<T>
のようにサイズが実行時に決まるデータが使う領域です。
コピーとムーブの例を確認
コピーされる例とムーブされる例を見ておきましょう。
fn main() { // コピーされる例 (i32) let x = 10; let y = x; // コピーなので x も y も使用できる println!("{}", x); println!("{}", y); // 移動 (ムーブ) される例 (String) let s1 = String::from("Hello World"); let s2 = s1; // 所有権が移っているので s2 は使える println!("{}", s2); // s1 は使えない (コメントアウトを外すとコンパイルエラー) // println!("{}", s1); }
上記では、基本型である i32
の変数 x
、y
の代入と、String
型である s1
、s2
の代入をしています。基本型である i32
は x
の値が y
にコピーされるためどちらの値も使用できています。一方で、String
型については、s2 = s1;
の時点で所有権が移動(ムーブ)しているため、それ以降 s1
は使えません。(上記例でコメントアウトを外すとコンパイルエラーとなります。
clone
の活用:所有権を保持したままデータをコピー
所有権が移動するような String
、Vec
型についても clone()
を使用することにより明示的にコピーすることも可能です。
fn main() { let s1 = String::from("Hello World!"); // clone によりコピーをすることも可能 let s2 = s1.clone(); // s1 も s2 もいずれも使える println!("{}", s1); println!("{}", s2); }
【実行結果】 Hello World! Hello World!
Rust では、コンパイルエラーで非常に詳しくエラーメッセージを出してくれますので、メッセージを注意深く読むことで、問題の場所やスコープをしっかり見直すことで所有権の問題を解消することができます。
ただし、Rust 初心者でどうしてもコンパイルエラーを解消できない場合は、clone()
を使用して所有権の問題を回避するのも1つの方法です。clone()
を使用するような型(String
や Vec
)はヒープ領域にあるデータであるため、コピーで複製することは実行コストが基本型などよりも高くなる傾向にあります。このことを理解して使用をよく検討するようにしましょう。
Rust の借用(Borrowing)
借用(Borrowing)とは、所有権の移動を行わずに、他の変数から値を一時的に使わせてもらうことです。借用の中には、以下の2種類の参照があります。
- 不変参照(immutable reference):読み取り専用の借用
- 可変参照(mutable reference):書き込み可能な借用
それぞれの例を見ていきましょう。
不変参照
不変参照(immutable reference)は、読み取り専用の借用のことで「&
」キーワードを使用して表します。
fn print_message(s: &String) { // 不変参照なので読み取りのみ println!("{}", s); } fn main() { let s1 = String::from("Hello World"); // s1 の内容を不変参照で関数に渡す print_message(&s1); // s1 は所有権を持っているので、ここでもまだ使える println!("{}", s1); }
【実行結果】 Hello World Hello World
関数を呼び出す際には、「&変数名
」という形で不変参照を渡します。関数側の型注釈も「: &型名
」としておく必要があります。
上記例では、関数 print_message
に不変参照で s1
変数を渡しています。この時には、所有権の移動(ムーブ)は起こらないため、関数から戻ってきた際でも s1
変数を使用することが可能です。なお、関数の終了と共に、関数内の参照変数 s
のスコープは終了します。ただし、参照先の実体 s1
が破棄されるわけではありません。
可変参照
可変参照(mutable reference)は、書き込みも可能な借用のことで「&mut
」キーワードを使用して表します。
fn append_message(s: &mut String) { // 可変参照なので変更可能 s.push_str("!!!"); } fn main() { let mut s1 = String::from("Hello World"); // s1 の内容を可変参照で関数に渡す append_message(&mut s1); // 関数側で変更された結果が表示される println!("{}", s1); }
関数を呼び出す際には、「&mut 変数名
」という形で可変参照を渡します。関数側の型注釈も「: &mut 型名
」としておく必要があります。
上記の例では、変更可能なように参照を渡しているため、関数 append_message
側で文字列を変更することが可能です。また、main
関数に戻ってきた際にも変更が反映されていることが分かるかと思います。なお、関数の終了と共に、関数内の参照変数 s
のスコープは終了します。ただし、参照先の実体 s1
が破棄されるわけではありません。
借用チェッカー(borrow checker)
Rust のコンパイラには、借用チェッカー(borrow checker)という重要な機能があります。この機能は、借用が安全に使われているかをコンパイル時に自動的にチェックしてくれます。Rust が「コンパイラが通れば安全性が保証される」と言われる中核の機能です。
所有者である元の変数と共に存在できる参照の組み合わせは、基本的に次の2つのどちらかに限られます。
- 元の変数(所有者)+ 1つまたは複数の不変参照
- 元の変数(所有者)+ 可変参照 1つ
借用の基本的な考え方としては、以下のようになっており、借用チェッカーがコンパイルでチェックしてくれます。
- 参照があるときには、元の所有権を持つ変数であっても値の変更はできません。
- 可変参照(
&mut T
)は、ある時点で1つだけしか存在できません。これによりデータの競合を防止することができます。 - 可変参照と不変参照は共存できません。これにより、不変参照が参照している間に意図せず値が変わってしまうことはありません。
- 参照が存在する間は、参照先のデータは解放されず、必ず有効なまま保持されます。これにより無効なメモリを指す状態(ダングリングポインタ)は発生しません。
現在状態に対して、どういった対応が可能かをまとめた表が以下になります。
状態 | 不変参照の作成 | 可変参照の作成 | 所有権を持つ 変数の値を変更 |
---|---|---|---|
参照が存在しない | 〇 (自由に作れる) | 〇 (不変参照が存在しなければ作れる) | 〇 (参照がなければ自由に変更可能) |
不変参照が存在 (読み取り専用) | 〇 (複数OK) | × (不可) | × (不可) |
可変参照が存在 (変更可) | × (不可) | × (同時に複数不可) | × (不可) |
まとめ
Rust の所有権と借用の仕組みは、最初は少し難しく感じるかもしれません。しかし、この記事で紹介したように、基本的なルールは非常に明確でシンプルです。
- 所有権を持つ変数は必ず1つだけです。
- 所有権は移動(ムーブ)が基本ですが、型の種類によりコピーの場合もあります。
- 借用により渡せる参照には不変参照と可変参照があり、それぞれ同時に扱える条件が異なります。
- Rust のコンパイラは「借用チェッカー」によって、これらのルールをコンパイル時に厳密にチェックしてくれます。
この仕組みにより、C/C++ などの言語でよく見られる メモリの二重解放やダングリングポインタといったバグを、言語レベルで防止できます。結果として、安全で効率的なプログラムを記述することができるのが Rust の大きな魅力です。
最初のうちは所有権や借用でコンパイルエラーになることも多いかもしれませんが、それは Rust が安全性を重視して設計された言語である証拠です。丁寧にエラーメッセージを読み、今回学んだルールを少しずつ理解していけば、自然と扱えるようになっていくと思います。
所有権と借用は、Rust の基礎であり、理解が進めば進むほど Rust らしいコードが書けるようになります。ぜひ繰り返し復習して、確かな理解を身につけてください。