在http://www.pragprog.com/ 看到有不少关于编程的好文章,当初好像是从《The Pragmatic Programmer》一书作者的介绍中得知这个网站的。有篇关于OOP的文章读了好几遍,每次读总是会忘了上一次读后理解到的意思,于是尝试着翻译一下。很明显我的英文非常蹩脚,希望多多指正!谢谢!
Procedural code gets information then makes decisions. Object-oriented code tells objects to do things. — Alec Sharp
过程式程序获取信息然后决策;OO程序则告诉对象做某事情。
也就是说,你应该尽量告诉对象你希望它们去做的事情;而不要询问它们的状态之后做出决定,最后才告诉它们做什么事情。
问题在于,调用方不应该基于被调用对象的状态来做决定,这会导致被调用对象的状态被改变。你正在实现的程序逻辑很可能是被调用对象的职责,而不是调用方本身的,因为你在被调用对象外部做决定破坏了被调用对象的封装。
当然,你可能会说:这是显而易见的,我从来没有写过这样子的代码。尽管如此,我们可以很容易地检测一些被引用对象,然后基于返回结果来调用不同的方法。但是这或许不是最好的办法。告诉被调用对象你想要的,让它来决定怎么做,用命令式而不是过程式的思维模式。
基于各个类的职责来设计这些类,你就可以很容易地跳出这个陷阱,然后你能够自然地指定这些类应该执行的命令,这与通过查询获得对象的状态是相反的。
Just the Data:仅仅是数据
这种实践的主要目的是确保正确分解类的职责,即将合适的功能点放置在恰当的类中,这不会引起该类对另一个类的过度耦合。
请求并从对象中获得数据的最大风险是,你不仅仅获取数据。从更大的方面讲,你不是在获取一个对象。尽管你从查询中接收到的是一个结构化的对象(例如,String 对象),该对象已经不是语义上的对象了。它与自身拥有者已经没有关联,因为你获取到的是包含内容为“RED”的String 对象,而你不能够询问该 String 对象中它代表的是什么。它代表拥有者的名字?Car的颜色?转速表的当前状态?对象才知道这些含义,而数据(data)是不了解的。
OOP 的基本原则就是方法与数据的统一,将这两者不恰当的分离会使得你回到过程式编程。
任何类都有不变式(即必须一直为true)。一些编程语言(如Eiffel)直接提供了对指定并检测不变式的支持,大多数其他的语言则不支持,但这只是意味着不变式不被显式支持,其实还是存在的。举个例子,迭代器有有如下的不变式(以Java为例子):
hasMoreElements() == true //implies that: nextElement() //will return a value |
也就是说,如果 hasMoreElements()为 true,则能够成功获得下一个元素,否则将会发生一些麻烦的意想不到的错误。如果你正在运行没有正确使用同步(加锁)的多线程代码,上面的不变式可能不成立,因为其他的线程已经在你之前取出了最后一个元素。
根据‘按契约设计’(Design by Contract),只要方法(查询与命令)可以自由地混合在一起,并且不会违反类的不变式,那么这是可行的。但是,在你维护类不变式时可能已经有意无意地增加了调用者和被调用者之间的耦合度,而这耦合度依你暴露出来的类的状态而定。
例如,你持有一个容器对象 C,可以将它的迭代器暴露给容器内持有对象(JDK类库大多这样做),或者你可以提供一个方法,该方法会执行集合内的一些成员函数来操作所有元素。在Java中你可能会这样声明:
public interface Applyable { public void each(Object anObject); } ... public class SomeClass { void apply(Applyable); } //Called as: SomeClass foo; ... foo.apply( new Applyable() { public void each(Object anObject) { // do what you want to anObject } }); |
这在支持函数指针的语言中很容易实现,Perl、Smalltalk中内建了函数指针这种概念的语言中会更简单。但是对于上面的代码,你应该有这样一种想法:运行这个函数来遍历容器中的所有元素,我不关心代码怎么做。
不管是通过 apply方式还是迭代器方式,你都能够获得同样的结果。最主要的抉择在于耦合度:最小化耦合度,只暴露最少必需的状态。对于上面这个例子,apply方式比迭代器方式暴露更少的状态。
我们决定尽可能少地暴露类的状态,以此来达到我们的目的。现在我们开始在类内部对另一个对象发送命令和查询,而不管系统是否支持。是的,你可以这样做,但依据Demeter原则(迪米特法则)这不是好的做法。Demeter原则限制类间的交互,以此来减少类间的耦合。(这里有更多相关讨论)
Demeter法则指的是,一个对象与越多对象交流,就得承担由这些对象变化时所带来的越大的风险。因此,不仅仅说尽可能减少,而且不应该跟超过不必要的对象有关系。实际上,根据Demeter法则,对象中任一方法应该只调用以下的方法:
▶自身的;
▶方法中传入的参数;
▶该对象所创建的对象;
▶组合的对象。
不在上面列出的方法则属于从其他调用返回的对象。例如(这里使用Java语法):
SortedList thingy = someObject.getEmployeeList();
这是我们应该避免的(上面的foo.getKey()也是要避免的例子)。类似的这一类直接访问扩大了调用者与其所需对象的耦合度。这些调用者依赖于以下事实:
▶SortedList 中持有employees 对象;
▶SortedList 的 add 方法是addElementWithKey();
▶ foo 查询其 key的方法是 getKey()。
上面的代码应该替换为:
现在调用者只是依赖于将foo添加(add)到thingy当中去,这意味着高层依赖于一种职责,而不是依赖于实现。
当然,这种做法的不足之处是你必须写大量小型的包装器方法来委托容器元素的遍历(或其他操作)。这需要在效率和类的紧耦合之间做出权衡。
类之间的耦合度越高,你所做的修改会引起其他地方被破坏的几率也就越高,这会导致代码很脆弱。
相比应用程序运行时的低效率,很多情况下开发与维护紧耦合的类更容易使你陷入泥潭。
现在回到ask与tell,ask就是查询,tell是命令。我赞成将这二者分离为多个方法开来维护,为什么呢?
▶如果你考虑到按照命令来执行指定的、定义良好的动作,这有利于维护;
▶如果你的类完全基于命令,这能够帮助你考虑类不变式。(如果你只是处理数据,那么没必要对不变式考虑太多)
▶如果你能假定查询的执行不会有副作用,那么你可以:
▶在调试器中使用查询,并且不会影响到测试的进行;
▶创建内建的、自动的回归测试;
▶评测类不变式、前置条件与后置条件。
上面最后一点也就是为什么Eiffel要求在断言(Assertion)中只能调用无副作用的方法。但是在C++或者Java中,如果你想要在某个代码点手工检测一个对象的状态,只有当你知道某个查询不会引起其他某些地方被修改,那么你才可以自信地调用这个方法。