设计模式之六大设计原则
面向接口编程是六大原则的根本
设计模式的六大原则都要在针对接口编程的基础上来实现,但是在应用开发中,为了尽快完成功能,很容易的就会走到面向接口的对立面---面向实现编程。
例如写一个视频播放器,如果是面向实现编程,另一个地方需要同样的进度条操作,但进度条逻辑写在了播放器框架里面,而视频数据控制的逻辑又写在了进度条里面,这将很难抽离进行复用。
所以请先停止复制代码,先思考将要实现的功能可以分为几部分,然后再去根据接口来实现。
对比面向接口编程的好处不在赘述,为了更好的实现面向接口编程,就需要请出设计模式之六大设计原则。
一. 单一职责原则
一个类或者模块应该有且只有一个改变的原因。
单一职责适用于 类,接口,方法。
类和接口的单一职责
举个手机的例子:
class:手机 {
func 呼叫
func 通话
func 挂断
}
“手机class” 中的三个方法 包含了两类功能,一类是“呼叫”与“挂断”的通讯协议管理,另一类是负责通话的“数据传输”。这就造成了有两个原因使类发生变化。
设想一下,如果后来需求出现 “微信class”,“bb机class”,甚至“无人机class”,会使相同的功能点无法更好的提炼。
可能抽离了一个基类,但无论基类中如何定义,却依然无法解决两个功能的纠缠,可能导致某些子类要复写一些方法,甚至要置空一些父类方法。
更可能由于仓促,直接将 “手机class”,改为 “DataConnectManager”这种仿佛要涵盖一切的大类,传一个type 就负责一种类型的通讯连接与数据传输,这种随着通讯类型的不断增加,使代码逐渐变得腐烂。
那如果建立两个类呢🤔️
class: 连接管理 {
func 连接
func 断开
}
class: 数据传输管理{
func 传输
}
这样不仅会导致这两个类耦合严重,而且会使扩展性也是同样的差。
更合理的方式:定义两个接口
protocol 通讯连接{
func 连接
func 断开
}
protocol 数据传输{
func 传输
}
“手机class” 通过实现这两个接口,来分别实现通讯连接和数据传输功能。
未来出现的新通讯工具,也要根据自身特点分别实现这两个接口,或者实现其中之一,每个类各自实现自己的接口。将不同的实现功能代码放在不同的类中,这样无论出现多少新类也无所畏惧。
类中实现了两个接口,算不算被两个因素改变,从而变得不具有单一职责呢,答案是否定的,因为面向接口编程,对外公布的是接口,而不是实现类。
类的弹性
类的单一职责很多时候在于程序员的经验以及对功能的理解程度,很难做到真正的单一,所以定义清晰且单一的接口便更加重要。
方法的单一职责
如果能定义出很合适的类,很清晰的接口,接下来需要注意的就是,在实现代码时,将每个函数定义成清晰且具备单一职责的。
反思一下自己有没有将一大段代码都塞到某个函数里,例如tableView的点击事件处理了一堆逻辑,这都会成为代码变质的开端。
二. 里氏替换原则
所有引用基类的地方必须能透明地使用其子类的对象。
这个原则是为了更好的继承
通俗点讲,里氏替换指在任何外部使用父类对象的地方,将其替换为子类对象,不会报错也不会报异常。
例如iOS中很常见的UI继承,我们写一个customView继承UIView,可以在ViewController中控制customView的frame,hidden,等方法,customView可以使用任何UIView的方法,customView当然也有自己的方法,但这些方法使用UIView类是无法调用的,这是很好的里氏替换原则的例子,也告诉我们里氏替换反过来是不成立的。
再举个例子,基类为“class枪”,有两个子类,手枪和步枪。
class 枪 {
func 加载枪身
func 上子弹
func 开火
}
class 手枪:枪 {
}
class 步枪:枪 {
}
手枪和步枪的相同点都在class枪内,这样做很正常。
但这时需求出现了“class玩具枪”,其中只可以有加载枪身的方法,不支持上子弹,也不能开火.
class 玩具枪:枪{
func 加载枪身 ☑️
func 上子弹 ❌
func 开火。❌
}
如果依然继承“class枪”,就会导致 “func上子弹”,“func开火”无法在“class 玩具枪”内使用,外部在不知道具体子类是什么的时候调用时“class枪”的“func开火”,每次要判断是不是玩具枪类,如果不是再去开火,使用会很麻烦,也不符合里氏替换原则,所以将“class玩具枪”单独实现吧,脱离这个继承关系。
继承的注意⚠️
反思我们自己写一些基类的时候,有没有要求某些特殊的子类不可以调用父类的一些方法,如果这样请将这个子类移出继承的关系。
或有没有将具体类当作基类,很多实现的细节都被子类继承过去,这样虽然能很快的实现功能,但对于子类的限制增大,使子类知道父类的内容过多。
三. 依赖倒置原则
● 高层模块不应该依赖低层模块,两者都应该依赖其抽象。
● 抽象不应该依赖细节。
● 细节应该依赖抽象。
这个原则为更好的定义抽象间的依赖
-
高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。
-
抽象接口之间有时需要一些参数,但这些参数不应该是具体类,而应该是抽象类。举个列子,“司机开车”,“司机”封装一个抽象类,“车”也应该封装一个抽象类,然而一般情况下,需求开始时可能仅仅有一种“奔驰车”,我们在封装“司机”抽象类的时候就会有个“开奔驰”的方法,我们至少应该做到的是,当出现第二种车“宝马”的时候,可以重构下代码,将 “车”这个抽象类提出来,避免更混乱的依赖,当然如果可以最开始提出“车”那是最完美的。
-
如果抽象间依赖处理的很到位,那么具体类的实现就会变的很轻松,再多的依赖,只要保证是在抽象之间,也不会有问题。
为什么叫依赖倒置:依赖正置指的是细节之间的依赖,所有的依赖都基于实现,比如之前所说的 “司机” 依赖 “车”,如果使用依赖正置,就是 “A证司机” 依赖 “大卡车”,“B证司机” 依赖 “面包车”,这些细节的东西如果写到抽象的接口当中,就会导致方法剧增,而且复用性极差。
接口隔离原则
客户端不应该依赖它不需要的接口
一些情况下,实现一个接口,可能有几个方法不需要实现,可以考虑将这些方法分成几个接口,保持每个接口的纯洁性。
此时可能产生疑问🤔️,隔离原则和单一原则的区别是什么呢?
单一原则保证的是接口只负责一件事,而隔离原则让每个接口和接口的实现类连接的更紧密。
来看这个美女的例子:
protocol 美女鉴别 {
func 五官是否好看
func 身材是否好看
func 气质是否好看
func 妆容是否好看
func 衣服是否好看
}
这个protocol 符合单一职责,只负责美女鉴别。
但有些类只需要判断化妆与打扮后是否为美女,而有些类只需要判断气质是否上佳,还有的类只需要判断五官和身材。所以这几个类并不需要 “protocol 美女鉴别”的所有方法,可以再次细分 “protocol美女鉴别”分为几个protocol,可以更灵活的解决问题。
但警惕过犹不及的风险,无论是一个接口,还是分为几个接口,都在于我们自己理解的原子逻辑,所以很可能将接口分的过细,如果电话的接口里包含中继服务器,3g协议等,那就属于粒度过细。
迪米特法则
我的知识你知道得越少越好
这个法则让我们更好的处理耦合问题
举个买咖啡的例子:老板想和咖啡,让助理去买。想象一下如果老板说:“助理,你出门打个出租车,大约花10元,去星巴克买个咖啡”,这个老板是不是管的太细了。
正确的姿势,老板告诉助理:“我想喝咖啡”,怎么去买咖啡应该由助理实现。
反应到编程中,老板类要持有咖啡类,实现管理咖啡的方法,又要持有助理类,实现管理助理的方法。这是没必要的,咖啡就完全交给助理吧。
咖啡
就像我们常用的MVC结构中,View 从来不需要知道Model的请求过程,也不需要知道Model如何改变,一切Model的变化都应该在Controller中进行,反思自己有没有在某个View类内部button的点击事件中请求数据,或是View中持有了Model,这都违反了MVC。
迪米特法则要求类间解耦,但解耦是有限度的,除非是计算机的最小单元——二进制的0和1。那才是完全解耦,在实际的项目中,需要适度地考虑这个原则,别为了套用原则而做项目。原则只是供参考,如果违背了这个原则,项目也未必会失败,这就需要在采用原则时反复度量,不遵循是不对的,严格执行就是“过犹不及”。
开闭原则
一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化
这个原则告诉我们要如何应对变化
“开闭”指的是面对改动,对谁开放,对谁关闭。上面已经说了答案,要用扩展来实现改动,而不是直接对去修改代码。
举个书店的例子:
protocol 书籍 {
func 书名
func 价格
func 作者
}
class 小说: 书籍 {
func init(名称,价格,作者){
...
}
}
class 书店 {
func main() {
let books = [小说(射雕,30元,金庸),小说(西游,50元,吴承恩),小说(牛棚,40元,季羡林)]
for(小说 in books){
print(小说.名称, 小说.价格, 小说.作者)
}
}
}
“protocol 书籍”定义了书的接口,“class小说”实现它,“class书店”中持有了几个“class小说”,并打印小说的信息。
此时需求发生变化,要求大于40元的书打9折,其他书籍打8折。
那应该如何做这项改动呢。
第一种做法:在“class书店”中计算,这样会导致任何类每次使用“class小说”,都需要计算价格,显然是不恰当的。
第二种做法:在“class小说”中重写价格方法,这种是我们最可能使用的方式,快速而不影响外部调用。但是,这种做法也存在一定坏处,类的外部没有告知任何打折信息,直接在类内部就打折了,调用者可能产生疑问,而且打折逻辑和价格逻辑也糅合在一起。
最好的做法:新建一个“class打折小说”继承于“class小说”。内部重写价格方法,在“class书店”的main方法中,使用“class打折小说”来替代“class小说”,
func main() {
let books = [打折小说(射雕,30元,金庸),打折小说(西游,50元,吴承恩),打折小说(牛棚,40元,季羡林)]
for(小说 in books){
print(小说.名称, 小说.价格, 小说.作者)
}
}
虽然有在外部(class书店)修改代码,但是没有改动任何的代码逻辑,只是改变了类型。
这样做不但可以清晰的知道代码整体的逻辑,也没有修改任何原来的逻辑,不需要从头再测试一遍“class小说”,不仅提高了可维护性,也增强了可副用性。
开闭原则是一个终极目标,任何人包括大师级人物都无法百分之百做到,但朝这个方向努力,可以非常显著地改善一个系统的架构,真正做到“拥抱变化”。
总结
一切的原则从面向接口编程开始。
参考文章 设计模式之禅