Java设计模式之7大设计原则详解

设计模式之七大基本原则

1. 单一职责原则 SRP

单一职责原则表示一个模块的组成元素之间的功能相关性。从软件变化的角度来看,就一个类而言,应该仅有一个让它变化的原因;通俗地说,即一个类只负责一项职责。

假设某个类 P 负责两个不同的职责,职责 P1 和 职责 P2,那么当职责 P1 需求发生改变而需要修改类 P,有可能会导致原来运行正常的职责 P2 功能发生故障。

我们假设一个场景:

有一个动物类,它会呼吸空气,用一个类描述动物呼吸这个场景:

class Animal{  
    public void breathe(String animal){  
        System.out.println(animal + "呼吸空气");  
    }  
}  
public class Client{  
    public static void main(String[] args){  
        Animal animal = new Animal();  
        animal.breathe("牛");  
        animal.breathe("羊");  
        animal.breathe("猪");  
    }  
}

在后来发现新问题,并不是所有的动物都需要呼吸空气,比如鱼需要呼吸水,修改时如果遵循单一职责原则的话,那么需要将 Animal 类进行拆分为陆生类和水生动物类,代码如下:

class Terrestrial{  
    public void breathe(String animal){  
        System.out.println(animal + "呼吸空气");  
    }  
}  
class Aquatic{  
    public void breathe(String animal){  
        System.out.println(animal + "呼吸水");  
    }  
}  
  
public class Client{  
    public static void main(String[] args){  
        Terrestrial terrestrial = new Terrestrial();  
        terrestrial.breathe("牛");  
        terrestrial.breathe("羊");  
        terrestrial.breathe("猪");  
          
        Aquatic aquatic = new Aquatic();  
        aquatic.breathe("鱼");  
    }  
} 

在实际工作中,如果这样修改的话开销是很大的,除了将原来的 Animal 类分解为 Terrestrial 类和 Aquatic 类以外还需要修改客户端,而直接修改类 Animal 类来达到目的虽然违背了单一职责原则,但是花销却小的多,代码如下:

class Animal{  
    public void breathe(String animal){  
        if("鱼".equals(animal)){  
            System.out.println(animal + "呼吸水");  
        }else{  
            System.out.println(animal + "呼吸空气");  
        }  
    }  
}  

public class Client{  
    public static void main(String[] args){  
        Animal animal = new Animal();  
        animal.breathe("牛");  
        animal.breathe("羊");  
        animal.breathe("猪");  
        animal.breathe("鱼");  
    }  
}

可以看得出,这样的修改显然简便了许多,但是却存在着隐患,如果有一天有需要加入某类动物不需要呼吸,那么就要修改 Animal 类的 breathe 方法,而对原有代码的修改可能会对其他相关功能带来风险,也许有一天你会发现输出结果变成了:“牛呼吸水” 了,这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却最大的。

另外还有一种修改方式:

class Animal{  
    public void breathe(String animal){  
        System.out.println(animal + "呼吸空气");  
    }  
  
    public void breathe2(String animal){  
        System.out.println(animal + "呼吸水");  
    }  
}  
  
public class Client{  
    public static void main(String[] args){  
        Animal animal = new Animal();  
        animal.breathe("牛");  
        animal.breathe("羊");  
        animal.breathe("猪");  
        animal.breathe2("鱼");  
    }  
}  

可以看出,这种修改方式没有改动原来的代码,而是在类中新加了一个方法,这样虽然违背了单一职责原则,但是它并没有修改原来已存在的代码,不会对原本已存在的功能造成影响。

那么在实际编程中,需要根据实际情况来确定使用哪种方式,只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则

遵循单一职责原的优点:

  • 可以降低类的复杂度,一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多。
  • 提高类的可读性,提高系统的可维护性。
  • 变更引起的风险降低,变更是必然的,如果单一职责原则遵守的好,当修改一个功能时,可以显著降低对其他功能的影响。

2. 开放-关闭原则 OCP

开闭原则是面向对象设计中最基础的设计原则。 开闭原则就是说对扩展开放,对修改关闭。在程序需要进行拓展的时候,不能去修改原有的代码,实现一个热插拔的效果。所以一句话概括就是:为了使程序的扩展性好,易于维护和升级。想要达到这样的效果,需要面向接口编程。

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

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

如果一个软件能够满足 OCP 原则,那么它将有两项优点:

  • 能够扩展已存在的系统,能够提供新的功能满足新的需求,因此该软件有着很强的适应性和灵活性。
  • 已存在的模块,特别是那些重要的抽象模块,不需要被修改,那么该软件就有很强的稳定性和持久性。

3. 里氏替换原则 LSP

里氏代换原则(Liskov Substitution Principle LSP)面向对象设计的基本原则之一。 里氏代换原则中说,任何基类可以出现的地方,子类一定可以出现。 LSP是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不受到影响时,基类才能真正被复用,而衍生类也能够在基类的基础上增加新的行为。通俗简单的说就是:子类可以扩展父类的功能,但不能改变父类原有的功能。

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

举个例子,我们需要完成一个两数相减的功能:

class A{  
    public int func1(int a, int b){  
        return a-b;  
    }  
}  

后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。即类B需要完成两个功能:

两数相减两数相加,然后再加100

由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下:

class B extends A{  
    public int func1(int a, int b){  
        return a+b;  
    }  
      
    public int func2(int a, int b){  
        return func1(a,b)+100;  
    }  
}  

我们发现原来原本运行正常的相减功能发生了错误,原因就是类 B 在给方法起名时无意中重写了父类的方法,造成了所有运行相减功能的代码全部调用了类 B 重写后的方法,造成原来运行正常的功能出现了错误。在实际编程中,我们常常会通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是这样往往也增加了重写父类方法所带来的风险。特别是运用多态比较频繁时,程序运行出错的几率非常大。如果非要重写父类的方法,比较通用的做法是:原来的父类和子类都继承一个更通俗的基类,原有的继承关系去掉,采用依赖、聚合,组合等关系代替。

再次来理解里氏替换原则:子类可以扩展父类的功能,但不能改变父类原有的功能。它包含以下4层含义:

子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
子类中可以增加自己特有的方法。
当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数更宽松。【注意区分重载和重写】
当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更严格。

看上去很不可思议,因为我们会发现在自己编程中常常会违反里氏替换原则,程序照样跑的好好的。所以大家都会产生这样的疑问,假如我非要不遵循里氏替换原则会有什么后果?

后果就是:你写的代码出问题的几率将会大大增加

4. 依赖倒转原则 DIP

所谓依赖倒置原则(Dependence Inversion Principle)就是要依赖于抽象,不要依赖于具体。实现开闭原则的关键是抽象化,并且从抽象化导出具体化实现,如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要手段。

定义:高层模块不应该依赖低层模块,二者都应该于抽象。进一步说,抽象不应该依赖于细节,细节应该依赖于抽象。

通俗点说:要求对抽象进行编程,不要对实现进行编程,这样就降低了客户与实现模块间的耦合。

举个例子, 某天产品经理需要添加新的功能,该功能需要操作数据库,一般负责封装数据库操作的和处理业务逻辑分别由不同的程序员编写。

封装数据库操作可认为低层模块,而处理业务逻辑可认为高层模块,那么如果处理业务逻辑需要等到封装数据库操作的代码写完的话才能添加的话讲会严重拖垮项目的进度。

正确的做法应该是处理业务逻辑的程序员提供一个封装好数据库操作的抽象接口,交给低层模块的程序员去编写,这样双方可以单独编写而互不影响。

依赖倒转原则的核心思想就是面向接口编程,思考下面这样一个场景:母亲给孩子讲故事,只要给她一本书,她就可照着书给孩子讲故事了。代码如下:

class Book{  
    public String getContent(){  
        return "这是一个有趣的故事";  
    }  
}  
  
class Mother{  
    public void say(Book book){  
        System.out.println("妈妈开始讲故事");  
        System.out.println(book.getContent());  
    }  
}  
  
public class Client{  
    public static void main(String[] args){  
        Mother mother = new Mother();  
        mother.say(new Book());  
    }  
}  

假如有一天,给的是一份报纸,而不是一本书,让这个母亲讲下报纸上的故事,报纸的代码如下:

class Newspaper{  
    public String getContent(){  
        return "这个一则重要的新闻";  
    }  
}  

然而这个母亲却办不到,应该她只会读书,这太不可思议,只是将书换成报纸,居然需要修改 Mother 类才能读,假如以后需要换成了杂志呢?原因是 Mother 和 Book 之间的耦合度太高了,必须降低他们的耦合度才行。

我们可以引入一个抽象接口 IReader 读物,让书和报纸去实现这个接口,那么无论提供什么样的读物,该母亲都能读。代码如下:

interface IReader{  
    public String getContent();  
}  

class Newspaper implements IReader {  
    public String getContent(){  
        return "这个一则重要的新闻";  
    }  
}  
class Book implements IReader{  
    public String getContent(){  
        return "这是一个有趣的故事";  
    }  
}  
  
class Mother{  
    public void say(IReader reader){  
        System.out.println("妈妈开始讲故事");  
        System.out.println(reader.getContent());  
    }  
}  
  
public class Client{  
    public static void main(String[] args){  
        Mother mother = new Mother();  
        mother.say(new Book());  
        mother.say(new Newspaper());  
    }  
}

这样修改之后,以后无论提供什么样的读物,只要去实现了 IReader 接口之后就可以被母亲读。实际情况中,代表高层模块的 Mother 类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒转原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。

在实际编程中,我们一般需要做到如下3点:

低层模块尽量都要有抽象类或接口,或者两者都有。【可能会被人用到的】
变量的声明类型尽量是抽象类或接口。
使用继承时遵循里氏替换原则。

5. 接口隔离原则 ISP

接口隔离原则,其 “隔离” 并不是准确的翻译,真正的意图是 “分离” 接口(的功能)。其原则字面的意思是:使用多个隔离的接口,比使用单个接口要好。本意降低类之间的耦合度,而设计模式就是一个软件的设计思想,从大型软件架构出发,为了升级和维护方便。所以上文中多次出现:降低依赖,降低耦合。

接口隔离原则强调:客户端不应该依赖它不需要的接口;一个类对另一个类的依赖应该建立在最小的接口上。

我们先来看一张图:
在这里插入图片描述

从图中可以看出,类 A 依赖于 接口 I 中的方法 1,2,3 ,类 B 是对类 A 的具体实现。类 C 依赖接口 I 中的方法 1,4,5,类 D 是对类 C 的具体实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。

用代码表示:

interface I {  
    public void method1();  
    public void method2();  
    public void method3();  
    public void method4();  
    public void method5();  
}  
  
class A{  
    public void depend1(I i){  
        i.method1();  
    }  
    public void depend2(I i){  
        i.method2();  
    }  
    public void depend3(I i){  
        i.method3();  
    }  
}  

class B implements I{  
 // 类 B 只需要实现方法 1,2, 3,而其它方法它并不需要,但是也需要实现
    public void method1() {  
        System.out.println("类 B 实现接口 I 的方法 1");  
    }  
    public void method2() {  
        System.out.println("类 B 实现接口 I 的方法 2");  
    }  
    public void method3() {  
        System.out.println("类 B 实现接口 I 的方法 3");  
    }  
    public void method4() {}  
    public void method5() {}  
}  
  
class C{  
    public void depend1(I i){  
        i.method1();  
    }  
    public void depend2(I i){  
        i.method4();  
    }  
    public void depend3(I i){  
        i.method5();  
    }  
}  


class D implements I{  
	// 类 D 只需要实现方法 1,4,5,而其它方法它并不需要,但是也需要实现
    public void method1() {  
        System.out.println("类 D 实现接口 I 的方法 1");  
    }  
    public void method2() {}  
    public void method3() {}  
    public void method4() {  
        System.out.println("类 D 实现接口 I 的方法 4");  
    }  
    public void method5() {  
        System.out.println("类 D 实现接口 I 的方法 5");  
    }  
}  
  
public class Client{  
    public static void main(String[] args){  
        A a = new A();  
        a.depend1(new B());  
        a.depend2(new B());  
        a.depend3(new B());  
          
        C c = new C();  
        c.depend1(new D());  
        c.depend2(new D());  
        c.depend3(new D());  
    }  
}  

可以看出,如果接口定义的过于臃肿,只要接口中出现的方法,不管依赖于它的类是否需要该方法,实现类都必须去实现这些方法,这就不符合接口隔离原则,如果想符合接口隔离原则,就必须对接口 I 如下图进行拆分:
在这里插入图片描述

代码可修改为如下:

interface I1 {  
    public void method1();  
}  
  
interface I2 {  
    public void method2();  
    public void method3();  
}  
  
interface I3 {  
    public void method4();  
    public void method5();  
}  
  
class A{  
    public void depend1(I1 i){  
        i.method1();  
    }  
    public void depend2(I2 i){  
        i.method2();  
    }  
    public void depend3(I2 i){  
        i.method3();  
    }  
}  
  
class B implements I1, I2{  
    public void method1() {  
        System.out.println("类 B 实现接口 I1 的方法 1");  
    }  
    public void method2() {  
        System.out.println("类 B 实现接口 I2 的方法 2");  
    }  
    public void method3() {  
        System.out.println("类 B 实现接口 I2 的方法 3");  
    }  
}  
  
class C{  
    public void depend1(I1 i){  
        i.method1();  
    }  
    public void depend2(I3 i){  
        i.method4();  
    }  
    public void depend3(I3 i){  
        i.method5();  
    }  
}  
  
class D implements I1, I3{  
    public void method1() {  
        System.out.println("类 D 实现接口 I1 的方法 1");  
    }  
    public void method4() {  
        System.out.println("类 D 实现接口 I3 的方法 4");  
    }  
    public void method5() {  
        System.out.println("类 D 实现接口 I3 的方法 5");  
    }  
}  

接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。也就是说,我们要为各个类建立专用的接口,而不要试图去建立一个很庞大的接口供所有依赖它的类去调用。本文例子中,将一个庞大的接口变更为3个专用的接口所采用的就是接口隔离原则。在程序设计中,依赖几个专用的接口要比依赖一个综合的接口更灵活。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。

说到这里,很多人会觉的接口隔离原则跟之前的单一职责原则很相似,其实不然。其一,单一职责原则原注重的是职责;而接口隔离原则注重对接口依赖的隔离。其二,单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建

采用接口隔离原则对接口进行约束时,要注意以下几点

接口尽量小,但是要有限度。对接口进行细化可以提高程序设计灵活性是不挣的事实,但是如果过小,则会造成接口数量过多,使设计复杂化。所以一定要适度。
为依赖接口的类定制服务,只暴露给调用的类它需要的方法,它不需要的方法则隐藏起来。只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

运用接口隔离原则,一定要适度,接口设计的过大或过小都不好。设计接口的时候,只有多花些时间去思考和筹划,才能准确地实践这一原则。

6. 迪米特法则 LOD

迪米特法则又称为 最少知道原则,它表示一个对象应该对其它对象保持最少的了解。通俗来说就是,只与直接的朋友通信。

首先来解释一下什么是直接的朋友:每个对象都会与其他对象有耦合关系,只要两个对象之间有耦合关系,我们就说这两个对象之间是朋友关系。耦合的方式很多,依赖、关联、组合、聚合等。其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。也就是说,陌生的类最好不要作为局部变量的形式出现在类的内部。

对于被依赖的类来说,无论逻辑多么复杂,都尽量的将逻辑封装在类的内部,对外提供 public 方法,不对泄漏任何信息。

举个例子,家人探望犯人

家人:家人只与犯人是亲人,但是不认识他的狱友
public class Family {
    public void visitPrisoner(Prisoners prisoners) {
        Inmates inmates = prisoners.helpEachOther();
        imates.weAreFriend();
    }
}
犯人:犯人与家人是亲人,犯人与狱友是朋友
public class Prisoners {
    private Inmates inmates = new Inmates();
    public Inmates helpEachOther() {
        System.out.println("家人说:你和狱友之间应该互相帮助...");
        return inmates;
    }
}
狱友: 犯人与狱友是朋友,但是不认识他的家人
public class Inmates {
    public void weAreFriend() {
        System.out.println("狱友说:我们是狱友...");
    }
}
场景类:发生在监狱里
public class Prison {
    public static void main(String args[])
    {
        Family family = new Family();
        family.visitPrisoner(new Prisoners());
    }
}

运行结果会发现:

家人说:你和狱友之间应该互相帮助…
狱友说:我们是狱友…

家人和狱友显然是不认识的,且监狱只允许家人探望犯人,而不是随便谁都可以见面的,这里家人和狱友有了沟通显然是违背了迪米特法则,因为在 Inmates 这个类作为局部变量出现在了 Family 类中的方法里,而他们不认识,不能够跟直接通信,迪米特法则告诉我们只与直接的朋友通信。所以上述的代码可以改为:

public class Family {
    //家人探望犯人
    public void visitPrisoner(Prisoners prisoners) {
        System.out.print("家人说:");
        prisoners.helpEachOther();
    }
}

public class Prisoners {
    private Inmates inmates = new Inmates();
    public Inmates helpEachOther() {
        System.out.println("犯人和狱友之间应该互相帮助...");
        System.out.print("犯人说:");
        inmates.weAreFriend();
        return inmates;
    }
     
}

public class Inmates {
    public void weAreFriend() {
        System.out.println("我们是狱友...");
    }
}

public class Prison {
    public static void main(String args[]) {
        Family family = new Family();
        family.visitPrisoner(new Prisoners());
    }
}

运行结果

家人说:犯人和狱友之间应该互相帮助…
犯人说:我们是狱友…

这样家人和狱友就分开了,但是也表达了家人希望狱友能跟犯人互相帮助的意愿。也就是两个类通过第三个类实现信息传递, 而家人和狱友却没有直接通信。

7. 组合/聚合复用原则 CRP

组合/聚合复用原则就是在一个新的对象里面使用一些已有的对象,使之成为新对象的一部分; 新的对象通过向这些对象的委派达到复用已有功能的目的。

在面向对象的设计中,如果直接继承基类,会破坏封装,因为继承将基类的实现细节暴露给子类;如果基类的实现发生了改变,则子类的实现也不得不改变;从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。于是就提出了组合/聚合复用原则,也就是在实际开发设计中,尽量使用组合/聚合,不要使用类继承。

举个简单的例子,在某家公司里的员工分为经理,工作者和销售者。如果画成 UML 图可以表示为:

但是这样违背了组合聚合复用原则,继承会将 Employee 类中的方法暴露给子类。如果要遵守组合聚合复用原则,可以将其改为:

这样做降低了类与类之间的耦合度,Employee 类的变化对其它类造成的影响相对较少。

总结:

总体说来,组合/聚合复用原则告诉我们:组合或者聚合好过于继承。聚合组合是一种 “黑箱” 复用,因为细节对象的内容对客户端来说是不可见的。

借鉴、摘抄于:
https://www.cnblogs.com/pony1223/p/7594803.html
https://zhuanlan.zhihu.com/p/24614363

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值