02.29 捋捋 Rust 中的 impl Trait 和 dyn Trait

緣起

一切都要從年末換工作碰上疫情, 在家閒著無聊又讀了幾首詩, 突然想寫一個可以瀏覽和背誦詩詞的 TUI 程序說起.我選擇了 https://github.com/gyscos/cursive 這個 Rust TUI 庫. 在實現時有這麼一個函數, 它會根據參數的不同返回某個組件(如 Button, TextView 等).在 Cursive 中, 每個組件都實現了 View 這個 trait, 最初這個函數只會返回某個確定的組件, 所以函數簽名可以這樣寫

<code>fn some_fn(param: SomeType) -> Button/<code>

隨著開發進度增加, 這個函數需要返回 Button, TextView 等組件中的一個, 我下意識地寫出了類似於下面的代碼

<code>fn some_fn(param1: i32, param2: i32) -> impl View {    if param1 > param2 {        // do something...        return Button {};    } else {        // do something...        return TextView {};    }}/<code>

可惜 Rust 編譯器一如既往地打臉, Rust 編譯器報錯如下

<code>  --> src\\main.rs:19:16   |13 | fn some_fn(param1: i32, param2: i32) -> impl View {   |                                         --------- expected because this return type......16 |         return Button {};   |                --------- ...is found to be `Button` here...19 |         return TextView {};   |                ^^^^^^^^^^^ expected struct `Button`, found struct `TextView`error: aborting due to previous errorFor more information about this error, try `rustc --explain E0308`./<code>

從編譯器報錯信息看函數返回值雖然是 impl View 但其從 if 分支推斷返回值類型為 Button 就不再接受 else 分支返回的 TextView. 這與 Rust 要求 if else 兩個分支的返回值類型相同的特性一致.那能不能讓函數返回多種類型呢? Rust 之所以要求函數不能返回多種類型是因為 Rust 在需要在編譯期確定返回值佔用的內存大小, 顯然不同類型的返回值其內存大小不一定相同.既然如此, 把返回值裝箱, 返回一個胖指針, 這樣我們的返回值大小可以確定了, 這樣也許就可以了吧.嘗試把函數修改成如下形式:

<code>fn some_fn(param1: i32, param2: i32) -> Box<view> {    if param1 > param2 {        // do something...        return Box::new(Button {});    } else {        // do something...        return Box::new(TextView {});    }}/<view>/<code>

現在代碼通過編譯了, 但如果使用 Rust 2018, 你會發現編譯器會拋出警告:

<code>warning: trait objects without an explicit `dyn` are deprecated  --> src\\main.rs:13:45   |13 | fn some_fn(param1: i32, param2: i32) -> Box<view> {   |                                             ^^^^ help: use `dyn`: `dyn View`   |   = note: `#[warn(bare_trait_objects)]` on by default/<view>/<code>

編譯器告訴我們使用 trait object 時不使用 dyn 的形式已經被廢棄了, 並且還貼心的提示我們把 Box 改成 Box , 按編譯器的提示修改代碼, 此時代碼no warning, no error, 完美.

但 impl Trait 和 Box 除了允許多種返回值類型的之外還有什麼區別嗎? trait object 又是什麼? 為什麼 Box 形式的返回值會被廢棄而引入了新的 dyn 關鍵字呢?

埋坑

impl Trait 和 dyn Trait 在 Rust 分別被稱為靜態分發和動態分發. 在第一版的 Rust Book 這樣解釋分發(dispatch)

When code involves polymorphism, there needs to be a mechanism to determine which specific version is actually run. This is called ‘dispatch’. There are two major forms of dispatch: static dispatch and dynamic dispatch. While Rust favors static dispatch, it also supports dynamic dispatch through a mechanism called ‘trait objects’.

即當代碼涉及多態時, 需要某種機制決定實際調用類型. Rust 的 Trait 可以看作某些具有通過特性類型的集合, 以上面代碼為例, 在寫代碼時我們不關心具體類型, 但在編譯或運行時必須確定 Button 還是 TextView.靜態分發, 正如靜態類型語言的"靜態"一詞說明的, 在編譯期就確定了具體調用類型. Rust 編譯器會通過單態化(Monomorphization) 將泛型函數展開.

假設 Foo 和 Bar 都實現了 Noop 特性, Rust 會把函數

<code>fn x(...) -> impl Noop/<code>

展開為

<code>fn x_for_foo(...) -> Foofn x_for_bar(...) -> Bar/<code>

(僅作原理說明, 不保證編譯會這樣展開函數名).

通過單態化, 編譯器消除了泛型, 而且沒有性能損耗, 這也是 Rust 提倡的形式, 缺點是過多展開可能會導致編譯生成的二級制文件體積過大, 這時候可能需要重構代碼.

靜態分發雖然有很高的性能, 但在文章開頭其另一個缺點也有所體現, 那就是無法讓函數返回多種類型, 因此 Rust 也支持通過 trait object 實現動態分發. 既然 Trait 是具有某種特性的類型的集合, 那我們可以把 Trait 也看作某種類型, 但它是"抽象的", 就像OOP中的抽象類或基類, 不能直接實例化.

Rust 的 trait object 使用了與 c++ 類似的 vtable 實現, trait object 含有1個指向實際類型的 data 指針, 和一個指向實際類型實現 trait 函數的 vtable, 以此實現動態分發. 更加詳細的介紹可以在 https://alschwalm.com/blog/static/2017/03/07/exploring-dynamic-dispatch-in-rust/看到.既然 trait object 在實現時可以確定大小, 那為什麼不用 fn x() -> Trait 的形式呢? 雖然 trait object 在實現上可以確定大小, 但在邏輯上, 因為 Trait 代表類型的集合, 其大小無法確定. 允許 fn x() -> Trait 會導致語義上的不和諧.那 fn x() -> &Trait 呢? 當然可以! 但鑑於這種場景下都是在函數中創建然後返回該值的引用, 顯然需要加上生命週期:

<code>fn some_fn(param1: i32, param2: i32) -> &'static View {    if param1 > param2 {        // do something...        return &Button {};    } else {        // do something...        return &TextView {};    }}/<code>

我不喜歡添加額外的生命週期說明, 想必各位也一樣. 所以我們可以用擁有所有權的 Box 智能指針避免煩人的生命週期說明. 至此 Box 終於出現了.那麼問題來了, 為什麼編譯器會提示 Box 會被廢棄, 特地引入了 dyn 關鍵字呢?答案可以在 https://github.com/rust-lang/rfcs/blob/master/text/2113-dyn-trait-syntax.md 中找到.

RFC-2113 明確說明了引入 dyn 的原因, 即語義模糊, 令人困惑, 原因在於沒有 dyn 讓 Trait 和 trait objects 看起來完全一樣, RFC 列舉了3個例子說明.

第一個例子, 加入你看到下面的代碼, 你直到作者要幹什麼嗎?

<code>impl SomeTrait for AnotherTrait impl SomeTrait for T where T: Another/<code>

你看懂了嗎? 說實話我也看不懂 : ) PASS

第二個例子, impl MyTrait {} 是正確的語法, 不過這樣會讓人以為這會在 Trait 上添加默認實現, 擴展方法或其他 Trait 自身的一些操作.實際上這是在 trait object 上添加方法.

如在下面代碼說明的, Trait 默認實現的正確定義方法是在定義 Trait 時指定, 而不應該在 impl Trait {} 語句塊中.

<code>trait Foo {    fn default_impl(&self) {        println!("correct impl!");    }}impl Foo {    fn trait_object() {        println!("trait object impl");    }}struct Bar {}impl Foo for Bar {}fn main() {    let b = Bar{};    b.default_impl();    // b.trait_object();    Foo::trait_object();}/<code>

Bar 在實現了 Foo 後可以通過 b.default_impl 調用, 無需額外實現, 但 b.trait_object 則不行, 因為 trait_object 方法是 Foo 的trait object 上的方法.

如果是 Rust 2018 編譯器應該還會顯示一條警告, 告訴我們應該使用 impl dyn Foo {}

第三個例子則以函數類型和函數 trait 作對比, 兩者差別只在於首字母是否大寫(Fn代表函數trait object, fn則是函數類型), 難免會把兩者弄混.

更加詳細的說明可以移步 https://github.com/rust-lang/rfcs/blob/master/text/2113-dyn-trait-syntax.md.

總結

impl trait 和 dyn trait 區別在於靜態分發於動態分發, 靜態分發性能好, 但大量使用有可能造成二進制文件膨脹; 動態分發以 trait object 的概念通過虛表實現, 會帶來一些運行時開銷. 又因 trait object 與 Trait 在不引入 dyn 的情況下經常導致語義混淆, 所以 Rust 特地引入 dyn 關鍵字, 在 Rust 2018 中已經穩定.

引用

以下是本文參考的資料

  • https://doc.rust-lang.org/nightly/edition-guide/rust-2018/trait-system/impl-trait-for-returning-complex-types-with-ease.html#argument-position
  • https://github.com/rust-lang/rust/issues/34511
  • https://github.com/rust-lang/rfcs/blob/master/text/2113-dyn-trait-syntax.md
  • https://joshleeb.com/posts/rust-traits-and-trait-objects/
  • https://lukasatkinson.de/2016/dynamic-vs-static-dispatch/
  • https://alschwalm.com/blog/static/2017/03/07/exploring-dynamic-dispatch-in-rust/


分享到:


相關文章: