🦀

【2024最新版】traitにasync fnを含める方法

2024/01/10に公開

公約[1]通り、2023年内で async fn in traitstable Rust (1.75.0) にやってきました。
(本当にめでたい。本当にありがとうございます。)

この記事では async fn in trait は何が嬉しいのかを書きます。
ついでにモックについての話もします。

これまでの async trait

これまで trait 内で async fn を定義すると、以下のように async-trait crate を使う必要がありました。

#[async_trait::async_trait]
trait Foo {
    async fn foo(&self) -> u32;
}

言語側に async fn を trait 内に書く仕組みがないので、Box<dyn Future>を返すような関数に変換されていました。

これからの async trait

これからは async fn を trait 内で定義できるようになりました。 async trait は、おそらく正式名称ではなく、"async fn in trait"が正式な用語だと思います。
async な trait というものはなく、trait 定義の中にfnasync fnを混在させることができるようになっただけなので、"async fn in trait" (もしくは "async fn in traits")という表現はしっくりきます。

用語のはなしはここまでにして、先ほど async-trait を使っていた例を async fn in trait に書き換えてみます。

trait Foo {
    async fn foo(&self) -> u32;
}

なんというか、こう、クリーンな感じがしますね。
動作的にもクリーンで、 heap を使わず、Future::pollがstatic dispatch されます。
static dispatch になることでどのくらい早くなるかは自分はよく調べていませんが、どう考えても効率的です。

ただし、落とし穴が2つあります。

1. Send がつかない

Foo::fooが返す future がSendであるかどうかは、それがどのように実装されているかに依存します。
以下のコードを見てください。SendFooSendになるように実装されていますが、UnsendFooSendになりません。

struct SendFoo;
struct UnsendFoo;

impl Foo for SendFoo {
    async fn foo(&self) -> u32 {
        let a = std::rc::Rc::new(0u32);
        *a
    }
}

impl Foo for UnsendFoo {
    async fn foo(&self) -> u32 {
        let a = std::rc::Rc::new(0u32);
        tokio::time::sleep(std::time::Duration::from_secs(0)).await; // 違い
        *a
    }
}

以下のようなテストを書くと確認できます。

#[test]
fn test_send() {
    let foo = SendFoo;
    assert_send(foo.foo());
    // 以下はコンパイルエラー
    // let foo = UnsendFoo;
    // assert_send(foo.foo());

    fn assert_send<T: Send>(_value: T) {}
}

これが本当に問題になるのはFooを generics で使う場合です。例えば以下のようなコードを見てください。

async fn use_foo<T: Foo + Send + Sync>(foo: &mut T) {
    assert_send(async {
        foo.foo().await;
    });

    fn assert_send<T: Send>(_value: T) {}
}

これはコンパイルエラーになります。fooが具体的な型でないため、どう頑張ってもFoo::fooの返す future がSendであることを保証できません。
これは大変なことで、assert_sendの部分をtokio::spawnに書き換えても同様のコンパイルエラーが出ることになりますし、このような関数を axum や actix-web などの handler として使うこともできません。

これには2つの解決方法(実質的には1つ)があります。

まず1つめは、async fnを脱糖して "return-position impl trait" にして手動でSendをつけることです。
こうです:

trait Foo {
    fn foo(&self) -> impl Future<Output = u32> + Send;
}

2つ目はtrait_variant::makeマクロを使うことです。これは勝手に1つ目と同じことをやってくれます。

#[trait_variant::make(Foo: Send)]
trait UnsendFoo {
    async fn foo(&self) -> u32;
}

これはFooUnsendFooの両方を定義してくれるので、もとの trait の名前をUnsendFooにするところがポイントです。
UnsendFooいる?と思うかもしれませんが、例えばライブラリの APIとしてFooを公開するときにはUnsendFooも一緒に公開することで、ユーザーは好きな方を使うことができます。
例えば single-thread な async runtime を使う場合はRcでもなんでもSendでないものを自由に使えるUnsendFooが便利になるわけです。

マクロから卒業できると思ったのにまたマクロを使うことになるのか感はありますが、いずれ trait のメソッドの impl Trait に対してトレイト境界をつけることができるようになりそうなので、そのときには本当にマクロを使わなくて済むようになると思います。

2. オブジェクトセーフでない

端的に言うとasync fnを使ったFooのようなトレイトを&dyn FooBox<dyn Foo>のようにして扱うことができません。
async fn のような返り値にimpl Traitを使うものは実質 trait の associated type だからですね。
async-trait を使ったときに&dyn Fooが使えたのは、Boxで型消去しているからです。
なのでdyn Fooを使いたい場合は、これまで通りasync-traitを使うか、型消去されたバージョンを手書きしましょう。
後者を自動で実装するようなマクロがリリースされる予定とのことです。

async fn in trait とモック

async_traitマクロを消したとき、
mockallはそのまま#[mockall::automock]をつけたままで動きます。
拙作のmryは対応していなかったのですが、さっき対応したバージョンをリリースしたので#[mry::mry]をつけたままで動くはずです。

モックライブラリを作る側からすると、async fn in trait はとてもありがたいです。
async_traitを使う際はasync_traitマクロの上にモック用のマクロを書くか下に書くかでどのようなモックコードが生成されるかが変わってしまったり、mryにいたっては下側に置くことができませんでした。
また、async fn in trait が標準化されたことで、モックライブラリ側としては特殊な実装をする必要がなくなりました。モックの作り方によるのかもしれませんが、普通の同期関数用のfnの処理をそのまま使うことができるようになりました。
先ほどのmryの対応も新しい処理を入れるのではなく、asyncをつけ忘れていた部分を修正するだけで済みました。
加筆: impl Futuretrait_variant::makeを考慮するのが大変ということが判明しました。

mryは今後は積極的にはメンテナンスしないつもりで、その代わりに、anonymous-traitというライブラリを作っています。
Javaの匿名クラスのように、traitを匿名で実装することができるライブラリです (anonymous trait implementation)。
モックのためのライブラリではないですが、これをモックに使うことができます。
これを使うと、production code である trait 定義に対して#[mry::mry]#[mockall::automock]といったモック向けのマクロをつける必要がなくなるのが、潔癖症の自分にはとてもありがたいです。

let num = 42;

#[anonymous_trait::anonymous_trait(let mock = ())]
impl Foo for () {
    async fn foo(&self) -> u32 {
        num
    }
}

assert_eq!(mock.foo().await, 42);

これが動きます。numをキャプチャ〜できているのが嬉しいですね。
リリースしたばかりなので荒削りも荒削りですが、使ってフィードバックをいただけるとありがたいです。

まとめ

  • async fn in trait と return-position impl trait がやってきた
  • async fn には Send がつかない
  • async fn はオブジェクトセーフでない
  • async fn はモックライブラリに優しい
  • anonymous-traitというライブラリを宣伝しました
脚注
  1. Stabilizing async fn in traits in 2023 ↩︎

株式会社ユーザベース

Discussion