< 返回我的博客

北海 发表于 2023-12-24 19:43

Tags:面向对象、原型法、装饰器模式

原型法

此原型法非原型模式,而是类似JavaScript中的原型扩展,在JS中,能够很轻松地为String类型“原地”扩展方法,如:

String.prototype.isDigit = function() {
  return this.length && !(/\D/.test(this));
};

这个能力其实很好用,但是C++无法这样,一直觉得std::string的功能不足,想为其添加更丰富的如trim/split之类的语义,只能采用继承或者组合代理方式:

  • 继承:用一个新类继承std::string,并为新类实现trim/split
  • 组合代理:用一个新类组合std::string,并为新类代理所有std::string的方法,包括各类构造方法和析构方法,再为新类实现trim/split

然后,使用std::string的地方替换成新类。这时候那种都比较复杂,组合的方式更复杂一些,所以也别无脑相信面向对象里“组合一定优于继承”。幸运的是,Rust能轻易完成原型法,比如有个bytes库提供了可廉价共享的内存缓冲区,避免不必要的内存搬运拷贝,bytes::BytesMut实现了可变缓冲区bytes::BufMut,有一系列为其写入u8、i8、u16、i16、slice等基础类型的接口,对于基础的通用的在bytes库中已经足够了,现在有个网络模块,想往bytes::BytesMut中写入std::net::SocketAddr结构,Rust可轻易为BytesMut扩展实现put_socket_addr

pub trait WriteSocketAddr {
    fn put_socket_addr(&mut self, sock_addr: &std::net::SocketAddr);
}

impl WriteSocketAddr for bytes::BytesMut {
    fn put_socket_addr(&mut self, sock_addr: &std::net::SocketAddr) {
        match sock_addr {
            SocketAddr::V4(v4) => {
                self.put_u8(4);        // 代表v4地址族
                self.put_slice(v4.ip().octets().as_ref());
                self.put_u16(v4.port()); 
            }
            SocketAddr::V6(v6) => {
                self.put_u8(6);        // 代表v6地址族
                self.put_slice(v6.ip().octets().as_ref());
                self.put_u16(v6.port()); 
            }
        }
    }
}

然后就可以使用BytesMut::put_socket_addr了,只需use WriteSocketAddr引入这个trait就可以,是不是很轻松!为何会这么容易?先看JS的原型法,其背后是原型链在支撑,调用String的方法,不仅在String对象里面查找,还会层层向String的父级、祖父级prototype查找,一旦找到就可以调用,而每个prototype本质上都是个Object,可以获取并编辑它们,ES6的继承本质上也是原型链。所以可以拿到String类的prototype,在它上面为其增加isDigit,就能让所有的String对象都能享受isDigit函数的便利,可谓十分方便。但是C++就不行了,也想拿到std::string的函数表,然后一通编辑为其添加trim/split行为,奈何C++不允许这危险的操作啊,只能派生子类,即便子类仅仅只包含一个std::string。那Rust为何可以,关键就是trait函数表与传统面向对象的虚函数表解藕了,后果就是,类型没有绑死函数表,可以为类型增加新trait函数表,然后就有了上面的Rusty原型法。类似的还可以为Rust的String扩展is_digit/is_email/is_mobile,一样地简单。一般有ext模块,就很可能发现原型法的身影,比如tokio::io::AsyncReadExt

原型法是最能体现trait函数表与传统面向对象虚函数表分离优势的设计模式!注意,Rust的原型法并没有产生任何新类型,只是增加了一个新的trait函数表,所以一开始称之为“原地”扩展,是比JS更干净的原型法,个人非常喜欢用这个模式,能用就用!更进阶的,Rust还能为所有实现了bytes::BufMut的类型扩展实现WriteSocketAddr特型,而不仅仅只为bytes::BytesMut实现:

/// 可以这样读:为所有实现了ButMut特型的类型实现WriteSocketAddr
/// bytes::BytesMut也不过是T的一种,代码复用性更佳
impl<T: bytes::ButMut> WriteSocketAddr for T {
    fn put_socket_addr(&mut self, sock_addr: &std::net::SocketAddr) {
        // 同样的代码
    }
}

原型法跟模板方法还有些联系,也算模板方法衍生出来的设计模式,因为子类如果不依赖父类,并且子类还不需要有任何字段,不需要有自己独特的结构就能实现算法策略时,那子类也不用依赖注入到父类了,直接在父类的基础上“原地“扩展,更加轻量。总结一下模板方法的衍生变化:

模板方法:

  • 子类拥有自己的结构,并依赖父类的结构和行为才能完成,是模板方法
  • 子类拥有自己的结构,但不依赖父类结构和行为也能完成,可不用继承转而采用组合依赖注入,最好多达2个以上组合,达成策略组合模式
  • 子类不需有自己的结构(或者一个空结构),依赖父类的结构和行为就能完成,只是算法在父类模块中不通用而没实现,可不用继承也不用组合,“原地”扩展,原型法即可
  • 子类不需有自己的结构,也不依赖父类,那这么独立也跟父类没任何关系了,理应属于其它模块

回到面向对象,凡是Rust能轻松做到的,面向对象却无法轻松做到的,就是面向对象该被批评的点。。面向对象说我服,早知道也不把虚函数表与对象内存结构绑死了。所谓长江后浪推前浪,新语言把老语言拍死在沙滩上,即便C++20如此强大,不改变虚函数表的基础设计,在原型法上也永远追赶不上Rust语言的简洁。

装饰器模式

上节说到,策略模式,要是为复合类型也实现trait,就类似装饰器模式,因为装饰器无论是内部委托成员,还是外部装饰器自己,都得实现同一个名为Decorate的trait,就是为了让它们可以相互嵌套组合:

trait Decorate {
    fn decorate(&mut self, params...);
}

/// 一个静多态的装饰器
struct SomeDecorator<D: Decorate> {
    delegate: D,    // 必要的委托
    ...
}

/// 还得为Decorator自己实现Decorate特型
impl<D: Decorate> Decorate for SomeDecorator<D> {
    fn decorate(&mut self, params...) {
        // 1. SomeDecorator itself do sth about params 
        self.do_sth_about_params(params...); // 这是真正要装饰的实现
        // 2. then turn self.delegate
        self.delegate.decorate(params...);    // 这一句都相同,1、2步的顺序可互换
    }
}

/// 另一个装饰器
struct AnotherDecorator<T: Decorate> {
    delegate: T,
    ...
}

impl<D: Decorate> Decorate for AnotherDecorator<D> {
    fn decorate(&mut self, params...) {
        // 1. AnotherDecorator itself do sth about params 
        self.do_sth_about_params(params...);
        // 2. then turn self.delegate
        self.delegate.decorate(params...);    // 这一句都相同
    }
}

/// 必要的终结型空装饰器
struct NullDecorator;

impl Decorator for NullDecorator { /*do nothing*/ }

/// 使用上
let d = SomeDecorator::new(AnotherDecorator::new(NullDecorator));
d.decorate();

SomeDecorator/AnoterDecorator是真正的装饰器,会有很多个,功能各异,每个Decorator所包含的相应的结构可能也不同。装饰器在使用上,就像链表一样,一个处理完之后,紧接着下一个节点再处理,它把链表结构包含进了装饰器的结构里面,并用接口/trait来统一类型。上述实现有重复代码,就是调用委托的装饰方法,还能继续改进:

/// 装饰的其实是一个处理过程
trait Handle {
    fn handle(&mut self, params...);
}

trait Decorate {
    fn decorate(&mut self, params...);
}

/// 装饰器的终结
struct NullDecorator;

impl Decorate for NullDecorator {
    fn decorate(&mut self, params...) {
        // do nothing
    }
}

/// 通用型装饰器,像是链表节点串联前后2个处理器节点
struct Decorator<D: Decorate, H: Handler> {
    delegate: D,
    handler: H,   // 这又是个干净的模板方法,将变化交给子类
}

/// 通用装饰器本身也得实现Decorate特质,可以作为另一个装饰器的D
impl<D: Decorate, H: Handler> Decorate for Decorator<D, H> {
    fn decorate(&mut self, params...) {
        // 这两步可互换
        self.handler.handle(params);
        self.delegate.decorate(params);
    }
}

/// 下面的处理器只关注处理器自己的实现就好了
struct SomeHandler { ... };

impl Handler for SomeHandler { ... }

struct AnotherHandler { ... };

impl Handler for AnotherHandler { ... }

/// 使用上
let d = Decorator {
    delegate: Decorator {
        delegate: NullDecorator,
        handler: AnotherHandler,
    },
    handler: SomeHandler,
};
d.decorate(params...);

可以看出,装饰器很像链表,emm...大家都知道链表在Rust中较复杂,那链表有多复杂,装饰器就有多复杂。上面的静多态实现也是不行的,不同的装饰器组合,就会产生不同的类型,类型可能随着Handler类型数目增加呈其全排列阶乘级类型爆炸,忍不了,必须得换用指针。装饰器模式,Rust实现起来不如传统面向对象,面向对象天然动多态,且Decorator继承可以让D、H两部分合为一体,让H也成装饰类的一个虚函数,都在this指针访问范围内,简单一些。而Rust将装饰器拆解成了链表型,将装饰器的底层结构还原了出来,确实装饰器可以用链表串联起各个处理器一个接一个地调用,效果一样的。只是面向对象技巧隐藏了链表的细节。

不过Rust有个很牛逼的装饰器,就是迭代器的map、step_by、zip、take、skip这些函子,它们可以随意串联组合调用,本质就是装饰器,只不过仅限于用在迭代器场景。如果装饰器能这样实现,能惰性求值,也能够编译器內联优化,就太强了。不过,各个装饰器功能不同,恐怕不能像迭代器函子那样都有清晰的语义,因此没有统一的装饰器库。不过装饰器实现时,肯定可以借鉴迭代器的函子思路。这样一来的话,Rust的装饰器又丝毫不弱于传统面向对象的了。而且,高,实在是高,妙,实在是妙!

/// 以下仅作摘选,让大家一窥迭代器函子的装饰器怎么玩的
pub trait Iterator {
    type Item;

    // Required method
    fn next(&mut self) -> Option<Self::Item>;

    // Provided methods
    // 像下面这样的函数还有76个,每个函数都映射到一个具体的装饰器,它们都返回一个装饰函子impl Iterator<Item = Self::Item>
    // 装饰器函数基本都定义完了,未来无法扩展?还记得原型法吗,为所有实现了Iterator的类型实现IteratorExt
    // 仅挑选一个step_by作为案例
    #[inline]
    #[stable(feature = "iterator_step_by", since = "1.28.0")]
    #[rustc_do_not_const_check]
    fn step_by(self, step: usize) -> StepBy<Self>
    where
        Self: Sized,
    {
        StepBy::new(self, step)
    }
}

/// StepBy装饰器,如第一种实现那样的写法
pub struct StepBy<I> {
    iter: I,    // 装饰器的delegate
    step: usize,
    first_take: bool,
}

/// 再为StepBy<I>实现Iterator
impl<I> Iterator for StepBy<I>
where
    I: Iterator,
{
    type Item = I::Item;

    #[inline]
    fn next(&mut self) -> Option<Self::Item> {
        self.spec_next()
    }
}

// 使用上,有别于传统装饰器模式从构建上去串联,这是利用返回值链式串联,顿时清晰不少
vec![1, 2, 3].iter().skip(1).map(|v| v * 2);

小结

至此,模板方法的变化告一断落。之前,有人说Rust不支持面向对象,导致Rust不好推广,实际上并不是,哪个OO设计模式Rust实现不了,还更胜一筹。因此,并非Rust不支持面向对象!有些设计模式,Rust天生也有,如:

  • 单例模式:其实单例模式如果不是为了懒加载,跟使用全局变量没啥差别;如果为了懒加载,那lazy_static或者once_cell就够用。(补充:标准库已经标准化成OnceLock了)
  • 代理模式:NewType模式作代理挺好;或者原型法“原地”扩展代理行为
  • 迭代器模式:Rust的迭代器是我见过最NB的迭代器实现了
  • 状态机模式:Rust语言官方文档中的NewType+enum状态机模式,这种静多态的状态机非常严格,使用上都不会出错,所有状态组合还可以用enum统一起来,比面向对象的状态机模式要好

还有一些设计模式,跟其它模式很像,稍加变化:

  • 适配器模式:同代理模式差别不大,很可能得有自己的扩展结构,然后得有额外“兼容处理”逻辑来体现“适配”
  • 桥接模式:就是在应用策略模式
  • 过滤器模式:就是在应用装饰器模式

还有一些设计模式,读者可自行用Rust轻松实现,如观察者模式之流。后续不会为这些设计模式单独成文了,除非它有点意思,访问者模式就还可以,只不过实际应用不咋多。有想用Rust实现哪个设计模式有疑问的,可留言交流。

罗列所有设计模式没啥意思,我也无力吐槽这么多设计模式,至今很多人仍区分不清某些设计模式的区别,因为设计模式在描述它们的时候,云里雾里的需求描述,关注点、应用场景不一样云云,什么模式都得来一句让“抽象部分”与“实现部分”分离,跟都整过容一样相似的描述,让人傻傻分不清。至今我再看各种设计模式,想去了解其间区别,都觉得无聊了,浪费时间!被大众广泛记住的设计模式就那么几个,因为基础的设计就那么几个,当你在使用接口、指针/引用、组合的时候,其实就在不知不觉中使用设计模式了。

上段是在批评设计模式没错,并不是说设计模式一无是处,能总结出模式作为编程界通用设计语言意义非凡。懂它肯定比不懂的强,要是都能区分清各类设计模式了,肯定是高手中的高手了,看懂这一系列文章不难。设计模式的套用,归根结底是为了代码复用,良好的可读性。大家看到相似模式的代码,一提那种设计模式就能明白。遗憾的是,即便是同一个设计模式,因为乱七八糟的类型、胡乱命名、粗糙的掺杂不少杂质的实现,为不停变化的需求弄的面目全非者,让人读起来,实在很难对的上有某种设计,这并非设计模式的锅,而是编程素质不专业、太自由发挥、总见多识少地自创概念/二流招式的毛病招致的。

在这方面,Rust的解决方案 极具 吸引力。后续对比着面向对象,讲讲Rusty那味,味道不错但更难掌握,属于基础易懂,逻辑一多就复杂(废话)!

评论区

写评论
作者 北海 2023-12-25 23:38

老铁真是太热情了,哈哈,感谢支持~

--
👇
Borber: 拜读完毕,期待更新

Borber 2023-12-25 13:10

拜读完毕,期待更新

1 共 2 条评论, 1 页