62

投稿日

更新日

Rustのlet-else文気持ち良すぎだろ

先日、Rust バージョン1.65.0が利用できるようになりました :cracker:

その中でも個人的に最も嬉しい機能追加がlet-else文になります!

let-else文の見た目

let-else文はこんな見た目をしています。

let-else文
// let 論駁可能パターン = 値 else { never型を返す処理 };
let Ok(val) = reqwest::get(url).await else { return };

このコードの意味としてはreqwest::get(url).awaitOk(結果)を返してきたらvalに束縛し、ダメだったら関数を抜ける、になります。

if-let

let-else文の詳細を説明する前に、まずはRustのif-let式について説明いたします。

Rustは式指向言語のためifも標準で式になっています。よく他言語では三項演算子使用で宗教戦争が起きていますが「if"式"があれば争いなんて起きないのに...(トオイメ」といつも思っています。

Rust
fn main() {
    let arg = std::env::args().nth(1).unwrap_or("bar".to_string());

    let val = if &arg == "bar" {
        "hoge".to_string()
    } else {
        "fuga".to_string()
    };

    println!("{}", val);
}

それぞれのブロックの最後の式が返す値として評価され、valに束縛されます。三項演算子のように使う場合は、真のときに返す型と偽のときに返す型が一致している必要があります。ただし、! (never)型を除きます。(後述)

そしてRust独特なif式にif-let式というものがあります。Rustのletにはパターンマッチの機能があり、パターンに合う時を真とするのがif-let式になります。以下に示す例1では、parseメソッドが返すResult型変数がOk(i32型)である時にnにパース結果を束縛してそれを返り値とし、valに束縛しています。合致しない場合はreturnしています。

例1: Result

例1
fn main() {
    let arg = std::env::args().nth(1).unwrap_or("NaN".to_string());

    let val = if let Ok(n) = arg.parse::<i32>() {
        n
    } else {
        println!("Caution!: {} is not a number, please pass a number.", arg);
        return;
    };

    println!("{}^2 = {}", val, val * val);
}

もう少し正確に説明すると、普通のlet文は論駁不可能パターン(必ず成功する束縛)のみ取り、if-let式は論駁可能パターンも束縛可能とする機能です。簡単にいえば、letにはもともと構造体やタプルを展開して束縛する機能、いわゆる分割代入があり、列挙体等複数可能性があるパターン(これを論駁可能なパターンといいます)についてはif-let式で受け取れるようにした、という感じでしょうか。そしてmatch式は論駁可能パターンに対して複数のパターンマッチを行い分岐するif-let式の強化版になっています。

例2: 構造体の分割代入

例2
#[derive(Clone, Copy)]
struct Point {
    x: u32,
    y: u32,
}

fn main() {
    let point = Point { x: 10, y: 20 };

    // こんな風に分割代入できる!
    let Point { x, y } = point;

    println!("x = {}, y = {}", x, y);

    // let Point { x: 0, y } = point;
    /* refutable pattern in local binding: `Point { x: 1_u32..=u32::MAX, .. }` not covered
       `let` bindings require an "irrefutable pattern", ...
       と怒られる
    */
    // そんな時はif-letやmatchの出番
    if let Point { x: 0, y } = point {
        println!("x = 0, y = {}", y);
    }
}

上記例ではif-let式の返り値を使わないためelse節を省略できます。

Result型やOption型で使われることが多いif-letですが、先に出てきた構造体のように、それ以外の論駁可能パターンにも使えます。

例3: 任意の列挙型

例3
enum MyState {
    Samumi,
    Nemumi,
    Other(String),
}

fn main() {
    let my_state = MyState::Other("Hello".to_string());

    let state_inner = if let MyState::Other(state_inner) = my_state {
        state_inner
    } else {
        println!("Your state is Samumi or Nemumi.");
        return;
    };

    println!("Your state is Other({})", state_inner);
}

ただしMyStateのように3項目以上ある列挙体では見通しが良いmatch式を使うべきでしょう。

例3'
let state_inner = match my_state {
    MyState::Other(state_inner) => state_inner,
    MyState::Samumi | MyState::Nemumi => { // `_ => {` でも可
        println!("Your state is Samumi or Nemumi.");
        return;
    }
};

never型 (ifelse節の返り値について)

ついでにここで! (never)型の説明もしておきます。(使うので)

通常、ifif-letを"式"として使う場合は、真の時の値と偽の時の値の型は一致する必要がありました。

しかし、他言語でも普通に考えられる「if文中にreturncontinue、あるいはthrow Error(panic!を指すとします)する」ケースではどうすれば良いでしょう?例1や例3がまさしくこのケースになっていますね。

実はこれらの処理構文はRustにおいては! (never)型を返すものとして扱われ、never型はif式やmatch式の型推論においてはその他の枝の型に型強制される特殊な型になっています。

returncontinue! (never)型を返す(ものと仮定されている)ので、その上で型推論が行われコンパイルエラーにはならないのです。こじつけ感が強い

ではlet-else文とは?

前置きが長くなってしまいましたがようやくメインディッシュです。

ずばり、「論駁可能パターン用のlet文」みたいな位置づけにあるのがlet-else文になります!パターンにマッチする時はそのまま分割代入され、マッチしない時はelse節の内容が実行されます。

let-else文
let 論駁可能パターン =  else { never型を返す処理 };

例3のif-let式を見てみてください。そしてstate_innerの数を数えてみましょう。

let state_inner = if let MyState::Other(state_inner) = my_state {
    state_inner
} else {
    println!("Your state is Samumi or Nemumi.");
    return;
};

3ですね。3回もstate_innerと書いています。Otherの中身を取り出したいだけなのに何回もstate_innerを書いてます。このif-let式の後にstate_innerを使ったメインの処理が連なることは明白です。この行で主張したいのはelse節のほうのみです。

let-else文を使えばこのような悩みから完全に開放されます!

let MyState::Other(state_inner) = my_state else {
    println!("Your state is Samumi or Nemumi.");
    return;
};

気持ち良すぎだろ!

これはセマンティクス的にもかなり意味が変わっておりより良い書き方になっています。

  • if-let式: 真の時の枝と偽の時の枝は対等であり、どちらのケースも同じぐらいの重要度を持つ
  • let-else文: 真の時の枝こそが通常時処理であり、else節の処理は異常系であり普通は起きない

if-letが必要なシーンではlet-elseのほうが最適なシーンがしばしばありそうです。とてもありがたい構文が追加されました。

注意点: else節はnever型

let-elseelse節では先程説明したnever型を最後に返すようにしなければなりません。つまり、returncontinuepanic!マクロ等最後に発散する式で締める必要があります。

「じゃあデフォルト値みたいなのを返したい時は?」という順当な疑問が湧くかと思いますが、その時は

  • ResultOption型: unwrap_orを使う
  • そのほかの列挙型: if-let式やmatch式を使う

といった感じで従来どおりに対応しましょう。

let-else文のユースケース

実は例1は?演算子(try!マクロ)とanyhow::Resultあたりを使って次のように書くのが定石です。

例1'
fn main() -> anyhow::Result<()> {
    let arg = std::env::args().nth(1).unwrap_or("NaN".to_string());
    let val = arg.parse::<i32>()?;

    println!("{}^2 = {}", val, val * val);

    Ok(())
}

Result型、Option型については、「?(tryマクロ)によってそもそもif-letmatchを使わないで書ける際は?を使う」のが慣例なのです。

もしエラーが発生した際になにか処理を挟みたい、という場合でも、次のようにmap_err等で行うことが可能です。

例1''
let val = arg.parse::<i32>().map_err(|e| {
    println!("エラー時処理");
    e
})?;

map_errで引数を_にしないで適切に処理すれば例外を握りつぶすことなくエラーハンドリングができます。しかしlet-else文ではこのような器用なことは不可能です。

同様に考えると、ResultでもOptionでもない列挙体を扱う際はmatchを使ったほうが見通しが立つシーンが多いでしょう。

おや...?もしかしてlet-else文要らない...?むしろ let-else乱用がアンチパターンになる...?

"正常な"異常を返すときには使えそう (例: 404 NOT FOUND等)

逆に言えばアンチパターンにならなそうなシーンでは使用して良いと思います。「正常な異常」ってなんだよってツッコミが来そうですが、Webサーバーの404のような、要は「原因となる例外を握りつぶして良い、システム側で想定済みのエラー」を表現する際にlet-else文は見通しを良くしてくれそうです。

Rust
struct User {
    name: String,
    access_count: u64,
}

type UserDict = Arc<Mutex<HashMap<String, User>>>;

async fn search_user(
    Path(user_id): Path<String>,
    user_dict: Extension<UserDict>,
) -> Result<String, StatusCode> {
    let mut user_dict = user_dict.0.lock().unwrap();

    let Some(user) = user_dict.get_mut(&user_id) else {
        // 見つからないというサーバーにとっては正常な異常
        return Err(StatusCode::NOT_FOUND);
    };

    // 正常時処理
    user.access_count += 1;
    Ok(format!(
        "{} has been accessed {} times",
        user.name, user.access_count
    ))
}

このハンドラはaxumクレートを想定したものです。(ソースコード全文は折りたたんでおきます。)

axumコード全文
Rust
use axum::{extract::Path, http::StatusCode, routing::get, Extension, Router};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

struct User {
    name: String,
    access_count: u64,
}

type UserDict = Arc<Mutex<HashMap<String, User>>>;

async fn search_user(
    Path(user_id): Path<String>,
    user_dict: Extension<UserDict>,
) -> Result<String, StatusCode> {
    let mut user_dict = user_dict.0.lock().unwrap();

    let Some(user) = user_dict.get_mut(&user_id) else {
        return Err(StatusCode::NOT_FOUND);
    };

    user.access_count += 1;
    Ok(format!(
        "{} has been accessed {} times",
        user.name, user.access_count
    ))
}

#[tokio::main]
async fn main() {
    let dict: UserDict = Arc::new(Mutex::new(
        vec![
            (
                "user1".to_string(),
                User {
                    name: "Alice".to_string(),
                    access_count: 0,
                },
            ),
            (
                "user2".to_string(),
                User {
                    name: "Bob".to_string(),
                    access_count: 0,
                },
            ),
        ]
        .into_iter()
        .collect(),
    ));

    let app = Router::new()
        .route("/users/:user_id", get(search_user))
        .layer(Extension(dict));

    axum::Server::bind(&"0.0.0.0:8000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

この処理は従来ならok_orok_or_else?演算子を使って次のように簡潔に書けます。

Rust
let user = user_dict.get_mut(&user_id).ok_or(StatusCode::NOT_FOUND)?;

OptionではなくてResultの場合でも.map_err(|_| ...)やthiserrorクレートを使用しやはり?で事足りてしまうシーンでしょう。

しかしながらこの「正常な異常なのでContextやバックトレース等を握り潰してよい」ケースでは、?は単純にreturnよりも見づらく(5文字少ないですからね)、もしかしたらlet-elseを使って素直に書いたほうが可読性向上につながり良いかもしれません。

...うーん、宗教!(ぜひコメント下さい)

誰もが納得するユースケースを考えてみたいところです。

for文のcontinueで欲しい

先述の通り、Result型やOption型が返せるシーンでは?とこれらの型が持つメソッドが有能すぎて、せっかく追加されたlet-else文は立場がないシーンが多いでしょう。

しかし、for文のcontinueは別です。returnしてしまう?では対応できません。let-else君の就職先あった!やったね!

例4
fn main() {
    let maybe_numbers = vec!["0", "1", "2", "fizz", "4", "buzz"];

    for maybe_number in maybe_numbers {
        let Ok(n) = maybe_number.parse::<i32>() else {
            continue;
        };

        println!("{}^2 = {}", n, n * n);
    }
}

...ん?なんですか?あなたは?はい?関数型言語界隈の方?

例4'
fn main() {
    let maybe_numbers = vec!["0", "1", "2", "fizz", "4", "buzz"];
    let pow2numbers: Vec<_> = maybe_numbers
        .into_iter()
        .filter_map(|maybe_number| maybe_number.parse::<i32>().ok())
        .map(|n| n * n)
        .collect();

    println!("result: {:?}", pow2numbers);
}

...あれ?let-else君?!どこいったんだ!let-else君!!!!!!!!

...
...

茶番をしましたが、for文を忌避する厨二病なRustaceanの皆様におかれましては、滅多なことではfor文は使いませんよね。失念しておりました。

非同期におけるfor文のcontinueで欲しい

関数型大好きRustaceanの皆様を黙らせる納得させる処理に非同期におけるforがあります。awaitmapfor_eachとはとても(少なくとも本記事では説明を省略させていただきたいぐらい)相性が悪い1ので、非同期においてはforを使うほうが多いと思います。

このようなシーンでcontinueしたい場合に、let-else文は真価を発揮するのではないでしょうか?!

例5
use anyhow::Result;

#[tokio::main]
async fn main() -> Result<()> {
    let sites = [
        "https://google.com",
        "http://localhost:8000",
        "https://yahoo.co.jp",
    ];

    for site in sites.iter() {
        let Ok(res) = reqwest::get(*site).await else { continue };
        println!("{}: {}", site, res.status());

        let Ok(body) = res.text().await else { continue };
        println!("body len: {}", body.len());
    }

    Ok(())
}

筆者は書きたくないので書きませんがforを使わないで書けた方がいらっしゃいましたらどちらが良いか感想いただければと思います。

ユースケースまとめ

他にも色々考えられると思いますが、とりあえず自分の中ではlet-elseが使えるシーンは次の3点ぐらいかなと考えています。

  • ガード節にて"正常な"異常を返す時
    • 言い換えると、原因となるエラーが不要で握りつぶしていい時
    • ?よりも可読性向上の可能性があります
  • forcontinueしたい時
    • 特にforを使う必要がある非同期
  • そのほか横着したい時 (下手するとアンチパターン)
    • 関数の返り値型をResultにしたくなくて、エラー代わりのデフォルト値を返す時
      • ...Result型を検討したほうがいい気がします
    • テストでパニックさせる際に別でなにかやらせたい時

使い所を思いついた皆様はlet-else君のためにもぜひコメントを頂けると幸いです。

まとめ・所感

let-else 文が最も求めていた答えであったであろう Rustで「あ、やっぱいいです」したい - Qiita という記事を書いたのですが、時間が経つにつれやはり関数の返り値をResult型にし?を使えるようにするのが良いのでは?と考えるようになっていました。それでもやはり?よりも読みやすくなるケースや、for文のcontinueみたいなResultが使えないケースが考えられるので、そういうシーンで真価を発揮させるだろうと、今回の記事を書かせていただきました。

最後のユースケースまとめではあまり芳しくないlet-else文ですが、個人的にはwhile-letに次いで好きな構文になっています。

ここまで読んでいただき誠にありがとうございました!

引用・参考

  1. https://qiita.com/legokichi/items/4f2c09330f90626600a6 などを見てほしいです。Futurecollectしすべて完了するのを待つみたいな書き方は可能でしょうが、、、高速化させたい等のシーンでないならば素直にループごとにawaitするように書くほうが"早"そうです。

新規登録して、もっと便利にQiitaを使ってみよう

  1. あなたにマッチした記事をお届けします
  2. 便利な情報をあとで効率的に読み返せます
ログインすると使える機能について
62