对于软件而言,层次是让人又爱又恨的东西。
?
很多问题是通过增加层次解决的,但另外一部分问题也是因为层次而导入的。我们来分别看几个例子。
?
例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这一层次的代价。
?
通过上述的例子我们可以大致体会到层次这把双刃剑的威力。
?
通过层次我们可以让软件更灵活,抽象更充分;但层次也会把达成某一功能所必须的信息进行分割,增加复杂度。所以层次的多少往往并非是一个对与错的问题,而是一个程度问题,究竟什么样的层次才合适,是需要现场的人进行判断的。
曾经有人说过这样一句话,可供我们参考,他说:如果你知道自己在做什么三层足够;如果你不知道自己在做什么,那么十七层也没用。
我个人是认同这一观点的,除非特别的情形,要努力控制层次在三层左右,否则宁愿牺牲一点抽象。
?
和层次相关的问题主要有两个:一个是层次的多少;另一个则是层次的一致性。如果说层次的多少是一个合适与否的问题,那么层次一致性则是一个是非问题。
某一个层次上所体现出来的东西应该具有层次一致性。
?
这和前面在讨论的需求中的层次问题类似。
比如说:如果有一个类叫Cat,那这个类的接口,可以有返回猫的颜色,猫的种类,这些属性是在一个抽象层面上的。但如果突然有一个接口是返回猫的个数,那么大多时候就会让人感到奇怪。
我们感觉到奇怪的本质原因是抽象的层次出现了不一致性。颜色和种类这种属性属于具体的某一只猫,而个数则属于猫的集合。
?
这种抽象层次的不一致实际上是增加耦合度的一个主要元凶。
?
很不幸的是,这又是一个要依赖于个人技能的地方。眼下还看不到自动判断抽象层次是否合适的方法。