迪米特法则LoD

13 阅读12分钟

狭义的迪米特法则

如果两个类不必彼此直接通信,那么这两个类就不应当发生直接的相互作用。如果其中的一个类需要调用另一个类的某一个方法的话,可以通过代理人转发这个调用。

满足转发调用要求的“代理人”条件:

  • 当前对象本身(this);
  • 以参量形式传入到当前对象方法中的对象;
  • 当前对象的实例变量直接引用的对象;
  • 当前对象的实例变量如果是一个聚集,那么聚集中的元素也都满足“代理人”条件;
  • 当前对象所创建的对象;

这样一来,使得系统内部的耦合度降低。在系统某一个类需要修改时,仅仅会直接影响到这个类的“代理人”们,而不会直接影响到其余部分。

狭义的迪米特法则的缺点

遵循狭义的迪米特法则会产生一个明显的缺点:会在系统里造出大量的小方法,散落在系统的各个角落。这些方法仅仅是传递间接的调用,因此与系统的商务逻辑无关。但 Spring 通过运行时产生的代理,则对传递调用是无感知的。

遵循类之间的迪米特法则会使一个系统的局部设计简化,因为每一个局部都不会和远距离的对象有直接的关联。但是,这也会造成系统的不同模块之间的通信效率降低,也会使系统的不同模块之间不容易协调。类如 Spring 的代理,会增加让调用之间的复杂度。

与依赖倒转原则互补使用

为了克服狭义的迪米特法则的缺点,可以使用依赖倒转原则,引入一个抽象的类型引用“抽象陌生人”对象,使“某人”依赖于“抽象陌生人”。换言之,就是将“抽象陌生人”变成代理人。

image.png

“某人”现在与一个抽象角色建立了朋友关系,这样做的好处是“朋友”可以随时将具体“陌生人”换掉。只要新的具体“陌生人”具有相同的抽象类型,那么“某人”就无法区分它们。这就允许“陌生人”的具体实现可以独立于“某人”而变化。

image.png

迪米特法则与设计模式

门面模式

如果一个子系统内部的对象构成自己的“朋友圈”,而子系统外部的对象都属于“陌生人”的话,那么子系统外部的对象与内部的对象就不应当直接通信,而应当通过一个双方都认可的朋友,也就是“门面”对象进行通信,这就导致了门面模式。

下面所示的对象图就显示了一个系统(一方框代表)内部和外部的通信。可以看出,内部对象与外部对象之间耦合度过度,应当降低它们之间的耦合度。

image.png

门面模式创造出一个门面对象,将客户端所涉及的属于一个子系统的协作伙伴的数目减到最少,使得客户端与子系统内部的对象的相互作用被门面对象所取代。显然,门面模式就是实现代码重构以便达到迪米特法则要求的一个强有力的武器。

下面所示的对象图就显示了使用门面模式改造之后的情况。这样做的好处很明显,如果修改子系统内部的话,不会直接影响到外部对象;而外部对象的修改不会导致子系统内部的修改。这样一来,子系统内外的演化就相对绝缘了。

image.png

调停者模式

这里一些对象形成一个中等规模的“朋友圈”,在圈内很多的对象都有排列组合般的交互作用,如下图所示:

image.png

这时,可以通过创造出一个大家共有的“朋友”对象,然后大家都通过这个“朋友”对象发生相互作用,而将相互之间的直接相互作用省略掉,这就导致了调停者模式。

调停者模式创造出一个调停者对象,将系统中有关的对象所引用的其他对象数目减到最少,使得一个对象与其同事的相互作用被这个对象与调停者对象的相互作用所取代,如下图所示,显然,调停者模式也是迪米特法则的一个具体应用。

image.png

广义的迪米特法则

其实,迪米特法则所谈论的,就是对对象之间的信息流量、流向以及信息的影响的控制。

在软件系统中,一个模块设计得好不好的最主要、最重要的标志,就是该模块在多大的程度上将自己的内部数据和其他与实现有关的细节隐藏起来。一个设计得好的模块可以将它所有的实现细节隐藏起来,彻底地将提供给外界的 API 和自己的实现分隔开来。这样一来,模块与模块之间就可以仅仅通过彼此的 API 相互通信,而不理会模块内部的工作细节,这一概念就是“信息的隐藏”,或者叫做“封装”,也就是大家熟悉的软件设计的基本教义之一。

信息的隐藏非常重要的原因在于,它可以使各个子系统之间脱耦,从而允许它们独立地被开发、优化、使用、阅读以及修改。这种脱耦化可以有效地加快系统的开发过程,因为可以独立地同时开发各个模块。它可以使维护过程变得容易,因为所有的模块都容易读懂,特别是不必担心对其他模块的影响。

虽然信息的隐藏本身并不能带来更好的性能,但是它可以使性能的有效调整变得容易。一旦确认某一个模块是性能的障碍时,设计人员可以针对这个模块本身进行优化,而不必担心影响到其他的模块。

信息的隐藏可以促进软件的复用。由于每一个模块都不依赖于其他模块而存在,因此每一个模块都可以独立地在其他的地方使用。一个系统的规模越大,信息的隐藏就越是重要,而信息隐藏的威力也就越明显。

迪米特法则的主要用意是控制信息的过载。在将迪米特法则运用到系统设计中时,需要注意以下几点:

  • 在类的划分上,应当创建有弱耦合的类。类之间的耦合越弱,就越有利于复用。一个处于弱耦合中的类一旦被修改,不会对有关系的类造成波及。
  • 在类的结构设计上,每一个类都应当尽量降低成员的访问权限。换言之,一个类包装好各自的 private 状态。这样一来,想要了解其中的一个类的意义时,不需要了解很多别的类的细节。一个类不应当 public 自己的属性,而应当提供取值和赋值方法让外界间接访问自己的属性。
  • 在类的设计上,只要有可能,一个类应当设计成不变类。
  • 在对其他类的作用上,一个对象对其他对象的引用应当降到最低。

在类设计上的体现

优先考虑将一个类设置成不变类

Java 语言的 API 中提供了很多的不变类,比如:String、BigInteger、BigDecimal 等封装类都是不变类,不变类易于设计、实现和使用。

一个对象与外界的通信大体可以分成两种,一种是改变这个对象的状态的,另一种是不改变这个对象的状态的。如果一个对象的内部状态根本就是不可能改变的,那么它与外界的通信当然就大大地打了折扣。

当涉及任何一个类的时候,都首先考虑这个类的状态是否需要改变。即便一个类必须是可变类,在给它的属性设置赋值方法的时候,也要保持谨慎的态度。除非真的需要,否则不要为一个属性设置赋值方法。

尽量降低一个类的访问权限

在满足一个系统对这个类的需求的同时,应当尽量降低这个类的访问权限(accessibility)。对于顶级(top-level)的类来说,只有两个可能的访问性等级:

  • package-private:这是默认访问权限,如果一个类是 package-private 的,那么它就只能从当前库访问。
  • public:如果一个类是 public 的,那么这个类从当前库和其它库都可以访问。

一个类具有 package-private 访问权限的好处是,一旦这个类发生修改,那么受到影响的客户端必定都在这个库内部。由于一个软件包往往有它自己的库结构,因此一个访问权限为 package-private 的类是不会被客户应用程序使用的。这就意味着软件提供商可以自由地决定修改这个类、增加新的 package-private 类,或者删除任何一个 package-private 类,而不必担心对客户的承诺。

相反,如果一个类被不恰当地设置成 public,那么客户程序就有可能使用这个类。一旦这个类在一个新版本中被删除,就有可能造成一些客户的程序停止运行的情况。

因此,如果一个类可以设置成为 package-private 的,那么就不应当将它设置成为 public 的。

谨慎使用 Serializable

一个类如果实现了 Serializable 接口的话,客户端就可以将这个类的实例序列化,然后再反序列化。由于序列化和反序列化涉及到类的内部结构,如果这个类的内部 private 结构在一个新版本中发生变化的话,那么客户端可能会根据新版本的结构试图将一个老版本的序列化结果反序列化,这会导致失败。

换言之,为防止这种情况发生,软件提供商一旦将一个类设置成为 Serializable 的,就不能再在新版本中修改这个类的内部结构,包括 private 的方法。

因此,除非绝对必要,不要使用 Serializable。

尽量降低成员的访问权限

类的成员包括属性、方法、嵌套类、嵌套接口等,一个类的成员可以有四种不同的访问权限:

  • private :这个成员只可能从当前的顶级类的内部访问。
  • package-private:这个成员可以被当前库中的任何一个类访问,这个默认访问权限。
  • protected:如果一个成员是 protected 的,那么当前库中的任何一个类都可以访问它,而且在任何库中的这个类的子类也都可以访问它。
  • public:此成员可以从任何地方被访问。

作为一个指导原则,在设计一个类的方法时,必须首先考虑将其设置成为 private 的。只有在发现当前库中还有别的类需要调用这个方法时,才可将其访问权限改为 package-private。如果有太多的方法是 package-private 的,那么就需要检查一下这个类是否划分得当,是不是可以重新设计成两个或者多个类。

对于一个 public 的类来说,将一个方法从 package-private 改成 protected 或者 public,意味着它的访问权限有了巨大的变化。一旦一个方法被设置成为 protected,这个方法就可以被位于另一个库中的子类访问,如果设置成 public,那么就可以被任何的类访问。对于一个软件提供商,这就意味着会有客户程序使用这个方法,因此在所有以后的版本中都要承诺不改变这个方法的特征。

因此,将 private 或者 package-private 方法改为 protected 或者 public,必须慎之又慎。

在代码层次上的实现

C 语言要求所有的局域变量都在一个程序块的开头声明,而 Java 语言允许一个变量在任何地方声明,即任何可以有语句的地方都可以声明变量。

在需要一个变量的时候才声明它,可以有效地限制变量的有效范围。一个变量如果仅仅在块的内部使用的话,就应当将这个变量在程序块的内部使用它的地方声明,而不是放到块的外部或者块的开头声明。这样做有两个好处:

  1. 程序员可以很容易读懂程序

设想如果所有的变量都是在开头声明的话,程序员必须反复对照使用变量的语句和变量的声明语句才能将变量的使用与声明对应上。有很多变量在声明后就再也没有使用过,或者随着程序一遍遍地修改,最后有很多变量不再使用了,但是也没有人将声明语句删除。

如果局域变量都是仅仅在马上就要使用的时候才声明,就可以避免上面的情况。

  1. 如果一个变量是在需要它的程序块的外部声明的,那么当这个块还没有被执行时,这个变量就已经被分配了内存;而在这个程序块已经执行完毕后,这个变量所占据的内存空间还没有被释放,这显然是不好的。

同样,如果局域变量都是马上就要使用的时候才声明,也可以避免这种情况。

如何做到迪米特法则

UML类图

image.png

示例代码

public class Dimitri {
    public static void main(String[] args) {
        Star star = new Star("Star");
        Fan fan = new Fan("Fan");
        Company company = new Company("Company");

        Agent agent = new Agent();
        agent.setStar(star);
        agent.setFan(fan);
        agent.setCompany(company);

        agent.meeting();
        agent.business();
    }
}

class Star {
    private String name;

    public Star(String name) {
        this.name = name;
    }

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

class Fan {
    private String name;

    public Fan(String name) {
        this.name = name;
    }

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

class Company {
    private String name;

    public Company(String name) {
        this.name = name;
    }

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

class Agent {
    private Star star;
    private Fan fan;
    private Company company;

    public void setStar(Star star) {
        this.star = star;
    }

    public void setFan(Fan fan) {
        this.fan = fan;
    }

    public void setCompany(Company company) {
        this.company = company;
    }

    public void meeting() {
        System.out.println(fan.getName() + "与明星" + star.getName() + "又见面了");
    }

    public void business() {
        System.out.println(company.getName() + "与明星" + star.getName() + "洽谈业务");
    }
}