当前位置: 代码迷 >> 综合 >> 【软件设计】六大设计原则讲解
  详细解决方案

【软件设计】六大设计原则讲解

热度:62   发布时间:2024-01-09 13:35:07.0

1. 单一职责原则 -Single Responsibility Principle

SRP,Single Responsibility Principle:

There should never be more than one reason for a class to change.

应该有且仅有一个原因引起类的变更。(如果类需要变更,那么只可能仅由某一个原因引起)

问题由来:

        类T负责两个不同的职责:职责P1,职责P2。当由于职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。


解决方案:

        遵循单一职责原则。分别建立两个类T1、T2,使T1完成职责P1功能,T2完成职责P2功能。这样,当修改类T1时,不会使职责P2发生故障风险;同理,当修改T2时,也不会使职责P1发生故障风险。


示例:

       如果一个接口包含了两个职责,并且这两个职责的变化不互相影响,那么就可以考虑拆分成两个接口。

       方法的职责应清晰、单一。一个method尽可能制作一件事情。changeUserInfo()可拆分为changeUserName()、changeUserAddr()....



        说到单一职责原则,很多人都会不屑一顾。因为它太简单了。稍有经验的程序员即使从来没有读过设计模式、从来没有听说过单一职责原则,在设计软件时也会自觉的遵守这一重要原则,因为这是常识。在软件编程中,谁也不希望因为修改了一个功能导致其他的功能发生故障。而避免出现这一问题的方法便是遵循单一职责原则。虽然单一职责原则如此简单,并且被认为是常识,但是即便是经验丰富的程序员写出的程序,也会有违背这一原则的代码存在。

        为什么会出现这种现象呢?因为有职责扩散。所谓职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2。此时,按照SRP 应该再新建一个类负责职责P2,但是这样会修改花销很大!除了改接口 还需要改客户端代码!所以一般就直接在原有类方法中增加判断 支持职责P2;或者在原有类中新增一个方法来处理职责P2(做到了方法级别的SRP),


例如原有一个接口,模拟动物呼吸的场景:

  1. class Animal{    
  2.     public void breathe(String animal){    
  3.         System.out.println(animal+"呼吸空气");    
  4.     }    
  5. }    
  6. public class Client{    
  7.     public static void main(String[] args){    
  8.         Animal animal = new Animal();    
  9.         animal.breathe("牛");    
  10.         animal.breathe("羊");    
  11.         animal.breathe("猪");    
  12.     }    
  13. }    

程序上线后,发现问题了,并不是所有的动物都呼吸空气的,比如鱼就是呼吸水的。


修改一:修改时如果遵循单一职责原则,需要将Animal类细分为陆生动物类Terrestrial,水生动物Aquatic,代码如下:

  1. class Terrestrial{    
  2.     public void breathe(String animal){    
  3.         System.out.println(animal+"呼吸空气");    
  4.     }    
  5. }    
  6. class Aquatic{    
  7.     public void breathe(String animal){    
  8.         System.out.println(animal+"呼吸水");    
  9.     }    
  10. }    
  11.     
  12. public class Client{    
  13.     public static void main(String[] args){    
  14.         <strong>Terrestrial </strong>terrestrial = new Terrestrial();    
  15.         terrestrial.breathe("牛");    
  16.         terrestrial.breathe("羊");    
  17.         terrestrial.breathe("猪");    
  18.             
  19.         <strong>Aquatic </strong>aquatic = new Aquatic();    
  20.         aquatic.breathe("鱼");    
  21.     }    
  22. }    

BUT,这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。


修改二:直接修改类Animal;虽然违背了单一职责原则,但花销却小的多

  1. class Animal{    
  2.     public void breathe(String animal){    
  3.         if("鱼".equals(animal)){    
  4.             System.out.println(animal+"呼吸水");    
  5.         }else{    
  6.             System.out.println(animal+"呼吸空气");    
  7.         }    
  8.     }    
  9. }    
  10.     
  11. public class Client{    
  12.     public static void main(String[] args){    
  13.         Animal animal = new Animal();    
  14.         animal.breathe("牛");    
  15.         animal.breathe("羊");    
  16.         animal.breathe("猪");    
  17.         animal.breathe("鱼");    
  18.     }    
  19. }    

这种修改方式要简单的多。但是却存在着隐患:有一天需要将鱼分为呼吸淡水的鱼和呼吸海水的鱼,则又需要修改Animal类的breathe方法,而对原有代码的修改会对调用“猪”“牛”“羊”等相关功能带来风险,也许某一天你会发现程序运行的结果变为“牛呼吸水”了。


这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却是最大的。


修改三:

  1. class Animal{    
  2.     public void breathe(String animal){    
  3.         System.out.println(animal+"呼吸空气");    
  4.     }    
  5.     
  6.     public void breathe2(String animal){    
  7.         System.out.println(animal+"呼吸水");    
  8.     }    
  9. }    
  10.     
  11. public class Client{    
  12.     public static void main(String[] args){    
  13.         Animal animal = new Animal();    
  14.         animal.breathe("牛");    
  15.         animal.breathe("羊");    
  16.         animal.breathe("猪");    
  17.         animal.breathe2("鱼");    
  18.     }    
  19. }    

这种修改方式没有改动原来的方法,而是在类中新加了一个方法;虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。


好处:

       一个接口的修改只对相应的实现类有影响,对其他接口无影响;有利于系统的可扩展性、可维护性。


问题:

       “职责”的粒度不好确定!

        过分细分的职责也会人为地增加系统复杂性。


建议:

       对于单一职责原则,建议 接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化。

       只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;

2. 里氏替换原则 -Liskov Substitution Principle

LSP,Liskov Substitution Principle:

1) If for each object s of type S, there is an objectt of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when s is substituted fort when S is a subtype of T.

2) Functions that use pointers or references to base classes must be able to user objects of derived classes without knowing it.

所有引用基类的地方,都能透明地替换成其子类对象。只要父类能出现的地方,子类就可以出现。


引入里氏替换原则能充分发挥继承的优点、减少继承的弊端。

继承的优点:

  • 代码共享,减少创建类的工作量;每个子类都有父类的方法和属性;
  • 提高代码重用性;
  • 子类可以形似父类,但又异于父类;
  • 提高代码可扩展性;
  • 提高产品开放性。

继承的缺点:

  • 继承是侵入性的——只要继承,就必须拥有父类的属性和方法;
  • 降低代码的灵活性——子类必须拥有父类的属性和方法,让子类自由的世界多了些约束;
  • 增强了耦合性——当父类的属性和方法被修改时,必须要考虑子类的修改。


示例(继承的缺点):

        原有类A,实现减法功能:

  1. class A{    
  2.     public int func1(int a, int b){    
  3.         return a-b;    
  4.     }    
  5. }    
  6.     
  7. public class Client{    
  8.     public static void main(String[] args){    
  9.         A a = new A();    
  10.         System.out.println("100-50="+a.func1(10050));    
  11.         System.out.println("100-80="+a.func1(10080));    
  12.     }    
  13. }    


        新增需求:新增两数相加、然后再与100求和的功能,由类B来负责

  1. class B extends A{    
  2.     public int func1(int a, int b){    
  3.         return a+b;    
  4.     }    
  5.         
  6.     public int func2(int a, int b){    
  7.         return func1(a,b)+100;    
  8.     }    
  9. }    
  10.     
  11. public class Client{    
  12.     public static void main(String[] args){    
  13.         B b = new B();    
  14.         System.out.println("100-50="+b.func1(10050));    
  15.         System.out.println("100-80="+b.func1(10080));    
  16.         System.out.println("100+20+100="+b.func2(10020));    
  17.     }    
  18. }    

OOPS! 原本运行正常的相减功能发生了错误。原因就是类B在给方法起名时无意中重写了父类的方法!



问题由来:

        有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。


解决方案:

        LSP为继承定义了一个规范,包括四层含义:


        1)子类必须完全实现父类的方法

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


        例如:父类AbstractGun有shoot()方法,其子类ToyGun不能完整实现父类的方法(玩具枪不能射击,ToyGun.shoot()中没有任何处理逻辑),则应该断开继承关系,另外建一个AbstractToy父类。


        2)子类可以有自己得个性

        即,在子类出现的地方,父类未必就能替代。


        3)重载或实现父类方法时,输入参数可以被放大(入参可以更宽松)

        否则,用子类替换父类后,会变成执行子类重载后的方法,而该方法可能“歪曲”父类的意图,可能引起业务逻辑混乱。


        4)重写或实现父类方法时,返回类型可以被缩小(返回类型更严格)


建议:

        在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用性会比较差,特别是运用多态比较频繁时,程序运行出错的几率非常大。

        父类中凡是已经实现好的方法(相对于抽象方法而言),实际上是在设定一系列的规范和契约,虽然它不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法任意修改,就会对整个继承体系造成破坏。而里氏替换原则就是表达了这一层含义。


        里氏替换原则通俗的来讲就是:子类可以扩展父类的功能,但不能改变父类原有的功能。


Q:

        LSP如何减少继承的弊端?

3. 依赖倒置原则 -Dependence Inversion Principle:

DIP,Dependence Inversion Principle:

High level modules should not depend upon low level modules. Both should depend upon abstractions.

Abstractions should not depend upon details. Details should depend upon abstractions.


“面向接口编程”

  • 高层模块不应该依赖低层模块,两者都应该依赖其抽象;——模块间的依赖通过抽象发生。实现类之间不发生直接的依赖关系(eg. 类B被用作类A的方法中的参数),其依赖关系是通过接口或抽象类产生的;
  • 抽象不应该依赖细节;——接口或抽象类不依赖于实现类;
  • 细节应该依赖抽象;——实现类依赖接口或抽象类。

何为“倒置”?

        依赖正置:类间的依赖是实实在在的实现类间的依赖,即面向实现编程,这是正常人的思维方式;

        而依赖倒置是对现实世界进行抽象,产生了抽象间的依赖,代替了人们传统思维中的事物间的依赖。


依赖倒置可以减少类间的耦合性、降低并行开发引起的风险。


示例(减少类间的耦合性):

        例如有一个Driver,可以驾驶Benz:

  1. public class Driver{  
  2.     public void drive(Benz benz){  
  3.         benz.run();  
  4.     }  
  5. }  
  6. public class Benz{  
  7.     public void run(){  
  8.         System.out.println("Benz开动...");  
  9.     }  
  10. }  

        问题来了:现在有变更,Driver不仅要驾驶Benz,还需要驾驶BMW,怎么办?

        Driver和Benz是紧耦合的,导致可维护性大大降低、稳定性大大降低(增加一个车就需要修改Driver,Driver是不稳定的)。


示例(降低并行开发风险性):

        如上例,Benz类没开发完成前,Driver是不能编译的!不能并行开发!


问题由来:

        类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。

        

解决办法:

        将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。


        上例中,新增一个抽象ICar接口,ICar不依赖于BMW和Benz两个实现类(抽象不依赖于细节)。

        1)Driver和ICar实现类松耦合

        2)接口定下来,Driver和BMW就可独立开发了,并可独立地进行单元测试


        依赖有三种写法:

       1)构造函数传递依赖对象(构造函数注入)

  1. public interface IDriver{  
  2.     public void drive();  
  3. }  
  4.   
  5. public class Driver implements IDriver{  
  6.     private ICar car;  
  7.   
  8.     public <strong>Driver(ICar _car)</strong>{  
  9.         this.car = _car;  
  10.     }  
  11.   
  12.     public void drive(){  
  13.         this.car.run();  
  14.     }  
  15. }  

      2)setter方法传递依赖对象(setter依赖注入)

  1. public interface IDriver{  
  2.     public void setCar(ICar car);  
  3.     public void drive();  
  4. }  
  5.   
  6. public class Driver implements IDriver{  
  7.     private ICar car;  
  8.     public void <strong>setCar(ICar car)</strong>{  
  9.         this.car = car;  
  10.     }  
  11.     public void drive(){  
  12.         this.car.run();  
  13.     }  
  14. }  

      3)接口声明依赖对象(接口注入)


建议:

        DIP的核心是面向接口编程;DIP的本质是通过抽象(接口、抽象类)使各个类或模块的实现彼此独立,不互相影响。

在项目中遵循以下原则:

  1. 每个类尽量都有接口或抽象类
  2. 变量的表面类型尽量使接口或抽象类
  3. 任何类都不应该从具体类派生*——否则就会依赖具体类。
  4. 尽量不要重写父类中已实现的方法——否则父类就不是一个真正适合被继承的抽象。
  5. 结合里氏替代原则使用
4. 接口隔离原则 -Interface Segregation Principle

Interface Segregation Principle:

Clients should not be forced to depend upon interfaces that they don't use.——客户端只依赖于它所需要的接口;它需要什么接口就提供什么接口,把不需要的接口剔除掉。

The dependency of one class to another one should depend on the smallest possible interface.——类间的依赖关系应建立在最小的接口上。

即,接口尽量细化,接口中的方法尽量少


问题由来:

        类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法


解决方案:

        将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。包含4层含义:

        1)接口要尽量小

        不能出现Fat Interface;但是要有限度,首先不能违反单一职责原则(不能一个接口对应半个职责)。


        2)接口要高内聚

        在接口中尽量少公布public方法。

       接口是对外的承诺,承诺越少对系统的开发越有利。


       3)定制服务

       只提供访问者需要的方法。例如,为管理员提供IComplexSearcher接口,为公网提供ISimpleSearcher接口。


       4)接口的设计是有限度的


建议:

  • 一个接口只服务于一个子模块或业务逻辑;
  • 通过业务逻辑压缩接口中的public方法;
  • 已被污染了的接口,尽量去修改;若变更的风险较大,则采用适配器模式转化处理;
  • 拒绝盲从


与单一职责原则的区别:

        二者审视角度不同;

        单一职责原则要求的是类和接口职责单一,注重的是职责,这是业务逻辑上的划分;

        接口隔离原则要求接口的方法尽量少。。。

5. 迪米特法则 -Law of Demeter

LoD,Law of Demeter:

又称最少知识原则(Least Knowledge Principle),一个对象应该对其他对象有最少的了解

一个类对自己依赖的类知道的越少越好。也就是说,对于被依赖的类来说,无论逻辑多么复杂,都尽量地的将逻辑封装在类的内部,对外除了提供的public方法,不对外泄漏任何信息。


问题由来:

        类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。


解决方案:

        迪米特法则包含4层含义:

        1)只和朋友交流

        Only talk to your immediate friends.两个对象之间的耦合就成为朋友关系。即,出现在成员变量、方法输入输出参数中的类就是朋友;局部变量不属于朋友。

--> 不与无关的对象发生耦合!

        方针:不要调用从另一个方法中返回的对象中的方法!只应该调用以下方法:

  • 该对象本身的方法
  • 该对象中的任何组件的方法
  • 方法参数中传入的对象的方法
  • 方法内部实例化的对象的方法

        例如:Teacher类可以命令TeamLeader对Students进行清点,则Teacher无需和Students耦合,只需和TeamLeader耦合即可。

反例:

  1. public float getTemp(){  
  2.      Thermometer t = station.getThermometer();  
  3.      return t.getTemp();  
  4. }  
客户端不应该了解气象站类中的温度计对象;应在气象站类中直接加入获取温度的方法。改为:

  1. public float getTemp(){  
  2.      return station.getTemp();  
  3. }  


        2)朋友间也应该有距离

        即使是朋友类之间也不能无话不说,无所不知。

--> 一个类公开的public属性或方法应该尽可能少!


        3)是自己的就是自己的

        如果一个方法放在本类中也可以、放在其他类中也可以,怎么办?

--> 如果一个方法放在本类中,既不增加类间关系,也对本类不产生负面影响,就放置在本类中。


        4)谨慎使用Serializable

        否则,若后来修改了属性,序列化时会抛异常NotSerializableException。


建议:

        迪米特法则的核心观念是:类间解耦。

        其结果是产生了大量中转或跳转类。


6. 开闭原则 -Open Closed Principle

Open Closed Principle:

Software entities like classes, modules and functions should be open for extension but closed for modifications.

对扩展开放,对修改关闭。一个软件实体应该通过扩展来实现变化,而不是通过修改已有代码来实现变化。

一个软件实体应该通过扩展来实现变化,而不是通过修改已有的代码来实现变化。——but,并不意味着不做任何修改;底层模块的扩展,必然要有高层模块进行耦合。


“变化”可分为三种类型:

  1. 逻辑变化——不涉及其他模块,可修改原有类中的方法;
  2. 子模块变化——会对其他模块产生影响,通过扩展来完成变化;
  3. 可见视图变化——界面变化,有时需要通过扩展来完成变化。


问题由来:

        在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。


解决方案:

        当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。要求:


       1)抽象约束(要实现对扩展开放,首要前提就是抽象约束)

        通过接口或抽象类可以约束一组可能变化的行为,并能实现对扩展开放。包含三层含义:

  • 通过接口或抽象类可以约束扩展,对扩展进行边界限定,不允许出现在接口抽象类中不存在的public方法;
  • 参数类型、引用对象尽量使用接口或抽象类,而不是实现类;
  • 抽象应尽量保持稳定,一旦确定即不允许修改。


        2)元数据(metadata)控制模块行为

       元数据,即用来描述环境和数据的数据,即配置数据。例如SpingContext。

     

      3)制定项目章程


      4)封装变化

      封装可能发生的变化。将相同的变化封装到一个接口或抽象类中;将不同的变化封装到不同的接口或抽象类中。


好处:

  • 易于单元测试

       如果直接修改已有代码,则需要同时修改单元测试类;而通过扩展,则只需生成一个测试类。


  • 可提高复用性


  • 可提高可维护性


  • 面向对象开发的要求


建议:

      开闭原则是最基础的原则,前5个原则都是开闭原则的具体形态。