はじめに
Rust の学習でほとんどの人が足を止めるのが所有権です。他の言語なら何も考えずに書ける「変数を別の変数に代入する」「関数に値を渡す」といった操作が、Rust ではコンパイルエラーになります。しかも、エラーメッセージに出てくる moved、borrowed、does not live long enough といった言葉の意味が分からないと、なぜ怒られたのかすら掴めません。
ただ、この仕組みは思いつきで作られたものではありません。メモリをどう管理するかという古典的な課題に対して、Rust が選んだ答えが所有権です。前提となるメモリの話から順に追いかけると、個々のルールが「なるほど、そう決めるしかない」と腑に落ちてきます。
この記事では、メモリ領域の基礎 → 所有権の3ルール → ムーブ → 借用(参照) → ライフタイム → スマートポインタ(Box / Rc)までを1本にまとめました。実際に手を動かして確認するコードも載せてあります。
この記事で分かること
- スタックとヒープの違いが分かり、なぜ Rust が所有権を必要としたのかを説明できる
- 所有権の3つのルールと、値がムーブされたあとに何が起きるかが分かる
-
Copyされる型とムーブされる型の見分けがつく - 共有参照と可変参照の使い分け、借用のルールが分かる
-
does not live long enoughエラーの読み方と直し方が分かる -
BoxとRcが何を解決するスマートポインタなのかが分かる - 章ごとの練習問題(解答例つき)で、理解できているかを自分で確認できる
第1章:プログラムが使う4つのメモリ領域
所有権の話に入る前に、プログラムがメモリをどう使っているかを押さえます。ここが曖昧なままだと、後の「なぜヒープだけ特別扱いなのか」が理解できません。
実行中のプログラムは、大きく4つの領域を使い分けています。
| 領域 | 格納されるもの | 例 |
|---|---|---|
| テキスト | コンパイル済みの機械語 | 関数の本体 |
| 静的 | 実行中に変化しない値 |
const、文字列リテラル |
| スタック | コンパイル時にサイズが決まるもの |
i32 の変数、固定長配列、関数の引数 |
| ヒープ | コンパイル時にサイズが決まらないもの |
String、Vec
|
所有権を理解するうえで重要なのは、後半のスタックとヒープです。
スタック ― 速いが融通が利かない
スタックは LIFO(Last In, First Out/後入れ先出し) で管理される領域です。お皿を下から積み上げて、上から取っていくイメージがよく使われます。
関数呼び出しの流れで見ると分かりやすいでしょう。main から func_a、func_a から func_b を呼ぶ場合、それぞれのローカル変数は宣言順に積まれ、関数を抜けるときに逆順で取り除かれます。
「積む・取り除く」だけの単純な操作なので、アクセスは高速です。その代わり、サイズがコンパイル時に確定していなければ置けません。i32 なら 4 バイト、i32 を4つ持つ配列なら 16 バイトというように、必要量が計算できることが条件です。
また、スタックには容量の上限があり、深すぎる再帰などで超えるとスタックオーバーフローになります。
「Rust のスタック上限は 8MB」と説明されることがありますが、正確にはメインスレッドのスタックサイズは Rust ではなく OS やリンカが決めます(Linux では 8 MiB 程度が典型的です)。一方、std::thread で生成したスレッドの既定サイズは現行では Tier-1 プラットフォームで 2 MiB とされており、RUST_MIN_STACK や Builder::stack_size で変更できます。この値は将来変わり得るので、正確な値が必要なときは実行環境で確認してください。
ヒープ ― 柔軟だが管理が難しい
ヒープは、実行時にならないとサイズが分からないデータを置く領域です。String や Vec が代表例で、要素の追加でいくらでも伸びます。
ただし構造は複雑です。データ本体はヒープに置かれ、それを指すポインタなどの情報はスタックに置かれます。Vec の場合、スタック側には次の3つが載ります。
| 項目 | 意味 |
|---|---|
ptr |
ヒープに確保した領域の先頭アドレス |
len |
現在の要素数 |
capacity |
確保済みの容量(何個まで入るか) |
capacity を超えて要素を追加しようとすると、より大きな領域を新しく確保し、中身をそちらへ移します。このときアドレスが変わります。第4章で実際に確認します。
そして、ヒープには「いつ解放するのか」という難問がついて回ります。ここが次章の出発点です。
第2章:メモリ管理の3つの流儀と、所有権という答え
ヒープの管理方法は、大きく3つに分かれます。
| 方式 | 代表的な言語 | メリット | デメリット |
|---|---|---|---|
| プログラマが手動で確保・解放 | C / C++ | 自由度が高く高速 | 解放忘れ(メモリリーク)、二重解放、解放済み領域へのアクセスなど致命的なバグを生みやすい |
| ガベージコレクション(GC) | Java / Go など | 安全に自動管理してくれる | 実行時コストがかかり、細かなチューニングが難しい |
| 所有権 | Rust | 実行時コストなしで安全性を確保 | ルールを覚えるまで学習コストが高い |
Rust は3つ目を選びました。所有権のチェックはコンパイル時に完結するため、実行時に GC のような処理が走りません。安全性と速度を両立させる代わりに、書き手がルールに従う必要がある、というトレードオフです。
所有権の3つのルール
覚えるべきルールはこれだけです。
- Rust の各値は、所有者と呼ばれる変数に対応している
- 値の所有者は、いかなる時も必ず1つである
- 所有者がスコープから外れたら、値は破棄される
イメージとしては、土地の所有権に近いものです。所有者は1人で、譲渡(ムーブ)したり、貸し出したり(借用)できます。
コードで見てみましょう。
fn main() {
let n = 100; // n が 100 の所有者になる(ルール1)
{
let v1 = vec![1, 2, 3]; // v1 が Vec の所有者になる
println!("{:?}", v1);
let v2 = v1; // 所有権が v1 から v2 へ移動(ルール2)
// v1.push(4); // ここでは v1 はもう使えないのでエラー
println!("{:?}", v2);
} // v2 がスコープを抜け、Vec が破棄・メモリ解放(ルール3)
println!("{n}");
} // n がスコープを抜けて破棄される
ルール3から、値の生存期間(ライフタイム)は所有者である変数のスコープと一致するという性質が導かれます。これが後のライフタイムの話につながります。
何が解決されたのか
この仕組みによって、次の問題がコンパイル時に潰れます。
- メモリリーク:スコープを抜ければ必ず解放されるので、解放忘れが起きない
- 二重解放:所有者は常に1つで、破棄されるのも1回だけ
- ダングリングポインタ(解放済み領域への参照):生存期間がスコープから決まるため、超えた参照はコンパイル時に検出できる
- 実行時コスト:GC のような追跡処理が不要
第3章:ムーブ ― 所有権が移動するとき
let v2 = v1; のような代入で所有権が移ることをムーブと呼びます。メモリ上で何が起きているのかを見ていきます。
なぜ「コピー」でも「共有」でもないのか
Vec の値はヒープにあり、スタック側には ptr / len / capacity が載っている状態です。ここで取り得た選択肢は3つありました。
Rust が採ったのは案3です。ムーブではヒープのデータには一切触らず、スタック側の情報だけが v1 から v2 へ移ります。そして v1 は「未初期化(moved-out)」の状態になり、以降は使えなくなります。
コピーではないのでコストはほぼゼロ、所有者は1つなので破棄も1回だけ。案1と案2の欠点を両方回避しているわけです。
どうしてもコピーしたいときは clone
値の複製が必要なら clone メソッドを使います。String や Vec などに実装されています。
let v1 = vec![1, 2, 3];
let v2 = v1.clone(); // ムーブではなく複製
println!("{:?} {:?}", v1, v2); // どちらも使える
clone はヒープのデータを丸ごと複製するため、サイズが大きいと素直にコストになります。「エラーを消すためにとりあえず clone」を続けると、Rust を使う意味が薄れていきます。まずは次章以降の参照で解決できないかを検討し、複製が本当に必要なときだけ使ってください。
Copy される型 ― ムーブされない値もある
一方で、次のコードはエラーになりません。
let a = 5;
let b = a;
println!("{a} {b}"); // a はまだ使える
これは i32 が Copy トレイトを実装しているためです。トレイトは他の言語のインターフェースに近い仕組みで、Copy を実装した型は代入時にムーブではなくコピーが行われます。コンパイル時にサイズが決まっていて、複製しても十分に速いと保証できるからです。
Copy が実装されている代表的な型は次のとおりです。
- 整数型(
i32、u64など)、浮動小数点数型、bool、char - 共有参照
&T - 上記だけで構成されたタプルや配列
タプルが Copy になるのは全要素が Copy の場合だけです。(i32, String) のように String や Vec を含むと Copy ではなくなり、ムーブされます。また、共有参照 &T は Copy ですが、可変参照 &mut T は Copy ではありません。ここは混同しやすいポイントです。
関数の引数と戻り値でもムーブは起きる
所有権が動くのは変数間の代入だけではありません。関数に値を渡すときと、関数が値を返すときにも移動します。
fn concat(s1: String, s2: String) -> String {
format!("{s1}{s2}")
}
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("World!");
let s3 = concat(s1, s2); // s1 と s2 の所有権が関数側へ移動
println!("{s3}");
// println!("{s1}"); // エラー:所有権を渡してしまったので使えない
}
呼び出しのあとも s1 を使いたい場合、ひとつの手は受け取った所有権を戻り値で返すことです。ただし Rust の関数は値を1つしか返せないため、複数返すにはタプルに詰める必要があります。
fn concat(s1: String, s2: String) -> (String, String, String) {
let s = format!("{s1}{s2}");
(s, s1, s2) // 所有権を呼び出し元へ返す
}
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("World!");
let (s3, s1, s2) = concat(s1, s2);
println!("{s3} / {s1} / {s2}");
}
動きはしますが、引数が増えるほど戻り値のタプルが膨らんでいき、明らかにスマートではありません。これを解決するのが第5章の参照です。
第4章:コードでムーブを確認する
ここまでの話を、実際に動かして確かめます。新しいプロジェクトでも既存の練習用プロジェクトでも構いません。
ポインタ・長さ・容量を覗く
fn main() {
let mut v1 = vec![1, 2, 3];
println!("as_ptr : {:p}", v1.as_ptr());
println!("&v1[0] : {:p}", &v1[0]);
println!("len = {}, capacity = {}", v1.len(), v1.capacity());
v1.push(4);
println!("after push");
println!("as_ptr : {:p}", v1.as_ptr());
println!("len = {}, capacity = {}", v1.len(), v1.capacity());
}
{:p} はポインタとして表示するフォーマット指定子です。実行すると、as_ptr() と &v1[0] が同じアドレスを示すことが分かります。つまり ptr には、ベクタの先頭要素のアドレスが入っています。
push の前後では len が 3 から 4 に増え、容量が足りなかった場合はアドレスそのものが変わります。新しい領域を確保して中身を移した証拠です。
capacity の初期値や増え方(何倍にするか)は標準ライブラリの実装依存で、バージョンや環境によって変わり得ます。「足りなくなったら再確保され、アドレスが変わることがある」という性質だけを覚えておけば十分です。
ムーブとクローンを見比べる
fn main() {
let v1 = vec![1, 2, 3];
let v2 = v1;
// println!("{:p}", v1.as_ptr()); // エラー:v1 は既にムーブ済み
println!("v2 : {:p}", v2.as_ptr());
}
v1 を使う行を let v2 = v1; より前に移動させれば、当然コンパイルは通ります。そのうえで v1 と v2 のアドレスを比べると一致しており、データが複製されたのではなく所有権だけが移ったことが確認できます。
let v2 = v1.clone(); に変えると結果が変わります。v1 はムーブされていないので後からでも使え、しかも v1 と v2 のアドレスは異なる値になります。別々の領域に、それぞれの実体が存在している状態です。
エラーメッセージも読んでおきましょう。ムーブ済みの値を使うと borrow of moved value や value used here after move が出て、「どこでムーブされたか」まで指し示してくれます。慣れるとエラーだけで原因が特定できるようになります。
第5章:参照と借用
タプルで所有権を返すような回りくどい書き方をせずに済ませる仕組みが参照です。
参照とは何か
参照は、所有権を持たないポインタです。値のアドレスを指しますが、所有権は移動しません。所有していないので、参照が破棄されても値そのものには影響しません。
参照は変数に & を付けて作ります。参照を作ることを借用と呼びます。所有権を奪うのではなく、一時的に借りるというニュアンスです。
fn main() {
let x1 = vec![1, 2, 3];
let x2 = &x1; // 借用(所有権は x1 のまま)
println!("{:?}", x1); // 所有者なので当然使える
println!("{:?}", x2); // 参照からも読める
}
x2 の型は &Vec<i32> です。参照の実体にアクセスしたい場合は * を付けて参照外しを行います。ただし println! の表示やメソッド呼び出しでは自動的に外れることが多く、明示が必要な場面は限られます。
共有参照と可変参照
参照には2種類あります。
| 種類 | 書き方 | できること | 同時に作れる数 |
|---|---|---|---|
| 共有参照 | &v |
読み込みのみ | 何個でも |
| 可変参照 | &mut v |
読み込みと変更 | 1つだけ |
そして、ある値に可変参照が存在する間は、その値に対する他の参照(共有・可変どちらも)を作れません。
このルールがあるのは、読んでいる最中に別の経路から書き換えられる、といった不整合を防ぐためです。データ競合をコンパイル時に排除する仕組みでもあります。
参照で関数を書き直す
第3章の concat を参照で書き直します。所有権を渡さないので、戻り値をタプルにする必要がなくなります。
fn concat(s1: &String, s2: &String) -> String {
format!("{s1}{s2}")
}
fn main() {
let s1 = String::from("Hello, ");
let s2 = String::from("World!");
let s3 = concat(&s1, &s2); // 共有参照を渡す
println!("{s3} / {s1} / {s2}"); // s1 も s2 もそのまま使える
}
タプルの詰め替えが消えて、意図がそのままコードに現れました。今回は値を変更しないので共有参照で十分です。
実務では、引数の型を &String ではなく &str にするのが定石です。&str にしておくと String からも文字列リテラルからも呼べて、呼び出し側の自由度が上がります(clippy も &String を見つけると &str を勧めてきます)。同様に、&Vec<T> より &[T] が好まれます。まずは所有権の理解を優先し、慣れてきたらこの形に寄せていってください。
第6章:ライフタイム
参照と切り離せないのがライフタイムです。ライフタイムとは、参照が有効であるスコープのことを指します。Rust のすべての参照はライフタイムを持っていて、型と同じように多くの場合は推論されるため、普段は書きません。
その目的はただひとつ、ダングリング参照を防ぐことです。
典型的なエラーを読む
fn main() {
let a;
{
let x = 5;
a = &x; // x を借用
} // ここで x が破棄される
println!("{a}"); // 解放済みの値を参照しようとしている
}
これはコンパイルエラーになります。エラーメッセージは x does not live long enough(x が十分な期間生存していない)です。
構造を整理すると次のようになります。
let a;
{
let x = 5;
a = &x; ← x が生きている範囲はここから
} ← ここまで
println!("{a}"); ← 生存期間を超えて参照している(エラー)
もし通ってしまえば、解放済みのメモリを読むダングリングポインタになります。Rust はこれを実行時ではなくコンパイル時に弾きます。
ここから導かれる制約はシンプルです。参照が使えるのは、参照元の変数が生きている間だけ。言い換えると、参照元の生存期間が、その参照を使う範囲を包含していなければなりません。
修正するなら、println! を内側のブロックへ入れます。
fn main() {
let a;
{
let x = 5;
a = &x;
println!("{a}"); // x が生きている範囲に収まった
}
}
現行の Rust の借用チェッカーは NLL(Non-Lexical Lifetimes) と呼ばれる方式で、Rust 1.63 以降はすべてのエディションで既定になっています。NLL では借用の有効範囲がブロックの閉じ括弧ではなくその参照の最後の使用箇所までとして扱われます。そのため、可変参照を使い終わったあとに同じ値を別途借用する、といったコードが素直に通ります。「スコープ末尾まで借用が続く」という説明は、NLL 以前の古いモデルの話です。
ライフタイムパラメータの書き方
推論に任せられない場面では、明示的に書きます。記法はシングルクォートに続けて名前で、'a のように書きます(読み方は「ティック エー」)。名前は任意ですが、慣習的に 'a から使われます。
fn print_num<'a>(x: &'a i32) {
println!("{x}");
}
- 関数名の後ろに
<'a>でライフタイムパラメータを宣言する(複数ならカンマ区切り) - 引数側は
&の直後にライフタイム名を書き、そのあとに型名を書く
この例は「任意の生存期間 'a を持つ i32 への参照を受け取る」という意味になります。実際にはこの程度の関数なら省略できます(省略規則があるため)。自分で書く必要が出てくるのは、複数の参照を受け取ってそのうちのどれかを返すような関数です。読めるようになっておけば、当面は十分です。
第7章:スマートポインタ ― Box と Rc
最後にスマートポインタです。ポインタとは値の置かれたアドレスを指す変数のことで、Rust で最も基本的なポインタは参照でした。参照は借用するだけで、それ以外の機能を持ちません。
この通常のポインタに追加機能を持たせたものがスマートポインタです。参照との最大の違いは、スマートポインタは指しているデータを所有する点にあります。標準ライブラリには多数ありますが、ここでは基本の2つを取り上げます。
Box ― 値をヒープに置く
Box::new で作ると、本来スタックに置かれるはずの値をヒープに置けます。
fn main() {
let x = Box::new(1); // 型は Box<i32>
println!("{:p}", x); // ヒープ上のアドレスが表示される
println!("{}", *x + 2); // 参照外しで中身にアクセス → 3
}
中身にアクセスするときは、参照と同じく * で参照外しをします。
使いどころは「コンパイル時にサイズが決まらない値を扱いたいとき」です。スタックに置く値はサイズが確定していなければならないため、そのままではコンパイルが通りません。Box で包めばスタック側にはポインタだけが載るので、サイズが確定します。
典型例が、自分自身を含む再帰的なデータ構造です。
enum List {
Cons(i32, Box<List>), // Box がないとサイズが無限に決まらない
Nil,
}
トレイトオブジェクト(Box<dyn Trait>)を扱うときにも登場します。
Rc ― 複数の所有者を許す
Rc は Reference Counted(参照カウント)の略で、値が何箇所から所有されているかを数えるスマートポインタです。所有者は必ず1つというルールの、例外を作るための仕組みだと考えてください。
使うには標準ライブラリからのインポートが必要です。
use std::rc::Rc;
fn main() {
let a = Rc::new(String::from("hello"));
println!("count = {}", Rc::strong_count(&a)); // 1
{
let b = Rc::clone(&a); // 所有者が増える
println!("a: {:p}", Rc::as_ptr(&a));
println!("b: {:p}", Rc::as_ptr(&b)); // a と同じアドレス
println!("count = {}", Rc::strong_count(&a)); // 2
} // b がスコープを抜け、カウントが減る
println!("count = {}", Rc::strong_count(&a)); // 1
} // カウントが 0 になり、ヒープの値が破棄される
Rc::clone は、String の clone と違って中身を複製しません。カウントを1つ増やして、同じデータへのポインタを返すだけです。アドレスを表示すると一致することが確認できます。
そしてカウントが 0 になった時点で、ヒープのデータが破棄されます。複数の所有者を許しつつ、解放のタイミングは安全に決まる、という仕組みです。
Rc を使うときの注意点が3つあります。
-
単一スレッド専用です。スレッドをまたいで共有したい場合は、アトミックにカウントする
Arcを使います。 -
中身は変更できません。共有したまま書き換えたい場合は
RefCellなどと組み合わせます(この場合、借用ルールの検査が実行時に行われます)。 -
循環参照を作るとカウントが 0 にならず、メモリリークします。相互に参照し合う構造では、カウントを増やさない
Weakを併用します。
第8章:練習問題
所有権は、読んで納得しても書くと詰まる分野です。実際に cargo run でコンパイラに怒られながら確かめてみてください。解答例は折りたたんであります。
問題1(第1章:メモリ領域)
次の各値について、実体がどの領域に置かれるかを答えてください。
let a = 42;
let arr = [0u8; 16];
let s = String::from("hello");
let v = vec![1, 2, 3];
let lit = "hello";
解答例
| 変数 | 実体の置き場所 |
|---|---|
a(i32) |
スタック |
arr([u8; 16]) |
スタック(サイズがコンパイル時に確定している) |
s(String) |
文字列データはヒープ、ptr / len / capacity はスタック |
v(Vec<i32>) |
要素はヒープ、ptr / len / capacity はスタック |
lit(&str) |
文字列データは静的領域(実行ファイルに埋め込まれる)、それを指すポインタと長さはスタック |
ポイントは、String や Vec は「ヒープにある」で終わりではなく、管理情報はスタックに載っているという二段構えになっている点です。ムーブで移動するのは、このスタック側の情報だけです。
問題2(第2章:所有権の3ルール)
次のコードで、Vec のメモリが解放されるのはどの時点でしょうか。理由もあわせて答えてください。
fn main() {
let v1 = vec![1, 2, 3];
{
let v2 = v1;
println!("{:?}", v2);
}
println!("end");
}
解答例
内側のブロックの閉じ括弧(println!("{:?}", v2); の次の行)で解放されます。
let v2 = v1; で所有権が v2 に移っているため、値の生存期間は所有者である v2 のスコープに一致します。v1 のスコープは main の最後までありますが、v1 はすでに所有権を手放しているので、main の終わりで Vec が解放されるわけではありません。
所有権のルール3「所有者がスコープから外れたら値は破棄される」の、所有者が誰かを追うことが肝になる問題です。
問題3(第3章:ムーブ)
次のコードはコンパイルエラーになります。理由を説明し、3通りの方法で修正してください。
fn print_len(s: String) {
println!("{}", s.len());
}
fn main() {
let s = String::from("hello");
print_len(s);
println!("{s}");
}
解答例
print_len(s) を呼んだ時点で s の所有権が関数側に移動し、main 側では使えなくなるためです。
// 方法1: 参照を渡す(推奨)
fn print_len(s: &String) {
println!("{}", s.len());
}
fn main() {
let s = String::from("hello");
print_len(&s);
println!("{s}");
}
// 方法2: clone して複製を渡す
fn main() {
let s = String::from("hello");
print_len(s.clone());
println!("{s}");
}
// 方法3: 所有権を戻り値で返してもらう
fn print_len(s: String) -> String {
println!("{}", s.len());
s
}
fn main() {
let s = String::from("hello");
let s = print_len(s);
println!("{s}");
}
このケースでは値を変更しないので、方法1が最適です。方法2はヒープの複製コストがかかり、方法3は呼び出し側の書き方が煩雑になります。
問題4(第3章:Copy)
次の4つのうち、コンパイルが通るのはどれでしょうか。通らないものは理由も答えてください。
// A
let a = 5;
let b = a;
println!("{a}");
// B
let a = (1, 2);
let b = a;
println!("{:?}", a);
// C
let a = (1, String::from("x"));
let b = a;
println!("{:?}", a);
// D
let a = String::from("x");
let b = &a;
println!("{a}");
解答例
通るのは A・B・D の3つです。
-
A:
i32はCopyなので、bに代入してもコピーが行われaは使えます -
B:
(i32, i32)は全要素がCopyなのでタプル全体もCopyです -
C:エラー。
StringはCopyではないため、Stringを含むタプルもCopyになりません。let b = a;でムーブが起き、aは使えなくなります -
D:
&aは借用なので所有権は移りません。所有者であるaはそのまま使えます
「タプルや配列が Copy かどうかは、中身次第で決まる」という点が問われています。
問題5(第5章:借用)
次のコードはコンパイルエラーになります。理由を説明し、修正してください。
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
v.push(4);
println!("{first}");
}
解答例
first が v を共有参照として借用している最中に、push が v の可変借用を必要とするためエラーになります。「可変参照が存在する間は他の参照を作れない」というルールに反しています。
実質的な危険もあります。push で容量が足りなくなると領域が再確保されてアドレスが変わるため、first が古いアドレスを指したままになりかねません(第1章・第4章で見たとおりです)。Rust はこれをコンパイル時に防いでいます。
// 修正1: 借用を使い終わってから push する
fn main() {
let mut v = vec![1, 2, 3];
let first = &v[0];
println!("{first}");
v.push(4);
}
// 修正2: 参照ではなく値をコピーして持っておく
fn main() {
let mut v = vec![1, 2, 3];
let first = v[0]; // i32 は Copy なので値が複製される
v.push(4);
println!("{first}");
}
修正1が通るのは NLL のおかげです。借用は「ブロックの終わりまで」ではなく「その参照の最後の使用箇所まで」なので、println! を先に済ませれば push の時点で借用は終わっています。
問題6(第6章:ライフタイム)
次のコードはコンパイルエラーになります。エラーメッセージ s does not live long enough の意味を説明し、2通りの方法で修正してください。
fn main() {
let r;
{
let s = String::from("hello");
r = &s;
}
println!("{r}");
}
解答例
s は内側のブロックを抜けた時点で破棄されますが、r はその s への参照を持ったまま外側で使われています。参照元の生存期間が、参照を使う範囲を包含していないためエラーになります。もし通れば、解放済みメモリを読むダングリング参照になります。
// 修正1: 参照元が生きている範囲に、使用箇所を収める
fn main() {
let r;
{
let s = String::from("hello");
r = &s;
println!("{r}");
}
}
// 修正2: 参照元の寿命を伸ばす(外側で宣言する)
fn main() {
let s = String::from("hello");
let r = &s;
println!("{r}");
}
does not live long enough を見たら、「参照元はどこで死ぬか」「参照はどこまで使われるか」の2点を比べるのが定石です。どちらを動かすかで、修正1と修正2に分かれます。
問題7(第7章:Rc)
次のコードの3か所の出力を予想してから、実行して確かめてください。
use std::rc::Rc;
fn main() {
let a = Rc::new(vec![1, 2, 3]);
let b = Rc::clone(&a);
println!("{}", Rc::strong_count(&a)); // (1)
{
let c = Rc::clone(&a);
println!("{}", Rc::strong_count(&c)); // (2)
}
println!("{}", Rc::strong_count(&b)); // (3)
}
解答例
2
3
2
-
(1):
aとbの2つが所有しているので2 -
(2):ブロック内で
cが増えて3。strong_countはどのRcから呼んでも同じ値を返します -
(3):
cがスコープを抜けてカウントが減り2に戻ります
Rc::clone はヒープの Vec を複製せず、カウントを増やすだけです。Rc::as_ptr でアドレスを表示すると、a / b / c がすべて同じ場所を指していることも確認できます。
総合問題:所有権を渡さずに関数へ渡す
次のコードはコンパイルエラーになります。所有権を渡さない形に修正して、main の最後で v を表示できるようにしてください。
fn total(v: Vec<i32>) -> i32 {
let mut sum = 0;
for n in v {
sum += n;
}
sum
}
fn main() {
let v = vec![10, 20, 30];
let t = total(v);
println!("{:?} の合計は {}", v, t);
}
解答例
total(v) で v の所有権が関数へ移動してしまうのが原因です。引数を共有参照に変えます。
fn total(v: &Vec<i32>) -> i32 {
let mut sum = 0;
for n in v {
sum += n;
}
sum
}
fn main() {
let v = vec![10, 20, 30];
let t = total(&v);
println!("{:?} の合計は {}", v, t); // [10, 20, 30] の合計は 60
}
第5章の :::note info で触れたとおり、実務では次のようにスライスで受けるのがより一般的です。呼び出し側は &v のままで動きます。
fn total(v: &[i32]) -> i32 {
let mut sum = 0;
for n in v {
sum += n;
}
sum
}
なお for n in v で v が参照の場合、n の型は &i32 になります。sum += n はそのまま書けますが、明示したいときは sum += *n; と参照外ししても構いません。
まとめ:所有権チェックリスト
- スタック(サイズ確定・高速)とヒープ(サイズ可変・低速)の違いを説明できる
- 所有権の3ルール(所有者は1つ/値には所有者がいる/スコープを抜けると破棄)を言える
-
let v2 = v1;でムーブが起き、v1が使えなくなる理由を説明できる -
Copyされる型(整数・浮動小数点・bool・char・&T・それらのみのタプルや配列)を挙げられる - 関数の引数・戻り値でも所有権が動くことを理解し、参照で回避できる
-
共有参照
&vは複数、可変参照&mut vは同時に1つだけというルールを守れる -
does not live long enoughを見たら、参照元の生存期間と使用箇所の関係を疑える -
Boxはヒープに置くため、Rcは所有者を複数にするためのスマートポインタだと区別できる
所有権は、覚えるべきルール自体は多くありません。詰まったときは「この値の所有者は誰か」「その所有者はいつスコープを抜けるか」の2点に立ち返ると、たいていの borrow checker のエラーは読み解けます。エラーメッセージも丁寧なので、怒られながら覚えていくのが結局いちばんの近道です。