Result による回復可能なエラー
ほとんどのエラーは、プログラム全体を完全に停止させなければならないほど深刻ではありません。関数が失敗したとしても、その理由を簡単に解釈して対応できる場合があります。たとえば、ファイルを開こうとしてその操作が失敗した原因がファイルの不存在であれば、プロセスを終了する代わりにそのファイルを作成したいかもしれません。
第2章の 「Result で起こりうる失敗を処理する」 で見たように、Result enum は次のように Ok と Err の2つのバリアントを持つよう定義されています。
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
この T と E はジェネリック型パラメータです。ジェネリクスについては第10章でさらに詳しく説明します。ここで今知っておく必要があるのは、T は Ok バリアント内で成功時に返される値の型を表し、E は Err バリアント内で失敗時に返されるエラーの型を表すということです。Result はこのようなジェネリック型パラメータを持っているため、返したい成功値やエラー値が異なるさまざまな状況で、Result 型とそれに対して定義された関数を使うことができます。
関数が失敗する可能性があるために Result 値を返す関数を呼び出してみましょう。リスト9-3では、ファイルを開こうとしています。
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
}
File::open の戻り値の型は Result<T, E> です。ジェネリックパラメータ T には、File::open の実装によって成功値の型である std::fs::File が当てられています。これはファイルハンドルです。エラー値に使われる E の型は std::io::Error です。この戻り値の型が意味するのは、File::open の呼び出しが成功して、読み取りや書き込みに使えるファイルハンドルを返すかもしれないということです。また、この関数呼び出しは失敗するかもしれません。たとえば、ファイルが存在しないかもしれませんし、そのファイルにアクセスする権限がないかもしれません。File::open 関数は、成功したか失敗したかを私たちに伝える方法を持つ必要があり、同時にファイルハンドルまたはエラー情報のどちらかを渡せなければなりません。この情報こそが、まさに Result enum が表現しているものです。
File::open が成功した場合、変数 greeting_file_result の値はファイルハンドルを含む Ok のインスタンスになります。失敗した場合、greeting_file_result の値は発生したエラーの種類についての詳細情報を含む Err のインスタンスになります。
File::open が返す値に応じて異なる動作をするように、リスト9-3のコードに処理を追加する必要があります。リスト9-4は、Result を、第6章で説明した基本的な道具である match 式を使って処理する一つの方法を示しています。
use std::fs::File;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => panic!("Problem opening the file: {error:?}"),
};
}
Option enum と同様に、Result enum とそのバリアントは prelude によってスコープに導入されているため、match アーム内の Ok と Err バリアントの前に Result:: を指定する必要はないことに注意してください。
結果が Ok のとき、このコードは Ok バリアントから内部の file 値を返し、そのファイルハンドル値を変数 greeting_file に代入します。match の後では、そのファイルハンドルを読み取りや書き込みに使えます。
match のもう一方のアームは、File::open から Err 値を受け取った場合を処理します。この例では、panic! マクロを呼び出すことにしています。現在のディレクトリに hello.txt という名前のファイルが存在しない状態でこのコードを実行すると、panic! マクロから次のような出力が表示されます。
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.73s
Running `target/debug/error-handling`
thread 'main' panicked at src/main.rs:8:23:
Problem opening the file: Os { code: 2, kind: NotFound, message: "No such file or directory" }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
いつものように、この出力は何がうまくいかなかったのかを正確に教えてくれます。
異なるエラーに対するマッチング
リスト9-4のコードは、File::open が失敗した理由にかかわらず panic! します。しかし、失敗理由に応じて異なるアクションを取りたい場合があります。File::open がファイルの不存在によって失敗したのであれば、そのファイルを作成して新しいファイルのハンドルを返したいところです。File::open がそれ以外の理由で失敗した場合、たとえばファイルを開く権限がなかった場合には、リスト9-4と同じようにコードを panic! させたいままです。そのために、リスト9-5に示すように、内側の match 式を追加します。
use std::fs::File;
use std::io::ErrorKind;
fn main() {
let greeting_file_result = File::open("hello.txt");
let greeting_file = match greeting_file_result {
Ok(file) => file,
Err(error) => match error.kind() {
ErrorKind::NotFound => match File::create("hello.txt") {
Ok(fc) => fc,
Err(e) => panic!("Problem creating the file: {e:?}"),
},
_ => {
panic!("Problem opening the file: {error:?}");
}
},
};
}
Err バリアントの内側で File::open が返す値の型は io::Error で、これは標準ライブラリが提供する構造体です。この構造体には kind というメソッドがあり、これを呼び出すことで io::ErrorKind の値を取得できます。enum io::ErrorKind も標準ライブラリが提供しており、io 操作の結果として起こりうるさまざまな種類のエラーを表すバリアントを持っています。ここで使いたいバリアントは ErrorKind::NotFound で、これは開こうとしているファイルがまだ存在しないことを示します。したがって、greeting_file_result に対してマッチさせるだけでなく、error.kind() に対しても内側でマッチさせています。
内側の match で確認したい条件は、error.kind() が返す値が ErrorKind enum の NotFound バリアントかどうかです。もしそうなら、File::create でそのファイルを作成しようとします。しかし、File::create も失敗する可能性があるため、内側の match 式には2つ目のアームも必要です。ファイルを作成できない場合は、別のエラーメッセージが表示されます。外側の match の2つ目のアームは同じままなので、プログラムは不足しているファイルのエラー以外のどのエラーでも panic します。
Result<T, E>でmatchを使う代わりの方法
matchがたくさん出てきました!match式は非常に便利ですが、同時に かなり基本的なものでもあります。第13章ではクロージャについて学びます。これはResult<T, E>に定義されている多くのメソッドとともに使われます。こうしたメソッドは、 コード内でResult<T, E>の値を扱う際に、matchを使うよりも 簡潔に書ける場合があります。たとえば、以下はリスト9-5で示したものと同じロジックを記述する別の方法で、 今回はクロージャと
unwrap_or_elseメソッドを使っています:use std::fs::File; use std::io::ErrorKind; fn main() { let greeting_file = File::open("hello.txt").unwrap_or_else(|error| { if error.kind() == ErrorKind::NotFound { File::create("hello.txt").unwrap_or_else(|error| { panic!("Problem creating the file: {error:?}"); }) } else { panic!("Problem opening the file: {error:?}"); } }); }このコードはリスト9-5と同じ振る舞いをしますが、
match式を まったく含んでおらず、よりすっきりしていて読みやすくなっています。この例には、 第13章を読んだあとに戻ってきて、標準ライブラリのドキュメントでunwrap_or_elseメソッドを調べてみてください。エラーを扱うときには、 こうしたメソッドがさらに多くあり、大きく入れ子になったmatch式を きれいにできます。
エラー時に panic するためのショートカット
match は十分うまく機能しますが、少し冗長になることがあり、意図が常に
うまく伝わるとは限りません。Result<T, E> 型には、さまざまな、より具体的な処理を
行うためのヘルパーメソッドが多数定義されています。unwrap メソッドは、
リスト9-4で書いた match 式とまったく同じように実装されたショートカットメソッドです。
Result の値が Ok バリアントであれば、unwrap は Ok の中の値を返します。
Result が Err バリアントであれば、unwrap は代わりに panic! マクロを
呼び出します。以下は unwrap を実際に使っている例です:
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt").unwrap();
}
このコードを hello.txt ファイルがない状態で実行すると、unwrap メソッドが
行う panic! 呼び出しによるエラーメッセージが表示されます:
thread 'main' panicked at src/main.rs:4:49:
called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }
同様に、expect メソッドでは panic! のエラーメッセージも自分で選べます。
unwrap の代わりに expect を使い、適切なエラーメッセージを与えることで、
意図を伝えやすくなり、パニックの原因を追跡しやすくなります。expect の
構文は次のようになります:
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")
.expect("hello.txt should be included in this project");
}
expect は unwrap と同じように使います。つまり、ファイルハンドルを返すか、
panic! マクロを呼び出します。expect が panic! を呼び出すときに使う
エラーメッセージは、unwrap が使うデフォルトの panic! メッセージではなく、
expect に渡した引数になります。次のようになります:
thread 'main' panicked at src/main.rs:5:10:
hello.txt should be included in this project: Os { code: 2, kind: NotFound, message: "No such file or directory" }
本番品質のコードでは、ほとんどの Rustacean は unwrap よりも expect を選び、
その操作が常に成功すると見込んでいる理由について、より多くの文脈を与えます。
そうすれば、もしその前提が誤りだと判明した場合でも、デバッグに使える情報が
より多く得られます。
エラーを伝播させる
関数の実装が失敗する可能性のある何かを呼び出すとき、関数自身の中で エラーを処理する代わりに、エラーを呼び出し元のコードに返して、どうするかを そこで決められるようにできます。これはエラーを 伝播させる こととして知られており、 呼び出し元のコードにより大きな制御を与えます。そこには、あなたのコードの文脈で 利用できるものよりも、エラーをどう処理すべきかを決めるための追加の情報や ロジックがあるかもしれないからです。
たとえば、リスト9-6はファイルからユーザー名を読み取る関数を示しています。もし ファイルが存在しない、または読み取れない場合、この関数はそれらのエラーを その関数を呼び出したコードに返します。
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let username_file_result = File::open("hello.txt");
let mut username_file = match username_file_result {
Ok(file) => file,
Err(e) => return Err(e),
};
let mut username = String::new();
match username_file.read_to_string(&mut username) {
Ok(_) => Ok(username),
Err(e) => Err(e),
}
}
}
この関数はもっと短く書けますが、エラー処理を探るために、まずはその多くを
手作業で行うところから始めます。最後に、より短い書き方を示します。まず、
この関数の戻り値の型を見てみましょう: Result<String, io::Error> です。これは、
この関数が Result<T, E> 型の値を返していることを意味します。ここで、
ジェネリックパラメータ T には具体的な型 String が入り、ジェネリック
パラメータ E には具体的な型 io::Error が入っています。
この関数が何の問題もなく成功した場合、この関数を呼び出すコードは String を保持した
Ok 値を受け取ります。つまり、この関数がファイルから読み取った username です。
この関数で何らかの問題が発生した場合、呼び出し元のコードは、どのような問題だったかに
ついての追加情報を含む io::Error のインスタンスを保持した Err 値を受け取ります。
この関数の戻り値の型として io::Error を選んだのは、関数本体の中で呼び出している、
失敗する可能性のある2つの操作、つまり File::open 関数と read_to_string
メソッドの両方が返すエラー値の型が、たまたま io::Error だからです。
この関数本体は、まず File::open 関数を呼び出すところから始まります。次に、
リスト9-4の match と似た match で Result 値を処理します。File::open が
成功した場合、パターン変数 file に入っているファイルハンドルが可変変数
username_file の値になり、関数は続行します。Err の場合は、panic! を
呼び出す代わりに、return キーワードを使って関数全体から早期に戻り、
現在はパターン変数 e に入っている File::open からのエラー値を、この関数の
エラー値として呼び出し元のコードに返します。
つまり、username_file にファイルハンドルがある場合、この関数は次に変数 username に新しい String を作成し、username_file のファイルハンドルに対して read_to_string メソッドを呼び出して、ファイルの内容を username に読み込みます。read_to_string メソッドも Result を返します。File::open が成功していても、この処理は失敗する可能性があるためです。したがって、その Result を処理するために、さらにもう1つの match が必要になります。read_to_string が成功した場合、この関数も成功したことになり、いま username に入っているファイル内のユーザー名を Ok で包んで返します。read_to_string が失敗した場合は、File::open の戻り値を処理した match でエラー値を返したのと同じ方法で、そのエラー値を返します。ただし、これは関数内の最後の式なので、明示的に return と書く必要はありません。
このコードを呼び出す側は、その後、ユーザー名を含む Ok 値か、io::Error を含む Err 値のいずれかを受け取ることになります。それらの値をどう扱うかは呼び出し元コードに委ねられます。呼び出し元コードが Err 値を受け取った場合、たとえば panic! を呼び出してプログラムをクラッシュさせることもできますし、デフォルトのユーザー名を使うこともできますし、ファイル以外のどこかからユーザー名を取得することもできます。しかし、呼び出し元コードが実際に何をしようとしているのかについて、私たちには十分な情報がありません。そのため、適切に処理できるよう、成功またはエラーに関するすべての情報を上位へ伝播させます。
このようなエラー伝播のパターンは Rust では非常によくあるため、Rust にはこれを簡単にするための疑問符演算子 ? が用意されています。
? 演算子によるショートカット
リスト9-7は、リスト9-6と同じ機能を持つ read_username_from_file の実装を示していますが、この実装では ? 演算子を使用しています。
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username_file = File::open("hello.txt")?;
let mut username = String::new();
username_file.read_to_string(&mut username)?;
Ok(username)
}
}
Result 値の後ろに置かれた ? は、リスト9-6で Result 値を処理するために定義した match 式と、ほぼ同じように動作するよう定義されています。Result の値が Ok であれば、その Ok の中の値がこの式から返され、プログラムは継続します。値が Err であれば、return キーワードを使ったかのように、その Err が関数全体から返され、エラー値が呼び出し元コードへ伝播されます。
リスト9-6の match 式が行うことと、? 演算子が行うことの間には、1つ違いがあります。? 演算子が適用されたエラー値は、標準ライブラリの From トレイトで定義されている from 関数を通過します。この関数は、ある型の値を別の型へ変換するために使われます。? 演算子が from 関数を呼び出すと、受け取ったエラー型は、現在の関数の戻り値の型で定義されているエラー型へ変換されます。これは、関数の一部がさまざまな理由で失敗しうる場合でも、関数が失敗しうるすべての方法を表すために1つのエラー型を返すときに役立ちます。
たとえば、リスト9-7の read_username_from_file 関数を、私たちが定義する OurError という名前のカスタムエラー型を返すように変更できます。さらに、io::Error から OurError のインスタンスを構築する impl From<io::Error> for OurError も定義すれば、read_username_from_file の本体にある ? 演算子の呼び出しは from を呼び出し、関数にこれ以上コードを追加しなくてもエラー型を変換してくれます。
リスト9-7の文脈では、File::open 呼び出しの末尾にある ? は、Ok の中の値を変数 username_file に返します。エラーが発生した場合、? 演算子は関数全体から早期に戻り、任意の Err 値を呼び出し元コードに渡します。同じことが read_to_string 呼び出しの末尾にある ? にも当てはまります。
? 演算子は多くの定型コードを取り除き、この関数の実装をよりシンプルにしてくれます。さらに、リスト9-8に示すように、? の直後にメソッド呼び出しを連結することで、このコードをさらに短くすることもできます。
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::{self, Read};
fn read_username_from_file() -> Result<String, io::Error> {
let mut username = String::new();
File::open("hello.txt")?.read_to_string(&mut username)?;
Ok(username)
}
}
新しい String を username に作成する処理は、関数の先頭へ移動しました。この部分は変わっていません。変数 username_file を作成する代わりに、File::open("hello.txt")? の結果に対して read_to_string の呼び出しを直接連結しています。read_to_string 呼び出しの末尾にも引き続き ? があり、File::open と read_to_string の両方が成功したときには、エラーを返すのではなく、username を含む Ok 値を返します。この機能は、やはりリスト9-6およびリスト9-7と同じです。これは、単に別の、より書きやすい書き方にすぎません。
リスト9-9は、fs::read_to_string を使ってこれをさらに短くする方法を示しています。
#![allow(unused)]
fn main() {
use std::fs;
use std::io;
fn read_username_from_file() -> Result<String, io::Error> {
fs::read_to_string("hello.txt")
}
}
ファイルを文字列に読み込むのはかなり一般的な操作なので、標準ライブラリには便利な fs::read_to_string 関数が用意されています。この関数はファイルを開き、新しい String を作成し、ファイルの内容を読み込み、その内容をその String に入れ、それを返します。もちろん、fs::read_to_string を使うと、すべてのエラー処理を説明する機会は得られないので、最初はより長い方法で説明しました。
? 演算子を使える場所
? 演算子は、その ? が使われる値と互換性のある戻り値型を持つ関数でのみ使用できます。これは、? 演算子が、リスト9-6で定義した match 式と同じように、関数から値を早期リターンするよう定義されているためです。リスト9-6では、match は Result 値を使っており、早期リターンするアームは Err(e) 値を返していました。この return と互換性を持たせるために、関数の戻り値型は Result でなければなりません。
リスト 9-10 では、? 演算子を、? を適用する値の型と互換性のない戻り値型を持つ
main 関数で使った場合に発生するエラーを見てみましょう。
use std::fs::File;
fn main() {
let greeting_file = File::open("hello.txt")?;
}
このコードはファイルを開きますが、これは失敗する可能性があります。? 演算子は
File::open が返す Result 値に従いますが、この main 関数の戻り値型は
Result ではなく () です。このコードをコンパイルすると、次のエラーメッセージが
表示されます。
$ cargo run
Compiling error-handling v0.1.0 (file:///projects/error-handling)
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:48
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
help: consider adding return type
|
3 ~ fn main() -> Result<(), Box<dyn std::error::Error>> {
4 | let greeting_file = File::open("hello.txt")?;
5 + Ok(())
|
For more information about this error, try `rustc --explain E0277`.
error: could not compile `error-handling` (bin "error-handling") due to 1 previous error
このエラーは、? 演算子を使えるのは、Result、Option、または
FromResidual を実装している別の型を返す関数の中だけであることを示しています。
このエラーを修正するには、2 つの選択肢があります。1 つは、そうすることを妨げる制約が
ないのであれば、関数の戻り値型を、? 演算子を適用している値と互換性のあるものに
変更することです。もう 1 つは、match または Result<T, E> のメソッドのいずれかを
使って、Result<T, E> を適切な方法で処理することです。
エラーメッセージでは、? を Option<T> の値と一緒に使えることにも触れていました。
Result に対して ? を使う場合と同様に、Option に対して ? を使えるのは、
Option を返す関数の中だけです。Option<T> に対して呼び出されたときの
? 演算子の振る舞いは、Result<T, E> に対して呼び出されたときの振る舞いと
似ています。値が None なら、その時点で関数から早期に None が返されます。
値が Some なら、Some の中の値がその式の結果値となり、関数の実行は継続します。
リスト 9-11 には、与えられたテキストの最初の行の最後の文字を見つける関数の例が
あります。
fn last_char_of_first_line(text: &str) -> Option<char> {
text.lines().next()?.chars().last()
}
fn main() {
assert_eq!(
last_char_of_first_line("Hello, world\nHow are you today?"),
Some('d')
);
assert_eq!(last_char_of_first_line(""), None);
assert_eq!(last_char_of_first_line("\nhi"), None);
}
この関数は Option<char> を返します。というのも、そこに文字がある可能性もありますが、
ない可能性もあるからです。このコードは text という文字列スライス引数を受け取り、
それに対して lines メソッドを呼び出します。これは文字列内の各行に対する
イテレータを返します。この関数は最初の行を調べたいので、イテレータに対して next
を呼び出して最初の値を取得します。text が空文字列なら、この next の呼び出しは
None を返します。その場合、? を使って処理を止め、last_char_of_first_line
から None を返します。text が空文字列でなければ、next は Some 値を返し、
その中には text の最初の行の文字列スライスが入っています。
? はその文字列スライスを取り出し、その文字列スライスに対して chars を呼び出して、
その文字たちのイテレータを取得できます。私たちが関心を持っているのはこの最初の行の
最後の文字なので、イテレータの最後の要素を返すために last を呼び出します。
これは Option です。というのも、最初の行が空文字列である可能性があるからです。
たとえば、text が "\nhi" のように空行で始まっていて、他の行には文字がある場合が
それにあたります。しかし、最初の行に最後の文字が存在するなら、それは Some
バリアントの中で返されます。途中にある ? 演算子によって、このロジックを簡潔に
表現でき、関数を 1 行で実装できます。Option に対して ? 演算子を使えなければ、
もっと多くのメソッド呼び出しか match 式を使ってこのロジックを実装しなければ
ならないでしょう。
Result を返す関数では Result に対して ? 演算子を使えますし、Option を返す
関数では Option に対して ? 演算子を使えますが、これらを混在させて使うことは
できません。? 演算子は Result を Option に、あるいはその逆に自動変換しては
くれません。そのような場合には、Result の ok メソッドや Option の ok_or
メソッドのようなものを使って、明示的に変換できます。
これまでに使ってきた main 関数は、すべて () を返していました。main 関数は、
実行可能プログラムのエントリポイントであり終了ポイントでもあるため特別であり、
プログラムが期待どおりに振る舞うためには、その戻り値型に制約があります。
ありがたいことに、main は Result<(), E> を返すこともできます。リスト 9-12 には、
リスト 9-10 のコードがありますが、main の戻り値型を Result<(), Box<dyn Error>>
に変更し、末尾に戻り値 Ok(()) を追加しています。このコードは今度は
コンパイルされます。
use std::error::Error;
use std::fs::File;
fn main() -> Result<(), Box<dyn Error>> {
let greeting_file = File::open("hello.txt")?;
Ok(())
}
Box<dyn Error> 型はトレイトオブジェクトです。これについては第 18 章の
「共有の振る舞いを抽象化するためにトレイトオブジェクトを使う」
で説明します。今のところは、Box<dyn Error> を「あらゆる種類のエラー」を意味するものと
考えてください。エラー型が Box<dyn Error> である main 関数の中で Result
値に対して ? を使えるのは、あらゆる Err 値を早期に返せるからです。この main
関数の本体が実際に返すエラーは std::io::Error 型だけですが、Box<dyn Error> を
指定しておけば、main の本体に他の種類のエラーを返すコードがさらに追加されても、
このシグネチャは引き続き正しいままです。
main 関数が Result<(), E> を返す場合、main が Ok(()) を返せば実行ファイルは
値 0 で終了し、Err 値を返せば 0 以外の値で終了します。C で書かれた実行ファイルは、
終了時に整数を返します。正常に終了するプログラムは整数 0 を返し、エラーで終了する
プログラムは 0 以外の整数を返します。Rust もこの慣習との互換性を保つために、
実行ファイルから整数を返します。
main 関数は、std::process::Termination トレイト
を実装する任意の型を返すことができます。このトレイトには ExitCode を返す report
という関数が含まれています。独自の型に対して Termination トレイトを実装する方法に
ついては、標準ライブラリのドキュメントを参照してください。
ここまでで panic! を呼び出すことと Result を返すことの詳細を説明したので、
どのような場合にどちらを使うのが適切か、という話題に戻りましょう。