rust 快速入门——12 面向对象

122 阅读17分钟

[!|center] 普若哥们儿

github.com/wu-hongbing…

gitee.com/wuhongbing/…

面向对象

面向对象程序设计核心思想是封装继承多态。Rust 通过向数据类型添加方法实现 数据+方法封装,通过 trait 实现继承多态,因此 Rust 具有面向对象的特性。

封装

Rust 通过向某个数据类型中添加方法来得到类,数量类型本身拥有数据,添加了方法就得到 数据+方法 的封装。

struct Int {
    x: i32,
}

impl Int {
    fn add(&mut self, num: i32) {
        self.x = self.x + num;
    }
    fn get_x(&self) -> i32 {
        self.x
    }
    fn owner_x(self) -> i32 { //获得所有权
        self.x
    }
}

fn main() {
    let mut i1 = Int { x: 1 };
    i1.add(5);
    println!("{}", i1.get_x());
    println!("{}", i1.owner_x());
    println!("{}", i1.owner_x()); //不能再次调用owner_x()
}

例中往 Int 结构体中添加了 addget_xowner_x 方法,其中 &self 相当于 C++中的 this 指针,调用时不需要传递参数,由编译器自动为它赋值。这样,Int 类型就类似 C++中的 class,封装了数据和方法。如果封装是一个语言被认为是面向对象语言所必要的方面的话,那么 Rust 满足这个要求。

get_x 的签名中,&self 实际上是 self:&Self 的简写,Self 类型是 impl 关键字所修饰的类型的别名,在例中相当于 Int。通过对象调用 get_x(self:&Self) 方法时,self 是该对象的不可变引用。

同样,&mut selfself:&mut Self 的简写,self 是对象的可变引用;selfself:Self 的简写,self 就是对象本身,并获取了所有权,但是不能通过 self 修改对象的值;mut selfmut self:Self 的简写,self 就是对象本身,并获取了所有权,同时通过 self 可以修改对象的值。

方法的第一个参数必须是一个名为 self 的参数,所以 Rust 让你在第一个参数位置上只用 self 这个名字来简化。

第 12 行的方法参数为 self,方法获得对象的所有权,因此第 22 行会报错,因为对象所有权已经转移。

继承与多态

继承Inheritance)是一个很多编程语言都提供的机制,一个对象可以定义为继承另一个对象定义中的元素,这使其可以获得父对象的数据和行为,而无需重新定义。

如果一个语言必须有继承才能被称为面向对象语言的话,那么 Rust 就不是面向对象的,因为没有语法支持定义一个结构体继承父结构体的成员和方法。

然而,如果你过去常常在你的编程过程中使用继承,根据你最初考虑继承的原因,Rust 也提供了其他的解决方案。

选择继承有两个主要的目的。第一个是为了重用代码:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。Rust 代码中可以使用默认 trait 方法实现来进行有限的共享,这类似于父类有一个方法的实现,而通过继承子类也拥有这个方法的实现。

第二个使用继承的目的与类型系统有关:表现为子类型可以用于父类型被使用的地方。这也被称为 多态polymorphism),这意味着如果多种对象共享特定的属性,则可以相互替代使用。

近年来继承作为一种语言设计的解决方案在很多语言中失宠了,因为其时常带有共享多于所需的代码的风险。子类不应总是共享其父类的所有特征,但是继承却始终如此。如此会使程序设计更为不灵活,并引入无意义的子类方法调用,或由于方法实际并不适用于子类而造成错误的可能性。

另外某些语言还只允许单继承(意味着子类只能继承一个父类),进一步限制了程序设计的灵活性。

一个数据类型实现了某种 trait 意味着具有某种方法。Rust 通过让多个数据类型具有相同的trait,相当于他们从同一个父类派生,从而实现了继承的特性。

rust 通过泛型函数、 trait 对象作为函数参数、实现了特定 trait 类型的引用作为函数参数等方式实现多态。

  1. 通过带trait bound的泛型函数实现多态:
trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(&self); //相当于接口说明
}

struct Rectangle {
    width: u32,
    height: u32,
}
struct Circle {
    radius: u32,
}

impl Draw for Rectangle { //实现接口
    fn draw(&self) {
        println!("Rectangle");
    }  
}

impl Draw for Circle {  //实现接口
    fn draw(&self) {
        println!("Circle");
    }  
}

fn draw_it<T: Draw>(item: &T) {  //通过带*trait bound*的泛型函数实现多态
    item.draw();
}

fn main() {
	let rectangle = Rectangle { width: 10, height: 20 };
	let circle = Circle { radius: 5 };
	draw_it(&rectangle);
	draw_it(&circle);
}

第 25-27 行,前面章节已经说过,可以使用特性约束 (trait bound) 语法的语法糖简写为:

fn draw_it(item: &impl Draw) {
    item.draw();
}

为一个类型添加方法与为类型添加 trait 是有区别的,即使实现的函数名相同,比如:

trait Draw {  //相当于java中的interface,或者C++中的抽象类
    fn draw(&self); //相当于接口说明
}

struct Rectangle {
    width: u32,
    height: u32,
}
struct Circle {
    radius: u32,
}

impl Rectangle { //添加方法
    fn draw(&self) {
        println!("Rectangle");
    }  
}

impl Circle {  //添加方法
    fn draw(&self) {
        println!("Circle");
    }  
}

fn draw_it<T: Draw>(item: T) {  //通过泛型实现多态
    item.draw();
}

fn main() {
    let rectangle = Rectangle { width: 10, height: 20 };
    let circle = Circle { radius: 5 };
    rectangle.draw();
    circle.draw();
    draw_it(rectangle);
    draw_it(circle);
}

例子中定义了名为 Draw 的 trait,RectangleCircle 并没有实现这个 trait,但是为它们添加了 draw 方法,方法的签名与 trait 中的函数签名完全一致,但是第 34 和第 35 行仍然报错,编译器认为 RectangleCircle 没有实现 Draw 这个 trait!

  1. 通过 trait 对象实现多态:
trait Foo {
    fn method(&self);
}
impl Foo for u8 {
    fn method(&self) {
        println!("u8: {}", self)
    }
}
impl Foo for String {
    fn method(&self) {
        println!("string: {}", self)
    }
}
fn do_something(x: &dyn Foo) { // 通过trait对象实现多态
    x.method();
}
fn main() {
    let x: String = "Hello".to_string();
    do_something(&x);
    let y: u8 = 8;
    do_something(&y);
}

-> 运算符到哪去了?

在 C/C++ 语言中,有两个不同的运算符来调用方法:. 直接在对象上调用方法,而 -> 在一个对象的指针上调用方法,这时需要先解引用(dereference)指针。换句话说,如果 object 是一个指针,那么 object->something() 就像 (*object).something() 一样。

Rust 并没有一个与 -> 等效的运算符;相反,Rust 有一个叫 自动引用和解引用automatic referencing and dereferencing)的功能。

它是这样工作的:当使用 object.something() 调用方法时,Rust 会自动为 object 添加 &&mut* 以便使 object 与方法签名匹配。比如:

#[derive(Debug, Copy, Clone)]
struct Point {
    x: f64,
    y: f64,
}

impl Point {
    fn distance(&self, other: &Point) -> f64 {
        let x_squared = f64::powi(other.x - self.x, 2);
        let y_squared = f64::powi(other.y - self.y, 2);

        f64::sqrt(x_squared + y_squared)
    }
}
fn main() {
    let p1 = Point { x: 0.0, y: 0.0 };
    let p2 = Point { x: 5.0, y: 6.5 };
    p1.distance(&p2);
    (&p1).distance(&p2);
}

第 18 、19 行是等价的,但是第 18 行更简洁。这种自动引用的行为之所以有效,是因为方法有一个明确的接收者———— self 的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self),做出修改(&mut self)或者是获取所有权(self)。事实上,Rust 对方法接收者的隐式借用让所有权在实践中更友好。

关联函数

所有在 impl 块中定义的函数被称为 关联函数associated functions),因为它们与 impl 后面命名的类型相关联。我们可以定义不以 self 为第一参数的关联函数(因此不是方法),因为它们并不作用于一个结构体的实例。我们已经使用过一个这样的函数:在 String 类型上定义的 String::from 函数。

关联函数类似于 C++中的类方法,通过类名直接调用,不与对象关联,因此不需要 this 指针。

不是方法的关联函数经常被用作返回一个结构体新实例的构造函数。这些函数的名称通常为 new ,但 new 并不是一个关键字。例如我们可以提供一个叫做 square 关联函数,它接受一个维度参数并且同时作为宽和高,这样可以更轻松的创建一个正方形 Rectangle 而不必指定两次同样的值:

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn square(size: u32) -> Self {
        Self {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(3);
}

关键字 Self 在函数的返回类型中代指在 impl 关键字后出现的类型,在这里是 Rectangle。调用这个关联函数要使用结构体名和 :: 语法,比如 Rectangle::square(3);。这个函数位于结构体的命名空间中::: 语法用于关联函数和模块创建的命名空间。

多个 impl

每个结构体都允许拥有多个 impl 块。例如,下例中的代码每个方法有其自己的 impl 块。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

impl Rectangle {
    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    let rect2 = Rectangle {
        width: 10,
        height: 40,
    };
    let rect3 = Rectangle {
        width: 60,
        height: 45,
    };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

这里没有理由将这些方法分散在多个 impl 块中,不过这是有效的语法。

方法定义中的泛型

在为结构体和枚举实现方法时,一样也可以用泛型。下例中定义了结构体 Point<T>,和在其上实现的名为 x 的方法。

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };

    println!("p.x = {}", p.x());
}

这里在 Point<T> 上定义了一个叫做 x 的方法来返回字段 x 中数据的引用。

注意必须在 impl 后面声明 T,这样就可以在 Point<T> 上实现的方法中使用 T通过在 impl 之后声明泛型 T,Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型

定义方法时也可以为泛型指定限制(constraint)。例如,可以选择为 Point<f32> 实例实现方法,而其他 T 不是 f32 类型的 Point<T> 实例则没有定义此方法:

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10 };
    let p2 = Point { x: 5.5, y: 10.5 };

    println!("p.x = {}", p1.x());
    println!("p.x = {}", p2.distance_from_origin());
}

第 6 行写为 imp Point<T>,则与第 12 行形式相同,编译器会认为 T 是一个类型。比如下例:

struct Point< T > {
    x: T,
    y: T,
}

type T=i32;  // 将 T 定义为一个类型别名

impl Point< T > { // 等价于   impl Point< f32 >
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };
    println!("p.x = {}", p.x());
}

结构体定义中的泛型类型参数并不总是与结构体方法签名中使用的泛型是同一类型。下例为 Point 结构体使用了泛型类型 X1Y1,为 mixup 方法签名使用了 X2Y2 来使得示例更加清楚。这个方法用 selfPoint 类型的 x 值(类型 X1)和参数的 Point 类型的 y 值(类型 Y2)来创建一个新 Point 类型的实例:

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

main 函数中,定义了一个有 i32 类型的 x(其值为 5)和 f64y(其值为 10.4)的 Pointp2 则是一个有着字符串 slice 类型的 x(其值为 "Hello")和 char 类型的 y(其值为 c)的 Point。在 p1 上以 p2 作为参数调用 mixup 会返回一个 p3,它会有一个 i32 类型的 x,因为 x 来自 p1,并拥有一个 char 类型的 y,因为 y 来自 p2println! 会打印出 p3.x = 5, p3.y = c

这个例子的目的是展示一些泛型通过 impl 声明而另一些通过方法定义声明的情况。这里泛型参数 X1Y1 声明于 impl 之后,因为它们与结构体定义相对应。而泛型参数 X2Y2 声明于 fn mixup 之后,因为它们只是相对于方法本身的。

使用特性约束 trait bound 有条件地实现方法

通过使用带有 特性约束 (trait bound) 的泛型参数的 impl 块,可以有条件地只为那些实现了特定 trait 的类型实现方法。例如,下例中的类型 Pair<T> 总是实现了 new 方法并返回一个 Pair<T> 的实例,Self 是一个 impl 块类型的类型别名(type alias),在这里是 Pair<T>)。不过在下一个 impl 块中,只有那些为 T 类型实现了 PartialOrd trait(来允许比较) Display trait(来启用打印)的 Pair<T> 才会实现 cmp_display 方法:

use std::fmt::Display;

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

也可以对任何实现了特定 trait 的类型有条件地实现 trait。对任何满足特定 trait bound 的类型实现 trait 被称为 blanket implementations,它们被广泛的用于 Rust 标准库中。例如,标准库为任何实现了 Display trait 的类型实现了 ToString trait。这个 impl 块看起来像这样:

impl<T: Display> ToString for T {
    // --snip--
}

因为标准库有了这些 blanket implementation,我们可以对任何实现了 Display trait 的类型调用由 ToString 定义的 to_string 方法。例如,可以将整型转换为对应的 String 值,因为整型实现了 Display

let s = 3.to_string();

blanket implementation 会出现在 trait 文档的 “Implementers” 部分。

trait 和 trait bound 让我们能够使用泛型类型参数来减少重复,而且能够向编译器明确指定泛型类型需要拥有哪些行为。然后编译器可以利用 trait bound 信息检查代码中所用到的具体类型是否提供了正确的行为。在动态类型语言中,如果我们调用了一个未定义的方法,会在运行时出现错误。Rust 将这些错误移动到了编译时,甚至在代码能够运行之前就强迫我们修复问题。另外,我们也无需编写运行时检查行为的代码,因为在编译时就已经检查过了。这样既提升了性能又不必放弃泛型的灵活性。

完全限定语法与消歧义

两个 trait 可以有相同名称的方法,一个类型可以同时实现这两个 trait,甚至可以直接在类型上实现已经有的同名方法!当调用这些同名方法时,需要告诉 Rust 我们希望使用哪一个。

考虑一下下例中的代码,这里定义了 trait PilotWizard 都拥有方法 fly。接着在一个本身已经实现了名为 fly 方法的类型 Human 上实现这两个 trait。每一个 fly 方法都进行了不同的操作:

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    person.fly();
}

当调用 Human 实例的 fly 时,编译器默认调用直接实现在类型上的方法,运行这段代码会打印出 *waving arms furiously*,这表明 Rust 调用了直接实现在 Human 上的 fly 方法。

为了能够调用 Pilot trait 或 Wizard trait 的 fly 方法,我们需要使用更明显的语法以便能指定我们指的是哪个 fly 方法。这个语法展示在下例中:

trait Pilot {
    fn fly(&self);
}

trait Wizard {
    fn fly(&self);
}

struct Human;

impl Pilot for Human {
    fn fly(&self) {
        println!("This is your captain speaking.");
    }
}

impl Wizard for Human {
    fn fly(&self) {
        println!("Up!");
    }
}

impl Human {
    fn fly(&self) {
        println!("*waving arms furiously*");
    }
}

fn main() {
    let person = Human;
    Pilot::fly(&person);
    Wizard::fly(&person);
    person.fly();
}

在方法名前指定 trait 名向 Rust 澄清了我们希望调用哪个 fly 实现。也可以选择写成 Human::fly(&person),这等同于上例中的 person.fly()

因为 fly 方法获取一个 self 参数,如果有两个 类型 都实现了同一 trait,Rust 可以根据 self 的类型计算出应该使用哪一个 trait 实现。

然而,不是方法的关联函数没有 self 参数。当存在多个类型或者 trait 定义了相同函数名的非方法函数时,Rust 就不总是能计算出我们期望的是哪一个类型,除非使用 完全限定语法fully qualified syntax)。

下例 Animal trait 有一个关联非方法函数 baby_name。结构体 Dog 实现了 Animal,同时又直接提供了关联非方法函数 baby_name

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Dog::baby_name());
}

Dog 上定义的关联函数 baby_name 的实现代码将所有的小狗起名为 Spot。Dog 类型还实现了 Animal trait,它描述了所有动物的共有的特征。小狗被称为 puppy,这表现为 DogAnimal trait 实现中与 Animal trait 相关联的函数 baby_name

main 调用了 Dog::baby_name 函数,它直接调用了定义于 Dog 之上的关联函数。这段代码会打印出:

A baby dog is called a Spot

这并不是我们需要的。我们希望调用的是 DogAnimal trait 实现那部分的 baby_name 函数,这样能够打印出 A baby dog is called a puppy。如下修改会得到一个编译错误:

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", Animal::baby_name());
}

因为 Animal::baby_name 没有 self 参数,同时这可能会有其它类型实现了 Animal trait,Rust 无法计算出所需的是哪一个 Animal::baby_name 实现。

为了消歧义并告诉 Rust 我们希望使用的是 DogAnimal 实现而不是其它类型的 Animal 实现,需要使用 完全限定语法,这是调用函数时最为明确的方式。下例展示了如何使用完全限定语法:

trait Animal {
    fn baby_name() -> String;
}

struct Dog;

impl Dog {
    fn baby_name() -> String {
        String::from("Spot")
    }
}

impl Animal for Dog {
    fn baby_name() -> String {
        String::from("puppy")
    }
}

fn main() {
    println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
    println!("A baby dog is called a {}", Dog::baby_name());
}

我们在尖括号中向 Rust 提供了类型注解,并通过在此函数调用中将 Dog 类型当作 Animal 对待,来指定希望调用的是 DogAnimal trait 实现中的 baby_name 函数。

完全限定语法定义为:

<Type as Trait>::function(receiver_if_method, next_arg, ...);

对于不是方法的关联函数,其没有一个 receiver,故只会有其他参数的列表。可以选择在任何函数或方法调用处使用完全限定语法。然而,允许省略任何 Rust 能够从程序中的其他信息中计算出的部分。只有当存在多个同名实现而 Rust 需要帮助以便知道我们希望调用哪个实现时,才需要使用这个较为冗长的语法。