LoginSignup
211

More than 5 years have passed since last update.

RustでOption値やResult値を上手に扱う

Last updated at Posted at 2016-02-22

この記事の概要

Option<T> 型や Result<T, E> 型の戻り値を、match 式で判別している以下のようなコードを、

fn double_arg(mut argv: env::Args) -> Result<i32, String> {
    match argv.nth(1) {
        None => Err("数字を1つ指定してください。".to_owned()),
        Some(arg1) => {
            match arg1.parse::<i32>() {
                Ok(n) => Ok(2 * n),
                Err(err) => Err(err.to_string()),
            }
        }
    }
}

Rust の標準的な部品を組み合わせて、以下のように、すっきり読みやすく仕上げるまでの道のりを紹介します。

fn double_arg(mut argv: env::Args) -> Result<i32, CliError> {
    let arg1 = try!(argv.nth(1).ok_or(CliError::NotEnoughArgs));
    let n = try!(arg1.parse::<i32>());
    Ok(2 * n)
}

なお、この記事は、Rust の公式ドキュメント「Rustプログラミング言語」(日本語翻訳版)の エラーハンドリング の章を参考にして書きました。

Rust のバージョン

この記事のコードは、2016年2月現在の最新の安定版で動作確認済みです:

  • rustc 1.6.0
  • cargo 0.8.0 (08da2f5 2015-12-21)

rustc 1.0 以降なら、どのバージョンでも問題なく動くと思います。

ソースコード

本記事で紹介するソースコードは、GitHub に置いてあります。

Rust と Cargo がインストールされた環境で、以下のようにすれば実行できます。

$ cd rust-option-result-examples
$ cargo --bin main{1-7の連番} -- プログラムへの引数

例:

$ cargo --bin main7 -- 123
   Compiling error-handling v0.1.0 (file:/// ... rust-option-result-examples)
     Running `target/debug/main7 123`
246

基礎知識:Option<T> 型と Result<T, E>

Option<T>

Rust の Option<T>は、値が 存在しない 可能性を暗示する列挙型です。定義は以下の通りです。

enum Option<T> {
    None,
    Some(T),
}

T型の値が存在する時は、Some で包まれています。存在しない時は None になります。

Option 型は、他の言語では MaybeOptional などと呼ばれることもあります。

例:

Linux の Docker が使える環境で rusti を実行:

% sudo docker run -it --rm quay.io/tatsuya6502/rusti
# cd ~/rusti
# cargo run
rusti=>

Some(10)Option<i32> 型です。

rusti=> Some(10)
Some(10)
rusti=> .type Some(10)
Some(10) = core::option::Option<i32>

注意: rusti で型を見ると core ライブラリの Option<T> 型が優先されてしまいます。通常の Rust プログラムでは core ライブラリを use 指定してないので、core ではなく、std ライブラリの std::option::Option<T> 型になります。

こちらは Option<String> 型です。

rusti=> Some("Hello, world!".to_owned())
Some("Hello, world!")
rusti=> .type Some("Hello, world!".to_owned())
...省略... = core::option::Option<collections::string::String>

None は単体だと Option<T>T が決まらないので、コンパイルエラーになります。

rusti=> None
<anon>:13:20: 13:24 error: unable to infer enough type information about `_`; type annotations or generic parameter binding required [E0282]
...

ヒントを与えましょう。

rusti=> None as Option<i32>
None
rusti=> .type None as Option<i32>
... = core::option::Option<i32>

Some に包まれた値を取り出すには、match 式による場合分けをします。

rusti=> .block
rusti+> let v = Some(10);
rusti+> match v {
rusti+>     Some(n) => n,
rusti+>     None    => -1,
rusti+> }
rusti+> .
10

Option に出会う度に場合分けをするのは面倒なので、標準ライブラリでは、Option から値を取り出したり、加工したりするためのメソッドが用意されています。例えば unwrap() は最も素朴なメソッドで、以下のような定義になっています:

Option<T>のunwrap()の定義
impl<T> Option<T> {
    fn unwrap(self) -> T {
        match self {
            Option::Some(val) => val,
            Option::None =>
              panic!("called `Option::unwrap()` on a `None` value"),
        }
    }
}

このように Some<T> に適用すると、値が取り出せます。

rusti=> Some(10).unwrap()
10

そして、None に適用すると、パニックします。

rusti=> (None as Option<i32>).unwrap()
thread '<main>' panicked at 'called `Option::unwrap()` on a `None` value', ../src/libcore/option.rs:330

map() はクロージャを引数にとり、Some の中の値にクロージャを適用して、結果を Some で包み直します。もし None に適用すると、None を返します。

rusti=> Some(10).map(|n| 2 * n)
Some(20)
rusti=> (None as Option<i32>).map(|n| 2 * n)
None

and_then()map() に似ていますが、違いは Some<T> だけでなく None も返せることです。

rusti=> Some(10).and_then(|n| if n > 5 {Some(n)} else {None} )
Some(10)
rusti=> Some(5).and_then(|n| if n > 5 {Some(n)} else {None} )
None
rusti=> (None as Option<i32>).and_then(|n| if n > 5 {Some(n)} else {None} )
None

map()and_then() は入力と出力に Option<T> 型の値を1つだけ取るので、数珠つなぎにできます。このような性質を持つメソッドを、コンビネータと呼びます。

rusti=> Some(10).and_then(|n| if n > 5 {Some(n)} else {None}).map(|n| 2 * n)
Some(20)
rusti=> Some(5).and_then(|n| if n > 5 {Some(n)} else {None}).map(|n| 2 * n)
None

Result<T, E>

Rust の Result<T, E>エラーになる可能性 を暗示する列挙型です。定義は以下の通りです。

enum Result<T, E> {
    Ok(T),
    Err(E),
}

処理が成功した時は値を Ok で包みます。失敗した時はエラーの値を Err で包みます。

Result 型は、他の言語では Either と呼ばれることもあります。

rusti=> .type Ok(10) as Result<i32, ()>
... = core::result::Result<i32, ()>
rusti=> .type Ok("Success!".to_owned()) as Result<String, ()>
... = core::result::Result<collections::string::String, ()>
rusti=> .type Err("Not found".to_owned()) as Result<i32, String>
... = core::result::Result<i32, collections::string::String>

Option<T> と同じようなメソッドが用意されています。unwrap() は値が Err の時はパニックします。

rusti=> (Ok(10) as Result<i32, String>).unwrap()
10
rusti=> (Err("Not found".to_owned()) as Result<i32, String>).unwrap()
thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: "Not found"', ../src/libcore/result.rs:746

and_then()map() は、値が Ok<T> のときはクロージャを実行し、値が Err<E> のときほ元の値を未加工で返します。

rusti=> .block
rusti+> (Ok(10) as Result<i32, String>)
rusti+>   .and_then(|n| if n > 5 {Ok(n)} else {Err("Too small".to_owned())})
rusti+>   .map(|n| 2 * n)
rusti+> .
Ok(20)
rusti=> .block
rusti+> (Ok(5) as Result<i32, String>)
rusti+>   .and_then(|n| if n > 5 {Ok(n)} else {Err("Too small".to_owned())})
rusti+>   .map(|n| 2 * n)
rusti+> .
Err("Too small")

また、逆に値が Err のときだけ働くメソッドもあります。

rusti=> .block
rusti+> (Err("Not found".to_owned()) as Result<i32, String>)
rusti+>   .map_err(|e| "Error! ".to_owned() + &e)
rusti+> .
Err("Error! Not found")

明示的な場合分けから、try! マクロとコンビネータへ

では、冒頭にあげたプログラムを例に、Option<T>Result<T, E> の扱いを、少しずつ改善していきましょう。このプログラムは、コマンドライン引数として整数値を表す文字列を受け取り、それを2倍した値を表示します。

引数として "10" を与えた時:

% cargo run --bin main1 -- 10
     Running `target/debug/main1 10`
20

unwrap() を使う

まず最初は、エラーの可能性は考慮せず、正しい引数が渡されたときの機能だけを実装します。以下のように double_arg() 関数は、コマンドライン引数の1番目の値を取得して、整数値としてパースし、2倍して返します。

src/main1.rs
use std::env;

fn double_arg(mut argv: env::Args) -> i32 {
    let arg1 = argv.nth(1).unwrap(); // エラー1
    let n = arg1.parse::<i32>().unwrap(); // エラー2
    2 * n
}

そして呼び出し元となる main() 関数では、double_arg() を呼び出して、結果を表示するようにしました。

src/main1.rs
fn main() {
    let n = double_arg(env::args());
    println!("{}", n);
}

このプログラムは、正しい引数を与えれば問題なく動きます。

% cargo run --bin main1 -- 42
     Running `target/debug/main1 42`
84

しかし当然、エラーになるとパニックを起こしてクラッシュします。

まず、引数を指定しないと、先ほどのソースコードのエラー1の行でパニックします。

% cargo run --bin main1
     Running `target/debug/main1`
thread '<main>' panicked at 'called `Option::unwrap()` on a `None` value', src/libcore/option.rs:367
Process didn't exit successfully: `target/debug/main1` (exit code: 101)

メッセージの意味は、None に対して、Option::unwrap() が呼ばれました、です。argv.nth(1)Option<String> 型の値を返します。コマンドライン引数のインデックス1の文字列があれば Some が返され、なければ None を返します。

rusti=> use std::env;
rusti=> .type env::args().nth(1)
... = core::option::Option<collections::string::String>

また、引数が整数としてパースできないときも、エラー2の行でパニックします。

% cargo run --bin main1 -- hoge
     Running `target/debug/main1 hoge`
thread '<main>' panicked at 'called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }', src/libcore/result.rs:741
Process didn't exit successfully: `target/debug/main1 hoge` (exit code: 101)

メッセージの意味は、Err に対して、Result::unwrap() が呼ばれました、です。parse() は、(この場合は i32 型の結果が期待されていますので) Result<i32, std::num::ParseIntError> 型の値を返します。パースできれば Ok<i32> を、できなければ Err<std::num::ParseIntError> を返します。

rusti=> .type "10".parse::<i32>()
... = core::result::Result<i32, core::num::ParseIntError>

明示的な場合分けをする

では、この2つのエラーに対応できるようにしましょう。double_arg() 関数は unwrap() でパニックする代わりに、エラーが起こったことを、戻り値を使って呼び出し元(main() 関数)に伝えるようにします。リターン型が i32 のままだとエラーを表現できないので、Result<i32, String> に変更します。エラーの内容は String 型の文字列で渡します。

まずは素朴なやり方として、match 式によるパターンマッチ、つまり、明示的な場合分けをしましょう。

src/main2.rs
use std::env;

fn double_arg(mut argv: env::Args) -> Result<i32, String> {
    match argv.nth(1) {
        None => Err("数字を1つ指定してください。".to_owned()),
        Some(arg1) => {
            match arg1.parse::<i32>() {
                Ok(n) => Ok(2 * n),
                Err(err) => Err(err.to_string()),
            }
        }
    }
}

このように、もし argv.nth(1)None を返したら、「数字を1つ指定してください」という String 型の文字列を Err() で包んで返します。

またもし parse::<i32>()Err<std::num::ParseIntError> を返したら、ParseIntErrorto_string()String 型の文字列に変換してから、Err<String> で包んで返します。

エラーがなければ、数値を2倍した値を Ok<i32> 型で返します。

main() 関数は double_arg() 関数からの戻り値に対して match 式で場合分けをして、必要ならエラーメッセージを表示します。

src/main2.rs
fn main() {
    match double_arg(env::args()) {
        Ok(n) => println!("{}", n),
        Err(err) => println!("エラー:{}", err),
    }
}

試してみましょう。

引数を忘れた時:

% cargo run --bin main2
     Running `target/debug/main2`
エラー:数字を1つ指定してください。

引数が整数としてパースできない時:

% cargo run --bin main2 -- hoge
     Running `target/debug/main2 hoge`
エラー:invalid digit found in string

どちらのケースもパニックしなくなりました。後者のメッセージは英語ですが、期待通りに動いてます。

この方法の長所は、プログラムの内容が見たままで、なにも抽象化されていないので、初心者にとってわかりやすいことです。短所は、エラーケースの場合分けが増えてくると、正常ケースのロジックがそれに埋もれてしまい、分かりにくくなってしまうことです。

最初はこのような書き方でもかまわないと思いますが、Rust に慣れてきたら、この後紹介するコンビネータや try! マクロを使った方法へ移行していくことをお勧めします。

コンビネータを使う

明示的な場合分けを、コンビネータに肩代わりしてもらいましょう。このように書き換えられます。

src/main3.rs
use std::env;

fn double_arg(mut argv: env::Args) -> Result<i32, String> {
    argv.nth(1)
        .ok_or("数字を1つ指定してください。".to_owned())
        .and_then(|arg1| arg1.parse::<i32>().map_err(|err| err.to_string()))
        .map(|n| 2 * n)
}

fn main() {
  // main2.rs と同じ。省略
}

Option<T>ok_or() コンビネータは、argv.nth(1) が返した Option<T> 型の値を、Result<T, E> 型に変換してくれます。もし値が Some<T> なら Ok<T> を作ります。もし値が None なら Err<E> を作って、引数として渡した値(この場合は String 型の "数字を1つ指定してください。")を包みます。

rusti=> Some("10").ok_or("error!".to_owned())
Ok("10")
rusti=> (None as Option<i32>).ok_or("error!".to_owned())
Err("error!")

Result<T, E>and_then()map_err()map() コンビネータは先ほど紹介した通りです。

流れを追ってみましょう。

コマンドライン引数に "10" が与えられたとき:

  1. argv.nth(1)Some("10") を返す。
  2. ok_or() コンビネータが、Ok("10") に変換する。
  3. and_then() コンビネータが、Ok<String> の中の値 "10" を取り出し、クロージャに渡す。
  4. クロージャは "10"arg1.parse::<i32>() を適用して、Ok(10) が得られる。
  5. 同クロージャの map_err() コンビネータは何もせず、Ok(10) を返す。
  6. map() コンビネータは Ok(10) の中の値 10 を取り出して、クロージャに渡す。
  7. クロージャはそれを2倍して 20 を返す。
  8. map() コンビネータは、それを包み直して Ok(20) を返す。この値が double_arg() の戻り値になる。

コマンドライン引数が与えられなかったとき:

  1. argv.nth(1)None を返す。
  2. ok_or() コンビネータは Err<String> 型の値 Err("数字を1つ...") を返す。
  3. 後続の and_then()map() は何もせず、Err<String> 値をそのまま返す。(double_arg() の戻り値)

コマンドライン引数に、数値として無効な値 "hoge" が与えられたとき:

  1. and_then() コンビネータのクロージャで "hoge".parse::<i32>() が実行され Err<std::num::ParseIntError> 型の値が返る。
  2. map_err() コンビネータがエラーの値に to_string() を適用して、Err<String> 型の値が作られる。
  3. map() コンビネータは何もせず、Err<String> 値をそのまま返す。(double_arg() の戻り値)

どうでしょう? たしかに行数は少なくなりましたし、プログラムの文面は英語の文章っぽくなりました。しかし、クロージャが頻出するので個人的には、少し読みにくくなったように思えます。慣れの問題かもしれませんが。

次の try! マクロとコンビネータを組み合わせる方法を見てみましょう。

try! マクロとコンビネータを組み合わせる

Rust らしいやり方は、try! マクロとコンビネータを適度に組み合わせることです。try!() マクロは、コンビネータと同様に場合分けを肩代わりしてくれます。しかし、それだけでなく「早期リターン(early return)」という、制御フローの抽象化もしてくれます。

src/main4.rs

use std::env;

fn double_arg(mut argv: env::Args) -> Result<i32, String> {
    let arg1 = try!(argv.nth(1).ok_or("数字を1つ指定してください。".to_owned()));
    let n = try!(arg1.parse::<i32>().map_err(|err| err.to_string()));
    Ok(2 * n)
}

fn main() {
  // main2.rs と同じ。省略
}

try!() マクロは、Result<T, E> 型の値を返す式を取ります。式を評価して Ok<T> の値が得られた時は、unwrap() のように、中の値を取り出して返します。一方、Err<E> の値が得られた時は、return 文を使って double_arg() 関数から抜け出して、呼び出し元(main())にそのエラーの値を返します。

流れを追ってみましょう。

コマンドライン引数として "10" が与えられたとき:

  1. argv.nth(1)Some("10") を返す。
  2. ok_or() コンビネータが、Ok("10") に変換する。
  3. try!() マクロが中の値 "10" を取り出す。
  4. 変数 arg1"10" に束縛される。
  5. "10"parse::<i32>() を適用すると Ok(10) が得られる。
  6. map_err() コンビネータは、Ok(10) をそのまま返す。
  7. try!() マクロは Ok(10) の中の値 10 を取り出す。
  8. 変数 n10 に束縛される。
  9. 2 * n の結果を Ok<i32> に格納する。(double_arg() の戻り値)

コマンドライン引数が与えられなかったとき:

  1. argv.nth(1)None を返す。
  2. ok_or() コンビネータは Err<String> 型の値 Err("数字を1つ...") を返す。
  3. try!() マクロは return Err("数字を1つ..."); を実行し、main() 関数へ早期リターンする。

コマンドライン引数に、数値として無効な値 "hoge" が与えられたとき:

  1. 変数 arg1"10" に束縛されるところまでは、正常系と同じ。
  2. "hoge".parse::<i32>()Err<std::num::ParseIntError> 型の値を返す。
  3. map_err() コンビネータがエラーの値に to_string() を適用して、Err<String> 型の値が作られる。
  4. try!() マクロが return ... を実行し、main() 関数へ早期リターンする。

早期リターンという手法が加わったことで、コードがすっきりしたと思いませんか? エラー処理をしてなかった頃のコードと比較してみましょう。

エラー処理なし

src/main1.rs
fn double_arg(mut argv: env::Args) -> i32 {
    let arg1 = argv.nth(1).unwrap(); // エラー1
    let n = arg1.parse::<i32>().unwrap(); // エラー2
    2 * n
}

エラー処理あり

src/main4.rs
fn double_arg(mut argv: env::Args) -> Result<i32, String> {
    let arg1 = try!(argv.nth(1).ok_or("数字を1つ指定してください。".to_owned()));
    let n = try!(arg1.parse::<i32>().map_err(|err| err.to_string()));
    Ok(2 * n)
}

エラー処理を加えた後でも、コードの明快さは失われてません。正常ケースのロジックがはっきりとわかりますし、その一方で、どこでエラーが起こりうるか、また、エラーが起こったらどうするのかもわかります。

エラー情報を改善する

いままで double_arg() ではエラーが起こった時に Err<String> 型の値を返していました。これは手軽な方法ですし、今回のような短いプログラムでは十分、実用に耐えられます。でも残念なのは、parse::<i32>() がせっかく Err<std::num::ParseIntError> 型の値を返しているのに、それを文字列に変換してから返していることです。

いったんエラーが文字列に変換されてしまうと、呼び出し元では場合分けがしにくくなってしまいます。例えば呼び出し元の main() 関数で、引数が指定されなかった時と、数値として無効な値が与えられた時で、別の動作をさせたくなったらどうしますか?また、エラーメッセージを日本語にローカライズしたい時もやりにくいでしょう。

もし double_arg()Err<std::num::ParseIntError> のようにエラーの値をそのまま返すなら、この問題は解決します。

Box<Error> 型を返す

エラーの値を直接返したいと思った時に問題になるのは、double_arg() では2種類のエラーが起こる可能性があることです。一方は Err<std::num::ParseIntError> ですが、もう一方はコマンドライン引数がなかった時に None になるので、いまは Err<String> 型の値を作って返しています。このままでは、両者を一つにまとめられないないので、リターン型の Result<i32, 何かのエラー型>何かのエラー型 の部分を決められません。

ParseIntError は、std::error::Error トレイトを実装しています。そこで、コマンドライン引数がなかった時のエラー型を新たに定義して、同じように Error トレイトを実装しましょう。こうすれば、両者を一つにまとめられます。

NotEnoughArgsError という構造体を作ります。

src/main5.rs
use std::error;
use std::fmt;

#[derive(Debug)]
struct NotEnoughArgError {}

このように Debug トレイトの実装を自動導出(derive)しています。Error トレイトを実装する時は Debug トレイトの実装が必須なので、自動導出するのがいいでしょう。

さらに、std::fmt::Display トレイトが要求する fmt() 関数の実装も必要です。

src/main5.rs
impl fmt::Display for NotEnoughArgError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "引数が不足しています")
    }
}

最後に std::error::Error トレイトが要求する description() 関数と cause 関数を実装します。

src/main4.rs
impl error::Error for NotEnoughArgError {
    fn description(&self) -> &str {
        "引数が不足しています"
    }

    fn cause(&self) -> Option<&error::Error> {
        None
    }
}

これで NotEnoughArgsError の実装は OK です。

さて、double_arg() のリターン型を変更して Result<i32, error::Error> にしたいところですが、これは Rust ではできません。Rust では関数の戻り値に、トレイトを使うことができないのです。

なぜなら、Rustは関数の引数と戻り値をコールスタックに直接積みますが、そのためには、コンパイル時に、これらの値が必要とするメモリのサイズが決まってないといけないのです。型を Error にしてしまうと、Error トレイトを実装している型ならどんなものでもスタックに置けることになります。しかし、個々の具象型(将来実装されるものも含む)によって、必要とするメモリのサイズが異なるので、それらをコンパイル時に列挙して、サイズを予測することは不可能です。

これを解決する方法のひとつはトレイトオブジェクトを使うことです。これは、元のエラー値を Box で包むことで作れます。具体的には Box::new(エラー値) とします。

rusti=> .type Box::new("hoge".parse::<i32>().unwrap_err())
... = Box<core::num::ParseIntError>
rusti=> .type Box::new("hoge".parse::<i32>().unwrap_err()) as Box<std::error::Error>
... = Box<std::error::Error>

値を box 化すると、その実体はスタックではなくて、ヒープに格納されるようになります。そしてスタックには、その実体へのポインタが積まれます。ポインタなら、必要なメモリサイズがコンパイル時に決まりますので大丈夫なわけです。

double_arg() のリターン型を Result<i32, Box<error::Error>> に変更します。

src/main5.rs(続き)

use std::env;
use std::error;

fn double_arg(mut argv: env::Args) -> Result<i32, Box<error::Error>> {
    let number_str = try!(argv.nth(1).ok_or(NotEnoughArgsError));
    let n = try!(number_str.parse::<i32>());
    Ok(2 * n)
}

fn main() {
  // main2.rs と同じ。省略
}

これで OK です。

あれ?でも、Err<NotEnoughArgError>Err<num::ParseIntError> 型の値を try!() に渡しているのに、リターン型として Result<i32, Box<error::Error>> と書けるのは不思議に思いませんか?さっき、box 化するには Box::new(エラー値) とすると言ったばかりです。なぜ、それがなくてもコンパイルできるのでしょう。

種を明かすと try!() マクロがエラーの型を変換しています。try!() マクロは以下のように定義されています。

macro_rules! try {
    ($e:expr) => (match $e {
        Ok(val) => val,
        Err(err) => return Err(::std::convert::From::from(err)),
    });
}

単に Err<なにかの型> の値を返すのではなく、そこに格納されたエラーの値に std::convert::From トレイトの from() を適用してから、Err<E> で包み直しています。この from() 関数は、ある型の値を、別の型の値へ変換します。そして、標準ライブラリには、以下のような From 実装があります。

impl<'a, E: Error + 'a> From<E> for Box<Error + 'a>

この実装により、Error トレイトを実装した全ての型の値は、トレイトオブジェクト Box<Error> へ変換できるのです。

つまり try!() マクロは、以下の3つを抽象化しています。

  1. 場合分け
  2. 制御フロー
  3. エラー値の型変換

double_arg() 関数のコードを、もう一度比較してみましょう。

エラー処理なし(エラー時にクラッシュする)

src/main1.rs
fn double_arg(mut argv: env::Args) -> i32 {
    let arg1 = argv.nth(1).unwrap(); // エラー1
    let n = arg1.parse::<i32>().unwrap(); // エラー2
    2 * n
}

エラー処理あり

src/main5.rs
fn double_arg(mut argv: env::Args) -> Result<i32, Box<error::Error>> {
    let arg1 = try!(argv.nth(1).ok_or(NotEnoughArgsError));
    let n = try!(arg1.parse::<i32>());
    Ok(2 * n)
}

try!() がエラーを変換してくれるので、以前の例にあった map() が不要になりました。フル機能のエラー処理を行っていますが、そのためのオーバーヘッド(付随するコード)は、ほとんどありません!

Box<Error> の些細な問題

前の節ではトレイトオブジェクト Box<Error> を使いましたが、ここには些細な問題があります。Box で包まれた値は Error トレイトを実装していることまでは分かるのですが、その具象型(NotEnoughArgError または ParseIntError)がなんであったのかは、コンパイラにはわからなくなってしまうのです。このようなコンパイラーの動作を、型消去(type erasure) と呼びます。

例えば、呼び出し元の main() 関数の側で、エラーの種類に応じて処理を分岐させたくても、単純な場合分けでは判断できなくなります。以下のように書くことはできません。

コンパイルエラーになる
fn main() {
    match double_arg(env::args()) {
        Ok(n) => println!("{}", n),
        Err(Box(num::ParseIntError(..))) => {
            println!("不正な数字です \"{}\"", env::args().nth(1).unwrap())
        },
        Err(err) => println!("エラー:{}", err),
    }
}

方法はなくはありせん。実行時のリフレクションを使って、具象型へダウンキャストすることは可能です。

src/main6.rs
fn main() {
    match double_arg(env::args()) {
        Ok(n) => println!("{}", n),
        Err(err) => {
            match err.downcast_ref::<num::ParseIntError>() {
                None => println!("エラー:{}", err),
                Some(..) => println!("不正な数字です \"{}\"", env::args().nth(1).unwrap()),
            }
        }
    }
}

起こりうるエラーの型一つ一つについて、ダウンキャストできたかの場合分けをしていくのは、少し冗長です。

ダウンキャストしなかったとしても、String を返すよりは良くなっています。なぜなら、Error トレイトに用意された、description() メソッドや cause() メソッドで、エラーについての、より詳細な情報を取得できるからです。

列挙型で独自のエラー型を定義する

この記事の締めくくりとして、トレイトオブジェクトを使った時の些細な問題を解決しましょう。2種類のエラーの値を包むことができる列挙型を定義して、Error トレイトと From トレイトを実装するのです。列挙型なら、トレイトオブジェクトと違い、型消去が起こりません。

列挙型を定義しましょう。コマンドラインインターフェイス(CLI)で起こるエラーということで、名前は CliError としています。バリアントとして、NotEnoughArgsParse(num::ParseIntError) を用意します。

src/main7.rs
use std::error;
use std::fmt;
use std::num;

#[derive(Debug)]
enum CliError {
    NotEnoughArgs,
    Parse(num::ParseIntError),
}

Display トレイトと、Error トレイトを実装します。このように値に応じて、単純に処理を切り替えれば OK です。

src/main7.rs
impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            CliError::NotEnoughArgs => write!(f, "引数が不足しています"),
            CliError::Parse(ref err) => write!(f, "Parse error: {}", err),
        }
    }
}

impl error::Error for CliError {
    fn description(&self) -> &str {
        match *self {
            CliError::NotEnoughArgs => "引数が不足しています",
            CliError::Parse(ref err) => err.description(),
        }
    }

    fn cause(&self) -> Option<&error::Error> {
        match *self {
            CliError::NotEnoughArgs => None,
            CliError::Parse(ref err) => Some(err),
        }
    }
}

引数がなかった時のエラーは CliError だけで十分表現できるので、前の節で作った NotEnhoughArgsError は、いらなくなってしまいました。

double_arg() 関数を修正しましょう。

src/main7.rs
fn double_arg(mut argv: env::Args) -> Result<i32, CliError> {
    let arg1 = try!(argv.nth(1).ok_or(CliError::NotEnoughArgs));
    let n = try!(arg1.parse::<i32>().map_err(|e| CliError::Parse(e)));
    Ok(2 * n)
}

このように、戻り値を Result<i32, CliError> に変更しました。しかし、arg1.parse::<i32>()num::ParseIntError を返したときのために、map_err() コンビネータによる変換が必要になってしまいました。

これを取り除くのは簡単です。num::ParseIntError から CliError への変換をする From トレイトを実装すればいいのです。

src/main7.rs
impl From<num::ParseIntError> for CliError {
    fn from(err: num::ParseIntError) -> CliError {
        CliError::Parse(err)
    }
}

double_arg() 関数は最終的にこうなります。

src/main7.rs
fn double_arg(mut argv: env::Args) -> Result<i32, CliError> {
    let arg1 = try!(argv.nth(1).ok_or(CliError::NotEnoughArgs));
    let n = try!(arg1.parse::<i32>());
    Ok(2 * n)
}

main() 関数は以前のままでも動きますが、せっかく列挙型を導入したので、double_arg() 関数の戻り値を使って、CliError::Parse() の時も日本語のメッセージを出すようにしましょう。

src/main7.rs
fn main() {
    match double_arg(env::args()) {
        Ok(n) => println!("{}", n),
        Err(err @ CliError::NotEnoughArgs) => println!("エラー:{}", err),
        Err(CliError::Parse(..)) => {
            println!("エラー:不正な数字です \"{}\"",
                     env::args().nth(1).unwrap())
        }
    }
}

これで完成です!

まとめ

  1. Option<T>Result<T, E> のコンビネータについて、よく学んでおきましょう。これらの値を簡潔に処理するための道具が用意されています。特によく使うのは and_then()map()unwrap_or() です。
  2. try!() マクロを使いこなしましょう。このマクロは3つのことを抽象化してくれます:
    • 場合分け
    • 制御フロー(早期リターン)
    • エラー値の型変換
  3. エラー型に文字列を使うのは避けて、Box<Error> を使いましょう。必要に応じて、構造体によるエラー型を定義しましょう。
  4. Box<Error> の型消去が問題になる場合は、列挙型を定義してエラーの値をラップしましょう。

3と4は必須ではありません。簡単なアプリケーションを書いている時は、Result<T, String> 型で十分なこともあります。

この記事で解説した内容を、もっと詳しく知りたくなったら、公式ドキュメント(日本語翻訳版)の エラーハンドリング を読んでください。

もちろん、公式ドキュメントのそれ以外の章も、Rust を使う上で理解しておくべき内容が詰まっていますので、ぜひ読んでもらいたいです。翻訳の進捗状況と、翻訳済みページヘのリンクは ここ にあります。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
211