以下のようなファイルを読み取って出力するプログラムを考える。

use std::fs::File;
use std::io::BufReader;
use std::io::BufRead;
use std::env;

fn main() {
    let args: Vec<_> = env::args().collect();
    let filename = args[1].clone();
    let f = File::open(filename).unwrap();
    let reader = BufReader::new(f);

    for c in reader.lines() {
        println!("{}", c.unwrap());
    }
}

エラー処理〜ベタ書き

これは一切のエラー処理をしていないので、例えば下記のときに異常終了する。

  • プログラム実行時の引数が指定されていない時
  • ファイルが存在しない、または読み取り権限がない場合
  • ファイル読み込み時に何らかのエラーが発生した時

例えば引数を指定しないと下記のように異常終了する。

$ target/debug/foobar
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', /builddir/build/BUILD/rustc-1.25.0-src/src/liballoc/vec.rs:1551:10
note: Run with `RUST_BACKTRACE=1` for a backtrace.

現代的な言語ではこれの対処をかんたんに書くことができる。 それは try, catch があるから。

// vim: set ft=cs
using System;
using System.IO;

public class Hoge
{
  static void Main(string[] args)
  {
    try {
      string filepath = args[0];
      using (StreamReader sr = new StreamReader(filepath)) {
        while (sr.EndOfStream == false) {
          string line = sr.ReadLine();
          Console.WriteLine(line);
        }
      }
    } catch (Exception ex) {
      Console.WriteLine("Error: {0}", ex.Message);
      Environment.Exit(1);
    }
  }
}

しかし rust に例外はない。さらに言えば導入するつもりもない。 理由はマルチスレッドを主要な目的として内部制御をシンプルにしたいことのようだ。

Exceptions complicate understanding of control-flow, they express validity/invalidity outside of the type system, and they interoperate poorly with multithreaded code (a major focus of Rust).

Rust は例外に対応していないので、いちいち戻り値を確認することになる。 これを単純にコードに書き換えていくと下記のようになる。

use std::fs::File;
use std::io::BufReader;
use std::io::BufRead;
use std::env;
use std::process;

fn main() {
    let args: Vec<_> = env::args().collect();
    let filename = if args.len() >= 2 {
        args[1].clone()
    } else {
        eprintln!("Error: file not specified");
        process::exit(1);
    };
    let f = match File::open(filename) {
        Ok(v) => v,
        Err(e) => {
            eprintln!("Error: {}", e);
            process::exit(1);
        }
    };
    let reader = BufReader::new(f);

    for c in reader.lines() {
        let c = match c {
            Ok(v) => v,
            Err(e) => {
                eprintln!("Error: {}", e);
                process::exit(1);
            }
        };
        println!("{}", c);
    }
}

このコードは単に書き出しただけで特に何もテクニックを使っておらず、 そしてエラー処理のせいで非常に長いコードとなってしまった。 なお、C 言語でのコード(下記)とほぼ同じでなり、 こんなのでは Rust で書く気力も失せてしまう。

/* vim: set ft=c */
#include <stdio.h>
#include <string.h>
#include <errno.h>

int main(int argc, char *argv[])
{
  const char *filename;
  FILE *f;
  int c;

  if (argc >= 2) {
    filename = argv[1];
  } else {
    fprintf(stderr, "Error: file not specified\n");
    return 1;
  }

  f = fopen(filename, "r");
  if (f == NULL) {
    fprintf(stderr, "Error: %s\n", strerror(errno));
    return 1;
  }

  while ((c = fgetc(f)) != EOF) {
    printf("%c", (char) c);
  }
  if (ferror(f) != 0) {
    fprintf(stderr, "Error: I/O Error\n");
    clearerr(f);
    fclose(f);
    return 1;
  }

  fclose(f);
  return 0;
}

エラー処理〜早期リターンとtry!

前節の処理はプログラムを終了させていたが、一般化するとエラーのときはエラーを 早期リターンすればよいことになる。戻り値の型としては std ライブラリで提供されている Result を使う。

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

これをそのまま利用すると、処理の本体を別関数に分けた上で下記のようなコードになる:

use std::fs::File;
use std::io::BufReader;
use std::io::BufRead;
use std::env;
use std::process;

fn open_file(args: Vec<String>) -> Result<i8, String> {
    let filename = if args.len() >= 2 {
        args[1].clone()
    } else {
        return Err("file not specified".to_string());
    };
    let f = match File::open(filename) {
        Ok(v) => v,
        Err(e) => return Err(format!("{}", e)),
    };
    let reader = BufReader::new(f);

    for c in reader.lines() {
        let c = match c {
            Ok(v) => v,
            Err(e) => return Err(format!("{}", e)),
        };
        println!("{}", c);
    }
    Ok(0)
}

fn main() {
    let args: Vec<_> = env::args().collect();
    match open_file(args) {
        Ok(_) => (),
        Err(e) => {
            eprintln!("Error: {}", e);
            process::exit(1);
        }
    };
}

より現実的にエラーの扱いを独自クラスに移行すると、下記にまとめる。

use std::fs::File;
use std::fmt;
use std::io;
use std::io::BufReader;
use std::io::BufRead;
use std::env;
use std::process;

#[derive(Debug)]
enum CliError {
    Io(io::Error),
    FileNotSpecified,
}

impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            CliError::Io(ref err) => write!(f, "{}", err),
            CliError::FileNotSpecified => write!(f, "file not specified"),
        }
    }
}

fn open_file(args: Vec<String>) -> Result<i8, CliError> {
    let filename = if args.len() >= 2 {
        args[1].clone()
    } else {
        return Err(CliError::FileNotSpecified);
    };
    let f = match File::open(filename) {
        Ok(v) => v,
        Err(e) => return Err(CliError::Io(e)),
    };
    let reader = BufReader::new(f);

    for c in reader.lines() {
        let c = match c {
            Ok(v) => v,
            Err(e) => return Err(CliError::Io(e)),
        };
        println!("{}", c);
    }
    Ok(0)
}

fn main() {
    let args: Vec<_> = env::args().collect();
    match open_file(args) {
        Ok(_) => (),
        Err(e) => {
            eprintln!("Error: {}", e);
            process::exit(1);
        }
    };
}

さてこれでも長い。結局のところ match して return Err(...) しているところが 多いのである。 エラークラスの実装の手間(オリジナルのエラーから独自エラーの 変換処理として From を実装)と引き換えに、 try! という macro を使うことでかなり書く分量を減らすことができる。

use std::fs::File;
use std::fmt;
use std::io;
use std::io::BufReader;
use std::io::BufRead;
use std::env;
use std::process;

#[derive(Debug)]
enum CliError {
    Io(io::Error),
    FileNotSpecified,
}

impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            CliError::Io(ref err) => write!(f, "{}", err),
            CliError::FileNotSpecified => write!(f, "file not specified"),
        }
    }
}

impl From<io::Error> for CliError {
    fn from(err: io::Error) -> CliError {
        CliError::Io(err)
    }
}

fn open_file(args: Vec<String>) -> Result<i8, CliError> {
    let filename = try!(args.get(1).ok_or(CliError::FileNotSpecified));
    let f = try!(File::open(filename));
    let reader = BufReader::new(f);

    for c in reader.lines() {
        println!("{}", try!(c));
    }
    Ok(0)
}

fn main() {
    let args: Vec<_> = env::args().collect();
    match open_file(args) {
        Ok(_) => (),
        Err(e) => {
            eprintln!("Error: {}", e);
            process::exit(1);
        }
    };
}

エラー処理〜"?"演算子

このペースで書いていくと try! だらけになってしまう。 これじゃまずいと rust 開発者も気づいたらしく、 try! の(ほぼほぼ)糖衣構文の ? 演算子が導入された。 これで書き換えるとコードの見た目がスッキリとする。

use std::fs::File;
use std::fmt;
use std::io;
use std::io::BufReader;
use std::io::BufRead;
use std::env;
use std::process;

#[derive(Debug)]
enum CliError {
    Io(io::Error),
    FileNotSpecified,
}

impl fmt::Display for CliError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match *self {
            CliError::Io(ref err) => write!(f, "{}", err),
            CliError::FileNotSpecified => write!(f, "file not specified"),
        }
    }
}

impl From<io::Error> for CliError {
    fn from(err: io::Error) -> CliError {
        CliError::Io(err)
    }
}

fn open_file(args: Vec<String>) -> Result<i8, CliError> {
    let filename = args.get(1).ok_or(CliError::FileNotSpecified)?;
    let f = File::open(filename)?;
    let reader = BufReader::new(f);

    for c in reader.lines() {
        println!("{}", c?);
    }
    Ok(0)
}

fn main() {
    let args: Vec<_> = env::args().collect();
    match open_file(args) {
        Ok(_) => (),
        Err(e) => {
            eprintln!("Error: {}", e);
            process::exit(1);
        }
    };
}

エラー構造体のFromなどのトレイトを定義するのが面倒

エラー構造体のトレイトの内容は機械的な内容を書くだけで面倒である。 これは過去にいろいろなcrateが出てきたが、純粋にこの機械的な内容をderiveに任せるのならば、 thiserrorが便利である。

[dependencies]
thiserror = "1"

下記のように書けば、CliErrorに対して書いていたトレイトの定義はすべてなくなる。

use thiserror::Error;

#[derive(Error, Debug)]
enum CliError {
    #[error(transparent)]
    Io(#[from] io::Error),
    #[error("file not specified")]
    FileNotSpecified,
}
// 以下略

anyhowというボックス化されたErrorを扱うcrateを使っても良い。 が、基本形を単に簡単にするという意味ではthiserrorがまさにその用途であるため、thiserrorをここで使用した。


…という流れで説明してくれたほうがわかりやすいと思うんだが、 ココらへんが妙に細かく書いてくれている(それはそれでよいが) ドキュメントやハウツーが多くてかえって理解に苦労した。

ただ、ここらへんの理解の仕方は古めかしい C 言語のコードで 条件分岐しまくったコードを書いたことがあるかどうかに依るのかもしれない。

余談: try catch 的な使い方

その上でクロージャ使えば関数で切り出さずに内部に処理をいれたまま扱えて、 一般的な言語の try catch みたいに使えるよね? と思って試したら一応書けた。

ただキモいコードであることは否めない。

fn main() {
    let args: Vec<_> = env::args().collect();
    match (|args: Vec<_>| -> Result<_, CliError> {
        let filename = args.get(1).ok_or(CliError::FileNotSpecified)?;
        let f = File::open(filename)?;
        let reader = BufReader::new(f);

        for c in reader.lines() {
            println!("{}", c?);
        }
        Ok(0)
    })(args)
    {
        Ok(_) => (),
        Err(e) => {
            eprintln!("Error: {}", e);
            process::exit(1);
        }
    };
}

参考文献