Rust 基礎講座 #3 所有権 — move が既定の言語、GC なしでメモリが解放される仕組み
第 1 回で、Rust はメモリ解放のタイミングをコンパイル時に確定すると言いました。今回がその実体である所有権(ownership)です。Rust 学習で最もつまずきやすい山場ですが、規則そのものは 3 行で、残りはその規則が代入、関数呼び出し、スコープでどう働くかという話です。ここを越えれば、以降の回はむしろ平坦です。
3 つの規則 #
- すべての値は、所有者(owner)と呼ばれる変数を持ちます。
- 所有者は、ある時点で 1 つだけです。
- 所有者がスコープを抜けると、値は解放(drop)されます。
規則 3 から見ていきます。GC 言語は「この値をまだ誰が使っているか」をランタイムで追跡し、使われていない値を回収します。Rust には追跡が要りません。所有者が 1 つだけなので、所有者がスコープを抜ける箇所が、そのまま解放の箇所です。コンパイラはそこに解放コードをあらかじめ埋め込みます。ランタイムの追跡コストがなく、解放のタイミングがコードを読むだけで予測できます。
{
let log = String::from("リクエスト処理開始");
// log を使う
} // スコープの終わり。ここで log のヒープメモリが解放される
では、値を代入したり関数に渡したりするとどうなるでしょうか?所有者が 2 つになれば規則 2 が破れ、同じメモリを二度解放することになります。そこで出てくるのが Rust の既定の動作、移動(move)です。
move — 代入はコピーではなく移動 #
let s1 = String::from("hello");
let s2 = s1; // 所有権が s2 へ移動
println!("{s1}"); // コンパイルエラー
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:4:15
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`,
| which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 | println!("{s1}");
| ^^^^ value borrowed here after moveString は実際の文字データをヒープに置き、変数にはその場所を指す情報だけを持ちます。let s2 = s1 でコピーされるのはこの指す情報だけで、ヒープのデータは 1 つのままです。Python や JavaScript ならここで 2 つの変数が同じオブジェクトを共有し、後始末は GC に任せます。Rust は代わりに所有権を s2 へ移し、s1 を無効化します。 所有者は相変わらず 1 つなので、二重解放も、解放済みの値の使用も、構造的に不可能になります。C++ で数十年にわたり事故を起こしてきた use-after-free が、Rust では上のようなコンパイルエラー 1 枚で終わるわけです。
エラーメッセージの value borrowed here after move は、Rust 入門者が最も多く出会う一文です。「移動した後に使った」という意味で、解決策は今回と次回にまたがって登場します。
Copy — スタックに収まる固定サイズ型の例外 #
let a = 5;
let b = a;
println!("{a} {b}"); // 問題なし。5 5
整数、浮動小数点、bool、char、そしてこれらだけを含むタプルは、移動せずコピーされます。サイズが小さくコンパイル時に確定したスタック上の値なので、コピーコストは事実上ゼロで、ヒープ所有の問題もないからです。こうした型は Copy トレイトを実装していると言い、上のエラーメッセージにも which does not implement the Copy trait という手がかりとして登場していました。トレイト自体は第 8 回で扱うので、いまは判断基準だけ覚えれば十分です。ヒープを所有するなら move、スタックに収まるなら Copy です。
clone — コストが見えるコピー #
ヒープのデータごと複製したいときは、明示的に頼みます。
let s1 = String::from("hello");
let s2 = s1.clone(); // ヒープのデータまでコピー
println!("{s1} {s2}"); // どちらも有効
GC 言語ではコピーなのか共有なのかがコードに現れませんが、Rust ではヒープコピーが起きるすべての箇所に .clone() が刻まれています。性能を追うとき、コピーコストを grep 一発で洗い出せるということです。入門段階では、コンパイルエラーを .clone() でしのぐのも正当な戦略です。まず動くものを作り、次回の借用で削っていけばよいのです。
関数の境界でも同じ規則 #
fn print_report(report: String) {
println!("{report}");
} // report がスコープを抜けて解放される
let summary = String::from("月次サマリー");
print_report(summary); // 所有権が関数へ移動
println!("{summary}"); // コンパイルエラー: value borrowed here after move
関数に値を渡すのも、代入とまったく同じ move です。summary の所有権は print_report へ渡り、関数が終わった瞬間に値も解放されました。しかしこのままでは、「値を読むだけの関数」を呼ぶたびに所有権を奪われ、使い続けるには戻り値で返してもらう面倒なコードになります。
fn print_report(report: String) -> String {
println!("{report}");
report // 所有権を返し直す
}
let summary = print_report(summary); // 受け取り直す
動きはしますが、毎回これでは書いていられません。「所有権はそのままに、少しだけ読む権利を借りる」方法が必要で、それが次回の参照と借用(borrowing)です。
まとめ #
- 所有権の規則は 3 行です。値ごとに所有者は 1 つ、所有者がスコープを抜けたら解放。これだけで、GC なしに解放タイミングがコンパイル時に確定します。
- ヒープを所有する型の代入と関数への受け渡しは move です。元の変数は無効化され、以後の使用は
value borrowed here after moveというコンパイルエラーになります。 - 整数のようなスタック上の固定サイズ型は
Copyなので自由にコピーされます。ヒープなら move、スタックなら Copy が判断基準です。 - ヒープコピーは
.clone()でしか起きないので、コピーコストがコードに現れます。入門段階では clone でしのぎ、後で削るのも戦略です。 - 次回は所有権を移さずに値を使う参照と借用です。入門者が最もぶつかる借用規則のエラーを正面から扱います。