六大设计原则(读书笔记)

作者: 国士无双A | 来源:发表于2017-01-10 22:50 被阅读370次

    最近在读秦小波写的设计模式之禅这本书,刚把6大设计原则给读完,写下这篇读书笔记做一个总结。

    单一职责原则(Single Responsibility Principle,简称SRP)

    定义:应该有且仅有一个原因引起类的变更。

    如何理解这个定义呢?

    定义中写到有且仅有一个原因,如果有两个原因就应该分成两个类,保证一个类的变更只有一个原因引起的。

    例子1:生活中我们经常会打电话,打电话这个过程可以分解为3个步骤:拨电话、通话中、挂电话。如果我们把其放在一个接口中,似乎很完美的。但是,我们仔细分析下就会发现,这个过程包含了两个职责:协议管理(拨电话、挂电话)、数据传送(通话中),协议变更会引起该接口发生变化,数据传送也会引起该接口发生变化。所以正确的做法应该是拨电话、挂电话封装成一个接口,数据传送封装成一个接口。通俗地来讲,就是让一个类尽可能干一件事情,不要同时干几件事情。

    例子2:一个类里面既干UI的事情,又干逻辑的事情,明显有两个职责。另外,当你写的一个类代码超过了1000行的时候,你就要考虑下该类是不是违反了单一职责的原则。因为超过1000行的类,已经显得很臃肿了。

    使用单一职责原则带来哪些好处呢?

    • 类的复杂性降低,一个类只有一个职责,类比较清晰。
    • 可读性、可维护性提高。
    • 变更引起的风险降低。

    单一职责原则,其实在告诉我们如何优雅地实现一个类。

    在实践中的一些建议

    对于职责的划分是很难的,不同的项目环境不一样,划分的标准也就不一样,只能根据经验而来。对于单一职责:接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。一个方法处理一件事情。


    里氏替换原则(Liskov Substitution Principle, 简称LSP)

    定义:所有引用基类的地方必须能透明地使用其子类的对象。就是说有父类出现的地方,都可以用子类来替换的。

    如何理解这个定义呢?可以从四个方面来理解这个定义:

    1. 子类必须完全实现父类的地方。
    2. 子类可以有自己的特性。
    3. 覆盖或实现父类的方法时输入参数可以被放大。
    4. 覆盖或实现父类的方法时输出结果可以被缩小。

    里氏替换原则可以引入数学中集合的概念来理解,如下图:

    1-1. 父类与子类集合.png

    从图中我们可以看到,子类集合包含父类集合且比父类的范围更大,这样在父类出现的地方完全可以用子类来代替,因为父类中有的,子类全部都包含有,这也就解释了1和2。3和4要从重载和覆写的角度去理解,3中的输入参数可以被放大,就符合了重载的定义(方法名相同,参数不同),这样在替换父类的时候,输入父类的参数范围,就只会调用父类的方法,而不是子类的方法了。4中输出结果可以被缩小,是实现覆写时的要求。

    注意点:

    • 在类中调用其他类时务必要使用父类或接口,如果不能使用父类或接口,则说明类的设计已经违背了LSP原则。

    • 如果子类不能完整地实现父类的方法,或者父类的某些方法在子类中已经发生了畸变,则建议断开父子继承关系,采用依赖、聚集、组合等关系代替。

    使用里氏替换原则带来的好处?
    增强了程序的健壮性,版本升级时也可以保持非常好的兼容,即使增加子类,原有的子类还可以继续运行。在实际项目中,每个子类对应不同的业务含义,使用父类作为参数,传递不同的子类完成不同的业务逻辑,非常完美。

    里氏替换原则,其实在告诉我们如何优雅地处理类继承之间的关系。

    在实践中的一些建议
    尽量避免子类的个性。子类有个性之后,把子类当做父类来使用,子类的个性就被抹杀了。把子类单独作为一个业务来使用,则会让代码间的耦合关系变得很复杂。


    依赖倒置原则(Dependence Inversion Principle, DIP)

    定义:在Java语言中可以这样理解。模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系通过接口或抽象类产生;接口和抽象类不依赖于实现类;实现类依赖接口与抽象。更加精简的定义就是面向接口编程。

    如何理解这个定义呢?
    如果不使用这个原则会出现什么情况呢?假设有两个模块,分别为模块1、模块2,模块1里面有A、B两个类,模块2里面有C、D两个类,A会对B、C、D都可能产生依赖,同样反过来也是如此,这样就会形成如下如所示的依赖关系:

    1-2 不使用依赖倒置.png

    从图中可以看到,依赖关系密密麻麻,惨不忍睹。这样来写项目的话,后期维护与扩展简直是一场灾难。那采用依赖倒置关系会是怎样呢?如下图:

    1-3 使用依赖倒置.png

    从图中可以看出依赖关系简单明了,也利于后期的扩展。

    注意点:设计是否具备稳定性,只要适当松松土,观察设计的蓝图是否还可以茁壮地成长就可以得出结论,稳定性较高的设计,在周围环境频繁变化的时候,依然可以做到我自岿然不动。

    依赖的三种写法:

    1. 构造函数传递依赖对象

       public interface IDriver {
           public void driver();
       }
      
       public class Driver implements IDriver {
           private ICar iCar;
           // 构造函数注入
           public Driver(ICar iCar) {
               this.iCar = iCar;
           }
      
           @Override
           public void driver() {
               this.iCar.run();
           }
       }
      
    2. Setter方法传递依赖对象

       public interface IDriver {
           public void setCar(ICar iCar);
           public void driver();
       }
      
       public class Driver implements IDriver {
           private ICar iCar;
           // setter依赖注入
           @Override
           public void setCar(ICar iCar) {
               this.iCar = iCar;
           }
      
           @Override
           public void driver() {
               this.iCar.run();
           }
       }
      
    3. 接口声明依赖对象

       public interface IDriver {
           public void driver(ICar iCar);
       }
      

    带来的好处呢?

    解决了模块之间、实现类与实现类之间纠缠不清的关系,使依赖关系简单明了,以及为团队协作并发开发提供了一种可能。

    为什么叫做倒置呢?因为按照我们正常的习惯,就是A依赖B,就把B放在A中进行依赖,这就是正置,而倒置就是A和B都依赖于抽象类,刚好与我们正常的习惯相反,所以叫做倒置。

    依赖倒置,其实告诉我们如何优雅处理模块之间、类之间的依赖关系。

    在实践中的一些建议

    • 每个类尽量都有接口或抽象类。
    • 变量的表面类型尽量是接口或者是抽象类。
    • 任何类都不应该从具体类派生。
    • 尽量不要覆写基类的方法。
    • 结合里氏替换则使用。

    接口隔离原则(Interface Segregations Principle, ISP)

    定义:客户端不应该依赖它不需要的接口;类间的依赖关系应该建立在最小的接口上。

    如何理解这个定义呢?

    通俗地来讲就是接口中的方法要尽量少,接口尽量细化。比如一个接口,可以同时提供给三个模块来使用,如下图:

    1-4 臃肿接口.png

    从头图中可以看出,B模块不需要提供给A、C模块的接口,C模块也不需要提供给A、B的接口,这样就违背了接口隔离原则,客户端不应该依赖它不需要的接口。正确的做法应该是,针对每个模块提供专用的接口,改进后的效果图如下:

    1-5 专用接口.png

    可以总结出一点:针对每个模块应该提供一个专用的接口。

    接口隔离原则,其实告诉我们如何优雅的设计一个接口,具体包含4层含义:

    • 接口要尽量小。但是在拆分接口的时候,首先必须满足单一职责原则。

    • 接口要高内聚。

      首先要明白什么是高内聚?高内聚就是提高接口、类、模块的处理能力,同时减少对外的交互(也就是少暴漏接口出去)。也就是说,尽量减少对外提供的接口,让提供的每个接口的处理能力增强。

    • 定制服务。可以理解为对每个模块提供专用的接口。也可以理解为只提供访问者需要的方法,不需要的方法都不提供。

    • 接口设计是有限度的。

    在实践中的一些建议

    • 一个接口只服务于一个子模块或业务逻辑。
    • 通过业务逻辑压缩接口中的public方法,接口时常去回顾。
    • 已经被污染的接口,尽量去修改,若变更的风险较大,可以采用适配器模式进行转换处理。
    • 根据具体的项目环境,具体分析。

    迪米特法则(Law of Demeter, LoD)

    定义:一个对象应该对其他对象有最少的了解。

    如何理解这个定义呢?

    通俗地来讲,就是一个类应该对自己需要耦合或调用的类知道的最少。这样来看,迪米特法则对类间的耦合提出了规范,降低了类间的耦合,从而实现高内聚、低耦合。

    先理解一个概念,朋友类:出现在成员变量、方法的输入输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。为什么要理解朋友类这个概念呢?因为类与类之间的关系是建立在类间的,而不是方法间的,因此一个方法尽量不引入一个类中不存在的对象,当然,JDK提供的类除外,这样可以有效地降低类间的耦合。总结起来说,一个类只和朋友交流,不与陌生类交流。

    注意:迪米特法则要求尽量不要对外公布太多的public方法和非静态的public变量,尽量内敛,多使用private、package-private、protected等访问权限。

    迪米特法则,其实告诉我们如何优雅降低类间的耦合。

    在实践中的一些建议

    迪米特法则的核心观念是类间解耦、弱耦合,只有弱耦合后,类的复用率才能提高。但是同时也会产生大量的中转或跳转类,导致系统的复杂性提高。在实际的应用中,如果一个类跳转两次才能访问到另一个类,就需要想办法进行重构了。


    开闭原则(Open Closed Principle, OCP)

    定义:一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。

    如何理解这个定义呢?

    有一个A模块,现在要往A模块上增加一个功能,现在有两种方式可以实现,一种是修改原有类达到目的,另一种是对原有类进行扩展达到目的。按照开闭原则来讲,应该使用扩展的方式来实现变化,这样做有什么好处呢?

    1. 对测试的影响降到最低。如果在现有类上进行修改,这样现有类的测试代码就要重写,对熟悉A模块的人来说,结果还算好些,可能很快就会写完了。如果让另外一个人去修改现有类,添加新的功能,他就需要先把你现有类的逻辑给搞清楚,然后才可以添加新的变化,并且新增加的变化还没有经过线上真正的检验,降低了软件的稳定性,会存在潜在的风险。如果在经过几个人的维护与修改,在交给最初的人,估计此时的他也都看不懂这块代码了。
    2. 提高代码的复用性与维护性。接手维护的人,想要添加新的功能不需要在原有代码的基础上修改了。

    开闭原则,其实为软件实体的未来事件而制定的对现行开发设计进行约束的一个原则。

    在实践中的一些建议

    1. 抽象行为。通过接口或抽象类可以约束一组可能变化的行为,并且实现对扩展开放。
    2. 元数据控制模块行为。什么是元数据?用来描述环境和数据的数据,也就是配置参数。比如Spring框架的控制反转。
    3. 制定项目规则
    4. 封装变化。将相同的变化封装到一个接口中。将不同的变化封装到不同的接口中。这也符合单一职责原则,有且仅有一个原因因其变化。

    总结:设计6大原则,单一职责原则告诉我们类的设计要单一,应该只有一个原因引起类的变化;里氏替换原则告诉我们如何处理类间的继承关系,协调父子的关系;依赖倒置原则告诉我们如何处理类间的依赖关系,模块间的依赖关系;接口隔离原则告诉我们如何设计一个好的接口,使其不臃肿而灵活;迪米特原则告诉我们怎样是类间的依赖关系变小,实现类间的低耦合,侧重于解决耦合的问题,依赖倒置原则侧重解决依赖问题,两者可以配合使用;要想实现开闭原则,前面5个原则做好了,就比较容易实现软件实体的开闭原则,否则就是空纸一谈。

    国士梅花

    欢迎大家关注国士梅花,技术路上与你陪伴。

    guoshimeihua.jpg

    相关文章

      网友评论

        本文标题:六大设计原则(读书笔记)

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