不记得从哪儿看到的一句话,大意是:面向对象的设计模式掩盖了软件设计其实是这样一个事实:把模块按照依赖关系,组织成有向无环图。"无环”是一个重要的要求,即软件模块之间不要出现循环依赖的情况。更好的架构是模块分层次,某一层的模块只依赖比它低一层的模块。另外,模块间的依赖,也就是图里的边,越少越好,边越少,架构越简单。
每个模块应该是一组方法的集合,也就是一个抽象数据结构。一种数据结构,实际上是由它上面的一组操作来定义的。比如整数,只要满足整数的运算规则,这种数据结构都叫整数。所以,模块应当只包含方法,这组方法完全定义了这个模块。如果是Java语言,理论上每个模块都应该是一个interface。
每个模块可以有多个实现,具体采用哪种实现,是动态绑定的——也就是说,不是在编译期,而是在运行期决定的。我提出一个观点(也许有人提出过了,待考证),把运行期划分为“初始化期”和“运转期”。在“初始化期”,需要为每个模块指定一种实现,并且建立模块间的依赖、引用关系。这个过程可以自写代码,也可以通过类似spring的框架完成初始化和依赖注入。初始化期完毕,软件进入“运转期”,这时软件才真正进入运行,可以实现既定的功能。
在“运转期”,模块间的依赖应当只包括抽象方法,而于某种具体实现中的特有方法无关。做出这种区分后,应当把尽量多的操作放在初始化期,因为初始化期软件尚未执行,而且初始化期只执行一次,无需考虑效率,可以进行复杂的操作和严格的检查。而且无论是执行检查、还是输出日志,都不会产生很大的量。初始化期易于检查、易于调试,尤其对于多线程程序,线程尚未运行,调试难度大大低于运转期。
目前的编程语言里没有提供区分“初始化期”和“运转期”的特性,但是做出这种区分是有意义的。对于某个具体实现,比如一个class,内部的成员变量可以大致分成两类,一类属于配置变量,一类属于运行状态变量。两者分别对应于初始化期和运转期。比如一个连接数据库的类,数据库地址、用户名、库名属于配置变量,在初始化期设定,并且一旦初始化后往往不会改变。而某次query返回的错误状态,属于运行状态变量,它在运转期不停地被改变。显然,状态变量使得软件行为更不可预测,而且带来并行安全性问题。我们希望状态变量越少越好,最好是没有。如果没有的话,这就是一个所谓的“幂等性”模块(即多次调用返回的结果是一样的)。
通常的编程语言并不提供定义“配置变量”和“状态变量”的语法,但是可以做个类比。如果类比java,可以把前者看成final变量。final变量在构造函数中赋值,并且不能改变。但是,有些配置变量不一定在创建对象的时候就能赋值,而是在创建对象以后、开始运转之前被赋值。这样Java就没法区分了。spring中提供了类似的概念,它将接口和实现完全分离,并且使用xml文件完成初始化期的工作。
与成员变量类似,成员方法可以按初始化期和运转期作类似的划分。例如对象A持有一个指向对象B的引用,我们通常会在A中提供一个类似'setB()'的函数,向A中注入B的引用。这就是一个典型的“配置函数”,它的作用是在初始化期建立模块间的依赖关系。而模块的抽象接口中定义的方法,通常是“运转期”方法。
如果接口和实现在初始化期绑定后,这种绑定关系在整个软件生存期不再改变(这种情况在工程中也是比较常见的,如果要替换实现,重新初始化即可),那么这种动态绑定完全可以放到编译期执行,例如I是一个接口,A和B都实现了I。我们初始化一个A的对象,并且将I的变量指向A的对象。如果在I的变量的整个生存期里这种绑定关系保持不变,那么这在编译器就可以确定。例如,可以把I里的所有方法直接替换成A里的方法,这样省去动态绑定所带来的虚函数查找开销,不过这似乎没有多大意义。此外可能有意义的一点是,如果这个过程放到编译期,编译器就可以进行更多的语法检查。把错误尽可能的在更靠前的阶段消除,能够大大减少调试时间。
OO里的两大核心概念:抽象和多态,前者用于解决模块化问题,后者解决接口和实现的绑定问题。这里要解决的核心问题,是接口和实现的分离。至于为什么要分离,根本原因还是控制复杂性。一个模块,在概念上应当是简单的,而实现上也许很复杂,但是这种复杂被约束在了模块内部,外部只能看到简单的概念。所以,模块的划分要合理。如果模块数量过多,或者关系杂乱,甚至接口定义经常改变,那么使用的工具再好也是无济于事的。