爱国的张浩予 发表于 2022-12-23 08:44
Tags:generic,typestate,phantomdata,monomorphization,move,intra-doc_link,oop,sealed-traits,smart-pointer,raii
对照OOP
浅谈【类型状态】设计模式
类型状态·设计模式Type State Pattern
也被称作“泛型·即是·类的类型(约束)Generic as Type Class (Constraint)
”。它是基于Rust
独有语言特性
- 单态化
monomorphization
move
赋值语义
的新颖设计模式。其中,【move
赋值语义】为Rust
所独有的原因是
-
一方面,
GC
类计算机语言已经将内存托管给vm
,所以他们就没再搞出这类复杂的内存管理概念,和增加开发者的心智负担。 -
另一方面,
Cpp
的move
赋值语义仅只是对历史包袱的【妥协方案】。这体现在Cpp
的move
语义是: 用空指针nullptr
换走原变量的值;但,原变量依旧可访问。这哪里是move
,分明是swap
呀!Rust
的move
语义是:拿走原变量的值;同时,作废原变量。这个操作也被称为“消耗consuming
”。
所以,和
Rust
相比,Cpp
的move
赋值语义至多就是一个“弟弟”。其功能相当于Rust
标准库提供的std::mem::take(&T) -> T
内存操作 — 使用【类型·默认值】置换出【引用】内存位置上的值;同时,保留·原变量·的【所有权】不被消耗掉和可以被接着使用。 -
此外,
move
也不是Cpp
变量赋值的默认语义。相反 ,开发者得显示地编码std::move(ptr)
函数调用和将lvalue
转换为rvalue
。Cpp
的std::move(ptr)
函数调用是【零·运行时·成本】的。在编译之后,编译器会将其从机器码内扣掉。其“辅助线”般的功能有些类似于Rust
中的std::marker::PhantomData<T>
。
名词解释
为了避免后续文章内容过于啰嗦,首先定义五个词条,包括:
- 泛型类型
- 泛型类型参数
- 泛型类型形参
- 泛型类型实参
- 泛型类型参数限定条件
一图抵千词,大家来看下图吧:
基本概念
【类型状态·模式】的初衷是:将【运行时】对象状态的(动态)信息·编码入·【编译时】对象类型的(静态)定义里。进而,借助现成且完备的Rust
【类型系统】,在【编译】过程中,确保:
- 处于不同状态的(泛型类型)实例·拥有不一样的(【成员方法】+【关联函数】+【字段】)集合。
- (泛型类型)实例·仅能在毗邻的状态之间进行“状态·过渡”,而不能“跳变”。
- 排查出·跨状态的成员方法调用。比如,
A
状态的实例调用了仅在B
状态才有效的成员方法。 而不是,让这类错误潜伏着和等【测试覆盖】或抛出【运行时·异常】。
以【订单系统】为例,【编译器】就能筛查出代码里
- 对【无效订单】实例的【发货】成员方法调用
- 对【出库订单】实例的【完成】成员方法调用 — 还未经历【发货】与【收款】两个状态
相对于传统的OOP
程序,Rust
【类型状态】设计模式将【对象·状态】的【运行时】检查前置于【编译环节】。进而带来的好处包括但不限于:
- 将【运行时】程序崩溃“无害化”为【编译时】错误。
- 就开发者而言,这意味着更短的【思考
+
试错】反馈回路。 - 就应用程序而言,这意味着更高的性能,更健壮的可靠性,和更重的应用程序大小 — 【单态化】的本质就是以空间换时间。
- 就开发者而言,这意味着更短的【思考
- 允许
IDE
提供更有价值的代码提示。即,仅智能地列出对当前状态实例有效的【成员方法】,而不是罗列全部成员方法。比如,当开发者“点”一个【无效订单】实例时,IDE
就不应该提示出【发货】成员方法。这才是对开发者最实在的帮助。
代码套路
从操作细节来说,为了采用【类型状态·设计模式】,我们需要:
- 将每个【状态】分别映射为独立的【结构体】(比如,
struct State1
)。和在结构体内,定义【状态】独有的:字段。(见伪码#1
注释)- 而不是,使用一个【枚举类】
enum State {...}
笼统地描述所有【状态】 - 后文称这类【结构体】为【状态·类型】。
- 而不是,使用一个【枚举类】
- 以【泛型·类型】
+
【泛型·类型·形参】的结构体定义(比如,struct Type1<S1>
),抽象所有【状态】共有的:字段。(见伪码#2
注释) - 以【泛型·类型】
+
【泛型·类型·形参】的实现块(比如,impl<S1> Type1<S1>
),抽象所有【状态】共有的:成员方法,关联函数,关联常量,和关联类型。(见伪码#3
注释) - 以【泛型·类型】
+
【泛型·类型·实参】的实现块(比如,impl Type1<State1>
),定制每个【状态】独有的:成员方法,关联函数,关联常量,和关联类型。(见伪码#4
注释)
/// #1 【状态·类型】
struct State1 {
private_field1: String // 定义【状态】独有【字段】
}
struct State2 {
private_field2: String // 定义【状态】独有【字段】
}
/// #2 【泛型·类型】+【泛型·类型·形参】
struct Type1<S1> { // <- 被参数化的【状态·类型】既作为【泛型·类型·参数】,
state: S1, // <- 也作为【状态·字段】的字段类型
com_field0: String // 抽象全部【状态】共有【字段】
}
/// #3 【泛型·类型】+【泛型·类型·形参】
impl<S1> Type1<S1> { // 抽象全部【状态】共有的【成员方法】
fn com_function0(&self) -> String {
"全状态可调用".to_string()
}
}
/// #4 【泛型·类型】+【泛型·类型·实参】
impl Type1<State1> { // 定制【状态】`State1`独有【成员方法】
fn private_function1(&self) -> String {
"仅 State1 状态可调用".to_string()
}
}
impl Type1<State2> { // 定制【状态】`State2`独有【成员方法】
fn private_function2(&self) -> String {
"仅 State2 状态可调用".to_string()
}
}
承上段代码,在【泛型·类型】struct Type1<S1>
中,被参数化的【状态·类型】S1
既作为【泛型·类型·参数】也作为【状态·字段】state
的字段类型(这是由Generic Struct
定义要求的 — 在结构体定义中,被声明的泛型参数必须被使用)。
// 继续前面的代码
let type1_state1 = Type1 {
com_field0: "对所有状态都看得到的,共用字段值".to_string(),
// 锚定 type1_state1 实例处于 State1 状态
state: State1 {
private_field1: "状态1的私有字段值。对其它任何状态都不可见".to_string()
}
};
// 即便对 Type1<State2> 实例,此【成员方法】调用也是成立的。
dbg!(type1_state1.com_function0());
// 对 Type1<State2> 实例,此会报编译错误。
dbg!(type1_state1.private_function1());
// 对【状态】独有【字段】的取值语句则有些“啰嗦”了。
dbg!(&type1_state1.state.private_field1[..]);
承上段代码,除了【状态】State1
的独有【字段】private_field1
需要隔着一层【状态·字段】state
取值(如,type1_state1.state.private_field1
),所有其它的【项】都能从type1_state1
实例上直接“点出来”(如,type1_state1.private_function1()
)。
虽然【状态】独有【字段】的取值语句有些冗长,但语法是“死”的,可人是“活”的呀!再额外封装一个【状态】独有getter
【成员方法】即可简化字段取值操作。
// 继续前面的代码
impl Type1<State1> {
// 既可缩短【状态】独有字段的取值路径,
// 也可抹掉 <Type1>.state 与 <State1>.private_field1 字段的`pub`可见性修饰符。
fn private_field1(&self) -> &str {
&self.state.private_field1[..]
}
}
// 取值语句是不是精简多了?此外,该成员方法对 Type1<State2> 类型实例不可见。
dbg!(type1_state1.private_field1());
至此,一个完整的【例程】往这里看。
代码结构·示意图
文档注释小技巧
将描述【状态】含义的doc comments
放在(【泛型·类型】+【泛型·类型·实参】)实现块impl Type1<State1>
的上端,而不是在【状态·类型】结构体定义struct State1
之上。那么, rustdoc
便会把全部【状态】的doc comments
文案都收拢于一个html
文档页内(即,【泛型·类型】struct Type1<S1>
文档页内里),而不是分散于多个html
页。这给“下游”开发者提供了更友好的文档阅读连贯性。
/// 将描述【状态】的【文档注释】加在这里,
impl Type1<State1> {...}
/// 而不是加在这里。借助`intra-doc link`注释指令:[`Type1<State1>`](struct@crate::Type1#impl-Type1<State1>)
/// 你可以直接链接到上面`impl Type1<State1> {...}`的位置。
struct State1 {...}
借助intra-doc link
注释指令[
`Type1<State1>
`](struct@crate::Type1#impl-Type1<State1>)
,从【状态·类型】结构体定义struct State1
向(【泛型·类型】+【泛型·类型·实参】)实现块impl Type1<State1>
做文档链接,可以避免文档注释的大量重复。
对照OOP
概念
OOP
继承
Method
调用安全
由于Rust
【单态化】允许【成员方法 / 关联函数】仅对特定的(【泛型·类型】+【泛型·类型·实参】)组合可见(比如,Type1<State1>
),所以遵从【类型·状态】设计模式的Rust
代码能够保证调用安全。即,凡是被【编译器】审核通过的【成员方法】调用,即便到了【运行时】,其也是语义/状态正确的。而,不需要开发者在【成员方法】起始位置附加额外的“防御性”判断,以禁止其运行于不匹配的状态。
在OOP
程序中,自觉地添加“防御性”判断是资深程序员的基本素养。进而,避免【成员方法】被错误地运行于不匹配状态,执行未定义行为,和输出逻辑错误结果。于是,虽然不能(如Rust
单态化)阻止错误成员方法调用的出现,但至少能(凭“防御性”代码)拒绝错误调用的执行 — 就是成本有点高,得以程序崩溃为代价。还好啦!至少坚守了底线。下面就是OOP
程序的反面例子。
/// 警告:不推荐这么写。这仅只是一个反面例子。
///
/// 假设一共有三个状态
enum State {
State1,
State2,
StateN
}
struct Type1 {
// 【枚举类·字段类型】笼统地概括了所有可能的【状态】
// 或者讲,所有的【状态】都是同一个类型。
state: State
}
impl Type1 {
// 根据设计,该成员方法仅只对`State1`状态的实例有效。
// 没有了【类型·状态】设计模式的赋能,`operate1()`成员方法便保证不了“调用安全”。
fn operate1(&mut self) {
// 运行时【防御性·判断】造成了
match &self.state { // (1)重复的代码
State::State1 => {
// (2)更深的缩进
},
_ => { // (3)潜在的崩溃点
panic!("我不能工作于 State2 与 State3 状态");
// 若能在 State2 与 State3 状态就点不到`operate1()`成员方法,
// 那就完美了。
}
};
}
}
与type state
模式的程序相比,此处的OOP
代码一下子就少了fluent
的感觉了。试想每个成员方法都如operate1()
这般臃肿,那将是多么令人烦躁的困境。
OOP
泛型
与Rust
相比 ,cpp/java
【泛型·类型】的“形状”(即,成员方法+
字段·的集合)永远是相同的,无论【泛型·类型·形参】被实际代入什么【具体类型】。这是因为
Rust
— 在【编译】语法分析阶段,借助于AST
,安全地生成新类型定义(单态化)。这不仅仅是代换入【泛型·类型·实参】这么初级。相反,每对(【泛型·类型】+
【泛型·类型·实参】)组合都是拥有新成员方法(和关联函数)的新类型。Cpp
— 在【编译】词法分析阶段,以“字符串替换”方式,将模板内的“占位符”安全地·调换为·具体“类型名”。这既没有对旧类型“形状”的裁剪,也没有对新类型的定义。Java
— 在【运行时】,将·具体值·代入Class Object
的泛型参数。无从谈起,新建或改变【类型定义】,因为Java
又不是动态语言。
所以,在这里再次重申我的观点:请不要吐槽
rustc
编译时间长了。你看它在【编译】期间明显地完成了更多得多的工作。这怎么可能有闪电般的编译速度呢?如果你的项目与团队对程序的编译延时有着非常苛刻的要求,没准你们需要“换一个脚本语言技术栈”。
OOP
状态字段
在仅OOP
的结构体定义中,【状态·字段】被设计为一个【枚举类】enum State {State1, State2, StateN}
和以一个类型笼统地描述所有【状态】,所以
-
不再需要【泛型·类型·参数】
S1
了。上例中的Type1
结构体也不是【泛型·类型】,而是普通结构体struct Type1
了。/// 【枚举类】笼统地概括了所有可能的【状态】 /// 或者讲,所有的【状态】都是同一个类型。 enum State { State1, State2, StateN } /// 不再是【泛型类型】了 struct Type1 { com_field0: String, state: State // 状态字段 } impl Type1 { fn operate1(&mut self) { // 1. 防御性·判断 // 2. 真正的业务逻辑代码 } }
-
rustc
不会凭借【单态化】与【泛型·类型·实参】生成新类型了。 -
不再保证
Method
调用安全,因为每个状态的结构体实例都能“点”出全部的【成员方法】,而不论被“点”出的成员方法与当前状态是否匹配。 -
仅仅修改【状态·字段】的值,即可实现【状态·过渡】。而
Rust
类型状态设计模式却要求【状态·过渡】是“建新,弃旧;move
数据”的过程(详细见下文)。 -
状态字段也不再是零抽象成本了
总之,Rust
类型状态设计模式与OOP
仅有一分相似却带九分不同:OOP
是·运行时·多态,而Type State pattern
是·编译时·多态。
状态·过渡
迥异于OOP
程序直接修改【状态·字段】的值self.state = State::State2;
,Rust
【类型·状态】设计模式则要求:
- 构造【新】状态的【新】实例(见伪码
#1
注释) - 消费掉【旧】状态的【旧】实例(见伪码
#2
注释)。进而,将旧状态的字段值com_field0
按值传递给新状态实例。
其背后的逻辑是:
Type1<State1>
与Type1<State2>
是两个不同的类型。- 从
State1
至State2
的状态过渡就是从Type1<State1>
至Type1<State2>
类型转换 — 更Rustacean
的表述就是impl From<Type1<State1>> for Type1<State2> {...}
。 OOP
才用一个枚举类enum State
“笼统地”概括所有【状态】。然后,修改Type1.state
状态字段值·实现·状态过渡。
// 状态共有·实现块
impl<S1> Type1<S1> {
// 【状态·过渡】泛型函数 — 对所有状态都可见
// #2 消耗型成员方法 - 消费掉【旧】状态的【旧】实例
fn state_transition<NextState>(self, new_state: NextState) -> Type1<NextState> {
// #1 构造【新】状态的【新】实例
Type1 {
state: new_state, // 代入下一个状态的【泛型·类型·实参】
com_field0: self.com_field0 // 同时“移入”状态共有的【字段值】。
// 注意:这里不能使用`..self`解构语法是因为`Type1<State1>`
// 与`Type1<State1>`的类型不同
}
// self.state = new_state; 会导致编译失败,因为类型不匹配。
}
}
至此,一个完整的【例程】往这里看。
在文章开篇就强调过:“【类型·状态】设计模式能够在【编译时】就筛查出无关【状态】之间的错误跳变”。为了具备这个能力,仅需对上面【例程】再稍加两处修改:
- 将【状态·过渡】成员方法
state_transition
从(【泛型·类型】+【泛型·类型·形参】)实现块impl<S1> Type1<S1>
搬移至(【泛型·类型】+【泛型·类型·实参】)实现块impl Type1<State1>
。于是,【状态·过渡】也就成为了每个【状态】的个性化行为了。 - 将【状态·过渡】成员方法的【返回值·类型】从【泛型·类型·形参】替换为具体的【状态·类型】。
至此,一个完整的【例程】往这里看。
严格模式
在之前的例程中,【泛型·类型·参数】S1
能够接受任意【状态·类型】,而不管【泛型·类型】Type1<S1>
是否知道如何有效地处理它。这类完全开放式的程序设计并不满足日常生产的实际需求。通过给【泛型·类型·形参】S1
添加trait bound
限定条件,便可
- 禁止自定义【状态·类型】。比如,让编译器拒绝
Type1<State100>
,因为State100
并不是由“上游”程序代码预定义的【状态类型】,而是由“下游”开发者随意扩充的。上游代码不知道State100
的存在,和如何处理它。 - 分组【状态·类型】。然后,给每一组【状态】定义(组)私有【成员方法】。
拒绝自定义【状态·类型】
就代码套路来讲,就三步:
- 给【状态·类型】实现某个自定义的
trait
。- 后文称它为“【状态·类型】
trait
”。比如,trait State {}
。
- 后文称它为“【状态·类型】
- 密封该【状态·类型】
trait
— 使其对外部程序可见·却·不可实现。- 这里讲的【外部程序】:
- <往小处说>是【状态·类型】定义
module
之外的程序 - <往大处说>是【状态·类型】定义
crate
之外的程序 - 总之,【外部程序】就是指“下游”代码
- <往小处说>是【状态·类型】定义
- 具体作法就是:
- 把【状态·类型】
trait
作为subtrait
。 - 让其继承本地某个私有的
supertrait
。
- 把【状态·类型】
- 于是,因为
supertrait
是私有的,所以subtrait
对外即便可见·也不可实现。
- 这里讲的【外部程序】:
- 给【泛型·类型】
Type1<S1>
中的【泛型·类型·形参】S1
添加【状态·类型】trait
限定条件。比如,struct Type1<S1: State>
。
核心部分代码片段如下
/// “上游”代码定义【状态·类型】和它的`trait`
mod upstream {
/// 【状态·类型】
pub struct State1 {
pub private_field1: String // 【状态】独有【字段】
}
pub struct State2 {
pub private_field2: String // 【状态】独有【字段】
}
/// (私有的)密封`trait`
#[allow(private_in_public)]
trait Sealed {}
/// (公开的)【状态·类型】`trait`对外不可实现,
/// 因为它继承了(私有的)密封`trait`。
pub trait State: Sealed {}
/// 限定条件【状态·类型】
impl Sealed for State1 {}
impl Sealed for State2 {}
impl State for State1 {}
impl State for State2 {}
/// 借助`trait bound`,仅接收被`trait State`限定的
/// 内部定义的【状态·类型】。任意乱七八糟来源的【状态·类型】都
/// 会导致编译器报错。
pub struct Type1<S1: State> {
pub state: S1,
pub com_field0: String
}
}
/// “下游”代码使用【状态·类型】,和扩充自定义【状态·类型】失败!
mod downstream {
use super::upstream::{State, State1, Type1};
struct State3;
// 因为`trait Sealed`对外不可见,所以`State3`不能被扩展为额外的【状态类型】
// impl State for State3 {}
pub fn main() {
let type1_state1: Type1<State1> = Type1 {
com_field0: "对所有状态都看得到的,共用字段值".to_string(),
state: State1 {
private_field1: "状态1私有字段值".to_string()
}
};
dbg!(&type1_state1.com_field0);
}
}
至此,一个完整的【例程】往这里看。
分组【状态·类型】灵活模式
在之前的例程中,【泛型·类型】Type1<S1>
的【成员方法】
- 要么,对所有【状态】都可见,
- 要么,仅对某个特定【状态】可见。
通过给【实现块】impl<S1> Type1<S1>
的【泛型·类型·形参】S1
添加【状态·类型】trait
限定条件,【成员方法】的可见范围就能够被限定于(同属一组的)某几个【状态】上。
就代码套路来讲,只需三步:
- 【准备】定义若干个
marker trait
分别代表不同的“分组”。比如,trait Group1 {}
。(见伪码#1
注释) - 【分组】给【状态·类型】实现不同的
marker trait
。(见伪码#2
注释) - 【就绪】定义包含了
marker trait
限定条件的【实现块】impl<S1: Group1> Type1<S1> {...}
。(见伪码#3
注释)
于是,在该【实现块】impl<S1: Group1> Type1<S1>
内定义的【成员方法】就仅只对组内若干个【状态】可见了。
/// 【状态·类型】
struct State1 {
private_field1: String // 【状态】独有【字段】
}
struct State2 {
private_field2: String // 【状态】独有【字段】
}
struct State3 {
private_field3: String // 【状态】独有【字段】
}
// #1 定义·分组`marker trait`
trait Group1 {}
// #2 分组【状态·类型】
impl Group1 for State2 {}
impl Group1 for State3 {}
/// 【泛型·类型】+【泛型·类型·形参】
struct Type1<S1> { // <- 被参数化的【状态·类型】既作为【泛型·类型·参数】,
state: S1, // <- 也作为【状态·字段】的字段类型
com_field0: String // 所有状态共有的【字段】
}
impl<S1> Type1<S1> { // 所有状态共有的【成员方法】
fn com_function0(&self) -> String {"全状态可调用".to_string()}
}
/// #3 【分组】内共有的【成员方法】
impl<S1> Type1<S1>
where S1: Group1 { // Group1 状态共有的【成员方法】
fn group_function1(&self) -> String {"Group1 状态可调用".to_string()}
}
/// 【状态】`State1`独有【成员方法】,对任何其它状态都不可见
impl Type1<State1> { //
fn private_function1(&self) -> String {"仅 State1 状态可调用".to_string()}
}
/// 【状态】`State2`独有【成员方法】,对任何其它状态都不可见
impl Type1<State2> {
fn private_function2(&self) -> String {"仅 State2 状态可调用".to_string()}
}
// 状态1 实例
let type1_state1: Type1<State1> = Type1 {
com_field0: "对所有状态都看得到的,共用字段值".to_string(),
state: State1 {private_field1: "状态1私有字段值".to_string()}
};
dbg!(&type1_state1.com_field0);
dbg!(type1_state1.com_function0());
// 对非 Group1 的状态,没有 group_function1() 成员方法
// dbg!(type1_state1.group_function1());
// 状态2 实例
let type1_state2: Type1<State2> = Type1 {
com_field0: "对所有状态都看得到的,共用字段值".to_string(),
state: State2 {private_field2: "状态1私有字段值".to_string()}
};
dbg!(type1_state2.group_function1());
至此,一个完整的【例程】往这里看。
性能优化
零抽象成本的【状态】字段
此优化方法仅适用于unit type
【状态·字段】。一旦不需要依靠【状态】自身的存储力(即,S1
没有字段),那么【泛型·类型】Type1<S1>
中的【状态·字段】state
就蜕变成了【编译时】仅供rustc
理解源码的“分类标记flag
”,而不是【运行时】赋值/比较的状态变量。其作用与【解析几何】中的“辅助线”无异,帮助rustc
读懂我们的代码,然后即被抛弃掉。
在Rust
语法中,将结构体·字段定义为“标记flag
”,仅需将该字段·数据类型限定为std::marker::PhantomData<T>
即可。比如,
use ::std::marker::PhantomData;
struct Type1<S1> {
state: PhantomData<S1>,
com_field0: String
}
于是,在编译后,字段state
便会
- 从机器码内被删除
- 避免任何的【运行时】存储开销
而在编译过程中,rustc
会把它当作【单态化】新类型的“辅助线”。
use ::std::marker::PhantomData;
/// 【状态·类型】
struct State1; // 无·独有·字段。仅做分类描述。编译后,其就没了。
struct State2;
/// 【泛型·类型】+【泛型·类型·形参】
struct Type1<S1> { // <- 被参数化的【状态·类型】既作为【泛型·类型·参数】,
state: PhantomData<S1>, // <- 也作为【状态·字段】的字段类型
com_field0: String // 所有状态共有的【字段】
}
/// 所有状态共有的【成员方法】
impl<S1> Type1<S1> {
fn com_function0(&self) -> String {"全状态可调用".to_string()}
}
/// 【状态】`State1`独有【成员方法】,对任何其它状态都不可见
impl Type1<State1> {
fn private_function1(&self) -> String {"仅 State1 状态可调用".to_string()}
}
/// 【状态】`State2`独有【成员方法】,对任何其它状态都不可见
impl Type1<State2> {
fn private_function2(&self) -> String {"仅 State2 状态可调用".to_string()}
}
// ---- 实例化【状态1】
let type1_state1: Type1<State1> = Type1 {
com_field0: "对所有状态都看得到的,共用字段值".to_string(),
state: PhantomData
};
// 即便对 Type1<State2> 实例,此【成员方法】调用也是成立的。
dbg!(type1_state1.com_function0());
// 对 Type1<State2> 实例,此会报编译错误。
dbg!(type1_state1.private_function1());
至此,一个完整的【例程】往这里看。
按【引用】存储·状态共有【字段值】
此方案的优化点就是:避免在【状态·过渡】期间从【旧·状态】Type1<State1>
实例向【新·状态】Type1<State2>
实例move
大数据结构的(状态)共有【字段值】com_field0
,因为Type State pattern
【状态·过渡】就是“弃旧建新,继承数据”的过程。
优化思路就是:
- 首先,在【泛型·类型】
Type1<S1>
中,不直接保存【字段·所有权·值】本尊com_field0: String
,而仅缓存【所有权·值】的指针。 - 然后,【字段·所有权·值】的指针
- 既可以是:【智能指针】
com_field0: Box<String>
- 也能够是:附有
lifetime
限定条件的普通引用com_field0: &'a String
- 既可以是:【智能指针】
按【智能指针】存储·状态共有【字段值】
优点:
- 实现简单 — 只需修改【字段·数据类型】与【构造函数】。借助于
Rust
【自动·解引用】语法糖,com_field0
字段的使用几乎不受影响。
缺点:
- 将字段值从【栈】上搬运到【堆】上,造成了一次【堆】分配的运行时开销
- 在【栈】上,【智能指针】多少还是要比【普通·引用】占用的内存空间大一些
因为比较简单,所以它没有单独的例程。但,在综合例程中,我以智能指针Arc<Mutex<T>>
来缓存多状态共用字段值。
按【普通·引用】保存·状态共有【字段值】
优点:
- 在【栈】上搞定一切的极致性能优化。但,请理性评估代码复杂度,客观掂量是否划得来。应用程序慢点又不是世界末日。Take easy!
缺陷:
- 花式操作【生命周期·限定条件】。考验咱们
rust
编程基本功的时刻到了。
这还真值得给一个完整的例程,如下:
use ::std::marker::PhantomData;
/// 【状态·类型】
struct State1; // 无·独有·字段。仅做分类描述。编译后,其就没了。
struct State2;
/// 【泛型·类型】+【泛型·类型·形参】
struct Type1<'a, S1> { // 多了一个【泛型·生命周期·参数】
state: PhantomData<S1>,
com_field0: &'a String // 状态共有字段值是【引用】,而不是字符串自身
}
/// 所有【状态】共有的【成员方法】
impl<'a, S1> Type1<'a, S1> {
fn com_function0(&self) -> String {
"全状态可调用".to_string()
}
}
/// 【状态】`State1`独有【成员方法】,对任何其它状态都不可见
impl<'a> Type1<'a, State1> {
fn private_function1(&self) -> String {
"仅 State1 状态可调用".to_string()
}
}
/// 【状态】`State2`独有【成员方法】,对任何其它状态都不可见
impl<'a> Type1<'a, State2> {
fn private_function2(&self) -> String {
"仅 State2 状态可调用".to_string()
}
}
/// 【状态】`State2`独有【状态·过渡】函数被以【类型转换】的方
/// 式实现。即,从【状态1】到【状态2】的类型转换。
impl<'a> From<Type1<'a, State1>> for Type1<'a, State2> {
fn from(src: Type1<'a, State1>) -> Self {
Type1 {
state: PhantomData,
com_field0: src.com_field0 // 这里被搬运不是大字符串 String,
// 而仅只是一个普通的引用 &'a String
}
}
}
// ---- 实例化【状态1】
let com_field0 = "对所有状态都看得到的,共用字段值".to_string();
let type1_state1: Type1<'_, State1> = Type1 {
com_field0: &com_field0,
state: PhantomData
};
dbg!(type1_state1.com_field0);
dbg!(type1_state1.com_function0());
dbg!(type1_state1.private_function1());
// ---- 从【状态1】过渡至【状态2】
let type1_state2: Type1<'_, State2> = type1_state1.into();
dbg!(type1_state2.com_field0);
dbg!(type1_state2.com_function0());
dbg!(type1_state2.private_function2());
至此,一个完整的【例程】往这里看。
RAII
即是Type States
在Rust
中,RAII
就是【类型·状态·设计模式】只有两个状态(living / dead
或open / closed
)时的特例。据此,一旦【实例】进入后一个状态(dead / closed
),那么属于前一个状态(living / open
)的成员方法与关联函数就都不可见与不可调用了 — 这也是Rust
承诺的安全特性之一。比如,从被关闭的数据库连接实例上“点”execute_sql(str)
成员方法,不用等运行时异常报bug
,编译器就会第一时间向你报怨“错了呀!”。
此外,若【实例】具有多个living
状态和一个dead
状态,这就是普通的【类型·状态·设计模式】。
综合例程
通过给“无人机·飞行控制”建立【程序模型】,集中展现【类型·状态】设计模式的完整编码套路。在此例程中,被涉及到的技术知识点包括:
- 零抽象成本·状态字段(见
Flying<S: Motionless>.destination_state
) - 按【智能指针】存储的多个状态共有字段值(见
Drone<S: State>.coordinate
) - 密封【状态类型】禁止下游代码额外扩展(见
seal_by_trait!()
宏) - 【状态类型】分组(见
group_by_trait!()
宏) - 【状态组】独有成员方法(见
Drone<S: Midair>::take_picture(&self)
和Drone<Flying<S: Motionless>>::inner_fly(mut self, state, step)
) - 【状态】独有成员方法(见
Drone<Idle>::take_off(self)
) - 【状态】独有数据字段(见
Flying<S: Motionless>.origin
) - 编译时“多态”的【状态过渡】(见
Drone<Flying<Idle>>::fly(mut self, step)
和Drone<Flying<Hovering>>::fly(mut self, step)
) intra-doc link
文档注释指令
【无人机】总共在三个状态之间切换:
- 待命
Idle
—— 无人机·在地面上 - 飞行
Flying
—— 无人机·空中飞行 - 悬浮
Hovering
—— 无人机·静止于空中
上述三个状态又分成了两组:
- “静止”组
Motionless
,包括Idle
和Hovering
Flying
的紧下一个状态必须是Motionless
的。
- “空中”组
Midair
,包括Flying
和Hovering
- 空中的无人机有一个额外的功能就是“拍照”。
【无人机】三个状态各自还有独特的行为:
-
Idle
有take_off()
起飞·行为,从而将Idle
状态过渡为Flying
-
Hovering
有move_to()
前往land()
着落
两个行为,从而将
Hovering
状态过渡为Flying
-
Flying
有fly()
飞行·行为。该行为- 既是异步的:
- 用跨线程【迭代器】模拟【无人机】(缓慢)飞行过程。
- 还是多态的:
- 若紧前状态是
Idle
,那么紧后状态一定是Hovering
。即,Idle -> Flying -> Hovering
- 若紧前状态是
Hovering
,那么状态过渡目标既有可能是Idle
,还可能还是Hovering
。这取决于之前Hovering
是如何过渡到Flying
的。
- 若紧前状态是
- 既是异步的:
完整的【例程】往这里看。
结束语
这篇文章是我2022
年的收官之作,希望能够给大家带来更多启发与帮助。大家来点赞,留言呀!
评论区
写评论用 rust 和 oop 横向类比的思路也是非常可取的。就像一开始MongoDB得类比着Mysql学习是一样的
大神!大神!
讲的很深,对Rust实现状态机有了更好的理解
在这个论坛说“好人一生平安”是不是看起来怪怪的...... 总之感谢博主的分享。