Rustにおける所有権
Rustの所有権システムは、メモリ安全性をコンパイル時に保証するための非常にユニークな仕組みです。他の言語ではガベージコレクション(GC)や手動でのメモリ管理によってメモリ安全性を担保しますが、Rustは所有権、借用(Borrowing)、ライフタイム(Lifetimes)という概念を用いて、実行時オーバーヘッドなしにメモリ安全性を実現します。
Rust言語におけるメモリ管理の考え方(他言語との比較)
他のプログラミング言語がどのようにメモリを管理しているかを見て、Rustの所有権がなぜユニークなのかを理解しましょう。
- Java, Python, JavaScriptなどガベージコレクタ(GC)を持つ言語:
これらの言語では、開発者がメモリの解放を意識する必要はほとんどありません。不要になったメモリ領域は「ガベージコレクタ」が自動的に検出し、解放してくれます。これは非常に便利ですが、GCがいつ実行されるか予測しにくい、GCの実行によって一時的にプログラムの実行が停止する(Stop-the-world)、といったオーバーヘッドが発生する可能性があります。 - C, C++など(手動メモリ管理の言語):
これらの言語では、メモリの確保(malloc
,new
など)と解放(free
,delete
など)を開発者が明示的に行います。これにより、非常に細かいレベルでメモリを制御でき、高いパフォーマンスを実現できます。しかし、メモリの解放忘れ(メモリリーク)や、既に解放されたメモリにアクセスしてしまう(Use-after-free)、二重解放(Double-free)といったバグが発生しやすく、これらのバグは発見・修正が非常に困難です。 - Rust(所有権システム):
RustはGCを持たず、手動でメモリを解放することもなく、コンパイル時にメモリ安全性を保証します。その中心にあるのが「所有権」の概念です。
Rustにおける所有権
Rustにおける「所有権」とは、簡単に言えば「あるデータがメモリ上のどこにあるのかを誰が責任を持って管理するか」という概念です。
Rustでは、メモリ上のすべてのデータは、たった一つの変数(所有者)によって所有されます。
所有者には以下のルールがあります。
- 各値は、それを所有する変数(所有者)を持つ。
- 一度に持てる所有者は1つだけである。
- 所有者がスコープを抜けると、値はドロップ(解放)される。
この最後のルールが特に重要で、メモリの解放を開発者が明示的に行わなくても、所有者がスコープを抜けるときに自動的に行われるため、メモリリークを防ぐことができます。
3. 「所有権の移動」(Ownership Transfer)とは?
「所有権の移動」とは、ある変数から別の変数へ、データの所有権が移ることを指します。所有権が移動すると、元の変数はそのデータにアクセスできなくなります。
所有権移動の実例
所有権移動の基本動作
型がプリミティブ型なら他の言語とかわりありません。直感の通りに動作します。
fn main() {
let i1 = 32;
let i2 = i1;
println!("{}", i1);
println!("{}", i2);
}
i2 = 32; は、「宣言と代入」ではなく「宣言と束縛」と呼びます。
変数がプリミティブ型ならlet i2 = i1ではコピーされた値(ここでは32)がi2に束縛されます。i1とi2の間で行われるのはコピーです(Copy Semantics)。
ところが、似たようなコードでもプリミティブ型以外だと動作が異なります。次のコードはString型を変数に束縛しますがコンパイルエラーが発生します。
fn main() {
let s1 = String::from("Hello"); // s1が"Hello"というStringの所有者になる
let s2 = s1; // ★所有権の移動が発生! s1からs2へ所有権が移動する
// println!("{}", s1); // コンパイルエラー! s1はもう所有権を持たないため、使えない
println!("{}", s2); // s2は所有者なので、問題なく使える
}
fn main() {
let s1 = "hello, Rsut".to_string();
println!("{}", s1);
let s2 = s1; // 所有権がs1からs2へ移動する。
// println!("{}", s1); // コンパイルエラー s1はもはや所有権をもっていない。
println!("{}", s2); // s2は所有権者なのでここで問題なく使える。
}
5行目はコメントをはずすと、コンパイルエラーとなります。
move occurs because s1
has type String
, which does not implement the Copy
trait
このコンパイルエラーは初学者には少しショックです。プリミティブ型以外の場合(例ではString型)には、let s2 = s1;の時点で値はs2に束縛され、所有権はs1からs2へ移動します Move Semantics。 このため移動以降はs1は無効で使えなくなる、という考え方です。
関数への引数と所有権の移動
関数に引数を渡す場合にも所有権の移動が発生します。
fn sample_func(some_string: String) { // some_stringが所有権を受け取る
println!("{}", some_string);
} // ここでsome_stringがスコープを抜け、所有していたデータが解放される
fn main() {
let s = String::from("world"); // sが所有者
sample_func(s); // sからsample_func関数内のsome_stringへ所有権が移動
// println!("{}", s); // コンパイルエラー! sはもう所有権を持たない
}
所有権の返還
実際には、関数の呼び出しの後も変数を引き続き使用することが多いはずです。そのために所有権を変換する、という定型パターンがあります。
fn takes_and_gives_back(a_string: String) -> String { // 所有権を受け取り、返す
println!("{}", a_string);
a_string // 所有権を呼び出し元に返す
}
fn main() {
let s1 = String::from("hello"); // s1が所有者
let s2 = takes_and_gives_back(s1); // s1から関数へ移動、その後関数からs2へ移動
// println!("{}", s1); // コンパイルエラー。s1はもう所有権を持たない
println!("{}", s2); // s2は新しい所有者なので使える
}
このパターンでは、関数を介して所有権が移動し、最終的にs2
が所有者となります。s1
はやはり使えません。
借用(Borrowing)する
こちらがRustにおける一般的な方法で、参照を渡すことで所有権を移動することなく関数を呼び出します。ここで使われるのが「借用」の考え方です。
fn func_example(s: &String) -> usize {
// &StringはStringへの参照を受け取る
s.len()
} // ここではsの所有権は移動していない
fn main() {
let s1 = String::from("hello"); // s1が所有者
let len = func_example(&s1); // 参照を渡すが、所有権は移動しないので借用となる
println!("The length of '{}' is {}.", s1, len); // s1は引き続き所有権をもっており使用できる
}
&s1
のように &
をつけることで、所有権を移動させずに「参照」を渡すことができます。これにより、func_example
関数はs1
が所有するデータにアクセスできますが、所有権はmain
関数内のs1
が持ち続けます。これが「借用」の基本です。
Copyトレイトについて
ところで最初の例で、型がプリミティブ型なら代入時の動作が他の言語とかわりないということを説明しました。
Rustでは、整数型(i32
, u64
など)、真偽値(bool
)、浮動小数点数型(f64
など)、文字型(char
)、そしてこれらの型のみで構成されるタプルなど、コンパイル時にサイズが分かっていて、スタックに直接格納されるようなシンプルな型は、デフォルトで「Copy
トレイト」というものを実装しています。
Copy
トレイトを実装している型の場合、代入や関数への引数渡しが行われても、元の値がコピーされ、所有権は移動しません。つまり他の多くの言語と同様の動作となります。
※Copyトレイトについては、別項で説明します。
コメント