编写可维护的代码(二)

假如一个系统中有多个模块,不妨命名为 Module1, Module2, Module3……,毫无疑问这个系统的启动过程中需要初始化所有这些模块,而退出时要销毁它们,那应该用下面哪种方法来完成这个任务呢?

A. 让这些模块都支持一个 IModule,然后定义一个 IModule* 类型的数组,把这些模块的指针都加进去:

IModule* modules[] = {&Module1, &Module2, &Module3, ...};

// 初始化时:
for(int i = 0; i < sizeof(modules)/sizeof(modules[0]); ++i)
    modules[i]->Init();
 
// 退出时:
for(int i = sizeof(modules)/sizeof(modules[0]) - 1; i >= 0; --i)
    modules[i]->Uninit();

B. 老老实实的一个一个的来:

// 初始化时:
Module1.Init();
Module2.Init();
Module3.Init();
...
// 退出时:
...
Module3.Uninit();
Module2.Uninit();
Module1.Uninit();

如果你读了我的上一篇,你肯定能猜到我的选择是 B。但我想先说说 A,把 A 说清楚了,选择 B 的理由也就出来了。

A 是典型的数据驱动 + Builder 模式,它最大的优点是增加或删除一个模块只需要增加或删除一个数据项,耦合很小,所以看起来非常优雅。

而 A 的缺点有两个,和上一篇一样,其中之一也出在调试上:当一个模块初始化失败后,如果我们只看外面这些代码,没有办法一眼得出是谁失败了,必须得多一些操作才行。

第二点是 A 实现强制了模块的初始化和退出顺序,先初始化的模块后退出貌似很合理,但在一个大型系统中却总会出例外,而且还可能出现 Module1 先初始化一半,然后 Module2 初始化,之后 Module1 再继续初始化等情况。当然,我们可以使用“把初始化顺序和退出顺序定义在两个数组中”或“把初始化划分为多个阶段”等方法处理这些问题,但这些方法都会增加复杂性,而且也都不能从根本上解决问题。

B 实现则用简单直接的方法很好的避免了 A 的问题,虽然它看起来好像很笨,增加删除一个模块要改多个地方,但这些改动总共也不过几行代码,而且往往只涉及一个文件,所以总体代价并不高。

最后,本文的场景乍看起来非常适合使用 Builder 模式,可为什么使用它的效果不好呢?我本人对设计模式不感冒也不擅长,所以只能试着解释一下这个问题:其原因就是这个场景只是看起来像,但其实并不适用 Builder 模式。

Builder 模式要求对象支持统一的接口,也希望对象之间没什么关联,这是我们作设计时追求的目标,但在实现一个复杂系统时却很难完全满足这些要求,所以硬套上去就会出问题。而且在实现一个系统时,各个模块还不可能完全定下来,实现过程中的改动也会给 Buidler 模式带来麻烦。

按我个人的理解,Buidler 模式不应被用来处理系统的主体模块,它真正的适用场合之一是实现对插件的支持,把所有插件定义在一个列表中,然后逐项处理,因为这时系统的主体功能已经完成,所以可以为插件定义出清晰的接口,而且就算定义的接口有一点问题,它所影响的也只是某些插件而非主体功能了。