Rust 所有権・参照

2024/11/07に公開

概要

Rustに興味があり勉強をし始めた。
しかし、Rustには特有の概念のようなものがあり初学者 (特に普段言語の特性や機能などを考えたことがない私のような人間)にとっては挫折しやすいといった話を耳にした。
そこで色々と調べていると、プログラミングRustという本の4章5章が理解できるとかなりRustのコンセプトのようなところを理解できそうな雰囲気を感じ取った。
なので言語の細かい書き方などは一度気にせずコンセプト的なところの勉強から行い、その際の理解をこのページにまとめようと思う。

たとえば4章や5章を読み解くと、Rustの急峻な学習曲線を形作る所有権やライフタイムといった概念が、何を目的として導入され、どのような嬉しさを我々にもたらすのかを知ることができます。

ref. Rust.Tokyoオーガナイザー・豊田優貴が薦めるRust本6選,

4章と5章を読み込んだら芋づる式に理解が進み始めた

ref. これまでと違う学び方をしたら挫折せずにRustを学べた話 / Programming Rust techramen24conf LT
以下4章と5章を実際に読んで理解した内容をまとめてみた。
理解が間違えていたら是非教えていただきたい。

4章 所有権

モチベーション

所有権はメモリ管理をいい感じに言語レベルで行ってくれる概念である。
そもそもなぜこのような機能を導入したかについて知る必要がある。
メモリ管理に関しては次の2つが重要である。

  • プログラマが選んだタイミングで適切に解放される (メモリ消費を制御可能)
  • 解放済みのオブジェクトへのポインタを使わないようにする (未定義動作を避ける)
    しかし、これらを同時に満たすのは難しい。
    1つ目に関してはC言語などのように手動でmalloc, freeを行うような言語を指す。
    2つ目はGolangのようなGCを持つ言語を指す。
    1つ目に関してはやはり、2重freeやfree後にデータにアクセスするなどのコードを作ってしまうことがある。2つ目に関しては、1つ目のような問題はないがGCが起動するまでにメモリの使用量が増えてしまう。またGCの処理の負荷はそれなりに重く回避できるならしたいところだ。
    (GolangはGOGCという変数を用いることでどの程度使用メモリが増えたらGCが起動するかを調整できる。この話はまたそのうち調べて書きたい)
    そこでRustではポインタの利用に制約を加えることでメモリ安全性のエラーをコンパイル時に検出できるようにした。

所有権規則

所有権が満たすルールとはどのようなものかについてだが、これはTRPLに記載されているものをそのまま以下に記す。
これからの説明は全て以下のルールについての説明である。

- Rustの各値は、_所有者_と呼ばれる変数と対応している。
- いかなる時も所有者は一つである。
- 所有者がスコープから外れたら、値は破棄される。

また、所有権の話が絡むのはスタックに詰まれるようなデータではなく、ヒープに置かれるデータである。スタックのデータはスコープを抜けたら勝手に取り除かれる。

説明

このような対応がなぜできるのかについてだが、まず上記で書いたとおりRustではメモリ管理やGCが不要である。それはなぜか?
通常各種変数はスコープを抜けると使えなくなる。Rustではそれと同じようにスコープを抜けると確保したメモリを変換する処理が走る。
これは上記で記したルールのうちの「所有者がスコープから外れたら、値は破棄される」に当てはまり、これをdropという。
1変数を考える場合は簡単である。
これからはstring型で考える。string型は容量と長さとバッファを持ち、バッファはメモリを確保しているので所有権の話に絡む。
以下のような場合はsがスコープを抜けるタイミングで文字列のバッファで確保したメモリは返還される。

fn main() {
	{
	    let s = String::from("hello"); // sはここから有効になる
	
	    // sで作業をする
	}                                  // このスコープはここでおしまい。sは
	                                   // もう有効ではない
}

では次の場合は?

fn main() {
    let s2;
    {
        let s1 = String::from("abc");
        s2 = s1;
    }
    println!("{}", s2);
}

この時にs2 = s1の処理ではバッファはコピーされず2つの変数から同じバッファのアドレスを参照されることになる。
pythonなどでシャローコピーなどあったと思うがそれと同じである。では、これの何が問題なのか?
上記のようにスコープを抜けたタイミングでdropしてしまうとs1dropするタイミングで文字列が参照していたバッファも返還されてしまう。
つまり、s2はまだ今後も利用するはずなのにバッファにあったデータが見れなくなってしまう。
これではいけない。
そこでこの問題を解決するのに役立つのが「いかなる時も所有者は一つである。」というルールである。
どういうことか?
s1に文字列がバインドされた時点でs1が文字列の所有者である。その後s2s1 をコピーしてs2にバインドするとs1, s2のどちらもが所有者かと言われるとそうではない。
s2にバインドされる時にs1は所有者から外されるのである。
この際にs1は未初期化状態とされ、文字列のバッファが解放されるタイミングはs2がdropされるタイミングに変わる。
これをmoveという。
以下のようなコードを実行するとs2 = s1の時点でmove (移動)されたのでs1は未初期化状態となっている。
それをprintlnしようとしているのでエラーが発生する。

fn main() {
    let s2;
    {
        let s1 = String::from("abc");
        s2 = s1;
        println!("{}", s1);
    }
    println!("{}", s2);
}
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:6:24
  |
4 |         let s1 = String::from("abc");
  |             -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
5 |         s2 = s1;
  |              -- value moved here
6 |         println!("{}", s1);
  |                        ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
5 |         s2 = s1.clone();
  |                ++++++++

以下からmoveした後に値を使おうとしたのが問題だとわかる。
-- move occurs because s1has typeString, which does not implement the Copy trait
もちろんディープコピーされて欲しい、つまり単純な値のコピーが走ってもらいたい場合なども存在する。そのような場合はCopy traitを実装するなどの回避策も用意されている。
ちなみに代入以外にも関数からの値の返却や新しい値の作成・関数への値渡しなどさまざまなシチュエーションでmoveが発生する。
参照で関数への値渡しでmoveが発生することは知っておく必要がある

5章 参照

ここまでの話ではメモリを確保した際にそのメモリは所有者がいるという話をしてきた。
しかし、ここにきて所有権を持たないポインタである参照の話が出てくる。

モチベーション

せっかく所有権という制約を入れたことでかなり安全になったと思ったのに、所有権を持たないなら他の言語と同様なのでは?という気がするがそこはうまく対応されている。
ここでプログラミングRustに乗ってる例を参考にしたい。
所有権を読んだ時に私は漠然と「値を別のものに渡したら未初期化状態になるってマジか。なんか色々と辛いことになりそう」みたいなことを思っていた。実際にそういったニーズはそれなりにあるはずで (printなどをした時にその度に所有権を取られるのは困る気がする)
実際以下のような関数があったとき、show(table)を一度使うとtableの所有権は関数に移動してしまい、未初期化状態になってしまう。
つまり呼び出し元でshow()を呼び出すとそれ以降tableが使えなくなる。
いくらなんでも不便である。
そこで、参照を用いると値の所有権に影響を与えずに値にアクセスできる!
つまり、未初期化状態にならずに済むのである。

fn show(table: Table) {
	for (k, v) in table {
		println("{}: {}", k, v);
	}
}

説明

参照は参照しているデータよりも長生きすることはできない。(つまり所有者より参照が長生きすることは許されない)
参照したものは所有者に必ず返す必要があり、これを強調するために参照を作ることをborrowing (借用)と呼ぶことがある。
参照には2種類存在する

  • 共有参照
    • 参照先の値を読めるが変更できない
    • ある値に対して複数の共有参照があっても問題ない
    • &e は e に対しての共有参照
    • e の型が T なら &e の型は &Tとなる
  • 可変参照
    • 参照先の値を読めるし書き換えもできる
    • 可変参照があるときにその値に対して共有参照・可変参照を他に用意することはできない
    • &mut e は e に対しての可変参照
    • e の型が T なら &mut e の型は &mut T となる
      これらの参照は借用先で作られたとしても、所有者もこのルールが適用される。
      これらを用意する理由はコンパイル時に複数読み出し・単一書き込みを強制するためである。
      つまり参照を使用すると以下のようなコードになる。
fn show(table: &Table) {
	for (k, v) in table {
		println!("{}: {}", k, v);
	}
}

ちなみに、所有権を移動する関数呼び出しを値渡し、参照を渡すのが参照渡しと呼ばれる。
プログラミングRust5.2章ではさまざまな参照の使い方を説明しているが今回は割愛する。

では、参照の安全性はどのように保障するのだろうか?
「参照は参照しているデータよりも長生きすることはできない」とはいうもののそれがどのように守られるのだろうか?
Rustコンパイラは全ての参照型にその参照が安全に利用できる期間を表すlifetime (生存期間)を割り当てる。
例えば以下の例を考える。

{
	let r;
	{
		let x = 1;
		r = &x
	}
	assert_eq!(*r, 1)
}

このコードではプログラムは動かない。
なぜか?
今回気をつけるべきはr, xの2変数のdropのタイミングである。
まず、rxの参照を渡しているのでxは未初期化状態にはならない。
変数rassert_eq!まで終えてdropされる。
しかし、その参照先であるxr = &xの後の}dropしてしまう。
つまり、rdropされたメモリへの参照を持つことになってしまう。
こういった状態にならないようにコンパイル時に弾くようになっている。
より簡単にまとめると参照を受け取るrは参照先であるxの生存期間以上生きることができないのである。
次に仮引数として参照を受け取る場合や構造体に参照を渡す場合について見ていく。
これらは生存期間を表す記号を用いて整合性を担保する。

fn f(p: &i32) {
	println!("{}", p);
}

というコードがあった時Rustコンパイラは自動的に以下のように保管してくれる。

fn f<'a>(p: &'a i32) {...}

<'a>という文字列が現れるようになった。(<'a>はtick Aと読む)
これが何を表すかというと生存期間のパラメータである。
引数の'aに関しては呼び出し時に渡された変数の生存期間だろうということはわかる。
関数の後ろにある<'a>は??
ここで使用する生存期間を宣言する必要がある。
つまり、ここで宣言しないとundeclearと怒られる。

error[E0261]: use of undeclared lifetime name `'a`  
--> src/main.rs:1:10  
|  
1 | fn f(x: &'a i32) {  
| - ^^ undeclared lifetime  
| |  
| help: consider introducing lifetime `'a` here: `<'a>`

また、上記のような単純な生存期間のケースはコンパイルの際に自動で保管してくれるので書く必要がない。
ライフタイムの省略ルールはライフタイムの省略を見ると良さそう。
当然返り値にも生存期間を指定することができる。

fn f<'a>(x: &'a i32) -> &'a i32 {
    x
}

ここまでの説明だと引数で受け取ったものの生存期間を型として書いているだけでそりゃそうだろ感しかない。
しかし、以下の場合はどうだろうか?

fn f<'a>(x: &'a i32) -> &'a i32 {
    x
}


fn main() {
    let ret;
    {
        let x = 10;
        ret = f(&x);
    }
    println!("{}", ret);
}
Compiling playground v0.0.1 (/playground)  
error[E0597]: `x` does not live long enough  
--> src/main.rs:10:17  
|  
9 | let x = 10;  
| - binding `x` declared here  
10 | ret = f(&x);  
| ^^ borrowed value does not live long enough  
11 | }  
| - `x` dropped here while still borrowed  
12 | println!("{}", ret);  
| --- borrow later used here

この処理ではfの引数と返り値は同じ生存期間を持つと関数に記述されている。しかし関数呼び出しごを見てみると、retxは生存期間が異なる。(retの方が長い)
その結果上記のようなエラーになる。
このように意図しない生存期間を持つ変数への値をセットを回避することができるのである。
他にも、複数引数の場合は各引数に別々の生存期間をつけることもできるし構造体にも同様の機能を使うことができる。

まとめ・感想

Rust は言語レベルで他の言語より強い制約を設けることでコンパイル時にメモリの安全性などを保証しながらGCいらずでメモリを適切なタイミングで解放できるようになった。
逆にここまでしないとメモリ安全を保ちながらGCを消すことができないのかと思った。
一番最初にTRPL読んだ時は本当に意味わからんみたいな気持ちが強かったが、プログラミングRustをを読んで、Golangでlatency求められるようなものを触ったりしてようやく少しだけ気持ちが理解できるようになってきた。
あとは、昔は自分と違って世の中の人間はメモリ管理とかミスらんやろみたいなことを考えていたのもありモチベがわからないみたいな物もあった気がする。
正直まだ理解がだいぶ浅いので今後も色々調べていきたい。(この記事とか似たようなところの説明なのでより自分の理解が浅いことがわかる)
そもそもリサーチ量がだいぶ足りなさそうない。。。
ただ、コンセプト的なところの雰囲気に触れられたのは良かった。

参考

Discussion