软件是一种固化的思维→思维的固化体现为概念和逻辑的固化→为保证简单性,概念和逻辑的分散程度要尽可能的低→概念和逻辑的层次要尽可能的少。
1.分层的利弊
对于软件而言,层次是让人又爱又恨的东西。
很多问题是通过增加层次解决的,但另外一部分问题也是因为层次而导入的。我们来分别看几个例子。
例1:很多时候我们并不希望最终的应用绑定于某个指定平台,如Windows。为了达成这种跨平台的目的,就需要在OS和应用之间加入一个中间层,这个中间层负责屏蔽不同OS的差异。Java虚拟机等走的都是这样一条路线。
例2:当使用XML文件保存配置信息的时候,我们并不希望XML的结构在整个程序中随处可见。比如说,现在我们在Configuration/OutputFolder节点下保存了默认保存目录,但将来很可能节点变成了Configuration/OutputFolder/Save。为了斩开与XML结构的关联,那么我们需要加入一个新的抽象层,来表征XML文件,再通过GetSaveFolder()这样的方法对默认保存目录进行获取。
通过加入层次解决问题的同时,新的问题也随之发生。在眼前蒙上一层薄纱可以防止眼睛被风沙所伤害,但如果蒙上十层,那更严重的后果将会出现——你看不到路了。
从可理解的角度看,只有某一功能所涉及的所有层次,所关联变量的各种可能性都被澄清之后,具体的代码才可能真的被理解。在排错的时候尤其如此。我们来看一个例子:
在用C++创建集合类的时候,我们可能希望对集合类的内存使用方法进行更多的定制。有时候我们可能想预先保留一块内存,接下来在这块内存上进行二次分配来存放各种小的对象。有时候我们也可能想直接在磁盘上分配空间存放集合类的对象。
为了达成上面这些目的,层次又一次站出来发挥作用,我们可以建立allocator这样的类来建立一层抽象,创建集合类的时候,可以通过指定不同的allocator来控制内存使用的方法。这应该是不错的设计方法,C++标准模板库里就是这么做的。
接下来我们来看一旦出了错的情形。我们可能希望放入集合类的对象总是进行浅复制(Swallow Copy),为此重载了类的拷贝构造函数和赋值函数,但最终发现当对象被放入集合类的时候,不知道为什么总是不成功。这个时候,逻辑上程序没有任何问题,因此只靠脑子想是完全解决不了问题的。为了排错,我们只能启动调试器。
调试的过程中,我们通常并不能一下就确认问题和allocator究竟有没有关联,所以为了找出问题所在,我们也要对allocator这一层次做点分析。这种分析的开销事实上就成为添加allocator这一层次的代价——在这一场景下,所需要的只是分配内存,但却必须付出了解allocator机制的代价。
List容器的声明如下:
通过添加allocator这个层次的方法解耦了“在哪里分配”与“容器的实现”,但代价则是,一旦有问题,就要去挖穿各个层次,这有时候很困难。
通过上述的例子我们可以大致体会到层次这把双刃剑的威力。通过层次我们可以让软件更灵活,抽象更充分;但层次也会把达成某一功能所必需的信息进行分割,增加复杂度。所以层次的多少往往并非是一个对与错的问题,而是一个程度问题,究竟什么样的层次才合适,是需要现场的人进行判断的。
2.层次与信息分割相对冲的实例
为了更好地理解层次与抽象程度进行对冲这一事实,我们来看一个著名的例子,这个例子取自《重构:改善既有代码的设计》,代码则主要来自侯捷先生的网站,标为粗体的注释则是为了说明问题加上去的。
程序的目的是为影片出租店计算每一位顾客的消费金额并打印报表。操作者会告诉程序:顾客租了哪些影片,租期。程序则根据租赁时间和影片类型算出费用。影片分为3类:普通片、儿童片和新片。除了计算费用,程序还要计算每个人的积分,积分会根据影片种类而有所不同。
1)最初版本。抽象不充分,但层次较少的代码。
表示电影和租赁的类都是数据类,因此略掉get/set相关的代码。
原作者指出了这段程序的明显缺点。
●报告部分和计算部分混杂在一起。一旦要添加新格式的报告,如html格式的,那么需要编写全新的htmlStatement()。这将导致同样的计费标准和影片分类规则在不同的代码段中存在,十分不利于面对变化。
事实上这就是不正交,可以独立的东西没有独立。几乎所有人都会认同原作者的观点。但问题总有两面性,这么写程序也并不是一无是处,例如:
●层次很少,任何人都可以一眼看穿程序做了什么。新手也可以。
接下来我们看重构后的代码。
2)重构后的代码。抽象较充分,但层次增加的代码。(www.xing528.com)
重构之后的好处是
●报告和计算互不干扰,这使增加新的报告格式不会影响现有代码。
●积分和计价规则独立在子类中,可以很容易地调整规则。
好处是非常明显的,几乎没有争议,如正交性增加,灵活性增加等,但我们也失去了些东西。
●层次增加了。getCharge()和getFrequentRenterPoints()被转发了2次。比如,Rental的getCharge()→Movie的getCharge()→Price的getCharge()在经过多态机制转到具体的对象。对于getCharge()而言,Movie这一层的主要作用是转发调用,并没有实际意义。
●概念增加了,Price被衍生出来了。
对于小规模的程序,不论收益或者损失可能真的是无关紧要。Martin Fowler自身也强调对于这种规模的程序,重构是不值得的。所以希望读者把这一例子想象为大系统的一部分。但事实上上面所提到的问题,在大系统中同样存在,收益和损失将同比放大。
这个例子提醒我们,设计中往往并非是追求单维度上的极值,而是要谋求一种平衡。7.3.1节中所提到的,正交是我们所要追求的,但这往往也带来层次。
平衡点究竟在哪里是每一个设计人员必须关注的问题。就上述例子而言,我们可以发掘出层次数介于上述两者之间的方案,比如,单纯的分割计算和进行statement()的代码,把计算的结果作为statement()的参数。这样既不用生成新的类,又可以部分解决添加htmlStatement()所带来的困扰。
3.一点总结
解耦这一工作本身达到一定水平后,其所带来的正效应会逐步降低。但其负效应却可能逐步增加。单纯从理论上讲,可维护性、可扩展性、可重用性、松散耦合、性能、可移植性、精简性、时间开销等要求决定了一个最佳平衡点。超过这一平衡点后,任何一方面的增强都是以其他方面更大的损失为代价的。从这个角度上讲,设计的过程是追求这个最佳平衡点的过程,而究竟平衡点在哪里事实上需要一定度量手段进行支持,否则就必然充斥过多的主观判断,而纷争无数。这一点我们会在第8章中进一步进行探讨。
曾经有人说过这样一句话,可供我们参考。他说:如果你知道自己在做什么,3层足够;如果你不知道自己在做什么,那么17层也没用。如果我们认为9是一个人可以同时记住的最大的概念数,那么实现某一功能时层数无疑要小于这个值的。
和层次相关的问题主要有两个:一个是层次的多少;另一个则是层次的一致性。如果说层次的多少是一个合适与否的问题,那么层次一致性则是一个是非问题。某一个层次上所体现出来的东西应该具有层次一致性。这和前面在讨论的需求中的层次问题类似。
比如说,如果有一个类叫Cat,那么这个类的接口,可以有返回猫的颜色,猫的种类,这些属性是在一个抽象层面上的。但如果突然有一个接口是返回猫的个数,那么大多时候就会让人感到奇怪。
我们感觉到奇怪的根本原因是抽象的层次出现了不一致性。颜色和种类这种属性属于具体的某一只猫,而个数则属于猫的集合。
这种抽象层次的不一致实际上是增加耦合度的一个元凶之一。
很不幸的是,这又是一个要依赖于个人技能的地方。眼下还看不到自动判断抽象层次是否合适的方法。
设计模式与层次
其实很多设计模式是通过增加层次来实现的。比如说,Factory,Proxy,Iterator,Command等。我们用《设计模式》一书中的Proxy来说明这种层次添加的过程。
假设像Word这样的程序中有两个类:Application和Image。每当Word打开一个文件时,它逐个遍历Image的实例,装载所有的图像,并显示它们。这个时候Application直接调用Image的方法。
但问题是这样一来,如果一个文档中有10000个图片,打开这个操作会很花时间。
所以需要用到Proxy模式。这个模式说的是,在Application和Image之间加入一个新的层次:ImageProxy。ImageProxy判断图像是否在显示区域内,如果在,则调用Image的方法来显示图像,否则什么都不做。这样一来,装载速度的问题就解决了。
设计模式之所以有用往往在于其投入产出比较好,而不是因为其毫无代价—从保持简单性的角度看增加的层次即是代价。
为了驾驭设计模式,而不是为设计模式所驾驭,归根到底是要把握这里的设计原则,把握现场状况,把握具体得失。
免责声明:以上内容源自网络,版权归原作者所有,如有侵犯您的原创版权请告知,我们将尽快删除相关内容。