RustのFn* trait

Rustにおけるクロージャとは、 Fn, FnMut, または FnOnce を実装した値にすぎない。これらを追ってみた。

Fnはどこから来たか

まず、 Fn/FnMut/FnOnceはキーワード/予約語ではない。syntax::symbolの一覧にない。

fn main() {
  let Fn = 0;
}

そこで、広く使われている Fn(u8, u8) -> u8 という記法がどのように実現しているかを追う。まず core::opsによると

#[lang = "fn_once"]
#[stable(feature = "rust1", since = "1.0.0")]
#[rustc_paren_sugar]
#[fundamental] // so that regex can rely that `&str: !FnMut`
pub trait FnOnce<Args> {
    /// The returned type after the call operator is used.
    #[stable(feature = "fn_once_output", since = "1.12.0")]
    type Output;

    /// This is called when the call operator is used.
    #[unstable(feature = "fn_traits", issue = "29625")]
    extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}

ここで出てくるattributeは以下の意味がある。

つまり、 #[rustc_paren_sugar] というフラグを立てることで特殊な記法を有効化していることがわかる。実際に実験してみると以下のようになる。

// #![feature(unboxed_closures)]

// #[rustc_paren_sugar]
// trait MyFn<Args> {
//     type Output;
// }
trait MyFn2<Args> {
    type Output;
}

fn test1<F>(f: F) where F: Fn(u8,u8) -> u8 {}
// fn test2<F>(f: F) where F: Fn<(u8,u8), Output=u8> {}
// fn test3<F>(f: F) where F: MyFn(u8, u8) -> u8 {}
// fn test4<F>(f: F) where F: MyFn<(u8,u8), Output=u8> {}
// fn test5<F>(f: F) where F: MyFn2(u8, u8) -> u8 {}
fn test6<F>(f: F) where F: MyFn2<(u8,u8), Output=u8> {}

fn main() {
    println!("Hello, world!");
}

上のコードでコメントアウトした部分はunstableで弾かれる。そこで2017/03/08時点のnightlyを使うと上でコメントアウトした部分も含めて全てコンパイルが通る。以下、 Fn* 系traitの内部構造について述べることはunstableであり、今後変更される可能性がある。

括弧記法はどこから来たか

これで、名前に関係なく T(X, Y) -> Z という形のトレイトがパースされるという予測がたった。実際に探してみると、syntax::parse::parserの型名をパースする部分にこの記述があった。

これによると、パースの段階では Foo<u8, ()>::Bar(str)::Baz(str) -> [u8]::Quux のような謎の物体も型名(path)として解釈されるようだ。このうち括弧がついている部分は ast::PathParameters::Parenthesized という補助的な情報が付与される。

ASTをHIRに落とす処理により、これはhir::ParenthesizedParameters に変換される。構造はASTのときとほぼ変わらない。

HIRに型をつける段階で convert_parenthesized_parameters により () 型引数が <> 型引数に変換される。この時点で <> との区別はなくなり、以下同様に取り扱われる。

Fnでのみ括弧が使える理由

それではトレイトによって () 記法が使えたり使えなかったりするのはどこで実現されているか。 #[rustc_paren_sugar]rustc_typeck::collect::trait_def_of_itemで処理されている。これを見ると TraitDef に単純にフラグが立てられているだけだということがわかる。実際の rustc::ty::trait_def::TraitDef::paren_sugar を見ると、このフラグによる利用制限は一時的なもので、将来的には撤廃する予定と書いてある。

そこで paren_sugar の利用場面を調べると、rustc_typeck::astconvの型代入を生成する関数で、以下のことがチェックされていることがわかる。

  • paren_sugar が有効なトレイトは、() 記法でのみ利用されている。
  • paren_sugar が無効なトレイトは、<> 記法でのみ利用されている。

クロージャートレイトのオーバーロード

Fn トレイトの定義を復習すると、引数の型は型引数、戻り値の型は関連型として扱われていた。つまり、トレイトとしての制約のみ考えると、

  • 同じ引数型に対して、複数の戻り値型を割り当てることはできない。
  • ただし、複数の異なる引数型を割り当てることはできる。

となるはずである。そこで実験してみると以下のようになる。

fn foo<F: Fn(u8, u8) -> u8 + Fn(u32, u32) -> u32>(f: F) {
    let x = f(6000, 8000);
}

fn main() {
    println!("Hello, world!");
}
error: the type of this value must be known in this context
 --> src/main.rs:2:13
  |
2 |     let x = f(6000, 8000);
  |             ^^^^^^^^^^^^^

error[E0059]: cannot use call notation; the first type parameter for the function trait is neither a tuple nor unit
 --> src/main.rs:2:13
  |
2 |     let x = f(6000, 8000);
  |             ^^^^^^^^^^^^^

error: aborting due to 2 previous errors

error: Could not compile `fn-impl-test`.

このように E0059 が出てしまう。

理由は正確にはわからなかったが、おそらく引数の型を推論できなかったために「タプルでもユニットでもない」と判断されたということのようだ。

まとめ

Fn(X, Y) -> Z のような記法は、lifetime elisionが行われるなどやや機能的に強力である点を除くと、基本的にトレイトのパラメーター指定の構文糖衣である。ただし、2つの記法の相互互換性にはまだ未確定の仕様が含まれているため、stableでは特定の記法しか利用できない。