坏蛋Dan
知乎@坏蛋Dan
发布时间:2024.1.4

前言

昨天我们学了如何在rust中并发开发

坏蛋Dan:rust基础学习--day34

今天咱们继续往下学


Rust中面向对象编程(Object-Oriented Programming)的特点

Object-oriented programming (OOP)也就是面向对象编程是一种对程序进行建模的方法。

把对象(Object)作为编程中的概念是在上个世纪60年代出现的,编程语言Simula第一次把这个概念引入到编程语言中。

这个概念影响了Alan Kay[2] 的编程架构(对象之间相互传递信息)。为了描述这种架构,他在1967年提出了这个词:*object-oriented programming*

于是大家争先恐后的定义这个词的意思, 而对于rust来说,从某些定义的角度来看是符合面向对象编程的,而有些不符合。

我们将探索部分特性,它们是基于面向对象编程来架构的,并且了解它们是如何在rust中变的常用的。

我们也将知道rust中如何实现OOP这种设计模式以及使用rust的优势来替代这种模式的解决方案的权衡。

面向对象语言的特点(characteristics)

目前对于一门编程语言要具有哪些功能才能被称为面向对象语言这一点在编程社区中并没有达成一致。

不过这里有三个特征应该是有共识的,那就是对象、封装(encapsulation)以及继承(inheritance)。

rust受到了各种编程范式的影响,包括OOP,比如之前提到过的函数式编程。

我们来看下rust是怎么支持这些特点的。


对象包含的数据(data)和行为(behavior)

有一本“四人帮”书:Design Patterns: Elements of Reusable Object-Oriented Software [5] ,是OOP设计模式的概括目录。

它是这样定义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毫无疑问是面向对象的。

比如我们的structenum,它们都是包含着数据,并且拥有impl block,里面包含着改变数据的method

比如这样

虽然它们不叫object,但是该有的它们都有。


封装(Encapsulation):隐藏实现的细节

说完了对象,接下来自然就是封装了。

封装指的是具体实现细节不会暴露出来,仅提供调用的入口public api,内部的数据是不允许外部改变的,得通过特定的api才能改变。

在第七章的时候我们学到了利用pub关键字将modules、type、method等暴露出去,而这些默认是私有的。

比如:

我们用pub关键字把AveragedCollection这个struct暴露出去,但是它的字段list以及average都还是私有的,外部无法直接访问。

但是我们的数据是需要改变的,这时就需要impl block提供改变数据的api

其中addremove以及average这三个methodpub的,而update_average是私有的。

调用这个AveragedCollection的开发者只能通过上面这三个pub的方法来访问数据和改变数据。

这就封装完毕了,有了这一层封装,我们后续重构就会简单很多,因为只需要保留public api即可,其它内部的用户都不会感知到,比如我们可以使用HashSet替换Vec的类型,我们的内部代码需要跟着调整,但是外部调用这些api的人还是使用这些个api名字,所以感知不到。

所以如果封装也是OOP必要的特性,那rust毫无疑问也是满足的。


作为类型系统(Type System)和代码共享(Code Sharing)的继承(Inheritance)

继承是一种机制,一个对象能通过继承另一个对象来获得该对象的数据和行为。

如果继承也是OOP必要的特性,那么rust这回不满足了~

我们之前学的内容也没有相关的特点,一个struct是没有办法去inherit另一个struct的。

不过你应该想到了trait,我们可以通过impl某个trait来获得这个trait的方法,并且我们还可以覆盖原有方法的功能。但是没有继承数据这一功能概念。

一般你使用继承是因为以下两点

  1. 代码复用:好几个对象中都有重复的功能,比如setget,这个时候我们就能把这个抽出来作为一个base的对象,然后原有的几个对象通过继承去直接获取setget,这样就省下了很多代码。
  2. 多态性(polymorphism):由于某些场景对象有多种类型,而你需要直接使用它们,这个时候就需要类型继承了,对象的类型继承另一个对象的类型来达到直接在某个场景中允许多个类型存在。

关于以上两点,rust都是能满足的,比如代码复用最直观的自然就是impl trait。通过实现trait来复用方法和代码。而多态性这一点在rust的类型系统中也是有所表现的,比如我们的dyn,只需要这个类型实现了某一个trait即可。

大部分人认为多态和继承是差不多的,但它其实是一个更通用的概念。

对于继承来说,那些继承了的类型相当于子类(subclasses)。

Rust则使用泛型来抽象不同的类型,使用trait bound来约束哪些类型需要提供或者实现。这种一般被叫做*bounded parametric polymorphism,* 翻译过来就是有界参数多态性。

继承这一特性现在在各种编程语言中已经不太流行了,因为它提供的内容大多数时候是超出我们所需要的,这就有一定的危险性。

比如前端的原型链,如果当前对象调用的属性或者方法在当前对象中是不存在的,那就会沿着原型链一直往上找。

但是有的时候我们确实只是想知道这个对象上有没有这个属性或者方法,这个时候我们就得用hasOwnProperty[8]这个方法。

扯远了,我们来看下rust是如何直接有界参数多态性的。


使用Trait Objects来允许数据存在不同的类型

之前第八章我们学习vector的时候有说过它的元素只能是同一个类型。不过我们可以使用一个枚举来包裹这个元素,枚举支持存储不同类型的数据,这样我们既能保持vector,又能存储各种类型的数据。

不过这其实还有个问题,那就是这个枚举是由开发这个功能的人来定义的,当另外的开发者调用这个功能的时候它就只能是基于这个枚举的几种类型来传入数据,并不能拓展自己想要的。

我们来搞个例子复现下这个场景

我们准备搞一个GUI(graphical user interface)小工具,这个小工具接受一个渲染列表list,这个list里的元素实现了Draw这个trait,然后我们的这个工具就会调用元素自身的draw方法把这些个元素渲染到screen上。

我们的这个gui还提供了可供使用的类型,比如Button或者TextField等。

另外我们还需要提供自定义的入口,因为用户可能想要自己拓展一个,比如Image等。

如果是通过继承的方式来实现这个小工具的话,我们可以先定义一个名为Component的类,然后这个类有draw的方法,然后我们再定义其它的类,比如Button,然后继承这个Component,这样它就有draw这个方法了。

然后用户可以自定义类继承这个Component,然后自定义draw的内容。这样就能拓展了。

但是在rust中不能这这么做,我们来用rust的方式实现上面的功能。

我们先直接重启一个文件夹吧

然后我们再开一个lib.rs文件和一个gui.rs文件

gui.rs引入到lib.rs

基本框架搞定,然后我们来实现这个小工具


定义一个通用行为的trait

首先这个draw方法是所有渲染元素类型都需要有的,那它就是重复代码,我们把它抽出来当作一个通用trait--Draw

我们并没有实现这个draw方法,因为不同渲染元素类型的渲染逻辑是不一样的,这个得由它们自己来实现。

然后轮到我们的list了,我们把它放到一个Screenstruct里,毕竟元素是要渲染到screen上的。

list就存放到components里了,当然,你叫list也没差。它的类型是一个Vec>,这个类型表示这个vector里的元素类型是一个指针,指向一个只需要实现Draw这个trait的类型数据。

这里为啥是一个Box呢?

它是一个trait object,关键字是dyn

trait object指向实现了Draw这个trait的某个类型和一个表,这个表可用在runtime时找到这个类型实现的Draw的方法,比如这个类型自己实现的draw

trait object是一个指针,一般可以用&引用或者Box,后面我们会知道为什么非得是一个指针,这里暂时不表。

我们可以使用trait object替代泛型或者特定的某个类型。

trait object有点类似其他语言的object,它们的行为和数据两个概念是放到一起的。而rust中常规的structenum的数据和行为都是分开的,行为需要单独impl实现。

不过trait object虽然把数据和行为这两个概念放到了一起,但是它不能存放数据。

我们大部分时候使用它都是用来允许某个通用行为抽象化。

扯远了,回到我们的代码中

我们还实现了一个run的方法,调用list元素自身的draw方法。

我们其实可以使用泛型 + trait bound来实现上面这块代码。

但是这两种实现方式是有区别的。

第一就是这个components它只能允许一个类型, 这完全不符合要求,不过这一点我们可以通过使用枚举来解决这个问题

我们拓展了这个vector的类型,有几种渲染类型就有多少个泛型参数。

可以看到代码是相当的繁琐,另外还有一个问题。

我们之前说过编译阶段会执行一个泛型单例化的操作,它会根据你的代码给这个struct实现具体的类型,这也就是为什么runtime没有损耗的原因,大多数情况下也是推荐这样的。

但是这么做就得和拓展说再见了,因为这些类型是固定的。

调用这些个方法的人不能自己去拓展新的类型(体验感差~)。

所以为了拓展性,我们还是用trait object比较好。

扯远了,回到我们的代码中


实现这个trait

架子我们搭建完了,然后我们就来实现这些个渲染类型。

首先是button

宽高是必不可少的,然后就是btn的内容。这里渲染逻辑我们就不写了(毕竟只是个demo,不对,连demo也算不上,就一空架子,再说了,我压根不会GUI相关的知识。。。)。

原本这些字段和数据都是Button实例自己私有的,外面暂时没有方式访问。

但是我们用pub直接暴露出去了。

尽量不要这么做,这里是省事。

这块就不多说了,暂时就写这三个字段。

最后再来个我们自己拓展的,这个我们直接放到lib.rs里,表示这个是开发者自己拓展的。

它是一个下拉栏,options是一个vector,元素类型是String

这个是我们自己拓展的,但是没有报错,这就是因为我们使用了trait object

然后我们再定义一个run的方法

然后我们来到main函数中调用下

Ok,直接来run一下

可以看到内容都打印出来了。

现在我们的小工具是完成了,我们的screen不需要知道我们的components里都是些啥元素,反正它一定实现了Draw这个trait,不然通不过编译,screen只需要知道run即可。

这个行为挺像动态语言中的一个概念:duck typing也就是行为决定类型,表示只关注响应的信息而不关注信息的具体类型。

比如一个动物叫的像鸭子,走的像鸭子,那它就是个鸭子~


Trait Object执行动态调度(Dynamic Dispatch)

还记得前面说的泛型单例化么,编译器查找我们用过的类型,然后帮我们把泛型转换成对应的具体类型。这种最终转换出来的代码执行的是static dispatch也就是静态调度,即编译器知道编译阶段你要调用什么方法。

与之相反的就是动态调度,也就是说编译阶段编译器压根不知道你要做什么,只能留到runtime处理了。

trait object就是动态调度的,编译器压根不知道你有什么具体类型。而在runtime阶段,由于之前插入的指针,可以轻松找到对应的具体类型的方法。

同时,由于是动态的,impl的代码没法被内联(inline)到具体类型中。

这就存在损耗了。

但它确实是舒服,灵活。

具体要不要用看你自己~


实现一个面向对象的设计模式(an Object Oriented Design Pattern)

state pattern 是一种设计模式,它也是一种面向对象的设计模式。我们接下来就叫它状态模式了,它的关键就是:一个值存在多个内部状态state,而这个值的状态就由这些个state object代表,这个值的行为变化取决于这个值的状态。

每一个状态对象都拥有它自己的行为和调整当前状态到下一个状态的能力。

而这个值压根不需要知道发生甚么事了。

使用这种设计模式的好处是: 当业务需求发生改变的时候,我们不用去改变这个值相关的代码,我们只需要改变某些状态对象里的规则或者新增几个状态对象。

纯概念看的头都晕了,我们来搞个例子。

准备搞一个博客发表的struct,它有三种内部状态:draftreview以及published

我们先用传统的设计模式来实现,然后我们再用rust的方式来实现。

这个例子最终应该有下面四部分

  1. 博客文章最开始是一份空的草稿,也就是draft的状态
  2. 当文章完成之后,需要复查,也就是review状态
  3. 如果审查没有问题,那就会被发布,也就是published的状态。
  4. 只有发布的文章才会打印输出内容。

其余的改变都应该是无效的,比如draft的状态没有review就发布,这是无效的,应该保持为draft状态。

我们先写框架再实现内容

Post::new创建一个博客文章草稿。

然后调用post.add_text添加博客内容。

这个时候调用post.content应该是没东西输出的,因为我们的文章没有发布。

接着我们调用request_review方法请求审查,这个时候自然也是没东西输出的。

最后我们approve方法将文章发布,这时就有东西输出了。

这里面的状态转换我们并没有在这里看到,因为是在内部实现的。

框架这就搭好了,我们再来实现内容。

定义Post以及创建一个Draft状态的实例

话不多说,直接上代码

这有两个struct: PostDraft和一个trait: State

注意这里的StateDraft以及后面会写的PendingReviewPublished都是私有的。

对于Post来说,它有两个字段和一个关联函数newNew会创建一个post实例,初始状态则是Draft

除此之外,再没有别的入口可以创建一个post实例。

Poststate字段是一个Option>的类型。

关于这里为啥要用Option的问题,之后会解释,而为啥要用Box这一点是图方便并且到时允许拓展,和之前说的一样。

然后我们用Draft实现State

虽然状态不同,但是有些行为是状态共有的基础行为,所以我们把它抽了出来做通用行为。


存储文本作为文章实例的内容

接下来我们来实现文章的内容,之前我们在main.rs文件中调用的是一个叫add_text的方法,我们实现它。

这没啥好说的。

不过有点需要了解,那就是这个方法只属于post自己,和状态无关,所以它不是状态模式的一部分。


确保Draft状态的content是空的

现在我们的文章有了内容,但是在Draft状态时,这个content得是空的,因为它还没发布。

这里说的空,指的是打印输出时得是空的,而不是指content是空的。

目前它只能返回一个空字符串,因为我们还没有publish

我们还没有办法实现状态转换,我们会在等会再完善这个方法。


申请审查来改变文章的状态

接下来该实现review状态了

我们给Post实现了request_review这个方法,这个方法会改变自身的状态,由draft变为review

还记得之前说的状态模式中状态改变是由状态对象自己控制的吗?这里我们调用state.request_review就是让状态自身来改变这个状态。

那么自然的,在Draft里面就得有一个request_review返回一个review的状态。

不过你应该还注意到了我们给PendingReview实现了State这个trait ,并且这个request_review是放在公共行为State里的,为什么呢?

因为会报错~

这个方法只在Draft里面有需要,但是由于我们的post.state的状态是dyn的,所以压根不知道现在是什么状态,为了能通过编译,我们只能都实现了。

最后我们来解释下为什么这里会用if let并且为什么state需要用Option包裹。

我们在Post里面的request_view这个方法中获取了state的所有权,利用state.take这个方法把原来Some里面的值拿出来并且用一个None代替它自己。

为什么要填充代替呢?因为rust不允许struct中存在未填充的字段。

我们利用Some巧妙地把值直接拿出来,而不是借用。

那么问题又来了,为啥是要直接获得所有权?

因为为了让旧的状态彻底失效,同时切换到新的状态。

这里有个点还需要注意,那就是take了之后这个post的状态就是None,并且此时的Draft状态也被拿出来了变成s,只能作用于这个方法里面,只有post.state = Some(s.request_review())了之后才变成review的状态。

让它临时变成了None是为了确保这期间没有任何的方法可以使用这个post的状态。

这里就能看得出状态模式的好处了,我们的Postrequest_review的时候不用关心当前是什么状态,转变的行为和规则全部都由状态自身控制。


实现批准方法来改变content方法的行为

实现完review,接下来自然就是published了。

我们来实现approve这个方法,这个方法和request_review类似,都会改变当前状态

这个approve自然又是得放在State里的,所以Draft虽然也是用不到但是也得实现。

这里其实就能看到一个状态模式的缺点了,就是要写一堆重复的代码。

这个时候你应该想到了,为啥我们不直接在State这个trait里实现这俩方法返回self,然后在对应状态里写具体代码覆盖即可。

比如:

实际上这么做会遇到下面这个问题

你这个self是个啥编译器压根不知道,因为你是dynamic的。

不过它不是这段代码的重点,我们既然已经实现了published这个状态,那么我们就能输出内容了。

先是调用as_refOption的值转换成一个引用Option<&Box>,毕竟我们只是输出内容,把所有权交出去就有点搞笑了。

unwrap是为了讨好编译器并把Some里面的值拿出来,我们的代码在这里肯定不会发生panic场景,所以我们直接调用unwrap把里面的Some里的值拿出来,这里有点取巧了。

然后我们又调用了state.content把自己传入到里面。

至于为什么要大费周章这么做而不是直接自己返回content,等我们实现完了之后会有解释。

由于其它几个状态不需要输出,所以这里直接默认实现了这个状态

由于content不涉及self,所以可以直接写默认逻辑,其它状态也就不需要实现它们了。

然后我们的published状态覆盖这个方法,返回post.content

这里我们使用了生命周期,因为我们引用了Post并且返回的内容和Post有关。

我们不需要也不能把post.content的所有权交给Published,所以直接传一个引用值给state,而对于编译器来说,这个&Post啥时候莫得咯是压根不知道的,所以自然需要一个生命周期来注释。回忆一下我们的生命周期省略三板斧,发现三板斧中关于method的貌似不适用(如果存在多个参数,其中一个参数是self,那么它的生命周期将分配给所有的output lifetimes),因为这里是Post引用而不是self,当然,self自己的其实确实省略了。

扯远了,回到我们的代码中。

为什么要大费周章写这么多东西呢?

因为我们的post是不清楚当前状态的,所以我们并不能在post.approve这个方法里去判断是什么状态然后返回对应的值,另外这样也是违背了这个状态模式的初衷的。

现在是否要展示content已经交由状态自身来控制了,而我们的Post这是一个傻白甜,完全不知道发生甚么事了。

那么代码到这里就告一段落了。

前面有说到过为啥不直接使用static dispatch,而得dynamic dispatch,就像上一个章节里使用enum改写的方式。

不用enum改为static dispatch主要有两点原因

  1. 自然就是代码简洁的问题,上面那个章节你应该也看到了,有一个match来匹配所有的类型,如果类型非常多,就会很恶心。更何况这一章节还用了状态模式,多了很多重复代码。
  2. 拓展性问题,静态了自然就没办法拓展了,虽然在这个例子中没有体现。

权衡(trade-offs)状态模式

传统的方式在rust中有些水土不服,中间说了很多话,这里就不跟着翻译过来了,感兴趣的大佬请自行查看。

Implementing an Object-Oriented Design Patterndoc.rust-lang.org/book/ch17-03-oo-design-patterns.html#trade-offs-of-the-state-pattern

稍微总结下它里面说的缺点

  1. 某些状态之间会相互耦合
  2. 需要重复写一些逻辑代码

既然传统方式实现完了,那就该轮到rust特有的方式了。


把状态(state)和行为(behavior)改为类型(types)

废话少说,直接来看最终的代码

先不看代码内容,义眼丁真,鉴定为代码量确实少了很多~

我们把Draft、Review俩个状态该变成了俩struct类型,至于Published,他被开除了。

其中DraftPost表示草稿类型,它有两个方法,一个是add_text(原本是Post里的),另一个是request_review,把状态切换到PendingReviewPost中。

除此之外没了,Post不需要存储state,它只需要在new的时候把DraftPost抛出去即可。

然后之后的事情就不关这个Post事了。

这个DraftPost不需要有content这个方法,因为没这个必要。当开发者手贱去调用它的content方法时等待它的只有panic

DraftPost还有一个request_review,其他类型也没必要拥有,只有它需要改变状态变成PendingReviewPost

这里还有个点就是content交由所有类型自己存储,状态改变的时候把值给下一个状态就行了。

PendingReviewPost这货就更简单了,只需要改变当前状态的方法就行。

最后也没必要有这个Published ,直接把Post返回就好了。然后post自己有一个content的方法,只有调用Post.content的时候才可能有值,其他状态类型都是没有的。

这就很舒服。

来看下main.rs中的调用

这几个状态我们都用了post来命名变量,这样原来的post自然就被杀死了。

是不是非常的妙和优雅~~


总结

这一大章概念有点多,比较抽象,这是设计模式必然会遇到的问题。

不过最后用rust的方式改写传统状态模式就非常的让人耳目一新。

谢谢Rust哥

参考

  1. ^rust-Object-Oriented https://doc.rust-lang.org/book/ch17-00-oop.html#object-oriented-programming-features-of-rust
  2. ^Alan Kay https://www.edge.org/memberbio/alan_kay
  3. ^rust-characteristics-of-Object-Oriented https://doc.rust-lang.org/book/ch17-01-what-is-oo.html#characteristics-of-object-oriented-languages
  4. ^rust-objects-contain-data-and-behavior https://doc.rust-lang.org/book/ch17-01-what-is-oo.html#objects-contain-data-and-behavior
  5. ^The Gang of Four book https://www.academia.edu/43687858/Design_Patterns_Elements_of_Reusable_Object_Oriented_Software_by_Erich_Gamma_Richard_Helm_Ralph_Johnson_John_Vlissides
  6. ^rust-Encapsulation-that-hide-implement-details https://doc.rust-lang.org/book/ch17-01-what-is-oo.html#encapsulation-that-hides-implementation-details
  7. ^rust-Ineritance-as-type-system-and-code-sharing https://doc.rust-lang.org/book/ch17-01-what-is-oo.html#inheritance-as-a-type-system-and-as-code-sharing
  8. ^Object.prototype.hasOwnProperty https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/hasOwnProperty
  9. ^rust-use-Trait-Objects-to-allow-values-has-different-types https://doc.rust-lang.org/book/ch17-02-trait-objects.html#using-trait-objects-that-allow-for-values-of-different-types
  10. ^rust-defining-a-trait-for-common-behavior https://doc.rust-lang.org/book/ch17-02-trait-objects.html#defining-a-trait-for-common-behavior
  11. ^rust-17.2-implement-trait https://doc.rust-lang.org/book/ch17-02-trait-objects.html#implementing-the-trait
  12. ^rust-trait-object-perform-dynamic-dispatch https://doc.rust-lang.org/book/ch17-02-trait-objects.html#trait-objects-perform-dynamic-dispatch
  13. ^rust-implementing-an-Object-Oriented-Design-Pattern https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#implementing-an-object-oriented-design-pattern
  14. ^rust-defining-Post-and-create-new-instance-of-Draft-state-instance https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#defining-post-and-creating-a-new-instance-in-the-draft-state
  15. ^rust-storing-text-as-post-content https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#storing-the-text-of-the-post-content
  16. ^rust-ensure-Draft-state-content-is-empty https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#ensuring-the-content-of-a-draft-post-is-empty
  17. ^requesting-a-review-of-the-post-changes-its-state https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#requesting-a-review-of-the-post-changes-its-state
  18. ^rust-adding-approve-to-change-the-behavior-of-content https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#adding-approve-to-change-the-behavior-of-content
  19. ^rust-trade-offs-state-pattern https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#trade-offs-of-the-state-pattern
  20. ^rust-encoding-state-and-behavior-as-types https://doc.rust-lang.org/book/ch17-03-oo-design-patterns.html#encoding-states-and-behavior-as-types

编辑于 2023-01-07 11:16・IP 属地广东