面向对象的设计原则你不要了解一下么?

昨天我看了单一职责原则和开闭原则,今天我们再来看里式替换原则和依赖倒置原则,千万别小看这些设计原则,他在设计模式中会有很多体现,所以理解好设计原则之后,那么设计模式,也会让你更加的好理解一点。

前言

在面向对象的软件设计中,只有尽量降低各个模块之间的耦合度,才能提高代码的复用率,系统的可维护性、可扩展性才能提高。面向对象的软件设计中,有23种经典的设计模式,是一套前人代码设计经验的总结,如果把设计模式比作武功招式,那么设计原则就好比是内功心法。常用的设计原则有七个,下文将具体介绍。

设计原则简介

  • 单一职责原则:专注降低类的复杂度,实现类要职责单一;

  • 开放关闭原则:所有面向对象原则的核心,设计要对扩展开发,对修改关闭;

  • 里式替换原则:实现开放关闭原则的重要方式之一,设计不要破坏继承关系;

  • 依赖倒置原则:系统抽象化的具体实现,要求面向接口编程,是面向对象设计的主要实现机制之一;

  • 接口隔离原则:要求接口的方法尽量少,接口尽量细化;

  • 迪米特法则:降低系统的耦合度,使一个模块的修改尽量少的影响其他模块,扩展会相对容易;

  • 组合复用原则:在软件设计中,尽量使用组合/聚合而不是继承达到代码复用的目的。

这些设计原则并不说我们一定要遵循他们来进行设计,而是根据我们的实际情况去怎么去选择使用他们,来让我们的程序做的更加的完善。

里式替换原则

定义

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

换句话来说,一个软件实体如果使用一个基类的话,那么一定适用于其子类,而且它根本不会察觉出基类对象和子类对象的区别。

比如说,假设有两个类,一个是Base类,另一个是Derived类,并且Derived类是Base的子类,那么一个方法如果可以接受一个基类对象b的话:method(Base b) ,那么它必然可以接受一个子类对象d,可以有 method1(d)

里式替换原则是继承复用的基石,只有当衍生类可以替换掉基类,软件单位的功能不会受到影响的时候,基类才能真正被复用,而衍生类也才能够在基类的基础上增加新的行为。

我们通过一个例子来理解一下:

《西游记》中,美猴王下地府桥段,个位应该有印象把,到达阎王殿之后,拿到生死簿,把生死簿上所有的包括自己,还有其他的猕猴,所有的猴子猴算都给划了,这也是导致之后真假美猴王桥段的前序。

画个图理解

很显然,地府管理一切生灵的生死的方法都是通过类来进行区分的,比如孙悟空就是石猴,之后出现的那个六耳猕猴就是猕猴,但是他们都是属于同一个类,猴类,就像下图中。

因此,孙悟空把猴类中有姓名的都从生死簿勾掉之后,显然是因为勾魂小鬼们并不区分石猴类与猕猴类,就像下图:

换句话来说,只要是猴类适用的,猕猴和石猴都适用,这其实就是里式替换原则。

这是第一种解释,还有第二个更加通俗易懂的解释:所有引用基类的地方必须能透明地使用其子类的对象。

第二种定义比较通俗,容易理解:只要有父类出现的地方,都可以用子类来替代,而且不会出现任何错误和异常。但是反过来则不行,有子类出现的地方,不能用其父类替代。

实例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class TestA {
    public void fun(int a,int b){
        System.out.println(a+"+"+b+"="+(a+b));
    }

    public static void main(String[] args) {
        System.out.println("父类的运行结果");
        TestA a=new TestA();
        a.fun(1,2);
        //父类存在的地方,可以用子类替代
        //子类B替代父类A
        System.out.println("子类替代父类后的运行结果");
        TestB b=new TestB();
        b.fun(1,2);
    }
}
class TestB extends TestA{
    @Override
    public void fun(int a, int b) {
        System.out.println(a+"-"+b+"="+(a-b));
    }
}

大家肯定也都能猜出来结果是什么样子的,

1
2
3
4
5
6
7
父类的运行结果
1+2=3
子类替代父类后的运行结果
1-2=-1

Process finished with exit code 0

我们想要的结果是“1+2=3”。可以看到,方法重写后结果就不是了我们想要的结果了,也就是这个程序中子类B不能替代父类A。这违反了里氏替换原则原则,从而给程序造成了错误。

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

这个很容易理解,子类继承了父类,拥有了父类和方法,同时还可以定义自己有,而父类没有的方法。这是在继承父类方法的基础上进行功能的扩展,符合里氏替换原则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

public class TestA {
    public void fun(int a,int b){
        System.out.println(a+"+"+b+"="+(a+b));
    }

    public static void main(String[] args) {
        System.out.println("父类的运行结果");
        TestA a=new TestA();
        a.fun(1,2);
        //父类存在的地方,可以用子类替代
        //子类B替代父类A
        System.out.println("子类替代父类后的运行结果");
        TestB b=new TestB();
        b.fun(1,2);
        b.newFun();
    }
}
class TestB extends TestA{
    public void newFun(){
        System.out.println("这是子类的新方法...");
    }
}

这次运行出来的代码结果就是我们意料中的内容了

1
2
3
4
5
6
7
8
父类的运行结果
1+2=3
子类替代父类后的运行结果
1+2=3
这是子类的新方法...

Process finished with exit code 0

JAVA语言对里式替换原则支持的局限

JAVA编译器的检查是有局限性的,为什么呢?举个例子来说,描述一个物体大小的量有精度和准确度两种属性。所谓的精度,就是这个量的有效数字有多少位;而所谓的精准度,是这个量与真实的物体大小相符合到什么程度。

一个量可以有很高的精度,但是却无法与真实物体的情况相吻合,JAVA语言编译器能够检查的,仅仅是相当于精度的属性而已,它没有办法去检查这个量与真实物体的差距。

换一句话来说,JAVA编译器不能检查一个系统在实现和商业逻辑上是否满足里式替换原则。

而里式替换原则在设计模式中也有体现,请关注我们的知识星球,链接在文末,我们将每周更新一篇关于设计模式的文章。

依赖倒置原则

如果说实现开闭原则的关键事抽象化,是面向对象设计的目标的话,依赖倒置原则就是这个面向对象设计的主要机制。

定义

抽象不应该依赖于细节,细节应当依赖于抽象。换言之,要针对接口编程,而不是针对实现编程。

为什么要实现倒置?这也是我们看这个定义的时候产生的一些问题,那么我们就来说说。

简单的来说,传统的过程性系统的设计办法倾向于使高层次的模块依赖于低层次的模块,抽象层依赖于具体层次,倒置原则是要把这个错误的依赖关系倒转过来,这就是依赖倒置原则的由来。也是为什么要进行依赖倒置。

依赖倒置原则的实现方法

依赖倒置原则的目的是通过要面向接口的编程来降低类间的耦合性,所以我们在实际编程中只要遵循以下4点,就能在项目中满足这个规则:

  • 每个类尽量提供接口或抽象类,或者两者都具备。
  • 变量的声明类型尽量是接口或者是抽象类。
  • 任何类都不应该从具体类派生。
  • 使用继承时尽量遵循里氏替换原则。

下面我们通过一些代码实例(商品售卖)来进行理解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
    class BeijingShop implements Shop{
        public String sell(){
            return "北京商店售卖:北京烤鸭,稻香村月饼";
        }
    }
    
    class ShanDongShop implements  Shop{
        @Override
        public String sell() {
            return "山东商店售卖:德州扒鸡,烟台苹果";
        }
    }
    //如果说顾客去购买商品
class Customer{

    public void shopping(ShanDongShop shop)
    {
        //购物
        System.out.println(shop.sell());
    }
}
//这是在山东商店购买,如果说是在北京商店购买就会这样
class Customer{

    public void shopping(BeijingShop shop)
    {
        //购物
        System.out.println(shop.sell());
    }
}

这也是这种设计的存在缺陷,顾客每更换一家商店,都要修改一次代码,这明显违背了开闭原则。存在以上缺点的原因是:顾客类设计时同具体的商店类绑定了,这违背了依赖倒置原则。解决方式我们可以定义一个共同的接口Shop,就可以这样了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class TestSale {
    public static void main(String[] args) {
        Customer c = new Customer();
        System.out.println("---顾客购买商品如下---");
        c.shopping(new ShanDongShop());
        c.shopping(new BeijingShop());
    }
}

interface Shop{
    //售卖方法
    public String sell();
}

class BeijingShop implements Shop{
    public String sell(){
        return "北京商店售卖:北京烤鸭,稻香村月饼";
    }
}

class ShanDongShop implements  Shop{
    @Override
    public String sell() {
        return "山东商店售卖:德州扒鸡,烟台苹果";
    }
}

class Customer{

    public void shopping(Shop shop)
    {
        System.out.println(shop.sell());//购物
    }
}

程序运行结果

1
2
3
4
5
6
---顾客购买商品如下---
山东商店售卖:德州扒鸡,烟台苹果
北京商店售卖:北京烤鸭,稻香村月饼

Process finished with exit code 0

这样,不管顾客类 Customer 访问什么商店,或者增加新的商店,都不需要修改原有代码了,

依赖倒置原则是OO设计的核心原则,设计模式的研究和应用是以依赖导致原则为知道原则的,在知识星球中的设计模式中我们将会一一给大家体现。

我是懿,一个正在被打击还在努力前进的码农。欢迎大家关注我们的公众号,加入我们的知识星球,我们在知识星球中等着你的加入。

Java Geek Tech wechat
欢迎订阅 Java 极客技术,这里分享关于 Java 的一切。