この記事では現在RustのRFC3058として提案されている,Try Trait v2について解説していきます.
とはいっても私はまだRustを学習している段階で,RFCの背景や実際になされている議論などには踏み込んだ説明をすることはできませんのでそこはご了承ください. もっと詳しく知りたいという方はRFCのページを見ると全て書いてあります.
このRFCは今も議論が続けられているのでもしかしたら大幅に変わる可能性もあります.ご了承ください.
また,v2という名前から察する人もいるかもしれませんが,同じ目的のv1もありました(今はv2に置き換えられています). rust-lang.github.io そちらについては書かないのか,と思う方もいるかもしれませんが,機能の説明にあたってはv1との差分をそこまで気にする必要はなさそうだと判断したため省いています. 実際のAPIのデザインに当たってはv1との差分を考えているようでしたので興味がわいた方は調べてみると面白いと思います(そしてできれば私に教えてください).
?
演算子の現在の挙動
このRFC(以下try_trait_v2)は,?
演算子の挙動を新しくするという目的で提案されています.
そこでまずは?
演算子の現在の挙動についておさらいしていきます.
The Rust Programming LanguageやReferenceに書かれているように,?
演算子は「評価するとResult<T,E>
やOption<T>
になるような式の後ろにつけることで,Ok
やSome
ならT
と評価され,Err
やNone
ならその関数から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::Error
やstr::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つのトレイトや型を用いて?
演算子を実装します.
- ops::ControlFlow型
- ops::FromResidualトレイト
- ops::Tryトレイト
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
がスーパートレイトになっていたりしますが,重要なのは二つの関連型Output
とResidual
,branch
関数です.
Output
とResidual
この二つの関連型はそれぞれ「処理を継続するときの値」と「早期リターンするときの値」に対応します.
分かりやすいのはOutput
の方で,let x = expression?
としたとき,x
の型はOutput
となる,といった具合なので特に説明はしません.
少しわかりにくいのがResidual
の方で,こちらはそもそも単語レベルでなじみがありませんが*5,単語の意味としては「残余」というのがメジャーらしいです.
こちらはOutput
とは逆に早期リターンするときの値の素になる値の型です.少しお茶を濁したような言い方をしている理由はFromResidual
の説明で分かると思いますので,ここではリターンされる値なんだなという理解で十分です.
Residualという分かりにくい単語ではありますがイメージとしては,「正常」な処理による値(Output
)の残りが「エラー」でリターンされる値(Residual
)という感じでしょうか(イメージを分かりやすくするため,早期リターン=エラーという前提に立ちましたが前述のとおり正しくない使い方です).
まとめると,?
演算子によって最終的に処理の中心となる値の型のうち,処理継続の時に使われるのがOutput
で,早期リターンで使われるのがResidual
という型ということです.
branch
関数
?
によってOutput
やResidual
といった型の値に処理の対象が分岐していきますが,どのような時に処理を続けてどのような時に早期リターンするのか,というのを決定するのが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_v2
featureゲートを使う必要があるためです.
次に,型変換を行っている
impl FromResidual<StatusResidual> for Result<(), NonZeroUsize> { fn from_residual(r:StatusResidual) -> Self { Err(r.0) } }
についてですが,これは最終的に?
演算子はreturn FromResidual::from_residual(r)
というように解釈されるため,Status
のResidual
であるStatusResidual
型からcall_fn_and_return_result
関数の返り値であるResult<(),NonZeroUsize>
への変換が存在する必要があるためです.
最後に
とても長くなりましたが,自分なりにRFC 3058のまとめのようなものを書いてみました.
記事を書くのにあたって,rustのRFCやそのtracking issueを初めて(完全には追えてはいないものの)読んでみましたが,当初とはデザインが変わっているという旨がかかれていたり,型の名前があまり直感的ではないので直した方がよいという議論が上がっていたりとRustはこうやって作られているのだな,というのを少し体感できたような気がして,いつかRust本体にも関わってみたいなと思いました.