【Rust】Rust学习 第十七章Rust 的面向对象特性

面向对象编程(Object-Oriented Programming,OOP)是一种模式化编程方式。对象(Object)来源于 20 世纪 60 年代的 Simula 编程语言。这些对象影响了 Alan Kay 的编程架构中对象之间的消息传递。他在 1967 年创造了 面向对象编程 这个术语来描述这种架构。关于 OOP 是什么有很多相互矛盾的定义;在一些定义下,Rust 是面向对象的;在其他定义下,Rust 不是。在本章节中,我们会探索一些被普遍认为是面向对象的特性和这些特性是如何体现在 Rust 语言习惯中的。接着会展示如何在 Rust 中实现面向对象设计模式,并讨论这么做与利用 Rust 自身的一些优势实现的方案相比有什么取舍。

17.1 面向对象语言的特征

关于一个语言被称为面向对象所需的功能,在编程社区内并未达成一致意见。Rust 被很多不同的编程范式影响,包括面向对象编程;比如第十三章提到了来自函数式编程的特性。面向对象编程语言所共享的一些特性往往是对象、封装和继承。让我们看一下这每一个概念的含义以及 Rust 是否支持他们。

对象包含数据和行为

由 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides(Addison-Wesley Professional, 1994)编写的书 Design Patterns: Elements of Reusable Object-Oriented Software 被俗称为 The Gang of Four,它是面向对象编程模式的目录。它这样定义面向对象编程:

面向对象的程序是由对象组成的。一个 对象 包含数据和操作这些数据的过程。这些过程通常被称为 方法 或 操作。

在这个定义下,Rust 是面向对象的:结构体和枚举包含数据而 impl 块提供了在结构体和枚举之上的方法。虽然带有方法的结构体和枚举并不被 称为 对象,但是他们提供了与对象相同的功能。

封装隐藏了实现细节

另一个通常与面向对象编程相关的方面是 封装encapsulation)的思想:对象的实现细节不能被使用对象的代码获取到。所以唯一与对象交互的方式是通过对象提供的公有 API;使用对象的代码无法深入到对象内部并直接改变数据或者行为。封装使得改变和重构对象的内部时无需改变使用对象的代码。

就像我们在第七章讨论的那样:可以使用 pub 关键字来决定模块、类型、函数和方法是公有的,而默认情况下其他一切都是私有的。比如,我们可以定义一个包含一个 i32 类型 vector 的结构体 AveragedCollection 。结构体也可以有一个字段,该字段保存了 vector 中所有值的平均值。这样,希望知道结构体中的 vector 的平均值的人可以随时获取它,而无需自己计算。换句话说,AveragedCollection 会为我们缓存平均值结果。

pub struct AveragedCollection {
    list: Vec,
    average: f64,
}

注意,结构体自身被标记为 pub,这样其他代码就可以使用这个结构体,但是在结构体内部的字段仍然是私有的。这是非常重要的,因为我们希望保证变量被增加到列表或者被从列表删除时,也会同时更新平均值。可以通过在结构体上实现 addremove 和 average 方法来做到这一点

// 公有的结构体
pub struct AveragedCollection {
    list: Vec,
    average: f64,
}
impl AveragedCollection {
    // 公有
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }
    // 公有
    pub fn remove(&mut self) -> Option {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            },
            None => None,
        }
    }
    // 公有
    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

公有方法 addremove 和 average 是修改 AveragedCollection 实例的唯一方式。当使用 add 方法把一个元素加入到 list 或者使用 remove 方法来删除时,这些方法的实现同时会调用私有的 update_average 方法来更新 average 字段。

list 和 average 是私有的,所以没有其他方式来使得外部的代码直接向 list 增加或者删除元素,否则 list 改变时可能会导致 average 字段不同步。average 方法返回 average 字段的值,这使得外部的代码只能读取 average 而不能修改它。

因为我们已经封装好了 AveragedCollection 的实现细节,将来可以轻松改变类似数据结构这些方面的内容。例如,可以使用 HashSet 代替 Vec 作为 list 字段的类型。只要 addremove 和 average 公有函数的签名保持不变,使用 AveragedCollection 的代码就无需改变。相反如果使得 list 为公有,就未必都会如此了: HashSet 和 Vec 使用不同的方法增加或移除项,所以如果要想直接修改 list 的话,外部的代码可能不得不做出修改。

如果封装是一个语言被认为是面向对象语言所必要的方面的话,那么 Rust 满足这个要求。在代码中不同的部分使用 pub 与否可以封装其实现细节。

继承,作为类型系统与代码共享

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

如果一个语言必须有继承才能被称为面向对象语言的话,那么 Rust 就不是面向对象的。无法定义一个结构体继承父结构体的成员和方法。然而,如果你过去常常在你的编程工具箱使用继承,根据你最初考虑继承的原因,Rust 也提供了其他的解决方案。

选择继承有两个主要的原因。第一个是为了重用代码:一旦为一个类型实现了特定行为,继承可以对一个不同的类型重用这个实现。相反 Rust 代码可以使用默认 trait 方法实现来进行共享,在前面示例 中我们见过在 Summary trait 上增加的 summarize 方法的默认实现。任何实现了 Summary trait 的类型都可以使用 summarize 方法而无须进一步实现。这类似于父类有一个方法的实现,而通过继承子类也拥有这个方法的实现。当实现 Summary trait 时也可以选择覆盖 summarize 的默认实现,这类似于子类覆盖从父类继承的方法实现。

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

多态(Polymorphism)

很多人将多态描述为继承的同义词。不过它是一个有关可以用于多种类型的代码的更广泛的概念。对于继承来说,这些类型通常是子类。 Rust 则通过泛型来对不同的可能类型进行抽象,并通过 trait bounds 对这些类型所必须提供的内容施加约束。这有时被称为 bounded parametric polymorphism

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

因为这些原因,Rust 选择了一个不同的途径,使用 trait 对象而不是继承。让我们看一下 Rust 中的 trait 对象是如何实现多态的。

17.2 为使用不同类型的值而设计的trait对象

在第八章中,谈到了 vector 只能存储同种类型元素的局限。其示例中提供了一个定义 SpreadsheetCell 枚举来储存整型,浮点型和文本成员的替代方案。这意味着可以在每个单元中储存不同类型的数据,并仍能拥有一个代表一排单元的 vector。这在当编译代码时就知道希望可以交替使用的类型为固定集合的情况下是完全可行的。

然而有时我们希望库用户在特定情况下能够扩展有效的类型集合。为了展示如何实现这一点,这里将创建一个图形用户接口(Graphical User Interface, GUI)工具的例子,它通过遍历列表并调用每一个项目的 draw 方法来将其绘制到屏幕上 —— 此乃一个 GUI 工具的常见技术。我们将要创建一个叫做 gui 的库 crate,它含一个 GUI 库的结构。这个 GUI 库包含一些可供开发者使用的类型,比如 Button 或 TextField。在此之上,gui 的用户希望创建自定义的可以绘制于屏幕上的类型:比如,一个程序员可能会增加 Image,另一个可能会增加 SelectBox

这个例子中并不会实现一个功能完善的 GUI 库,不过会展示其中各个部分是如何结合在一起的。编写库的时候,我们不可能知晓并定义所有其他程序员希望创建的类型。我们所知晓的是 gui 需要记录一系列不同类型的值,并需要能够对其中每一个值调用 draw 方法。这里无需知道调用 draw 方法时具体会发生什么,只要该值会有那个方法可供我们调用。

在拥有继承的语言中,可以定义一个名为 Component 的类,该类上有一个 draw 方法。其他的类比如 ButtonImage 和 SelectBox 会从 Component 派生并因此继承 draw 方法。它们各自都可以覆盖 draw 方法来定义自己的行为,但是框架会把所有这些类型当作是 Component 的实例,并在其上调用 draw。不过 Rust 并没有继承,我们得另寻出路。

定义通用行为的trait

为了实现 gui 所期望的行为,让我们定义一个 Draw trait,其中包含名为 draw 的方法。接着可以定义一个存放 trait 对象trait object) 的 vector。trait 对象指向一个实现了我们指定 trait 的类型的实例,以及一个用于在运行时查找该类型的trait方法的表。我们通过指定某种指针来创建 trait 对象,例如 & 引用或 Box 智能指针,还有 dyn keyword, 以及指定相关的 trait)。我们可以使用 trait 对象代替泛型或具体类型。任何使用 trait 对象的位置,Rust 的类型系统会在编译时确保任何在此上下文中使用的值会实现其 trait 对象的 trait。如此便无需在编译时就知晓所有可能的类型。

之前提到过,Rust 刻意不将结构体与枚举称为 “对象”,以便与其他语言中的对象相区别。在结构体或枚举中,结构体字段中的数据和 impl 块中的行为是分开的,不同于其他语言中将数据和行为组合进一个称为对象的概念中。trait 对象将数据和行为两者相结合,从这种意义上说  其更类似其他语言中的对象。不过 trait 对象不同于传统的对象,因为不能向 trait 对象增加数据。trait 对象并不像其他语言中的对象那么通用:其(trait 对象)具体的作用是允许对通用行为进行抽象。src/lib.rs

pub trait Draw {
    fn draw(&self);
}

因为第十章已经讨论过如何定义 trait,其语法看起来应该比较眼熟。接下来就是新内容了:下面实例定义了一个存放了名叫 components 的 vector 的结构体 Screen。这个 vector 的类型是 Box,此为一个 trait 对象:它是 Box 中任何实现了 Draw trait 的类型的替身。src/lib.rs

pub struct Screen {
    pub components: Vec>,
}

在 Screen 结构体上,我们将定义一个 run 方法,该方法会对其 components 上的每一个组件调用 draw 方法。src/lib.rs

impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这与定义使用了带有 trait bound 的泛型类型参数的结构体不同。泛型类型参数一次只能替代一个具体类型,而 trait 对象则允许在运行时替代多种具体类型。例如,可以定义 Screen 结构体来使用泛型和 trait bound。src/lib.rs

pub struct Screen {
    pub components: Vec,
}

impl Screen
    where T: Draw {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

这限制了 Screen 实例必须拥有一个全是 Button 类型或者全是 TextField 类型的组件列表。如果只需要同质(相同类型)集合,则倾向于使用泛型和 trait bound,因为其定义会在编译时采用具体类型进行单态化。

另一方面,通过使用 trait 对象的方法,一个 Screen 实例可以存放一个既能包含 Box

你可能感兴趣的:(Rust,rust,学习,开发语言,keep,studying)