走向灵活软件之路-面向对象的六大原则

734 阅读29分钟

前言

关于设计模式六大设计原则的资料网上很多,但感觉很多地方解释地都太过于笼统化,特此再总结一波。

优化第一步-单一职责原则SRP

单一职责原则(Single Responsibility Principle, SRP):一个类只负责一个功能领域中的相应职责,或者可以定义为:就一个类而言,应该只有一个引起它变化的原因。

经典问题: 类T负责两个不同的职责:职责P1,职责P2。当职责P1需求发生改变而需要修改类T时,有可能会导致原本运行正常的职责P2功能发生故障。

解决方案: 遵循单一职责原则。分别建立两个类T1、T2,使T1完成职责P1功能,T2完成职责P2功能。这样,当修改类T1时,不会使职责P2发生故障风险;同理,当修改T2时,也不会使职责P1发生故障风险。

单一职责原则是实现高内聚、低耦合的指导方针,它是最简单但又最难运用的原则,因为单一职责的划分界限并不总是很清晰的,它需要设计人员发现类的不同职责并将其分离,这就需要设计人员具有较强的分析设计能力和相关实践经验。

在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责发生变化时,可能会影响其他职责的运行,因此需要将这些不同的职责进行封装在不同的类中,即将不同的变化封装在不同的类中。如果多个职责总是同时发生改变则可将它们封装在同一类中。

说到单一职责原则,很多人都会不屑一顾。因为它太简单了。稍有经验的程序员在进行设计软件时也会自觉的遵守这一重要原则,而且谁也不希望因为修改了一个功 能导致其他的功能发生故障。虽然单一职责原则如此简单,但是即便是经验丰富的程序员也会有违背这一原则的代码存在。为什么会出现这种现 象呢? 因为有职责扩散。所谓职责扩散,就是因为某种原因,职责P被分化为粒度更细的职责P1和P2。

比如:类T只负责一个职责P,这样设计是符合单一职责原则的。后来由于某种原因,也许是需求变更了,也许是程序的设计者境界提高了,需要将职责P细分为粒度更细的职责P1,P2,这时如果要使程序遵循单一职责原则,需要将类T也分解为两个类T1和T2,分别负责P1、P2两个职责。但是在程序已经写好的情况下,这样做简直太费时间了。所以,简单的修改类T,用它来负责两个职责是一个比较不错的选择,虽然这样做有悖于单一职责原则。(这样做的风险在于职责扩散的不确定性,因为我们不会想到这个职责P,在未来可能会扩散为P1,P2,P3,P4……Pn。所以记住,在职责扩散到我们无法控制的程度之前,立刻对代码进行重构。)

举例说明,用一个类描述动物运动这个场景:

    @Test
     public void SrpTest() {
        Animal animal = new Animal();
        animal.move("牛");
        animal.move("羊");

    }

    private class Animal {

        private void move(String animal) {
            System.out.println(animal + "奔跑");
        }
    }
    
    运行结果:
    牛奔跑
    羊奔跑

程序上线后,发现问题了,并不是所有的动物都是奔跑的,比如鱼就是在水游的。修改时如果遵循单一职责原则,需要将Animal类细分为陆生动物类Terrestrial,水生动物Aquatic,代码如下:

 @Test
     public void SrpTest() {
        Terrestrial terrestrial = new Terrestrial();
        terrestrial.move("牛");
        terrestrial.move("羊");

        Aquatic aquatic = new Aquatic();
        aquatic.move("鱼");

    }
    
     private class Terrestrial {

        private void move(String animal) {
            System.out.println(animal + "奔跑");
        }
    }

    private class Aquatic {

        private void move(String animal) {
            System.out.println(animal + "在水里游");
        }
    }
    
    运行结果:
    牛奔跑
    羊奔跑
    鱼在水里游

我们会发现如果这样修改花销是很大的,除了将原来的类分解之外,还需要修改客户端。而直接修改类Animal来达成目的虽然违背了单一职责原则,但花销却小的多。

    @Test
     public void SrpTest() {
        Animal animal = new Animal();
        animal.move("牛");
        animal.move("羊");
        animal.move("鱼");

    }

    private class Animal {

       // 极差的拓展方式
        private void move(String animal) {
            if ("鱼".equals(animal)) {
                System.out.println(animal + "奔跑");
            } else {
                System.out.println(animal + "在水里游");
            }
        }
    }
    
    运行结果:
    牛奔跑
    羊奔跑
    鱼在水里游

可以看到,这种修改方式要简单的多。但是却存在着隐患:有一天需要将鱼分为在浅水游的鱼和在深水里游的鱼,或者是需要添加鸟要在天上飞等情况,则又需要修改Animal类的move方法,而对原有代码的修改会对调用“牛”“羊”等相关功能带来风险,也许某一天你会发现程序运行的结果变为“牛在水里游”了。这种修改方式直接在代码级别上违背了单一职责原则,虽然修改起来最简单,但隐患却是最大的。还有一种修改方式:

    @Test
     public void SrpTest() {
        Animal animal = new Animal();
        animal.move("牛");
        animal.move("羊");
        animal.move2("鱼");

    }

    private class Animal {

       private void move(String animal) {
            System.out.println(animal + "奔跑");
        }

        private void move2(String animal) {
            System.out.println(animal + "在水里游");
        }
    }
    
    运行结果:
    牛奔跑
    羊奔跑
    鱼在水里游

可以看到,这种修改方式没有改动原来的方法,而是在类中新加了一个方法,这样虽然也违背了单一职责原则,但在方法级别上却是符合单一职责原则的,因为它并没有动原来方法的代码。这三种方式各有优缺点,那么在实际编程中,采用哪一中呢?其实这真的比较难说,需要根据实际情况来确定。我的原则是:只有逻辑足够简单,才可以在代码级别上违反单一职责原则;只有类中方法数量足够少,才可以在方法级别上违反单一职责原则;

遵循单一职责原可以降低类的复杂度(一个类只负责一项职责,其逻辑肯定要比负责多项职责简单的多);提高类的可读性,提高系统的可维护性;降低变更引起的风险;
如何划分一个类、一个函数的职责,每个人都有自己的看法,但是它也是有一些基本的指导原则的:

  1. 两个完全不一样的功能就不应该放在同一个类中;
  2. 一个类中应该是一组相关性很高的函数、数据的封装;

工程师不断审视自己的代码,根据具体的业务、功能对类进行相应的拆分,这是工程师优化代码迈出的第一步。

让程序更稳定、更灵活-开闭原则OCP

开闭原则(Open-Closed Principle, OCP):软件中的对象(类、模块、函数等)应当对扩展开放,对修改关闭。即软件实体应尽量在不修改原有代码的情况下进行扩展。

经典问题: 在软件的生命周期内,因为变化、升级和维护等原因需要对软件原有代码进行修改时,可能会将错误引入原本已测试通过的代码,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试。如何确保原有软件模块的正确性,以及尽量少的影响原有模块?

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

用抽象构建框架,用实现扩展细节。为了满足开闭原则,需要对系统进行抽象化设计,抽象化是开闭原则的关键。我们为系统定义一个相对稳定的抽象层,通过接口、抽象类等机制将不同的实现行为移至具体的实现层中完成。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。开闭原则是面向对象的可复用设计的第一块基石,它是最重要的面向对象设计原则.

举例说明,还是描述动物运动的场景:

    @Test
    public void ocpTest() {
        Animal animal = new Animal();
        animal.move("terrestrial", "牛");
        animal.move("terrestrial", "羊");
        animal.move("aquatic", "鱼");

    }

    private class Animal {

        // 这里做个经典的示范
        private void move(String type, String animal) {
            if (type.equals("terrestrial")) {
                Terrestrial terrestrial = new Terrestrial(animal);
                terrestrial.move();
            } else if (type.equals("aquatic")) {
                Aquatic aquatic = new Aquatic(animal);
                aquatic.move();
            }

        }
    }

    private class Terrestrial {
         private String mTerrestrial;

        Terrestrial(String animal) {
            mTerrestrial = animal;
        }

        @Override
        public void move() {
            System.out.println(mTerrestrial + "奔跑");
        }
    }

    private class Aquatic {
        private String mAquatic;

        Aquatic(String animal) {
            mAquatic = animal;
        }

        @Override
        public void move() {
            System.out.println(mAquatic + "在水里游");
        }
    }

但是如果有一天我发现我需要要添加鸟这种需求,因为我不能说鸟是在水里游的,此时就需要要修改Animal类的move()方法的源代码,增加新的判断逻辑,这就违反了开闭原则,对于拓展是开放的,但对于修改是封闭的。 竟然所有的动物都可以移动,那么我们是否可以总结抽象出动物都可以移动这一特性?

  @Test
    public void ocpTest() {
        Animal animalTerrestrial = new Terrestrial("牛");
        animalTerrestrial.movement();
        Animal animalAquatic = new Terrestrial("鱼");
        animalAquatic.movement();

    }

    // 这里是通过抽象类的方法实现,接口也可以实现
    // 使用接口还是抽象类请根据实际情况来选择
    private abstract class Animal {
        abstract void move();

        private void movement() {
            move();
        }
    }

    private class Terrestrial extends Animal {
        private String mTerrestrial;

        Terrestrial(String animal) {
            mTerrestrial = animal;
        }

        @Override
        public void move() {
            System.out.println(mTerrestrial + "奔跑");
        }
    }

    private class Aquatic extends Animal {
        private String mAquatic;

        Aquatic(String animal) {
            mAquatic = animal;
        }

        @Override
        public void move() {
            System.out.println(mAquatic + "在水里游");
        }
    }
    
    private class Celestial extends Animal{

        private String mCelestial;

        Celestial(String animal) {
            mCelestial = animal;
        }

        @Override
        public void move() {
            System.out.println(mCelestial + "在天空飞");
        }
    }

这里只是为了突出对于拓展是开放的,但对于修改是封闭的一特性,如果想要继续优化则涉及到工厂模式方面,这里就先不延伸下去了。

为什么使用开闭原则?

  1. 开闭原则是最基础的设计原则,其它的五个设计原则都是开闭原则的具体形态,也就是说其它的五个设计原则是指导设计的工具和方法,而开闭原则才是其精神领袖。
  2. 开闭原则可以提高软件的维护性和拓展性,符合开闭原则的代码更易读懂理解,对于拓展也不需去修改一个类,而是新增一个类,减少了出错的可能性。
  3. 面向对象开发的要求,万物皆在发展变化,有变化就要有策略去应对,在设计之初考虑到可能会变化的因素,抽象出对应的特性,将“可能”转变为“实际”。

遵守开闭原则的重要手段应该是通过抽象;当软件需要变化时,应该尽量通过扩展的方式来实现变化,而不是通过修改已有的代码来实现。 “应该尽量” 说明OCP原则并不是说绝对不可以修改原始类,当我们嗅到代码“腐朽气味”时,应尽早的重构,而不是通过继承等方式添加新的实现,这会导致类型的膨胀以及历史遗留代码的冗余;实际的开发过程也没那么理想化完全的不需要修改原理的代码,因此,在开发过程中要结合实际的具体的情况去进行考量,是通过修改旧代码还是通过继承使得软件系统更稳定、更灵活;

构建扩展性更好的系统-里氏替换原则LSP

里氏代换原则(Liskov Substitution Principle, LSP):

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

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

经典问题: 有一功能P1,由类A完成。现需要将功能P1进行扩展,扩展后的功能为P,其中P由原有功能P1与新功能P2组成。新功能P由类A的子类B来完成,则子类B在完成新功能P2的同时,有可能会导致原有功能P1发生故障。

解决方案: 当使用继承时,遵循里氏替换原则。类B继承类A时,除添加新的方法完成新增功能P2外,尽量不要重写父类A的方法,也尽量不要重载父类A的方法。

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

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

里氏替换原则的核心原理是抽象,抽象又依赖于继承这个特性,在OOP中,继承的优缺点都相当的明显。
继承的优点:

  1. 代码重用,减少创建类的成本,每个子类都拥有父类的方法和属性;
  2. 子类和父类基本相似,但又与父类有所区别;
  3. 提高代码的可拓展性;

继承的缺点:

  1. 继承是侵入性的,只要继承就必须拥有父类所有的属性和方法;
  2. 可能造成子类代码冗余,灵活性减低;
  3. 如果一个类被其他的类所继承,则当这个类需要修改时,必须考虑到所有的子类,并且父类修改后,所有涉及到子类的功能都有可能会产生故障。

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

举例说明继承的风险,我们需要完成一个两数相减的功能,由类A来负责。

    @Test
    public void lspTest() {

        A a = new A();
        System.out.println("100 + 50 = " + a.add(100, 50));

    }


    private class A {
        public int add(int a, int b) {
            return a + b;
        }
    }
    
     运行结果:
    100 + 50 = 150

后来,我们需要增加一个新的功能:完成两数相加,然后再与100求和,由类B来负责。由于类A已经实现了第一个功能,所以类B继承类A后,只需要再完成第二个功能就可以了,代码如下:

    @Test
    public void lspTest() {

        A a = new A();
        System.out.println("100 + 50 = " + a.add(100, 50));


         B b = new B();
        System.out.println("100 + 50 = " + b.add(100, 50));
        System.out.println("100 + 50 + 100 = " + b.minus(100, 50));

    }


    private class A {
        public int add(int a, int b) {
            return a + b;
        }
    }

    private class B extends A {

        public int add(int a, int b) {
            // a 不再是加上 b
            return a - b;
        }

        private int minus(int a, int b) {
            return add(a , b) + 100;
        }

    }
    
     运行结果:
    100 + 50 = 150
    100 + 50 = 50
    100 + 50 + 100 = 150

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

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

里氏代换原则是实现开闭原则的重要方式之一。除了父类外,在传递参数时使用基类对象,在定义成员变量、定义局部变量、确定方法返回类型时都可使用里氏代换 原则,针对基类编程,在程序运行时再确定具体子类。

让项目拥有变化的能力-依赖倒置原则DIP

依赖倒转原则(Dependency Inversion Principle, DIP):高层模块不应该依赖底层模块,两者都应该依赖其抽象,抽象不应该依赖于细节,细节应当依赖于抽象。

经典问题: 类A直接依赖类B,假如要将类A改为依赖类C,则必须通过修改类A的代码来达成。这种场景下,类A一般是高层模块,负责复杂的业务逻辑;类B和类C是低层模块,负责基本的原子操作;假如修改类A,会给程序带来不必要的风险。

解决方案: 将类A修改为依赖接口I,类B和类C各自实现接口I,类A通过接口I间接与类B或者类C发生联系,则会大大降低修改类A的几率。

如果说开闭原则是面向对象设计的目标的话,那么依赖倒转原则就是面向对象设计的主要实现机制之一,它是系统抽象化的具体实现.在Java中,抽象就是指接口或抽象类,两者都是不能被实例化的;细节就是实现类,实现接口或继承抽象类而产生的类就是细节,它是可以被实例化的;

依赖倒置原则在Java语言中的表现为:模块间的依赖通过抽象发生,实现类之间不发生直接的依赖关系,其依赖关系是通过接口或抽象类产生的;

依赖倒转原则要求我们在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。

在实现依赖倒转原则时,我们需要针对抽象层编程,而将具体类的对象通过依赖注入(DependencyInjection,DI)的方式注入到其他对象中,依赖注入是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。

常用的注入方式有三种:

  1. 构造注入,构造注入是指通过构造函数来传入具体类的对象;
  2. 设值注入,设值注入是指通过Setter方法来传入具体类的对象;
  3. 接口注入,接口注入是指通过在接口中声明的业务方法来传入具体类的对象;

这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。

依赖倒置原则基于这样一个事实:相对于细节的多变性,抽象的东西要稳定的多。以抽象为基础搭建起来的架构比以细节为基础搭建起来的架构要稳定的多。

依赖倒置原则的核心思想是面向接口编程,我们依旧用一个例子来说明面向接口编程比相对于面向实现编程好在什么地方。假设当前场景为读文章:

    @Test
    public void dipTest() {
        Writer writer = new Writer();
        Book book = new Book();
        writer.read(book);
    }


    private class Book {

        private String getContent() {
            return "读书";
        }
    }

    private class Writer {
        private void read(Book book) {
            System.out.println("作家" + book.getContent());
        }
    }
    
    运行结果:作家读书

假如有一天,需求变成这样:作家读书,也可以读报纸,报纸的代码如下:

 private class Newspaper {

        private String getContent() {
            return "读报纸";
        }
    }
    
    private class Writer {
        private void read(Book book) {
            System.out.println("作家" + book.getContent());
        }

        private void read(Newspaper newspaper) {
            System.out.println("作家" + newspaper.getContent());
        }
    }
    
    运行结果:
    作家读书
    作家读报纸

仅仅是添加阅读报纸的功能就需要去修改Writer类,以后还要读杂志、小说等等的怎么办呢?这显然不是好的设计,因为就是Writer与Book、Newspaper之间的耦合性太高了(这里是很明显违背了开闭原则)
下面我们引入一个抽象的接口IReader来降低他们之间的耦合度

    @Test
    public void dipTest() {
        Writer writer = new Writer();
        writer.read(new Book());
        writer.read(new Newspaper());
    }
    
    private interface IReader {
        String getContent();
    }
    
    private class Book implements IReader{

        @Override
        public String getContent() {
            return "读书";
        }
    }

    private class Newspaper implements IReader{

        @Override
        public String getContent() {
            return "读报纸";
        }
    }
    
     运行结果:
    作家读书
    作家读报纸

这只是一个简单的例子,实际情况中,代表高层模块的Writer类将负责完成主要的业务逻辑,一旦需要对它进行修改,引入错误的风险极大。所以遵循依赖倒置原则可以降低类之间的耦合性,提高系统的稳定性,降低修改程序造成的风险。
注: 这里的Writer类也可以说是表明一种职业,有兴趣的话可以将它再抽象化;

依赖倒置原则的核心就是要我们面向接口编程。采用依赖倒置原则给多人并行开发带来了极大的便利,比如上例中,原本Writer类与Book类直接耦合时,Writer类必须等Book类编码完成后才可以进行编码,因为Writer类直接依赖于Book类。修改后的程序则可以同时开工,互不影响,因为Writer与Book类一点关系也没有。参与协作开发的人越多、项目越庞大,采用依赖导致原则的意义就越重大,现在很流行的TDD开发模式就是依赖倒置原则最成功的应用。

系统有更高的灵活性-接口隔离原则ISP

接口隔离原则(Interface Segregation Principle, ISP):
定义一:客户端不应该依赖那些它不需要的接口; 定义二:类间的依赖关系应该建立在最小的接口上;

经典问题: 类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法。

解决方案: 将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系。也就是采用接口隔离原则。

接口原则将非常庞大、臃肿的接口拆分为更小更具体的接口,这样客户端只需要知道他们感兴趣的方法;接口隔离原则目的是系统解开耦合,从而更容易重构、更改和重新部署;
接口隔离原则的含义是:建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。在面向对象编程语言中,实现一个接口就需要实现该接口中定义的所有方法,因此最好能根据其职责不同分别放在不同的小接口中,以确保每个接口使用起来都较为方便,并都承担某一单一角色。接口是设计时对外部设定的“契约”,通过分散定义多个接口,可以预防外来变更的扩散,提高系统的灵活性和可维护性。

未遵循接口隔离原则的设计.png

类A依赖接口I中的方法1、方法2、方法3,类B是对类A依赖的实现。类C依赖接口I中的方法1、方法4、方法5,类D是对类C依赖的实现。对于类B和类D来说,虽然他们都存在着用不到的方法(也就是图中红色字体标记的方法),但由于实现了接口I,所以也必须要实现这些用不到的方法。
可以看出如果接口过于臃肿,只要接口中出现的方法,不管对依赖于它的类有没有用处,实现类中都必须去实现这些方法,这显然不是好的设计。下面将这个设计修改为符合接口隔离原则的接口,我们需要对接口I进行拆分。

image.png

| 这里我就不举例子了,上面两幅图已经很清晰的体现了接口隔离原则;

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

  1. 需要注意控制接口的粒度,接口不能太小,如果太小会导致系统中接口泛滥,不利于维护;接口也不能太大,太大的接口将违背接口隔离原则;
  2. 一般而言,接口中仅包含为某一类用户定制的方法或是为依赖接口的类定制服务,不应该强迫客户依赖于那些它们不用的方法,只有专注地为一个模块提供定制服务,才能建立最小的依赖关系。
  3. 提高内聚,减少对外交互。使接口用最少的方法去完成最多的事情。

接口隔离原则与单一职责原则的不同

  1. 单一职责原则注重的是职责;而接口隔离原则注重对接口依赖的隔离;
  2. 单一职责原则主要是约束类,其次才是接口和方法,它针对的是程序中的实现和细节;而接口隔离原则主要约束接口接口,主要针对抽象,针对程序整体框架的构建。

更好的可扩展性-迪米特原则LOD

迪米特法则(Law of Demeter, LoD),或称最小知识原则(Least Knowledge Principle, LDP):一个对象应该对其他对象有最少的了解。

经典问题: 类与类之间的关系越密切,耦合度越大,当一个类发生改变时,对另一个类的影响也越大。

解决方案: 尽量降低类与类之间的耦合。

迪米特法则还有一个更简单的定义:只与直接的朋友通信。 在迪米特法则中,对于一个对象,其朋友包括以下几类:

  1. 当前对象本身(this);
  2. 以参数形式传入到当前对象方法中的对象;
  3. 当前对象的成员对象;
  4. 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友;
  5. 当前对象所创建的对象。

迪米特法则要求我们在设计系统时,应该尽量减少对象之间的交互,如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用,如果其中的一个对象需要调用另一个对象的某一个方法的话,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。

假设当前场景为通过中介找房:

    @Test
    public void ispTest() {
        Tenant tenant = new Tenant(18, 2720);
        Mediator mediator = new Mediator();
        tenant.rentRoom(mediator);
    }

    private class Room {

        private int mPrice;
        private int mArea;

        Room(int price, int area) {
            mPrice = price;
            mArea = area;
        }

        @Override
        public String toString() {
            return "当前房子" +
                    "房价: " + mPrice +
                    ", 房子面积: " + mArea +
                    '}';
        }
    }

    // 中介
    private class Mediator {

        List<Room> mRooms = new ArrayList<Room>();

        Mediator() {
            for (int i = 0; i < 5; i++) {
                mRooms.add(new Room(15 + i, (15 + i) * 150));
            }
        }

        private List<Room> getRooms() {
            return mRooms;
        }
    }

    // 租客
    private class Tenant {

        private int mRoomPrice;
        private int mRoomArea;
        private int mDiffPrice = 10;
        private int mDiffArea = 100;

        Tenant(int price, int area) {
            mRoomPrice = price;
            mRoomArea = area;
        }

        private void rentRoom(Mediator mediator) {
            List<Room> rooms = mediator.getRooms();
            for (Room room: rooms) {
                if (isSuitable(room)) {
                    System.out.println("租到房子了," + room.toString());
                    break;
                }
            }
        }

        private boolean isSuitable(Room room) {
            return Math.abs(room.mPrice - mRoomPrice) < mDiffPrice
                    && Math.abs(room.mArea - mRoomArea) < mDiffArea;
        }
    }

从上面的代码可以看到Tenant类不仅依赖了Mediator类还频繁的跟Room类打交道。当Room变化时Tenant也会随之变化,而Tenant类又与Mediator类耦合,这就出现了纠缠不清的关系。这个时候我们就需要分清谁是我们的朋友了

@Test
    public void ispTest() {
        Tenant tenant = new Tenant(18, 2720);
        Mediator mediator = new Mediator();
        tenant.rentRoom(mediator);
    }

    private class Room {

        private int mPrice;
        private int mArea;

        Room(int price, int area) {
            mPrice = price;
            mArea = area;
        }

        @Override
        public String toString() {
            return "当前房子" +
                    "房价: " + mPrice +
                    ", 房子面积: " + mArea +
                    '}';
        }
    }

    // 中介
    private class Mediator {
        private int mDiffPrice = 10;
        private int mDiffArea = 100;
        List<Room> mRooms = new ArrayList<Room>();

        Mediator() {
            for (int i = 0; i < 5; i++) {
                mRooms.add(new Room(15 + i, (15 + i) * 150));
            }
        }

        private Room rent(int price, int area) {
            for (Room room : mRooms) {
                if (isSuitable(room, price, area)) {
                    System.out.println("租到房子了," + room.toString());
                    return room;
                }
            }
            return new Room(0, 0);
        }

        private boolean isSuitable(Room room, int price, int area) {
            return Math.abs(room.mPrice - price) < mDiffPrice
                    && Math.abs(room.mArea - area) < mDiffArea;
        }

    }

    // 租客
    private class Tenant {

        private int mRoomPrice;
        private int mRoomArea;

        Tenant(int price, int area) {
            mRoomPrice = price;
            mRoomArea = area;
        }

        private void rentRoom(Mediator mediator) {
             mediator.rent(mRoomPrice, mRoomArea);
        }
    }

这里是将Room的判断操作移到了Mediator类中,这本该是Mediator类的职责,根据租客的设定条件查找房子,并将结果返回给租客;租客不应该知道太多房子的细节,我们只需通过中介沟通就好,不需要跟房主等其他角色沟通,因为这些角色都不是我们的直接朋友。“只与直接的朋友通信”能将我们从复杂的关系网中抽离出来,使程序耦合性更低,更稳定;

在将迪米特法则运用到系统设计中时,要注意下面的几点:

  1. 在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;
  2. 在类的结构设计上,每一个类都应当尽量降低其成员变量和成员函数的访问权限;
  3. 在类的设计上,只要有可能,一个类型应当设计成不变类;在对其他类的引用上,一个对象对其他对象的引用应当降到最低。

迪米特法则的做法观念就是类间解耦,弱耦合,只有弱耦合了以后,类的复用率才可以提高,其要求的结果就是产生了大量的中转或跳转类,导致的复杂性提高,同时也为维护带来了难度,所以在采用迪米特法则时需要反复权衡,既做到让结构清晰,又做到高内聚低耦合。

但是过度使用迪米特法则,也会造成系统的不同模块之间的通信效率降低,使系统的不同模块之间不容易协调等缺点。同时,因为迪米特法则要求类与类之间尽量不直接通信,如果类之间需要通信就通过第三方转发的方式,这就直接导致了系统中存在大量的中介类,这些类存在的唯一原因是为了传递类与类之间的相互调用关系,这就毫无疑问的增加了系统的复杂度。解决这个问题的方式是:使用依赖倒转原则(通俗的讲就是要针对接口编程,不要针对具体编程), 这要就可以是调用方和被调用方之间有了一个抽象层,被调用方在遵循抽象层的前提下就可以自由的变化,此时抽象层成了调用方的朋友。

总结

单一职责原则告诉我们实现类要职责单一;
里氏替换原则告诉我们不要破坏继承体系;
依赖倒置原则告诉我们要面向接口编程;
接口隔离原则告诉我们在设计接口的时候要精简单一;
迪米特法则告诉我们要降低耦合。
而开闭原则是总纲,他告诉我们要对扩展开放,对修改关闭。

最后说明一下如何去遵守这六个原则。对于六个原则的遵守并非是和否的问题,而是多和少的问题,也就是说,一般我们只会说遵守程度是否合理,是否很好的平衡了各个原则达到一个较优的解决方案。任何事都是过犹不及,设计模式的六个设计原则也是一样,我们需要的是理解六个原则的思想及原因,根据实际的情况灵活的运用他们,而不是刻意和死板的去遵守它们;

一千个读者眼中有一千个哈姆雷特,如果大家对这六项原则的理解跟我有所不同或是我哪里理解错误,欢迎留言,大家共同探讨。文中例子源码:请点击这里

查考文献: