如果你已经编写Rust一段时间了,那么多态可能对你来说并不新鲜!但我还是希望这篇总结能在实践中派上用场。不管怎样,这对我很有用。
当你编写的代码需要处理几种不同类型的值,而事先不知道它们是什么时,Rust对你的要求比很多语言都要多一点。当然,只要代码在运行时能够正常工作,动态语言就允许您传入任何内容。Java/ c#会要求一个接口或超类,鸭子类型的语言,如Go或TypeScript,需要一些结构体类型,具有一组特定属性的对象类型。
Rust是不同的,在Rust中主要有三种处理这种情况的方法,每一种都有自己的优点和缺点。
问题
我们来看一个典型的多态问题,形状问题。
Shape
|-Rectangle
|-Triangle
|-Circle
我们定义perimeter()和area()函数来求各形状的周长和面积,并且可以编写使用这些函数的代码,而不需要关心在给定时间查看哪个特定形状。
Enums
// Data
enum Shape {
Rectangle { width: f32, height: f32 },
Triangle { side: f32 },
Circle { radius: f32 },
}
impl Shape {
pub fn perimeter(&self) -> f32 {
match self {
Shape::Rectangle { width, height } => width * 2.0 + height * 2.0,
Shape::Triangle { side } => side * 3.0,
Shape::Circle { radius } => radius * 2.0 * std::f32::consts::PI
}
}
pub fn area(&self) -> f32 {
match self {
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { side } => side * 0.5 * 3.0_f32.sqrt() / 2.0 * side,
Shape::Circle { radius } => radius * radius * std::f32::consts::PI
}
}
}
// Usage
fn print_area(shape: Shape) {
println!("{}", shape.area());
}
fn print_perimeters(shapes: Vec<Shape>) {
for shape in shapes.iter() {
println!("{}", shape.perimeter());
}
}
Rust中的枚举是一种数据结构,可以用来表示几种不同的形状之一。这些不同的形状将存储在内存中的同一个槽中(槽的大小取其中最大的内存值)。
这是在Rust中实现多态性最直接的方法,它的优点是:
结构化的数据是内联的(不需要跟随对其他内存位置的引用来查找它)。这里最重要的一点是,集合中的枚举在内存中“彼此相邻”,因此检索它们所需的查找更少。这在对性能要求严格的场景中非常有用。
集合可以从它的元素中获取不同的枚举变体。
可以更容易地使用原始数据。
然而,它也有一些缺点:
如果不同变体的大小差异很大,可能会浪费一些内存。在某些变体中是一个大的集合,它可能无论如何都存在于堆中,而不是内联。
更重要的一点是,lib中的枚举不能被该库的用户扩展。在枚举定义好,它就是固定的。
Traits
// Data
trait Shape {
fn perimeter(&self) -> f32;
fn area(&self) -> f32;
}
struct Rectangle { pub width: f32, pub height: f32 }
struct Triangle { pub side: f32 }
struct Circle { pub radius: f32 }
impl Shape for Rectangle {
fn perimeter(&self) -> f32 {
self.width * 2.0 + self.height * 2.0
}
fn area(&self) -> f32 {
self.width * self.height
}
}
impl Shape for Triangle {
fn perimeter(&self) -> f32 {
self.side * 3.0
}
fn area(&self) -> f32 {
self.side * 0.5 * 3.0_f32.sqrt() / 2.0 * self.side
}
}
impl Shape for Circle {
fn perimeter(&self) -> f32 {
self.radius * 2.0 * std::f32::consts::PI
}
fn area(&self) -> f32 {
self.radius * self.radius * std::f32::consts::PI
}
}
trait是Rust中另一个重要的多态概念。它们可以被认为类似其他语言的接口或协议:它们指定一组必须实现的方法,任意的struct都可以实现它,这些struct可以在期望trait的地方使用。
它们相对于枚举的一个主要优势是,你可以从crates中导入一个trait,为你自己的struct实现它,然后将该struct传递给需要该trait的crate中。
有一个缺点,没有办法找出你正在使用的是哪个变体,并得到它的属性。没有instanceof,没有as类型转换,你只能通过实际的trait方法来处理该值。
泛型Trait (静态分发)
// Usage
fn print_area<S: Shape>(shape: S) {
println!("{}", shape.area());
}
fn print_perimeters<S: Shape>(shapes: Vec<S>) { // !
for shape in shapes.iter() {
println!("{}", shape.perimeter());
}
}
Rust trait 可以用来约束泛型函数(或泛型结构)中的类型参数。我们可以说“S必须是一个实现Shape的结构体”,这就给了我们在相关代码中调用trait方法的权限。
与枚举一样,数据的大小在编译时就已经知道了(Rust为传递给它的每个具体类型实现不同的函数副本)。
不过,与枚举不同的是,这阻止我们在同一泛型代码中同时使用多个变量。例如:
fn main() {
let rectangle = Rectangle { width: 1.0, height: 2.0 };
let circle = Circle { radius: 1.0 };
print_area(rectangle); // ✅
print_area(circle); // ✅
print_perimeters(vec![ rectangle, circle ]); // compiler error!
}
这行不通,因为Vec需要单一具体类型。我们可以使用Vec<Rectangle>或Vec<Circle>,但不能同时使用。我们也不能只使用Vec<Shape>,因为Shape在内存中没有固定的大小。这只是一份接口契约,这就引出了……
Trait Object (动态分发)
// Usage
fn print_area(shape: &dyn Shape) {
println!("{}", shape.area());
}
fn print_perimeters(shapes: Vec<&dyn Shape>) {
for shape in shapes.iter() {
println!("{}", shape.perimeter());
}
}
在Rust语法中,&Foo是对结构Foo的引用,而&dyn Bar是对实现某种特征Bar的结构的引用。trait没有固定的大小,但是指针有固定的大小。
fn main() {
let rectangle = Rectangle { width: 1.0, height: 2.0 };
let circle = Circle { radius: 1.0 };
print_area(&rectangle); // ✅
print_area(&circle); // ✅
print_perimeters(vec![ &rectangle, &circle ]); // ✅
}
这里我们可以混合匹配结构体,因为它们的所有数据都在指针之后,指针有一个已知的大小,集合可以用它来分配内存。
需要注意的是:动态分发本身涉及到在虚表中查找所需的方法。通常,编译器会提前知道方法代码的确切内存位置,并可以硬编码该地址。但使用动态分发,它无法提前知道它有什么类型的结构,所以当代码实际运行时,需要做一些额外的工作来找出它的方法在哪里。
本文翻译自:
https://www.brandons.me/blog/polymorphism-in-rust