[Rust 入門] #06 Result と Option — 失敗を型で扱う方法を動かして確かめる

[Rust 入門] #06 Result と Option — 失敗を型で扱う方法を動かして確かめる

はじめに

ここまでの連載に、説明を後回しにした書き方が3つありました。

出てきた回書き方起きたこと
第2回"42".parse().unwrap()unwrap の意味を、第6回に回した
第2回i32::MAX.checked_add(1)桁あふれで None が返った
第5回list[5]範囲外で、実行時に止まった

今回は、この3つを回収します。話題は、失敗や「ない」を、Rust がどう表すかです。

Option: 「ないかもしれない」

checked_add の結果を、match で調べます。

fn main() {
    let a = i32::MAX.checked_add(1);
    let b = 1_i32.checked_add(1);
    println!("{:?} {:?}", a, b);
    match a {
        Some(n) => println!("結果 {n}"),
        None => println!("桁あふれ"),
    }
    match b {
        Some(n) => println!("結果 {n}"),
        None => println!("桁あふれ"),
    }
}
None Some(2)
桁あふれ
結果 2

checked_add が返すのは、整数そのものではなく、Option という型です。

値意味
Some(2)値がある。中身は 2
None値がない(ここでは、桁あふれで計算できなかった)

Vec の get も、同じです。第5回の list[5] は、範囲外で止まりましたが、get(5) は止まらずに、None を返します。

let v = vec![10, 20, 30];
println!("{:?}", v.get(1));
println!("{:?}", v.get(5));
match v.get(5) {
    Some(n) => println!("あった {n}"),
    None => println!("なかった"),
}
Some(20)
None
なかった

Option は、ただの enum

Option は、第5回の enum です。同じ形を、自分で作って動かします。

enum MyOption {
    None,
    Some(i32),
}

fn half(n: i32) -> MyOption {
    if n % 2 == 0 {
        MyOption::Some(n / 2)
    } else {
        MyOption::None
    }
}

fn main() {
    for n in [8, 7] {
        match half(n) {
            MyOption::Some(h) => println!("{n} の半分は {h}"),
            MyOption::None => println!("{n} は割り切れない"),
        }
    }
}
8 の半分は 4
7 は割り切れない

標準の Option は、i32 に限らず、どんな型の値でも入れられるようになっています(これは、第7回のジェネリクスで扱います)。使い方は、MyOption と同じです。第5回で確認したとおり、None の場合を match に書き忘れると、E0004 で止まります。

中身を取り出さないと、使えない

Option のままでは、中身の整数として、計算できません。

let v = vec![10, 20, 30];
let n = v.get(0);
println!("{}", n + 1);
error[E0369]: cannot add `{integer}` to `Option<&{integer}>`
 --> a3.rs:4:22
  |
4 |     println!("{}", n + 1);
  |                    - ^ - {integer}
  |                    |
  |                    Option<&{integer}>

「あるかもしれない値」は、そのままでは使えません。match で、ない場合を書いて取り出すか、あとの節の unwrap などで取り出します。

Java の null と並べる

Java では、Map から、存在しないキーで取り出すと、null が返ります。

Map<String, String> m = new HashMap<>();
m.put("a", "apple");
String s = m.get("x");
System.out.println(s);
System.out.println(s.length());
null
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local2>" is null
	at J2.main(J2.java:8)

1行目の s は、String 型ですが、null が入っています。型の上では、他の String と見分けが付かないので、s.length() はコンパイルが通り、実行時に止まります。

Rust では、「ないかもしれない値」は Option 型です。Option のままでは、上の n + 1 のように、そのまま使えません。取り出す時点で、None の場合を書くことになります。

Java にも、Optional があります。ただし、空の Optional に get() を呼ぶと、実行時に止まります。

Exception in thread "main" java.util.NoSuchElementException: No value present
	at java.base/java.util.Optional.get(Optional.java:143)

Result: 「失敗するかもしれない」

文字列を数に直す parse は、失敗することがあります。その結果は、Result です。

fn main() {
    let ok = "42".parse::<i32>();
    let ng = "abc".parse::<i32>();
    println!("{:?}", ok);
    println!("{:?}", ng);
    match ng {
        Ok(n) => println!("数 {n}"),
        Err(e) => println!("失敗: {e}"),
    }
    let empty = "".parse::<i32>();
    println!("{:?}", empty);
    let big = "99999999999".parse::<i32>();
    println!("{:?}", big);
}
Ok(42)
Err(ParseIntError { kind: InvalidDigit })
失敗: invalid digit found in string
Err(ParseIntError { kind: Empty })
Err(ParseIntError { kind: PosOverflow })
値意味
Ok(42)成功。中身は 42
Err(...)失敗。中身は、失敗の理由(ここでは ParseIntError)

Option は「ない」だけでしたが、Result の Err は、理由を持ちます。kind: InvalidDigit(数字でない文字)、Empty(空)、PosOverflow(大きすぎる)と、理由が区別されています。{e} と書くと、人が読む形の説明が出ます。

Result も enum で、match で場合分けします。Option と同じ使い方です。

parse::<i32>() の ::<i32> は、「i32 に直す」という指定です。let n: i32 = ...parse() のように、変数の型で指定することもできます。

結果を、使わずに捨てると

Result を受け取らずに、捨てるとどうなるでしょうか。

fn main() {
    "12".parse::<i32>();
    println!("終わり");
}
warning: unused `Result` that must be used
 --> a6.rs:2:5
  |
2 |     "12".parse::<i32>();
  |     ^^^^^^^^^^^^^^^^^^^
  |
  = note: this `Result` may be an `Err` variant, which should be handled

エラーではなく警告で、プログラムは動きました。失敗を無視しても、気づけるようになっています。

Java の例外と並べる

Java の Integer.parseInt は、失敗すると、例外を投げます。

int n = Integer.parseInt("abc");
Exception in thread "main" java.lang.NumberFormatException: For input string: "abc"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)

NumberFormatException は、try で囲まなくても、コンパイルは通ります。

ファイルを読むときは、様子が違います。

String t = Files.readString(Path.of("list.txt"));
J3.java:4: error: unreported exception IOException; must be caught or declared to be thrown

こちらは IOException(検査例外)なので、try か throws を書かないと、コンパイルが通りません。

JavaRust
失敗の知らせ方例外を投げる(parseInt など)Result を返す
書き忘れの検査検査例外だけコンパイルで検査される値を使う時点で、Err の場合を書くことになる。捨てると警告
失敗の場所呼び出しの型には、現れない(検査例外以外)戻り値の型 Result<..> に、現れる

unwrap と expect

第2回の .parse().unwrap() は、Result の中身を取り出す書き方です。成功なら、中身が出ます。失敗なら、プログラムが止まります(パニック)。

fn main() {
    let n: i32 = "abc".parse().unwrap();
    println!("{n}");
}
thread 'main' panicked at a5.rs:2:32:
called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }

終了コードは 101(第4回・第5回の、実行時に止まる例と同じ)でした。Option の unwrap も、None で止まります。

let v: Vec<i32> = Vec::new();
let n = v.first().unwrap();
thread 'main' panicked at a5c.rs:3:23:
called `Option::unwrap()` on a `None` value

止まるときのメッセージを、自分で付けるのが expect です。

let n: i32 = "abc".parse().expect("数を入れてください");
thread 'main' panicked at a5b.rs:2:32:
数を入れてください: ParseIntError { kind: InvalidDigit }

止めずに、失敗のときの値を決めておく書き方もあります。unwrap_or です。1つだけ調べるなら、第5回の if let も使えます。

let a = "abc".parse::<i32>().unwrap_or(0);
let b = "7".parse::<i32>().unwrap_or(0);
println!("{a} {b}");
if let Ok(n) = "15".parse::<i32>() {
    println!("数 {n}");
}
0 7
数 15
書き方失敗のとき
unwrap()パニックで止まる
expect("説明")説明つきで、パニックで止まる
unwrap_or(0)指定した値(ここでは 0)になる
match / if let失敗の場合を、自分で書く

? 演算子: エラーを呼び出し元に渡す

失敗を、自分で処理せずに、呼び出した側に任せたいときは、? を付けます。

use std::num::ParseIntError;

fn add_str(a: &str, b: &str) -> Result<i32, ParseIntError> {
    let x = a.parse::<i32>()?;
    let y = b.parse::<i32>()?;
    Ok(x + y)
}

fn main() {
    println!("{:?}", add_str("1", "2"));
    println!("{:?}", add_str("1", "x"));
    println!("{:?}", add_str("y", "x"));
}
Ok(3)
Err(ParseIntError { kind: InvalidDigit })
Err(ParseIntError { kind: InvalidDigit })

? は、次の動きをします。

  • Ok(中身) なら、中身を取り出して、先へ進む
  • Err なら、その場で関数を終えて、その Err を、関数の戻り値にする

3行目の add_str("y", "x") は、"y" の時点で関数が終わっています。"x" の parse は、実行されていません。

Option を返す関数でも、? は使えます。None なら、その場で None を返します。

fn first_upper(s: &str) -> Option<char> {
    let c = s.chars().next()?;
    Some(c.to_ascii_uppercase())
}
Some('R')
None

(first_upper("rust") と first_upper("") の結果です)

? を使える場所

? は、Result か Option を返す関数の中でしか、使えません。main が何も返さないままだと、エラーです。

fn main() {
    let n: i32 = "12".parse()?;
    println!("{n}");
}
error[E0277]: the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
 --> a9.rs:2:30
  |
1 | fn main() {
  | --------- this function should return `Result` or `Option` to accept `?`
2 |     let n: i32 = "12".parse()?;
  |                              ^ cannot use the `?` operator in a function that returns `()`

main が Result を返すようにすれば、使えます。

use std::num::ParseIntError;

fn main() -> Result<(), ParseIntError> {
    let n: i32 = "12".parse()?;
    println!("n = {n}");
    let m: i32 = "abc".parse()?;
    println!("m = {m}");
    Ok(())
}
n = 12
Error: ParseIntError { kind: InvalidDigit }

Err が main から返ると、Error: ... と表示して、終了コード 1 で終わります。m = の行は、実行されていません。成功したときは、最後に Ok(()) を返します(() は、第2回の「何もない値」です)。

エラーの型が合わないとき

? を使う関数の戻り値の、Err の型が、元のエラーと違うと、エラーです。

fn parse_qty(s: &str) -> Result<u32, String> {
    let n = s.parse::<u32>()?;
    Ok(n)
}
error[E0277]: `?` couldn't convert the error to `String`
 --> a12.rs:2:29
  |
1 | fn parse_qty(s: &str) -> Result<u32, String> {
  |                          ------------------- expected `String` because of this
2 |     let n = s.parse::<u32>()?;
  |               --------------^ the trait `From<ParseIntError>` is not implemented for `String`
  |
  = note: the question mark operation (`?`) implicitly performs a conversion on the error value using the `From` trait

? は、エラーを、関数の戻り値のエラー型に変換しようとします。ParseIntError から String への変換が、用意されていないので、止まりました。この変換の仕組み(From トレイト)は、第7回で扱います。

この記事では、match で自分で変換して、Err(String) を返す形にします。

fn parse_qty(s: &str) -> Result<u32, String> {
    match s.parse::<u32>() {
        Ok(n) if n > 0 => Ok(n),
        Ok(_) => Err(String::from("0 は指定できません")),
        Err(_) => Err(format!("「{s}」は数ではありません")),
    }
}
Ok(3)
Err("0 は指定できません")
Err("「abc」は数ではありません")
Err("「-1」は数ではありません")

("3"、"0"、"abc"、"-1" を順に渡した結果です)Ok(n) if n > 0 は、第5回のガードです。"-1" は、parse::<u32> の段階で、失敗しました。

サンプル: 買い物リストを、ファイルから読む

第5回の買い物リストを、ファイルから読み込む形にします。1行が 名前, 個数 で、書き間違いのある行は、行番号つきで報告して、読み飛ばします。

cargo new shop4
cd shop4

list.txt を、Cargo.toml と同じ場所に作ります。

りんご, 3
みかん
お茶, abc
電池, 0
ぶどう, 2

src/main.rs を、次のようにします。

use std::fs;

struct Item {
    name: String,
    qty: u32,
}

fn parse_qty(s: &str) -> Result<u32, String> {
    match s.parse::<u32>() {
        Ok(n) if n > 0 => Ok(n),
        Ok(_) => Err(String::from("個数は 1 以上にしてください")),
        Err(_) => Err(format!("「{s}」は数ではありません")),
    }
}

fn parse_line(line: &str) -> Result<Item, String> {
    let (name, qty) = match line.split_once(',') {
        Some(pair) => pair,
        None => return Err(String::from("「,」がありません")),
    };
    let qty = parse_qty(qty.trim())?;
    Ok(Item {
        name: String::from(name.trim()),
        qty,
    })
}

fn main() {
    let text = match fs::read_to_string("list.txt") {
        Ok(t) => t,
        Err(e) => {
            println!("list.txt を読めません: {e}");
            return;
        }
    };

    let mut items = Vec::new();
    for (i, line) in text.lines().enumerate() {
        match parse_line(line) {
            Ok(item) => items.push(item),
            Err(msg) => println!("{} 行目: {msg}", i + 1),
        }
    }

    for item in &items {
        println!("{} × {}", item.name, item.qty);
    }
    println!("{} 品目", items.len());
}
cargo run
2 行目: 「,」がありません
3 行目: 「abc」は数ではありません
4 行目: 個数は 1 以上にしてください
りんご × 3
ぶどう × 2
2 品目

初めて出てきた書き方は、次のとおりです。

書き方意味
use std::fs;標準ライブラリの fs(ファイル操作)を、fs:: で使えるようにする
fs::read_to_string("list.txt")ファイルを、文字列として読む。結果は Result(失敗の理由は、入出力のエラー)
line.split_once(',')最初の , で2つに分ける。, がなければ None(Option)
let (name, qty) = ...2つの値の組を、2つの変数に分けて受け取る
return Err(...)その場で、関数を終える
text.lines().enumerate()行を1つずつ、行番号(0 から)つきで取り出す
qty.trim()前後の空白を、取り除く

parse_line の中の parse_qty(...)? は、Err(String) を返す関数から、同じ Err(String) を受け取っているので、型が合い、? が使えます。

list.txt がないときも、止まらずに、理由を表示して終わります。

list.txt を読めません: No such file or directory (os error 2)

Java で、同じ失敗を try で受けると、次のようになります。

try {
    String t = Files.readString(Path.of("list.txt"));
    System.out.println(t);
} catch (IOException e) {
    System.out.println("failed: " + e);
}
failed: java.nio.file.NoSuchFileException: list.txt

cargo clippy と cargo fmt --check は、指摘なしで通りました。

歴史

年できごと
1960年代半ばTony Hoare が、null 参照を導入した。InfoQ のページの要約は 1965 年、同ページの講演メモは 1964 年と書いている
2009年Hoare が、「Null References: The Billion Dollar Mistake」と題して講演し、null の導入を「billion-dollar mistake」と呼んだ(InfoQ のページの掲載日は 2009-08-25)

InfoQ のページの要約には、null を導入した理由は、「実装が簡単だったから」とあります。講演メモには、null を、あらゆる型の値になれるものとして入れたため、参照を使うたびに null の確認が必要になった、という趣旨の話もあります。

Rust の Wikipedia の記事は、Option<T> を「値があるかもしれない」ことを表す型として説明し、& と &mut の参照は、null にならず、有効なメモリを指すことが保証される、と書いています。

動作確認

確認できたこと

  • 上のコードと出力は、すべて、Linux(Rust 1.97.0、Cargo 1.97.0、edition 2024)で、実際に実行した結果です
  • Java のコードは、同じ環境の Java(OpenJDK 21)で、実際に実行した結果です
  • checked_add・get・parse が、Option / Result を返し、match で場合分けできること
  • 自作の MyOption(i32 専用)が、Option と同じ形で使えること
  • Option を、取り出さずに足し算すると、E0369 になること
  • parse の失敗の理由が、InvalidDigit・Empty・PosOverflow と区別されること
  • Result を捨てると、警告(unused_must_use)になり、実行はできること
  • unwrap・expect が、Err と None で止まり、終了コードが 101 になること。unwrap_or・if let の動き
  • ? が、Err / None のとき、その場で関数を終えること。main から Err が返ると、Error: ... と表示し、終了コード 1 で終わること
  • ? を、戻り値が () の関数で使うと E0277、エラーの型が違うと E0277(From が見つからない)になること
  • 買い物リストのサンプルが、cargo run で動くこと。list.txt がないときの表示。cargo clippy と cargo fmt --check が、指摘なしで通ること
  • Java で、Map.get が null を返して NullPointerException になること、Optional.get() が NoSuchElementException になること、Integer.parseInt が NumberFormatException になること、Files.readString を try なしで書くとコンパイルが通らないこと

歴史の出典

  • null 参照の導入、「billion-dollar mistake」という呼び方、導入の理由は、InfoQ の「Null References: The Billion Dollar Mistake」のページ(要約と講演メモ)の記述です。取得ツールが返した要約を通して確認しました。導入の年は、ページの中で 1965 年と 1964 年の両方が書かれていて、1つには決められませんでした
  • 講演のイベント名は、ページの中で、QCon London 2009 と QCon San Francisco 2009 の両方に読める記述があり、確認できていません
  • Option<T> と、参照が null にならないことは、Wikipedia の「Rust (programming language)」の記述です。1つの出典で、取得ツールが返した要約を通して確認しました

確認できていないこと

  • Rust が null を持たないことの、言語の仕様としての根拠。この記事では、Option の使い方と、Wikipedia の記述だけを書いています
  • 標準の Option と Result の定義そのもの。自作の MyOption を動かして、同じ形で使えることだけを確認しました
  • 「失敗を値で返す」と「例外を投げる」のどちらがよいか。この記事では、動きの違いだけを並べています
  • Windows と macOS での実行結果。特に、list.txt がないときのエラーメッセージ(No such file or directory (os error 2))は、Linux の文言です

次回

次回は、トレイトとジェネリクスです。今回は、次のものを、説明せずに使いました。

  • Option<T> の T(どんな型の値でも入れられる仕組み)
  • ? の裏で使われる From
  • #[derive(Debug)] の Debug

これらが、トレイトとジェネリクスの話です。Java の interface との違いも、並べて見ます。

連載の目次(予定)

入門編(8回)

  1. Rust とは / 環境構築 / Hello World
  2. 変数・型・関数(Java との対比)
  3. 所有権(ムーブ)
  4. 借用と参照
  5. 構造体・enum・match
  6. Result / Option とエラー処理(今回)
  7. トレイトとジェネリクス(Java の interface との違い)
  8. ライフタイム入門と、総まとめの CLI(ファイル一覧ツール)