メモリで見るRustの所有権と移動

Rust Advent Calendar 2018の11日目の記事です。
 
こんにちはRustビギナーの@mrsekutです。
オライリーの「プログラミングRust」(通称、蟹本)の4章を読んで、はっ、とする部分があったのでそこを中心に簡単にまとめたいと思います。
 
どちらかといえば、Rust入門者のための記事になるかと思います。
 
Rustガチ勢で読んでくださる方は、間違ってる箇所など、お気づきの点があればコメント頂けると幸いです。

tl;dr

最初にこの記事のまとめです。

「スタック領域」はデータを積んでいき、「ヒープ領域」はデータを乱雑においていくメモリ領域のこと。

Rustには所有権(Ownership)というものがあり、メモリ上ではツリー構造を取る。
つまり、「一つの親」を持つ。

別の変数などに代入されると、所有権の移動(move)が起こり親が別のものに代わる。
このとき、元の親は未初期化状態になっている。

また、int型やbool型など移動が発生しない型もあり、その見分け方は「その型がCopy型かどうか」である。

スタックとヒープのおさらい

所有権の話に入るためにはまずスタックとヒープについて少し知っておく必要があります。

少し前に勉強したときの備忘録記事があるので、そちらも御覧ください。(Rustの話ではない部分も混じってるので注意です)

ここでは簡単にスタックとヒープについておさらいします。

スタック領域とはなにか

「スタック」とは後入れ先出しのLIFOのデータ構造です。
データを整然と積み上げていき、最後に積み上げたものから取り出して使います。

メモリ上には「スタック領域」があり、この方式でデータを出し入れします。

よくある言語でのローカル変数の宣言などがこれに当たります。

ヒープ領域とはなにか

ヒープは乱雑にデータを出し入れできるイメージです。

メモリ上にはヒープ領域もあります。
LIFOでデータの出し入れをするスタック領域と異なり、ヒープ領域では任意のタイミングで出し入れすることができます。

例えばC言語では、malloc()でヒープにデータを確保し、free()で解放します。

所有権をメモリから見る

所有権とは何なのか、何のためにあるのか、をメモリの状態から見ていきます。

簡単な数値のタプルの例

Box()を使ってタプルを作るコードを見てみます。

これはメモリ上ではどんなふうになっているのかというと、以下の図のように変数pはスタックフレームへ、値である(0.25, 1.5)はヒープへ確保されています。

そして変数pがスコープから外れると、同時にヒープ上の2つの値も解放されます。
この仕組のおかげで、GCがなくても良い感じにメモリ管理ができているわけですね。

ちなみにRustでは解放のことを「drop」と呼ぶので、以下ではdropとします。

すこし複雑な文字列のリストの例

先ほどの例は、単純なタプルでしたが、もう少し複雑な文字列を含んだリストだとどうでしょうか。

作成後のメモリの状態は以下のようになります。

だいぶ複雑になりました。
まずスタック上に変数pが、中身がヒープ上に確保されます。
ここまではさっきと同じですね。

まず、スタック上の4とか3とかある部分は「容量(capacity)」と「長さ(length)」です。
リストはこの、「ポインタ(黒丸)」、「容量」、「長さ」の3要素で構成されるのですが、今回の話にはあまり関係がありませんのでスルーします。

次に、ヒープを見てみます。
左側3マスがTarouさんのもので、同じようにポインタと容量と長さで構成されています。
そして、そのポインタの指す先(ヒープ)に文字が確保されています。
HanakoさんとHanaoさんも同様です。

重要なのは、ツリー構造になっていることです。
つまり、一つの親が子を持ち、その子が孫を持ち、、、という構造になっています。

これからわかるように、全ての値には「一つの親」がいます。
「一つ」の親。これも重要。

そして、さっきと同じように、親がdropされれば、その子孫はすべてdropされます。

雑な図だ。。。

これで、簡単な例でのメモリに扱われ方がちょっとわかってきました。
次に、「移動」を見ていきます。

移動をメモリから見る

先ほど見たリストを使って、移動を見てみます。
変数pに入っているリストを変数t,uにコピーしたい、というコードが以下です。

しかし、このコードはコンパイルエラーが起こります。
エラー内容は以下のような感じです。

「値pの所有権がここでmoveされているよ」と怒られました。

5行目のときのメモリの図はこんな感じです。

一方、6行目後のときのメモリの図はこんな感じ

つまり変数pが6行目の直後で未初期化状態になっています!
未初期化ってことは宣言してるだけのときと同じ状態ってことですね。
値が入っていないのに渡すなんてそら無理だ!って感じです。
なるほど。なるほど。

こんなふうにある値を持つ親が変わることを移動(move)と言います。
全ての要素は「一つの親」を持つのでした。

6行目が終わったあとに、リストの中身の親はpからtに移動したのですね。

「所有権が移動したのでエラーが起こりました」という文章だけじゃ少しわかりにくかったですが、実際にメモリの状態図を見てみると、なるほどという感じがしました。

まぁそれはわかったけど、じゃあさっきの例はどうやって解決するの?

Rustには、親が一人に決まる「所有権」というものと、親を移し替える「移動」というものがあることはわかりました。

では、先ほどのコード例のようなことをしたければどうすれば良いのでしょうか。

答えの一つは、「参照」です。

こうすれば、printでs、t、u、どれを出力させても中身を見ることができます。

見るだけでなく、値の変更もしたいとなると話は別で、更に「参照」に加えて「借用」についても知る必要がありますので、また調べてみてください。

(ちなみに、蟹本では5章で解説されてありました。)

moveしない型もある

今まで見たきたmoveですが、数値型、文字型、論理型などでは挙動が少し異なり、moveしなくとも値がコピーされます。
 

メモリはこんな感じ。
 
これらはヒープでは使わずに、スタックに積まれています。
見るからに、無駄にmoveなどの概念があってもわかりにくいだけですね。
 
 

そのカタの見分けカタはどうするか?

では、その型の値がスタックかヒープのどちらに確保されるのかどうやって見分けるのでしょうか。

それは、その型がCopyトレイトを実装している型かどうかで判別します。

 
僕はトレイトに関してはまだよくわかっていないのですが、haskellの型クラスのようなイメージで良いんですかね。
 
Rust難しいです。。。

所感

ここで、一番上のまとめ「tl;dr」に戻ります。

以下、所感です。

蟹本を読んでて一番、はっ、とさせられたのは「move後にもとの値が未初期化状態になる」という部分でした。

Rustは難しいですが、一つ一つ紐解いていくと理解できそうです。

こんなややこしい機能があるのも、GCを使わず、かつメモリ安全にプログラムを組むためなのですね。
たしかに、この機構があればダングリングポインタとかできない気がします。

所有権と移動の最も簡単な部分は前述したとおりですが、構造体が出てきたり、関数が出てきたりすると、また複雑になってきて、あれ?ってなったりしてまた戻ってきます。

このレベルを知って「おっけー、所有権完全に理解した」と思ってプログラムを書いていくと、やっぱりコンパイラに怒られたりします。

理論も大事ですがやはり手を動かしてコンパイラと戦うのも上達への道っぽいですね。