Rust: エラー処理(6/6)
エラーの検出と処理は、一般にソフトウェア開発の基礎ですが、堅牢性と可用性を優先するソフトウェアにとっては特に差し迫ったトピックです。 エラー処理は、Rust が自身を差別化している領域の 1 つでもあります。その仕組みと綿密さの両面においてです。
大まかに言うと、エラーは次の 3 つのクラスのいずれかに分類できます。
-
コンパイル時エラー - 単一のモジュールのコンパイルを妨げる構文エラーまたは所有権エラー。Rust のコンパイラは、このような場合に実行可能な対処を示すエラーメッセージを出力する傾向があります。特に言語を学び始めたばかりの頃には、その多くを目にすることになるでしょう。ただ覚えておいてください。あなたは安全性検証プロセスを支援しているのです。
-
リンク時エラー - 複数のモジュールの合成を妨げるシンボル解決エラー。
cargoのおかげで、純粋な Rust コードベースで作業しているときにリンクエラーが起きることはまれなはずです。しかし、大規模な多言語プロジェクトや、C/C++ ライブラリを依存関係として使用している場合には現れることがあります。 -
ランタイムエラー - 実行時に、破られた不変条件や操作の失敗によって引き起こされるエラー。このクラスは保証に影響します。これが本セクションの主題であり、Rust におけるランタイムエラー処理の戦略を見ていきます。
論理エラー(例: 誤ったアルゴリズムの実装)が上に挙げられていないことに注意してください。 これらは一般的なバグであり、エラー処理に関する議論の範囲外であると考えます。
本来のエラーについて、一部の開発者コミュニティでは以下の区別をします。
-
「エラー」は、プログラムが合理的に処理できない壊滅的な失敗(例: システムメモリの枯渇)を特に指します。
-
「例外」は、プログラマー定義のロジックによって「捕捉」して処理できるエラー(例: ファイルが存在しない)です。
ここではその区別はしません。 「エラー」という用語を、壊滅的なケースと処理可能なケースの両方を含むものとして使います。
Option vs Result
Rust の標準ライブラリは、失敗しうる操作を表現するために 2 つの enum 型、Option1 と Result2 を提供しています。
厳密に言えば、エラー処理が指すのは Result だけです。
しかし、この 2 つは概念的に似ており、関数の戻り値の型として広く使われているため、ここで両方を扱います。
Option
Option は、関数が潜在的に返すものを持たない可能性があることを伝えます。
操作自体は正常に完了したとしてもです。
これは通常の振る舞いです。
列挙型とジェネリクスの両方を扱ったので、この標準ライブラリ型の定義1を解釈してみてください。
pub enum Option<T> {
None,
Some(T),
}
Option の定義における None バリアントにはデータが含まれていないことに注目してください。
この定義は、「何らかの型 T XOR 何もない」という概念をエンコードしています。
結果を返すかもしれない失敗しうる操作に理想的です。
後で非常に詳しく扱うことになる例として、順序付き集合の get メソッドがあります。
要素の取得は、その要素が集合に存在しない場合に None を返します。
use std::collections::BTreeSet;
let set = BTreeSet::from([1, 2, 3]);
assert_eq!(set.get(&2), Some(&2));
assert_eq!(set.get(&4), None);
概念チェックポイント
上の
BTreeSetの使用例には、この章で導入した概念に関連する細かな点があります。 理解を固めましょう。
let set: BTreeSet<i32> = ...は推論されています。i32は Rust のデフォルト整数型であり、私たちは 3 つの整数リテラルからなる配列から集合を作成しています。したがって、ここで
getはOption<&i32>を返します。この戻り値シグネチャにある参照演算子&は、取得によって要素が集合の外へムーブされないことを保証します。集合は依然としてそれを所有しており、私たちはそれが存在するかを確認しているだけです。
- 実際に要素を削除するには、別の集合メソッドである
takeを使います。これはOption<T>(私たちの例ではOption<i32>)を返し、所有権を移転します。同様に、
getへの引数は型&i32です(したがってset.get(&2です)。探している要素の所有権をget関数に取得させたくないからです。
- プリミティブな整数は安価にコピーできるのに、なぜでしょうか。それは
BTreeSet<T>がジェネリックコンテナだからです。集合に格納される項目は、i32だけでなく、大きく複雑なオブジェクトである可能性があります。
Result
一方、Result にはまったく異なるユースケースがあります。
それは、関数が操作を完了できない可能性があることを伝えます。
失敗は異常であり、問題を報告する必要がある、または操作を再試行する必要があることを意味します。
Result の定義2では、両方のバリアントがデータを含んでいます。
Ok バリアントは成功した操作の出力をカプセル化し、一方 Err バリアントは失敗を示し、カスタムエラー型をカプセル化します。
pub enum Result<T, E> {
Ok(T),
Err(E),
}
第 2 章の CLI ツールの文脈ですでに見た例として、ファイル I/O があります。
ファイルを開こうとすると、いくつかの理由で失敗することがあります。ファイルが存在しないかもしれませんし、読み取り権限がないかもしれません。
以前は ? 演算子を使ってエラー伝播を短絡していましたが、次のようにファイルオープンの Result を明示的にマッチすることもできます。
#![allow(unused)]
fn main() {
use std::fs::File;
match File::open("/path/to/non-existent/file.txt") {
Ok(f) => println!("Successfully opened: {:?}", f),
Err(e) => eprintln!("Error occurred: {:?}", e),
}
}
Option とは異なり、Result は内部的に #[must_use] 属性でマークされています。
Result を返す関数を書くときは常に、呼び出し側は Ok と Err の両方のケースを明示的に処理しなければなりません。
この組み込みの強制は、別の MISRA ルールにも適しています。
[AR, Directive 4.7] 関数から返されるエラー情報を常にテストする3
Result は潜在的な失敗を表現するための便利な仕組みを提供し、自動的に処理を強制しますが、それでもエラー処理を実際に行うというアプリケーション固有のタスクは残ります。
一般に、次の 3 つのアプローチのいずれかを取ることができます。
-
不変条件をアサートする - エラーが発生した場合、プログラムを即座に終了します。エラーから妥当に回復できない場合に有用です。
-
マージして伝播する - 複数種類のエラーを単一の不透明なエラーにマージし、呼び出し元へ渡します。無関係な詳細を隠蔽したいが、それでも呼び出し元に対応する機会を与えたい場合に有用です。
-
列挙して伝播する - 詳細なエラー情報を呼び出し元へ渡します。呼び出し元の対応処理が、発生したエラーの正確な種類に依存する場合に有用です。
それぞれのアプローチをより具体的にし、細かな詳細をいくつか探るために、第2章の RC4 ライブラリと対応する CLI ツールに変更を加えます。
Rust のエラー vs C++ の例外
C++ では、2つのエラーハンドリング戦略が可能です4:
戻り値コード: 関数は、
-1やNULLのような特別な値を返すことで、エラーが発生したことを暗黙的に示せます。しかし開発者は、すべての呼び出し箇所でこの特別なケースを確認し、その意味を解釈することを覚えておかなければなりません。
- うっかりチェックをし忘れることは、C と C++ の両方で、上記の Directive 4.7 に対する一般的な違反です。
スローされる例外: 例外は、プログラマーが定義したハンドラー、またはそれが提供されていない場合は OS 自体によって、必ずキャッチされなければなりません。そのため、ハンドリングが強制されます。また、説明的なコンテキストを提供できる場合もあります。
しかし、C++ の例外は通常のコードフローの外側で発生します。非常に深くネストされた関数から伝播されるため、一見無関係に見えることがあります。これにより、関数に「見えない」終了点が導入されます。これは別の MISRA ルール(ここではまだ言及していないもの)に違反するだけでなく、一部の C++ プログラマーが例外の使用を「悪いプラクティス」と考える原因にもなります。
さらに、アンワインドはマルチコアシステムでは性能上のボトルネックになります(グローバルロックのため)5。
Resultにより、Rust は両方の長所を提供します。 戻り値コードと同様に、Resultは通常の呼び出しチェーンを通じて上位へ渡されます。 C++ の例外と同様に、Resultはうっかり無視できず、Errバリアントを通じて意味のあるコンテキストを提供します。
不変条件をアサートする
前の章では、RC4 暗号インスタンス用のコンストラクターを書きました。
慣例により、コンストラクターは new という名前の関連関数です。
私たちの new 関数は、単一のパラメーターである鍵バイト配列を受け取り、不変条件をアサートしていました。
pub fn new(key: &[u8]) -> Self {
// 有効な鍵長を検証する(40〜2048ビット)
assert!(5 <= key.len() && key.len() <= 256);
// ...ここにさらにコード...
}
一方で、これは重要なルール(入力検証)に従っています。
[RR, Directive 4.14] 外部入力は検証されなければなりません3
他方で、私たちはライブラリのユーザーに代わって議論の余地がある判断をしました。提供された鍵が短すぎる、または長すぎる場合、プログラムを終了することにしたのです。 このエラー条件に到達した場合、ユーザーには対応する機会がありません。
特定の壊滅的な失敗ケースについては、Rust 言語自体も同様の判断をします。 たとえば、配列を範囲外インデックスで参照したとします。
let mut five_item_arr = [0; 5];
for i in 0..6 {
five_item_arr[i] = i;
}
ループは i == 0 から i == 5 までの6回反復しますが、配列には有効なインデックスが5つ(0 から 4)しかありません。
このプログラムはコンパイルには成功しますが、実行時に終了し、次のように表示されます。
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src/main.rs:7:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
これは典型的な「off-by-one」エラーです。 テストをしていれば、このインデックス参照の失敗を捕捉する助けになったでしょう。 しかし、すべての致命的な不変条件がテストしやすいわけではないため、現実世界のほとんどのプログラムには、何らかのアサーションベースのエラーハンドリングが含まれます。 この例のような暗黙的なケースも含まれます。
テストの目的の1つは、チェックや緩和策によって、プログラムが実際にはそのようなアサーションに到達しないだけの堅牢性を持つことを示すことです。 一定数の致命的なアサーションは常に存在しますが、徹底したテストにより、プログラムがそれらを回避しているという確信を得られます。
特定の場合には、問題が起きる可能性を完全に取り除けることがあります。 たとえば、配列をイテレーターを使って初期化すれば、範囲外インデックスの可能性を排除できました。
let mut five_item_arr = [0; 5];
for (i, item) in five_item_arr.iter_mut().enumerate() {
*item = i;
}
次に、致命的ではないケース、つまり検出して伝播できるエラーを見ていきましょう。 エラー伝播戦略を示すために、RC4 コンストラクターをリファクタリングします。
マージして伝播する
提供された鍵が正しいサイズでなかった場合、第2章の RC4 CLI はユーザーに説明的なエラーを示していたことを思い出してください。実質的には、有効な長さの鍵を再入力するよう促していました。
これは clap の num_args = 5..=256 アノテーションによって実現しました。
ライブラリ自体(CLI フロントエンドではなく)は、不変条件をアサートしていました。 フロントエンドのチェックは、このアサーションが決してトリガーされないことを保証していただけです。
このライブラリを使用する任意のプログラムに対して、フロントエンドかどうかにかかわらず、同様のチェックをライブラリに強制させたいとします。 次のように、単一の不透明なエラーを伝播させることができます。
impl Rc4 {
/// 新しい Rc4 ストリーム暗号インスタンスを初期化する
pub fn new(key: &[u8]) -> Result<Self, ()> {
// 有効な鍵長を検証する(40〜2048ビット)
if (key.len() < 5) || (key.len() > 256) {
return Err(());
}
// 構造体をゼロ初期化する
let mut rc4 = Rc4 {
s: [0; 256],
i: 0,
j: 0,
};
// ...ここにさらに初期化コード...
// 初期化済みの Rc4 を返す
Ok(rc4)
}
}
カスタムエラー型の代わりにユニット型(()、空の値)を選ぶのは、「最低限」のアプローチです。
一般的には、プライベートな内部 API により適しています。
しかし、呼び出し元は返された Result の Ok と Err の両方のバリアントに対して適切なアクションを取らなければならないため、目的は果たします。
Ok バリアントには、正常に初期化された暗号が含まれます。
列挙して伝播する
公開 API では、() よりもカスタムエラー enum の方が望ましい可能性が高いです。
#[derive(Debug)]
pub enum Rc4Error {
KeyTooShort(usize),
KeyTooLong(usize),
}
impl Rc4 {
/// Init a new Rc4 stream cipher instance
pub fn new(key: &[u8]) -> Result<Self, Rc4Error> {
const MIN_KEY_LEN: usize = 5;
const MAX_KEY_LEN: usize = 256;
// Verify valid key length (40 to 2048 bits)
if key.len() < MIN_KEY_LEN {
return Err(Rc4Error::KeyTooShort(MIN_KEY_LEN));
} else if key.len() > MAX_KEY_LEN {
return Err(Rc4Error::KeyTooLong(MAX_KEY_LEN));
}
// Zero-init our struct
let mut rc4 = Rc4 {
s: [0; 256],
i: 0,
j: 0,
};
// ...more initialization code here...
// Return our initialized Rc4
Ok(rc4)
}
}
上記では、単一の KeyLengthInvalid バリアントなどを使うのではなく、両方のエラー条件(短すぎる場合と長すぎる場合)を列挙することを選びました。
各バリアントにはしきい値の長さも含まれており、KeyTooShort バリアントでは最小値、KeyTooLong では最大値です。
この粒度の細かさが、このコンテキストで適切かどうかは場合によります。 ストリーム暗号ライブラリでは、一般的なパターンではないことは確かです。 しかし、この例は、さまざまな内部エラーを列挙し、それらを渡す方法を示しています。
これにより、呼び出し元はエラーの enum バリアントに対して match し、各ケースを適切に処理できます。
概念的には、次のようなものになります。
use rc4::{Rc4, Rc4Error};
let key = [0x1, 0x2, 0x3];
match Rc4::new(&key) {
Ok(rc4) => println!("Do en/decryption here!"),
Err(e) => match e {
Rc4Error::KeyTooShort(min) => eprintln!("Key len >= {} bytes required!", min),
Rc4Error::KeyTooLong(max) => eprintln!("Key len <= {} bytes required!", max),
},
}
Error トレイト
Rust のエラーハンドリングのパズルには、もう 1 つ重要なピースがあります。それは標準ライブラリで定義されている Error トレイトです6。
この特殊なトレイトを私たちの Rc4Error 型に実装すると、次の 2 つの利点があります。
-
Rc4Errorがエラー型であることを明確に示せます。単に名前にErrorが含まれているenumというだけではありません。 -
このトレイトの
sourceメソッドと [現在は不安定な]backtraceメソッドにより、より豊富なエラーレポートが可能になります。
しかし、私たちの RC4 ライブラリでこのトレイトを使わないことには、十分な理由があります。
私たちの暗号実装は #![no_std] 互換であり、任意の環境、さらには「ベアメタル」でも実行できることを思い出してください。
Error トレイトは、バックトレースを取得して出力するために必要なランタイムサポートを持つオペレーティングシステムの存在を前提としています。
したがって、#![no_std] ライブラリでは std::error::Error をインポートできません。
そのユースケースをサポートすることはできないのでしょうか?
Errorトレイトを省くことが満足のいかない妥協に思えるなら、演習としてこのトレイトのサポートをフィーチャーゲートしてみてください。 そのためには、Cargo.toml7 ビルドファイルを変更し、cfgマクロ8の背後でトレイトを実装する必要があります。 慣例として、このフィーチャーはstdと呼ばれ、次のように選択します。cargo build --features="std"依存関係は、自身の
Cargo.tomlエントリ内でオプションのフィーチャーを有効にすることを選択できます。[dependencies] rc4 = { path = "../rc4", version = "0.1.0", features = ["std"] }これにより、両方の長所を得られます。デフォルトでは組み込みシステムをサポートしつつ、ライブラリ利用者が非組み込みターゲット向けにビルドする際にオプションのフィーチャーを有効にすれば、より豊富なエラーレポートを可能にできます。
まとめ
Rust の Result 型は、概念的に似ている Option と混同しないでください。これは、ランタイムエラーを報告し、その処理を強制するための主要な仕組みです。
C++ の例外と同様に、無視することはできません。
C++ の例外とは異なり、通常の呼び出しチェーンの一部です。
エラーハンドリングは保証に不可欠ですが、実際に取るべき具体的なアクションは最終的にはアプリケーション固有です。 各状況に応じて最適なアプローチを選択できます。不変条件をアサートする、不透明なエラーを伝播する、あるいは具体的なエラーを伝播する、といった方法です。
これで、Rust のコアコンセプトを巡る 6 部構成のツアーは終わりです! この章の残りでは、この言語で大規模かつ野心的なシステムを構築するのに役立つ機能とツールを見ていきます。
-
列挙型
std::option::Option。The Rust Team(2022 年閲覧)。 ↩ ↩2 -
列挙型
std::error::Error。The Rust Team(2022 年閲覧)。 ↩ ↩2 -
MISRA C: 2012 Guidelines for the use of the C language in critical systems(第 3 版)。MISRA(2019)。 ↩ ↩2
-
C++ Exceptions: Pros and Cons。Nemanja Trifunovic(2009)。 ↩
-
P2544R0 C++ exceptions are becoming more and more problematic。Thomas Neumann(2022)。 ↩
-
列挙型
std::error::Error。The Rust Team(2022 年閲覧)。 ↩