Skip to content

Instantly share code, notes, and snippets.

@sile
Last active July 9, 2021 09:20
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save sile/7b967511f9d922ecfab80dc27d17d82d to your computer and use it in GitHub Desktop.
Save sile/7b967511f9d922ecfab80dc27d17d82d to your computer and use it in GitHub Desktop.
Rustの『RFC 1859: Tryトレイト』の要約メモ

RFC 1859: Tryトレイト

要約

  • ?の挙動をカスタマイズするためのTryトレイトを導入する
  • Result以外にも?が適用可能になる

動機

?Result以外の型で使用する

Result?演算子の組み合わせは便利だけど、それ以外の型とも組み合わせて使えるべき。

rustfmtのソースコードから抜粋した、以下の二行を考えてみる:

let lhs_budget = try_opt!(width.checked_sub(prefix.len() + infix.len()));
let rhs_budget = try_opt!(width.checked_sub(suffix.len()));

これらはOptionに対して、独自定義のtry_opt!を用いて、?と同様の早期脱出を実現している。

このRFCにより、?を用いて次のように書けるようになる。

let lhs_budget = width.checked_sub(prefix.len() + infix.len())?;
let rhs_budget = width.checked_sub(suffix.len())?;

try!に対する?の利点が、上の例でも同様に当てはまる:

  • 接尾記法は、よりなめらかなAPIを許可する (! foo()?.bar()?.baz()?的な話?)
  • 気付きやすくありながら簡潔

ただし?の振る舞いを、ResultOptionに対してハードコードするのは避け、拡張可能なようにしたい。 例えばFutureの適用結果を表現するためには、以下のような型が考えられる。

enum Poll<T, E> {
    Ready(T),
    NotReady,
    Error(E),
}

この場合は「Poll::Ready以外が返ってきたら即座に関数から抜ける」といった挙動を?で実現したい。

相互変換をサポートするが、注意は必要

既存のtry!マクロと?演算子は、既に制限付きでの型変換を許容している:

  • 早期脱出の際にResult型のErr部に対してFrom::fromを勝手に適用してくれる
  • エラーの場合によく使われる:
    // ! RFC内には存在しない例
    fn foo() -> Result<(), OuterError> {
      // a) `?`を使った場合
      bar()?;
    
      // b) 上のコードの展開形(イメージ)
      if let Err(e): Result<_, InnerError> = bar() {
        // `OuterError`が`From<InnerError>`トレイトを実装していれば、
        // `?`内で以下のような型変換が自動で行われる。
        return Err(OtherError::from(e));
      }
    
      Ok(())
    }
  • 例えば、関数内で発生するいろいろなエラーをBox<Error>のような、より共通的なものにupcastする時等に使用される

より進んで、異なる型同士での相互変換が行えると嬉しいケースがある:

  • e.g., Result<T, HttpError>HttpResponseの相互変換 (rust-lang/rfcs#1718 (comment))
  • e.g., 上に出てきたPollResultの相互変換
    // NOTE: `bar()`は、`Poll`を返す関数
    
    // a) 返り値が`Poll`の場合: `Poll` => `Poll`
    fn foo() -> Poll<T, E> {
        let x = bar()?; // propagate error case
        ...
    }
    
    // b) 返り値が`Result`の場合: `Poll` => `Result`
    fn foo() -> Result<T, E> {
        let x = bar()?; // propagate error case
        ...
    }

注意点:

  • 相互変換は意図的に行われるようにするべき
  • 例えば、ResultからOptionへの変換を許容してしまうのはリスクがある
    • Resultは"未処理エラー"を表すのによく使われるのに対して、Optionはそうではない
    • ?で暗黙的に変換できてしまうと、エラーを予期せずに見落としてしまう危険性がある
  • このようなリスクを緩和するために、偶発的な変換の発生防止用のいくつかの変換を、このRFCでは採用している(詳細は後述)

詳細なデザイン

遊び場

ここで、RFCで定義されているトレイトとその実装を、実際に試せるよ。

デシュガーとTryトレイト

?は、以下のように展開されるように変更される(Tryは後述):

match Try::into_result(expr) {
    Ok(v) => v,

    // `Result`という共通形式を通して、型変換を行っている。
    //
    // 仮に`expr`の型を`T`、帰り値の型を`U`とすると:
    // 1. exprを`T::Error`に変換 (`Try::into_result`)
    // 2. `T::Error`を`U::Error`に変換 (`From::from`)
    // 3. `U::Error`を`U`に変換 (`Try::from_error`)
    Err(e) => return Try::from_error(From::from(e)),
}

Trylibcore::opsに追加されるトレイト (std::opsでも参照可能):

trait Try {
    type Ok;
    type Error;

    /// Applies the "?" operator. A return of `Ok(t)` means that the
    /// execution should continue normally, and the result of `?` is the
    /// value `t`. A return of `Err(e)` means that execution should branch
    /// to the innermost enclosing `catch`, or return from the function.
    ///
    /// If an `Err(e)` result is returned, the value `e` will be "wrapped"
    /// in the return type of the enclosing scope (which must itself implement
    /// `Try`). Specifically, the value `X::from_error(From::from(e))`
    /// is returned, where `X` is the return type of the enclosing function.
    fn into_result(self) -> Result<Self::Ok, Self::Error>;

    /// Wrap an error value to construct the composite result. For example,
    /// `Result::Err(x)` and `Result::from_error(x)` are equivalent.
    fn from_error(v: Self::Error) -> Self;

    /// Wrap an OK value to construct the composite result. For example,
    /// `Result::Ok(x)` and `Result::from_ok(x)` are equivalent.
    ///
    /// *The following function has an anticipated use, but is not used
    /// in this RFC. It is included because we would not want to stabilize
    /// the trait without including it.*
    fn from_ok(v: Self::Ok) -> Self;
}

初期実装

libforeは、以下の実装を備えるはず。

Result

Result用(今の同じ動作):

impl<T,E> Try for Result<T, E> {
    type Ok = T;
    type Error = E;

    fn into_result(self) -> Self {
        self
    }

    fn from_ok(v: T) -> Self {
        Ok(v)
    }

    fn from_error(v: E) -> Self {
        Err(v)
    }
}

Option

Option用:

mod option {
    pub struct Missing;

    impl<T> Try for Option<T>  {
        type Ok = T;
        type Error = Missing;

        fn into_result(self) -> Result<T, Missing> {
            self.ok_or(Missing)
        }

        fn from_ok(v: T) -> Self {
            Some(v)
        }

        fn from_error(_: Missing) -> Self {
            None
        }
    }
}

Missingの使い方が特徴的:

  • ()ではなく、Optionのための型を新設:
    • ResultからOptionに誤って変換してしまう危険性を軽減するため
    • Result<T, Missing>からしかOption<T>には変換できない
    • 参考: rust-lang/rfcs#1859 (comment)
  • Missingのような型(fresh type)の使い方は、#[must_use]属性を持たないような型にTryを実装する場合にはいつでも推奨される
    • より意味論的に述べるなら「"unhandled error"を表現しない型」に対して実装する場合

型推論との相互作用

このRFCにより?の返り値がResult以外にもなり得るようになるので、型推論は困難になる。

例えば今はvec.iter().map(|e| ..).collect()?と書けるのが、 以下のように型を明示しなければならなくなる(あるいはtry!マクロを使うか):

vec.iter().map(|e| ...).collect::<Result<_, _>>()?

別の問題:

  • f()??みたいにネストできない
  • try!でも同様だし、このRFCとは直行する問題に見えるので、ここでは扱わない:
    • 解決したいなら推論フォールバック的なものを導入する必要がある?

どのように教えるか

どこに・どのようにドキュメントを書くか

このRFCは既存の?演算子の拡張なので、以下の段階を踏むのが良い:

    1. 最初はResultの例を提示
    1. ?がオーバーロード可能なことにも言及し、詳細なページへのリンクを貼る
    1. そこではOptionに適用可能なことを説明
    1. さらにはデシュガーについても述べて、自前の型に対して実装可能なようにする

Rust bookRust by examplesの内容も更新する。

option::Missingのような特別なエラー型を導入するのが適切なケースについてのガイドラインも発行すべき。

エラーメッセージ

変換が行えないケースでのエラーメッセージの内容も重要。 今はCarrierへの参照を示すだけで分かりにくい。

ソース型がTryを実装していない

以下のようなエラーメッセージを出すことができる:

`?` cannot be applied to a value of type `Foo`

返り値の型がTryを実装していない

返り値の型を()とすると、以下のようなメッセージが可能:

cannot use the `?` operator in a function that returns `()`

あるいはもっと厳密に、次のようにしたいかもしれない:

`?` cannot be applied to a `Result<T, Box<Error>>` in a function that returns `()`

このケースだと「Result<(), Box<Error>>に返り値を変えてはどうですか?」と示唆したいかもしれない。 ただしトレイトの実装メソッドやmain関数の中の場合は、ユーザが勝手に型を変更できないので、この示唆は行いたくない。 ただ、トレイトが同じcrate内で定義されているならしても良いかも。

エラー同士が相互変換できない

返り値の型Rが、Tryは実装しているけど、結果エラーからは構築できない場合 (e.g., R = Option<T>だけど?Result<T, ()>に適用された):

`?` cannot be applied to a `Result<T, Box<Error>>` in a function that returns `Option<T>`

この変換失敗は、以下の二つの理由から発生し得るので、紛らわしく成り得る:

  • a. From実装がない(おそらくミス)
  • b. Tryの実装が意図的に制限されている (e.g., Option)

以下のようなメッセージを出すことは、ユーザが状況を診断する助けとなり得る:

22 | fn foo(...) -> Option<T> {
   |                --------- requires an error of type `option::Missing`
   |     write!(foo, ...)?;
   |     ^^^^^^^^^^^^^^^^^ produces an error of type `io::Error`
   | }

catchを使うことの示唆を検討する

catchが安定化されたら、返り値の型との齟齬による変換エラーの場合にはcatchの使用を示唆できるかも (「catchを使用するか、返り値の型を変更してはどうでしょうか?」)。

拡張されたエラーメッセージ文

返り値の型が変更できないケースでは、以下のように該当部分を別のヘルパ関数に切り出すリファクタリングを示唆しても良いかもしれない。

fn inner_main() -> Result<(), HLError> {
    let args = parse_cmdline()?;
    // all the real work here
}

fn main() {
    process::exit(match inner_main() {
        Ok(_) => 0,
        Err(ref e) => {
            writeln!(io::stderr(), "{}", e).unwrap();
            1
        }
    });
}

実装ノート

?のデシュガーが「ASTからHIRの変換時」ではなく「HIRからMIRへの変換時」に行われると、エラーメッセージの改善に有用かもしれない。 ただし、おそらくソースアノテーションを使えば十分。

欠点

  • Result型以外のサポートにより、型推論は難しくなる
  • "must use"の値が誤って別の型に変換され見過ごされてしまう危険性
    • option::Missingのような型の導入により緩和はされる

代替案

"本質主義者"のアプローチ

このRFCの最初の提案時にはTryは以下のような形だった(今と全く異なる):

trait Try<E> {
    type Success;

    // 実装型(e.g., `Option`)を`Result`に変換 (! 一方向変換?)
    fn try(self) -> Result<Self::Success, E>;
}

エラー型Eをパラメータとして受け取っていることに注目:

  • 文脈を考慮した変換が可能となる
  • e.g., Eの型がFooBarかで、実装(変換)を変えられる

これは現在の"還元主義者"的なアプローチに変更された:

    1. 最初に文脈非依存の方法でOk/Error値に変換
    1. 文脈に沿う型にfrom_errorを使って変換

おおまかな変更理由:

  • トレイトがよりシンプルで直観的になる
    • from_okも簡単にサポート可能
  • 文脈依存の挙動は、全く予期せぬ動作に繋がる可能性がある
  • option::Missingのような特別な型の使用により、もともとのデザインが回避したいと考えていた問題(過度に緩い相互変換の回避)を緩和可能
  • ?のデシュガーにFromを使うのは良いこと:
    • いろいろな型に対して、広く使われサポートされているため
  • 孤児ルール(RFC 1023)との親和性が若干良い:
    • 例:
      • "本質主義者"のアプローチで「Pollをyieldする関数内でResultを返す(変換する)ことを許可したい」とする
      • impl<T,E> Try<Poll<T,E>> for Result<T,E>のような実装が必要
      • => 孤児ルールに抵触する(i.e., 他のcrateの型に対するimplは不許可)

高階型の上に実装されたトレイト

  • ResultOptionの自由な相互変換を避けたい、ということなら高階型(HKT)が適切に見えるかも
  • ただ、今はRustでHKTは使えない
  • また、この問題に対してHKTが特別適切、という訳ではないことも分かっている
    • 以下のように異なるkindが欲しくなる:
      • type -> type (OptionTryを実装する場合)
      • type -> type -> type (ResultTryを実装する場合)
      • type (boolTryを実装する場合)

このトレイトの名前を何にするか

いろいろな名前が提案された:

  • Carrier:
    • もともとの名前
    • 実装型が、エラー値の"運び屋"となるため
  • QuestionMark:
    • ?演算子に由来
    • ただRustでは「演算子名」ではなく「それが実行する操作」をトレイトの名前とする傾向がある
      • e.g., PlusではなくAddStarAsteriskではなくDeref
    • => Tryがこの操作に対する一番良い名前に見える

未解決の問題

特になし

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment