[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 を書かないと、コンパイルが通りません。
| Java | Rust | |
|---|---|---|
| 失敗の知らせ方 | 例外を投げる(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回)
- Rust とは / 環境構築 / Hello World
- 変数・型・関数(Java との対比)
- 所有権(ムーブ)
- 借用と参照
- 構造体・enum・match
- Result / Option とエラー処理(今回)
- トレイトとジェネリクス(Java の interface との違い)
- ライフタイム入門と、総まとめの CLI(ファイル一覧ツール)