17、Rust程序设计语言——面向对象编程特性

1. 面向对象语言的特征

1.1 对象包含数据和行为

  面向对象的程序由对象组成。一个对象同时封装了数据以及操作这些数据的过程。这些过程通常被称为方法或操作。
  在这个定义下,Rust是面向对象的:结构体和枚举包含数据而impl块提供了在结构体和枚举之上的方法。虽然带有方法的结构体和枚举并不称为对象,但它们提供了与对象相同的功能。

1.2 封装隐藏了实现细节

  另一个与面向对象编程关联的概念是封装:一个对象的实现细节对使用该对象的代码不可见。因此,对象交互的唯一方式是通过公有API;使用对象的代码不应该直接触及对象的内部并改变数据或行为。这使得程序员能够更改和重构一个对象的内部实现,而无需改变使用该对象的代码。
  可以使用pub关键字来决定代码中的哪些模块、类型、函数和方法是公有的,而默认情况下其他所有内容都是私有的。例如,我们可以定义一个AveragedCollection结构体,其中有一个存有Vec<i32>的字段。该结构体还可以有一个字段存储向量中值的平均值,从而无需在每次需要时重新计算:

pub struct AveragedCollection {
	list: Vec<i32>,
	average: f64,
}

  该结构体被标记为pub,其他代码可以使用它,但结构体内的字段仍保持私有。这在这种情况下很重要,因为我们想确保每当列表中添加或删除值时,平均值也会更新。我们通过实现结构体上的add、remove和average方法来做到这一点:

impl AveragedCollection {
    pub fn new() -> Self {
        Self {
            list: Vec::new(),
            average: 0.0,
        }
    }
    
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        self.list.pop().inspect(|_| self.update_average())
    }

    pub fn average(&self) -> f64 {
        self.average
    }

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

  公有方法add、remove和average是访问或修改AveragedCollection实例中数据的唯一途径。当使用add方法把一个元素加入到list或者使用remove来删除时,这些方法的实现同时会调用私有方的update_average方法来更新average字段。
  list和average是私有的,所以没有其他方式来使得外部的代码直接向list增加或者删除元素,否则list改变时可能会导致average字段不同步。average方法返回average字段的值,这使得外部的代码只能读取average而不能修改它。
  因为我们已经封装了AveragedCollection的实现细节,改动数据结构等内部实现非常简单。
  如果封装被认为是面向对象语言所必要的特征,那么Rust满足这个要求。

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

  继承是一种机制:一个对象可以从另一个对象的定义中继承元素,从而获得父对象的数据和行为,无需再次定义。
  如果一种语言必须拥有继承才能算面向对象,那么Rust就不是这样的语言。Rust没有办法在不借助宏的情况下,定义一个结构体去继承父结构体的字段和方法实现。
  选择继承通常有两个主要原因。其一是复用代码:你可以先为某种类型实现特定行为,然后借助继承把这份实现复用到另一种类型上。在Rust中,可以通过trait方法的默认实现,在一定程度上做到这一点。trait方法的默认实现,很像父类已经实现了某个方法,而继承它的子类也随之拥有这份实现。实现trait的时候,可以覆盖某个方法的默认实现,这类似于子类去重写从父类继承而来的方法实现。
  另一个使用继承的原因和类型系统有关:它可以让子类型出现在父类型能出现的地方。这也被称为多态,意思是如果多个对象共享某些共同特征,那么在运行时就可以把它们彼此替换使用。
  Rust通过不提供继承,选择了另一组不同的权衡。继承常常有“共享了超出需要的代码”的风险。子类并不总是应该继承父类的全部特征,但使用继承时确往往会这样发生。这会让程序设计变得不够灵活。它还会引入这样一种可能:在子类上调用一些其实并不使用的方法,结果这些方法要么根本说不通,要么会导致错误。另外,一些语言只支持单继承,这也进一步限制了程序设计的灵活性。
  Rust使用trait object,而不是继承,来实现运行时多态。

2. 使用trait object抽象共享行为

  vector只能存储同种类型元素的局限性。但可以通过定义存储不同类型变体的枚举来解决这个问题。这意味着我们可以在每个单元中存储不同类型的数据,并仍能拥有一个代表一排单元的vector。
  然而有时我们希望库用户在特定情况下能够扩展有效的类型集合。为了展示如何实现这一点,这里将创建一个图形用户接口工具的例子,它通过遍历列表并调用每一个项目的draw方法来将其绘制到屏幕上——此乃一个GUI工具的常见技术。我们将要创建一个叫做gui的库crate,它含一个GUI库的结构。这个GUI库包含一些可供开发者使用的类型,比如Button或TextField。在此之上,gui用户希望创建自定义的可以绘制于屏幕上的类型:比如,一个程序员可能会增加Image,另一个可能会增加SelectBox。
  这个例子并不会实现一个功能完整的GUI库,不过会展示其中各个部分是如何结合在一起的。编写库的时候,我们不能知晓并定义其他程序员希望创建的类型。我们所知晓的是gui需要记录一系列不同类型的值,并需要能够对其中每一个值调用draw方法。这里无需知道调用draw方法时具体会发生什么,只要该值会有那个方法可供我们调用即可。

2.1 定义通用行为的trait

  为了实现gui所期望的行为,让我们定义一个Draw trait,其中包含draw的方法。接着可以定义一个存储trait对象的vector。trait对象指向一个实现了我们指定trait类型的实例,以及一个用于在运行时查找该类型的trait方法的表。我们通过指定某种指针来创建trait对象,例如&引用或Box<T>,还有dyn关键字以及指定相关的trait。可以使用trait对象代替泛型或具体类型。任何使用trait对象的位置,Rust的类型系统会在编译时确保任何在此上下文中使用的值会实现其trait对象的trait。如此便无需在编译时就知晓所有可能的类型。
  Rust中结构体和枚举里,字段中的数据和impl块里的行为是分开的,而在其他语言中,数据和行为往往会被合并进一个被称为对象的概念里。trait object和其他语言中的对象也不完全相同,因为我们不能往trait object中添加数据。trait object没有其他语言中的对象那么通用;它的特定用途是为共享行为提供抽象。
  定义Draw trait和trait方法draw:

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

  定义保存了trait object的vector,每个trait object必须实现Draw trait.

pub struct Screen {
	pub components: Vec<Box<dyn Draw>>,
}

  在Screen结构体上定义run方法,,对每个component调用draw方法:

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

  这与定义使用了带有trait约束的泛型类型参数的结构体不同。泛型类型参数一次只能替代一个具体类型,而trait object则允许在运行时替代多种具体类型。下面使用泛型和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();
		}
	}
}

  上面这种定义方式限制了Screen实例拥有一个全是Button类型或者全是TextField类型的组件列表。如果只需要同质(相同类型,homogeneous)集合,则倾向于使用泛型和trait约束,因为其定义会在编译时采用具体类型进行单态化(monomorphized)。
  通过使用trait object的方法,一个Screen实例可以存放一个技能包含Box<Button>,也能包含Box<TextField>的Vec<T>。

2.2 实现trait

  现在来增加实现了Draw trait的类型。

pub struct Button {
	pub width: u32,
	pub height: u32,
	pub label: String,
}

impl Draw for Button {
	fn draw(&self) {
		// 实际绘制按钮的代码
	}
}

  在Button上的width、height和label字段会和其他组件不同。每一个我们希望在屏幕上绘制的类型都会使用不同的代码来实现Draw trait的draw方法来定义如何绘制特定的类型。除了实现Draw trait之外,Button还可能有另一个包含按钮点击如何响应的方法的impl块。这类方法并不适用于像TextField这样的类型。
  如果一些库的使用者决定实现一个包含width、height和options字段的结构SelectBox,并且它为其实现了Draw trait:

use gui::Draw;

struct SelectBox {
	width: u32,
	height: u32,
	options: Vec<String>,
}

impl Draw for SelectBox {
	fn draw(&self) {
		// 绘制SelectBox的代码
	}
}

  库使用者现在可以在他们的main函数中创建一个Screen实例:

use gui::{Button, Screen};

fn main() {
	let screen = Screen {
		components: vec![
			Box::new(SelectBox {
				width: 75,
				height: 10,
				options: vec![
					String::from("Yes"),
					String::from("Maybe"),
					String::from("No"),
				],
			}),
			Box::new(Button {
				width: 50,
				height: 10,
				label:: String::from("OK"),
			}),
		],
	};
	screen.run();
	};
}

  在编写库的时候,我们不知道何人会在何时增加SelectBox类型,不过Screen的实现能够操作并绘制这个新类型,因为SelectBox实现了Draw trait,这意味着它实现了draw方法。
  这个概念——只关心值所反映的信息而不是其具体类型——类似于动态类型语言中称为鸭子类型的概念:如果它走起来像鸭子,叫起来像鸭子,那么它就是一只鸭子!Screen的run实现并不需要知道每个组件的具体类型是什么,只需要每个组件都有draw方法即可。而通过Box<dyn Draw>作为components vector中值的类型,我们就定义了Screen为需要可以在其上调用draw方法的值。
  使用trait对象和Rust类型系统来进行类似鸭子类型操作的优势是无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现trait对象所需trait则Rust不会编译这些代码。
  下面用String来展示没有实现Draw trait会发生什么:

use gui::Screen;

fn main() {
	let screen = Screen {
		components: vec![Box::new(String::from("Hi"))],
	};
	screen.run();
}

在这里插入图片描述

2.3 trait对象执行动态分发

  当对泛型使用trait约束时编译器所执行的单态化处理:编译器为每一个被泛型类型参数代替的具体类型生成了函数和方法的非泛型实现。单态化产生的代码在执行静态分发static dispatch,也就是说编译器在编译时就知晓要调用什么方法。这与动态分发dynamic dispatch相对,这时编译器在编译时无法知晓要调用哪个方法。在动态分发的场景下,编译器会生成负责在运行时确定该调用什么方法的代码。
  当使用trait object时,Rust必须使用动态分发。编译器无法知晓所有可能用于trait object代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust在运行时使用trait object对象中的指针来知晓需要调用哪个方法。这种查找会带来在静态分发中不会产生的运行时开销。动态分发也阻止编译器有选择地内联方法代码,这会相应地禁用一些优化,Rust还定义了一些规则,称为dyn兼容性dyn compatibility,用于规定可以和不可以在哪些地方使用动态分发。

3. 实现一个面向对象设计模式(状态变化不要用枚举类型,定义多个状态的结构体)

  状态模式state pattern是一个面向对象设计模式。该模式的关键在于定义值的一系列内含状态。这些状态体现为一系列的状态对象state objects,同时值的行为随着其内部状态而改变。
  状态对象共享功能:在Rust中使用结构体和trait而不是对象和继承。每一个状态对象负责其自身的行为,以及该状态何时应当转移至另一个状态。持有一个状态对象的值对于不同状态的行为以及何时状态转移毫不知情。
  使用状态模式的优点在于,程序的业务需求改变时,无需改变值持有状态或者使用值的代码。我们只需更新某个状态对象中的代码来改变其规则,或者是增加更多的状态对象。
  我们首先以一种更加传统的面向对象的方式实现状态模式,接着使用一种在Rust中更自然的方式。让我们使用状态模式来增量式地实现一个发布博文的工作流以探索这个概念。
  最终功能看起来像这样:
  1. 博文从空白的草稿开始。
  2. 一旦草稿完成,请求审核博文。
  3. 一旦博文过审,它将被发表。
  4. 只有被发表的博文的内容会被打印,这样就不会意外打印出没有被审核的博文的文本。
  任何其他对博文的修改尝试都不会生效。

3.1 尝试采用传统的面向对象风格(不推荐这么写,很奇怪)

use blog::Post;

fn main() {
	let mut post = Post::new();
	post.add_text("I ate a salad for lunch today");
	assert_eq!("", post.content());
	post.request_review();
	assert_eq!("", post.content());
	post.approve();
	assert_eq!("I ate a salad for lunch today", post.content());
}

  上面是我们希望blog crate实现API的实例。通过Post::new新建一个博文草稿。如果在审批之前尝试获取博文的内容,不应该获取任何文本因为博文仍然是草稿。我们请求审核博文,等待审核阶段content应该仍然返回空字符串。最后当博文审核通过,它应该被发表,这意味着当调用content时博文的文本将被返回。
  我们与这个crate交互的唯一的类型是Post。这个类型会使用状态模式并会存放处于三种博文所可能的状态之一的值——草稿、审核和发布。状态上的改变由Post类型内部进行管理。状态依库用户对Post实例调用的方法而改变,但是不能直接管理状态变化。这也意味着用户不会在状态上犯错误,比如在过审前发布博文。

3.1.1 定义Post并创建一个新实例

  我们知道需要一个公有结构体来存放一些文本,所以让我们从结构体的定义和一个创建Post实例的公有关联函数new开始。还需定义一个私有trait State用于定义Post的状态对象所必须有的行为。
  Post将在私有字段state中存放一个Option<T>类型的trait object对象Box<dyn State>。稍后会看到Option<T>为何是必须的。

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(),
		}
	}
}

trait State {}

struct Draft {}

impl State for Draft {}

  State trait定义了所有不同状态的博文所共享的行为,这个状态对象是Draft、PendingReview和Published,它们都会实现State trait。现在这个trait没有任何方法,同时开始将只定义Draft状态因为这是我们希望博文的初始状态。
  当创建新的Post时,我们将其state字段设置为一个存放了Box的Some值。这个Box指向一个Draft结构体新实例。这确保了无论何时新建一个Post实例,它都会从草稿开始。因为Post的state字段是私有的,也就无法创建任何其他状态的Post了!Post::new函数中将content设置为新建的空String。

3.1.2 存储文章内容的文本

  通过调用add_text方法并向其传递一个&str来将文本增加到博文的内容中。选择实现为一个方法而不是将content字段暴露为pub。这意味着之后可以实现一个方法来控制content字段如何被读取。

impl Post {
	// 省略

	pub fn add_text(&mut self, text: &str) {
		self.content.push_str(text);
	}
}

  add_text获取一个self的可变引用,因为需要改变调用add_text的Post实例。接着调用content中的String的push_str并传递text参数来将其追加到已保存的content中。这不是状态模式的一部分,因为它的行为并不依赖博文所处的状态。add_text方法完全不与state字段交互,不过这是我们希望支持的行为的一部分。

3.1.3 确保草稿文章的内容为空

  即使调用了add_text并向博文增加一些内容之后,我们仍然希望content方法返回一个空字符串slice,因为博文仍然处于草稿状态。现在让我们用能满足要求的最简单的方式来实现content方法:总是返回一个空字符串slice。当实现了将博文状态改为发布的能力之后将改变这一做法。但是目前博文只能是草稿状态,意味着其内容应该总是空的。

impl Post {
	// 省略
	pub fn content(&self) -> &str {
		""
	}
}

3.1.4 请求审核,从而改变文章状态

  接下来需要增加请求审核博文的功能,这应当将其状态由Draft改为PendingReview。

impl Post {
	// 省略
	pub fn request_review(&mut self) {
		if let Some(s) = self.state.take() {
			self.state = Some(s.request_review())
		}
	}
}

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
	}
}

  这里为Post增加一个获取self可变引用的公有方法request_review。接着在Post的当前状态下调用内部的request_review方法,并且第二个request_review方法会消费当前的状态并返回一个新状态。
  这里给State trait增加了request_review方法;所有实现了这个trait的类型现在都必须实现request_review方法。这个方法使用了self: Box<Self>,意味着该方法只可在持有这个类型的Box上被调用。这个语法获取了Box<Self>的所有权使老状态无效化,以便Post的状态值可转换为一个新状态。
  为了消费老状态,request_review方法需要获取状态值的所有权。这就是Post中state字段中Option的来历:调用take方法将state字段中的Some值取出并留下一个None,因为Rust不允许结构体实例中存在未初始化的字段。这使得我们将state的值移出Post而不是借用它。接着我们将博文的state设置为这个操作的结果。
  我们必须先将 state 字段临时置为 None,然后才能安全地获取老状态的所有权,进而执行状态转换。
如果试图写成 self.state = Some(self.state.unwrap().request_review());,虽然类型上似乎可行,但 Rust 编译器会报错:无法将 self.state 的值移出借用的上下文,因为 &mut self 只允许修改字段,不允许直接移动其所有权。
  Draft的request_review方法需要返回一个新的,装入Box的PendingReview结构体的实例,其用来代表博文处于等待审核状态。结构体PendingReview同样也实现了request_review方法,不过它不进行任何状态转换。相反它返回自身,因为当我们请求审核一个已经处于PendingReview状态的博文,它应该继续保持PendingReview状态。
  现在我们能看出状态模式的优势了:无论state是何值,Post的request_review方法都是一样的。每个状态负责自己的规则。
  我们将继续保持Post的content方法实现不变,返回一个空字符串slice。现在我们可以拥有PendingReview状态和Draft状态的Post了,不过我们希望在PendingReview状态下Post也有相同的行为。

3.1.5 添加approve来改变content的行为

  approve方法将与request_review方法类似:它会将state设置为审核通过时应处于的状态:

impl Post {
	// 省略
	pub fn approve(&mut self) {
		if let Some(s) = self.state.take() {
			self.state = Some(s.approve());
		}
	}
}

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 {
	// 省略
	fn approve(self: Box<Self>) -> Box<dyn State> {
		self
	}
}

struct PendingReview {}

impl State for PendingReview {
	// 省略
	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
	}
}

  为State trait增加了approve方法,并新增了一个实现State状态的结构体,Published状态。
  如果对Draft调用approve方法,并没有任何效果,因为它会返回self。当对PendingReview调用approve时,它返回一个新的、装入Box的Published结构体的实例。Published结构体实现了State trait,同时对于request_review和approve两方法来说,它返回自身,因为在这两种情况下博文应该保持Published状态。
  现在需要更新Post的content方法。我们希望content根据Post的当前状态返回值,所以需要Post代理一个定义于state上的content方法。

impl Post {
	// 省略
	pub fn content(&self) -> &str {
		self.state.as_ref().unwrap().content(self)
	}
	// 省略
}

  因为目标是将所有像这样的规则保持在实现了State的结构体中,我们将调用state中的值的content方法并传递博文实例作为参数,接着返回state值的content方法的返回值。
  这里调用Option的as_ref方法是因为需要Option中值的引用而不是获取所有权。因为state是一个Option<Box<dyn State>>,调用as_ref会返回一个Option<&Box<dyn State>>。如果不调用as_ref,将会得到一个错误,因为不能将state移动出借用的&self函数参数。
  接着调用unwrap方法,这里我们知道它永远不会panic,因为Post的所有方法都确保在它们返回时state会有一个Some值。这种安全的unwrap最好在代码上注释,以防那天改动逻辑之后突然在这里panic了,便于获取原因。在这种安全的地方还需要处理Option<T>,会让你感到生理性的厌烦,大胆使用unwrap并加上注释
  接着我们就有了一个&Box<dyn State>,当调用其content的时候,解引用强制转换会作用于&和Box,这样最终会调用实现了State trait的类型的content方法。这意味着需要为State trait定义增加content,这也是放置根据所处状态返回什么内容的逻辑的地方。

trait State {
	// 省略
	fn content<'a>(&self, post: &'a Post) -> &'a str {
		""
	}
}

// 省略
struct Published {}

impl State for Published {
	// 省略
	fn content<'a>(&self, post: &'a Post) -> &'a str {
		&post.content
	}
}

  这里增加了一个content方法的默认实现来返回一个空字符串slice。这意味着无需为Draft和PendingReview结构体实现content了。Published结构体会重写content方法并返回post.content的值。
  这里需要获取post的引用作为参数,并返回post的一部分的引用,所以返回的引用的生命周期与post参数相关。
  我们通过发布博文工作流的规则实现了状态模式。围绕这些规则的逻辑都存在于状态对象中而不是分散在Post之中。

3.1.6 为什么不用枚举

  不用枚举的根本原因在于状态是过程”而非集合。枚举最适合描述封闭的、固定的类型集合,因为新增一个变体需要修改所有match分支,牵一发动全身;而状态机天然是开放的,新增状态是常态,用枚举会导致扩展性灾难。因此选择trait对象(或多态)将“新增状态”的成本降到最低——只需新增结构体并实现trait,完全不动已有代码,遵循开闭原则。

3.1.7 评估状态模式

  通过这种组织代码的方式,要找到所有已发布博文的不同行为只需查看一处代码:Published的State trait的实现。
  如果要创建一个不使用状态模式的替代实现,则可能会在Post方法中,或者甚至于main代码中用到match语句,来检查博文状态并在这里改变其行为。这意味着需要查看很多位置来理解处于发布状态的博文的所有逻辑!在增加更多状态时会变得更糟:每一个match语句都会需要另一个分支。
  对于状态模式来说,Post的方法和使用Post的位置无需match语句,同时新增状态只涉及到增加一个新struct和为其实现State trait。这个实现易于扩展增加更多功能。
  状态模式的一个缺点是因为状态实现了状态之间的转换,一些状态会相互联系。如果在PendingReview和Published之间增加另一个状态,比如Scheduled,则不得不修改PendingReview中的代码来转移到Scheduled。如果PendingReview无需因为新增状态而改变就更好了,不过这意味着切换到另一种设计模式。
  另一个缺点是我们会发现一些重复的逻辑。为了消除它们,可以尝试在State trait中为request_review和approve方法提供返回self的默认实现;然而这样行不通:当将State用作trait object时,trait并不知道self具体是什么类型,因此无法在编译时确定返回类型。
  其他的重复逻辑还包括Post中request_review和approve两个类似的实现。它们都对Post的state字段调用Option::take,如果state为Some,就将调用委托给封装值的同名方法,并将返回结果重新赋值给state字段。如果Post中的很多方法都遵循这个模式,我们可能考虑定义一个宏来消除重复。

3.2 尝试采用更符合 Rust 风格的方案

3.2.1 将状态和行为编码为类型

  我们将状态编码进不同的类型。如此,Rust的类型检查就会将任何在只能使用发布博文的地方使用草稿博文的尝试变为编译时错误。让我们从之前main的第一部分开始考虑:

fn main() {
	let mut post = Post::new();
	post.add_text("I ate a salad for lunch today");
	assert_eq!("", post.content());
}

  我们仍然希望能够使用Post::new创建一个新的草稿博文,并能够增加博文的内容。不过不同于存在一个草稿博文时返回空字符串的content方法,我们将使草稿博文完全没有content方法。这样如果尝试获取草稿博文的内容,将会得到一个方法不存在的编译错误。这使得我们不可能在生产环境意外显示出草稿博文的内容,因为这样的代码甚至就不能编译。

pub struct Post {
	content: String,
}

pub struct DraftPost {
	content: String,
}

impl Post {
	pub fn new() -> DraftPost {
		DraftPost {
			content: String::new(),
		}
	}
	
	pub fn content(&self) -> &str {
		&self.content
	}
}

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方法!如此现在程序确保了所有博文都从草稿开始,同时草稿博文没有任何可供展示的内容。任何绕过这些限制的尝试都会产生编译错误。

3.2.2 实现状态转移为不同类型的转换

  如何得到发布的博文?希望强制执行的规则是草稿博文在可以发布之前必须被审核通过。等待审核的博文应该仍然不会显示任何内容。通过增加另一个结构体PendingReviewPost来实现这个限制,在DraftPost上定义request_review方法来返回PendingReviewPost,并在PendingReviewPost上定义approve方法来返回Post:

impl DraftPost {
	// 省略
	pub fn request_review(self) -> PendingReviewPost {
		PendingReviewPost {
			content: self.content,
		}
	}
}

pub struct PendingReviewPost {
	content: String,
}

impl PendingReviewPost {
	pub fn approve(self) -> Post {
		Post {
			content: self.content,
		}
	}
}

  request_review和approve方法获取self的所有权,会消费DraftPost和PendingReviewPost实例,并分别转换为PendingReviewPost和Post。这样调用request_review之后就不会遗留任何DraftPost实例,后者同理。PendingReviewPost没有定义content方法,尝试读取其内容会导致编译错误,DraftPost同理。因为唯一得到定义了content方法的Post实例的途径是调用PendingReviewPost的approve方法,而得到PendingReviewPost的唯一方法是调用DraftPost的request_review方法,如此便将发博文的工作流编码进了类型系统。
  这也意味不得不对main做出修改。因为request_review和approve返回新实例而不是修改被调用的结构体,需要增加更多的let post =遮蔽赋值来保存返回的实例。也不能再断言草稿和等待审核的博文的内容为空字符串了。更新后的main代码:

use blog::Post;

fn main() {
	let mut post = Post::new();
	post.add_text("I ate a salad for lunch today");
	let post = post.request_review();
	let post = post.approve();
	assert_eq!("I ate a salad for lunch today", post.content());
}

  不得不修改main来重新赋值post使得这个实现不再完全遵循面向对象的状态模式:状态间的转换不再完全封装在Post实现中。然而,得益于类型系统和编译时类型检查,我们得到的收获是无效状态时不可能的了!这确保了某些特定的bug,将在部署到生产环境之前被发现。
  虽然Rust能够实现面向对象设计模式,但Rust还提供了诸如将状态编码进类型系统的其他模式。这些模式有着不同的权衡取舍。虽然你可能非常熟悉面向对象模式,重新思考这些问题来利用Rust提供的像在编译时避免一些bug这样的有益的功能。在Rust中面向对象模式并不总是最好的解决方案,因为Rust拥有像所有权这样的面向对象语言所没有的特性。

参考

1、面向对象编程特性

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值