Help us understand the problem. What is going on with this article?

Rustのメモリ管理って面白い

はじめに

Rustという言語に初めて触れてみて、これのメモリ管理戦略って面白いなと思ったのでちょっと書いてみます。まだ真剣に見始めて実質1日くらいなので大いに勘違している可能性もありますが、自分の理解のためにもまとめてみます。

なお、言語の成り立ちとか特徴とかはググると色々出てくると思いますが、個人的に乱暴にまとめてしまうと

  • Mozillaが支援していて、FirefoxのレンダリングエンジンServoの開発に使われている
  • C/C++が得意としていたシステムプログラミングに使える
  • 性能と安全性のバランスを取っている
  • Go言語と何かと比較されている

といったところかなと思います。

なお、Rust関連情報のリンク集として、@moshgさんのこの記事もとても役にたちました。ありがとうございます。

興味を持ったきっかけ

Rustという言語自体は知っていましたが、最初に聞いたときは「また新しい言語が出てきたみたいだけど、何を今さら」と思ったのを覚えています。そんなRustに対してここに来て急に興味を持ったのは、Denoというプロジェクトに出会ったからです。

DenoはNode.jsを作ったRyan Dahl (ry)さんの新しいプロジェクトで、TypeScriptをNativeにサポートする、V8を使ったJavaScript/TypeScript実行環境です。RyanさんはNode.jsに関しては色々と後悔する部分があるとのことで、昨年のJS Conf EUで発表もされてます。そのビデオがここにあってとても面白い発表なのでオススメなのですが、その後半でDenoプロジェクトについても触れられています。

その時点ではDenoはGo言語で書かれていました。それが今はRustで書き換えられています。Goのランタイム、特にGC(ガベージコレクション)の複雑さに懸念を持っていたとのことで、それのないRustが選ばれたようです。じゃ、Rustではどのようにメモリ管理しているのかな?というところが興味を持った発端です。

Go言語との比較

メモリ管理の詳細に入る前に、ざっくりと言語仕様を見たのとサンプルを少し書いてみた感想を少し書いておきます。Goは比較的早めに触っていて、それについてQiitaで書いた記事もあるのですが、実はあまり使っていません。というのも、どうも書いていて楽しくない。「PythonとかJSならここをもう少しスッキリと書けるのに」という場面が度々あって、実直に冗長に書いていかないといけないという気ががします。特にエラー処理が比較的面倒です。例えばこんな感じ。

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}

関数を実行した返り値に、実際の値だけでなく、エラーを示す値も合わせて返すようにし、その値を一々if文で確認しています。例外処理を排除したので仕方ないとは言え、ちょっと先祖返りしている感が否めません。

RustはGo同様に例外処理を採用しなかったのですが、こんな風に書くようです。

io::stdin().read_line(&mut guess)
  .expect("Failed to read line");

標準入力から一行取り込んでその文字列をguessという変数に入れるコードですが、read_lineの返り値をexpect()という関数にチェーンしています。実際にはexpect()Result型のメソッドの一つで、read_lineの結果がOkであれば成功時の値(read_lineの場合は読んだ文字数)を返し、Errの場合にはpanicして落ちる、という挙動をします。

好みの問題もあると思いますが、Goと基本的には同じことをしていながら、私はRustの方がスッキリしていてわかりやすいかなと思います。

それ以外にもかゆいところに手が届く的な機能が幾つかあり、書いていて楽しい言語かも知れないと思い始めています。

Rustのメモリ管理戦略

前説が長くなりましたが、本題のRustのメモリ管理についてです。これまで、言語処理系でメモリ管理と言えば次の2つが主に使われていたと思います。

  1. プログラマが全責任を持って管理する。
  2. システムが自動で不要なメモリをかき集める。

1つ目はCや初期のC++などで使われているレガシーなやり方で、mallocnewでヒープから領域を確保し、freedeleteで手放すまで持ち続けるというやり方です。プログラマーが全ての制御権を持つ一方で、開放し忘れてメモリーリークを起こしたり、二重に解放しておかしな挙動になったりということを注意深く避けなければなりません。

二つ目はJava, Python, JavaScriptなど最近の言語で採用されているガベージコレクション(GC)ですね。上でも述べたようにGo言語でもGCを採用しています。これにも色々やり方があり、変数の参照回数をカウントしたり、リンクを辿れるかどうかをチェックしたり。プログラマがメモリ管理に気を使わなくて良い一方で、実行時のオーバーヘッドを許容しなければなりません。

Rustではこのどちらでもない第三のやり方でメモリ管理を行っています。それを順を追って解きほぐして見たいと思います。

スコープと値の寿命

Rustのメモリ管理の基本的な考え方は以下のルールで表されます。

  1. Rust上に存在する「値」は「変数」に入れられている。その変数を、その値の「所有者」と呼ぶ
  2. 値の所有者になれる変数は一時期に一つだけ
  3. 所有者である変数が「スコープ」を出た時に、その値は利用できなくなる

これだけだと良くわからないので例を使って理解してみましょう。ある関数の中で文字列”hello”を使いたい場合、文字列リテラルで“hello”を定義した上でそれを変数sに代入します。この変数のスコープは関数f1に閉じられているので関数を出たらその変数にはアクセスできません。

fn f1() {
    let s = "hello";
    // sを使った処理
}
// ここではsに代入された値は使えない("hello"の値は破棄されている)

これは上記のルール3によるものですが、まあ極当たり前の話で、スコープ内で定義された変数(ローカル変数と呼ばれる事が多い)はその中でのみ利用できます。Rustだけでなくスコープの概念を持つプログラミング言語はほぼ同様の挙動をすると思います。そして実装的な話をすると、これらのローカル変数は関数呼び出し時のスタックに積まれている事が多く、関数から呼び出し元に戻った瞬間にそのスタックは破棄されるので、とても自然な動作と言えると思います。

ここまでは普通ですが、Rustはこの考え方を拡張して、ヒープ領域から確保してくる変数に対しても同様のルールを適用します。例えば上と似ているけれども少し違う例を出します。

fn f2() {
    let s = String::from("hello");
    // sを使った処理
}
// ここではsに代入された値は使えない(Stringオブジェクトは破棄されている!)

ここでsに代入された値はただの文字列ではなくString型のオブジェクトです。普通の文字列と違って文字を追加したり削除したり可変長な操作のできる優れモノです。このオブジェクトの生成のためにString::fromという関連関数(associated function)を使っていますが、その時にこのオブジェクトの為のメモリ領域が確保されます。

これはC++でnew を使って領域確保した上でコンストラクタでオブジェクトの初期化をするのと基本的には同じ動作です。Rustが特別なのは、このオブジェクトはスコープを出た途端に破棄することを言語レベルでサポートしていることです。

C++などでは、生成されたオブジェクトは明示的に破棄しない限り残り続けるので上のようなコードはメモリリークの元になります。実際にはそういった問題を回避するために、C++ではunique_ptrなどのスマートポインタを使うことを推奨されているようです。(@c-yanさん、@yatra9さん、ご指摘ありがとうございます)

あるいはPythonの様なGCがある言語では、いずれ回収されるけれどもしばらくはそのまま放置される領域ということになります。ところがRustでは値の所有者である変数のスコープが終わった時点でそれが保持している値の寿命も尽きることになっています。スコープを出た時点で自動的にdeleteが呼ばれるイメージでしょうか。

所有権とその譲渡

では、あるスコープ内で生成されたオブジェクトが関数呼び出しで渡された場合にどうなるのか? 例えば以下のようなコードを考えてみます。

fn string_length(ss: String) -> usize {
  ss.len()
}

fn f3() {
  let s = String::from("hello");
  let len = string_length(s);
  // この先、sは利用できない
  // lenを使った処理は可能
}

本来であればs.len()を直接呼び出せば良いところですが、あえて関数呼び出ししてみます。この場合、所有権はstring_length()を呼び出した時点でその引数のssに渡ります。上記のルール2により「値の所有者になれる変数は一時期に一つだけ」とあるので、元々のsは所有権を失うことになります。それはどういうことかというと、それ以降sとそこに格納されている値(Stringオブジェクト)は利用できなくなります。

一方でstring_length内では新たな所有者であるssを通じて引き続きこのStringオブジェクトを利用することができ、ここではその長さをlen()メソッドを使って取得しています。それがこの関数に返り値としてf3()に返され、len変数に格納されるわけです。一方で、渡されたStringオブジェクトはどうなったのか? 先ほどのケースと同様で、string_lengthのスコープが終わった時点で破棄されます。つまり、関数f3()で生成されたStringオブジェクトは関数string_lengthで寿命を終えるということです。

ここで関数の見た目的にStringオブジェクトのコピーが渡されている(Call by Value)ようにも見えますが、そうではなくここではオブジェクトそのものが渡されていることになります。

この所有権と移譲という考え方は実は関数呼び出し時だけでなく、一つのスコープ内での他の変数への代入や関数の返り値を受ける場合にも発生します。例えば、

fn f4() {
  let s = String::from("hello");
  let s2 = s;
  // これ以降sは使えない
}

とすると、Stringオブジェクトの所有権はs2に移り、それ以降sは使えなくなります。また、次のように生成したオブジェクトを関数の返り値として返すことで所有権を引き渡すということもあります。

fn gen_hello(msg: &str) -> String {
  let mut s = String::from("hello, ");
  s.push_str(msg);
  s
}

fn f5() {
  let s = gen_hello("world");
  println!("{}", s);
}

gen_hello()で生成されたStringオブジェクトにf5()から渡された文字列を追加して返していますが、このStringオブジェクトはf5sに所有権が移っているので、gen_helloが終わってもそのオブジェクトは破棄されません。

この動作を例える上手い例が無いかなと思って考えてみたのですが、例えば、「値」を「人」、「変数」を「電車」と考えてみると、人は常に一つの電車に載っているということになります。電車が終点に達するとその人はそこで寿命を迎えて土に帰ります。終点に達する前に他の電車に乗り換えると、生き長らえることができますが、それも終点に着けばオシマイ。更に生き延びるにはその前に別の電車に乗り換え。。。みたいな感じかなと思いますが、わかりにくいですかね(笑)

所有権のレンタル

関数呼び出しで値を渡す一方でそれを呼び出し側でも使い続けたいという場合もあるかと思います。そのためには大きく3つほどやり方があるようです。

  1. オブジェクトのコピーを明示的に作って渡す
  2. 渡した値を返してもらう
  3. リファレンス(参照)で渡す

1はある意味自明な解だと思いますが、値そのものを渡すのではなく、それのコピーを取って渡すというやり方です。例えばString型にはcloneという自らをコピーするというメソッドがあり、それを使ってこんな風にかけます。

fn f6() {
  let s = String::from("hello");
  let len = string_length(s.clone());
  //sはここでも使える

この場合、sに格納されたStringオブジェクトの所有権はそのままなので、その寿命はf6が終わった時までになります。一方でstring_lengthに渡されたコピーの方は渡された先で寿命を終えます。当然ながらこのアプローチはそれだけ多くのメモリ容量とコピーするためのCPU負荷が必要となります。

2つ目の「渡したものを返してもらう」というアプローチは例えばこんな感じ。

fn string_length_2(ss: String) -> (usize, String) {
  (ss.len(), ss)
}

fn f7() {
  let s = String::from("hello");
  let (len, s2) = string_length_2(s);
  // sは使えないがs2をsの代わりに使える
  // s2が保持しているStringオブジェクトは元々sが保持していたもの
}

string_length_2()を呼び出した時に渡したStringオブジェクトは一旦はstring_length_2ssに所有権が渡りますが、返り値として長さだけでなく、渡したStringオブジェクトもタプルにして返してもらっているので再度f7側で使えることになります。sが所有権を戻せれば良いのですが、Rustの変数は基本的にimmutableなので新しい変数で受け取っちゃってます。これは不要なコピーとかは発生しませんが、ちょっとコードが汚くなります。

そして3つ目のリファレンスで渡すやり方。

fn string_length_3(ss: &String) -> usize {
  ss.len()
}

fn f8() {
  let s = String::from("hello");
  let len = string_length_3(&s);
  // sはここで問題なく使える
}

f3の例とあまり変わらないように見えますが、string_length_3の引数の型指定に&が付いているのと、関数呼び出しする際の値に&が付いているのが違いになります。これを参照呼び出しと呼んでいますが、Cや C++の"Call by reference"とはちょっと意味合いが違います。上の例でもあったように、&を付けようが付けまいが関数呼び出し時の値の引き渡しは基本的には値をコピーせずに渡します。そういう意味では常にCall by reference(厳密に言うと幾つかのプリミティブな型(整数型など)は値がコピーされますがここでは単純化のためにそれは一旦おいておきます)。

では&をつけた場合に何が違うのか。それは、所有権を渡すか渡さないかが変わってきます。つまり、リファレンス渡しの場合には所有権は相手に渡らず、元のオーナーが保持し続けます。なので、f8ではstring_length_3()を呼び出した後でもsを使い続けられるというわけです。渡された側は一時的に所有権を「借りている」状態で、そのスコープが終わった時点で自動的に返されることになります(所有権がないので値の破棄はしません)。

この「所有権を借りてくる」という考え方は変数の代入のケースでも使えて、あまり意味はないですが、例えば次のようなこともできちゃいます。

fn f9() {
  let s = String::from("hello");
  let s2 = &s;
  let s3 = &s;
  // sもs2もs3も使える(全部同じオブジェクトを指しているけど!)
}

基本的にリファレンスによる所有権の貸出は幾つでもできます。ただし、それはsがimmutableな場合のみ。mutableな場合には一つだけ(そしてimmutableな貸出はできない)という制約が別途ありますが、Rustのimmutable/mutableの話を始めるとまた長くなっちゃうのでまた別の機会にでもまとめられたらと思います。

あと、関数内で作ったオブジェクトをリファレンスで返すのもご法度です。考えてみれば当たり前ですが、その関数が終わった時点で所有権を持つ変数もスコープアウトしそこに格納されていた値は破棄されてしまいます。そのリファレンスを返されても使いようが無いですよね(笑)。

所有権システムのオーバーヘッド

以上が所有権に基づくメモリ管理のざっくりした概要です。とても単純でわかりやすいルールに則っていながら、メモリリークや二重解放の問題をスマートに解決しているところが良いなと感じました。そしてこれにはもう一つ素晴らしいところがあって、それは「実行時に負荷がかからない」ということ。つまり、所有権を持っていない変数へのアクセスとか、ルールに則っていない使い方をしたコードがあればそれは全てコンパイル時にエラー(あるいはワーニング)になります。

それは実行速度の点でも当然優位に働きますし、実行時に予期せぬエラーが起きて落ちるみたいなこともなく、そのエラーリカバリなども考える必要がありません。それはGCを使った言語に対する大きなアドバンテージになると思います。

まとめ

Rustのメモリ管理について例を交えて自分の理解を書いてみました。ポイントは、

  • スコープアウトしたら値は破棄される
  • 値の代入すると所有権も譲渡される
  • 所有権を渡したくないときには参照を使う

という単純なルールに則っていながら、それを

  • コンパイル時にチェックできる

というところかなと思います。

もし間違った理解をしているところなどあればご指摘いただけると助かります。

なお、Rustは

  • 変数は基本的にimmutable
  • ifとかloopなども式(expression)で値を返せる

など、ちょっと関数型プログラミングに適した特徴もあるみたいで、そのあたりももう少し眺めてみたいなと思っています。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした