本文涵蓋為什麼 Rust trait 不支援 async fnasync_trait crate 如何解決這個問題、它的代價是什麼,以及 Rust 1.75 之後的原生支援現況。

問題:async fn 不能直接用在 trait 裡

如果你第一次在 Rust 寫 async 相關的 trait,很可能會直覺地這樣寫:

trait MyTrait {
    async fn do_something(&self) -> String; // ❌ 編譯錯誤
}

但這在穩定版 Rust(1.75 之前)是不允許的。為什麼?


根本原因:impl Trait 與 dyn Trait 的衝突

async fn 是語法糖,它會被編譯器展開成回傳 impl Future 的普通函式:

// 你寫的:
async fn do_something(&self) -> String { ... }

// 編譯器看到的:
fn do_something(&self) -> impl Future<Output = String> { ... }

問題出在 trait 裡。每個實作 trait 的型別,其 do_something 都會回傳一個不同的具體 Future 型別

impl MyTrait for StructA {
    // 回傳型別是某個只有編譯器知道名字的 Future_A
    async fn do_something(&self) -> String { ... }
}

impl MyTrait for StructB {
    // 回傳型別是另一個 Future_B,跟 Future_A 完全不同
    async fn do_something(&self) -> String { ... }
}

這讓 trait 無法做到 object-safe(也就是無法用 dyn MyTrait),因為動態派發需要在執行期才知道要呼叫哪個實作,但每個實作的回傳型別大小不同,vtable 根本無法表達。


解法:async_trait crate

async_trait 是由 David Tolnay(serdeanyhow 的作者)開發的 procedural macro,它的做法是把所有 async fn 的回傳值統一包進 Box

use async_trait::async_trait;

#[async_trait]
trait MyTrait {
    async fn do_something(&self) -> String;
}

#[async_trait]
impl MyTrait for MyStruct {
    async fn do_something(&self) -> String {
        "hello".to_string()
    }
}

macro 展開後,實際上變成這樣:

trait MyTrait {
    fn do_something(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>>;
}

impl MyTrait for MyStruct {
    fn do_something(&self) -> Pin<Box<dyn Future<Output = String> + Send + '_>> {
        Box::pin(async move {
            "hello".to_string()
        })
    }
}

回傳型別統一成 Pin<Box<dyn Future>>,大小固定,vtable 可以表達,dyn MyTrait 就可以正常運作了。


代價與限制

堆積分配(Heap allocation)

每次呼叫 async trait method,都會觸發一次 Box::pin(),也就是一次 heap 分配。對於高頻呼叫的場景,這個開銷是需要考慮的。

Send bound

預設情況下,async_trait 要求產生的 Future 必須是 Send(可以跨執行緒傳遞),適合多執行緒 async runtime(如 Tokio)。

如果你的情境不需要 Send(例如單執行緒 runtime),可以這樣關掉:

#[async_trait(?Send)]
trait MyTrait {
    async fn do_something(&self) -> String;
}

生命週期複雜度

macro 會自動處理大部分生命週期,但在一些邊緣情況(例如 &self 裡有複雜的借用關係)還是可能需要手動標注,錯誤訊息也可能比較難讀。


Rust 1.75 的原生支援

Rust 1.75(2023 年 12 月)穩定了 Return Position Impl Trait in Trait(RPITIT),讓 async fn 可以直接用在 trait 裡:

trait MyTrait {
    async fn do_something(&self) -> String; // ✅ Rust 1.75+ 可以!
}

不需要任何外部 crate,不需要 Box,沒有 heap 分配。

但 dyn Trait 仍有限制

原生支援的版本有一個重要限制:動態派發(dyn MyTrait)尚未完全支援

fn call(obj: &dyn MyTrait) {  // ⚠️ 可能有限制
    obj.do_something();
}

如果你需要 dyn Trait,目前仍建議使用 async_trait crate,或者搭配 dynosaur 等新興 crate 來橋接。


async_trait 與 Pin 的關係

async_trait 展開後的回傳型別是 Pin<Box<dyn Future>>,這裡的 Pin 是必要的——因為 dyn Future 可能是自引用的(async block 裡可能跨 await 持有引用),必須保證它被 poll 的過程中不會被移動。

這也是為什麼 async_trait 的文件裡,你會看到它與 PinBox 密不可分。如果你對 Pin 還不熟悉,可以先閱讀《深入理解 Rust 的 Pin》


應該用哪個?

情境 建議
Rust 1.75+,不需要 dyn Trait 原生 async fn in trait
需要 dyn Trait async_trait crate
效能極度敏感,避免 heap 分配 考慮手動實作或 impl Trait 參數
舊版 Rust(< 1.75) async_trait crate

總結

  • Rust trait 原本不支援 async fn,根本原因是每個實作的 Future 型別不同,無法做到 object-safe。
  • async_trait crate 透過把回傳值包進 Pin<Box<dyn Future>> 解決這個問題,代價是每次呼叫需要 heap 分配。
  • Rust 1.75 穩定了原生的 async fn in trait,但動態派發(dyn Trait)的支援仍不完整,async_trait 在這個場景仍有其價值。