Skip to content

필사 모드: Rustの所有権・借用・ライフタイム

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに — GCなしの安全なメモリ

ほとんどの言語は、メモリ安全を2つの方法のどちらかで手に入れます。C・C++のようにプログラマにすべてを委ねるか(そしてuse-after-free、二重解放、データ競合を得るか)、Java・Go・Pythonのようにガベージコレクタに委ねるか(そしてランタイムのオーバーヘッドと予測できない停止を得るか)です。

Rustは第三の道を行きます。メモリと資源の寿命をコンパイル時に追跡し、GCなしでもuse-after-freeとデータ競合がそもそもコンパイルされないようにします。この魔法の正体は3つのルール集合です。所有権(ownership)借用(borrowing)ライフタイム(lifetimes)。この3つを理解すれば、Rustの残りの大半は自然についてきます。逆に、この3つと格闘する期間こそが、あの悪名高い「借用チェッカーとの戦い」です。

この記事の目標は、その戦いを早く終わらせることです。ルールを暗記する代わりに、なぜそのルールがあるのかを理解すれば、チェッカーがなぜ怒っているのかが見え始めます。

所有権の3つのルール

Rustの公式書籍は、所有権を3つの文にまとめています。

  1. Rustのすべての値には**所有者(owner)**がいる。
  2. 所有者は一度に1つだけ。
  3. 所有者がスコープを抜けると、値は**ドロップ(dropped)**される。

3番目のルールがGCを置き換える部分です。スコープの閉じ}で、Rustはそのスコープが所有する値のdropを自動的に呼び出します。ヒープメモリなら解放され、ファイルなら閉じられ、ロックなら解放されます。これをRAII(Resource Acquisition Is Initialization)と呼び、C++由来の概念ですが、Rustはこれを言語レベルで強制します。

fn main() {
    let s = String::from("hello"); // sがヒープ文字列を所有
    println!("{s}");
} // ここでsがスコープを抜ける → drop(s)が自動呼び出し → ヒープメモリ解放

Stringはデータをヒープに置き、スタックには(ポインタ、長さ、容量)の3つの値を持ちます。スコープが終わるとスタックの3値が消え、その直前にdropがヒープバッファを返却します。開発者がfreeを呼ぶ必要も、GCが後で掃除する必要もありません。解放のタイミングがコードに静的に刻まれているのです。

ムーブセマンティクス — コピーではなくムーブ

ここで「所有者は1つだけ」というルールが面白くなります。値を別の変数に代入するとどうなるでしょうか。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;        // s1の所有権がs2へ「ムーブ(move)」
    println!("{s2}");   // OK
    // println!("{s1}"); // コンパイルエラー! s1はもう有効ではない
}

他の言語に慣れていると、s1s2は同じ文字列を指す2つの名前だと予想します。しかしそれではルール2を破ります。所有者が2つになるからです。もし両方が有効なら、スコープ終了時にdrop2回呼ばれ、同じヒープバッファを二重解放してしまいます。これがC++で悪名高いバグです。

Rustの解法は、代入をムーブとして定義することです。let s2 = s1;の後、s1は「ムーブ済み(moved-out)」状態になり、もう使えません。所有権はちょうど1つの変数(s2)にあり、dropも1回だけ呼ばれます。ムーブ自体はスタックの3値(ポインタ、長さ、容量)だけをコピーする浅い操作なので、ヒープデータには触れません。速くて安全です。

関数呼び出しも同じです。値を関数に渡すと、所有権が関数へムーブします。

fn consume(s: String) {
    println!("{s}");
} // ここでsがドロップされる

fn main() {
    let s = String::from("hello");
    consume(s);         // 所有権がconsumeへムーブ
    // println!("{s}"); // エラー! sはすでにムーブ済み
}

E0382 — ムーブ後使用エラーを読む

上のコメントを外すと、Rustはこう言います。Rustのエラーメッセージは世界でもっとも親切な部類なので、実際に読むだけで大半が解決します。

error[E0382]: borrow of moved value: `s`
  --> src/main.rs:9:22
   |
7  |     let s = String::from("hello");
   |         - move occurs because `s` has type `String`,
   |           which does not implement the `Copy` trait
8  |     consume(s);
   |             - value moved here
9  |     // println!("{s}");
   |                    ^ value borrowed here after move

このメッセージを分解しましょう。error[E0382]はエラーコードです。rustc --explain E0382で詳しい説明を見られます。本文は3か所を正確に指します。(1) sがムーブされた理由はStringCopyを実装していないから、(2) 値がムーブされた地点はconsume(s)、(3) ムーブ後に値を再び使おうとした地点。原因、ムーブ地点、再使用地点が一目でわかります。

解決策は状況によって異なります。値を使い続けたいなら、(a) consumeが所有権を取る代わりに借用するようにする(次節)、(b) s.clone()で深いコピーを渡す、(c) consumeが値を返すようにする、のいずれかです。たいていの正解は(a)です。

借用 — 所有権を渡さずにアクセスする

すべての関数が引数の所有権を取るなら、プログラミングは苦行になります。文字列の長さを測るためだけに所有権を渡して返してもらう必要があるからです。そのために**借用(borrowing)**があります。値を所有せずに一時的な参照だけを借りるのです。構文はアンパサンド(&)です。

fn length(s: &String) -> usize { // &String: 文字列を「借用」
    s.len()
} // sは参照にすぎないので、ここでドロップされない(元は生き続ける)

fn main() {
    let s = String::from("hello");
    let n = length(&s); // &s: sを貸し出す
    println!("{s} has length {n}"); // sはまだ有効!
}

参照は値を所有しないので、参照がスコープを抜けても元はドロップされません。lengthが終わってもmainsは無傷です。これが借用の核心です。所有権はそのままに、アクセス権だけをしばらく貸し出す。

借用には2種類あります。

  • 不変参照(&T): 読み取り専用。値を見られるが変更はできない。
  • 可変参照(&mut T): 読み書き。借りた値を変更できる。
fn push_world(s: &mut String) { // 可変で借用
    s.push_str(" world");       // 元を変更
}

fn main() {
    let mut s = String::from("hello"); // 可変で貸すには`mut`宣言が必要
    push_world(&mut s);                // 可変参照を渡す
    println!("{s}"); // "hello world"
}

借用チェッカー — 「共有 XOR 可変」ルール

さて、Rustの心臓部です。借用チェッカーは参照についてちょうど1つのルールを強制します。これを理解すればチェッカーエラーの90%が説明できます。

任意の瞬間、ある値について**1つの可変参照(&mut)**を持つか、**複数の不変参照(&)**を持つか、どちらか一方だけが可能。両方を同時には持てない。

これはよく**「共有 XOR 可変(shared XOR mutable)」または「エイリアス XOR 変更(aliasing XOR mutation)」**と呼ばれます。排他的論理和(XOR)という名前の通り、共有(複数の&)と変更(&mut)は相互排他です。複数が読んでいる間は誰も書けず、誰かが書いている間は誰も読めません(その本人を含めて)。

fn main() {
    let mut s = String::from("hello");

    let r1 = &s; // 不変参照1
    let r2 = &s; // 不変参照2 — OK、複数の不変参照は許される
    println!("{r1} and {r2}");

    let r3 = &mut s; // 可変参照 — 上の不変参照がもう使われないならOK
    r3.push_str(" world");
    println!("{r3}");
}

不変参照が生きている間に可変参照を作ろうとすると、エラーです。

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;        // 不変借用が始まる
    let r2 = &mut s;    // エラー! 不変借用中に可変借用はできない
    println!("{r1} {r2}");
}

このルールがなぜ必要なのでしょうか。データ競合とイテレータ無効化(iterator invalidation)をコンパイル時に防ぐためです。あるスレッドがベクタを読んでいる間に別のスレッドが(あるいは同じスレッドが)そのベクタに要素を追加すると、再割り当てが起きて読んでいた参照が虚空を指すことになります。C++ではこれがランタイムクラッシュや静かな破損として現れますが、Rustではそもそもコンパイルされません。「可変参照が1つなら、その参照を通さずに誰も値を変えられない」という保証が、データ競合の定義そのもの(「同期なしの並行アクセスで少なくとも1つが書き込み」)をコンパイル時に不可能にします。

E0502 — 不変・可変借用の衝突

上の衝突を実際のコレクションのコードで見るとこうなります。もっともよく出会うエラーの1つです。

fn main() {
    let mut v = vec![1, 2, 3];
    let first = &v[0];  // 不変借用(要素を指す)
    v.push(4);          // 可変借用(ベクタ全体を変更)
    println!("{first}");
}
error[E0502]: cannot borrow `v` as mutable because it is also
              borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let first = &v[0];
  |                  - immutable borrow occurs here
4 |     v.push(4);
  |     ^^^^^^^^^ mutable borrow occurs here
5 |     println!("{first}");
  |               ------- immutable borrow later used here

このエラーは煩わしく見えますが、実は本物のバグを防いでいます。 v.push(4)は、ベクタの容量が足りなければヒープに大きなバッファを新しく割り当て、既存の要素をコピーして古いバッファを解放します。するとfirstが指していたアドレスはすでに解放されたメモリです。C++のstd::vectorではこれが有名なイテレータ無効化バグで、たいてい静かに誤った値を読むかセグフォルトします。Rustはfirstの不変借用がprintln!まで生きていることを知っているので、その間のpushを拒否します。解決策は、firstを使い終わってからpushする、あるいはインデックスでその都度アクセスすることです。

ちなみに、Rust 2018で導入されたNLL(Non-Lexical Lifetimes)のおかげで、参照の寿命はスコープの閉じ}ではなく最後に使われた地点までです。だから上でprintln!("{first}")を消せばコンパイルが通ります。firstがもう使われないので、借用はすでに終わったとみなされるからです。

ライフタイム — 参照は参照先より長生きしてはいけない

借用チェッカーの2つ目の仕事は、**ダングリング参照(dangling reference)**を防ぐことです。参照は自分が指す値より長生きしてはいけません。さもないと、すでに解放されたメモリを指すことになるからです。

fn main() {
    let r;
    {
        let x = 5;
        r = &x;     // xを借用
    }               // xがここでドロップされる
    // println!("{r}"); // エラー! rは死んだxを指す
}

Rustはrxより長生きしようとしていることを知り、拒否します(エラーコードE0597、"x does not live long enough")。ここまではコンパイラが自力で推論します。しかし関数の境界を越えると、コンパイラが単独では分からない場合が出てきます。そのとき私たちがライフタイム注釈で関係を明示します。

// 2つの文字列スライスのうち長い方を返す
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

ここに付いた'a(ライフタイムパラメータ、「ティック・エー」と読みます)がライフタイム注釈です。これが言っているのはこうです。「返される参照は、xyのうち短く生きる方と同じだけは有効だ」。注釈は参照の実際の寿命を変えません。 ただ複数の参照の寿命の関係をコンパイラに伝えるだけです。コンパイラはこの関係を使って、返り値が元より長く使われていないかを呼び出し地点で検査します。

なぜ必要なのでしょうか。コンパイラの立場からは、longestの返り値がxから来たのかyから来たのか、本体を見ずには分かりません(そして検査はシグネチャだけを見て行います)。2つの引数の寿命は異なりうるので、返された参照がどれだけ有効かを表現する方法が必要です。'aがまさにその表現です。

ライフタイム省略 — たいていは書かなくてよい

ここまで読んで「ではすべての参照に'aを付けるのか」と心配なら、幸いそうではありません。コンパイラには**ライフタイム省略規則(elision rules)**があり、よくあるパターンでは注釈を自動で推論します。だから実戦で明示的なライフタイムを書くことは、思ったより稀です。

// 明示的に書くと:
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }

// 省略規則のおかげで、こう書いても同じ:
fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(i) => &s[..i],
        None => s,
    }
}

省略規則はおおよそこうです。(1) 各入力参照はそれぞれのライフタイムを得る。(2) 入力参照がちょうど1つなら、そのライフタイムがすべての出力参照に付与される。(3) メソッドに&self&mut selfがあれば、selfのライフタイムがすべての出力に付与される。この規則で曖昧さがなければ、私たちは何も書きません。規則で決まらない場合(longestのように入力参照が2つ)だけ明示します。

'staticライフタイムも知っておく価値があります。&'static strはプログラム全体の期間生きる参照で、代表例は文字列リテラル("hello")です。リテラルはバイナリに埋め込まれており、常に有効だからです。

Copy vs Clone — ムーブが起きない型

先ほど、Stringの代入はムーブでした。しかし整数は違います。

fn main() {
    let x = 5;
    let y = x;          // ムーブではなくコピー(copy)
    println!("{x} {y}"); // 両方有効! xはムーブされていない
}

i32のような型はCopyトレイトを実装しています。Copyな型は代入時にムーブの代わりにビット単位のコピーが起き、元がそのまま有効です。なぜ整数はよくてStringはだめなのでしょうか。整数はスタックに丸ごと住む固定サイズの値なので、ビットをコピーしてもヒープ資源の共有がなく、二重解放の危険がありません。一方Stringはヒープポインタを抱えているので、ビットコピーすると2つの所有者が同じヒープを指すことになります。だからCopyにはなれません。

Copyな型: すべての整数・浮動小数(i32, u64, f64)、boolchar、そしてCopyなものだけで構成されるタプル・配列((i32, i32), [u8; 4])。ルールは「ヒープ資源を所有せずDropも実装しない、純粋なスタック値」です。

Cloneは明示的な深いコピーです。Copyが自動・暗黙・安価なコピーなら、Cloneは手動・明示的で、たいてい高価です(ヒープ割り当てを伴います)。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone(); // ヒープデータまで丸ごと複製(新しい割り当て)
    println!("{s1} {s2}"); // 両方有効 — s1はムーブされていない
}

.clone()はムーブエラーからの手軽な脱出口ですが、濫用は性能の臭いです。ヒープ文字列を毎回複製するコードは、たいてい借用(&str)に書き換えられます。初心者のうちに.clone()でチェッカーをなだめたなら、慣れた後にその箇所を参照へリファクタリングするのが良い次の一歩です。

まとめると、代入時の挙動は3通りです。Copyなら自動コピー(元は有効)、Cloneを明示的に呼べば深いコピー(元は有効)、どちらでもなければムーブ(元は無効化)。

手で覚える

所有権・借用・ライフタイムは、読んで知るのと手で知るのの隔たりが特に大きいテーマです。チェッカーがなぜ拒否するのかは、結局のところ何度も拒否されて体に染みます。Rust学習ラボでムーブ・借用・ライフタイムのシナリオを自分でコンパイルし、エラーメッセージを読む練習をすれば、この記事のルールがずっと早く手に馴染みます。

1つ実戦的なコツで締めます。チェッカーと戦うことになったら、コードをむやみに変える前にエラーコードで原因をまず読んでください。 E0382(ムーブ後使用)かE0502(借用衝突)かE0597(寿命不足)かで、処方はまったく異なります。Rustのエラーは大半が、何を、どこで、なぜ破ったかを正確に教えてくれます。その自白を読むことが、推測より百倍速いのです。

おわりに

所有権・借用・ライフタイムは最初は障害物のように感じられますが、実は他の言語ならランタイムで(あるいは本番の午前3時に)爆発したはずのバグを、コンパイラが代わりに捕まえてくれているのです。use-after-free、二重解放、データ競合、イテレータ無効化 — これらすべてが「所有者は1つ、共有 XOR 可変、参照は参照先より長生きできない」という3つのルールから静的に排除されます。

借用チェッカーとの戦いには終わりがあります。ルールの理由を理解した瞬間、チェッカーは敵ではなくペアプログラミングのパートナーになります。その時点からRustは「気難しい言語」ではなく「自分のミスをコンパイル時に指摘してくれる言語」になります。

参考資料

현재 단락 (1/156)

ほとんどの言語は、メモリ安全を2つの方法のどちらかで手に入れます。C・C++のようにプログラマにすべてを委ねるか(そしてuse-after-free、二重解放、データ競合を得るか)、Java・Go・P...

작성 글자: 0원문 글자: 8,852작성 단락: 0/156