:静态分发与动态分发)
我们曾在上一篇文章《Rust 颠覆过你的面向对象编程世界观吗》中简单提到过 Rust 是怎样实现“多态”的但没有展开。此前在《Rust 语言特性Trait》一文中也介绍过 Rust 是如何通过 trait 泛型的技术组合实现“多态”的但这两篇文章都没有系统地讨论过“多态”的实现这次我们就来深入地讨论一下这个话题。真诚推荐本公众号近期的两篇精华好文《你真得懂 Rust 借用检查吗一文聊透 Borrow Checker 底层逻辑权限模型》和《深入理解 Rust 生命周期可能是全网最透彻的解读了》。1. 重新思考“多态”是怎么实现的好像过去我们在学习其他语言的时候并没有什么书籍或文章特别介绍过“多态”是怎么实现的这是一个很有趣的问题我的一个猜测是多态的实现其实已经是编译器的范畴了普通的开发者并不需要知道如此底层的细节。不过到了 Rust一些关于多态的“细节”直接暴露给了用户这就需要我们不但要“知其然”还要“知其所以然”。怎么切入这个话题呢我们就从经典的“猫狗喵汪叫”示例开始聊起吧这是我自己给这个例子起的一个颇为得意的名字 不需要介绍什么业务背景了吧就是Dog和Cat两个 struct加一个SoundabletraittraitSoundable{fnsound(self);}structDog{name:String,}structCat{name:String,}implSoundableforDog{fnsound(self){println!({}: Woof!,self.name);}}implSoundableforCat{fnsound(self){println!({}: Meow!,self.name);}}fnmake_soundT:Soundable(animal:T){animal.sound();}fnmain(){letdogDog{name:Buddy.to_string()};letcatCat{name:Kitty.to_string()};make_sound(dog);make_sound(cat);}make_sound这个泛型函数接受的是一个Soundable类型的参数Soundable只是一个 trait 并不是一个具体类型实际传递给make_sound的参数必须是一个具体的 struct 或 enum所以我们的调用代码是make_sound(dog)和make_sound(cat)。对make_sound这个函数来说它的实现毕竟是基于Soundable类型编写的dog和cat之所以可以被接受并不如我们想象的那般容易我们已经习惯了“多态”这种“自然而然”的行为总是认为编译器应该会像我们一样“聪明”吧把dog和cat丢给make_sound它应该知道怎么把它们当成Soundable类型来处理。嗯…怎么说呢编译器确实办到了但不是用我们以为的方式但其实我们自己可能也没想过“我们以为的方式”具体的细节到底是什么因为对于人类来说这个事情是如此的显而易见根本不需要动脑筋但是如果让编译器去完成这件事情就得设计出一套切实可行的方法这种方法就叫“分发”dispatch2. 分发“分发”dispatch这个术语并不是 Rust 独有的它其实应该算是编译器领域里的一个概念只是 Rust 把这个概念直接暴露给了用户。简单地说分发就是确定使用哪一个版本的函数也就是跳转到哪一个内存地址。显然这里有一个上下文就是针对同一个“抽象”它有多种具体的“实现”这才会涉及到如何“分发”的问题就如同我们上面的“猫狗喵汪叫”示例那样。2.1 静态分发为了不让大家感觉这个概念太 Rust我们从 C 开始聊一下吧因为 C 和 Rust 一样都是既有静态分发又有动态分发并且实现思想也很相似。我们知道C 中的模板方法采用的是模板实例化template instantiation也就是编译器把“带类型参数的模板”真正变成具体代码的过程而 Rust 的泛型实现方法是单态化monomorphization即编译器为每一种具体类型生成一份专门代码。这和 C 的“模板实例化”是同类型的实现思路。说白了就是针对泛型方法中出现的每一种具体类型“编译一个对应的版本”了事可谓是“大力出奇迹”这样每一个具体类型的调用其实都对应着一个单独编译的函数版本比如在开头的示例中实际情形是make_sound(dog)和make_sound(cat)会被分别编译成fnmake_sound_for_dog(animal:Dog){animal.sound();// 直接调用 Dog::sound}fnmake_sound_for_cat(animal:Cat){animal.sound();// 直接调用 Cat::sound}上面展示的既是泛型的“单态化”同时也是“静态分发”只是描述的出发点不同而已也可以这样说静态分发的实现方式就是泛型的“单态化”也就是针对泛型方法中出现的每一种具体类型“编译一个对应版本”它发生在编译期是实打实的“零成本”抽象没有任何的性能损失唯一的缺点就是如果牵涉的具体类型过多编译出的二进制文件可能会比较大不过这算不上是大问题。静态分发显然是“极好的”也是 Rust 鼓励我们优先选择的分发模式。2.2 动态分发有静态分发就有动态分发对比着看的话动态分发就是在运行期动态决定调用哪一个具体版本的函数所以动态分发就不会有多个不同的编译版本出现了。在 Rust 中动态分发使用的是 dyn Trait也称 Trait Object同样的还是先看示例代码所有的代码保持和文章开头的示例一样只需要把原来的make_sound改为下面的实现// fn make_soundT: Soundable(animal: T) {// animal.sound();// }fnmake_sound(animal:dynSoundable){animal.sound();}当我们执行make_sound(dog)时传入的dog将会发生强制转换它会被转换为dyn Soundable这个被转换后的dyn Soundable就是传说中的“Trait Object”。单就“猫狗喵汪叫”这个示例来看动态分发和静态分发在使用效果上是没有区别的那在有了很高效的静态分发下为什么 Rust 还要引入 dyn Trait 呢原因很简单因为有些时候在编写一个函数时你没有办法确定它将会处理什么类型并不是还有未知的实现类型而是无法确定是已知实现类型中的哪一种因为具体的类型要到运行期才能确定下来且可能是用户主导的不确定行为。有这样的例子吗这种“运行时才能确定下来”的情形最经典的场景就是 GUI 应用因为程序启动后的行为是由用户驱动的用户的行为是不可预测的。不过为人所熟知的《The Rust Programming Language》举过的那个 Screen 的例子并不是很有说服力因为在 Screen 中添加什么 components 对一个应用来说是确定的示例代码却用 components 数组来故意模糊这种确定性试图营造一种“不可预测性”。我们换一种更有说服力的场景假设我们要开发一个截图编辑软件用户可以在这个软件的工具栏里选择一些预制的图形拖放到画布上以便对截图做一些标注常用的图形有Line、Circle、Rectangle 等它们都实现了 Shape 这个 trait。拖放的图形放置于画布 Canvas 内所以Canvas 会有一个数组来存放用户拖放的图形这个 Canvas 应该是这样的structCircle;structRectangle;traitShape{fndraw(self);}implShapeforCircle{fndraw(self){println!(Circle draw!);}}implShapeforRectangle{fndraw(self){println!(Rectangle draw!);}}structCanvas{shapes:VecBoxdynShape}implCanvas{// 用户在画布上添加图形时会调用该方法fnadd_shape(mutself,shape:BoxdynShape){self.shapes.push(shape);}// 绘制画布自然也会绘制出它包含的所有图形fndraw_all(self){forshapeinself.shapes{shape.draw();}}}fnmain(){letmutcanvasCanvas{shapes:vec![]};// 模拟运行期用户在画布上自主添加图形canvas.add_shape(Box::new(Circle));canvas.add_shape(Box::new(Rectangle));canvas.draw_all();}这就是一个典型的必须使用 dyn Trait 的场景因为shapes存放的图形类型完全是在运行期由用户决定的。对于很多 Rust 新手来说这个场景他们是非常认可的但是这个例子未必能解答他的疑惑因为他可能会想把 Canvas 泛型化应该也能解决这个问题吧就像是这样structCanvasT:Shape{shapes:VecT}这是 Rust 新手很容易混淆的一个问题那就是在这里声明的类型参数T虽然被限定为Shape类型但这并不意味着你可以随意将各种具体的Shape类型“混着”塞给Canvas因为一个CanvasT: Shape实例化后要么是一个CanvasCircle要么是一个CanvasRectangle这是早在编译期就确定下来的所以你不可以让一个CanvasCircle实例 add 一个Rectangle我们可以通过代码来验证implT:ShapeCanvasT{fnadd_shape(mutself,shape:T){self.shapes.push(shape);}fndraw_all(self){forshapeinself.shapes{shape.draw();}}}fnmain(){letmutcanvasCanvas{shapes:vec![]};// 在编译这一行时编译器就知道 canvas 是一个 CanvasCircle 了// 所以实际上编译出的是一个类似于 Canvas_Circle 版本的 Canvascanvas.add_shape(Circle);// 下面一行代码会报错Type mismatch expected Circle, but found Rectanglecanvas.add_shape(Rectangle);canvas.draw_all();}上面这个“走歪了”的示例又把我们带回到了静态分发这其实说明了一个问题我们过去在其他 OO 语言里习惯了的那种“任何子类都可以透明替代父类”的“传统多态”恰好就是我们正在讨论的动态分发也就是 dyn Trait。那在 Rust 中动态分发是怎么实现的呢这就得聊一下胖指针和虚函数表vtable了我们会安排在下一篇文章中继续介绍。