Rust: 実践における所有権(全6回中5回)
Rust は、所有権を柔軟かつ実用的にするための4つの仕組みを提供します。 基本原則を守りながら、プログラム全体で所有権が移り変わるための方法です。
そのうちの2つ、ムーブと借用はすでに見てきましたが、全体像を一通り見渡すことには十分な価値があります。 これらは、所有権の実装と強制を担うコンパイラコンポーネント1である借用チェッカーとうまく付き合うための「コツ」です。
借用チェッカーを納得させるのは難しい場合があります。 Rust に慣れていないプログラマーは、プログラムを表現しようとしたときにエラーに遭遇する「借用チェッカーとの戦い」を経験するかもしれません。 幸いなことに、こうした障害の大部分は経験を積むにつれて消えていきます。
このセクションでは、次の内容を通じて、所有権についての議論を続けます。
- 新しい視点から課題の動機付けを行う。
- ASCII による可視化でライフタイムを説明する。
- 借用チェッカーと付き合うための4つの仕組みをすべて列挙する。
アシュアランスの目標を念頭に置く
コードスニペットに戻る前に、私たちの動機を改めて確認しましょう。 なぜ、これらの複雑な所有権の概念を学ぶ価値があるのでしょうか。
Rust コンパイラは、人間がループに入る性質検証エンジンに似ている、と言うこともできるでしょう。 機械と人間の融合です。 これは大げさな概念化です。 しかし、そこには一定の真実があります2。
-
利点: 機械は、性能上の制約のもとでメモリ安全性を保証するための解析を実行します(証明される性質)。
-
部分的な自動化とのトレードオフ: 機械が行き詰まったときに助けとなるよう、人間がライフタイムのソースアノテーションを管理します。あるいは、ときには問題を完全に捉え直して、機械で検査可能な形にします。
- コンパイラエラーのフィードバックループ: Rust のコンパイル時エラーは、多くの場合、非常に実行可能な対処を示します。しかし、それらは複雑でもあり、頻繁に発生することもあります。これは不完全なフィードバックチャネルです。
この協働がうまくいけば、大きな成果が得られます。 メモリ安全性の脆弱性がなく、一般的な信頼性(例: 厳格なエラー処理)を重視した、高性能なプログラムが得られます。 これは高アシュアランスソフトウェアの堅実な出発点です。
Computers and Humans Exploring Software Security(CHESS)
CHESS は、「米国政府、軍、および経済が依存している複雑なソフトウェアエコシステムに適した規模と速度で 0-day 脆弱性を発見することを目標として、コンピューターと人間がソフトウェア成果物について協働で推論できるようにすることの有効性」に関する DARPA の研究プログラム3でした4。
これは、詳細なセキュリティ評価がスケールさせるのが難しい専門家プロセスであるという事実への対応です。 Rust は CHESS プログラムにおける解決策とは見なされていませんでした。 すべての基準を満たすことはなかったでしょう。 しかし、これをライフサイクルにおけるシフトレフトと考えることはできます。つまり、借用チェッカーに支援された開発者は、評価者が発見すべきメモリ破壊バグを持ち込まない、ということです。
その観点から見ると、Rust には驚くほど高い**投資対効果(ROI)**があります。 Rust が早期に防ぐバグは、資産のライフサイクルの後半で修正する方が高くつきます。
- 本番環境へのパッチ適用には、顧客ごとのコストとリスクが伴います。
- コンパイラエラーに従うことには、それがありません。
スコープとライフタイム
前述したように、ほとんどのプログラミング言語では、スコープとライフタイムは別々の概念です。
-
スコープとは、値にアクセスできるコードの範囲です。
- 値がグローバルでない限り、通常は関数内、つまり多くの言語では
{と}の括弧の間を意味します。
- 値がグローバルでない限り、通常は関数内、つまり多くの言語では
-
ライフタイムとは、値が有効な状態にある期間です。
- ガベージコレクションを備えた言語では、それは値への参照が存在する限りです。システム言語では、値が解放されるまでである場合があります。
Rust の借用チェッカーは、これら2つの概念の境界を曖昧にします。 借用チェッカーは、スコープに基づくライフタイムの強制に執拗にこだわります。
これらの考え方がどのように展開するのか、他の情報源から借りた例5で感覚をつかみましょう(意図した洒落です)。 まず、小さな C++ コードスニペットから始めます。
#include <iostream>
int main() {
int *p; // 整数へのポインター
{ // スコープ S の開始
int x = 1337; // 値
p = &x; // 値への参照
} // スコープ S の終了
// x を出力すると未定義動作が発生します! :(
std::cout << "x = " << *p << std::endl;
return 0;
}
C++ には借用チェッカーがないため、このプログラムは警告なしでコンパイルされます6。
そして、この関数の最後にある出力(std::cout で始まる行)は UB を引き起こします。
より大きなプログラムの文脈では、どのような UB もクラッシュやエクスプロイトにつながる可能性があります。
問題は、すでにスコープ外になった値(x)への参照(p)を使おうとしていることです。
出力時点では、x のライフタイムは終わっています。
Rust で同じことを試したときに、借用チェッカーが何と言うか見てみましょう。
fn main() {
let p; // 整数への参照
{ // スコープ S の開始
let x = 1337; // 値
p = &x; // 値への参照
} // スコープ S の終了
// コンパイル時エラー!
println!("x = {}", p);
}
次のエラーが出力されます。
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
6 | p = &x; // 値への参照
| ^^ borrowed value does not live long enough
7 | } // スコープ S の終了
| - `x` dropped here while still borrowed
...
10 | println!("x = {}", p);
| - borrow later used here
このコンパイラエラーを少し時間を取って読んでみてください。
読者によっては、複雑なコンパイラエラーがある程度意味を持ち始めるのは、ここかもしれません。
借用チェッカーはライフタイムの問題について不満を述べています。
それは正当なことです。
ここで関係している2つのライフタイム('a と 'b)を書き出すことができます。
fn main() {
let p; // ---------+-- 'a
// |
{ // |
let x = 1337; // -+-- 'b |
p = &x; // | |
} // -+ |
// |
println!("x = {}", p); // ---------+
}
借用は、参照先の値より長く存続できないことを思い出してください。
上記では 'a が 'b より長く存続するため、借用チェッカーがこのプログラムを拒否するのは正当です。
x の定義を包み込むネストされたスコープ S がなければ、C++ でも Rust でもこの問題は発生しません。
これは問題ありません。
fn main() {
let p; // ---------+-- 'a
// |
let x = 1337; // -+-- 'b |
p = &x; // | |
// | |
println!("x = {}", p); // -+-------+
}
ここでは、借用のライフタイム('b)は、借用される値のライフタイム('a)の厳密な部分集合です。
どのルールにも違反していません。
さて、関数内のネストされたスコープはそれほど一般的ではないため、この例は作為的に感じられるかもしれません。 それはもっともです。 これは概念を説明するためだけのものです。 より現実的な例としては、スタック上のローカル変数への参照を返す、変数を二重に解放する、解放済みの値を読み取る、などが考えられます。 私たちの例におけるネストした波括弧と同様に、これらのケースでもライフタイムの不一致が発生する可能性があります。
コードベースの規模と複雑さが増すと、ライフタイムを手作業で推論することは難しくなります。 そして、たった 1 つのミスであっても、信頼性やセキュリティ、あるいはその両方を危険にさらす可能性があります。
柔軟性の仕組み
所有権を実世界のプログラムの出荷と両立させるには、多少の余地が必要です。 単一所有者ルールの中に、少しの融通が必要です。 ここからは、これらの柔軟性の仕組みを概観し、本書全体で使用していきます。
1) 所有権をムーブする
前のセクションでムーブを見ました。
ライフタイムについてよりよく理解できたので、前のセクションの最初の Proc ツリーの例、つまり借用ではなくムーブを使用した例を振り返ってみましょう。
以下の右側の ASCII グラフは、各変数の値が別の変数へムーブされたときに、その変数のライフタイムがどのように終了するかを示しています。
// bash を割り当て //
let bash = Proc::new("bash", State::Running, Vec::new()); // ---------+-- 'a
// |
// rsyslogd を割り当て、1 回目のムーブ: bash -> rsyslogd // |
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![bash]); // ---------+-- 'b
// |
// cron を割り当て // |
let cron = Proc::new("cron", State::Sleeping, Vec::new()); // -+-- 'c |
// | |
// init を割り当て、2 回目と 3 回目のムーブ: cron -> init, rsyslogd -> init // | |
let init = Proc::new("init", State::Running, vec![cron, rsyslogd]); // -+-------+--'d
// |
// 所有権階層を見るためにシリアライズされたツリーを出力する // |
dbg!(init); // ---------+
一般に、所有権は次の方法でムーブできます。
- 値を新しい変数に代入する。
- 値を関数に渡す(参照を使用しない場合)。
- 値を関数から返す。
2) Copy トレイトを実装する型のデータを複製する
文字列と Proc 構造体について、ムーブを扱いました。これらは多くのデータを所有する可能性がある型です。
-
文字列は非常に長いかもしれず、場合によってはファイル全体の内容を含んでいるかもしれません。
-
Procインスタンスは、直接またはネストされた多数の子を持つ可能性があります。
このような場合、ムーブによって代入演算子 = は効率的になります。所有権が移転されるときに、大きなデータはコピーされません。
既知の有効なポインターを複製するだけです。
しかし、整数や文字のような一部の型では、ムーブは過剰です。 これらの型が保持するデータは非常に小さく、コピーを行うのは取るに足りないことです。短いビット列を複製するだけです。 後で解放するリソースはなく、完全な複製を安価に作成できます。 ムーブする代わりに、単にデータをコピーできます。
以下を考えてみましょう。
#![allow(unused)]
fn main() {
let x = "42_u64".to_string();
let y = x; // x は y に *ムーブ* される。y は String 値 "42_u64" を所有し、x はなくなる。
let a = 42_u64;
let b = a; // a は *コピー* され、b に代入される。値 42 のインスタンスが 2 つ得られる。
// これはコンパイル時エラーになる
//println!("Strings: {x}, {y}");
// これは動作する
println!("Integers: {a}, {b}");
}
これは次を出力します。
Integers: 42, 42
文字列 x がムーブされたのに対し、64 ビット符号なし整数 a はコピーされました。
代入操作は依然として安価でしたが、所有権を移転する代わりに、小さな複製を作成しました。
便利なのは、所有権やムーブについて考える必要がなく、独立した複製をそれぞれ別個の値として自由に使えることです。 これにより、整数や浮動小数点数のようなプリミティブ型の扱いが、はるかに人間工学的になります。 Rust のムーブセマンティクスによる認知的負荷から、ありがたい休息を得られます。
代入は、Copy トレイトを実装する任意の型に対してコピーを行います7。
外部に割り当てられたデータ(Vec や String フィールドなど)を保持しないのであれば、独自のカスタム型に対して Copy を derive または実装できます。
なぜ、すべてに Copy を実装させて、二度とムーブの心配をしなくてよいようにしないのでしょうか。
データの複製は、プログラムの実行時間とメモリ消費を増加させるからです。
ほとんどのユーザー定義構造体のような大きなデータの塊には、Copy は適していません。
そのため、日常的なプリミティブ型以外では、Copy トレイトは明示的にオプトインしなければなりません。
3) ライフタイムの一部だけを借用する
前のセクションで借用を見ました。 考え方は、所有権を移転する(ムーブを行う)ことも、データを複製する(コピーを行う)こともなく、参照によって値への一時的なアクセスを得られるというものでした。
復習として、参照ベースの Proc 構造体を見てみましょう(右側に追加されたライフタイム図が、前のムーブの場合とどのように異なるかに注目してください)。
// bashを確保 //
let bash = Proc::new("bash", State::Running, Vec::new()); // -------------------------+-- 'a
// |
// rsyslogdを確保、1回目のムーブ: bash -> rsyslogd // |
let rsyslogd = Proc::new("rsyslogd", State::Running, vec![&bash]); // ------------------+-- 'b |
// | |
// 所有値を出力(新規!) // | |
dbg!(&bash); // | |
// | |
// cronを確保 // | |
let cron = Proc::new("cron", State::Sleeping, Vec::new()); // ----------+-- 'c | |
// | | |
// initを確保、2回目と3回目のムーブ: cron -> init, rsyslogd -> init // | | |
let init = Proc::new("init", State::Running, vec![&cron, &rsyslogd]); // --+-- 'd | | |
// | | | |
// 別の所有値を出力(新規!) // | | | |
dbg!(&cron); // | | | |
// | | | |
// 所有権階層を確認するためにシリアライズ済みツリーを出力 // | | | |
dbg!(&init); // --+-------+-------+------+
Rustは、参照が常に安全に使用できることを保証します。 参照は、それが参照する値よりも長く存続することはできません。 これは、参照が常に有効である期間として、参照先よりも短いライフタイムしか持てないことを意味します。 時間的メモリ安全性の問題につながる「ダングリングポインタ」は発生し得ません。 そのため、以下のMISRAルールに準拠します:
[AR, Rule 18.6] オブジェクトのライフタイムが終了するなら、参照のライフタイムも終了しなければならない8
さらに、「共有 XOR 可変」という有名なルールがあります。 Rustの参照は、次のいずれかになれます(ただし両方同時にはなれません):
-
&T- 不変(参照先の値を変更できない)かつ共有(複数の参照を同時に使用できる)。- 参照はデフォルトで不変です。
-
&mut T- 可変(参照先の値を変更できる)かつ排他(任意の時点で1つだけ存在する)。- 参照を可変にするには明示的にマークする必要があります。
ここまでは、最初のケースだけを示してきました。これは多くの場合、共有参照と呼ばれます。 さらにRustコードを書き進めながら、2つ目のケース、すなわち可変参照を扱う方法を学んでいきます。 排他可変の制約を先取りして見ると、次のコードはコンパイルに失敗します:
let mut x = "こんにちは!".to_string();
let r1 = &mut x; // 1回目の可変借用
let r2 = &mut x; // 2回目の可変借用 - 問題!
println!("{}, {}", r1, r2);
次のエラーが出ます:
error[E0499]: cannot borrow `x` as mutable more than once at a time
--> src/main.rs:7:10
|
6 | let r1 = &mut x; // 1回目の可変借用
| ------ first mutable borrow occurs here
7 | let r2 = &mut x; // 2回目の可変借用 - 問題!
| ^^^^^^ second mutable borrow occurs here
8 |
9 | println!("{}, {}", r1, r2);
| -- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
しかし、これはOKです:
#![allow(unused)]
fn main() {
let mut x = "こんにちは!".to_string();
let r1 = &mut x; // 1回目の可変借用
// 文字列を変更する
r1.pop();
r1.push_str(", 世界");
println!("r1で変更: {}", r1);
// 1回目の可変借用の暗黙の(開閉括弧のない)スコープの終わり。
// この関数内では再び使われないため
let r2 = &mut x; // 2回目の可変借用 - OK、同時ではない!
// 別の参照を介して文字列を変更する
r2.push('!');
println!("r2で変更: {}", r2);
}
次のように出力されます:
r1で変更: こんにちは, 世界
r2で変更: こんにちは, 世界!
可変借用で難しいのは、それらが排他であり続けなければならないという要件です。 その要件を満たすことは必ずしも簡単ではありません。それは経験を通じて身につくスキルです。
4) 「内部可変性」パターン
最初の3つの所有権の「回避策」(ムーブ、コピー、借用)は、この本で必要になるすべてです。
しかし、4つ目の選択肢として、Rustでよく知られたパターンがあります。
これは内部可変性と呼ばれ、&T xor &mut Tチェックの強制を緩和します。
それでもこのルールには従わなければなりませんが、すべての可能な実行に対する相互排他をコンパイル時検証(静的保証)で証明する必要はありません。 その厳格さは、特定の問題を表現することを難しすぎるものにします。 しかし、コンパイルできれば、それは保証されています。
代わりに、内部可変性は実行時検証(動的保証)を可能にします。 以下は、内部可変性パターンでよく使われる2つの型です。 これらの型シグネチャが何を意味するかは心配しないでください。トレードオフに注目しましょう:
-
Rc<RefCell<T>>の可用性リスク: コード内のある文が、別の文によってすでに可変借用されている値を可変借用しようとすると、そのスレッドはpanic!します(即座に終了します)9。- 例: シングルスレッドアプリケーションを終了させるリスクがあります。
-
Arc<RwLock<T>>の潜在的なパフォーマンスへの影響: - スレッドAが、スレッドBが書き込みロックを保持している間にデータへの読み取りアクセスを要求すると、スレッドAはスレッドBがロックを解放するまでブロックされます(実行を一時停止します)。ただし、複数の読み取り側が同時に存在することは許可されます10。-
例: マルチスレッドアプリケーションでパフォーマンス低下のリスクがあります。
-
リーダー/ライターロックは、システムプログラミングで一般的な同期メカニズムです。Rust固有のものではありません。
-
繰り返しますが、この本では内部可変性を使用しません。 それなしでも機能豊富なライブラリを構築できます。 また、コンパイル時の保証には失敗し得る実行時チェックが不要であるため、私たちの実装はより高いレベルの保証を得られます。
それでも、内部可変性はいずれ学び、使う価値があります。 ある種の問題に対してはベストプラクティスであり、他のリソースで十分に取り上げられています11。 ただし覚えておいてください - Rustは大きな言語です。 生産的になるために、すべての機能を習得する必要はありません。
ランタイムに関する厄介ごとからまだ抜け出したわけではありません!
私たちのコードは、たとえば
arr[i]のようなインデックスベースの配列アクセスを行います。 これにより、実行時の境界チェックが発生します。 失敗(範囲外インデックスへのアクセスの試み)は、RefCellと同様にpanic!を意味します。 しかし、配列のインデックス指定は推論しやすいものです。インデックス指定のロジックに対する信頼性と、より一般的な信頼性を正当化するために、第12章ではストレステストの高度な形式である差分ファジングを紹介します。
要点
これで、所有権をより包括的に捉えられるようになりました。 借用チェッカーと付き合う4つの方法を含めてです。
-
Moving(移動)所有権をある変数から別の変数へ移すこと。
-
Copying(コピー)データを複製し、2つ目の独立した所有インスタンスを作成すること。
-
Borrowing(借用)ライフタイムの一部の期間だけデータにアクセスすること。
-
Interior mutability - 緩和された実行時の所有権強制の一形態。
以上です! Rust プログラミング言語において最も難しく悪名高い側面を扱ってきました。 これらの概念を念頭に置きながらさらにコードを書いていけば、やがて所有権は自然に身につくものにさえなるかもしれません。
所有権はメモリ安全性を保証します。 しかし Rust は一般的な正しさ、つまりメモリ安全性を超えた堅牢性でも知られています。 その評判の大きな理由が、エラーハンドリングのあり方です。 そしてそれが次のトピックです。
-
MIR 借用チェック。Rustc 開発ガイド(2022年閲覧)。 ↩
-
あるレベルでは、これはほとんどのプログラミング言語(PL)の革新(たとえば、型システムやアノテーションベースのフレームワーク)にも当てはまります。 また、これは業界の開発ツールやプラクティス(たとえば、製品作成を支援する強力な IDE やフレームワーク、本番品質のシステムやサービスを支えるテストおよびデプロイプロセス)から得られる堅牢性の利点を補完します。Rust は特別なものでも「銀の弾丸」でもなく、多くの現代的な開発ツールの1つです。しかし Rust は重要なニッチ、すなわち高速 && メモリ安全に取り組んでいます。 ↩
-
CHESS: Computers and Humans Exploring Software Security。Dustin Fraze(2018年、パブリックドメイン)。 ↩
-
Computers and Humans Exploring Software Security (CHESS)。William Martin(2022年閲覧)。 ↩
-
適切なクレジットを示すために述べると、この例は この StackOverflow の質問 と TRPL 本の この部分 に基づいています。特に、TRPL と同じ ASCII 図のコメントを使用しています。 ↩
-
このプログラムは、
g++バージョン 9.4.0(執筆時点で Ubuntu 20.04 LTS に同梱されていた最新バージョン)を使用し、コマンドg++ scope.cpp -o scopeでコンパイルしました。警告は出力されませんでした。 ↩ -
トレイト
std::marker::Copy。The Rust Team(2022年閲覧)。 ↩ -
MISRA C: 2012 Guidelines for the use of the C language in critical systems (3rd edition)。MISRA(2019年)。 ↩
-
モジュール
std::cell。The Rust Team(2022年閲覧)。 ↩ -
構造体
std::sync::RwLock。The Rust Team(2022年閲覧)。 ↩ -
RefCell<T>と内部可変性パターン。Steve Klabnik、Carol Nichols 著(2022年閲覧)。 ↩