昨天我们学了如何在rust
中并发开发
今天咱们继续往下学
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
毫无疑问是面向对象的。
比如我们的struct
和enum
,它们都是包含着数据,并且拥有impl block
,里面包含着改变数据的method
。
比如这样
虽然它们不叫object
,但是该有的它们都有。
Encapsulation
):隐藏实现的细节说完了对象,接下来自然就是封装了。
封装指的是具体实现细节不会暴露出来,仅提供调用的入口public api
,内部的数据是不允许外部改变的,得通过特定的api
才能改变。
在第七章的时候我们学到了利用pub
关键字将modules、type、method
等暴露出去,而这些默认是私有的。
比如:
我们用pub
关键字把AveragedCollection
这个struct
暴露出去,但是它的字段list
以及average
都还是私有的,外部无法直接访问。
但是我们的数据是需要改变的,这时就需要impl block
提供改变数据的api
其中add
、remove
以及average
这三个method
是pub
的,而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
的方法,并且我们还可以覆盖原有方法的功能。但是没有继承数据这一功能概念。
一般你使用继承是因为以下两点
set
和get
,这个时候我们就能把这个抽出来作为一个base
的对象,然后原有的几个对象通过继承去直接获取set
和get
,这样就省下了很多代码。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
了,我们把它放到一个Screen
的struct
里,毕竟元素是要渲染到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
中常规的struct
和enum
的数据和行为都是分开的,行为需要单独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
,它有三种内部状态:draft
、review
以及published
。
我们先用传统的设计模式来实现,然后我们再用rust
的方式来实现。
这个例子最终应该有下面四部分
draft
的状态review
状态published
的状态。其余的改变都应该是无效的,比如draft
的状态没有review
就发布,这是无效的,应该保持为draft
状态。
我们先写框架再实现内容
Post::new
创建一个博客文章草稿。
然后调用post.add_text
添加博客内容。
这个时候调用post.content
应该是没东西输出的,因为我们的文章没有发布。
接着我们调用request_review
方法请求审查,这个时候自然也是没东西输出的。
最后我们approve
方法将文章发布,这时就有东西输出了。
这里面的状态转换我们并没有在这里看到,因为是在内部实现的。
框架这就搭好了,我们再来实现内容。
Post
以及创建一个Draft
状态的实例话不多说,直接上代码
这有两个struct
: Post
、Draft
和一个trait
: State
。
注意这里的State
和Draft
以及后面会写的PendingReview
、Published
都是私有的。
对于Post
来说,它有两个字段和一个关联函数new
,New
会创建一个post
实例,初始状态则是Draft
。
除此之外,再没有别的入口可以创建一个post
实例。
Post
的state
字段是一个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
的状态。
这里就能看得出状态模式的好处了,我们的Post
在request_review
的时候不用关心当前是什么状态,转变的行为和规则全部都由状态自身控制。
content
方法的行为实现完review
,接下来自然就是published
了。
我们来实现approve
这个方法,这个方法和request_review
类似,都会改变当前状态
这个approve
自然又是得放在State
里的,所以Draft
虽然也是用不到但是也得实现。
这里其实就能看到一个状态模式的缺点了,就是要写一堆重复的代码。
这个时候你应该想到了,为啥我们不直接在State
这个trait
里实现这俩方法返回self
,然后在对应状态里写具体代码覆盖即可。
比如:
实际上这么做会遇到下面这个问题
你这个self
是个啥编译器压根不知道,因为你是dynamic
的。
不过它不是这段代码的重点,我们既然已经实现了published
这个状态,那么我们就能输出内容了。
先是调用as_ref
把Option
的值转换成一个引用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
主要有两点原因
match
来匹配所有的类型,如果类型非常多,就会很恶心。更何况这一章节还用了状态模式,多了很多重复代码。trade-offs
)状态模式传统的方式在rust
中有些水土不服,中间说了很多话,这里就不跟着翻译过来了,感兴趣的大佬请自行查看。
稍微总结下它里面说的缺点
既然传统方式实现完了,那就该轮到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哥
编辑于 2023-01-07 11:16・IP 属地广东