iOS 开发每天分享优质文章iOS开发之常用技术点ios

设计模式之六大设计原则

2019-01-30  本文已影响63人  mkvege

面向接口编程是六大原则的根本

设计模式的六大原则都要在针对接口编程的基础上来实现,但是在应用开发中,为了尽快完成功能,很容易的就会走到面向接口的对立面---面向实现编程。

例如写一个视频播放器,如果是面向实现编程,另一个地方需要同样的进度条操作,但进度条逻辑写在了播放器框架里面,而视频数据控制的逻辑又写在了进度条里面,这将很难抽离进行复用。

所以请先停止复制代码,先思考将要实现的功能可以分为几部分,然后再去根据接口来实现。

对比

面向接口编程的好处不在赘述,为了更好的实现面向接口编程,就需要请出设计模式之六大设计原则。

一. 单一职责原则

一个类或者模块应该有且只有一个改变的原因。

单一职责适用于 类,接口,方法。
类和接口的单一职责

举个手机的例子:

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玩具枪”单独实现吧,脱离这个继承关系。

继承的注意⚠️

反思我们自己写一些基类的时候,有没有要求某些特殊的子类不可以调用父类的一些方法,如果这样请将这个子类移出继承的关系。

或有没有将具体类当作基类,很多实现的细节都被子类继承过去,这样虽然能很快的实现功能,但对于子类的限制增大,使子类知道父类的内容过多。

三. 依赖倒置原则

● 高层模块不应该依赖低层模块,两者都应该依赖其抽象。
● 抽象不应该依赖细节。
● 细节应该依赖抽象。

这个原则为更好的定义抽象间的依赖
  1. 高层模块和低层模块容易理解,每一个逻辑的实现都是由原子逻辑组成的,不可分割的原子逻辑就是低层模块,原子逻辑的再组装就是高层模块。

  2. 抽象接口之间有时需要一些参数,但这些参数不应该是具体类,而应该是抽象类。举个列子,“司机开车”,“司机”封装一个抽象类,“车”也应该封装一个抽象类,然而一般情况下,需求开始时可能仅仅有一种“奔驰车”,我们在封装“司机”抽象类的时候就会有个“开奔驰”的方法,我们至少应该做到的是,当出现第二种车“宝马”的时候,可以重构下代码,将 “车”这个抽象类提出来,避免更混乱的依赖,当然如果可以最开始提出“车”那是最完美的。

  3. 如果抽象间依赖处理的很到位,那么具体类的实现就会变的很轻松,再多的依赖,只要保证是在抽象之间,也不会有问题。

为什么叫依赖倒置:依赖正置指的是细节之间的依赖,所有的依赖都基于实现,比如之前所说的 “司机” 依赖 “车”,如果使用依赖正置,就是 “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小说”,不仅提高了可维护性,也增强了可副用性。

开闭原则是一个终极目标,任何人包括大师级人物都无法百分之百做到,但朝这个方向努力,可以非常显著地改善一个系统的架构,真正做到“拥抱变化”。

总结

一切的原则从面向接口编程开始。

参考文章 设计模式之禅

上一篇下一篇

猜你喜欢

热点阅读