设计模式之六大设计原则

作者: mkvege | 来源:发表于2019-01-30 19:30 被阅读63次

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

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

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

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

    对比

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

    一. 单一职责原则

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

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

    举个手机的例子:

    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小说”,不仅提高了可维护性,也增强了可副用性。

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

    总结

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

    参考文章 设计模式之禅

    相关文章

      网友评论

        本文标题:设计模式之六大设计原则

        本文链接:https://www.haomeiwen.com/subject/yoqjjqtx.html