Rustのメモリ管理
C言語のメモリ管理はとても扱いにくいものでした。間違ったプログラマが悪いのかもしれませんが、世界中のエンジニアが徹夜してメモリ関係の不良と向き合ってきました。メモリ関係のバグはプログラミング言語が何とかしてくれよ!!とみんなが思っています。
しかし、プログラミング言語はどうすればいいのか?
Javaはガベージコレクションという仕組みを解答としました。しかし、いつかフルGCが走って性能低下を引き起こすので、性能を理由にC言語を使っている人たちにとっては無理でした。
「メモリ関係のバグをプログラミング言語側で引き受ける」ということがどれだけ難しいかは、C++ががいろいろな改善を試行錯誤していることでも分かります。
C++の壮大な試行錯誤の結果がRustのメモリ管理に結実しています。
Rustのプログラミング言語としての言語仕様でメモリ関係のバグを根絶してくれます。少し分かりにくい言語仕様となっていますが、時間をかけて学ぶ価値があるというものです。
C言語のメモリ管理がつらかったのを知りたい方はこちらをどうぞ。
変数スコープ
C++にも所有権と変数スコープの話はあります。C++ではなんとなく理解しておけばプログラムを書けますが、Rustではしっかりと心に刻んでおかないとコンパイルエラーを取り除けないことになります。
Rustではスコープを抜けると破棄されます。
以下のようにカッコ{}で囲った中で文字列リテラルのsは有効です。
sのスコープは{から}まで、という言い方をします。
{ // sは、ここでは有効ではない。まだ宣言されていない let s = "hello"; // sは、ここから有効になる // sで作業をする } // このスコープは終わり。もうsは有効ではない
これはスカラー型の値でも同じです。
{ // xは、ここでは有効ではない。まだ宣言されていない let x = 1; // xは、ここから有効になる // xで作業をする } // このスコープは終わり。もうxは有効ではない
上記ではsもxもスタック領域にとられた変数です。次はヒープ領域の変数を見てみます。
{ let s = String::from("hello"); // sはここから有効になる // sで作業をする } // このスコープはここでおしまい。sは // もう有効ではない
sは文字列リテラルではなくString型になります。String型はヒープ領域にとられます。
※CやC++の人は、sはヒープ領域の変数のポインタと理解するのがいいと思います。
スタック領域であっても、ヒープ領域であってもカッコで示すスコープを抜ければ破棄されてなくなります。
所有権、ムーブとクローン
スタック領域の変数とヒープ領域の変数で扱いが異なるのがムーブです。
以下のようにすると、x=5でyも5になります。xもyもアクセス可能です。
let x = 5; let y = x;
以下のようにヒープ領域で同じことをやると、s2=s1とやった段階で、s1のスコープは終了します。つまり、s2=s1とした後にs1にアクセスするとエラーになります。これを所有権がムーブしたあるいは単にムーブしたと表現します。
let s1 = String::from("hello"); let s2 = s1;
※ポインタからアクセスできる人は一人だけという状態にしておけば、メモリ破壊とか二重free()が防げます
s2を値としてコピーしたい場合、つまりs1とは別に新たにヒープ領域に領域を確保したい場合はクローンを用います。
let s1 = String::from("hello"); let s2 = s1.clone();
これで、s1とs2は異なる領域を指す変数となります。
関数とムーブ
スタック領域の変数は代入してもムーブせず、ヒープ領域の変数は代入するとムーブしました。
関数コールでも同じことが言えます。スタック領域の変数は関数コールではムーブしないですが、ヒープ領域の変数は関数コールでムーブします。
以下のようにmakes_copy()という関数をコールしてもムーブされません。
fn main() { let x = 5; // xがスコープに入る makes_copy(x); // xも関数にムーブされるが、 // i32はCopyなので、この後にxを使っても // 大丈夫 } fn makes_copy(some_integer: i32) { // some_integerがスコープに入る println!("{}", some_integer); } // ここでsome_integerがスコープを抜ける。何も特別なことはない。
しかし、以下のようにヒープ領域の変数sはtakes_ownership()をコールするとムーブします。さらに関数が終わるとsのスコープが終了します。
fn main() { let s = String::from("hello"); // sがスコープに入る takes_ownership(s); // sの値が関数にムーブされ... // ... ここではもう有効ではない } fn takes_ownership(some_string: String) { // some_stringがスコープに入る。 println!("{}", some_string); } // ここでsome_stringがスコープを抜け、`drop`が呼ばれる。メモリが解放される。
関数コールした後も変数を使いたいときは、2通りの方法があります。ヒープ領域の変数を関数コール後も使うのは、戻り値で返す、か参照を使うかです。
戻り値で所有権をムーブする
まず、次のように関数側でヒープ領域のメモリを確保して戻り値で所有権をムーブすることができます。
fn main() { let s1 = gives_ownership(); // gives_ownershipは、戻り値をs1に // ムーブする } fn gives_ownership() -> String { // gives_ownershipは、戻り値を // 呼び出した関数にムーブする let some_string = String::from("hello"); // some_stringがスコープに入る some_string // some_stringが返され、呼び出し元関数に // ムーブされる }
これを利用すると、関数にきた所有権を戻り値で返すことができます。
fn main() { let s2 = String::from("hello"); // s2がスコープに入る let s3 = takes_and_gives_back(s2); // s2はtakes_and_gives_backにムーブされ // 戻り値もs3にムーブされる } // takes_and_gives_backは、Stringを一つ受け取り、返す。 fn takes_and_gives_back(a_string: String) -> String { // a_stringがスコープに入る。 a_string // a_stringが返され、呼び出し元関数にムーブされる }
参照と借用
引数を参照にすると所有権はムーブしません。
fn main() { let s1 = String::from("hello"); let len = calculate_length(&s1); // '{}'の長さは、{}です println!("The length of '{}' is {}.", s1, len); } fn calculate_length(s: &String) -> usize { s.len() }
calculate_length()の引数で&Stringが「String参照型」になります。呼び出す方も&をつけて呼ぶ必要があります。
calculate_length()内ではsの所有権を持っていないので所有権を借用(borrow)しているという言い方をします。
まとめ
変数の所有権という概念と、ムーブ、参照、借用などの概念によってメモリが守られます。
大事なのはこのような規則を破るとコンパイラがエラーを吐くということです。CやC++ではコンパイラは通し「動かすとエラー」、下手をすると「動かし続けてごくたまにおかしい」みたいに難しい不良になりえました。
慣れないと煩わしいですが、メモリ関係のバグがなくなると思えばそんな苦労は小さいものです。
コメント