[关闭]
@XingdingCAO 2017-11-25T23:25:38.000000Z 字数 4644 阅读 2025

迪米特法则的高明之处

OOD Law-of-Demeter

译自:https://dzone.com/articles/the-genius-of-the-law-of-demeter

为什么我们应该遵循迪米特法则?

我们熟知的OOP概念有许多,如:封装、内聚、耦合等,创造这些概念的目的是为了做出好的设计以及写出良好的代码。当然,这些概念都很重要,但是它们又不够具体,不能够在实际开发中直接使用。必须有一个人来解释这些概念,这样的情况下,这些概念就具有了某种主观色彩,并且依赖于这个人的经验与知识。

上述的这些问题也存在于一些设计原则,如:单一职责原则、开闭原则等。这些设计原则允许一个较为宽泛的解释,所以在直接使用方面就有些困难了。

迪米特法则的高明之处就源于其充分、明确的定义。因此,它可以直接在编写代码时被运用,却又几乎同时保证了封装、高内聚与低耦合。迪米特法则的作者成功地将抽象的概念与其本质融入了这个清晰明确的OOP原则。

迪米特原则到底讲了什么?

原定义如下:

对于一个类C,它拥有的一个方法称为M
方法M发送信息的目标对象必须为:
* M方法的参数对象,还有对自身的引用(被方法M创建的实例对象、被方法M调用的方法生成的实例对象、全局对象)。
* 类C的实例对象。

在早期的面向对象设计中,对象之间就会互相“发送消息”。这就是它们交流的方式。所以上面的描述:方法M发送信息,也就大致等同于:方法M使用的对象,或者实际上就是说:方法M中所有使用的方法的宿主对象。

你可能已经注意到括号中补充的说明,那些其实是额外的规则。所以,在此处重新规整一下整个法则,所有的方法M发送消息的对象都单独列出:
1. 本身(Java中的this)
2. 方法M的参数对象
3. 类C的实例对象
4. 被方法M创建的对象 或 方法M调用的方法创建的对象
5. 全局(类C内部的作用域)对象(Java中的静态字段)

迪米特法则意味着什么?

迪米特法则规定了我们在方法M中可以使用的方法的宿主对象。所以,这些规定的目的是为了禁止什么?

第4条规则说到:“被方法M创建的对象” 或 “ 方法M调用的方法创建的对象”是允许的,那么这就禁止了方法调用的宿主对象是调用前就已经存在了的对象

这些调用前就已经存在的对象必然存在对它们的引用,否则没有人可以对其访问。因此,这些对象必然通过其他类的字段实例来被引用。进行上述了“排外”后,第5条规则又允许了全局(类C内部的)对象,这就又留给了我们使用对象的实例变量的机会(若必须使用其他类的实例,则将其引入类的内部)。

第1、2、3条规则进一步允许了“自身”、方法M的参数和类C的所有实例。
综上所述,迪米特法则禁止“发送消息”至已经存在的、被其他类掌握的字段实例,除非这个类也被掌握在本类下(类C)或者作为方法M的参数传递过来。

样例

1.本例的close方法符合迪米特法则吗?

  1. public final class NetworkConnection {
  2. public void close() {
  3. sendShutdownMessage(); // Allowed?
  4. }
  5. private void sendShutdownMessage() {
  6. ...
  7. }
  8. }

close方法对sendShutdownMessage方法的调用显然是符合规则的,因为正如第1条规则所述:任何方法可以调用当前对象。

2.本例的close方法呢?

  1. public final class NetworkConnection {
  2. private Socket socket;
  3. public void close() {
  4. socket.close(); // Allowed?
  5. }
  6. }

当然也是符合规则的,因为正如第3条规则所述(socket是本类的一个字段)。

3.本例的close方法符合吗?

  1. public final class NetworkConnection {
  2. public void send(Person person) {
  3. sendBytes(person.getName().getBytes());
  4. sendBytes(person.getPhoto().getBytes();
  5. ...
  6. }
  7. }

注意!本例违反了迪米特法则。这个方法接受了参数person,所有对该实例的方法调用是允许的。然而,再次在该实例的方法(getName() getPhoto())返回值上调用方法(getBytes())是不允许的。假设这个例子的getter方法是返回某个被其他对象的实例字段引用的、已经存在的对象,那么这个方法就访问了明显不该直接访问的对象。

链式调用

有些人将对迪米特法则的解释集中在链式调用上,表述为减少方法中引用的数量,更直接一点:减少成员运算符“.”的使用。

car.getOwner().getAddress().getStreet();

上例明显违背了迪米特法则。所有对象(如:Owner Address)都是在方法M调用前就存在的实例变量,这就违反了规定。

流畅API

流畅接口是由Martin Fowler和Eric Evans创造的,流畅API意味着你构建一个API需要遵循以下要点:

  1. API用户能够容易理解API
  2. API为完成一个任务能够执行一系列动作,比如Java中可以看成是一系列方法调用,方法链。
  3. 每个方法名称应该是与业务领域相关的专门术语
  4. API应该能提示指导API用户下一步用什么,以及某个时刻用户可能采取的操作。

有些人将迪米特法则这样理解:既然链式调用被禁止了,那么流畅API设计也应该被禁止。流畅API的设计是为了使的类库或类的使用语法更加简单,例如:

  1. Report report = new ReportBuilder()
  2. .withBorder(1)
  3. .withBorderColor(Color.black)
  4. .withMargin(3)
  5. .withTitle("Law of Demeter Report")
  6. .build();

为了看出这种结构是否允许,我们将每个调用的方法全都分析一下。ReportBuilder对象的实例的初始化是在方法M中完成的,因此withBorder(1)这个方法的调用是被第4条规则允许的。

然后,所有后面的方法调用,包括build()都使用了上个调用的返回值引用。这些返回值都是同一个对象的引用。不仅在本例中,而且在大部分流畅API的设计中都是这样一次又次一次地返回相同对象地引用。这样的话,既然这个对象是在方法M中创建地,那么就是被第4条规则允许。

结论:迪米特法则并没有禁止流畅API。

“包装”方法

一些观点,包括wiki百科,都在就迪米特法则中调用其他对象的“包装”方法的使用进行争论,例如:

  1. car.getOwner().getAddress().getStreet(); // This is a violation
  2. car.getOwnerAddressStreet(); // This is a proposed "solution"

这种解决方法存在着许多问题,例如:getOwnerAddressStreet()方法是怎么实现的?可能是这样的:

  1. public final class Car {
  2. private final Owner owner;
  3. public Street getOwnerAddressStreet() {
  4. return owner.getAddressStreet();
  5. }
  6. ...
  7. }

两个新的方法被引入,但并没有实际结构或设计的改变。尽管迪米特法则被严格遵守,但违背了其精神。

结构依然可以从方法名中窥探出来,获取主人的街道地址。问题是:为什么调用这个方法的方法M要这样做?它怎么知道信息存在于这个方法中呢?这就是迪米特法则与面向对象设计的结合的问题了。这个法则的目的是不想卷入额外的障碍,使得我们的“生活”变得更艰难(迪米特法则又可以概况为“不要和陌生人说话,只和朋友交流”)。同时,我们应该确保我们没有刻板地遵守迪米特法则却不去考虑整体的设计。

迪米特法则明确告诉我们:不能直接访问Street对象。因为,你甚至不知道它的存在与否。不要用“包装”方法去愚弄法则(欺骗自己)。更深层次的设计问题则有待解决。

单纯的“数据结构”

Robert C. Martin的著作Clean Code中,有一段关于迪米特法则定的陈述,其中有两点很有意思。其一就是通过使用直接访问成员变量,迪米特法则可能会被绕开,所以,不要效仿下面的做法:

  1. car.getOwner().getAddress().getStreet();

上例是错误的,但是它可以被转化为下例,这样就符合了迪米特法则的语法,因为它没有调用方法:

  1. car.owner.address.street; // Using direct access to fields

有两种方式可以推理出这种做法的错误性。第一种是:这种做法还是在努力符合语法,却没有将语义理解并执行。第二种是:直接访问变量还是一种“消息的发送”,还是一种对象间的沟通,这样的话还是违背了迪米特法则。

关于上面的做法,Robert C. Martin还提到了更细微的一点——不管怎样,迪米特法则都不应该应用在单纯的数据结构上。

纯粹的数据结构(没有行为、只有数据的对象)是一块过程、函数堆积(或混合而成的范例堆积)而来的积木。它们应该被豁免,因为迪米特法则是一条面向对象的设计准则。

Getter方法

大多数情况下,如果使用了Getter方法却没有违反迪米特法则,那么会是下面的情况:

  1. public final class Car {
  2. private final Owner owner;
  3. public Owner getOwner() {
  4. return owner;
  5. }
  6. }

本例并不是一个巧合,假如有另外一个类中方法使用了上面的Getter方法:

  1. public final class Garage {
  2. public void isAllowed(Car car) {
  3. Owner owner = car.getOwner(); // Allowed?
  4. ...
  5. }
  6. }

上例对Getter方法的调用允许吗?car是方法的参数对象,所以在这个对象上调用任何方法都是正确的,所以允许。

但是,我们会对Getter方法的返回对象干什么呢?这个Getter方法返回了一个owner对象,它既不是原方法的参数,也不是原方法可以直接访问的。因此,任何方法调用都不能在它上进行,不能进行toString() hashCode(),什么方法都不行!

这种做法是不被赞同的。尽管对Getter方法的调用严格来说正确地,但是我们却无法使用返回值。Getter方法从设计上就违反了迪米特法则。

正如前面说的,它既不是巧合也不是无意的。在面向对象的范例中,对象应该告诉其他对象做什么,但是应该是代表性的行为,而不是从其他对象中查询到数据后自己完成所有工作。

何时应用?

本篇博文还有其它文章都表达了一个观点:迪米特法则更多是一种“建议”或“指南”而不是一条“法则”。理论上,它很好,但是它不适用于数据结构、Java Beans、entities、业务对象、值对象、数据传输对象或者表现层、持久层的对象。

一个面向对象的开发者应该多去应用而不是避之不用。迪米特法则的众多思想来自幕后不同的程序范例,而在这些范例中迪米特法则可能有着不同的权重甚至是不同的解释。

结论

迪米特法则是一个定义良好的一系列面向对象的准则。一个正确理解并熟练运用面向对象设计原则的设计者更能从表里与内在理解这个法则。

更进一步,一旦代码偏离了面向对象的道路,它就可以发出明确的信号。因此,它是保证设计与代码分格行驶在正确道路的无价的工具。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注