设计原则

前言

设计原则有

  • 开闭原则
  • 依赖倒置原则
  • 单一职责原则
  • 接口隔离原则
  • 迪米特法则
  • 里氏替换原则
  • 合成复用原则

每个原则都有自己的焦点,在我们实际开发过程中,讲究的是一个平衡,我们要考虑人力、时间、成本、资量、包括有些项目是有dealline的,还有业务的扩展性。如果一开始把扩展性做的特别特别完美的话,那成本又上来了。所以追寻设计原则也不要过度,在适当的场景去追寻。
比如设计模式中就可以看到这些设计原则的影子,同时在某些设计原则中并不是完全的遵守着7大原则的,体现的就是一个取舍的问题,有些设计模式可能追寻两样到三样,而破坏一样两样,最重要的是我们找到合适的业务场景。所以设计原则不是强行遵守的,而是要讲究一个度一个平衡、一个取舍。

开闭原则

一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。所谓的开闭也是对扩展和修改两个行为的原则,强调的是用抽象构建框架,用实现扩展细节。
优点是提高软件系统的可复用性及可维护性。开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统,例如版本更新中尽量不修改原代码,但是可以增加新功能。
在实际生活中对开闭原则还有个体现,例如我们很多互联网公司都是弹性制,所谓的弹性制是指每天工作8小时是固定的,但是对于什么时候来什么时候走,这个制度是开放的,如果早点来就可以早点走,晚点来就要晚点走,总之要满足工作8小时。这也是生活中对开闭原则的一个体现。

面向抽象编程

实现开闭原则的核心思想就是面向对象的面向抽象编程。打个比方,我们对于校验的逻辑,一般分顺序——先校验什么后校验什么再校验什么然后在校验什么,如果我们代码模块之间设计的好,那对于新增一个校验规则是要开放的,我们新增的话尽量不要修改原来的校验规则代码,以免易于新的风险。
抽象相对来说是稳定的,让类去依赖固定的抽象,对修改来说就是封闭的。通过面向对象的继承、多态的机制,就可以实现对抽象体的继承了。通过重写改变其固有方法,或者实现新的扩展方法,当变化发生时,我们可以创建抽象来隔离以后有可能发生的同类变化。
关键的核心在于实现抽象化。我们怎么从业务场景中抽象出来业务模型,并且从抽象化得出具体化的一个实例。

coding

一个课程类的示例

下面是一个课程的接口

package com.design.principle.openclose;

/**
 * 课程
 */
public interface ICourse {
    Integer getId();
    String getName();
    Double getPrice();

}	

课程有很多种类,下面是一个java分类的课程

package com.design.principle.openclose;

public class JavaCourse implements ICourse{
    private Integer id;
    private String name;
    private Double price;

    public JavaCourse(Integer id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Integer getId() {
        return this.id;
    }

    public String getName() {
        return this.name;
    }

    public Double getPrice() {
        return this.price;
    }
}

接着是测试代码

package com.design.principle.openclose;

public class Test {
    public static void main(String[] args) {
        ICourse javaCourse=new JavaCourse(96,"java从零到企业级电商开发",238d);
        System.out.println("课程ID:"+javaCourse.getId()+"课程名称:"+javaCourse.getName()+"课程价格:"+javaCourse.getPrice()+"元");

    }
}

运行之后的结果:
在这里插入图片描述
上面示例的类图结构如下图:
在这里插入图片描述

课程示例的更新

现在有活动了,比如双11或618要进行一个打折活动,那我们怎么来开发这个需求呢?

违反开闭原则的开发

是不是我们在接口ICourse里增加一个 方法

package com.design.principle.openclose;

/**
 * 课程
 */
public interface ICourse {
    Integer getId();
    String getName();
    Double getPrice();
	Double getDiscountPrice();
}	

如上,我们增加了这个方法,getPrice() 是获取课程的原价,getDiscountPrice()是获取课程的打折价格,那我们JavaCourse的类就要进行修改了,至少要实现getDiscountPrice()

package com.design.principle.openclose;

public class JavaCourse implements ICourse{
    private Integer id;
    private String name;
    private Double price;

    public JavaCourse(Integer id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Integer getId() {
        return this.id;
    }

    public String getName() {
        return this.name;
    }

    public Double getPrice() {
        return this.price;
    }
	
    /**
     * 打折价格(假设现在是打8折)
     * @return
     */
    public Double getDiscountPrice() {
        return this.price*0.8;
    }
}

我们看下上面的写法,首先我们修改了接口,然后这个类也进行了一个实现,假设我们课程的类型很多,那所有的课程实现类都要实现一下这个方法,那如果类少呢我们也就忍了,但是有一点需要提——我们的接口是不应该经常变化的,它应该是稳定且可靠的,否则接口做为契约,这个作用也就失去了。

遵守开闭原则的开发

我们再换一种思路,在接口中把方法getDiscountPrice()去掉,在JavaCourse类中getPrice()中,直接乘以0.8(8折),看起来是我们更简单的完成了这个需求。

package com.design.principle.openclose;

public class JavaCourse implements ICourse{
    private Integer id;
    private String name;
    private Double price;

    public JavaCourse(Integer id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Integer getId() {
        return this.id;
    }

    public String getName() {
        return this.name;
    }
	
	/**
	*直接修改getPrice方法的实现
	*/
    public Double getPrice() {
        return this.price*0.8;
    }
    
    

//    /**
//     * 打折价格
//     * @return
//     */
//    public Double getDiscountPrice() {
//        return this.price*0.8;
//    }
}

但是我们种写法是否满足需求了呢?假设需求还要求把原价显示出来,这个getPrice()方法是获取不到原价的,再往复杂了说,如果我们课程大于300元的活动才进行打8折,后续再引进一些优惠券的活动又怎么办?上面的写法还不能完成需求,我们将 getPrice()修改回来this.price;
那我们为什么最开始的Course是个接口呢?这里面也是要演示我们要面向接口编程,然后继续来看我们这个类JavaCourse,如果我们通过扩展再写一个其的子类呢?

package com.design.principle.openclose;

public class JavaDiscountCourse  extends JavaCourse{
    public JavaDiscountCourse(Integer id, String name, Double price) {
        super(id, name, price);
    }

    /**
     * 获取原价
     * @return
     */
    public Double getOriginPrice(){
        return super.getPrice();
    }
    
    @Override
    public Double getPrice() {
        return super.getPrice()*0.8;
    }
}

在这里插入图片描述
虚线是实现接口,实线是实现继承,而我们这个子类的构造器是调用父类的构造器,下面跟着的就是方法。
现在打折销售的需求已经开发完了,我们修改的是偏应用层的代码,而底层的接口和底层的基类我们并没有修改,这样也防止了风险的一个扩撒。也就是说,如果ICourse接口里有很多的方法,实现类JavaCourse里面逻辑又非常复杂,那如果我们改折后价的话有可能修改到这里面的实现,甚至是其它的实现,这在开发过程中都是容易一起bug的。而我们通过继承了一个基类的方式,使我们对于这个扩展是开放的,对于修改基类和接口是关闭的,变化的都是应用层的一个子模块。

越基层的模块影响的范围是越大的,越高级的模块变化影响的范围会越小,简单的理解如果一个dao层变化了,这个dao层被很多层使用,这个影响就非常大,有可能我为了改a模块影响了b模块。所以我们在面向对象编程的时候,我们一定要强调开闭原则,其它几个原则也都是开闭原则的具体形态。首先提高了我们的复用性,提高了我们的可维护性,因为在开发过程中并不是我们一个人单打独斗,我们要考虑软件的维护成本。如果开发人员都采用我们的开闭原则,我们项目维护起来就相当容易,成本也非常低。

依赖倒置原则

定义:高层模块不应该依赖低层模块,二者都应该依赖其抽象。

定义的补充:抽象不应该依赖细节,细节应该依赖抽象;针对接口编程,不要针对实现编程。

我们使用抽象,包括使用接口或者抽象类,可以使各个类或模块的实现彼此独立互不影响,从而实现模块间的松耦合,降低模块间的耦合性。使用依赖倒置的时候,还有些注意的点,比如说每个类尽量都继承自接口或抽象类。

优点:可以减少类间的耦合性、提高系统稳定性,提高代码可读性和可维护性,可降低修改程序所造成的风险。

coding

违反依赖倒置原则的开发

假如一个用户在个视频网上学习,对java的课程比较感兴趣,也对前端的课程感兴趣,首先我们创建一个类

package com.design.principle.dependenceinversion;

public class Xiaoming {

    public void studyJavaCourse(){
        System.out.println("Xiaoming在学习Java课程");
    }

    public void studyFECourse(){
        System.out.println("Xiaoming在学习FE课程");
    }
}

再写一个测试类

package com.design.principle.dependenceinversion;

public class Test {
    public static void main(String[] args) {
        Xiaoming xiaoming=new Xiaoming();
        xiaoming.studyJavaCourse();
        xiaoming.studyFECourse();;
        
    }
}

假如现在我们要学习一个python课程,就要在Xiaoming类里再加

package com.design.principle.dependenceinversion;

public class Xiaoming {

    public void studyJavaCourse(){
        System.out.println("Xiaoming在学习Java课程");
    }

    public void studyFECourse(){
        System.out.println("Xiaoming在学习FE课程");
    }

    public void studyPythonCourse(){
        System.out.println("Xiaoming在学习Python课程");
    }
}

这时候我们体会下,现在的编程就是面向实现编程,因为整个Xiaoming就是一个实现类,我们发现这个实现类是要经常修改的,扩展的性能非常差。也就是说,我们应用层的代码是依赖于底层实现的,因为我们没有抽象造成我们应用层(test类,高层模块)的这个类及函数依赖于Xiaoming(底层模块)这个类的,Test类要实现什么,我们都要在Xiaoming这个类里进行补充。

遵守依赖倒置原则的开发

我们引入抽象看看,创建一个课程接口,里面一个学习课程的方法

package com.design.principle.dependenceinversion;

public interface ICourse {
    void studyCourse();
}

上面课程具体是什么课程,我们交给应用层Test类来实现,再来创建实现类

package com.design.principle.dependenceinversion;

public class FECourse implements ICourse {

    public void studyCourse() {
        System.out.println("Xiaoming在学习FE课程");

    }
}
package com.design.principle.dependenceinversion;

public class JavaCourse implements ICourse {
    public void studyCourse() {
        System.out.println("Xiaoming在学习Java课程");
    }
}

小明类也修改下

package com.design.principle.dependenceinversion;

public class Xiaoming {
    public void studyCourse(ICourse iCourse){
        iCourse.studyCourse();
    }
}

上面代码Xiaoming学习课程时候交给搞层的实现类来实现,而不是Xiaoming本身。再看test代码

package com.design.principle.dependenceinversion;

public class Test {
    //v1
//    public static void main(String[] args) {
//        Xiaoming xiaoming=new Xiaoming();
//        xiaoming.studyJavaCourse();
//        xiaoming.studyFECourse();;
//
//    }
    public static void main(String[] args) {
        Xiaoming xiaoming=new Xiaoming();
        xiaoming.studyCourse(new JavaCourse());
        xiaoming.studyCourse(new FECourse());
    }
}

我们再看下类图
在这里插入图片描述
通过上面类图,可以看出JavaCourse、FECourse是平行并列的,课程的扩展是面向ICourse接口的,而不是面向Xiaoming这个实现类。对于高层模块Test,我们具体学习什么课程是交给Test来选择的,例如学习Python课程就可以交给Test模块,这样就做到了Xiaoming和Test间是解耦的,Xiaoming同具体的课程实现是解耦的,同时JavaCourse、FECourse等和ICourse接口是耦合的,所谓的高内聚低耦合。

接下来的扩展就简单了,比如还想学习Python的课程,再创建一个类PythoCourse

package com.design.principle.dependenceinversion;

public class PythonCourse  implements ICourse{

    public void studyCourse() {
        System.out.println("Xiaoming在学习Python课程");
    }
}

Test在只要加一行Xiaoming.studyCourse(new Python)就可以了。

通过构造器的方式来注入具体的实现

上面是通过接口方法注入的方式来实现的,我们还可以通过构造器的方式注入Xiaoming具体的实现。

package com.design.principle.dependenceinversion;

public class Xiaoming {
    private ICourse iCourse;
    
    public Xiaoming(ICourse iCourse){
        this.iCourse=iCourse;
    }
    
    public void studyCourse( ){
        iCourse.studyCourse();
    }
}

再看Test中如何使用

package com.design.principle.dependenceinversion;

public class Test {
    //v1
//    public static void main(String[] args) {
//        Xiaoming xiaoming=new Xiaoming();
//        xiaoming.studyJavaCourse();
//        xiaoming.studyFECourse();;
//
//    }
    //v2
//    public static void main(String[] args) {
//        Xiaoming xiaoming=new Xiaoming();
//        xiaoming.studyCourse(new JavaCourse());
//        xiaoming.studyCourse(new FECourse());
//    }
    public static void main(String[] args) {
        Xiaoming xiaoming=new Xiaoming(new JavaCourse());
        xiaoming.studyCourse();
        xiaoming=new Xiaoming(new FECourse());
        xiaoming.studyCourse();
    }
}

Tset中Xiaoming学习了两门课程就new了两次,因为这种注入方式只有在构造的时候才能注入进去,在Xiaoming类里并没有开放对iCourse属性的注入,现在我们开放出去

使用setter注入的方式
package com.design.principle.dependenceinversion;

public class Xiaoming {
    private ICourse iCourse;

    public ICourse getiCourse() {
        return iCourse;
    }

    public void setiCourse(ICourse iCourse) {
        this.iCourse = iCourse;
    }

    public void studyCourse( ){
        iCourse.studyCourse();
    }
}

Test类

package com.design.principle.dependenceinversion;

public class Test {
    //v1
//    public static void main(String[] args) {
//        Xiaoming xiaoming=new Xiaoming();
//        xiaoming.studyJavaCourse();
//        xiaoming.studyFECourse();;
//
//    }
    //v2
//    public static void main(String[] args) {
//        Xiaoming xiaoming=new Xiaoming();
//        xiaoming.studyCourse(new JavaCourse());
//        xiaoming.studyCourse(new FECourse());
//    }
    //v3
//    public static void main(String[] args) {
//        Xiaoming xiaoming=new Xiaoming(new JavaCourse());
//        xiaoming.studyCourse();
//          xiaoming=new Xiaoming(new FECourse());
//        xiaoming.studyCourse();
//    }
    public static void main(String[] args) {
        Xiaoming xiaoming=new Xiaoming();
        xiaoming.setICourse(new JavaCourse());
        xiaoming.studyCourse();
        
        xiaoming.setICourse(new FECourse());
        xiaoming.studyCourse();
    }
}

现在的类图
在这里插入图片描述
Xiaoming 这个类是不依赖于具体的JavaCourse、FECourse的,Xiaoming想学什么课都可以在不动它的情况下进行增加学习课程。Xiaoming是相对于ICourse和FECourse、JavaCourse等更高层的一个类,具体学习什么课程不需要动,只需要在低层模块进行扩展,这也符合开闭原则。
依赖倒置的原则就是高层次的模块不应该依赖低层次的模块,依赖倒置原则还能表达出什么样的事实呢?相对于细节的多边性抽象的东西要稳定的多,以抽象为基础搭建起来的架构比细节为基础搭建起来的架构要稳定的多。

单一职责原则

定义:不要存在多于一个导致类变更的原因

这句话怎么理解?假设有一个类负责两个职责,职责1和职责2 ,如果需求变更职责1需要发生相应的改变,职责1修改这个类后有可能导致原本能正常运行的职责2发生故障,这就是因为我们在构建这个类的时候没有遵循单一职责原则。

体现的方面:一个类/接口/方法只负责一项职责

优点:降低类的复杂度,提高类的可读性,提高系统的可维护性、降低变更引起的风险
降低负责度,一个类只有一项职责,肯定比负责多项职责简单的多
提高类的可读性,如果这个类更简单也就更好阅读,更容易维护
降低变更引起的风险,首先变更是必然的,我们要接收变更,那如果单一职责遵守的好,在变更时候可以显著降低对其它功能的影响

coding

不遵循单一原则

package com.design.principle.singleresponsibility;

public class Bird {
    public void mainMoveMode(String birdName){
        System.out.println(birdName+"用翅膀飞");
    }
}

Test类

package com.design.principle.singleresponsibility;

public class Test {
    public static void main(String[] args) {
        Bird bird=new Bird();
        bird.mainMoveMode("大雁");
        bird.mainMoveMode("鸵鸟");
    }
}

这个时候,我们发现鸵鸟用翅膀飞是不对的,因为鸵鸟飞不起来。我们是不是要在Bird中做一个判断呢?从日常的开发需求来说,在Bird类的mainMoveMode方法中修改是最快的方式,我们在工作中还要考虑开发的成本、进度、deadline等等,完全遵循单一原则呢有时候还真是要看实际情况的,但是我们应该有一颗按原则写代码的心,在条件允许的情况遵守这些设计原则。

package com.design.principle.singleresponsibility;

public class Bird {
    public void mainMoveMode(String birdName){
        if("鸵鸟".equals(birdName)){
            System.out.println(birdName+"用脚走");
        }else {
            System.out.println(birdName + "用翅膀飞");
        }
    }
}

如果继续有特殊的鸟类,我们这个方法还要继续扩展。单一原则有个主要的特点就是变更时风险率降低,现在是不遵循单一原则的,所以我们刚刚加了个鸵鸟风险还是非常大。

尊循单一原则

类的角度

我们将鸟拆分为会飞的和走路的

package com.design.principle.singleresponsibility;

public class FlyBird {
    public void mainMoveMode(String birdName){
        System.out.println(birdName + "用翅膀飞");
    }
}
package com.design.principle.singleresponsibility;

public class WalkBird {
    public void mainMoveMode(String birdName){
        System.out.println(birdName+"用脚走");
    }
}

看下Test类

package com.design.principle.singleresponsibility;

public class Test {
    public static void main(String[] args) {
//        Bird bird=new Bird();
//        bird.mainMoveMode("大雁");
//        bird.mainMoveMode("鸵鸟");

        FlyBird flyBird=new FlyBird();
        flyBird.mainMoveMode("大雁");

        WalkBird walkBird=new WalkBird();
        walkBird.mainMoveMode("鸵鸟");

    }
}

我们把一个类进行拆分,这样每个类里的方法职责是单一的,这样比较简单也不至于修改的时候引入新的问题,我们看下类图
在这里插入图片描述

接口的角度

下面一个课程的接口

package com.design.principle.singleresponsibility;

public interface ICourse {
    String getCourseName();
    byte[] getCourseVideo();

    void studyCourse();
    void refundCourse();
}

我们来看一下这个接口,对于一个课程可以获取名称、获取视频的字节流,还可以学习课程、退款课程。要在单一职责上说的话,ICourse可不是只有一个单一职责,首先是有个获取课程信息,另一个职责是管理课程,和课程内容无关。例如学习课程。我们把这个接口拆分成两个,

package com.design.principle.singleresponsibility;

public interface ICourseManager {
    void studyCourse();
    void refundCourse();
}
package com.design.principle.singleresponsibility;

public interface ICourseContent {
    String getCourseName();
    byte[] getCourseVideo();
}

再写一个课程实现类

package com.design.principle.singleresponsibility;

public class CourseImpl implements ICourseContent,ICourseManager{

    public String getCourseName() {
        return null;
    }

    public byte[] getCourseVideo() {
        return new byte[0];
    }

    public void studyCourse() {

    }

    public void refundCourse() {

    }
}

我们再看下类图
在这里插入图片描述
课程的实现类实现两个接口,这两个接口的职责也是单一的。也就是说我们可以通过实现一个接口或者多个接口来组合出这个接口的实现,我们这个实现类实现哪些接口都是清晰明确的,可读性提高了,也就更容易维护了,同时变更引起的风险降低,维护的风险只对相应的实现类有影响。

方法级别
package com.design.principle.singleresponsibility;

public class Method {
    /**
     * 更新用户名和地址
     * @param userName
     * @param address
     */
    private void   updateUserInfo(String userName,String address){
        //下面是伪代码
        userName="Xiaoming";
        address="beijing";
    }

    /**
     * 更新用户名和其它的属性
     * @param userName
     * @param proertities
     */
    private void updateUserInfo(String userName,String ... proertities){
        //伪代码
    }
}

上面两个方法里面的职责都不是单一的,更好的方式是下面这样

package com.design.principle.singleresponsibility;

public class Method {
 
    private void updateUserName(String userName){
        //伪代码
    }
    
    private void updateAddress(String address){
        //伪代码
    }
}

这样更新用户名和地址时候,调用对应的方法就可以。

总结

类的单一职责原则和接口、方法的是一样的,但是我们在实际开发中,很多类都不符合,这是因为我们在创建一个类的时候包括依赖、组合、聚合这些关系受很多因素的影响,包括我们项目的规模,还有项目的周期,技术人员的水平,还有对进度的把控,是否有deadline等等,这些都是一个平衡的因素。另外有一个考虑,就是我们在扩展的时候,如果我们没有面向接口编程,而又非常良好的遵循单一职责,有可能引起类的爆炸——类的数量非常的多,所以在总结起来,在实际的开发中,我们的接口和方法一定要做到单一职责,因为这个还是蛮好的,对我们维护起来也想对简单,同时它的成本非常低,那类的单一职责遵循情况就看实际的项目情况。

接口隔离原则

定义:用多个专门的接口,而不使用单一的总接口,客户端不应该依赖它不需要的接口

注意

  • 一个类对一个类的依赖应建立在最小的接口上
    意思是我有一个大接口,里面很多很多方法,我们用一个类来实现这些接口的话,所有的方法都要实现。这里说的类也指interface。
  • 建立单一接口,不要建立庞大臃肿的接口
  • 尽量细化接口,接口中的方法尽量少
  • 注意适度原则,一定要适度
    上面说接口中的方法要尽量少,但是又要有限度,那我们对接口进行细化肯定是可以提高程序设计的灵活性,但是如果接口设计的过小,也就是里面的方法过少,则会造成接口数量过多,提高整个程序设计的复杂性,所以要适度。

优点:符合我们常说的高内聚低耦合的设计思想,从而使得类具有很好的可读性、可扩展性和可维护性

我们在设计接口的时候,我们只暴露调用的类它需要的方法,它不需要的方法则隐藏起来,只有专注的为一个模块提供定制服务,才能建立最小的依赖关系,这种就从一定程度上减少了耦合。
提高内聚怎么理解呢?减少对外的交互,使接口中最少的方法去完成最多的事情,这个就是高内聚的一个体现。那我们运用接口隔离原则一定要适度,接口设计的过大或者过小都不好,所以我们在设计接口的时候,要多花些时间去思考和筹划,才能准确的实现这一原则。在实际的项目开发中在实践接口隔离原则的时候我们要多考虑业务模型,包括有可能以后会发生变更的地方,这些也有做一些预判,所以对于抽象我们业务模型是非常重要的。

coding

不符合接口隔离原则

我们写一个接口

package com.design.principle.interfacesegregation;

public interface IAnimalAction {
    void eat();
    void fly();
    void swim();
}

再写一个实现类Dog

package com.design.principle.interfacesegregation;

public class Dog implements IAnimalAction {
    public void eat() {

    }
    public void fly() {

    }
    public void swim() {

    }
}

狗吃是没有问题的,游泳也是没有问题的,会飞吗?不会。可实现IAnimalAction接口fly()是必须得写的,也就是说它可以有个空实现。我们再写一个实现类Bird

package com.design.principle.interfacesegregation;

public class Bird implements IAnimalAction {


    public void eat() {

    }

    public void fly() {

    }

    public void swim() {

    }
}

鸟能吃能飞不能游泳,所以在Bird这个实现类里还是会有一些空的实现放在里面。看下下面的类图,Bird和Dog类将IAnimalAction接口的方法都实现了
在这里插入图片描述

我们实际开发过程中也经常遇到这种情况,里面声明的过多,并且它们是不同类型的,也就是说我们可以更细化来进行编写,这样通过实现单个或多个接口来编写,就是接口隔离原则的体现。

符合接口隔离原则

我们将IAnimalAction接口进行拆分,变成下面这样

package com.design.principle.interfacesegregation;

public interface IEatAnimalAction {
    void eat();
}
package com.design.principle.interfacesegregation;

public interface IFlyAnimalAction {
    void fly();
}
package com.design.principle.interfacesegregation;

public interface ISwimAnimalAction {
    void swim();
}

再写下Dog的实现类,

package com.design.principle.interfacesegregation;

public class Dog implements ISwimAnimalAction,IEatAnimalAction {
    public void eat() {

    }

    public void swim() {

    }
}

上面这样Dog是不需要实现fly()方法的,只需要实现eat()和swim(),下面是类图
在这里插入图片描述
同级别的接口有3个,Dog实现了IEatAnimalAction的eat()方法,实现了ISwimAnimalAction的swim()方法,这个时候我们的实现类就相对于第一种写法显得更为灵活了,同时对接口进行了隔离,因为细粒度是可以组装的,而粗粒度是不可以拆分的。

与单一职责原则的区别

接口隔离原则和单一职责原则,是不是很像呢?

首先单一职责原则指的是类、接口、方法的职责是单一的,强调的是职责,也就是在一个接口里只要职责是单一的,有多个方法也可以,例如吃有很多吃法,又如游泳可以狗刨等等。
接口隔离原则注重的是对接口依赖的隔离。单一职责原则约束的是类、方法和接口,它针对的是程序中的实现和细节,接口隔离原则主要约束的是接口,是针对抽象,对整体框架的一个构建。

总结

在实际开发中也要注意一个点——接口尽量小,但是要有限度,如果太小的话导致接口数量过多,设计也会变的更复杂。在实际的使用中,我们要结合各个因素选择一定的平衡,还有这个项目的一个规模。另外需要注意的一点是提高内聚,使用最少的方法实现最多的事情。

迪米特原则

定义:一个对象应该对其它对应保持最少的了解。又叫最少知道原则。

主要强调的是尽量降低类与类之间的耦合

优点:降低类之间的耦合

从代码层面上,最少知道是指该知道知道不该知道不知道,尽量少定义public方法和非静态的public变量,尽量内敛,多使用包权限和private权限。迪米特的核心观念就是类之间的解耦,解耦是有一定程度的,我们尽量做到弱耦合,只有耦合越低类的复用率才会越高。因为我们降低了每个类之间的依赖,降低耦合的关系,但是凡事都要有个度,过分的使用迪米特原则,会导致产生大量的中介类,导致系统变复杂,为维护带来难度。

强调:只和朋友交流,不和陌生人说话
朋友:出现在成员变量、方法的输入、输出参数中的类称为成员朋友类,而出现在方法体内部的类不属于朋友类。

coding

需求,一个大老板给teamleader说,“查下现在的课程有多少在线课程”

不遵守迪米特原则

课程类

package com.design.principle.demeter;

public class Course {
}

TeamLeader类

package com.design.principle.demeter;

import java.util.List;

public class TeamLeader {
    public  void checkNumberOfCourses(List<Course> courseList){
        System.out.println("在线课程的数量是:"+courseList.size());
    }
}

Boss类

package com.design.principle.demeter;

import java.util.ArrayList;
import java.util.List;

public class Boss {
    public void commandCheckNumber(TeamLeader teamLeader){
        List<Course> courseList=new ArrayList<Course>();
        //假设有20门
        for(int i=0;i<20;i++){
            courseList.add(new Course());
        }
        teamLeader.checkNumberOfCourses(courseList);
    }
}

再写一个Test的测试类

package com.design.principle.demeter;

public class Test {
    public static void main(String[] args) {
        Boss boss=new Boss();
        TeamLeader teamLeader=new TeamLeader();
        boss.commandCheckNumber(teamLeader);
    }
}

我们分析下,迪米特原则强调的是只和直接的朋友交流,比如说Boss,teamleader做为它的一个入参是直接的朋友,在commandCheckNumber方法中的Course类不算朋友。也就是说Boss给teamleader下指令,teamleader查出结果直接给boss就可以了,Boss不应该和陌生的Course发生交流。
在这里插入图片描述

遵守迪米特原则

将Boss里的Course挪到Teamleader中,

package com.design.principle.demeter;

import java.util.ArrayList;
import java.util.List;

public class TeamLeader {
    public  void checkNumberOfCourses( ){
        List<Course> courseList=new ArrayList<Course>();
        //假设有20门
        for(int i=0;i<20;i++){
            courseList.add(new Course());
        }
        System.out.println("在线课程的数量是:"+courseList.size());
    }
}

现在的Boss类就清爽多了,下指令给teamLeader,Boss不再和Course接触,TeamLeader跟Course接触

package com.design.principle.demeter;

import java.util.ArrayList;
import java.util.List;

public class Boss {
    public void commandCheckNumber(TeamLeader teamLeader){
        teamLeader.checkNumberOfCourses( );
    }
}

看下类图
在这里插入图片描述

迪米特法则理解起来,也是相当容易,最主要的是能分清那些类是直接的朋友,哪些类不是朋友,只要能遵循这些,我们在进行开发的时候也就游刃有余了。

里氏替换

这是由麻省理工学院的一位姓里的女士提出来的,所以叫里氏替换原则。
定义:如果对每一个类型为T1的对象o1,都有类型为T2的对象o2,使得以T1定义的所有程序P在所有的对象o1都替换成o2时,程序P的行为都没有发生变化,那么类型T2是类型T1的子类型。

实现开闭原则的关键就是进行抽象,而父类和子类的继承关系就是抽象化的具体实现,所以里氏替换原则非常重要,它是对实现抽象化的具体步骤、规范。对于里氏替换原则的定义我们反着想,例如我们继承了ArrayList写了一个自定义List,当我们用这个List去获取元素的时候,我们调用get方法,里面要传一个入参index,JDK的ArrayList是从0开始的,我们继承了ArrayList后重写了get方法,让它从第一个元素开始获取,这样当别人用我们写的继承ArrayList的那个list去调用get方法的时候,就会得到不一样的结果,也就是说程序p的行为发生了变化,那么我们认为自己写的ArrayList就不是JDK的ArrayList的子类。

定义扩展:一个软件实体如果适用一个父类的话,那一定适用于其子类,所有引用父类的地方必须能透明地使用其子类的对象,子类对象能够替换父类对象,而程序逻辑不变。

关于里氏替换原则的理解,还可以通过继承这个方面深入理解下。打个比方,父类中相对于抽象方法而言,父类中如果已经有实现好的方法,实际上就是在设定一系列的规范和契约,那父类虽然不强制要求所有的子类必须遵从这些契约,但是如果子类对这些非抽象方法随意修改,那就会对继承造成破坏,那里氏替换原则就是表达了反对子类重写父类方法的这一层含义。
继承做为面向对象的特性之一,给我们设计程序的时候带来了巨大的便利,同时也带来了弊端。比如我们使用继承,会给程序带来一些侵入性,可移植性也会降低,增加了对象间的耦合,如果一个父类被很多子类继承,假设我们修改这个父类的时候,必须要考虑所有的子类,而且在修改父类之后如果没有考虑子类的话,很容易给我们的系统引入一些风险,发生故障。

引申意义:子类可以扩展父类的功能,但不能改变父类原有的功能。
含义1:子类可以实现父类的抽象方法,但不能覆盖父类的非抽象方法。
含义2:子类中可以增加自己特有的方法。
开闭原则中的示例——课程上线,然后进行打折活动,获取一个打折的价格

package com.design.principle.openclose;

public class Test {
    public static void main(String[] args) {
        ICourse iCourse=new JavaDiscountCourse(96,"java从零到企业级电商开发",238d);
        JavaDiscountCourse javaCourse=(JavaDiscountCourse)iCourse;
        System.out.println("课程ID:"+javaCourse.getId()+"课程名称:"+javaCourse.getName()+"课程价格:"+  javaCourse.getOriginPrice()+"元 课程折后价格:"+javaCourse.getPrice());
    }
}

再看下父类JavaCourse和子类JavaDiscountCourse的代码

package com.design.principle.openclose;

public class JavaCourse implements ICourse{
    private Integer id;
    private String name;
    private Double price;

    public JavaCourse(Integer id, String name, Double price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Integer getId() {
        return this.id;
    }

    public String getName() {
        return this.name;
    }

    public Double getPrice() {
        return this.price*0.8;
    }
}
package com.design.principle.openclose;

public class JavaDiscountCourse  extends JavaCourse{
    public JavaDiscountCourse(Integer id, String name, Double price) {
        super(id, name, price);
    }

    /**
     * 获取原价
     * @return
     */
    public Double getOriginPrice(){
        return super.getPrice();
    }

    @Override
    public Double getPrice() {
        return super.getPrice()*0.8;
    }
}

JavaDiscountCourse中的getOriginPrice()方法是里氏替换原则引申的含义2,getPrice()方法已经对父类JavaCourse中的getPrice()方法含义发生变化,不符含义1,怎么办呢?其实很简单,我们将JavaDiscountCourse类的getOriginPrice()方法改为getDiscountPrice(),并删除getPrice()方法。这样就没有覆盖父类的非抽象方法getPrice()。

我们在开发中经常会通过重写父类的非抽象方法来完成新的功能,这样的确是非常的简单方便。但是整个继承体系的可复用性就非常的差,特别是用多态比较频繁的时候,程序运行出错的几率也会增加。那就里氏替换原则的两个含义而言,如果非要重写父类的非抽象方法,还是有解决办法的,我们可以将JavaCourse和子类JavaDiscountCourse的继承关系去掉,改用依赖、聚合或者组合的关系替代。

含义3:当子类的方法重载父类的方法时,方法的前置条件(即方法的输入/入参)要比父类方法的输入参数更宽松
打个比方,父类的入参是HashMap,如果要符合含义3,可以使用Map。

含义4:当子类的方法实现父类的方法时(重写/重载或实现抽象方法),方法的后置条件(即方法的输出/返回值)要比父类更严格或相等。
打个比方,父类的方法返回值为T,子类的相同方法返回值为S,里氏替换原则要求S<=T

看到上面说的里氏替换原则有这么多的条条框框,而我们在实际编程中可能没有注意这些,而我们的程序跑的也好好的,那我们想一下:如果我们就不遵循里氏替换原则会怎么样呢?
后果可能会导致我们在引入一些需求变更或新功能,进行重构的时候出问题的风险就会增加,里氏替换原则是个非常好的约束。
优点1:约束继承泛滥,开闭原则的一种体现
里氏替换原则为实现开闭原则提供了步骤规范,打个防止继承泛滥的比方:我们有个类是人,方法是繁殖,那子类可能是各大洲的人,都可以继承繁殖这个行为。如果子类是超人、变形金刚、机器人,人的基本特征它们都有,但是让机器人去生娃的话,它还不具备这个行为,机器人不能成为人这个父类的子类。所以,我们在选择使用继承的时候,还是要慎重选择,还是要分析它们的属性、特征、行为这些因素,而结合实际开发时候,更是要把我们业务模型中的东西抽象到我们的开发代码中。

优点2:加强程序的健壮性,同时变更时也可以做到非常好的兼容性,提高程序的维护性、扩展性。降低需求变更时引入的风险。

coding

不符里氏替换原则

长方形类

package com.design.principle.liskovsubsitution;

public class Rectangle {
    private long length;
    private long width;

    public long getLength() {
        return length;
    }

    public void setLength(long length) {
        this.length = length;
    }

    public long getWidth() {
        return width;
    }

    public void setWidth(long width) {
        this.width = width;
    }
}

正方形类

package com.design.principle.liskovsubsitution;

import sun.plugin.dom.css.Rect;

public class Square extends Rectangle {
    private long sideLength;

    public long getSideLength() {
        return sideLength;
    }

    public void setSideLength(long sideLength) {
        this.sideLength = sideLength;
    }

    @Override
    public void setLength(long length) {
        setSideLength(length);
    }

    @Override
    public void setWidth(long width) {
        setSideLength(width);
    }

    @Override
    public long getLength() {
        return getSideLength();
    }

    @Override
    public long getWidth() {
        return getSideLength();
    }
}

测试代码

package com.design.principle.liskovsubsitution;

public class Test {
    /**
    *将宽加大到比长多1
    */
    public static void resize(Rectangle rectangle){
        while(rectangle.getWidth()<= rectangle.getLength()){
            rectangle.setWidth(rectangle.getWidth()+1);
            System.out.println("width:"+rectangle.getWidth()+ " length:"+rectangle.getLength());
        }
        System.out.println("resize方法结束 width:"+rectangle.getWidth()+ " length:"+rectangle.getLength());
    }

  public static void main(String[] args) {
        Rectangle rectangle=new Rectangle();
        rectangle.setLength(20);
        rectangle.setWidth(10);
        resize(rectangle);
        
        System.out.println("长方形替换为子类正方形");
        Square square=new Square();
        square.setSideLength(10);
        resize(square);
    }
}

看到运行结果,是

width:11 length:20
width:12 length:20
width:13 length:20
width:14 length:20
width:15 length:20
width:16 length:20
width:17 length:20
width:18 length:20
width:19 length:20
width:20 length:20
width:21 length:20
resize方法结束 width:21 length:20
长方形替换为子类正方形
....
width:284132 length:284132
width:284133 length:284133
width:284134 length:284134
width:284135 length:284135
....

当我们把程序中resize方法的父类替换为子类后,程序运行的结果和我们期望的是不一样的,这也就体现除了在resize这个业务场景中,正方形是不可以成为长方形的子类的。里氏替换原则讲的是父类和子类的关系,只有当这种关系存在的时候里氏替换关系才存在。

符合里氏替换原则的方案

违反了里氏替换原则的设计,那怎么办呢?我们可以再创建一个新的类,解除长方形和正方形的继承关系,要长方形和正方形同时继承C,这样来限制长方形和正方形的行为。
或者根据实际的业务行为,改成正方形里面引用长方形。再打个比方,我们写了很多关于鸟的业务,包括飞、吃等等,这个时候又有个新的子类鸵鸟,它又不会飞,这个时候就要靠我们的业务模型,对于关于飞的这个业务模型的现有的抽象及继承关系是否合理,从而决定这里是否需要重构。假设我们认为飞就是鸟的特性,鸵鸟也就是不能飞的,我们强行让鸵鸟继承鸟这个类,最终还是会导致我们的代码出错,无法达到预期的效果。

还看我们上面的示例,我们创建一个四边形

package com.design.principle.liskovsubsitution;

public interface Quadrangle {
    long getWidth();
    long getLength();
}

长方形实现四边形

package com.design.principle.liskovsubsitution;

public class Rectangle implements Quadrangle{
    private long length;
    private long width;


    public long getWidth() {
        return width;
    }

    public long getLength() {
        return length;
    }

    public void setLength(long length) {
        this.length = length;
    }

    public void setWidth(long width) {
        this.width = width;
    }
}

正方形不再继承长方形,而是实现四边形

package com.design.principle.liskovsubsitution;
 
public class Square implements Quadrangle {
    private long sideLength;

    public long getSideLength() {
        return sideLength;
    }

    public void setSideLength(long sideLength) {
        this.sideLength = sideLength;
    }

    public long getWidth() {
        return sideLength;
    }

    public long getLength() {
        return sideLength;
    }
}

再来看我们的Tset类,其中的resize方法改成四边形为入参

package com.design.principle.liskovsubsitution;

public class Test {
    public static void resize(Quadrangle rectangle){
        while(rectangle.getWidth()<= rectangle.getLength()){
            rectangle.setWidth(rectangle.getWidth()+1);
            System.out.println("width:"+rectangle.getWidth()+ " length:"+rectangle.getLength());
        }
        System.out.println("resize方法结束 width:"+rectangle.getWidth()+ " length:"+rectangle.getLength());
    }
}

这时候发现rectangle.setWidth(rectangle.getWidth()+1);这一行时报错的,编译不能通过,因为接口四边形没有setLength方法,这样就达到了约束,禁止继承泛滥,因此在这个业务场景中,通过四边形这个类,就解决了长方形和正方形不符合里氏替换原则的问题,这里的正方形不适用于用在resize方法中。
实际工作中,项目一般不是由我们一个人开发,是有很多人一起进入这个项目,我们在开发中会使用别人提供的组件,同时自己也会为别人提供组件,最终所有的组件最终都是经过层层包装和组合的,形成了一个完整的系统。在使用的过程中,例如使用gova,在使用过程中我们只需要对外暴露的接口,这些就是它的具体行为,对于内部如何实现的我们可以通过看源码,如果不想知道也可以不用看,对于使用者而言,我们只需要知道组件提供的接口实现自己的预期就可以了。如果组件的接口提供出来的行为,和我们的预期不一样,错误也就产生了。里氏替换原则最大的作用就是在设计时可以避免继承的泛滥,父类和子类不一致行为的情况,对继承形成约束,提高我们代码的健壮性。

方法的入参方面

我们创建一个Base类

package com.design.principle.liskovsubsitution.methodinput;

import java.util.HashMap;

public class Base {
    public void method(HashMap map){
        System.out.println("父类被执行");
    }
}

再创建一个Child类

package com.design.principle.liskovsubsitution.methodinput;

import java.util.HashMap;
import java.util.Map;

public class Child extends Base {
    //重写
    @Override
    public void method(HashMap map) {
        System.out.println("子类HashMap入参方法被执行");
    }
    //重载
    public void method(Map map) {
        System.out.println("子类Map入参方法被执行");
    }
}

接着再创建一个Test类

package com.design.principle.liskovsubsitution.methodinput;

import java.util.HashMap; 

public class Test {
    public static void main(String[] args) {
        Child child=new Child();
        HashMap hashMap=new HashMap();
        child.method(hashMap);
    }
}

运行后结果是

子类HashMap入参方法被执行

从运行结果可以看到,子类重写父类的方法被执行。如果把重写的方法注释,只留下上面重载的方法呢?

package com.design.principle.liskovsubsitution.methodinput;

import java.util.HashMap;
import java.util.Map;

public class Child extends Base {
    //重写
//    @Override
//    public void method(HashMap map) {
//        System.out.println("子类HashMap入参方法被执行");
//    }
    //重载
    public void method(Map map) {
        System.out.println("子类Map入参方法被执行");
    }
}

再运行下,可以看到结果是执行的父类的方法,并没有执行Child的重载方法

父类被执行

这是正确的,父类的方法是HashMap类型,而子类方法的参数是Map类型,子类的入参类型比父类大,那么子类的方法永远也不会执行。如果反过来,Base类中方法入参使用Map,

package com.design.principle.liskovsubsitution.methodinput;

import java.util.Map;

public class Base {
    public void method(Map map){
        System.out.println("父类被执行");
    }
}

子类的方法也改下

package com.design.principle.liskovsubsitution.methodinput;

import java.util.HashMap;
import java.util.Map;

public class Child extends Base {
    //重写
//    @Override
//    public void method(HashMap map) {
//        System.out.println("子类HashMap入参方法被执行");
//    }
    //重载
    public void method(HashMap map) {
        System.out.println("子类HashMap入参方法被执行");
    }
}

Test的运行结果是

子类HashMap入参方法被执行

可以看到这个时候执行了子类的method方法,这个时候就违反了里氏替换原则,在实际开发中很容易引起业务逻辑的混乱。在工作中,子类重载父类的方法时候,方法的前置条件入参一定要比父类的更宽松,如果要是相同的话,就不是重载是重写了。

方法的返回值

我们先写个Base类

package com.design.principle.liskovsubsitution.methodoutput;

import java.util.Map;

public abstract class Base {
    public abstract Map method();
}

再写个Child类

package com.design.principle.liskovsubsitution.methodoutput;

import java.util.HashMap; 

public class Child extends  Base {
    //当子类实现父类的抽象方法时,返回值应该比父类的更严格或者相等
    public HashMap method() {
        HashMap hashMap=new HashMap();
        System.out.println("子类method被执行");
        return hashMap;
    }
}

接着写个Test测试类

package com.design.principle.liskovsubsitution.methodoutput;

public class Test {
    public static void main(String[] args) {
        Child child=new Child();
        System.out.println(child.method());
    }
}

执行结果是

子类method被执行
{}

在实现父类的抽象方法时,子类的返回值一定要小于等于父类的方法返回值,那假设我们把子类的方法返回值改为Object

package com.design.principle.liskovsubsitution.methodoutput;

import java.util.HashMap;

public class Child extends  Base {
    //当子类实现父类的抽象方法时,返回值应该比父类的更严格或者相等
    public Object method() {
        HashMap hashMap=new HashMap();
        System.out.println("子类method被执行");
        return hashMap;
    }
}

这个时候 public Object method() {这行代码就会报错,我们使用的Idea都会进行检查,所以这类错误实际开发中都没有犯的。

合成(组合)/聚合复用原则

定义: 尽量使用对象组合/聚合,而不是继承关系达到软件复用的目的
聚合是has-A的关系,组合是contains-A 的关系

优点:可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其它类造成的影响相对较少

缺点:使用这种方式建造的系统会有较多的对象进行管理,也就是说A对象有个B类,再加个C类,要管理的对象较多。

继承复用:继承复用的优点是新的扩展性比较容易实现,因为我们继承一个父类,父类的大部分关系都可以通过继承给子类,修改和扩展相对容易些。继承复用的缺点:

  • 会破坏包装,将父类的实现细节暴露给子类。
    继承复用是白箱复用,而聚合/组合是黑箱复用,有什么区别?黑箱复用看不见,A类包含B类,对于B类的实现细节,A是看不到的。白箱复用,父类全暴露给子类了,这个时候父类的实现发生改变,子类的也不得不发生改变。

何时使用合成/聚合、继承
聚合是has-A的关系、组合是contains-A 继承是 is-A 。

coding

继承复用

创建一个数据库连接

package com.design.principle.compositionaggregation;

public class DBConnection {
    public String getConnection(){
        return "MySQL数据库连接";
    }
}

再写个产品的Dao

package com.design.principle.compositionaggregation;

public class ProductDao extends  DBConnection {
    public void addProduct(){
        String conn=super.getConnection();
        System.out.println("使用"+conn+"增加连接");
    }
}

写一个测试类

package com.design.principle.compositionaggregation;

public class Test {
    public static void main(String[] args) {
        ProductDao productDao=new ProductDao();
        productDao.addProduct();
    }
}

这个逻辑很简单,单纯的DBConnction,ProductDao继承DBConnection。再看UML
在这里插入图片描述

组合/聚合复用

如果我们现在又接入了其他数据库,比如大象数据库PostgreSQL,怎么办呢?
在DBConnection中再写一个getPostgreSQLConnection的方法,但是这样会违反开闭原则。我们现在用组合复用原则来重构它,这样之后对扩展开放,对修改关闭。

连接抽象类

package com.design.principle.compositionaggregation;

public abstract class DBConnection {
    public abstract String getConnection(); 
}

具体怎么实现,交给实现它的子类。我们再写MySQL、PostgreSQL的子类

package com.design.principle.compositionaggregation;

public class MySQLConnection extends DBConnection {
    public String getConnection() {
        return "MySQL数据库连接";
    }
}
package com.design.principle.compositionaggregation;

public class PostgreSQLConnection extends  DBConnection {
    public String getConnection() {
        return "PostgreSQL数据库连接";
    }
}

ProductDao就不需要继承DBConnection了

package com.design.principle.compositionaggregation;

public class ProductDao  {
    private DBConnection dbConnection;

    public void setDbConnection(DBConnection dbConnection) {
        this.dbConnection = dbConnection;
    }
    public void addProduct(){
        String conn=dbConnection.getConnection();
        System.out.println("使用"+conn+"增加连接");
    }
}

在Test中newProdctDao时候,还要调用setter方法

package com.design.principle.compositionaggregation;

public class Test {
    public static void main(String[] args) {
        ProductDao productDao=new ProductDao();
        productDao.setDbConnection(new MySQLConnection());
        productDao.addProduct();
    }
}

运行结果

使用MySQL数据库连接增加连接

现在对扩展来说非常的简单,如果我们再接入一个SQLServer,只需要写一个和MySQLConnection平级的类就可以。再看下类图
在这里插入图片描述
ProductDao和DBConnection是组合关系。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值