緣起
一切都要從年末換工作碰上疫情, 在家閒著無聊又讀了幾首詩, 突然想寫一個可以瀏覽和背誦詩詞的 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 implSomeTrait 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/
閱讀更多 PrivateRookie 的文章