< 返回我的博客

爱国的张浩予 发表于 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,所以他们就没再搞出这类复杂的内存管理概念,和增加开发者的心智负担。

  • 另一方面,Cppmove赋值语义仅只是对历史包袱的【妥协方案】。这体现在

    • Cppmove语义是: 用空指针nullptr换走原变量的值;但,原变量依旧可访问。这哪里是move,分明是swap呀!
    • Rustmove语义是:拿走原变量的值;同时,作废原变量。这个操作也被称为“消耗consuming”。

    所以,和Rust相比,Cppmove赋值语义至多就是一个“弟弟”。其功能相当于Rust标准库提供的std::mem::take(&T) -> T内存操作 — 使用【类型·默认值】置换出【引用】内存位置上的值;同时,保留·原变量·的【所有权】不被消耗掉和可以被接着使用。

  • 此外,move也不是Cpp变量赋值的默认语义。相反 ,开发者得显示地编码std::move(ptr)函数调用和将lvalue转换为rvalue

    Cppstd::move(ptr)函数调用是【零·运行时·成本】的。在编译之后,编译器会将其从机器码内扣掉。其“辅助线”般的功能有些类似于Rust中的std::marker::PhantomData<T>

名词解释

为了避免后续文章内容过于啰嗦,首先定义五个词条,包括:

  • 泛型类型
  • 泛型类型参数
  • 泛型类型
  • 泛型类型
  • 泛型类型参数限定条件

一图抵千词,大家来看下图吧:

名词解释

基本概念

【类型状态·模式】的初衷是:将【运行时】对象状态的(动态)信息·编码入·【编译时】对象类型的(静态)定义里。进而,借助现成且完备的Rust【类型系统】,在【编译】过程中,确保:

  1. 处于不同状态的(泛型类型)实例·拥有不一样的(【成员方法】+【关联函数】+【字段】)集合。
  2. (泛型类型)实例·仅能在毗邻的状态之间进行“状态·过渡”,而不能“跳变”。
  3. 排查出·状态的成员方法调用。比如,A状态的实例调用了仅在B状态才有效的成员方法。 而不是,让这类错误潜伏着和等【测试覆盖】或抛出【运行时·异常】。

以【订单系统】为例,【编译器】就能筛查出代码里

  • 对【无效订单】实例的【发货】成员方法调用
  • 对【出库订单】实例的【完成】成员方法调用 — 还未经历【发货】与【收款】两个状态

相对于传统的OOP程序,Rust【类型状态】设计模式将【对象·状态】的【运行时】检查前置于【编译环节】。进而带来的好处包括但不限于:

  • 将【运行时】程序崩溃“无害化”为【编译时】错误。
    • 就开发者而言,这意味着更短的【思考+试错】反馈回路。
    • 就应用程序而言,这意味着更高的性能,更健壮的可靠性,和更重的应用程序大小 — 【单态化】的本质就是以空间换时间
  • 允许IDE提供更有价值的代码提示。即,仅智能地列出对当前状态实例有效的【成员方法】,而不是罗列全部成员方法。比如,当开发者“点”一个【无效订单】实例时,IDE就不应该提示出【发货】成员方法。这才是对开发者最实在的帮助。

代码套路

从操作细节来说,为了采用【类型状态·设计模式】,我们需要:

  1. 将每个【状态】分别映射为独立的【结构体】(比如,struct State1)。和在结构体内,定义【状态】独有的:字段。(见伪码#1注释)
    1. 而不是,使用一个【枚举类】enum State {...}笼统地描述所有【状态】
    2. 后文称这类【结构体】为【状态·类型】。
  2. 以【泛型·类型】+【泛型·类型·参】的结构体定义(比如,struct Type1<S1>),抽象所有【状态】共有的:字段。(见伪码#2注释)
  3. 以【泛型·类型】+【泛型·类型·参】的实现块(比如,impl<S1> Type1<S1>),抽象所有【状态】共有的:成员方法,关联函数,关联常量,和关联类型。(见伪码#3注释)
  4. 以【泛型·类型】+【泛型·类型·参】的实现块(比如,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继承

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}和以一个类型笼统地描述所有【状态】,所以

  1. 不再需要【泛型·类型·参数】S1了。上例中的Type1结构体也不是【泛型·类型】,而是普通结构体struct Type1了。

    /// 【枚举类】笼统地概括了所有可能的【状态】
    /// 或者讲,所有的【状态】都是同一个类型。
    enum State {
        State1,
        State2,
        StateN
    }
    /// 不再是【泛型类型】了
    struct Type1 {
        com_field0: String,
        state: State // 状态字段
    }
    impl Type1 {
        fn operate1(&mut self) {
            // 1. 防御性·判断
            // 2. 真正的业务逻辑代码
        }
    }
    
  2. rustc不会凭借【单态化】与【泛型·类型·实参】生成新类型了。

  3. 不再保证Method调用安全,因为每个状态的结构体实例都能“点”出全部的【成员方法】,而不论被“点”出的成员方法与当前状态是否匹配。

  4. 仅仅修改【状态·字段】的值,即可实现【状态·过渡】。而Rust类型状态设计模式却要求【状态·过渡】是“建新,弃旧;move数据”的过程(详细见下文)。

  5. 状态字段也不再是零抽象成本了

总之,Rust类型状态设计模式与OOP仅有一分相似却带九分不同:OOP是·运行时·多态,而Type State pattern是·编译时·多态。

状态·过渡

迥异于OOP程序直接修改【状态·字段】的值self.state = State::State2;Rust【类型·状态】设计模式则要求:

  1. 构造【新】状态的【新】实例(见伪码#1注释)
  2. 消费掉【旧】状态的【旧】实例(见伪码#2注释)。进而,将旧状态的字段值com_field0按值传递给新状态实例。

其背后的逻辑是:

  1. Type1<State1>Type1<State2>是两个不同的类型。
  2. State1State2的状态过渡就是从Type1<State1>Type1<State2>类型转换 — 更Rustacean的表述就是impl From<Type1<State1>> for Type1<State2> {...}
  3. 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; 会导致编译失败,因为类型不匹配。
    }
}

至此,一个完整的【例程】往这里看。

在文章开篇就强调过:“【类型·状态】设计模式能够在【编译时】就筛查出无关【状态】之间的错误跳变”。为了具备这个能力,仅需对上面【例程】再稍加两处修改:

  1. 将【状态·过渡】成员方法state_transition从(【泛型·类型】+【泛型·类型·参】)实现块impl<S1> Type1<S1>搬移至(【泛型·类型】+【泛型·类型·参】)实现块impl Type1<State1>。于是,【状态·过渡】也就成为了每个【状态】的个性化行为了。
  2. 将【状态·过渡】成员方法的【返回值·类型】从【泛型·类型·形参】替换为具体的【状态·类型】。

至此,一个完整的【例程】往这里看。

严格模式

在之前的例程中,【泛型·类型·参数】S1能够接受任意【状态·类型】,而不管【泛型·类型】Type1<S1>是否知道如何有效地处理它。这类完全开放式的程序设计并不满足日常生产的实际需求。通过给【泛型·类型·形参】S1添加trait bound限定条件,便可

  • 禁止自定义【状态·类型】。比如,让编译器拒绝Type1<State100>,因为State100并不是由“上游”程序代码预定义的【状态类型】,而是由“下游”开发者随意扩充的。上游代码不知道State100的存在,和如何处理它。
  • 分组【状态·类型】。然后,给每一组【状态】定义(组)私有【成员方法】。

拒绝自定义【状态·类型】

就代码套路来讲,就三步:

  1. 给【状态·类型】实现某个自定义的trait
    • 后文称它为“【状态·类型】trait”。比如,trait State {}
  2. 密封该【状态·类型】trait — 使其对外部程序可见·却·不可实现
    • 这里讲的【外部程序】:
      • <往处说>是【状态·类型】定义module之外的程序
      • <往处说>是【状态·类型】定义crate之外的程序
      • 总之,【外部程序】就是指“下游”代码
    • 具体作法就是:
      1. 把【状态·类型】trait作为subtrait
      2. 让其继承本地某个私有的supertrait
    • 于是,因为supertrait私有的,所以subtrait对外即便可见·也不可实现
  3. 给【泛型·类型】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限定条件,【成员方法】的可见范围就能够被限定于(同属一组的)某几个【状态】上。

就代码套路来讲,只需三步:

  1. 【准备】定义若干个marker trait分别代表不同的“分组”。比如,trait Group1 {}。(见伪码#1注释)
  2. 【分组】给【状态·类型】实现不同的marker trait。(见伪码#2注释)
  3. 【就绪】定义包含了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 / deadopen / closed)时的特例。据此,一旦【实例】进入后一个状态(dead / closed),那么属于前一个状态(living / open)的成员方法与关联函数就都不可见与不可调用了 — 这也是Rust承诺的安全特性之一。比如,从被关闭的数据库连接实例上“点”execute_sql(str)成员方法,不用等运行时异常报bug,编译器就会第一时间向你报怨“错了呀!”。

此外,若【实例】具有多个living状态和一个dead状态,这就是普通的【类型·状态·设计模式】。

综合例程

通过给“无人机·飞行控制”建立【程序模型】,集中展现【类型·状态】设计模式的完整编码套路。在此例程中,被涉及到的技术知识点包括:

  1. 零抽象成本·状态字段(见Flying<S: Motionless>.destination_state
  2. 按【智能指针】存储的多个状态共有字段值(见Drone<S: State>.coordinate
  3. 密封【状态类型】禁止下游代码额外扩展(见seal_by_trait!()宏)
  4. 【状态类型】分组(见group_by_trait!()宏)
  5. 【状态组】独有成员方法(见Drone<S: Midair>::take_picture(&self)Drone<Flying<S: Motionless>>::inner_fly(mut self, state, step)
  6. 【状态】独有成员方法(见Drone<Idle>::take_off(self)
  7. 【状态】独有数据字段(见Flying<S: Motionless>.origin
  8. 编译时“多态”的【状态过渡】(见Drone<Flying<Idle>>::fly(mut self, step)Drone<Flying<Hovering>>::fly(mut self, step)
  9. intra-doc link文档注释指令

image

【无人机】总共在三个状态之间切换:

  1. 待命Idle —— 无人机·在地面上
  2. 飞行Flying —— 无人机·空中飞行
  3. 悬浮Hovering —— 无人机·静止于空中

上述三个状态又分成了两组:

  1. “静止”组Motionless,包括IdleHovering
    • Flying的紧下一个状态必须是Motionless的。
  2. “空中”组Midair,包括FlyingHovering
    • 空中的无人机有一个额外的功能就是“拍照”。

【无人机】三个状态各自还有独特的行为:

  1. Idletake_off()起飞·行为,从而将Idle状态过渡为Flying

  2. Hovering

    1. move_to()前往
    2. land()着落

    两个行为,从而将Hovering状态过渡为Flying

  3. Flyingfly()飞行·行为。该行为

    1. 既是异步的:
      • 用跨线程【迭代器】模拟【无人机】(缓慢)飞行过程。
    2. 还是多态的:
      1. 若紧状态是Idle,那么紧状态一定是Hovering。即,Idle -> Flying -> Hovering
      2. 若紧前状态是Hovering,那么状态过渡目标既有可能是Idle,还可能还是Hovering。这取决于之前Hovering是如何过渡到Flying的。

完整的【例程】往这里看。

结束语

这篇文章是我2022年的收官之作,希望能够给大家带来更多启发与帮助。大家来点赞,留言呀!

评论区

写评论
kidd808 2022-12-27 12:31

用 rust 和 oop 横向类比的思路也是非常可取的。就像一开始MongoDB得类比着Mysql学习是一样的

kidd808 2022-12-25 12:26

大神!大神!

ManonLoki 2022-12-23 19:01

讲的很深,对Rust实现状态机有了更好的理解

RedPanda 2022-12-23 09:22

在这个论坛说“好人一生平安”是不是看起来怪怪的...... 总之感谢博主的分享。

1 共 4 条评论, 1 页