浅聊Rust【策略·设计模式】Strategy / Policy design pattern
【Rust - Strategy / Policy策略·模式】与【OOP - Dependency Inversion依赖倒置·模式】和【Javascript - Callback Functon回调函数·模式】皆同属一类设计模式组合Inversion of Control + Dependency Injection(控制反转 + 依赖注入)。为了描述简洁,后文将该组合记作:IoC + DI。
先上图(一图抵千词)
就着上图,我再进一步展开论述。
IoC容器
在IoC容器内定义
“业务总线”。即,算法实现的关键路线·工作流workflow。
在上图中,它就是从【固化模块1】至【固化模块3】的棕色箭头线路·处理流程。
可复用模块 — 它既屏蔽了算法的敏感技术细节,也起到了程序复用作用。
在上图中,我将其称为“固化模块”。
一般IoC容器会对外导出一个pub函数来
接收·依赖注入
触发执行·整个工作流
DI依赖注入
利用DI从“业务总线”上扣出可·填入·自定义实现细节的“trait坑位” — 非具体类型,避免IoC容器和单一类型“捆绑”。
在上图中,其对应于【可替换模块1】与【待实现模块1】。
作为“坑位”,有两个特质不能少:
第一,坑位·填充标准 — 即,坑位的规格。只有满足指定“填充标准”的struct实例才被允许注入IoC容器内的“坑位”里。
在rust中,由trait书面定义“填充·标准”。而且,因为rust区分【编译时·抽象】与【运行时·抽象】,所以“坑位·规格”又进一步分为:
【静态分派】泛型·类型impl Trait
【堆·动态分派】Box
【栈·动态分派】&dyn Trait — 【依赖项·构造】代码必须与【依赖·注入】程序处于同一个函数内,而不能再被抽离·封装于一个独立【构造函数】了。因为没有【所有权·智能指针】保持所有权“不灭”,所以【胖指针】背后的实际变量值会随着【构造函数】的结束执行而被释放掉 — 这会给【构造函数】调用端造成【野指针】困扰,借入检查器是不会答应的。若不明白的话,你再体会,体会!
在OOP中,由interface书面约定“填充·标准”。
因为js是弱类型的,所以不需要“书面的”坑位规格描述,开发者把【回调函数】约定记在心里或写到代码注释里即好。
第二,坑位·填充物。简单地讲,其就是各种【接口】的实现类·实例。
在rust中,还是区分【编译时·抽象】与【运行时·抽象】两种情况
【静态分派】trait具体·实现类·实例 — 瘦指针。编译器会自动将【泛型·类型·参数】的【具体·类型】实参展开 — 这叫单态化。
【动态分派】trait Object — 胖指针。而trait Object实例是被保存在【栈】上,还是被存储于【堆】内,并不重要。
在OOP中,就是实现了interface的class实例。
在js中,就是满足了(你在代码注释里备注的)函数签名约定的回调函数。
trait坑位
就IoC容器而言,仅有trait定义里的
成员方法
关联函数
关联常量
关联类型
是可见的。另外,因为rust允许为trait method提供默认实现,所以trait坑位也能为自己提供缺省实现项,若调用端·程序员没有注入定制解决方案的话。
trait坑位·填充物
首先,在Rust语境中,该“填充物”有一个专属名词叫作Strategy Structs。
其次,【闭包Closure】与【函数指针fn】被允许经由DI接口·注入至IoC容器内·不是什么语言“特例”,而是仅只因为【闭包Closure】与【函数指针fn】本质上就是实现了Fn / FnMut / FnOnce trait的struct实例。至于它们在字面量上不像struct,那是因为语法糖:
就【闭包】而言,编译器会自动为【闭包】生成一个匿名的struct类型,并将被捕获变量作为该struct类型的(私有)字段。此外,因为每个【闭包】的上下文环境与捕获变量都是不同的,所以每个【闭包】也都有专属的、一个独一无二的匿名struct类型和不同的私有字段。而在【闭包】体内定义的业务代码则会被封装于【闭包】struct的Fn::call(&self, args: Args) -> FnOnce::Output成员方法里。
就【函数指针fn】而言,fn自身就是一个无字段的Fn trait实现类。于是,因为fn类型没有字段,所以【函数】也就不能捕获任何的外部变量。
编译器真的为我们做了许多的事情。
最后,凭借trait实现类的(私有)字段,还能实现
缓存·中间计算结果
捕获·容器外状态数据
的功能。
IoC + DI在rust的技术落地
相对于弱类型的js,强类型的rust
借助trait method,约定“回调函数”的函数签名 — js没有类型,也就不需要书面地声明(回调)函数签名
所有·技术细节·都以对IoC容器透明的方式被封装于此回调函数里。
借助trait实现类的(私有)字段,从IoC容器外捕获变量 — js函数的天赋技能之一就是【捕获变量】,所以不用显示地写这类代码。
这样从DI接口注入就不只是功能“行为”,还有(独立于输入数据的)额外状态信息。
相对于玩转【堆】的java,rust还允许向IoC容器注入复杂数据类型的【栈】变量值,而无论该变量值是被【静态分派】还是【动态分派】。
于是,我的总结是在rust里的IoC + DI的设计模式落地·比js严谨,比java灵活。
综合性【例程】将知识点串联起来
该【例程】实现的功能是:
载入【源数据】
生成【报表】
给【报表】生成【数字签名】 — 防止【报表】内容被篡改。
该【例程】代码分成三个子模块。它们分别对应IoC + DI设计模式内的三大构件:
IoC容器mod ioc_container和ioc_container::Report类型。并且,在ioc_container::Report::generate()关联函数内定义了
业务总线
可复用的功能模块ioc_container::Report::sign_me()给【报表】生成【数字签名】。
DI注入标准(也称trait坑位规格)mod di_spec。只有满足了该规格要求的struct实例或closure才能被注入到IoC容器内。在本例中,包括:
如何获取【源数据】di_spec::Ingredient— 这是一个被动态分派的【闭包】签名。
如何格式化【源数据】di_spec::Formatter — 这是一个待实现的trait
DI依赖项(也称trait坑位·填充物)mod di_stuff。在本例中,包括:
高阶函数fn data_builder()。
它输出了可生成【报表·源数据】的闭包。
更重要的是,由此高阶函数输出的闭包满足了di_spec::Ingredient定义的函数签名。
纯文本格式化【源数据】的代码di_stuff::Text
JSON格式化【源数据】的代码di_stuff::Json
最后,在main函数内,依次
实例化DI依赖项
将DI依赖项注入IoC容器 — 就是给ioc_container::Report::generate()关联函数传参。
执行“业务总线”工作流
读取【源数据】,
格式化【报表】,
生成【数字签名】
获得一个Report结构体实例。其包括了
报表的文本内容
它的数字签名
思路扩展
【条件编译】plus【策略·设计模式】是一套非常棒的多平台适配方案。即,
将【核心业务】中·与平台相关的·功能模块·扣成trait坑位。
给每个trait坑位准备多套·适配不同(交叉编译)目标平台的·Strategy Structs具体实现。
在编译时,根据rustc --cfg或cargo --features命令行参数,(利用#[cfg(...)]元属性)将恰当的Strategy Struct(依赖)注入到·封装了核心业务IoC容器里的trait坑位内。
输出兼容于指定平台的exe或dll文件。
哎呀!怎么越讲,越像serde crate了。但是,这么设计真是很【优雅】!
结束语
经由【回调函数】将·可定制技术细节·甩出【主函数】是一条比较常见的编程套路。可是,一旦给“土·方子”赋上一个fancy name,好似一切都变得好高端、好抽象、好难理解!所以,我个人提议:将Rust - Strategy设计模式重命名为更接地气的和土得掉渣的名字“回调函数·模式”。
Rust技术社区微信公众号转载链接