• 探索Rust的多态理念
  • 发布于 2个月前
  • 114 热度
    0 评论
多态(Polymorphism),英文单词来源自希腊语,"poly" 意为 "多","morph" 意为 "形态",也就是“多形态”的意思,翻译为“多态”也比较贴切。遥想当年初学Java时,那时的我对Polymorphism还是相当困惑,为何是多种形态?到底是什么意思,因为中文语境里面并不存在“多态”这个词。我打算以我的理解方式,白话一下“多态”一词。

函数(方法)寻址
从调用者的角度看,不管什么语言,所谓多态,最终都是解决函数(方法)寻址的问题,也就是我写了一个函数调用,代码实际调用哪个具体的函数?为了解释清楚这个问题,我们还要分别从两种情况来分析。

静态寻址
在有些编程语言中,函数或方法是支持重载的,也就是函数名一样,参数列表不一样,或者参数列表中的参数类型不一样。
class OverloadExample {
    void display(int a) {
        System.out.println("Displaying int: " + a);
    }

    void display(String s) {
        System.out.println("Displaying String: " + s);
    }

    public static void main(String[] args) {
        OverloadExample obj = new OverloadExample();
        obj.display(5);       // 调用第一个display方法
        obj.display("Hello"); // 调用第二个display方法
    }
}
从调用者的视角来看,会由于传入参数的不同,被寻址到一个特定的函数,这一切是静态的、发生在编译时。换句话说只是让程序员从人类视角上,在写代码时感受到了多态,即同一个函数具有多种形态。从调用者的角度看,静态寻址并没有让调用方代码得到简化,只是简化了程序员的心智负担。

动态寻址
对于java这类提供Interface和class的OOP语言,在调用方视角看,通过面向接口编程的方式,的确提升了代码重用能力。因为随着传入的实例对象不同,想同的调用代码逻辑会产生不一样的行为。换句话说,就是同一个方法调用代码,在你编程时并不知道最终调用哪个实际的函数代码,只有在运行时才能寻址到具体的函数,这也就是动态寻址了。

此刻多态,动态多种形态提供了几个关键的特点:
统一的接口:多态性允许调用方使用统一的接口来处理不同类型的对象。例如,通过使用基类或接口类型的引用,调用方可以编写通用的代码来处理所有实现该接口或继承该基类的对象。这种统一接口的方式大大减少了重复代码,提高了代码的复用性。
灵活的扩展:多态性使得系统更容易扩展。当需要添加新的对象类型时,只要新的类型实现了预期的接口或继承了预期的基类,现有的调用方代码就不需要修改。这种灵活性使得代码更具可扩展性,同时也提高了复用性。
减少耦合:多态性通过依赖于抽象而不是具体实现,减少了调用方代码与具体实现之间的耦合。这种松耦合的设计使得调用方代码更加独立于具体实现,从而更容易在不同的上下文中复用。
简化维护:多态性简化了代码的维护工作。由于调用方代码依赖于抽象而不是具体实现,当需要修改或替换具体实现时,调用方代码通常不需要修改。这种简化维护的特性也间接地提高了代码的复用性。
提高可测试性:多态性使得代码更容易进行单元测试。通过使用接口或基类类型的引用,调用方可以轻松地替换实际的对象实现,使用模拟对象(mock objects)或测试替身(test doubles)进行测试。这种可测试性也促进了代码的复用。
这些优势也是Java等OOP语言在应用领域大受欢迎的原因之一。

Rust的多态
上面我们解释了所谓多态,本质上就是函数寻址,接下来我们看看Rust语言如何理解多态的。在Rust语言中,多态性主要通过泛型(Generics)和特征(Traits)来实现。这两种机制允许开发者编写灵活且可重用的代码,同时保持类型安全和性能。

静态寻址
上文我们距离的函数重载的例子,大家可以明显看出代码时重复的。Rust语言其实也不支持函数重载。Rust通过泛型,更智能的实现了静态多态。你可以认为泛型代码在逻辑上就是提供了“模版”(只不过这种模版是严格约束的),当调用者在调用泛型函数时,编译器会自动为该类型准备一个特殊的实现。并且调用代码静态绑定到这个新函数实现上。
fn print_item<T: std::fmt::Display>(item: T) {
    println!("{}", item); // 堆代码 duidaima.com
}

fn main() {
    print_item(5);       // 输出 "5"
    print_item("Hello"); // 输出 "Hello"
}
这种优势相当明显,不仅简化了调用方的心智负担,也避免重复手工实现函数重载。所以这也是多态行为,因为对于调用方而言,同一个函数调用会被寻址到各种不一样的函数,而且这些函数还是自动从模版生成的。从这一点来看,java的方法重载真的是相当的弱,难怪Rust不需要实现函数重载,不是它不能,而是不屑于实现。

动态寻址
Rust中没有Interface的概念,虽然有Trait,但是Trait是不能作为类型的。所以Rust引入了trait object(trait对象)这个类型,Trait对象是一种特殊的类型,它允许你在运行时存储和操作实现了某个trait的任何类型的值。Trait对象是通过使用&dyn Trait或Box语法来创建的。
trait Animal {
    fn make_sound(&self);
}

struct Dog;
struct Cat;

impl Animal for Dog {
    fn make_sound(&self) {
        println!("Dog barks");
    }
}

impl Animal for Cat {
    fn make_sound(&self) {
        println!("Cat meows");
    }
}

fn make_animal_sound(animal: &dyn Animal) {
    animal.make_sound();
}

fn main() {
    let dog = Dog;
    let cat = Cat;
    
    make_animal_sound(&dog); // 输出 "Dog barks"
    make_animal_sound(&cat); // 输出 "Cat meows"
}
这意味着它们可以指向任何实现了 Animal trait 的对象。当调用 make_sound 方法时,实际调用的是对象的具体实现,这就是动态分派的体现。

优点
灵活性:动态分派允许你在运行时处理不同类型的对象,只要这些对象实现了相同的trait。这提供了很大的灵活性,特别是在处理集合或需要多态行为的情况下。

简化代码:通过使用trait对象,你可以编写更通用的代码,而不需要为每种类型单独编写代码。这简化了代码的复杂性,使得代码更易于维护和扩展。


缺点
性能开销:与静态分派相比,动态分派通常会有一些性能开销。因为编译器无法在编译时确定具体的方法调用,需要在运行时查找和调用方法,这可能会导致一些额外的开销。
类型安全:使用trait对象时,你失去了一些编译时的类型检查。因为trait对象是动态的,编译器无法在编译时检查所有可能的类型,这可能会导致运行时错误。其实trait object提供了类似java interface的功能,为程序员提供了一种动态多态的能力。

总结
多态的本质就是函数寻址,包含静态和动态两种。Rust语言很精炼,仅仅通过泛型、Trait 两个基本的概念,就能同时提供强大的静态多态、动态多态的能力,难怪有那么多人喜爱Rust语言。不过Rust就和榴莲一样,喜欢的人觉得是个宝,就好这一口,不懂的人感觉避之不及。
用户评论