本文不是为了论证面向对象方法论。那需要深厚的理论知识和丰富的实践经验。本人两方面都差得很远。
这里只是试图给出一个对面象接口的深入浅出的简单原则。
就象数学很难,数论很难,但是九九表不难,各位数字之和被3整除推出这个整数能被3整除也不难。(但是,两者都很有用)
其实,总感觉oo被多数人都误解了。Fp世界的人一说oo,必然就拿出oo的类呀,继承啊,来和fp比较一番。多态被他们理解为在继承中的一种定制(也就是override了)
而oo世界中的人呢,有的也是抱着类,继承不放。增量设计是他们的圣经。有的则是捧着一本本经典的面向对象著作念经,什么design pattern拉,refactoring啦,OO software construction啦,孜孜不倦地一个一个原则,一个一个定义,一个一个模式地反复辨析。所谓读书破万卷,下笔如有神啊。书上很多微言大义被反复引用,到处套用,但是有时候却总是看上去不是那么回事。同一句话可以被两个人引用却得到不同的结论。就象都是读新约的Christian,却搞出了天主教,新教等等互指为异端的教派。到底是书错了?还是读书的人错了?
个人最讨厌故弄玄虚,把简单的事情搞复杂。这里,让我试试能不能简单地解释一下面向接口这个oo原则。
任何软件都是由各种不同的模块组成的(没错,最小的软件,如一个hello world, 也是)
从自顶向下的观点看,一个大模块由若干个小模块组成,一个小模块又由若干个更小的模块组成。就象大楼由砖造成,砖由分子组成,分子由原子组成一样。
这些模块之间不可能是互相独立的,相互之间肯定要有各种关系。
这些关系可以被总结为简单的两种:
需求和服务。
“需求”就是我要求别人给我提供什么样功能的服务。
“服务”就是我提供一个什么样功能的服务。
所有的关系,都是这样两个原子关系的组合。
当设计任何一个模块的时候,你所看见的就只应该是这个模块对外界的需求和要提供的服务。你不应该看见隔着十万八千里的模块乙,也不应该看到容器或者配置文件是如何把模块们(包括你现在设计的模块)组装起来的。那些,都属于另外一个维度,另外一个和你不相交的宇宙空间的事。
一些c++同志喜欢二分法,软件在他们那里变成一个简单的库-用户这样的结构。在他们看来,库可以任意复杂,只要给用户提供一个简单的接口就够了。
他们没有看到,所谓的“库-用户”的划分是相对的而不是绝对的。一个模块提供一定的功能,那么它相对于使用它的功能的模块就是一个“库”,而这个模块可能还要别人提供一些功能,那么象对于提供这些服务的模块,它又是“用户”。
两个模块,很又可能互相都是用户,也都是库。(只不过相对于不同的服务层面,不同的维度而已)
这样的服务/需求的关系遍布于软件的各个地方。
而所谓oo, 面向接口,就是用来管理这些依赖关系的。
就象你整理自己的计算机网络布线或者电视机后面的各种颜色的线一样,oo也就是一套行之有效的整理这些关系,让它们不要变成一团乱麻的经验之谈。
任何一个理论系统,要想优美,就要遵循下面的准则:
1.完整
2.自恰
3.简单
比如几何学,用了几条最简单的,互相不相关的(所谓“正交”是也)的公理,组建出了一个宏伟的大厦。
面向对象的设计原则也应该如此。我试着给出下面两个公理,让我们看看能不能
1.完整地描述面向对象方法。
2.不自相矛盾。
3.简单。
原则A:需求者只要求自己需要的,ask no more, ask no less!
原则B:服务者只提供最小的能够提供足够功能的界面, promise no more, promise no less!
从这两个原则,我们试着推演一下其它的许多oo的准则来。
1.Ioc原则,或者dip原则。所谓具体依赖抽象,抽象不依赖具体。这是关于需求者的一个设计方法。
遇到一个需要的功能,这个功能的实现实际上和我自己模块的实现不相关,正交,所以我定义一个接口,从外界注射进来一个实现。
那么,用原则A是怎么得到这个准则的呢?
首先,ask no less, 所以如果功能不是和我正交的,那么仅仅定义一个接口从外界注射进来对我就不够。不符合no less。比如,我的实现碰巧让我需要一个InputStreamReader,而不能是StringReader,那么,如果仅仅从外界注射进来一个Reader,对我的实现来说,它达不到我的要求。
所以,根据no less, ioc进来的需要是和当前模块实现正交的。
然后,no more,如果我不用ioc,直接自己new一个FileReader如何?本来只需要InputStreamReader, 你却要求它的子类型FileReader? 明显违反了no more的要求。
再举个例子,ioc要求不要new,而是从外界注射。那么是不是说我们就永远不能new呢?永远都不能X.instance()呢?
当然不是。注意,我们的前提是正交,是no less。
假如,我有一个抽象工厂:
java代码:
1 interface PersistenceFactory{
...}
2
Persistence create();
3 }
那么,当实现这个工厂的jdbc实现的时候,很可能是这样:
java代码:
1 class JdbcPersistenceFactory{
...}
2
Persistence create(){
...}
3
Return JdbcPersistence.instance();
4
}
5 }
这里,你用了一个静态工厂,直接依赖于JdbcPersistence实现类了。是不是违反了ioc规则呢?
当然不是,请注意,我们的模块本身就是实现JdbcPersistence的,那么,从外界再ioc一个PersistenceFactory或者Persistence就不符合正交,no less的要求了。
而且,其实从常识就可以看出来,你JdbcPersistenceFactory的任务就是生成一个关于jdbc的PersistenceFactory。你如果自己不做,再ioc进来,这层层推诿,真正的工作谁做呢?
2.Lsp。所谓任何地方如果你期待的是一个父类型Base,那么把它替换成任何的子类型Derived1, Derived2,程序都能正常工作。
还是关于需求者的。如果你做到了ask no more,比如说你只需要Base提供的功能,就不要在接口上要求Derived1, Derived2,如此,我们自然可以任意替换实际的实现。
如果你做到了ask no less,需要InputStreamReader就直接要求InputStreamReader而不是Reader,你就不会需要在代码中做downcast到InputStreamReader的动作。也就不会出现把Reader替换成StringReader之后出现的运行时错误。
3.单一职责原则。一个模块只应该做一件事。
仍然是需求者的设计方法。这里的“事”的概念应该是一个正交于其它“事”的功能。两个互相紧密耦合的“事”其实是一件事。
根据ask no more,如果一个模块做了两件正交的事,也就是把两个正交的模块耦合在一起,就意味着在我这个滥模块的某个地方有从一个模块到另一个模块的不正当的需求。你要求了你不应该要求的。
4。Ocp。开闭原则。软件,模块应该是可以不用改动代码而被扩展的。
其实,ocp与其说是一个原则,不如说是一个理想。它并没有指出具体的可操作方法,而只是给了一个目标。
一些人认为这就意味着类可以继承。这个看法太狭隘了。扩展一个模块固然可以用继承和override,但是,用接口组合一样可以做到。关键是,如果你的模块依赖抽象的接口而不是具体的类,那么别人就可以很容易通过接口组合,adapter, decorator什么的通过给你传递不同的接口实现而达到扩展的目的。
这里面,仍然是一个简单的ask no more在起作用。
总而言之,所谓面向接口,对需求者来说,就是:用接口定义好自己需要的功能,no more, no less。而所谓“多态”,就是用来实现接口用的工具而已。
完了。
以上都是关于需求者的,那也是面向接口的主要方面。那么如何约束服务者呢?
封装啊。封装实际上完全是给服务提供者的工具。你可以用它来隐藏自己的实现细节,通过最小化对客户的服务承诺来得到最大的设计弹性。
你要写一个BankAccount,是否要公开所有的内部成员呢?一般可能都不是吧?
对于这些服务的提供者,如果公开了数据成员,那么对用户的account.balance = 100;这种动作,你没有任何弹性,只能老老实实地做field update。
相比于setBalance(),后者可以自由地在内部做trace啦,或者把职责转交给内部类啦,等等等等。灵活得多。
那么,为什么后者灵活呢?因为用方法封装了field之后,我们promise的东西少了。我不对客户承诺:“我肯定修改我的balance成员变量”,而是简单地说:“我肯定会修改那个逻辑上的balance,你再getBalance()就可以得到这个新的值”。至于我是不是物理上内部有一个balance变量,是不是setBalance()就直接去修改这个变量,对不起,无可奉告。我可能没有,也可能有。可能今天没有明天有,也可能今天有,明天一重构就没有了。
两者其实都达到了用户的需求。但是后者明显没有承诺不必要承诺的实现细节。所以根据promise no more的原则,封装后比封装前好。
下面再唠叨一遍静态工厂。
对于类
java代码:
1 class X implements I{
...}
2
public X(){…}
3
public static I instance(){return new X();}
4 }
下面两个方法都各自对服务做了什么承诺呢?
jav