Rust: 所有権の原則(4/6)
所有権の仕組みに入る前に、その動機、すなわちメモリ管理を理解しておく必要があります。
メモリを割り当てることはたいてい簡単で、変数宣言時に行われます。
固定サイズ型(例: [T; N])はスタックに割り当てることができ、動的サイズ型(例: Vec<T>)はヒープに割り当てなければなりません。
メモリの割り当て解除(別名「解放」)こそが厄介な部分です。 従来、戦略は2つありました。自動ガベージコレクションと手動メモリ管理です。 Rustは1、3つ目のアプローチである所有権を導入しています。 以下の表は、おおよその比較を示しています。
| メモリ管理 | 高速か? | 安全か? | トレードオフ | 言語の例 |
|---|---|---|---|---|
| ガベージコレクション | いいえ、予測不能なレイテンシの急増がある2 | はい | リアルタイムシステムや組み込みシステムには不向き | Go、Java、Python、Haskell など |
手動の malloc/new と free/delete | はい | いいえ、極めて高い UB リスクがある3 | 何十年にもわたりセキュリティ脆弱性の重大な原因 | C、C++ |
| 所有権 | はい | はい | 一部の構造は表現が難しい | Rust |
ガベージコレクションの性能上のペナルティには説明が必要です。 問題は、回収が言語ランタイム(あなたのプログラムに同梱される別のプログラム)によって実行される非同期操作であることです。 つまり、予測しにくい間隔で、他のコードが実行されている間、あなたのプログラムは「一時停止」されます。 ガベージコレクターは CPU 時間を消費し、あなたのプログラムがその役割を果たすのを一時的に妨げます。
手動管理アプローチの安全性上の欠点を本当に理解するために、次章ではメモリ破壊エクスプロイトを書きます。
所有権の導入
では、Rust における所有権とは何でしょうか。 大まかに言えば、プログラム内のすべての値について、次の2つの情報を決定するための仕組みです。
-
有効なスコープ: その値が有効であり、したがって使用できる場所。
-
アクセス種別: 有効な値への参照をどのように使用できるか(読み取り専用か、書き込み可能か)。
参照については、その有効なスコープは Rust ではライフタイムと呼ばれます。 他のほとんどの言語では、スコープとライフタイムは別個の概念です。 しかし Rust の所有権モデルは、その境界を曖昧にします。
-
C 系の言語では、すでに解放された(そのライフタイムは終了した)後でも、変数がスコープ内にあるため、参照によってその変数にアクセスできてしまいます。その結果は UB です。
-
Rust では、それはコンパイル時エラーになります。安全なアクセスだけが許可されるため、スコープとライフタイムは重なっていなければなりません。
健全に推論するために、コンパイラはプログラム内のすべての値についてライフタイム情報を必要とします。
-
直接保持される値(参照の背後にない値)については、ライフタイムは自明です。現在それを所有しているなら、その値は生存していなければなりません。
-
参照の背後にある値については、多くの場合、ライフタイムを自動的に推論できます(これはライフタイム省略と呼ばれます)。それ以外の場合は、プログラマーが明示的に注釈を付けなければなりません。
ライフタイムは実行可能ファイルに存在しますか?
いいえ、ライフタイムはコンパイル時の構成要素です。 生成される機械語コードには現れず、コンパイラがプログラムの安全性を解析するためにのみ使用されます。 実行時のチェックもコストもありません。
すべての値には、単一で一意な所有者があります。 ソースコード上で所有者がスコープから「ドロップ」するまさにその場所(たとえば、それが宣言された関数の終端)で、その所有者が所有するすべての値のライフタイムは終了します。 そのため、コンパイラは所有されているすべての値を割り当て解除(別名「解放」)するコードを自動的に挿入します。
その結果、ソースコードに基づいてメモリとリソースの使用を正確に制御できます。 C や C++ が提供するものに匹敵しますが、安全性と引き換えに追加の制約があります。
以上が大まかな原則ですが、コードには微妙な点があります。 ルールと、それに対する例外という形でです。 このセクションと次のセクションの両方で、所有権を詳しく見ていきます。
メモリリークとは何ですか?管理戦略ごとには?
「メモリリーク」は、ある値がプログラムによって使われなくなったにもかかわらず、割り当て解除されない場合に発生します。 これは「情報漏えい」(機密情報を誤って露出させること)とは別のものです。 情報漏えいとは異なり、メモリリークはセキュリティ上の問題ではありません。
ただし、可用性には影響する可能性があります。 長時間実行されるプロセス(例: Web サーバー)が(例: ループ内で)繰り返しメモリをリークすると、最終的に利用可能な RAM を使い果たし、OS によって強制終了される可能性があります。
リークの起こり方は、メモリ管理戦略によって異なります。
ガベージコレクション - コレクターのコードには、値のライフタイムに関するソースレベルの情報がありません。実行時に参照を追跡しなければなりません。そして保守的でなければなりません。つまり、生存している可能性がある値は回収できません。アプリケーション固有のエッジケースによって、値への参照が無期限に保持され、リークが発生することがあります。
手動 - たとえ元の割り当てを遠く離れたライブラリコードが行っていたとしても、プログラマーが値を手動で解放し忘れれば、リークが発生します。
所有権 - リークが起こり得るのは、プログラマーが循環参照を伴う内部可変性(このセクションの最後を参照)のような特定のパターンを選択した場合に限られます。
所有権の階層
すべての値はちょうど1つの所有者を持たなければならないため、所有権は階層的な木として考えることができます。 木の各ノードは値であり、親の値はその子の値を所有します。
これは、すべてのプロセスがちょうど1つの親を持つ OS のプロセスツリーの構造におおよそ対応します。 完全な類推ではありませんが4、この例では木という考え方を定着させるために、そのまま進めます。 木は本書の中心的なテーマであり、コンピューターサイエンスにおける不朽の概念です。
次のプログラムを考えてみましょう。これは、以前の Proc 構造体を変更したバージョンを使用しています。
#[derive(Debug)]
pub enum State {
Running,
Stopped,
Sleeping,
}
#[derive(Debug)]
pub struct Proc {
name: &'static str, // Process name (update: nicer print than u32 pid)
state: State, // Current state
children: Vec<Proc>, // Children (update: now owned!)
}
impl Proc {
pub fn new(name: &'static str, state: State, children: Vec<Proc>) -> Self {
Proc {
name,
state,
children,
}
}
}
fn main() {
// Build process tree using 3 "moves" (more info soon):
//
// init
// |- cron
// |- rsyslogd
// |- bash
//
// Run "pstree -n -g" (in container) to see your OS's real process tree!
// Alloc bash
let bash = Proc::new("bash", State::Running, Vec::new());
// Alloc rsyslogd, 1st move: bash -> rsyslogd
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);
// Alloc cron
let cron = Proc::new("cron", State::Sleeping, Vec::new());
// Alloc init, 2nd and 3rd moves: cron -> init, rsyslogd -> init
let init = Proc::new("init", State::Running, vec![cron, rsyslogd]);
// Print serialized tree to see ownership hierarchy
dbg!(init);
}
このコードは、一連のムーブを使用して木(他の Proc 構造体を再帰的に含む Proc 構造体)を構築します。
ムーブは、所有権を移転する方法です。
次のセクションでは、このコードのムーブのシーケンスを調べます。
ここでは最終的な出力に注目しましょう。
[src/main.rs:49] init = Proc {
name: "init",
state: Running,
children: [
Proc {
name: "cron",
state: Sleeping,
children: [],
},
Proc {
name: "rsyslogd",
state: Running,
children: [
Proc {
name: "bash",
state: Running,
children: [],
},
],
},
],
}
Proc は Debug トレイトを導出しているため、dbg! マクロを使ってその内容のシリアライズを出力できます。
上で出力されている 4 つの Proc 値(init、cron、rsyslogd、bash という名前)にはすべて所有者があり、それは出力されたツリーで確認できます。
最も低く深いネスト階層から、下から上へ見ていくと次のようになります。
bashはrsyslogdに所有されています。rsyslogdはinitに所有されています。cronもinitに所有されています。initは別の値に所有されているのではなく、関数mainに所有されています。
これから、この所有権の階層がメモリの割り当てと解放にどのように影響するかを順に見ていきます。
静的ライフタイム
ここでの細かな点は
nameフィールドです。 その型である&strは、不変の文字列参照です。&'static strは、名前が 文字列リテラル であり、そのライフタイム('static)が、main関数の前後も含むプログラムの 実行全体 にわたることを意味します(その間に OS はセットアップとティアダウンのタスクを行います)。私たちの値はいずれも、その文字列名(
"init"、"cron"、"rsyslogd"、"bash")を 所有 していません。 それらは「永久に」生存する(プロセス終了まで生存する)何かへの参照(&)を 借用 しているだけであり、解放する必要はありません。 これらの文字列はコンパイル済みバイナリに埋め込まれています。
割り当て
各 Proc 構造体のメモリは、変数宣言時に割り当てられました。
各インスタンスは固定サイズで、64 ビットマシンではわずか 48 バイトです5。
#[test]
fn test_size() {
assert_eq!(core::mem::size_of::<Proc>(), 48);
}
固定サイズであることは割り当てには便利ですが(48 バイトの塊を切り出すだけで済みます)、奇妙に思えるかもしれません。
結局のところ、Proc の children フィールドは動的なリストです。
各子はそれぞれ 48 バイトのインスタンスであり、それぞれがさらに独自の子を持つ可能性があります。
定義されている Proc は再帰的な構造です。
さらに、name は任意の長さを持つことができます。
では、どうしてサイズを固定でき、これほど簡単に割り当てられるのでしょうか。
それは、Proc のサイズ計算には、その名前と子のリストへの ポインタ5 だけが含まれるためです。
子を持たない単一の Proc 構造体がメモリ上でどのように見えるかを、おおよそ示すと次のようになります。
1 つの子を持つ場合(その子自身は子を持たない)は、次のようになります。
構造体のサイズは変わっておらず、指し示されているスロットの内容だけが変わっています。
上の図に注目してください。これは、"proc_1" という名前の構造体が "proc_2" という名前の構造体を所有しているとき、メモリがおおよそどのように見えるかを示しています。
まもなく、1 つの構造体が別の構造体への参照を 借用 する、別のレイアウトと対比します。
解放
では、所有権は解放をどのように扱うのでしょうか。
先ほど見た dbg! の出力にある階層を使って考えます。
init は「トップレベル」の値であり、他の 3 つを所有しています。
そして、main 関数の実行が終わると ドロップ されます。
そのためコンパイラは、main の最後の行である dbg!(init); の直後に解放ロジックを追加します。
デストラクタ は自動的に実装されます。
このデストラクタは所有権の階層をたどることで、init に所有されている他の構造体をどのようにクリーンアップすればよいかを把握しています。
println! を 1 つ追加するだけで、実行時の解放シーケンスを追跡できます。
Rust は、ある型に Drop トレイト6 を実装するだけで、デストラクタの前に任意のロジックを実行できます。
外部リソースを解放するためのカスタムコード(たとえばネットワーク接続やデータベース接続を閉じるなど)や、それに類する処理を実行する必要がある場合に使えます。
今回は、ドロップのたびに name フィールドと Proc 構造体のメモリアドレスを出力します。
impl Drop for Proc {
fn drop(&mut self) {
println!("De-alloc-ing \'{}\' Proc @ {:p}", self.name, self);
}
}
ここでプログラムを実行すると、(シリアライズされた dbg! の出力の後に)次のように出力されます。
De-alloc-ing 'init' Proc @ 0x7ffd4d149460
De-alloc-ing 'cron' Proc @ 0x560d2fbb0b10
De-alloc-ing 'rsyslogd' Proc @ 0x560d2fbb0b40
De-alloc-ing 'bash' Proc @ 0x560d2fbb0ad0
関数 main が終了する直前に、構築したプロセスツリー全体がクリーンアップされていることが分かります。
ガベージコレクションとは異なり、この一連のイベントは予測可能です。
ソース内でデストラクタを手動で呼び出してはいませんが、経験豊富な Rust 開発者なら何が起こるかを推測できます。
このようにプログラムを書くことで、メモリ使用量をきめ細かく制御できました。
頭の体操をしたい気分なら、名前がこの特定の順序で出力される理由(ヒント: 決定的です)と、最初のアドレスが次の 3 つと異なって見える理由(ヒント: どの 2 つの 場所 が関わっているでしょうか)を少し考えてみてください。
Resource Acquisition is Initialization (RAII)
先ほど見た割り当て/解放の戦略は、より一般的には RAII と呼ばれ、特に C++ の世界でそう呼ばれます。 Scope-Bound Resource Management (SBRM) という用語を好む人もいます。
RAII/SBRM の中心的な考え方は、リソース(たとえばメモリ、ファイルハンドル、ロックなど)はコンストラクタで 取得 され、構築された値のライフタイム/スコープの間は使用でき、デストラクタで 解放 される、というものです。
Rust のコンパイラは、メモリについて常にこの振る舞いを強制します。
std::fs::File7 のような型や、Dropを実装したユーザー定義型は、メモリ以外のリソースについても同じことができます。
これで、所有権がメモリ管理、つまり割り当てと解放にどのように関係するかを直接見てきました。 その動機を理解したところで、所有権システムを効果的に使うために必要な概念へ移りましょう。 特に重要な 2 つは、ムーブ(所有権の移転)と 借用(所有されている値へのアクセスを一時的に貸し出すこと)です。
ムーブ
所有権をある値から別の値へ移転(別名「ムーブ」)できなければ、Rust は実用性に乏しいおもちゃの言語になってしまうでしょう。 ムーブ は、次のような日常的なプログラミングタスクを可能にします。
- 関数から値を返す。
- 計算結果を変数に格納する。
- ベクターやハッシュマップのような状態を持つコレクションを使用する。
上のプロセスツリーの例では、ムーブによって、他の Proc を含む Proc という複雑なネスト構造を段階的に組み立てることができました。
しかし、まずはもっと単純なものから始めましょう。
fn main() {
let x = "Hello!".to_string();
let y = x; // x は y にムーブされる。y が String 値 "Hello!" を所有するようになる
// これは動作する
println!("Owned string: {y}");
// これはコンパイル時エラーになる。x は「なくなって」おり、その値はムーブ済み!
//println!("Owned string: {x}");
}
// スコープの終わり。ここで y がドロップされる。
代入 let y = x; は、ヒープに割り当てられた String のコピーを作成したわけではありません。
長い文字列ではそれは高コストになるため、データ複製関数を明示的に呼び出す必要があります(例: let y = x.clone();)。
Rust はパフォーマンスを優先します。
代わりに、所有権の安価な移転を行いました。つまり、x から y(スタック変数)へのファットポインターのムーブです。
String 値は任意の時点で一意の所有者を 1 つしか持てないため、x は所有権を y に移すことでそれを「手放した」のです。
代入文が実行された後、y が唯一の所有者になります。
x は未初期化の空の状態のままになります。
そのため、もはやそれを使用したり出力したりすることはできません。
視覚的には、状況は次のようになります。
その概念を念頭に置いて、ツリーの構築においてムーブがどのように行われたかを見直してみましょう。 最初の 2 行は次のとおりでした。
// bash を割り当て
let bash = Proc::new("bash", State::Running, Vec::new());
// rsyslogd を割り当て、1 回目のムーブ: bash -> rsyslogd
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);
vec! マクロは、新しい Vec を作成して要素をその中にプッシュするための初期化の省略記法であることを思い出してください。
ベクターに要素を追加するとき、私たちはムーブを行っています。
代入 let y = x; と同様に、Rust はデータを暗黙的に複製しません。
つまり、rsyslogd プロセスを作成することで、値(Proc 構造体のインスタンス)をローカル変数 bash からローカル変数 rsyslogd の children フィールドへとムーブしました。
すべての値はちょうど 1 つの所有者を持たなければならないため、以前の x と同様に、変数 bash はもはや使用できません。
それを出力しようとしたとしましょう。
// bash を割り当て
let bash = Proc::new("bash", State::Running, Vec::new());
// rsyslogd を割り当て、1 回目のムーブ: bash -> rsyslogd
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);
// エラー: 所有者のない値は出力できません!
dbg!(bash);
このコードはコンパイルされません。
error[E0382]: use of moved value: `bash`
--> src/main.rs:70:10
|
64 | let bash = Proc::new("bash", State::Running, Vec::new());
| ---- move occurs because `bash` has type `Proc`, which does not implement the `Copy` trait
...
67 | let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]);
| ---- value moved here
...
70 | dbg!(bash);
| ^^^^ value used here after move
For more information about this error, try `rustc --explain E0382`.
なぜでしょうか? 解放について思い出してください。 ムーブは単一の所有者を維持しなければならず、それによって所有された値を格納しているメモリをいつ解放してよいかが明確(曖昧でない状態)になります。
上の誤ったコードは、特定の Proc インスタンスの所有権を rsyslog に渡そうとしながら、同時に bash の所有権も保持しようとしています。
そうすると解放が曖昧になります。コンパイラは、そのインスタンスのリソースを最終的に解放する責任が 2 つのうちどちらにあるのかを判断できません。
わかりました。 これで、なぜムーブは所有権を移転しなければならないのかについて感覚をつかめました。 しかし、これは制約が強いように感じます。 大規模で複雑なプログラムを書いている場合はどうでしょうか? 変数を出力したり、さらにはアクセスしたりする能力を失うことは、すぐに不満の種になるでしょう。 完全な障害にならないとしてもです。 ここで 借用 が登場します。
借用とライフタイム
借用は、値へのアクセスを一時的に許可する仕組みです。 その値をムーブして元の所有者を空にすることはありません。
この概念は抽象的に感じるかもしれないので、実用的な例、つまり関数の引数から始めましょう。 以下のプログラムは以前の文字列の例に似ていますが、今回は文字列パラメーターの長さを出力する関数によってムーブが行われます。 最後の行がコメントアウトされていなければ、ムーブに関連する別のコンパイル時エラーが発生します。
fn print_str_len(s: String) {
println!("\'{}\' is {} bytes long.", s, s.len());
}
fn main() {
let x = "Hello!".to_string();
// x は関数にムーブされ、その関数が String 値 "Hello!" を所有するようになる
print_str_len(x);
// これはコンパイル時エラーを引き起こす。x は「なくなって」おり、その値はムーブ済み!
//println!("Owned string: {x}");
}
1 つの潜在的な修正方法は、print_str_len が入力の String を返すようにして、それを x に再代入できるようにすることです。
本質的には、関数型スタイルで行ったり来たりムーブするということです。
しかし、借用はよりよい方法を提供します。
このコードはコンパイルされ、実行されます。
fn print_str_len(s: &String) {
println!("\'{}\' is {} bytes long.", s, s.len());
}
fn main() {
let x = "Hello!".to_string();
// 関数は参照によって x を一時的に借用する。
print_str_len(&x);
// 今回はエラーにならない!x は依然として String を所有している。
println!("Owned string: {x}");
}
-
print_str_lenのパラメーターがString(「文字列」)から&String(「文字列への不変参照」)に変わりました。- 小さな詳細:
&str型を使うとさらによかったでしょう。そうすれば、print_str_lenは文字列スライスに対しても動作できるようになるためです。静的ライフタイムを持つものも含まれます。
- 小さな詳細:
-
呼び出し元は、参照によって
xを借用するようになりました(print_str_len(x);がprint_str_len(&x);に更新されました)。- 重要な概念: 関数はもはや所有権を受け取りません。出力を行うのに十分な時間だけ、文字列へのアクセスを借用するだけです。
借用には従わなければならない規則があり、前の章で可変エイリアシングについて議論したときにそれに触れました。
それらの規則については次のセクションで戻ってきます。
借用によって Proc ツリーの例がどのように変わるかを見てみましょう。
#[derive(Debug)]
pub struct Proc<'a> {
name: &'static str, // Process name
state: State, // Current state
children: Vec<&'a Proc<'a>>, // Children (update: now borrowed!)
}
impl<'a> Proc<'a> {
pub fn new(name: &'static str, state: State, children: Vec<&'a Proc>) -> Self {
Proc {
name,
state,
children,
}
}
}
fn main() {
// Alloc bash
let bash = Proc::new("bash", State::Running, Vec::new());
// Alloc rsyslogd, 1st move: bash -> rsyslogd
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![&bash]);
// Print owned value (new!)
dbg!(&bash);
// Alloc cron
let cron = Proc::new("cron", State::Sleeping, Vec::new());
// Alloc init, 2nd and 3rd moves: cron -> init, rsyslogd -> init
let init = Proc::new("init", State::Running, vec![&cron, &rsyslogd]);
// Print another owned value (new!)
dbg!(&cron);
// Print serialized tree to see ownership hierarchy
dbg!(&init);
}
大きな違いは 2 つあります。
-
借用を使うことで、
bashとcronをdbg!で出力するためにアクセスする柔軟性が得られました。ツリーに追加した後でさえ可能です。これは、追加がもはやムーブを行わないためです。childrenはProc構造体によって借用されるだけであり、別の場所で「生きる」ことができます。 -
Proc構造体定義と、そのコンストラクター引数の 1 つに ライフタイム 注釈('a)を追加しました。これまで使ってきた'staticライフタイムは、値(ここではname)がプログラム全体にわたって生存することを示しますが、'aはProcが、それが借用する参照(ここでは所有されていないchildren)と少なくとも同じ長さだけ生存しなければならないことを示します。
ライフタイム注釈は、特にジェネリクスと一緒に現れると、威圧的に見えるかもしれません。 Rust のシグネチャは、ときどき少し込み入ったものになることがあります。 今の時点でこの記法やその背後にある概念に慣れている必要はありません。これは時間と経験とともに感覚がつかめるものです。 本書を通じて、これには繰り返し戻ってきます。
なぜコンパイラにはライフタイム注釈が必要なのでしょうか?
Rust のコンパイラは、特定の結果を 1 つの関数ごとに見て計算する必要があります。なぜなら、考えられるすべての関数呼び出し列を全体的な「呼び出しチェーン」として考慮することは、非常に高コストだからです。
しかし、計算された結果は、考えられるすべての呼び出しチェーンに対して妥当である必要があります。 この種のものを指す技術用語は「手続き間静的解析」です。 構造体や関数に付けるライフタイム注釈は、こうした種類の解析を支援します。 手短に言えば、ライフタイム注釈は、人間参加型の性質検証システムを可能にします。 コンパイラは、ときどきあなたの天才的な生身の脳にそれを問い合わせることで、あなたの意図を学習し、そうでなければ手に負えない問題、すなわちメモリエラーの排除を解く手助けをします。 それは、ほぼ全知でありながら狭い範囲に集中する完璧主義者と一緒にペアプログラミングをするようなものです。
うげ、ライフタイム注釈っていつも書き出さないといけないの?
幸い、その必要はありません! ライフタイム情報は常に存在していなければなりませんが、多くの場合は自動的に推論できます。 実際、コンパイラには先ほどの
print_str_len関数が次のように見えています。#![allow(unused)] fn main() { fn print_str_len<'a>(s: &'a String) { println!("\'{}\' is {} bytes long.", s, s.len()); } }静的解析により、その文字列参照が有効なライフタイムを持つことが保証され、安全性とメモリ管理が処理されます。しかも、それを明示的に書き出す必要はありません。
視覚的には、子を持たない単一の Proc 構造体について、借用ベースのメモリレイアウトは次のようになります。
そして、子を 1 つ持つ Proc(その子自身は子を持たない)の場合は次のようになります。
親プロセスは子への参照を保持し、そのライフタイムをコンパイラがチェックします。 これを、以前のムーブベースの図と比較してみましょう。
-
借用では、解放に関する限り、各構造は独立したままです。
-
構築した論理ツリーは変わりません。上記の借用ベースのプログラムの完全な出力では、
[src/main.rs:73]で始まる行にそれを確認できます。
[src/main.rs:61] &bash = Proc {
name: "bash",
state: Running,
children: [],
}
[src/main.rs:70] &cron = Proc {
name: "cron",
state: Sleeping,
children: [],
}
[src/main.rs:73] &init = Proc {
name: "init",
state: Running,
children: [
Proc {
name: "cron",
state: Sleeping,
children: [],
},
Proc {
name: "rsyslogd",
state: Running,
children: [
Proc {
name: "bash",
state: Running,
children: [],
},
],
},
],
}
De-alloc-ing 'init' proc @ 0x7ffe455e5c40
De-alloc-ing 'cron' proc @ 0x7ffe455e5bf0
De-alloc-ing 'rsyslogd' proc @ 0x7ffe455e5ae0
De-alloc-ing 'bash' proc @ 0x7ffe455e5a90
このツリーのムーブで構築したバリエーションと借用で構築したバリエーションの違いは、今すぐ完全に腑に落ちる必要はありません。 これらは、コンパイラ設計とコンピュータアーキテクチャの交差点にある難しい概念です。 しかし、これらすべてがどのようにつながっているのか、少し感覚がつかめ始めているかもしれません。
まとめ
所有権は Rust の最も斬新な機能ですが、同時に最も複雑な機能でもあります。 ここまで所有権について学んだことを振り返ってみましょう。
-
これはメモリの割り当てと解放を管理する、高速で安全な方法です。
-
すべての値には所有者があります。所有権の移転はムーブと呼ばれます。
-
値は、その所有者がスコープを抜けたときに解放されます。そのスコープは、Rust では実質的にライフタイムです。
-
値は参照を通じて借用できます。不変かつ非排他的に、または可変かつ排他的に借用できます。どちらも、値をムーブせずに一時的なアクセスを許可する方法です。
-
借用は、参照先の値より長く生存することはできません。借用は必然的に、より短いライフタイムを持ちます(名前が示すように、借用は一時的なものです)。
これら 5 つの箇条書きが大まかな直感として結びつき始めているなら、順調です。 そして、所有権システムを日々扱うためのコードパターンを、より詳しく見ていく準備ができています。
この概念の集中砲火は今は難解に感じるかもしれませんが、所有権はやがて、時間と練習を通じて身についていきます。 これは物事を行ううえで異なる方法ですが、慣れていけるものです。 さらに探っていきましょう。
-
「所有権」は Rust が発明したものではなく、線形型とアフィン型に関する先行研究に基づいています。さらに、コンパイル時メモリ管理を行う所有権ベースの「領域解析」は、Cyclone という秘教的な言語に登場していました。しかし、メモリを管理するために所有権の強制を使用する、主流で商業的に実用可能な言語としては、Rust が初めてです。 ↩
-
Discord が Go から Rust に移行する理由。Jesse Howarth(2020)。 ↩
-
SoK: メモリにおける永遠の戦争。Laszlo Szekeres、Mathias Payer、Tao Wei、Dawn Song(2012)。 ↩
-
この類推は完全ではありません!Linux では、最初に実行されるプロセスである
initには親がありません。同様に、&'staticライフタイムを持つ Rust の値は、プログラム内のどの変数にも所有されません。親プロセスは、その子も終了させることなく kill できます。Rust では、所有者がスコープを抜けると、その所有者が所有するすべての値も解放されます。 ↩ -
assertしている 48 バイトのサイズには、childrenのヒープデータへの fat pointer(メモリアドレス、総容量、現在の長さのタプル)だけが含まれています。同様に、nameフィールドは読み取り専用メモリにハードコードされた文字列へのポインタです。 ポインタは単なるメモリアドレスであり、マシン固有の幅を持ちます。そのため、このサイズが「64 ビットマシン」向けであるという注意書きをしています。 ↩ ↩2 -
トレイト
std::ops::Drop。The Rust Team(2022 年参照)。 ↩ -
構造体
std::fs::File。The Rust Team(2022 年参照)。 ↩