🥰

Rustはいいぞ: エラーハンドリング編

2024/06/06に公開

はじめに

Rustのいいところが分からない……という人のために、エラーハンドリングをいい感じに書けるよということを伝えます!!!!!

対象読者 🐬

  • 周囲にRustを触れと執拗に薦めてくる人がおり少し興味を持ったはいいがいったいRustの何がいいのか分からない
  • try-catch でエラーハンドリングする言語を使う人

要約

  • try-catch によるエラーハンドリングは、つらい
  • Rust では、失敗するかもしれない処理の結果(戻り値)は Result 型で表される
  • Result 型のエラーハンドリングが行われていないと、コンパイラが怒ってくれるので、エラーハンドリング忘れがなくてとても良い

背景

前提知識: try-catch文による例外処理

例えば、Typescript (またはJavaScript) では、例外が発生する可能性のある関数を実行する時、例外が発生した時に対処するために次のように書きます。

try {
    // 例外をthrowするかもしれない関数
    const result = functionMayThrow();
} catch (err) {
    // 例外がthrowされたらこのブロックが実行される
}

try 内で宣言した変数はスコープ外で使用できない

tryブロック内で宣言された変数は、当然スコープ外では利用できません。

try {
    const result1 = hogehoge();
} catch (err) {
    console.log("例外が発生!!");
}

// tryブロックを抜けたら `result1` は無効

result1を利用したいときは、まず、ロジックをtry内に全部突っ込むことが方法として考えられます。

ここで、例外を throw する可能性のある関数 fugafuga()hogehoge() を考えてみましょう。ロジックの都合で、これらの関数は1つ前の関数の実行結果を引数として利用するものとします。

try {
    const result1 = hogehoge();
    const result2 = fugafuga(result1);
    const result3 = piyopiyo(result2);
} catch (err) {
    console.log("例外が発生!");
}

fugafuga()piyopiyo() が増えるくらいならまだ分かりやすいですが、tryブロックのロジックを増やすと、どの関数で例外が発生したか分からない可能性が出てきます

言語によっては catch を複数書いて型を書いたり、条件分岐したりするとかろうじて判別はできますが、仮にどの関数も同じ例外をthrowする場合、判別できません。

さらに、tryブロック内のロジックが長くなるほど、余計に分からなくなります。できる限り try-catch は細かく分けたいです。

try-catch文の外で結果を利用するために、再代入しないのに変更可能な変数を作ってしまう

1つのtryブロック内にロジックが集約される原因は、スコープ外で変数が利用できないことにあります。それでは、変数をtryの外に定義して、try-catchで初期化するのはどうでしょうか?

// let は再代入可能な変数を表す
let result1;
try {
    result1 = hogehoge();
} catch (err) {
    // どの関数で例外が発生したか判別しやすくなった
    console.log("hogehogeで例外が発生!");
}

let result2;
try {
    result2 = fugafuga(result1);
} catch (err) {
    // どの関数で例外が発生したか判別しやすくなった
    console.log("fugafugaで例外が発生!");
}

let result3;
try {
    result3 = piyopiyo(result2);
} catch (err) {
    // どの関数で例外が発生したか判別しやすくなった
    console.log("piyopiyoで例外が発生!");
}

どの関数で例外が発生したか判別しやすくなりました。

しかし、たった1回の初期化のためだけに、変数を後から再代入可能な let で宣言してしまうと、思わぬ再代入を起こす余地を作ってしまいます。コードを書く人が「気をつけたら」 防げるかもしれませんが、こういうのは仕組みで防止したいです (JavaScriptの場合は const) 。

エラーハンドリング忘れを引き起こす

静的型付けの言語では、関数には戻り値の型が書かれています。

// 文字列を数値に変換する
function parseInt(text: string) : number {
    // ...なんかする
}

しかし、関数の型定義を見ただけでは、その関数がエラーを throw するかどうか(= 失敗する可能性があるかどうか)は分かりません[1]。関数の内側を覗かないといけません。動的型付け言語に至っては、もうサッパリです。

また、例外をthrowする可能性のある関数を try-catch で囲んでいなくても、コンパイラは警告してくれません。例外がcatchされないと、プログラムが終了してしまいます。

Rust のエラーハンドリング

基本文法: 変数束縛

エラーハンドリングの話をする前に、Rust の基本文法に軽く触れます。

Rust では、値と変数を結びつけるときは let キーワードを利用します。これを変数束縛といい、let で束縛された変数は不変であり、新しく値を束縛することはできません。TypeScript の const に相当します。

// a に 0 を束縛する
let a = 0;

// もう一度束縛することはできない
// a = 3;

let の後ろに mut キーワードをつけると、変数を可変にすることができます。

// b に 0 を束縛する
let mut b = 0;

// b に 0 の代わりに 99 を束縛する
b = 99;

それでは、エラーハンドリングについて説明します。

Result<T,E>

Rustでは、例外の代わりに、失敗するかもしれない処理 (=エラーが発生するかもしれない) の結果を、Result<T,E> 型で表現しています。

成功した時に返される値の型が T、失敗した時に返される値の型が E として表現されています。

enum Result<T, E> {
    Ok(T), // 成功を表す。Tには結果の型が入る
    Err(E), // エラーを表す。Eにエラーの型が入る
}

enum は列挙型と呼び、構成する要素のどちらかを値としてとります。

つまり、Result<T, E> 型は Ok(T) または Err(E) のいずれかを値として取ります。

// Result型の変数を作ってみる
fn main() {
    // Tは `i32`, Eは `String` のResult型の変数を定義
    // let 変数名 : 型; (mut をつけることで可変にする)
    let mut res: Result<i32, String>;

    // 処理が成功した場合は `Ok(結果の値)` で表現する
    // Ok(i32) は、Result<i32, String> の要素
    // 成功値として16を返す
    res = Ok(16);

    // 処理が失敗した場合は `Err(エラーの値)` で表現する
    // Err(String) は、Result<i32, String> の要素
    // エラー値として "数字が大きすぎて失敗しました" と返す
    res = Err(String::from("数字が大きすぎて失敗しました"));
}

Rustで失敗する可能性のある関数の戻り値は、Result 型が広く利用されているため、関数の戻り値を見るだけで失敗する可能性があるかどうかを判断できます

また、1回の関数呼び出しに1個の Result 型が戻り値として返されるので、どのエラーがどの関数から発生したものなのか簡単に分かります

match を使って成功/エラー状態によって処理を分ける

Ok(i32)Err(String)res という変数に代入ができます。

res の型はあくまで Result<i32, String> です。つまり res の値を利用する側からすると、現時点で Ok なのか Err なのかが分かりません。

つまり、resOk なのか Err なのかを必ず処理する必要があります。すなわち、処理が成功しているのか失敗しているのかを必ず判断する必要があります。エラーハンドリング忘れを防ぐことができるのです。

Ok または Err に応じて処理を分けるには、match 式を使います。他言語の switch 文に似ているものです。

fn main() {
    let res1: Result<i32, String> = Ok(32);

    // `match` 式により分岐する
    match res1 {
        // Ok(v) と書くことで、処理に成功した時の値に `v` という名前をつけて、
        // 後続の処理で利用することができる。
        Ok(v) => println!("処理結果: {v}"),

        // Err(e) と書くことで、処理に失敗した時のエラーの値に `e` という名前をつける
        Err(e) => println!("エラーが発生しました: {e}"),
    }
    // [出力]
    // 処理結果: 32 

    let res2: Result<i32, String> = Err("数字が大きすぎます".to_string());
    match res2 {
        Ok(v) => println!("処理結果: {v}"),
        Err(e) => println!("エラーが発生しました: {e}"),
    }
    // [出力]
    // エラーが発生しました: 数字が大きすぎます
}

match では OkErr によって処理を分けていますが、どちらかを忘れると以下のようにコンパイラが怒ってくれます。エラーハンドリング忘れを防げていいですね。

fn main() {
    let res1: Result<i32, String> = Ok(32);

    match res1 {
        Ok(v) => println!("処理結果: {v}"),
        // Err の部分の処理をコメントアウトする
        // Err(e) => println!("エラーが発生しました: {e}")
    }
}

// コンパイルエラー!!
// error[E0004]: non-exhaustive patterns: `Err(_)` not covered
//   --> src/main.rs:7:11
//     |
// 7   |     match res1 {
//     |           ^^^^ pattern `Err(_)` not covered
//     |

実例: 文字列から整数への変換

失敗するかもしれない処理の1つとして文字列から整数への変換があります。文字列から数値への変換は、parse() メソッドを使います。

use std::num::ParseIntError;

fn main() {
    let valid_str = "32";
    let res1: Result<i32, ParseIntError> = valid_str.parse();

    let invalid_str = "32a";
    let res2: Result<i32, ParseIntError> = invalid_str.parse();

    // res1, res2 の値を利用するには、必ず成功しているかどうかをチェックする必要がある
    match res1 {
        Ok(n) => println!("valid number: {}", n),
        Err(e) => println!("invalid number: {:?}", e),
    }
    match res2 {
        Ok(n) => println!("valid number: {}", n),
        Err(e) => println!("invalid number: {:?}", e),
    }

    // [出力]
    // valid number: 32
    // invalid number: ParseIntError { kind: InvalidDigit }
}

エラーハンドリングを簡潔にする構文

match によるエラーハンドリングは素晴らしい機能ですが、どちらかの場合にのみ処理したい時や、ただ成功時の値を取り出したいだけの時などは、少し冗長に感じてしまいます。Rust では、このような状況に応じて if-let 式や let-else が用意されています[2]

if-let 式

成功した場合 (もしくはエラーが発生した場合) のみ処理をしたい場合は if-let 式が利用できます。

let sample_str = "...";
let res1: Result<i32, ParseIntError> = sample_str.parse();

if let Ok(n) = res1 {
    println!("valid number: {}", n);
}

let-else

early-return に最適な構文です。成功時の値を、ネストを深くせずに取得しつつ、エラー処理ができる優秀なやつです。

let sample_str = "...";
let res: Result<i32, ParseIntError> = sample_str.parse();

// `res` の成功時は、成功時の値を `num` という変数に束縛し、
// エラー時は else { ... } 内を実行する。{ } 内で return (または panic) が必須 (忘れるとコンパイルエラー)
let Ok(num) = res else {
    println!("invalid number!!"),
    return;
}

// ネストを深くせずとも `num` という名前で成功時の値を取得できる
println!("valid number: {}", num)

さいごに

Result 型の素晴らしさについて紹介しました。Rust にはこんな機能があるんだなあと心の片隅に置いて、日々コードを書いていただけると幸いです。

TypeSript なら Union で Reslt っぽいのを実装できるので、試してみるといいかもしれません。

参考

脚注
  1. Javaの場合は、クラスのメソッドに例外を throw する可能性があることを示すよう強制される場合があります。詳しくはチェック例外 (Checked Exception)で調べてみてください。 ↩︎

  2. if-let や let-else は Result 型に限った構文ではありませんが、今回は Result 型 に絞った話をします。 ↩︎

Discussion