はじめに
こんにちは!ITスクールRareTECHにてCS(Customer Support)を担当している池村です。
今回の記事はRustの所有権についてです。私自身、今まで触ってきた言語でメモリを意識することはあまりなかったので、初見では『?』となった概念でした。
※どちらかというと私の備忘録的な内容にもなっています
今回は公式ドキュメントの以下のページの内容を自分なりの言葉でまとめたものです。
Rustのメモリ管理はどうなっているのか?
プログラミング言語のメモリ管理といえば有名なのはGC(ガベージコレクション)ですね。今までの言語ではほぼこれが搭載されている気がします。CやC++などには搭載されていないですが、メモリを自由にいじれるあまり、様々なバグが発生していたことから搭載されるようになったものです。
Rustの場合、C言語やGCが搭載された言語とは違って、独自のメモリ管理の手法がとられています。
それが所有権と呼ばれるものです。
スタックとヒープについて
メモリの話をする際に出てくるのがスタック領域とヒープ領域というものです。これはメモリ上でのデータの管理場所の違いといったイメージを持ってくれればOKです。
スタック領域
スタック領域に保持されるデータはすべて固定長(長さや大きさが決まっている)であり、取り出すのも速い特徴があります。
以下の表がそれらですが、あまり覚えなくて良いです。数値とか、真偽値とか、配列(固定)などが含まれると簡単に覚えたいです。
種類 | 具体例 | 説明 |
---|---|---|
スカラー型(Scalar Types) |
i32 , u32 , f64 , bool , char
|
固定サイズの基本型。整数や浮動小数点、真偽値、文字型など。 |
コンパイル時にサイズが決まるタプル |
(i32, i32) , (bool, f64)
|
すべての要素が固定サイズの場合、スタックに保存される。 |
固定サイズの配列(Array) |
[i32; 4] , [bool; 10]
|
要素数が決まっている配列はスタックに保存される。 |
関数ポインタ | fn(i32) -> i32 |
関数そのものではなく、関数を指し示すポインタがスタックに保存される。 |
参照(Reference) |
&T , &mut T
|
ヒープや他の場所にあるデータへの参照自体はスタックに保存される。 |
固定サイズの構造体(Struct) | struct Point { x: i32, y: i32 } |
フィールドが固定サイズで構成されている構造体はスタックに保存される。 |
固定サイズの列挙型(Enum) | enum Color { Red, Green, Blue(i32) } |
フィールドが固定サイズで構成される列挙型はスタックに保存される。 |
ヒープ領域
ヒープ領域にはString型やHashMapなどがあります。基本的にサイズが固定ではなく可変なデータを保存する領域ですね。スタックに比べてデータを取り出す速度は遅いです。ヒープ領域にデータを置くときは、ある程度のスペース確保をOSに対して依頼し、OSはその領域を使用中にしてその場所のポインタを返してくれます。ポインタはスタック領域に保存され、ヒープ領域の実際のデータの場所を示しています。
ヒープ領域に保存されるのは以下になります。これもとりあえずざっと眺めるので十分です。
種類 | 具体例 | 説明 |
---|---|---|
String 型 |
String::from("hello") |
サイズが可変な文字列はヒープにデータを保存し、ポインタや長さ、容量はスタックに保存される。 |
ベクタ型(Vector) | vec![1, 2, 3] |
可変長の配列。データはヒープに保存され、ポインタや長さ、容量はスタックに保存される。 |
ボックス型(Box) | Box::new(10) |
ヒープ上にデータを格納するためのスマートポインタ。ポインタ自体はスタックに保存される。 |
HashMap 型 |
HashMap::new() |
キーと値のペアを格納するデータ構造。内部データはヒープに保存される。 |
VecDeque 型 |
VecDeque::new() |
双方向キューのデータ構造。データはヒープに保存される。 |
ヒープを使用するタプル | (i32, String) |
一部の要素がヒープデータ(String や Vec など)であれば、それらのデータはヒープに保存される。 |
ヒープを使用する構造体 | struct Data { a: i32, b: String } |
フィールドの一部がヒープを使う型の場合、そのデータはヒープに保存される。 |
ヒープを使用する列挙型 | enum Message { Text(String), Number(i32) } |
フィールドの一部がヒープを使う型の場合、そのデータはヒープに保存される。 |
メモリには二つの領域があって、スタックとヒープがある。
スタックは素早く取り出せる固定長のデータ、ヒープには可変長のデータがあり、その場所を指し示すポインタをスタックに保持しておく。スタックにあるポインタをみて、ヒープにあるデータを見に行っている。これはどの言語でも大体一緒。
文字列とスコープについて
まずはスコープについてと、文字列を扱うString型について少し解説します。
スコープというのは変数の有効範囲のことです。
{ // sは、ここでは有効ではない。まだ宣言されていない
let s = "hello"; // sは、ここから有効になる
// sで作業をする
} // このスコープは終わり。もうsは有効ではない
大体はブロック内では有効というのが通例です。
では可変長の文字列を扱うString型を見ていきましょう。
let mut s = String::from("hello");
s.push_str(", world!"); // push_str()関数は、リテラルをStringに付け加える。
println!("{}", s);
リテラルというのはベタがきしたデータのこと。この場合は, world!
の部分
::
はString型に準備されているfromというメソッドを使うための記号です。
文字列の型は&str型とString型があるので注意。固定長と可変長と覚えればとりあえずOK。
&str型でmut
をつけると違う文字列を参照させることはできる(再代入など)が、.push_str
などの関数で付け足しなどはできない。
メモリの確保について
&str型とString型ではメモリからデータを取り出す速度が違います。
&str型の中身である文字列リテラルは、コンパイル時に中身が判明しているのでより高速に取り出せます。
String型では、いつでも変更できて長さも変更できるテキストを使えるように、コンパイル時にポインタと長さ、容量を確保します。だが実際のデータは入っていません。実行時に確定されてメモリに要求されるものです。
この確保したメモリを解放するタイミングが大事で、失敗すると値がなかったり、メモリの無駄にもなります。2回解放しようとしてもエラーになります。
Rustではブロックを抜けた時に自動的にdrop関数を呼び出してメモリ領域を解放しています。
所有権について
ようやく所有権について解説できます。
所有権のルール
- Rustの各値は、所有者と呼ばれる変数と対応している
- いかなる時も所有者は一つである
- 所有者がスコープから外れたら、値は破棄される
ではString型などのヒープ領域のデータはどう保存されているのかを見ていきます。
let s1 = String::from("hello");
この図の見方ですが、以下のポイントを押さえたいです。
- String型には3つのデータがある
- ポインタ、長さ、容量の3つがある
- これらはスタックに保持されている
- ポインタは実際にヒープ上にあるデータの場所を表している
コンパイル時と実行時にどう動くのか👇
項目 | コンパイル時 | 実行時 |
---|---|---|
データサイズ | 不明 | 実際のデータサイズに基づいて決定 |
準備されるもの | ポインタ、長さ、容量 | ヒープに文字列データが確保される |
実際のデータ | 存在しない(ただの枠組み) | ヒープに確保され、データが格納される |
ムーブ
では再代入した場合どうなるのか?
let s2 = s1;
どちらの変数も同じ場所を指すのか?それとも、hello
というヒープ領域にあるデータもコピーされるのか?
実際にはそのどちらでもありません。
正解は以下の図のようになります。
s1
のデータにはアクセスできなくなります。これは、s1が所有していたhello
という文字列への所有権をs2
に渡したという扱いになります。これをムーブと言います。
クローン
前の変数の所有権を保持しつつ、同じデータを作ることもできます。
ただこれは、スタックのデータもヒープのデータも完全に別物ができます。内容は同じhello
ですが。
let s1 = String::from("hello");
let s2 = s1.clone();
println!("s1 = {}, s2 = {}", s1, s2);
コピー(スタックのデータのみ)
ではスタック領域のデータは同じようにムーブされるのでしょうか?
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
ムーブと同じ動きをする場合、変数x
は所有権を失うはずなのですが、上記は問題なく動きます。これはスタック領域だけの動きで、ムーブではなくデータのコピーが行われています。
トレイトと呼ばれる型につける印のようなものがあり、それがついているデータはコピーが行われます。
関数の場合どうなるのか?
関数の引数と戻り値の場合、所有権はどう動くのか見ていきましょう。
引数の場合
fn main() {
let s = String::from("hello"); // sがスコープに入る
takes_ownership(s); // sの値が関数にムーブされ...
// ... ここではもう有効ではない
let x = 5; // xがスコープに入る
makes_copy(x); // xも関数にムーブされるが、
// i32はCopyなので、この後にxを使っても
// 大丈夫
} // ここでxがスコープを抜け、sもスコープを抜ける。ただし、sの値はムーブされているので、何も特別なことは起こらない。
//
fn takes_ownership(some_string: String) { // some_stringがスコープに入る。
println!("{}", some_string);
} // ここでsome_stringがスコープを抜け、`drop`が呼ばれる。後ろ盾してたメモリが解放される。
//
fn makes_copy(some_integer: i32) { // some_integerがスコープに入る
println!("{}", some_integer);
} // ここでsome_integerがスコープを抜ける。何も特別なことはない。
takes_ownership
:こちらの関数に渡されたs
という変数はムーブで渡されます。なので、takes_ownership
関数が使われた後ではs
を使うことはできません。
makes_copy
:こちらに渡されたx
はコピーで渡されています。なので、その後のブロック内でも使うことができます。
戻り値の場合
では次に戻り値の場合を見ていきます。
fn main() {
let s1 = gives_ownership(); // gives_ownershipは、戻り値をs1に
// ムーブする
let s2 = String::from("hello"); // s2がスコープに入る
let s3 = takes_and_gives_back(s2); // s2はtakes_and_gives_backにムーブされ
// 戻り値もs3にムーブされる
} // ここで、s3はスコープを抜け、ドロップされる。s2もスコープを抜けるが、ムーブされているので、
// 何も起きない。s1もスコープを抜け、ドロップされる。
fn gives_ownership() -> String { // gives_ownershipは、戻り値を
// 呼び出した関数にムーブする
let some_string = String::from("hello"); // some_stringがスコープに入る
some_string // some_stringが返され、呼び出し元関数に
// ムーブされる
}
// takes_and_gives_backは、Stringを一つ受け取り、返す。
fn takes_and_gives_back(a_string: String) -> String { // a_stringがスコープに入る。
a_string // a_stringが返され、呼び出し元関数にムーブされる
}
gives_ownership
:こちらの関数では戻り値のsome_string
の変数の中身の所有権が変数s1
に渡されている。
takes_and_gives_back
:こちらの関数では、まずs2
にあった所有権がこの関数に渡され、その後s3
に所有権を戻している。
おわりに
今回はRustの所有権について解説しました。ただ、この後出てくる参照と借用の概念を理解してようやくひと段落というところなので、次の記事ではそこについて詳しく解説したいと思います。
所有権という概念が面白そうと思ってRustに手を出しましたが、やはり面白いですね。メモリについてもかなり詳しくなりそうでワクワクします。
よかったら次の記事も見ていってください。