上节说到,模板方法变化一下就能成策略模式,怎么变化的?且看策略模式典型案例:
pub trait Fly {
fn fly(&self);
}
pub trait Quack {
fn quack($self);
}
/// 先以静多态的方式实现
/// 似 trait Fly + Quack就是Duck,只是Fly和Quack独立地变化
struct Duck
where
F: Fly,
Q: Quack,
{
fly_behabior: F, // 单看这个成员,与模版方法如出一辙
quack_behavior: Q, // 一样,将不同的算法部分交给子类去实现
}
impl Duck
where
F: Fly,
Q: Quack,
{
pub fn new(fly_behavior: F, quack_behavior: Q) {
Self { fly_behavior, quack_behavior }
}
}
/// 实现不同的Fly、Quack策略,参考下图,省略...
/// 下图引用自 Oreilly.Head First Design Pattern
以上是策略模式的简单案例,策略模式可以说是模板方法的衍生变化。还记得上一章中第一种模板方法的实现方式不,单看Fly就是模板方法:模板方法里子类完全不依赖父类,干净地完成算法策略,那子类就能够依赖注入到父类中;最好这种子类不止一个,比如不仅有Fly还有Quack,就是纯正的策略组合模式了。了解这种变化可以帮助区分二者,比那说不清道不明的优缺点、适用场景描述能让你更清晰、透彻地认识到两者的差别与联系。
策略模式,公认的妙。上面是静多态实现的策略模式,会遇到类型爆炸的问题,比如有2种飞行方式、3种呱呱叫方式,那总共有2*3=6种复合类型,体现了组合是类型系统中的积类型。在嵌入式上,因为内存环境限制,类型爆炸导致程序大小变大成了问题,不得不改用动多态,以减少类爆炸带来的影响。
/// 动多态,类型统一了,类型也不会爆炸了
struct DynamicDuck {
fly_behavior: Box,
quack_behavior: Box,
}
面向对象语言,都是动多态,Java对象皆引用,当引用没地方用了就垃圾回收;C++没有指针则玩不转面向对象,只可能将子类指针赋值给父类指针来多态,无法将子类对象赋值给父类对象来多态吧!所以面向对象的策略模式是动多态,天然无类型爆炸问题。
那类型爆炸一定差吗,类型统一就肯定好吗?先讨论下类型爆炸合理不。自然界生物划分“界门纲目科属种”,动物界有那么多动物,比如都是猫科动物,难道老虎和狮子还不配拥有个自己的类型吗,只能共用猫类型吗?要是想为老虎这个类型单独实现点东西,但不想为狮子也实现这个东西,共用猫类型就不行了!这样看起来,接受类型爆炸挺好,类型完整,也没几个类型,程序大小允许就可以,相比于动不动就异步的task、协程,只要不是大规模类型爆炸,可以忍。而类型统一就会造成一种“类型丢失”,它的不良影响发生在后续为Duck添加其它行为时,这些行为并非所有Duck都需要的时候。比如为绿头鸭实现捕猎,为橡皮鸭实现电动,它们不再是所有鸭子都应有的行为,已有点不再适合使用新策略扩展(可不是所有扩展的行为都是鸭子通用型的Swim、Display,策略模式只拣好的说),但动多态却因“类型丢失”而不知所措,这其实是个难处理的点,本质是为了减少类型爆炸而采用动多态统一类型的牺牲。
/// 静多态可以直接别名
type MallardDuck = Duck<...>;
type RubberDuck = Duck<...>;
type DecoyDuck = Duck<...>;
/// 动多态因“类型丢失”,只能使用NewType,并在NewType中约束DynamicDuck。
/// 那这样,类型还是难免爆炸了啊!
struct MallardDuck(DynamicDuck);
struct RubberDuck(DynamicDuck);
struct DecoyDuck(DynamicDuck);
/// 仅为绿头鸭MallardDuck实现捕猎
impl MallardDuck {
fn hunt(&self) {
...
}
}
动多态策略模式再往下写很可能就开始坏味道了。为了解决这个问题,各种奇招就来了,如不管三七二十一,先把捕猎行为塞进Duck中,管其它鸭子会不会错用呢;或者,为橡皮鸭RubberDuck、木头鸭WoodDuck也实现个假的捕猎,这样“捕猎”就又符合新的策略了,又能使用策略模式了;又或者,再来次继承把绿头鸭子类化吧,然后单独给绿头鸭实现捕猎。。然而新类型MallardDuck一方面与动多态复合类型的Duck意义有冲突,不得不在文档中留下一句提醒使用者:“如果想用MallardDuck,请勿使用DynamicDuck构建,而是使用更具体的MallardDuck!”;另一方面,其它类型的Duck也需要子类化吗,若是的话岂不是又免不了类型爆炸了!策略模式这时正失去优雅的光环,它还是那个妙不可言的“策略模式”吗?
Rust语言,则可以静多态一路走到黑,Duck
类型当参数时一直泛型约束使用下去。这样看起来,静多态是一种挺好的应对策略模式后续变化的解决方案。Rust还有一种方式,可以终止这种“一直”,就是将有限的静多态类型通过enum和类型统一起来,然后再使用时就不必继续用泛型了,用这个enum和类型就好了。这是个好方法,但也有个弊端,enum和类型终止了模块之外的“扩展性”!在模块之外,再也无法为模块内的enum和类型扩展其它Duck实现,而动多态和一直泛型约束的静多态,则仍不失模块外的扩展性。
策略模式还有个问题,值得探讨,Duck也会飞,也会呱呱叫了,那有没有必要为Duck也实现Fly、Quack特型呢?
/// 有没有必要为Duck实现Fly/Quack trait?
impl Fly for Duck
where
F: Fly,
Q: Quack,
{
fn fly(&self) {
self.fly_behavior.fly();
}
}
impl Quack for Duck
where
F: Fly,
Q: Quack,
{
fn quack(&self) {
self.quack_behavior.quack();
}
}
这是个令人迷惑的选项,个人很讨厌这种“都可以”的选项,让人迟迟下不了决策。很多人从“应该不应该”的角度出发,会得到“应该”的答案,Duck应该会飞,所以为Duck实现了Fly特型,后面就可以用Fly来特型约束了。其实,若实现了,就像是另外一个设计模式——装饰器模式了。但我不建议普通的策略模式这样实现,将Fly和Quack组合起来的Duck,不再是飞行策略实现的一种变体,要是RubberDuck也能因满足Fly特型约束,再次充当Duck自己的“翅膀”F,组合成一个新Duck,那这是什么Duck?闹笑话了,一向以“严格”著称的Rust可不喜欢这样做。看起来Duck会飞,和飞行策略的Fly特型有所不同,读者可自行感受,那如何约束Duck,让别人知道Duck也是可飞行的一个类型呢?可以使用AsRef,让鸭子实现AsRef
,意为“Duck拥有飞行的策略”,鸭子自然也会飞,能做所有会飞的类型可以做的事情。
fn fly_to_do_sth(fly_animal: &mut T)
where
T: AsRef,
F: Fly,
{
// Duck也可以作为fly_animal来执行此函数了
}
注意,这里AsRef跟Deref的区别。AsRef可以实现多次,到不同类型的借用转换,比如Duck同时AsRef
初识策略模式时,觉得妙不可言,但它其实没提策略模式那逐渐不可控的后续演化,源于为策略模式的复合类型Duck扩展行为时,并非所有Duck都该有这些扩展行为了,它们很可能是某些鸭子独有的,主要原因是动多态造成了“类型丢失”,而解决办法还没法令人满意!因此,策略模式适合后续不再演化的场景。能应对后续演化的,还得是类型完整的静多态思路。
编程的一大挑战就是为了应对变化,开发者知道的招式变化越多,应对的就越从容,使用看起来正确实际上却会逐渐失控的招式,只会味道越来越坏。变化就是“可扩展性”,谈到“可扩展性”,面向对象说这个我熟,“可扩展性”就是面向对象的目标之一啊!先别轻信,完美应对变化可不容易,即便资深的面向对象专家,都不敢说他写的每个东西都真能满足“单一职责”。。单一职责的足够“原子化”吗?面向对象思想有个老毛病,就是不够具体,让人抓不到,又让人以为抓到了,实际上是面向对象规定的东西,包括它的评论、解释大都泛泛而谈,没有一个度,很难意见统一。
(强调一下:因每个人理解层次不同,这一系列文章无意引战,也不想批评C++,只要C++想,就能实现Rust一样的效果,毕竟现代C++无所不能的。面向对象有些问题值得指出、批评,但个人还是认可面向对象的结构之美。这些文章,仅供大家友好交流Rust和面向对象技术,若有迁移一个面向对象项目到Rust重新实现的需求,那可能会有帮助,欢迎大家友好讨论!)
(原创不易,请在征得作者同意后再搬运,并注明出处!)