[译]Tell, Don't Ask

原文链接:Tell, Don’t Ask

Alec Sharp在其最近的书Smalltalk by Example[1]中指出了一个极有价值的观点:

过程化的代码获取信息并做出决定。面向对象的代码只是告诉对象去做。
— Alec Sharp

这意味着,你应该尝试直接告诉对象你希望他们去做什么,而不是询问他们的状态,做出决定,再告诉他们去做什么。

这样做的问题是,作为调用者,你不应该根据你获得的被调用对象的状态来做决定, 然后再去改变对象的状态。你实现的这部分逻辑很可能是被调用对象的职责,而不是你的。由你来在对象外部做出决定破坏了它的封装。

当然,你可以说,这是显然的。我从来不会写那样的代码。然而,我们仍然会通过检验一些引用对象,并根据不同的返回值调用不同的方法。但是这也许不是最好的做法。告诉对象你要什么,由它来决定如何做。要用声明式的思考方式,而不是过程式的!

仅仅得到数据

这种实践的主要目的是确保任务被分配到了正确的类型中的正确的函数的正确功能中,避免引入和于其他类型额外的耦合。

这里最危险的信号是向一个对象请求数据,而你仅仅得到了数据。你得到的不是一个对象,至少在很大意义上不是。尽管你查询得到的是一个结构意义上的对象(比如一个String),它在语义上已经不是一个对象的。它和拥有它的对象已经失去了关联。你得到了一个内容是”RED”的字符串,但是你无法从这个字符串中获得它的实际意义。它是拥有者的姓?是车的颜色?是当前测速表的状态?一个对象知道这些信息,而数据不知道。

面向对象编程的基础原则是统一方法和数据。讲这两者分开会将你带回到面向过程编程。

不变序列是不够的

每一个类中都存在不变序列-那些永远为真的条件。某些语言(比如Eiffel)直接提供定义和检查不变序列的支持。大多数语言没有,但这只是因为这些不变序列没有被显式的说明—他们仍然存在。比如,一个迭代器有如下的不变序列(这里使用Java描述):

1
2
3
4
hasMoreElements() == true
// 意味着
nextElement()
// 会有返回值

换句话说,如果hasMoreElements()返回true,获取下一个元素的操作一定会成功,否则一定是发生了错误。如果你在没有同步机制(加锁)的情况下运行多线程的代码,可能导致上面的不变序列失败:某个线程在你操作前拿走了最后一个元素。

不变序列不成立,所以一定哪里出错了—出现bug了。

根据契约式设计原则,如果你的方法(查询和命令)可以被自由混合并且你的不变序列无法被打破,一切就是安全的。但是在你维护类型的不变序列时,根据你暴露的状态的多少,你可能会极大的增加调用者和被调用者的耦合。

例如,假设你有一个容器对象c。你可能会暴露这个容器中包含的对象的迭代器,正像JDK core那样,或者你也可以提供一个允许某个函数在集合中所有成员上运行一遍的方法。在Java中你可以这样声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface Applyable {
public void each(Object anObject);
}
...
public class SomeClass {
void apply(Applyable);
}
//调用时:
SomeClass foo;
...
foo.apply(new Applyable() {
public void each(Object anObject) {
// do what you want to anObject
}
});

(原谅这个奇怪的“Apply-able”,我们发现用“-able”后缀命名interface十分便捷,但是在这里英语词汇似乎不太够用)

对于带有函数指针的语言来说,实现这个会容易很多。对于像Perl和Smalltalk这样自带这个概念的语言来说更加容易。但是你应该能明白这里的意思:在容器里所有的条目上运行这个方法,我不在乎这是怎么实现的。

无论使用apply还是迭代器,你都会得到相同的结果。如何选择完全取决于你能接受多大程度的耦合:为了减少耦合,需要尽可能少的暴露状态。如上所示,相比于迭代器,apply暴露了更少的状态。

LoD(得墨忒耳定律)

为了达到目标,我们决定尽可能少的暴露状态。很不错!现在我们可以在自己类的内部随意向其他对象发送命令和请求了吗?好吧,你的确可以这么做,但是根据得墨忒耳定律,这样做同样是有害的。得墨忒耳定律约束了类型间的交互来降低耦合。(更多讨论可以看这里[2])

得墨忒耳定律表明,你的类和越多的对象发生关联,因为某个对象修改导致你的类被破坏的概率就越大。所以,你不仅要尽可能少说话(暴露自己的状态),还要尽可能的少和其他对象发生关联。实际上,根据得墨忒耳定律关于方法的阐述,一个对象的任何方法只可以调用属于以下对象的方法:

  • 它自己
  • 传入这个方法的参数
  • 这个对象创建的对象
  • 复合的对象

显然这里没有包含其他调用返回的对象的方法。例如(这里使用Java说明)

1
SortedList thingy = someObject.getEmployeeList(); thingy.addElementWithKey(foo.getKey(), foo);

这正是我们需要避免的(这里的foo.getKey()也是一个违反了Tell Don’t Ask的例子)。像这样直接访问一个子对象给调用增加了不必要的耦合。调用者依赖于如下实现:

  • someObject将employees保存到一个SortedList中
  • SortedList的add方法是addElementWithKey()
  • foo获取key的方法是getKey()

这里更好的做法是

1
someObject.addToThingy(foo);

现在调用者只依赖于一个实现:他可以将foo添加到thingy中,作为一个任务,这听起来足够抽象,而不必过分依赖实现细节。

这么做的弊端也很明显,你需要写很多的包装函数来做诸如容器遍历之类的细小的工作。这里需要在低效和高耦合之间做出选择。

类型之间耦合越高,你的修改就越可能影响到其他的类。这会增加代码的脆弱性。

根据你应用的不同,在大多数情况下,高耦合代码带来的开发和维护成本的增加造成的影响远大于运行时效率的降低。

分离命令和查询

让我们回到查询和命令的话题上来。我同意将命令和查询放到不同方法的做法。为什么呢?

  • 明确的定义良好的命令维护了“Tell, Don’t Ask”原则。
  • 如果你的类是基于命令的,它有助于你考虑到类型的不变序列。(如果你的类只是抛出数据,你很可能不会考虑到不变序列)
  • 如果你可以假设某个查询的结果没有任何副作用,那么你可以:
    1. 在调试器中使用查询并不影响测试的过程。
    2. 创建内置的自动的回归测试。
    3. 计算类型的不变序列,和前置后置条件。

最后,这就是为什么Eiffel要求只有没有副作用的方法可以在Assertion中被调用。即使在C++和Java中,如果你想在某处代码手动的检查一个对象的状态,并且你知道这个查询不会导致其他改变,你就可以很放心的进行这个操作。

[注释]

[1] Sharp, A. “Smalltalk By Example” McGraw-Hill, 1997.

[2] Appleton, B. Introducing Demeter and Its Laws