此原型法非原型模式,而是类似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 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 {
delegate: D, // 必要的委托
...
}
/// 还得为Decorator自己实现Decorate特型
impl Decorate for SomeDecorator {
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 {
delegate: T,
...
}
impl Decorate for AnotherDecorator {
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 {
delegate: D,
handler: H, // 这又是个干净的模板方法,将变化交给子类
}
/// 通用装饰器本身也得实现Decorate特质,可以作为另一个装饰器的D
impl Decorate for Decorator {
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;
// Provided methods
// 像下面这样的函数还有76个,每个函数都映射到一个具体的装饰器,它们都返回一个装饰函子impl Iterator-
// 装饰器函数基本都定义完了,未来无法扩展?还记得原型法吗,为所有实现了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
where
Self: Sized,
{
StepBy::new(self, step)
}
}
/// StepBy装饰器,如第一种实现那样的写法
pub struct StepBy {
iter: I, // 装饰器的delegate
step: usize,
first_take: bool,
}
/// 再为StepBy实现Iterator
impl Iterator for StepBy
where
I: Iterator,
{
type Item = I::Item;
#[inline]
fn next(&mut self) -> Option {
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那味,味道不错但更难掌握,属于基础易懂,逻辑一多就复杂(废话)!