【Rust RFC 3058: Try Trait v2】?演算子(Question Operator)の新しい挙動についてのまとめ

この記事では現在RustのRFC3058として提案されている,Try Trait v2について解説していきます.

とはいっても私はまだRustを学習している段階で,RFCの背景や実際になされている議論などには踏み込んだ説明をすることはできませんのでそこはご了承ください. もっと詳しく知りたいという方はRFCのページを見ると全て書いてあります.

rust-lang.github.io

このRFCは今も議論が続けられているのでもしかしたら大幅に変わる可能性もあります.ご了承ください.

また,v2という名前から察する人もいるかもしれませんが,同じ目的のv1もありました(今はv2に置き換えられています). rust-lang.github.io そちらについては書かないのか,と思う方もいるかもしれませんが,機能の説明にあたってはv1との差分をそこまで気にする必要はなさそうだと判断したため省いています. 実際のAPIのデザインに当たってはv1との差分を考えているようでしたので興味がわいた方は調べてみると面白いと思います(そしてできれば私に教えてください).

?演算子の現在の挙動

このRFC(以下try_trait_v2)は,?演算子の挙動を新しくするという目的で提案されています.

そこでまずは?演算子の現在の挙動についておさらいしていきます.

The Rust Programming LanguageReferenceに書かれているように,?演算子は「評価するとResult<T,E>Option<T>になるような式の後ろにつけることで,OkSomeならTと評価され,ErrNoneならその関数からreturnする」というような挙動をします.

つまり

// この式は
expression?

// こう解釈されます
// Optionの場合にはSome(v)がvに,Noneはreturn Noneになります
match expression {
    Ok(v) => v,
    Err(e) => return Err(e)
}

ということです(本当は嘘ですがそれは後述します).

このように書くと少しわかりにくいので実際のコードで説明します.

fn ret_ok() -> Result<isize,()> {
    Ok(10)
}

fn ret_err() -> Result<isize,()> {
    Err(())
}
fn print_if_ok() -> Result<(),()> {
    println!("{}",ret_ok()?);
    // println!("{}",ret_err()?);

    println!("print succeed");
    Ok(())
}

fn main() {
    match print_if_ok() {
        Ok(_) => println!("ok"),
        Err(_) => println!("err")
    }
}

このコードを実行すると以下のように出力されます.

10
print succeed
ok

一方,print_if_ok関数内のコメントを外すと

10
err

というように表示されます.

まず,ret_ok関数はOk(10)を返します. そのためret_ok()?という式は全体として10と評価され,結果として10と表示されます.

一方コメントを外すと10と表示されるところまでは同じですが,ret_err関数はErr(())を返すため,ret_err()?という式はreturn式として評価されます.*1

この例だとあまり使い道がわからないような気がしますが,よく使われるケースとしてはエラーの伝搬が挙げられます.

Resultを返す関数の中でResultを返す関数を呼ぶとき(例えばファイルを読んでその内容を出力するみたいな関数とかの中にはエラーを起こしうる関数呼び出しが含まれます),成功したらOkの中身を使いたいんだけど失敗したらそのままエラーをreturnしてほしい,という処理はよくあると思いますが,そのようなときに?演算子を使うことでより簡潔に書くことができます.

さて,例を挙げて現在の?演算子の挙動について説明しましたが,まだ一つ説明していない点があります. 先ほど式がmatch式に解釈され,その中でErr(e)return Err(e)となるようなmatchアームを書きましたが,これは厳密には正しくありません.

これを考慮すると,実際には下のようになります.

// この式は
expresstion?

// こう解釈される
// Optionの場合には先ほどと同じ
match expression {
    Ok(v) => v,
    Err(e) => return Err(From::from(e))
}

ほとんど変わってはいませんが,return Err(From::from(e))という部分だけが変わっています.

詳しくはFromトレイトのドキュメントを見ると分かりますが,これは「eの型(expressionがResult<T,E>ならE)から何かの型Uへの変換」を行っています.

これは,関数内で起こる様々なエラー型をまとめて一つのエラー型で表したうえでエラーを伝搬したい,といったケースで有効です. 例えばio::Errorstr::Utf8Errorなどを返すエラーを返しうる関数を呼ぶ関数において,独自エラー型MyErrorを返すようにしてそのエラー型に対しFrom<io::Error>From<str::Utf8Error>を実装することで,?演算子によって自動的にMyErrorに変換してくれます.

struct MyError;

impl From<std::io::Error> for MyError {
    fn from(e: std::io::Error) -> Self {
        MyError
    }
}

fn err() -> Result<(),MyError> {
    // ここではio::ErrorだけだがFromを様々なエラーに実装すると複数の型をまとめて扱える
    std::fs::File::open("notexist.txt")?;
    Ok(())
}

fn main() {
    err();
}

新しい?演算子に求められること

以上のように現在の?演算子は,「エラーがあったらreturnしてそうでないなら中の値が欲しい」という場合,非常に便利です.

しかし一方で,これを一般化した「何かの条件を満たすのであれば何かをreturnしてそうでないなら中の値が欲しい」というケースでは現状の?演算子では扱うことができません.

まず,?演算子ResultまたはOptionを返す関数内でしか使えません. プログラムを書く上では,プログラムを各対象に応じたよりリッチな型を返したい,と思うことは往々にしてあります. 例えば何かHTTPサーバーを作っているときに,Result型ではなくてHTTPResponseに対応した独自型を返したい,というケースなどです.

そこで新しい?演算子には,特定の型でのみ使えるのではなく任意の型に使えてほしいという要件が求められます.

また,従来でも実装されていた型変換の機能も当然求められます.

Try Trait v2

さて,新しい?演算子に求められている要件を整理したところで,ようやく本題であるRFC 3058: Try Trait v2について説明していきます.

Try Trait v2では,以下の3つのトレイトや型を用いて?演算子を実装します.

ControlFlow型

ControlFlow型は他の二つのトレイトとは異なり,すでにstableに取り入れられています.なぜControlFlow型だけ先にstabilizeされているのかはわからない*2んですが,多分Try Trait v2での議論とはある程度直行しているからとかだと思います.

この型はシンプルで以下のようなシグネチャになっています.

enum ControlFlow<B, C = ()> {
    Continue(C),
    Break(B),
}

この型によって,何か処理をしているときに,そのまま処理を続けるのか(Continue)それとも途中でやめるのか(Break)を表現することができ,さらにそれらは何か値を運ぶことができます*3

この型は,上の「エラーがあったとき」を「何かの条件を満たすとき」と一般化したことで必要になったであろう型です. というのもこの型の挙動自体はResult型でも特に問題なく実現できるのですが,その場合には非直感的になってしまうことが避けられないからです.

例としてグラフの探索など何か続けて処理をするときのことを考えます. 探索していた要素を見つけたので早期にリターンしたいと思った場合,Result型を使う場合だと,正常に見つかった場合でもErrバリアントを使う必要があり,これは非直感的です*4

つまり,Result型は「早期リターンするとき,それは何らかのエラーである」という前提を持っているといえます. この前提はResult型を返す関数に限って言えばある程度は正しそうですが,新しい?演算子では任意の型を返せる必要があるため明らかに間違いです.

そこでControlFlow型は,「早期リターンするのか,それとも続けるのか」という,正常・エラーの価値判断なしに使える型になっています.

この型を使うことで任意の型の値に対して早期リターンするのか処理を続けるのかという挙動を直感的に書けるようになりますが,実際にどのように書くのかは登場人物がすべて出そろってからまとめて紹介します.

Tryトレイト

Tryトレイトは名前からわかるように,Try Trait v2の中心となるトレイトで,このトレイトを実装した型に対して?演算子を使った短縮記法が使えるようになります.

このトレイトは以下のようなシグネチャになっています.

trait Try: FromResidual<Self::Residual> {
    type Output;
    type Residual;

    fn from_output(output: Self::Output) -> Self;
    fn branch(self) -> ControlFlow<Self::Residual, Self::Output>;
}

まだ解説していないFromResidualがスーパートレイトになっていたりしますが,重要なのは二つの関連型OutputResidualbranch関数です.

OutputResidual

この二つの関連型はそれぞれ「処理を継続するときの値」と「早期リターンするときの値」に対応します.

分かりやすいのはOutputの方で,let x = expression?としたとき,xの型はOutputとなる,といった具合なので特に説明はしません.

少しわかりにくいのがResidualの方で,こちらはそもそも単語レベルでなじみがありませんが*5,単語の意味としては「残余」というのがメジャーらしいです.

こちらはOutputとは逆に早期リターンするときの値の素になる値の型です.少しお茶を濁したような言い方をしている理由はFromResidualの説明で分かると思いますので,ここではリターンされる値なんだなという理解で十分です.

Residualという分かりにくい単語ではありますがイメージとしては,「正常」な処理による値(Output)の残りが「エラー」でリターンされる値(Residual)という感じでしょうか(イメージを分かりやすくするため,早期リターン=エラーという前提に立ちましたが前述のとおり正しくない使い方です).

まとめると,?演算子によって最終的に処理の中心となる値の型のうち,処理継続の時に使われるのがOutputで,早期リターンで使われるのがResidualという型ということです.

branch関数

?によってOutputResidualといった型の値に処理の対象が分岐していきますが,どのような時に処理を続けてどのような時に早期リターンするのか,というのを決定するのがbranch関数です.

この関数は下のようなシグネチャになっています.

fn branch(self) -> ControlFlow<Self::Residual, Self::Output>;

Tryトレイトを実装してある型Selfを引数にとり,ControlFlow型を返すことで処理を継続するのか,早期リターンするのかを決定します.

この動作は?演算子の本質といっても過言ではなく,新しい?演算子は下のコードのように解釈されます.

// このコードは
expression?

// このように解釈される
match Try::branch(expression) {
    ControlFlow::Continue(v) => v,
    ControlFlow::Break(r) => return r,  // 型変換が入るため実際にはこうではないがやっていることは似ている
}

FromResidualトレイト

ControlFlow型が処理の継続と早期リターンという一般化のための型で,Tryトレイトは?演算子の挙動の本質的な部分を担っているということを述べていきましたが,FromResidualトレイトは,?演算子のもう一つの役割である型変換を担っており,以下のようなシグネチャを持ちます.

trait FromResidual<R = <Self as Try>::Residual> {
    fn from_residual(residual: R) -> Self;
}

シグネチャからわかるように,何かの型R(何も指定しなければTryを実装する型のTry::Residual)から自身の型に変換するためのトレイトです.

上で?がどう解釈されるのかを書きましたが,このトレイトを使った型変換まで考慮に入れると下のような解釈になり,これは完全な解釈です.

// このコードは
expression?

// こう解釈される
match Try::branch(expression) {
    ControlFlow::Continue(v) => v,
    ControlFlow::Break(r) => return FromResidual::from_residual(r),
}

先ほどと違う部分はControlFlow::Breakバリアントだった時にreturnされる値がrからFromResidual::from_residual(r)になっている点です.

これによって,もしTry::Residualと返り値の型が異なっていても,返り値の型にFromResidualトレイトを実装することで適切に変換することが可能になります.

ここでやっていることはFromトレイトと同じですが,わざわざ別のトレイトを使うことで?のための変換とそれ以外の変換を分ける,という意図があるのだと思います*6

実際の使い方

さて,ここまでで新しい?演算子についてある程度は理解できたかと思いますが,じゃあ実際にはどう使うのかというのがまだわからない人もいると思います(私も説明読んだだけではわかりませんでした). そこで,簡単な例を使って使い方を解説していきます.

ここでは,C言語での返り値による実行ステータスを表す型を実装してみることにします. また,その型を返す関数の結果を使いResult型を返す関数を書くことで,型変換についても説明していきます.

#![feature(try_trait_v2)]
use std::num::NonZeroUsize;
use std::ops::{Try,FromResidual,ControlFlow};

// 0が成功,1以上なら失敗のステータスコード
struct Status(usize);

// StatusのResidualは成功でないものなのでこのように書ける
struct StatusResidual(NonZeroUsize);

impl FromResidual for Status {
    fn from_residual(r: StatusResidual) -> Self {
        // UsizeはFrom<NonZeroUsize>を実装しているので変換可能
        Self(r.0.into())
    }
}

impl Try for Status {
    type Output = ();
    type Residual = StatusResidual;
    
    fn from_output(_ : <Self as Try>::Output) -> Self {
        Self(0)
    }
    
    fn branch(self) -> ControlFlow<<Self as Try>::Residual, <Self as Try>::Output> {
        // NonZeroUsize::new(x)はxが0だったらNoneを,それ以外ならSome(x)を返す
        // つまりSomeなら失敗ステータスを表している
        match NonZeroUsize::new(self.0) {
            Some(r) => ControlFlow::Break(StatusResidual(r)),
            None => ControlFlow::Continue(()),
        }
    }
}

impl FromResidual<StatusResidual> for Result<(), NonZeroUsize> {
    fn from_residual(r:StatusResidual) -> Self {
        Err(r.0)
    }
}

// 実際には何かの処理を行ってそのステータスコードを返す関数
fn something_success() -> Status {
    Status(0)
}
fn something_error() -> Status {
    Status(100)
}

// 終了ステータスコードが返される関数を中で呼び出し,Resultに変換して返す
fn call_fn_and_return_result() -> Result<(), NonZeroUsize> {
    let x: () = something_success()?;
    let x: () = something_error()?;
    Ok(())
}

fn main() {
    match call_fn_and_return_result() {
        Ok(_) => println!("ok"),
        Err(sc) => println!("err with {}",sc),
    };
}

出力は

err with 100

となります.

そこまで説明のいらないコードだと思うので説明はしませんが,いくつか注意点があります.

まずは,このコードはnightlyなrustを使わないといけません. これは,このRFCが安定化されておらず,try_trait_v2featureゲートを使う必要があるためです.

次に,型変換を行っている

impl FromResidual<StatusResidual> for Result<(), NonZeroUsize> {
    fn from_residual(r:StatusResidual) -> Self {
        Err(r.0)
    }
}

についてですが,これは最終的に?演算子return FromResidual::from_residual(r)というように解釈されるため,StatusResidualであるStatusResidual型からcall_fn_and_return_result関数の返り値であるResult<(),NonZeroUsize>への変換が存在する必要があるためです.

最後に

とても長くなりましたが,自分なりにRFC 3058のまとめのようなものを書いてみました.

記事を書くのにあたって,rustのRFCやそのtracking issueを初めて(完全には追えてはいないものの)読んでみましたが,当初とはデザインが変わっているという旨がかかれていたり,型の名前があまり直感的ではないので直した方がよいという議論が上がっていたりとRustはこうやって作られているのだな,というのを少し体感できたような気がして,いつかRust本体にも関わってみたいなと思いました.

*1:returnは文ではなく,評価されると関数からreturnされる式なのを初めて知りました. Return expressions - The Rust Reference

*2:一応理由は書いてあるんですが少し意味が分からなかった

*3:ResultでのOkに対応していそうなContinueの運ぶ型がジェネリクスの二つ目に入っているのはなぜなんだろうか

*4:じゃあOkバリアントを使えばいいのでは?と思うかもしれませんが,それだと?による早期リターンが使えなくなります

*5:RFCの議論でもわかりにくいと言われている

*6:明確な根拠は見つからなかったので知っている人がいたら教えてくれると幸いです