当前位置:网站首页>【Rust投稿】捋捋 Rust 中的 impl Trait 和 dyn Trait

【Rust投稿】捋捋 Rust 中的 impl Trait 和 dyn Trait

2022-06-25 03:34:00 51CTO

PrivateRookie --作者


缘起

一切都要从年末换工作碰上特殊时期, 在家闲着无聊又读了几首诗, 突然想写一个可以浏览和背诵诗词的 TUI 程序说起. 我选择了 Cursive 这个 Rust TUI 库. 在实现时有这么一个函数, 它会根据参数的不同返回某个组件(如 Button, TextView 等). 在 ​​Cursive​​​ 中, 每个组件都实现了 ​​View​​ 这个 trait, 最初这个函数只会返回某个确定的组件, 所以函数签名可以这样写

      
      
fn some_fn(param: SomeType) -> Button
  • 1.

随着开发进度增加, 这个函数需要返回 Button, TextView 等组件中的一个, 我下意识地写出了类似于下面的代码

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

可惜 Rust 编译器一如既往地打脸, Rust 编译器报错如下

      
      
--> 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 error

For more information about this error, try `rustc --explain E0308`.
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.

从编译器报错信息看函数返回值虽然是 ​​impl View​​​ 但其从 ​​if​​​ 分支推断返回值类型为 ​​Button​​​ 就不再接受 ​​else​​​ 分支返回的 ​​TextView​​​. 这与 Rust 要求 ​​if else​​ 两个分支的返回值类型相同的特性一致. 那能不能让函数返回多种类型呢? Rust 之所以要求函数不能返回多种类型是因为 Rust 在需要在 编译期确定返回值占用的内存大小, 显然不同类型的返回值其内存大小不一定相同. 既然如此, 把返回值装箱, 返回一个胖指针, 这样我们的返回值大小可以确定了, 这样也许就可以了吧. 尝试把函数修改成如下形式:

      
      
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 {}); }}
  • 1.

现在代码通过编译了, 但如果使用 Rust 2018, 你会发现编译器会抛出警告:

      
      
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
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

编译器告诉我们使用 trait object 时不使用 ​​dyn​​​ 的形式已经被废弃了, 并且还贴心的提示我们把 ​​Box<View>​​​ 改成 ​​Box<dyn View>​​, 按编译器的提示修改代码, 此时代码 no warning, no error, 完美.

但 ​​impl Trait​​​ 和 ​​Box<dyn Trait>​​​ 除了允许多种返回值类型的之外还有什么区别吗? ​​trait object​​​ 又是什么? 为什么 ​​Box<Trait>​​​ 形式的返回值会被废弃而引入了新的 ​​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 会把函数

      
      
fn x(...) -> impl Noop
  • 1.

展开为

      
      
fn x_for_foo(...) -> Foofn x_for_bar(...) -> Bar
  • 1.

(仅作原理说明, 不保证编译会这样展开函数名).

通过单态化, 编译器消除了泛型, 而且没有性能损耗, 这也是 Rust 提倡的形式, 缺点是过多展开可能会导致编译生成的二级制文件体积过大, 这时候可能需要重构代码.

静态分发虽然有很高的性能, 但在文章开头其另一个缺点也有所体现, 那就是无法让函数返回多种类型, 因此 Rust 也支持通过 trait object 实现动态分发. 既然 Trait 是具有某种特性的类型的集合, 那我们可以把 Trait 也看作某种类型, 但它是"抽象的", 就像 OOP 中的抽象类或基类, 不能直接实例化.

Rust 的 trait object 使用了与 c++ 类似的 ​​vtable​​​ 实现, trait object 含有1个指向实际类型的 ​​data​​ 指针, 和一个指向实际类型实现 trait 函数的 vtable, 以此实现动态分发. 更加详细的介绍可以在

Exploring Dynamic Dispatch in Rustalschwalm.com


看到. 既然 trait object 在实现时可以确定大小, 那为什么不用 ​​fn x() -> Trait​​​ 的形式呢? 虽然 trait object 在实现上可以确定大小, 但在逻辑上, 因为 Trait 代表类型的集合, 其大小无法确定. 允许 ​​fn x() -> Trait​​​ 会导致语义上的不和谐. 那 ​​fn x() -> &Trait​​ 呢? 当然可以! 但鉴于这种场景下都是在函数中创建然后返回该值的引用, 显然需要加上生命周期:

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

我不喜欢添加额外的生命周期说明, 想必各位也一样. 所以我们可以用拥有所有权的 ​​Box​​​ 智能指针避免烦人的生命周期说明. 至此 ​​Box<Trait>​​​ 终于出现了. 那么问题来了, 为什么编译器会提示 ​​Box<Trait>​​​ 会被废弃, 特地引入了 ​​dyn​​ 关键字呢? 答案可以在 RFC-2113 中找到.

RFC-2113 明确说明了引入 ​​dyn​​​ 的原因, 即语义模糊, 令人困惑, 原因在于没有 ​​dyn​​ 让 Trait 和 trait objects 看起来完全一样, RFC 列举了3个例子说明.

第一个例子, 加入你看到下面的代码, 你知道作者要干什么吗?

      
      
impl SomeTrait for AnotherTrait impl < T > SomeTrait for T where T: Another
  • 1.

你看懂了吗? 说实话我也看不懂 : ) PASS

第二个例子, ​​impl MyTrait {}​​ 是正确的语法, 不过这样会让人以为这会在 Trait 上添加默认实现, 扩展方法或其他 Trait 自身的一些操作. 实际上这是在 trait object 上添加方法.

如在下面代码说明的, Trait 默认实现的正确定义方法是在定义 Trait 时指定, 而不应该在 ​​impl Trait {}​​ 语句块中.

      
      
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();}
  • 1.

​Bar​​​ 在实现了 ​​Foo​​​ 后可以通过 ​​b.default_impl​​​ 调用, 无需额外实现, 但 ​​b.trait_object​​​ 则不行, 因为 ​​trait_object​​​ 方法是 ​​Foo​​ 的 trait object 上的方法.

如果是 Rust 2018 编译器应该还会显示一条警告, 告诉我们应该使用 ​​impl dyn Foo {}​

第三个例子则以函数类型和函数 trait 作对比, 两者差别只在于首字母是否大写(Fn代表函数trait object, fn则是函数类型), 难免会把两者弄混.

更加详细的说明可以移步

RFC-2113github.com


.

总结

​impl trait​​​ 和 ​​dyn trait​​​ 区别在于静态分发于动态分发, 静态分发性能 好, 但大量使用有可能造成二进制文件膨胀; 动态分发以 trait object 的概念通过虚表实现, 会带来一些运行时开销. 又因 trait object 与 Trait 在不引入 ​​dyn​​​ 的情况下经常导致语义混淆, 所以 Rust 特地引入 ​​dyn​​ 关键字, 在 Rust 2018 中已经稳定.

引用

以下是本文参考的资料

impl Trait for returning complex types with easedoc.rust-lang.org

impl trait 社区跟踪github.com

rust-lang/rfcsgithub.com【Rust投稿】捋捋 Rust 中的 impl Trait 和 dyn Trait_编译器Traits and Trait Objects in Rustjoshleeb.comDynamic vs. Static Dispatchlukasatkinson.deExploring Dynamic Dispatch in Rustalschwalm.com



PS: 题图为卢浦大桥, 全上海我最喜欢的大桥, 没有之一~


原网站

版权声明
本文为[51CTO]所创,转载请带上原文链接,感谢
https://blog.51cto.com/u_15683898/5417588

随机推荐