Java面试题剖析(基础篇) | 第五篇: 深入理解Java面向对象设计的六大原则(上)

这篇博文主要讲的是在Java面向对象设计中,我们应该遵循的六大原则。只有掌握了这几大原则,我们才能更好的理解设计模式。

Java面向对象设计的六大原则如下:

  • 单一职责原则——SRP

  • 开闭原则——OCP

  • 里式替换原则——LSP

  • 依赖倒置原则——DIP

  • 接口隔离原则——ISP

  • 迪米特原则——LOD

下面我们会通过具体的实例逐个讲解这几大原则。

一、单一职责原则(SRP)

单一职责原则定义接口或类和职责的关系是一一对应的,就一个类而言,应该有且仅有一个引起它变化的原因。也就是说一个类应该只负责一件事情。单一职责原则在日常开发中是经常用到的,以一个类举例,假如目前有2种动物,猫和狗,虽然它们之间是有相似的地方,但是我们在定义类的时候也会把它们区分成2个类;以接口举例,尽管List和Map都是用来存放数据的容器,但是它们的数据结构是不一样的,包含的方法也有区别,所以它们也被区分为2个接口。

1.1 遵循单一职责原则的好处

  1. 降低类或接口的复杂度。每个类或接口都只实现单一的职责,定义明确清晰,内部变量、方法等也容易理解;
  2. 代码可读性高。在类的定义和声明清晰的基础上,自然会带来较高的代码可读性;
  3. 代码可维护性高。代码可读性强,更容易理解,自然方便维护;类的职责单一,类之间耦合度低,所以更容易修改;
  4. 拓展性更好。有新的职责需要拓展,只需要新增成员或实现对应的接口即可。

1.2 实例分析

因为面向对象的编程是推崇面向接口编程的,所以我们对外暴露的方法也最好是以接口的形式定义,再由具体的类进行实现。下面就基于一个简单的场景来进行程序设计,满足单一职责原则。

我们知道苹果厂商早期生产Iphone4,是可以打电话、发短信的;后来发布了Iphone5,即可以打电话、发短信又可以支持指纹解锁;同时它还有一个产品是Ipad,可以指纹解锁,但是不能打电话、发短信。基于这个场景,我们来进行程序设计。

我们先定义几个接口,里面分别包含打电话、发短信、指纹解锁等方法,代码如下:

public interface CallInterface {
    public void call();
}
public interface MessageInterface {
    public void sendMessage();
}
public interface FingerPrintInterface {
    public void fingerPrint();
}

可以看到,这几个接口在定义的时候就根据功能划分设定了单一职责。然后我们再定义几个类,实现上面的几个接口,同样设定单一职责:

public class CallDevice implements CallInterface {
    @Override
    public void call() {
        System.out.println("我能打电话");
    }
}
public class MessageDevice implements MessageInterface {
    @Override
    public void sendMessage() {
        System.out.println("我能发短信");
    }
}
public class FingerPrintDevice implements FingerPrintInterface {
    @Override
    public void fingerPrint() {
        System.out.println("我能指纹解锁");
    }
}

CallDevice、MessageDevice、FingerPrintDevice相当于支持各种功能的零件,现在零件有了,我们需要生产设备了。我们首先生产Iphone4,它能打电话和发短信。打电话和发短信的零件已经有了,我们只需要装配上就可以。代码如下:

//在生产这台手机时我们就已经明确知道了它的功能(打电话和发短信)
public class Iphone4 implements CallInterface, MessageInterface {
    /**
     * 打电话的零件
     */
    private CallDevice callDevice = new CallDevice();
    /**
     * 发短信的零件
     */
    private MessageDevice messageDevice = new MessageDevice();

    @Override
    public void sendMessage() {
        messageDevice.sendMessage();
    }

    @Override
    public void call() {
        callDevice.call();
    }

    public static void main(String[] args) {
        Iphone4 iphone4 = new Iphone4();
        iphone4.call();
        iphone4.sendMessage();
    }

}

输出结果:

Java面试题剖析(基础篇) | 第五篇: 深入理解Java面向对象设计的六大原则(上)_第1张图片

这样,一台Iphone4就生产出来了。同理,Iphone5、Ipad也可以简单装配出来。假如Iphone4出新款了,打电话逻辑和之前不一样了,那么只需要再定义一个零件类,它采用 “新技术” 实现CallInterface接口中的call方法,这样在Iphone4装配时,打电话模块采用新的零件就可以了。这就体现了单一职责的好处,它对于现有类的修改造成的影响有了约束

1.3 扩展

通过上面的实例,我们可以对单一职责原则有一个更清晰的理解。其实在项目开发中,如果针对每个接口都提供一个实现类会导致类的数量很庞大,使用起来很不方便,所以,在上面实例的基础上,我们可以整合一些功能。

在下面的例子中,我们的接口依旧单一职责,但是接听和拨打电话的功能往往是不可分的,他们基本上是同时存在的。所以我们可以提供一个同时继承两个接口的实现类。代码如下:

public class CallAndMessageDevice implements CallInterface,MessageInterface{
    @Override
    public void sendMessage() {
        System.out.println("我即会发短信");
    }
    @Override
    public void call() {
        System.out.println("我又会打电话");
    }
}

1.4 注意点

  • 对于单一职责原则,接口一定要做到单一职责,类的设计尽量做到只有一个原因引起变化;
  • 单一职责原则的难点在于职责的划分,在不同情景和生产环境下我们对职责的细化是不同的。

二、开闭原则(OCP)

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

在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。开闭原则要求我们,当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。

2.1 遵循开闭原则的优势

  1. 通过扩展已有的软件系统,可以提供新的行为,以满足对软件的新需求,使变化中的软件系统有一定的适应性和灵活性;
  2. 已有的软件模块,特别是最重要的抽象层模块不能再修改,这就使变化中的软件系统有一定的稳定性和延续性;
  3. 这样的系统同时满足了可复用性与可维护性。

2.2 如何遵循开闭原则

  • 抽象约束
  1. 通过接口或者抽象类约束扩展,对扩展进行边界限定,不允许出现在接口或抽象类中不存在的public方法;
  2. 参数类型、引用对象尽量使用接口或者抽象类,而不是实现类;
  3. 抽象层尽量保持稳定,一旦确定即不允许修改。
  • 元数据(metadata)控制模块行为
  1. 元数据就是用来描述环境和数据的数据,通俗地说就是配置参数,参数可以从文件中获得,也可以从数据库中获得。
  2. Spring容器就是一个典型的元数据控制模块行为的例子,其中达到极致的就是控制反转(Inversion of Control)
  • 制定项目章程
  1. 在团队中,建立项目章程是非常重要的,因为章程中指定了所有人员都必须遵守的约定,对项目来说,约定优于配置。
  • 封装变化

       对变化的封装包含两层含义:

  1. 将相同的变化封装到一个接口或者抽象类中;
  2. 将不同的变化封装到不同的接口或抽象类中,不应该有两个不同的变化出现在同一个接口或抽象类中。

2.3 实例分析

下面我们举例说明什么是开闭原则。以书店销售书籍为例,其类图如下:

Java面试题剖析(基础篇) | 第五篇: 深入理解Java面向对象设计的六大原则(上)_第2张图片

IBook接口代码:

package com.moi.csdn;

public interface IBook {
    public String getName();
    public int getPrice();
    public String getAuthor();
}

NovelBook小说书籍类:

package com.moi.csdn;

public class NovelBook implements IBook{
    protected String name;
    protected int price;
    protected String author;
    public NovelBook(String name,int price,String author){
        this.name = name;
        this.price = price;
        this.author = author;
    }
    @Override
    public String getName(){
        return this.name;
    }
    @Override
    public int getPrice(){
        return this.price;
    }
    @Override
    public String getAuthor() {
        return this.author;
    }
}

Test类:

package com.moi.csdn;

public class Test {
    public static void main(String[] args) {
        IBook novel = new NovelBook("笑傲江湖",100,"金庸");
        System.out.println("书籍名字:"+novel.getName()+"\n书籍作者:"+novel.getAuthor()+"\n书籍价格:"+novel.getPrice());
    }
}

输出结果:

以上是针对初期需求实现的代码,功能方面是没有问题的。但是目前有一个新需求,在书籍销售的过程中,我们经常因为各种原因,要打折来销售书籍,这是一个需求上的变化。我们应该如何设计并修改我们的代码呢?

我们有下面三个方法来应对新需求:

  • 修改接口
    在IBook接口中,增加一个方法getOffPrice(),专门用于进行打折处理,所有的实现类实现此方法。但是这样的一个修改方式,实现类NovelBook要修改,同时IBook接口应该是稳定且可靠,不应该经常发生改变,否则接口作为契约的作用就失去了。因此,此方案否定。

  • 修改实现类
    修改NovelBook类的方法,直接在getPrice()方法中实现打折处理。此方法是有问题的,因为我们在对应新需求的时候也要保证老需求功能的实现。当然我们也可以再增加getOffPrice()方法,这也是可以实现新需求,但是这里面就有两个读取价格的方法,因此,该方案也不是一个最优方案。

  • 通过扩展实现类来解决
    我们可以增加一个子类OffNovelBook,重写父类NovelBook的getPrice()方法。此方法修改少,对现有的代码没有影响,风险小,满足开闭原则

修改后的类图:

Java面试题剖析(基础篇) | 第五篇: 深入理解Java面向对象设计的六大原则(上)_第3张图片

代码:

package com.moi.csdn;

public class OffNovelBook extends NovelBook{
    public OffNovelBook(String name,int price,String author){
        super(name,price,author);
    }
    // 重写价格方法,当价格大于40,就打8析,其他价格就打9析
    public int getPrice(){
        if(this.price > 40){
            return (int) (this.price * 0.8);
        }else{
            return (int) (this.price * 0.9);
        }
    }
}

上面针对打折销售的功能开发完成了,我们只是增加了一个OffNovelBook类,我们修改的代码都是在上层模块,没有修改底层模块,代码改变量少,可以有效的防止风险的扩散。

2.4 总结扩展

通常情况下,我们可以把需求变化归纳为两种类型:

  • 逻辑变化
    只变化了一个逻辑,而不涉及其他模块。比如一个算法是a*b*c,现在需要修改为a+b+c,可以通过直接修改原有类中的方法的方式来完成,前提条件是所有依赖或关联类都按照相同的逻辑处理。

  • 子模块变化
    一个模块变化,会对其它的模块产生影响。特别是一个底层的模块变化必然引起其上层模块的变化,因此可以通过扩展实现该变化。

事实上,开闭原则无非就是想表达这样一层意思:用抽象构建框架,用实现扩展细节。当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化。

三、里式替换原则(LSP)

里式替换原则严格的定义:如果对每一个类型为 T1的对象 o1,都有类型为 T2 的对象o2,使得以 T1定义的所有程序 P 在所有的对象 o1 都代换成 o2 时,程序 P 的行为没有发生变化,那么类型 T2 是类型 T1 的子类型。

里式替换原则通俗的定义:所有引用基类(父类)的地方必须能透明地使用其子类的对象。

里氏代换原则告诉我们,在软件中将一个基类对象替换成它的子类对象,程序将不会产生任何错误和异常。反过来则不成立,如果一个软件实体使用的是一个子类对象的话,那么它不一定能够使用基类对象。例如:我喜欢动物,那我一定喜欢狗,因为狗是动物的子类;但是我喜欢狗,不能据此断定我喜欢动物,因为我并不喜欢老鼠,虽然它也是动物。

所以说,里氏替换原则其实就是为“良好的继承”制定一些规范。

3.1 继承的优缺点

既然 里氏替换原则 是解决继承带来的问题,那么我们先总结一下使用继承的优缺点。

3.1.1 继承的优点

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

3.1.2 继承的缺点

  • 继承是侵入性的,只要继承就必须拥有父类的所有属性和方法;
  • 降低代码的灵活性,子类必须拥有父类的属性和方法,让子类增加了约束;
  • 增强了耦合性,当父类的常量、变量和方法被修改时,必须考虑子类的修改。

3.2 良好的继承规范

上面我们总结了继承的优缺点,也提出了 里氏替换原则 是为“良好的继承”制定一些规范,那么具体有哪些规范呢?

1、子类要完全实现父类的抽象方法,但尽量不要覆盖父类的非抽象方法。

这一点很容易理解,如果子类覆盖了父类的非抽象方法,当使用子类代替父类时,程序行为可能会有所改变

举例:我们知道,计算器可以用来计算加减乘除。现在我们定义一个计算器类,里面实现非抽象的计算方法。

package com.moi.csdn;

public class Calculator {
    public int calculate(int num1, int num2) {
        return num1 + num2;
    }
}

然后我们再定义一个子类,继承Calculator类,它里面也实现计算方法,覆盖父类的方法。

package com.moi.csdn;

public class MiniCalculator extends Calculator{
    public int calculate(int num1, int num2) {
        return num1 - num2;
    }
}

可以看到,父类和子类都有calculate方法,父类是加法实现,子类是减法实现。我们再写一个Test类:

package com.moi.csdn;

public class Test {
    public static void main(String[] args) {
        Calculator calculator = new Calculator();
        System.out.println(calculator.calculate(10, 5));
        
        MiniCalculator calculator2 = new MiniCalculator();
        System.out.println(calculator2.calculate(10, 5));
    }
}

输出结果如下:

可以看到,由于子类MiniCalculator重写了父类Calculator的calculate方法,当子类MiniCalculator代替父类Calculator时,导致计算结果有误,也就是程序的行为发生了变化。所以对父类的非抽象方法,尽量不要覆盖重写

2、子类中可以增加自己特有的方法。

子类一般都会有自己特有的属性或方法,这个无须赘述。

3、当子类的方法重载父类的方法时,方法的前置条件(即方法的形参)要比父类方法的输入参数要更抽象化。

举例说明,首先我们定义一个父类,Father类:

package com.moi.csdn;
import java.util.HashMap;
public class Father {
    public void doSomething(HashMap map){
        System.out.println("父类被执行");
    }
}

可以看到,Father类中doSomething方法中接收的是HashMap。下面定义子类Son:

package com.moi.csdn;
import java.util.Map;
public class Son extends Father{
    public void doSomething(Map map){
        System.out.println("子类被执行");
    }
}

最后是Test类:

package com.moi.csdn;
import java.util.HashMap;
import java.util.Map;
public class Test {
    public static void main(String[] args) {
        Father father = new Father();
        HashMap map = new HashMap();
        father.doSomething(map);

        Son son = new Son();
        Map map2 = null;
        son.doSomething(map2);
    }
}

执行结果如下:

可以看到,父类和子类分别执行。在这个例子中,子类方法的形参要比父类方法的形参更加抽象化。反之,则调用的都是父类方法,那么子类的方法重写就失去了意义。具体代码就不贴出来了,读者可以自行编码验证。

4、当子类的方法实现父类的抽象方法时,方法的后置条件(即方法的返回值)要比父类更具体化。

这个规范是必须要遵守的,否则会报编译错误。在上面的例子中我们给Father类doSomething方法加一个HashMap返回值,如果我们在Son类doSomething方法中返回Map,那么就会报错。如下:

Java面试题剖析(基础篇) | 第五篇: 深入理解Java面向对象设计的六大原则(上)_第4张图片

3.3 实例分析

下面我们举例分析 里式替换原则 的用法。需求如下:

在Sunny软件公司开发的CRM系统中,客户(Customer)可以分为VIP客户(VIPCustomer)和普通客户(CommonCustomer)两类,系统需要提供一个发送Email的功能,无论是普通客户还是VIP客户,发送邮件的过程都是相同的,而且在本系统中还将增加新类型的客户。为了让系统具有更好的扩展性,同时减少代码重复,我们需要使用里氏代换原则进行设计开发。

在本实例中,可以定义一个抽象客户类Customer,同时将CommonCustomer和VIPCustomer类作为其子类,邮件发送类EmailSender类针对抽象客户类Customer编程,根据里氏代换原则,能够接受基类对象的地方必然能够接受子类对象,因此将EmailSender中的send()方法的参数类型改为Customer,如果需要增加新类型的客户,只需将其作为Customer类的子类即可。结构设计图如下:

Java面试题剖析(基础篇) | 第五篇: 深入理解Java面向对象设计的六大原则(上)_第5张图片

开始编码。首先我们定义抽象类Customer:

package com.moi.csdn;
public abstract class Customer {
    public abstract String getName();
}

getName()方法用于区分发送Email的对象。

然后分别定义CommonCustomer和VIPCustomer继承Customer,并分别实现getName()方法:

package com.moi.csdn;
public class CommonCustomer extends Customer{
    @Override
    public String getName() {
        return "普通客户";
    }
}
package com.moi.csdn;
public class VIPCustomer extends Customer{
    @Override
    public String getName() {
        return "VIP客户";
    }
}

再定义EmailSender类,声明send方法,接收Customer参数用于发送邮件:

package com.moi.csdn;
public class EmailSender {
    public void send(Customer c){
        System.out.println("发送给:"+c.getName());
    }
}

最后是Test类及运行结果:

package com.moi.csdn;
public class Test {
    public static void main(String[] args) {
        EmailSender sender = new EmailSender();
        sender.send(new CommonCustomer());
        sender.send(new VIPCustomer());
    }
}

后续如果需要增加新类型的客户,只需将其作为Customer类的子类即可。

3.4 总结扩展

里氏代换原则是实现开闭原则的重要方式之一,由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。

      在使用里氏代换原则时需要注意如下几个问题:

  1. 子类的所有方法必须在父类中声明,或子类必须实现父类中声明的所有方法。根据里氏代换原则,为了保证系统的扩展性,在程序中通常使用父类来进行定义,如果一个方法只存在子类中,在父类中不提供相应的声明,则无法在以父类定义的对象中使用该方法;
  2. 我们在运用里氏代换原则时,尽量把父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法,运行时,子类对象替换父类对象,我们可以很方便地扩展系统的功能,同时无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。里氏代换原则是开闭原则的具体实现手段之一;
  3. Java语言中,在编译阶段,Java编译器会检查一个程序是否符合里氏代换原则,这是一个与实现无关的、纯语法意义上的检查,但Java编译器的检查是有局限的。

由于篇幅有限,本文先总结到这里,另外3大原则(依赖倒置原则、接口隔离原则、迪米特原则)将在后续博文中推出。

如有任何疑问,可关注公众号留言,工程师将尽快回答您的问题。公众号二维码:

                                        

 

 

你可能感兴趣的:(Java面试题剖析,Java面试题剖析)