29 KiB
Rust 的面向对象编程特性
面向对象编程(Object-Oriented Programming,OOP)是一种模式化编程方式。
面向对象语言的特点
Rust 受到许多编程范式的影响,例如:函数式编程的特性,面向对象编程(OOP)。面向对象编程语言共享某些共同特性,即对象、封装和继承。
对象:数据和行为的集合体
《设计模式:可复用面向对象软件的基础》中面向对象编程的定义:Object-oriented programs are made up of objects. An object packages both data and the procedures that operate on that data. The procedures are typically called methods or operations. 面向对象的程序是由对象组成的。一个 对象 包含数据和操作这些数据的过程。这些过程通常被称为 方法 或 操作。
在这个定义下,Rust 是面向对象的:结构体和枚举包含数据,而 impl 块提供了对结构体和枚举的方法。虽然带有方法的结构体和枚举并不被称为 对象,但是他们提供了与对象相同的功能。
封装
封装(Encapsulation):对象的实现细节对于使用该对象的代码是不可访问的。因此,与对象交互的唯一方式是通过其公共接口(public API);使用对象的代码不应直接访问对象的内部并直接更改数据或行为。封装使得改变和重构对象的内部时无需改变使用对象的代码。
rust中可以使用pub
关键字来决定哪些模块、类型、函数和方法是公共的,而默认情况下其他一切都是私有的。下面定义一个名为AveragedCollection的结构体:
pub struct AveragedCollection {
list: Vec<i32>,
average: f64,
}
结构体标记为pub
,故其他代码可以使用它,但结构体内部的字段为私有。
impl AveragedCollection {
pub fn add(&mut self, value: i32){
self.list.push(value);
self.update_average();
}
pub fn remove(&mut self) -> Option<i32>{
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;
}
}
公共方法 add
、remove
和 average
是访问或修改 AveragedCollection
实例中数据的唯一方式。当使用 add
方法向列表添加项目或使用 remove
方法删除项目时,每个方法的实现都会调用私有的 update_average
方法,该方法负责更新平均值字段。pub
或非 pub
选项的使用使得可以封装实现细节。
继承
在面向对象编程中,继承有两个主要用途:
- 代码重用: 通过继承,您可以在一个类中实现一些特定的功能,然后在另一个类中继承这些功能。这意味着您可以避免重复编写相同的代码,提高代码的可维护性和复用性。
- 类型系统和多态性: 继承也可以用于实现多态性,即子类可以被视为其父类的一种类型,从而可以在某些情况下替代父类使用。这种特性使得代码更灵活,可以根据不同的需求使用不同的子类对象,而不需要改变大量的代码。
然而,在 Rust 中,传统的继承机制并不像其他一些编程语言(如Java或C++)那样直接。Rust 鼓励使用组合
和 trait
来实现类似的功能,以更安全和灵活的方式处理代码重用和类型多态性。
总之,尽管 Rust 中的继承不同于其他语言中的继承,但它提供了其他的方法来实现类似的概念,使得代码可以更加可靠地工作并且更易于维护。
多态性是一种编程概念,它允许代码在处理不同类型数据时具有灵活性,而在 Rust 中通过泛型和 trait 实现了更安全和更灵活的多态性。
在传统的面向对象编程中,多态性通常与继承概念相关联,即基类和子类之间可以具有相同的方法名,但实际行为可能不同。然而,在 Rust 中,多态性的实现方式略有不同。Rust 使用泛型(Generics)来实现参数化类型,允许代码适用于多种类型而不关心具体类型是什么。同时,Rust 使用 trait(特征)来定义一组共享的方法或功能,通过 trait 约束可以确保泛型类型必须提供特定的方法。这种多态性被称为有界参数多态性(Bounded Parametric Polymorphism),它在泛型和 trait 的约束下实现。
为使用不同类型的值而设计的 trait 对象
向量只能存储相同类型的值。但一个枚举的变体是在同一个枚举类型下定义的,故需要一个类型来表示不同类型的元素时,可以定义一个枚举
来实现在每个单元格中存储不同类型的数据,并仍能拥有一个代表一排单元的 vector
。
若希望允许库的用户在特定情况下扩展有效的数据类型集合。传统的继承
在这种情况下可能不适用,因为 Rust 不支持继承。但我们可以使用 trait
对象让用户使用不同的类型进行扩展。 trait
对象允许不同类型的值在运行时表现出多态性,从而在不同类型之间实现共享的绘制方法。这样,用户可以灵活地扩展库,而无需使用继承。
定义通用行为的 trait (基类/接口)
为了实现特定的行为,下面定义了一个名为 Draw
的 trait
,其中包含一个 draw
方法。接着可以定义一个存放 trait
对象(trait object) 的 vector。trait
对象指向实现了指定 trait
的类型的实例,允许在运行时查找 trait
方法。创建 trait
对象需要指定某种类型的指针(例如 &
引用或 Box<T>
智能指针),使用 dyn
关键字,并指定相关的 trait
。trait
对象可以在泛型或具体类型的位置使用,Rust 的类型系统会在编译时验证所使用的值是否实现了 trait
对象的 trait
。我们可以使用 trait
对象来实现多态性。
在 Rust 中,结构体和枚举不被称为 对象
,而是将数据和行为分开处理。Trait
对象在某种程度上类似于其他语言中的对象,但不同之处在于它们无法添加数据,专注于允许通用行为的抽象。
pub trait Draw {
fn draw(&self);
}
存放 trait 对象的 vector:
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
// vector 的类型是 Box<Draw>,此为一个 trait 对象:它是 Box 中任何实现了 Draw trait 的类型的替身。
}
impl Screen {
pub fn run(&self){
for component in self.components.iter(){
component.draw();
}
}
}
泛型类型参数一次只能替代一个具体类型,而 trait 对象则允许在运行时替代多种具体类型。添加泛型后 Screen
实例必须存放同一类型。
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T> where T: Draw,{
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
实现 trait
按照书中案例,添加结构体实现 Draw
trait。使用 trait 对象来存储实现了相同 trait 的不同类型的值。
pub trait Draw {
fn draw(&self);
}
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// 实际绘制按钮的代码
}
}
impl Button {
fn hello(&self){ // 添加额外方法。
println!("hello");
}
}
// SelectBox 结构体
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// code to actually draw a select box
}
}
在编写库时,我们并不知道可能会添加 SelectBox
类型,但是我们的 Screen
实现能够处理这种新类型并进行绘制,因为 SelectBox
实现了 Draw
trait,这意味着它实现了 draw
方法。
这个概念强调关注一个值反映的消息,而不关心值的具体类型,类似于动态类型语言中的鸭子类型:如果它走起来像鸭子,叫起来像鸭子,那么它就是鸭子!在 Screen
的 run
实现中,我们不需要知道每个组件的具体类型。它不会检查组件是 Button
还是 SelectBox
的实例,只会调用组件的 draw
方法。通过将 Box<dyn Draw>
指定为 components
向量中值的类型,我们定义了 Screen
需要值实现能够调用 draw
方法的特性。
使用 trait 对象和 Rust 的类型系统编写类似鸭子类型的操作的优势在于,我们永远不必在运行时检查一个值是否实现了特定的方法,也不必担心如果一个值没有实现某个方法但我们仍然调用它会出错。如果值没有实现 trait 对象需要的特性,Rust 将不会编译我们的代码。
实现Draw trait 后:
Trait对象执行动态分发
编译器在泛型上使用 trait bound 时所进行单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了非泛型的函数和方法实现。单态化所产生的代码进行 静态分发(static dispatch)
。静态分发发生于编译器在编译时
就知晓调用了什么方法的时候。这与 动态分发 (dynamic dispatch)
相反,这时编译器在编译时无法知晓调用了什么方法。在动态分发的情况下,编译器会生成在运行时
确定调用了什么方法的代码。
当使用 trait
对象时,Rust必须使用动态分发
。编译器无法知晓所有可能用于 trait
对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此在运行时,Rust使用 trait
对象中的指针来知晓需要调用哪个方法。这种查找会产生运行时的开销,而静态分发则不会发生这种情况。动态分发还阻止编译器选择内联方法的代码,从而阻止了某些优化。
Trait 对象要求对象安全
只有 对象安全(object safe
的 trait 才可以组成 trait 对象。围绕所有使得 trait 对象安全的属性存在一些复杂的规则,不过在实践中,只涉及到两条规则。如果一个 trait 中所有的方法有如下属性时,则该 trait 是对象安全的:
- 返回值类型不为 Self
- 方法没有任何泛型类型参数
Clone
trait: clone
方法的返回类型是 Self
,所以不能将 Clone
trait 用于构建安全的 trait 对象。
pub trait Clone: Sized {
// Required method
fn clone(&self) -> Self;
// Provided method
fn clone_from(&mut self, source: &Self) { ... }
}
使用clone 替换 Draw 是会产生错误。
对象安全性涉及到在 trait 中使用 Self
关键字和泛型类型参数的情况。
- 使用
Self
关键字:Self
是一个类型别名,表示要实现该 trait 或方法的类型。当一个 trait 方法的返回类型是Self
时,这意味着方法返回了实现该 trait 的具体类型。然而,在使用 trait 对象时,无法知道实现该 trait 的具体类型是什么,因为 trait 对象抹去了具体类型的信息。如果一个 trait 方法返回的是具体的Self
类型,但 trait 对象却忘记了实际的类型,那么就无法使用已经忘却的具体类型。因此,为了确保在 trait 对象中能够正常使用方法,这个方法的返回类型不能是Self
。 - 使用泛型类型参数:在使用泛型类型参数时,将具体的类型参数放入 trait 中,这个具体类型变成了实现该 trait 的类型的一部分。然而,在使用 trait 对象时,具体的类型参数信息被擦除,无法知道放入泛型参数的具体类型是什么。
实现面向对象设计模式
状态模式(state pattern)是一种面向对象的设计模式。模式的关键在于一个值有某些内部状态,体现为一系列的 状态对象,同时值的行为随着其内部状态而改变。
使用状态模式的优势在于,当程序的业务需求发生变化时,我们无需更改保存状态的值的代码或使用该值的代码。我们只需要更新一个状态对象内部的代码,以更改其规则或者可能添加更多的状态对象。
下面通过状态模式来开发博客功能:
定义 Post
并新建一个草案状态的实例
trait State {}
struct Draft {}
impl State for Draft{}
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post{
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
}
State trait 定义了所有不同状态的博文所共享的行为,同时 Draft、PendingReview 和 Published 状态都会实现 State 状态。现在这个 trait 并没有任何方法,同时开始将只定义 Draft 状态因为这是我们希望博文的初始状态。
存放博文内容的文本
通过实现为add_text方法,而不是将content字段公开为pub,以便以后我们可以实现一个控制如何读取content字段数据的方法。
请求审核博文来改变其状态
这段代码的作用是,它首先尝试从self.state
中取出当前状态值。如果存在状态值,就调用当前状态的request_review()
方法,获得请求审查后的新状态,并将其设置为self.state
,这样文章的状态就会从草稿状态转变为待审查状态。如果self.state
中没有状态值(即为None
),则什么也不做,因为不可能在没有状态的情况下进行状态转换。
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft{
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview{})
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State>{
self
}
}
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post{
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str){
self.content.push_str(text);
}
pub fn request_review(&mut self){
if let Some(s) = self.state.take(){
self.state = Some(s.request_review())
}
}
}
将request_review
方法添加到State
trait中;现在所有实现该trait的类型都需要实现request_review
方法。
请注意,方法的第一个参数不再是self
、&self
或&mut self
,而是self: Box<Self>
。这种语法意味着该方法只在调用类型为Box
的变量上时有效。这种语法会获取Box<Self>
的所有权,使旧状态无效,从而让Post
的状态值转变为新状态。
为了消耗旧状态,request_review
方法需要获取状态值的所有权。这就是Post
的state
字段中的Option
发挥作用的地方:我们调用take
方法,将state
字段中的Some
值取出,并在其位置留下None
,因为Rust不允许在结构体中存在未填充的字段。这使我们可以将状态值从Post
中移出,而不是借用它。然后,我们将文章的状态值设置为此操作的结果。
我们需要临时将state
设置为None
,而不是直接使用诸如self.state = self.state.request_review();
之类的代码来获取状态值的所有权。这确保了在我们将其转换为新状态后,Post
不能再使用旧状态值。
现在,我们可以开始看到状态模式的优势:不管Post
的状态值如何,request_review
方法都是相同的。每个状态都对其自身的规则负责。
增加改变 content
的 approve
方法
approve
方法将类似于 request_review
方法:当状态被批准时,它将把状态设置为当前状态所指定的值
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft{
// --snip--
fn approve(self: Box<Self>) -> Box<dyn State>{
self
}
}
struct PendingReview {}
impl State for PendingReview {
// --snip--
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published{})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn approve(&mut self){
if let Some(s) = self.state.take(){
self.state = Some(s.approve())
}
}
pub fn content(&self) -> &str{
""
}
}
我们将 approve
方法添加到 State
trait 中,并添加一个新的结构体来实现 State
,即 Published
状态。
类似于对 PendingReview
上的 request_review
方法的处理方式,如果我们在 Draft
上调用 approve
方法,它将没有任何效果,因为 approve
将返回 self
。当我们在 PendingReview
上调用 approve
时,它将返回一个新的、装箱的 Published
结构体的实例。Published
结构体实现了 State
trait,在 request_review
方法和 approve
方法上,它都返回自身,因为在这些情况下,文章应保持在 Published
状态。
现在,我们需要更新 Post
上的 content
方法。我们希望从 content
返回的值取决于 Post
的当前状态,因此我们将让 Post
委托给其状态上定义的 content
方法,如第17-17节所示:
impl Post {
// --snip--
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
// --snip--
}
在 Option
上调用 as_ref
方法,获得一个对 Option
内部值的引用,而不是值的所有权。因为 state 是一个 Option<Box<dyn State>>
,当我们调用 as_ref 时,会返回一个 Option<&Box<dyn State>>
。如果我们没有调用 as_ref,就会出错,因为我们不能从函数参数的借用 &self 中移动 state。
然后,我们调用 unwrap
方法,我们知道这绝对不会导致 panic,因为我们知道 Post
上的方法会确保在这些方法完成时,state
会始终包含一个 Some
值。
在这个时候,当我们在 &Box<dyn State>
上调用 content
时,解引用强制转换(Deref Coercion)会生效,使得 content
方法最终在实现 State
trait 的类型上被调用。因此,我们需要将 content
添加到 State
trait 的定义中,这就是根据不同的状态确定返回什么内容的逻辑所在,
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
fn content<'a>(&self, post: &'a Post) -> &'a str{""}
}
struct Draft {}
impl State for Draft{
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview{})
}
fn approve(self: Box<Self>) -> Box<dyn State>{
self
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State>{
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published{})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post{
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str){
self.content.push_str(text);
}
pub fn request_review(&mut self){
if let Some(s) = self.state.take(){
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self){
if let Some(s) = self.state.take(){
self.state = Some(s.approve())
}
}
pub fn content(&self) -> &str{
self.state.as_ref().unwrap().content(&self)
}
}
至此已实现了状态模式,与规则相关的逻辑存放在状态对象中,而不是分散在 Post
中。运行图:
枚举也可以实现此功能,将不同的文章状态作为变体。使用枚举的一个缺点是,每个检查枚举值的地方都需要一个 match
表达式或类似的结构来处理每个可能的变体。与这种 trait
对象的解决方案相比,会产生更多重复。
状态模式的权衡之处
优点:
- 清晰可读: 状态模式能够消除大量的条件逻辑,使代码更加清晰和易读。
- 扩展性: 添加新状态和行为相对容易,只需要创建新的结构体并实现相应的trait方法。
- 单一职责: 每个状态实现只关注自己的行为,提高了代码的模块化和可维护性。
弊端:
- 状态耦合: 状态之间的转换可能会导致一些状态耦合,特定的状态转换会影响到其他状态的实现。
- 部分重复逻辑: 一些相似的方法实现可能会出现重复,例如在Post对象的不同方法中委托相同的操作。
- trait安全性: 在trait对象中,无法为trait方法提供默认实现返回
self
,这可能导致一些逻辑重复。
修改代码逻辑:
- 只允许博文处于 Draft 状态时增加文本内容。
- 增加 reject 方法将博文的状态从 PendingReview 变回 Draft。
- 在将状态变为 Published 之前需要两次 approve 调用。
trait State {
// --snip--
fn reject(self: Box<Self>) -> Box<dyn State>;
fn can_add_text(&self) -> bool {
false
}
}
struct Published {}
impl State for Published {
// --snip--
}
struct Draft {}
impl State for Draft{
// --snip--
fn can_add_text(&self) -> bool {
true
}
}
struct PendingReview {
approve_count: u32,
}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State>{
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
if self.approve_count < 1{
Box::new(PendingReview {
approve_count: self.approve_count+1,
})
}else{
Box::new(Published{})
}
}
fn reject(self: Box<Self>) -> Box<dyn State> {
Box::new(Draft{})
}
}
pub struct Post {
// --snip--
}
impl Post {
// --snip--
pub fn add_text(&mut self, text: &str) {
if let Some(state) = self.state.as_mut() {
if state.can_add_text() {
self.content.push_str(text);
}
}
}
pub fn reject(&mut self){
if let Some(s) = self.state.take(){
self.state = Some(s.reject())
}
}
// --snip--
}
将状态和行为编码为类型
使用不同的类型来编码不同的状态,从而利用Rust的类型检查系统
来防止在不允许的情况下使用错误的状态。通过使用类型系统,可以在编译时捕获错误,从而提供更强的安全性和可靠性。
pub struct Post {
content: String,
}
impl Post {
pub fn new() -> DraftPost{
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str{
&self.content
}
}
pub struct DraftPost {
content: String,
}
impl DraftPost {
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
Post 和 DraftPost 结构体都有一个私有的 content 字段来储存博文的文本。这些结构体不再有 state 字段因为我们将类型编码为结构体的类型。Post 将代表发布的博文,它有一个返回 content 的 content 方法。
Post::new 函数,不过不同于返回 Post 实例,它返回 DraftPost 的实例。现在不可能创建一个 Post 实例,因为 content 是私有
的同时没有任何函数返回 Post。
DraftPost 上定义了一个 add_text 方法,这样就可以像之前那样向 content 增加文本,不过注意 DraftPost 并没有定义 content 方法!如此现在程序确保了所有博文都从草案开始
,同时草案博文没有任何可供展示的内容。任何绕过这些限制的尝试都会产生编译错误。
实现状态转移为不同类型的转换
pub struct Post {
content: String,
}
impl Post {
pub fn new() -> DraftPost{
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str{
&self.content
}
}
pub struct DraftPost {
content: String,
}
impl DraftPost {
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
// --snip--
pub fn request_review(self) -> PendingReviewPost { // self 获取所有权
PendingReviewPost {
content: self.content,
}
}
}
pub struct PendingReviewPost {
content: String,
}
impl PendingReviewPost {
pub fn approve(self) -> Post{ // self 获取所有权
Post {
content: self.content,
}
}
}
// main 函数
use blog_type::Post;
fn main() {
let mut post = Post::new();
post.add_text("yes or no");
post.add_text(", yes");
let post = post.request_review();
// post.add_text("yes or no");
let post = post.approve();
println!("content: {0}", post.content());
}
request_review
和 approve
方法获取 self 的所有权,因此会消费 DraftPost
和 PendingReviewPost
实例,并分别转换为 PendingReviewPost
和发布的 Post
。request_review
和 approve
方法返回新的实例,而不是修改调用它们的结构体,因此我们需要添加更多的 let post
重新赋值语句来保存返回的实例。
面向对象模式在 Rust 中并不总是最佳解决方案。这种作法使用了Rust的类型系统和所有权机制来实现状态模式,相比传统的面向对象方法,有一些区别:
- Ownership and Borrowing: 在这种方法中,状态转换时使用了所有权的转移,例如从
DraftPost
转换到PendingReviewPost
或从PendingReviewPost
转换到Post
。这允许每个状态在转换时拥有content
字段的所有权,从而防止在不正确的状态下操作。这是通过获取和释放所有权来确保状态的合理转换。 - Compile-Time Checks: 使用不同的类型来表示不同的状态,例如
DraftPost
、PendingReviewPost
和Post
,可以在编译时进行静态类型检查,防止在错误的状态下执行操作。这种方法可以在编译阶段捕获可能的错误,而不是在运行时出现问题。 - No Trait Objects: 这种方法不涉及动态分发或使用 trait 对象,因此不需要使用 trait 中的方法。相反,不同的状态由不同的类型来表示,并且每个类型都有自己的方法。这可以提高代码的可读性和性能。
- Explicit State Transitions: 状态之间的转换是显式的,通过调用各个状态的方法来实现。这使得代码更易理解和调试。
综上所述,这种方法利用了Rust强大的类型系统和所有权机制,以一种更安全、更明确的方式来实现状态模式,同时避免了一些可能的运行时错误。这种方式与传统的面向对象方法相比,在一些方面可能会更加清晰和可靠。