本セッションの登壇者
セッション動画
Return-position impl trait in trait、いわゆるRPITITの実装をあくまで「雑に見ていく」というタイトルで発表したいと思います。
前田喬之といいます。SNSはTaKO8Kiというハンドルネームでやっていて、基本的にRustのコミッターというか、コンパイラのコントリビューターチームや、エラー周りの機構を実装するワーキンググループに所属しています。
メソッドの戻り値タイプに impl トレイトを指定できるRPITIT
まず最初に、RPITITはそもそもどういうものなのかをおさらいして、次のトピックに進んでいければと思います。
RPITITというのは、Rust 1.75でリリースされた機能です。このようにアナウンスされました。
まず、そもそもの話として、Rustにおいては関数のリターンタイプとしてimpl トレイトというものを使うことができていました。
しかし、この例のようにメソッドのリターンタイプとして implトレイトを使うことは、Rust 1.75以前はできませんでした。これができるようになるのが今回のテーマであるRPITITです。
RPITITと合わせて「Async function in trait」というものも利用できるようになりました。今まではasync-trait proc macroがなければ使えなかった async ファンクションをトレイト内に書けるようになるというものです。なぜこれがRPITITと同時に出たかというと、上がasyncなメソッドを含んだトレイトの定義です。これが下のようにdesugarされて、返り値に impl Future
が来ています。これを見て分かるように、RPITITが実装されると、asyncなメソッドを含んだトレイトを同時に実装することができるからです。
コードで読み解くRPITITの実装
続いて、RPITITがどのように実装されているかを、ソースコードを実際に覗きながら見ていこうと思います。まず、最初のステップとしてソースから転換された後、HIRというハイレベルな中間表現に落とし込むという作業があります。ここではそれをloweringと呼びます。loweringされることで先ほど述べたよくある普通の関数の返り値のリターンタイプとして impl トレイトを使う場合と同じように、RPITITの場合も、HIRの中のItemKind
というenumに変換されます。
OpaqueTy
はこういう感じで定義されています。ここで分かるように、同じenum、同じstructで扱われはするんですが、in_trait
というフラグを持たせてこれがPRITIT由来のものかどうかというのを区別できるようになっています。
これが実際にこのstructのインスタンスを作成している場所のコードです。具体的には下のリンクを踏んでいただければリポジトリのリンクに飛びます。これを見て分かるように、ライフタイムのマッピングなど、必要な値がこのインスタンスを作成するタイミングで渡されていて、in_trait
というフラグも同様に、このインスタンスを作成するメソッドの引数から直接渡されていることが分かると思います。
このようにASTからHIRへの変換を行った後に、続いてHIRから型へのloweringを行います。このステップではtraitsとimplsそれぞれで新しい関連型(アソシエイテッドタイプ)を作って、これを扱うようにすることでRPITITを使えるようにしています。具体的には、hir::TyKind::OparqueDef
を、アソシエイテッドタイプであるoparque type A = impl B;
の代わりに射影(projection)にloweringします。射影というのは、traitsの中であれば元のopaqueに戻すことができますし、implsの中であればRPITITの推論された型にノーマライズすることができます。
実際に例を見ていこうと思います。画面の上側のような traits に lowering を行うと、下側のようになります。何が変わっているかというと、オレンジで囲っている部分のようにアソシエイテッドタイプが増えていて、メソッドのリターンタイプにはその Iterator
が使われていることです。
先ほどは traits の例で、次は impls の例です。こちらも同様にアソシエイテッドタイプが生えていて、それがメソッドのリターンタイプとして使われていることが分かると思います。
続いて、このアソシエイテッドタイプを生やすというのが、実際にコード上でどのように行われているのかを見てみましょう。実際行われている箇所は create_def
というメソッドが呼ばれている場所です。実際の行は下のリンクを読んでください。アソシエイテッドタイプの DefKind::AssocTy
を指定して、DefID に親のトレイトの ID を指定することで、新しく関連型を生やしていることが分かります。
これは impls に関しても同様で、親の impl の DefID を指定し、同様に AssocTy
をDefKind
として指定して、新しく定義を生やしていることが分かると思います。
ここで、新しく作る関連型の visibility、つまりプライベートなのかパブリックなのかとか、デフォルトかどうかという defaultness などのいろいろな要素はどのように決定すればいいのかという疑問が出てきます。
それに関しては、先ほど create_def
で作成したものに、親の visibility や defaultness と同じものを引き継いでやればいいということになります。実装を見るとこのようになっています。
その他、処理として必要なものはいくつかありますが、ここではよくある分かりやすいものを持ってきました。generics of
などのクエリを実行して関連した処理をしたり、いくつか必要なステップはその後にもあります。
ただしオブジェクトセーフではないのでダイナミックディスパッチは使えない
ここまでRPITITがどう動いているのかを追ってきたんですが、次にダイナミックディスパッチ(dyn)がなぜ動かないのかを軽く見ていきます。一言で言うと、アナウンスの記事にも載っていたように、RPITITやasync fn はトレイトの中でobject safeではないので、ダイナミックディスパッチが使えないというのが結論になります。具体的にオブジェクトセーフティというのがどういう定義なのか、どういうルールで決められているのかということはこちらをご覧ください。
これが object-safeではないトレイトの例です。たとえば、associated constがobject-safeではなかったり、sizeがなかったりselfを持っていないアソシエイテッドファンクションがあります。他にも、スーパートレイトがobject-safeでなければならなくて、そうでない場合、それはobject-safeではないなど、ルールがいくつか決まっています。
先ほど述べたように、RPITITでダイナミクディスパッチが動かないのはobject-safeでないからですが、そもそもobject-safeはどのタイミングで使われるようになったかというと、RFCの225番のプルリクエストに上がっています。2014年くらいのものですが、もともとはメソッドが呼び出されるタイミングでエラーを吐いていました。PRITITが導入されたことによって、トレイトオブジェクトが生成されるタイミングでobject-safeかどうかを確認して、コンパイルエラーを吐く実装に切り替わりました。これによって、トレイトオブジェクトがobject-safeかどうか、詳細なエラーを得られるようになったのが大きな利点です。
たとえば、このようにサイズのないアソシエイテッドファンクションを持っているクレートを定義してコンバイルしたとします。
その場合、画面のように self
というパラメーターを持ってないトレイトファンクションが原因でエラーが出ていることが分かります。このように具体的に解決策まで定義したエラーを出せることが1つの利点です。
将来的にはdyn可能になる方向
最後に、RPITITでこのダイナミックディスパッチをやりたい場合について深堀りしたいと思います。リリース記事ではtrait-variantというクレートがダイナミックディスパッチに対応する予定だと書かれていたのですが、現状ではissueは上がっているものの、まだ対応していなさそうです。
対応方針があったので、それをソースコードと共に見て終わりにしたいと思います。
この例はasyncなファンクションを抱えているトレイトです。
メソッドをdesugarするとこのようにimplフューチャーというものに書き換えることができます。
さらにこのように関連型を定義するという書き換え方をすることができます。
トレイトバリアントの中で解決策として挙げられていたのは、こういう2つのstructとtraitを作ることによって解決するというものでした。この‘DynAsyncIter というのがファットポイントになっていて、
ErasedAsyncIter`というのが、vtableを作るためのトレイトになっています。
こうすることで、このようにDynAsyncIter
に「new」というファンクションが入っていると思いますが、その中でErasedAsyncIter
というトレイトのオブジェクトにダイナミックに関連したインスタンスを作っているのが分かるかと思います。
フィールドで使われているファットポインターの定義はこのようになっていて、usizesフィールドの01番目の要素としてvtableのポインターが入る実装になっていることが分かります。
現状の方針としては、先ほど述べた実装をtrait_variant(dyn)
という属性を使ってprocマクロで生成するというのが現状の方針らしいですが、そこまで議論が進んでいないので、今後、方針が変わることもあるかもしれません。
まとめとしては、まずASTからenumにloweringされるところから始まって、RPITITがどういうふうに動作するか流れとして理解することができるというのが1つ目です。2つ目は、RPITITや asyncファンクションはobject-safeではないのでダイナミックディスパッチが使えないということです。最後がダイナミックディスパッチを使うためには、今までどおりasync-trait proc macroを使う必要があります。ただ、将来的には先ほど紹介したtrait-variant(issueは3週間ぐらい前)を使っていく方向性だと思われます。
以上です。