北海 - Rust与面向对象(三)

2023-12-30 14:32:30 浏览数 (2)

策略模式

上节说到,模板方法变化一下就能成策略模式,怎么变化的?且看策略模式典型案例:

代码语言:javascript复制
pub trait Fly {
    fn fly(&self);
}

pub trait Quack {
    fn quack($self);
}

/// 先以静多态的方式实现
/// 似 trait Fly   Quack就是Duck,只是Fly和Quack独立地变化
struct Duck<F, Q> 
where
    F: Fly,
    Q: Quack,
{
    fly_behabior: F,      // 单看这个成员,与模版方法如出一辙
    quack_behavior: Q,    // 一样,将不同的算法部分交给子类去实现
}

impl<F, Q> Duck<F, Q> 
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种复合类型,体现了组合是类型系统中的积类型。在嵌入式上,因为内存环境限制,类型爆炸导致程序大小变大成了问题,不得不改用动多态,以减少类爆炸带来的影响。

代码语言:javascript复制
/// 动多态,类型统一了,类型也不会爆炸了
struct DynamicDuck {
    fly_behavior: Box<dyn Fly>,
    quack_behavior: Box<dyn Quack>,
}

面向对象语言,都是动多态,Java对象皆引用,当引用没地方用了就垃圾回收;C 没有指针则玩不转面向对象,只可能将子类指针赋值给父类指针来多态,无法将子类对象赋值给父类对象来多态吧!所以面向对象的策略模式是动多态,天然无类型爆炸问题。

那类型爆炸一定差吗,类型统一就肯定好吗?先讨论下类型爆炸合理不。自然界生物划分“界门纲目科属种”,动物界有那么多动物,比如都是猫科动物,难道老虎和狮子还不配拥有个自己的类型吗,只能共用猫类型吗?要是想为老虎这个类型单独实现点东西,但不想为狮子也实现这个东西,共用猫类型就不行了!这样看起来,接受类型爆炸挺好,类型完整,也没几个类型,程序大小允许就可以,相比于动不动就异步的task、协程,只要不是大规模类型爆炸,可以忍。而类型统一就会造成一种“类型丢失”,它的不良影响发生在后续为Duck添加其它行为时,这些行为并非所有Duck都需要的时候。比如为绿头鸭实现捕猎,为橡皮鸭实现电动,它们不再是所有鸭子都应有的行为,已有点不再适合使用新策略扩展(可不是所有扩展的行为都是鸭子通用型的Swim、Display,策略模式只拣好的说),但动多态却因“类型丢失”而不知所措,这其实是个难处理的点,本质是为了减少类型爆炸而采用动多态统一类型的牺牲。

代码语言:javascript复制
/// 静多态可以直接别名
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<F, Q>类型当参数时一直泛型约束使用下去。这样看起来,静多态是一种挺好的应对策略模式后续变化的解决方案。Rust还有一种方式,可以终止这种“一直”,就是将有限的静多态类型通过enum和类型统一起来,然后再使用时就不必继续用泛型了,用这个enum和类型就好了。这是个好方法,但也有个弊端,enum和类型终止了模块之外的“扩展性”!在模块之外,再也无法为模块内的enum和类型扩展其它Duck实现,而动多态和一直泛型约束的静多态,则仍不失模块外的扩展性。

策略模式还有个问题,值得探讨,Duck也会飞,也会呱呱叫了,那有没有必要为Duck也实现Fly、Quack特型呢?

代码语言:javascript复制
/// 有没有必要为Duck实现Fly/Quack trait?
impl<F, Q> Fly for Duck<F, Q> 
where
    F: Fly,
    Q: Quack,
{
    fn fly(&self) {
        self.fly_behavior.fly();
    }
}

impl<F, Q> Quack for Duck<F, Q>
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<F: Fly>,意为“Duck拥有飞行的策略”,鸭子自然也会飞,能做所有会飞的类型可以做的事情。

代码语言:javascript复制
fn fly_to_do_sth<T, F>(fly_animal: &mut T) 
where
    T: AsRef<F>,
    F: Fly,
{
    // Duck也可以作为fly_animal来执行此函数了
}

注意,这里AsRef跟Deref的区别。AsRef可以实现多次,到不同类型的借用转换,比如Duck同时AsRef<F: Fly>和AsRef<Q: Quack>;而Deref只能实现一次到一个主Target的类型转换,而Fly和Quack无论哪个行为,明显都不足以让Duck为其实现Deref,它的父类动物结构,才值得Duck使用Deref。

小结

初识策略模式时,觉得妙不可言,但它其实没提策略模式那逐渐不可控的后续演化,源于为策略模式的复合类型Duck扩展行为时,并非所有Duck都该有这些扩展行为了,它们很可能是某些鸭子独有的,主要原因是动多态造成了“类型丢失”,而解决办法还没法令人满意!因此,策略模式适合后续不再演化的场景。能应对后续演化的,还得是类型完整的静多态思路。

编程的一大挑战就是为了应对变化,开发者知道的招式变化越多,应对的就越从容,使用看起来正确实际上却会逐渐失控的招式,只会味道越来越坏。变化就是“可扩展性”,谈到“可扩展性”,面向对象说这个我熟,“可扩展性”就是面向对象的目标之一啊!先别轻信,完美应对变化可不容易,即便资深的面向对象专家,都不敢说他写的每个东西都真能满足“单一职责”。。单一职责的足够“原子化”吗?面向对象思想有个老毛病,就是不够具体,让人抓不到,又让人以为抓到了,实际上是面向对象规定的东西,包括它的评论、解释大都泛泛而谈,没有一个度,很难意见统一。

(强调一下:因每个人理解层次不同,这一系列文章无意引战,也不想批评C ,只要C 想,就能实现Rust一样的效果,毕竟现代C 无所不能的。面向对象有些问题值得指出、批评,但个人还是认可面向对象的结构之美。这些文章,仅供大家友好交流Rust和面向对象技术,若有迁移一个面向对象项目到Rust重新实现的需求,那可能会有帮助,欢迎大家友好讨论!)

(原创不易,请在征得作者同意后再搬运,并注明出处!)

0 人点赞