JAVA设计模式(一)面向对象设计原则

在进行软件开发时,不仅仅需要将最基本的业务给完成,还要考虑整个项目的可维护性和可复用性,我们开发的项目不单单需要我们自己来维护,同时也需要其他的开发者一起来进行共同维护,因此我们在编写代码时,应该尽可能的规范。

因此在进行代码开发设计时,应当遵循一定的设计原则,基于这些设计原则,衍生出了很多种常用的设计模式。因此,这一节对常用的设计原则进行一个总结。

单一职责原则

单一职责原则(Simple Responsibility Pinciple,SRP)是最简单的面向对象设计原则,它用于控制类的粒度大小。

单一职责,在开发中是最不要钱的优秀的编码手段,它指的是引起类变化的原因,一个类只能有一个。有什么好处?当一个类职责越多,被引用的地方就越多,依赖关系就越复杂,同样可能引起类变化的原因就越多,这样的类就会越脆弱。

例如:定义一个消息中心,可以发送邮件,发送手机短信和微信推送三种方式,有如下代码:

public interface MessageCenter {
    // 发送邮件
    void sendByEmail();
  
    // 发送手机短信
    void sendByPhone();

    // 微信推送
    void sendByWeChat();
}

上面的代码违反了单一职责原则,对于一个类或者接口,原则上只需要完成单一的职责,为了解决这个问题,代码写成如下的方式:

1)定义发送消息接口,只定义消息发送功能

public interface MessageCenter {
    // 发送消息
    void sendMessage();
}

2)具体的实现类实现单一功能

public class EmailSender implements MessageCenter{

    @Override
    public void sendMessage() {
        System.out.println("发送邮件...");
    }
}

再举一个例子,对于一个类Computer ,可以实现如下的功能:

public class Computer {
  
    public void open(){
        System.out.println("正在开机中...");
    }
  
    public void calculate(){
        System.out.println("正在执行计算任务");
    }
  
    public void shutdown(){
        System.out.println("正在关机中...");
    }
  
    public void video(){
        System.out.println("播放视频...");
    }
  
}

看起来设计的没有问题,但是却是违背了单一职责要求。其中开机、关机属于单一的职责,计算属于单一职责,播放视频同样也是一个职责。单一职责指一个接口或者类只有一个原因引起变化,以上显然不属于。进行修改后如下:

1)定义Calculate接口 实现计算功能

public interface Calculate {

    void add();

    void sub();
}

2)定义LifeCircle接口,实现生命周期管理

public interface LifeCircle {

    void open();

    void init();

    void destroy();

    void close();
}

3) 实现这两个接口

public class Computer implements Calculate, LifeCircle{
    @Override
    public void add() {
    
    }

    @Override
    public void sub() {

    }

    @Override
    public void open() {

    }

    @Override
    public void init() {

    }

    @Override
    public void destroy() {

    }

    @Override
    public void close() {

    }
}

虽然一个接口中有多个接口方法,但是做的事情是一样的,可以理解成只有一个原因引起变化。

这样的设计才是完美的,一个类实现两个接口,把两个职责融合在一个类中。这样看起来Computer类有两个原因引起了变化。是的,但是别忘记了,Java是面向接口编程,对外公布接口而不是实现类。这一原则在很多源码中都有所体现。此外,Computer类可以继续实现多个接口对功能进行扩展,但是依然满足单依职责原则。并且通常一个类不会实现很多接口,但是由于Java接口可以继承接口,会使用接口继承的方式实现各个功能。

开闭原则

软件实体应当对扩展开放,对修改关闭。

在面向对象领域中,开闭原则规定软件中的对象、类、模块和函数对扩展应该是开放的,但对于修改是封闭的。这意味着应该用抽象定义结构,用具体实现扩展细节,以此确保软件系统的开发和维护过程的稳定性。开闭原则也可以理解为面向抽象编程。

当功能需要变化的时候,我们应该是通过扩展的方式实现,而不是通过修改已有的代码来实现。随着软件规模越来越大,软件寿命越来越长,软件维护成本越来越高,设计满足开闭原则的软件系统也变得越来越重要。

例如:在交易场景中,常常需要定义多种支付方式。现在有两种支付方式,分别是微信和支付宝,各自定义了支付的细节。

public class AliPay {

    void transferMoney(){
        System.out.println("扣除手续费");
        System.out.println("交易中...");
        System.out.println("微信交易完成...");
    }
}

public class WeChatPay {

    void transferMoney(){
        System.out.println("扣除手续费");
        System.out.println("交易中...");
        System.out.println("微信交易完成...");
    }
}
public class TransferService {

    public void transfer(Integer type){
        // 微信支付
        if (type == 1){
            WeChatPay weChatPay = new WeChatPay();
            weChatPay.transferMoney();
        }
        // 支付宝
        else if (type == 2){
            AliPay aliPay = new AliPay();
            aliPay.transferMoney();
        }
    }
}

这样的代码有典型的问题,当新增一个新的支付方式时,不得不修改TransferService中的transfer方法。而开闭原则的核心就是把这种硬编码转换成抽象的类或者接口。我们可以定义一个IPay接口,接口中定义transferMoney方法,在转账时不再传入type而是接口。


public interface IPay {

    void transferMoney();
}

public class AliPay implements IPay{

    @Override
    public void transferMoney() {
        System.out.println("扣除手续费");
        System.out.println("交易中...");
        System.out.println("微信交易完成...");
    }
}

public class WeChatPay implements IPay{

    @Override
    public void transferMoney(){
        System.out.println("扣除手续费");
        System.out.println("交易中...");
        System.out.println("微信交易完成...");
    }
}
public class TransferService {

    public void transfer(IPay payType){
        // 不使用硬编码 对扩展开放
        payType.transferMoney();
    }
}

这样就实现了对扩展开放,对修改关闭。另外,JDK8新特性中的default方法,允许在接口中提供一些方法的默认实现。同样很好的体现了这一思想,如果子类需要扩展只需要重写default方法而完全不影响其他实现该接口的类。

里氏替换原则

所有引用基类的地方必须能透明地使用其子类的对象。

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

里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常,反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。

具体来说,有以下四点:

  • 子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
  • 子类可以增加自己特有的方法。
  • 当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松。
  • 当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的输出/返回值)要比父类更严格或与父类一样。

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

如果程序违背了里氏替换原则,则继承类的对象在基类出现的地方会出现运行错误。这时其修正方法是:取消原来的继承关系,重新设计它们之间的关系。

例如:定义一个Coder抽象类,实现了一个方法coding方法,并返回完成的代码行数。

public abstract class Coder {

    public Integer coding(){
        System.out.println("敲代码呢...");
        System.out.println("敲了100行");
        // 返回敲代码的行数
        return 100;
    }
}

public class CCoder extends Coder{
}

public class JavaCoder extends Coder{
}

public class TrashCoder extends Coder{

    @Override
    public Integer coding() {
        return -100;
    }
}

分别有三个类继承了Coder,但是有一个程序员比较垃圾,写不了代码,得靠别人帮忙写代码,因此重写了coding方法返回-100。这就违反了里氏替换原则。由于重写了父类的方法,在基类作为变量时候容易发生异常。

    public static void main(String[] args) {
        Coder coder = new TrashCoder();
        Integer codingNum = coder.coding();
        System.out.println("今日每小时代码行数: " + String.valueOf(codingNum / 8));
    }

这就会出现一些奇怪的问题,最终的每小时代码量是负数。这里更合适的解决方法是TransCoder不要继承Coder,而继承更一般的如People,从而解决这样的问题。

说的再简单一些,就是尽量用基类作为变量接收,子类替换父类不会报错(因为没有改写父类逻辑)。如果一定要改写,那就抽出一个更一般的类,将这个方法作为抽象方法,取消继承父类转为继承这个更一般的类实现抽象方法。这样的思想在很多框架中被使用。

依赖倒转原则

高层模块不应依赖于底层模块,它们都应该依赖抽象。抽象不应依赖于细节,细节应该依赖于抽象。

依赖倒置原则主要有以下几点:

  • 高层模块不应该依赖低层模块,二者都应该依赖其抽象
  • 抽象不应该依赖细节,细节应该依赖抽象
  • 依赖倒转(倒置)的中心思想是面向接口编程
  • 使用接口或抽象类的目的是制定好规范,而不涉及任何具体的操作,把展现细节的任务交给他们的实现类去完成

举个例子,我们有一个厨师类,擅长做面条:

public class Cooker {

    public void cook(Noodles noodles){
        noodles.component();
    }
}


class Noodles {

    public void component(){
        System.out.println("面条, 油, 盐...");
    }
}
    public static void main(String[] args) {
        Cooker cooker = new Cooker();
        cooker.cook(new Noodles());
    }

这种情况下,在后续扩展中如果Cooker需要做其他的菜,就需要进行大的改动。如果改成如下方式:

public interface Dish {
  
    void component();
}

public interface ICook {

    void cook(Dish dish);
}

public class Cooker implements ICook{

    @Override
    public void cook(Dish dish) {
        dish.component();
    }
}

对于Dish和Cooker都进行抽象,面向抽象编程。

public class DiSanxian implements Dish{

    @Override
    public void component() {
        System.out.println("土豆, 茄子, 辣椒...");
    }
}

// 这样Cooker可以更加方便的进行扩展,类与类之间也实现了解耦
    public static void main(String[] args) {
        Cooker2 cooker2 = new Cooker2();
        cooker2.cook(new DiSanxian());
    }

这样的方式在spring中经常使用到。并且依赖是可以传递的。A对象依赖B对象,B依赖C,C依赖D...,只要做到抽象依赖,即使是多层的依赖传递也无所畏惧。在spring中依赖的注入可以通过构造函数和setter方法注入,并且spring还实现了IOC(控制反转),使得对象的控制交由spring去管理,进一步降低了编写代码过程中耦合的程度,更利于大规模系统的开发。

接口隔离原则

客户端不应该依赖它不需要的接口,一个类对另一个类的依赖应该建立在最小的接口上。

接口隔离原则可以定义为:建立单一接口,不要建立臃肿庞大的接口。也就是说,接口尽量细化,同时接口中的方法尽量少。

看到这,可能会与单一职责原则弄混,单一职责原则,强调的是职责,站在业务逻辑的角度;而接口隔离原则,强调接口的方法尽量少。

举个简单的例子,定义一个Student接口,可以输出学生基本信息。

public interface Student {
  
    void printName();
  
    void printAge();

    void printChinese();
  
    void printMath();
  
    void printPhysics();
  
    void printHistory();
}

有一个学生Tom实现了这个接口:

public class Tom implements Student{

    @Override
    public void printName() {
        System.out.println("Tom...");
    }

    @Override
    public void printAge() {
        System.out.println("12...");
    }

    @Override
    public void printChinese() {
        System.out.println("Chinese...");
    }

    @Override
    public void printMath() {
        System.out.println("Math...");
    }

    @Override
    public void printPhysics() {
        System.out.println("Physics...");
    }

    @Override
    public void printHistory() {
        System.out.println("History...");
    }
}

但是有一个新的学生是理科生,不学历史,因此printHistory方法不能实现,经过观察,为了提高接口的复用性,我们应用接口隔离原则对代码进行如下改进:

public interface Student {

    void printName();

    void printAge();

    void printChinese();

    void printMath();
}

public interface Science {

    void printPhysics();

    void printChemistry();

}

public interface Art {

    void printHistory();

    void printPolitics();
}

将接口拆分成Student、Art、Science,如果仅仅实现Student可以实现学生基本的信息,并根据程序中的需求实现多个不同的接口。

合成复用原则

优先使用对象组合,而不是通过继承来达到复用的目的。

一般而言,如果两个类之间是“Has-A”的关系应使用组合或聚合,如果是“Is-A”关系可使用继承。"Is-A"是严格的分类学意义上的定义,意思是一个类是另一个类的"一种";而"Has-A"则不同,它表示某一个角色具有某一项责任。

也就是说,当我们需要在当前类中实现一个在其他类中已经实现的功能,如果当前类与其他类是同一类(Is-a)的可以使用集成,这样不仅可以使用功能,并且通过继承还能得到这一类别的基本属性与方法。如果不是,通常使用对象组合的方式。因为如果仅仅为了实现这一个功能而通过继承的方式实现复用,将类B直接继承自类A,在后续维护的时候成本会成倍的增加,并且由于父类的属性和方法暴露给了子类,会造成一定的不安全性。

应用合成复用原则主要有以下几类情况:

  • USE 动态之间临时的、动态的调用
  • ASSOCIATION 长期的静态的联系
  • COMPOSITION 将简单对象组合合并成更复杂的对象
  • AGGREGATION 部分与整体的关系

通常第一种使用传参的方式,后三种通常使用构造函数注入或直接new的方式创建对象。例如在某一个模块中需要使用已经实现了的交易功能:

// 定义支付接口
public interface IPay {
  
    void pay(Double money);
}

// IPay的一个实现类,以支付宝为例
public class AliPay implements IPay{

    @Override
    public void pay(Double money) {
        System.out.println("成功支付了" + money + "元!");
    }
}

如果在我们的系统中使用合成复用原则:

// 1.使用参数传递
public class MyClass1 {

    public void transfer(IPay pay, Double money){
        pay.pay(money);
    }

    public static void main(String[] args) {
        MyClass1 myClass1 = new MyClass1();
        myClass1.transfer(new AliPay(), 0.05);
    }
}

// 2.使用构造函数
public class MyClass2 {

    private IPay pay;

    public MyClass2(IPay pay) {
        this.pay = pay;
    }

    public void transfer(Double money){
        pay.pay(money);
    }

    public static void main(String[] args) {
        MyClass2 myClass2 = new MyClass2(new AliPay());
        myClass2.transfer(0.1);
    }
}

// 3.直接new
public class MyClass3 {

    private IPay pay = new AliPay();

//    public MyClass3(IPay pay) {
//        this.pay = new AliPay();
//    }

    public void transfer(Double money){
        pay.pay(money);
    }

    public static void main(String[] args) {
        MyClass3 myClass3 = new MyClass3();
        myClass3.transfer(0.1);
    }
}

总之,合成复用原则的核心思想继承不变功能,委托可变功能。

迪米特法则

每一个软件单位对其他单位都只有最少的知识,而且局限于那些与本单位密切相关的软件单位。

迪米特法则指出了一个对象应该对其他对象保持最少的了解,又叫最少知道原则,尽量降低类与类之间的耦合度。迪米特原则主要强调:只和朋友交流,不和陌生人说话。出现在成员变更、方法的输入、输出参数中的类都可以称为成员朋友类,而出现在方法体内部的类不属于朋友类。

举个简单的例子,部门需要进行汇报,P8要求P7做一下汇报,但是实际做PPT的是P6,干活的是P5。但是P8不能够直接找到P7,P6,P5把他们组合一下,因为P7是”朋友“,需要P7去直接或间接的联系P6和P5。最开始是这样的,上代码:

public class P7 {

    public void doPresentation(){
        System.out.println("拿P6和P5的内容来汇报");
    }
}

public class P6 {

    public void doPPT(){
        System.out.println("做PPT呢");
    }
}

public class P5 {

    public void doWork(){
        System.out.println("干活被人拿来汇报");
    }
}
public class P8 {

    private P7 p7;
    private P6 p6;
    private P5 p5;

    public P8(P7 p7, P6 p6, P5 p5) {
        this.p7 = p7;
        this.p6 = p6;
        this.p5 = p5;
    }

    public void projectMeeting(){
        p5.doWork();
        p6.doPPT();
        p7.doPresentation();
    }

    public static void main(String[] args) {
        P8 p8 = new P8(new P7(), new P6(), new P5());
        p8.projectMeeting();
    }
}

这样导致的现象就是耦合性比较大,不符合迪米特法则,修改成这样:

public class P8 {
  
    public void projectMeeting(P7 p7){
        p7.doPresentation();
    }

    public static void main(String[] args) {
        P8 p8 = new P8();
        p8.projectMeeting(new P7());
    }
}

public class P7 {

    public void doPresentation(){
        new P6().doPPT();
        System.out.println("拿P6和P5的内容来汇报");
    }
}

public class P6 {

    public void doPPT(){
        new P5().doWork();
        System.out.println("做PPT呢");
    }
}

public class P5 {

    public void doWork(){
        System.out.println("干活被人拿来汇报");
    }
}

当然很多时候过度使用也会造成一定的问题,但是迪米特原则的好处在于职责清晰,很多时候利于维护。

参考文章

1.[设计原则与模式](https://www.jianshu.com/p/ecdb91945153)

2.[Java 设计模式6大原则之(一):开闭原则](https://blog.csdn.net/qq\_40116418/article/details/124745725)

3.[Java设计模式](https://blog.csdn.net/qq\_25928447/article/details/124884700)

4.[里氏替换原则](http://c.biancheng.net/view/1324.html)

5.[依赖倒置](https://blog.csdn.net/m0\_54485604/article/details/113756740)

6.[依赖倒置原则](https://www.jianshu.com/p/a7f51723228b)

7.[合成复用原则](https://blog.csdn.net/logictime2/article/details/105583064)

最后修改:2022 年 06 月 13 日
如果觉得我的文章对你有用,请随意赞赏