« 好的设计 | 返回首页 | 浅谈 C 语言中模块化设计的范式 »

C 语言对模块化支持的欠缺

继续昨天的话题。随便列些以后成书可能会写的东西。既然书的主题是:怎样构建一个(稍具规模的)软件。且我选择用 C 为实现工具来做这件事情。就不得不谈语言还没有提供给我们的东西。

模块化是最高原则之一(在 《Unix 编程艺术》一书中, Unix 哲学第一条即:模块原则),我们就当考虑如何简洁明快的使用 C 语言实现模块化。

除开 C/C++ ,在其它现在流行的开发语言中,缺少标准化的模块管理机制是很难想象的。但这也是 C 语言本身的设计哲学决定的:把尽可能多的可能性留给程序员。根据实际的系统,实际的需要去定制自己需要的东西。

对于巨型的系统(比如 Windows 这样的操作系统),一般会考虑使用一种二进制级的模块化方案。由模块自己提供元信息,或是使用统一的管理方案(比如注册表)。稍小一点的系统(我们通常开发接触到的),则会考虑轻量一些的源码级方案。

首先要考虑的往往是模块的依赖关系和初始化过程。

依赖关系可以放由链接器或加载器来解决。尤其在使用 C 语言时,简单的静态库或动态库,都不太会引起大的麻烦。

C++ 则不然,C++ 的某些特性(比如模板类静态成员的构造)必须对早期只供 C 语言使用的链接器做一些增强。即使是精心编写的 C++ 库,也有可能出现一些意外的 bug 。这些 bug 往往需要对编译,链接,加载过程很深刻的理解,才能查出来。注:我并不想以此来反对使用 C++ 做开发。

我们需要着重管理的,是模块的初始化过程。

对于打包在一起的一个库(例如 glibc ,或是 msvcrt ),会在加载时有初始化入口,以及卸载时有结束代码。我想说的不是这个,而是我们自己内部拆分的更小的模块的相互依赖关系。

谁先初始化,谁后初始化,这是一个问题。

在 C++ 的语言级解决方案中,使用的是单件模块。要么由链接器决定以怎样的次序来生成初始化代码,这,通常会因为依赖关系和实际构造次序不同而导致 bug (注:我在某几本 C++ 书中都见过,待核实。自己好久不写 C++ 也没有实际的错误例子);要么使用惰性初始化方案。这个惰性初始化也不是万能的,并且有些额外的开销。(多线程环境中尤其需要注意)

我使用 C 语言做初期设计的时候,采用的是一种足够简单的方法。就是,以编码规范来规定,每个模块必须存在一个初始化函数,有规范的名字。比如 foo 模块的初始化入口叫

int foo_init()

规定:凡使用特定模块,必须调用模块初始化函数。

为了避免模块重复初始化,初始化函数并不直接调用,而是间接的。类似这样: mod_using(foo_init);

mod_using 负责调用初始化函数,并保证不重复调用,也可以检查循环依赖。

在这里,我们还约定了初始化成功于否的返回值。(在我们的系统中,返回 0 表示正确,1 表示失败)然后定义了一个宏来做这个使用。

#define USING(m) if (mod_using(m##_init,#m)) { return 1; }

注:我个人反对滥用宏。也尽可能的避免它。这里使用宏,经过了慎重的考虑。我希望可以有一个代码扫描器去判断我是否漏掉了模块初始化(可能我使用了一个模块,但忘记初始化它)。宏可以帮助代码扫描分析器更容易实现。而且,使用宏更像是对语言做的轻微且必要的扩展。

这样,我的系统中模块模块的实现代码最后,都有一个 init 函数,里面只是简单的调用了 USING 来引用别的模块。例如:

#include "module.h" /* 我个人偏爱把 module.h 的引入放在源文件最后,初始化入口之前。 它里面之定义了 USING 宏,以及相关管理函数。 这样做是为了避免在代码的其它地方去引入别的模块。 */ int foo_init() { USING(memory); // 引用内存管理模块 USING(log); // 引用 log 模块 return 0; }

至于模块的卸载,大部分需求下是不需要的。今天在这里就不论证这一点了。

Comments

232
云风大大,不是很明白mod_using这个函数并没有记录是谁调用了mod_using函数,那么怎么检查循环依赖呢?百思不得其解,请见谅。
我也倾向与在程序开始处初始化所有模块,便于处理依赖关系和出错处理。
我也倾向与在程序开始处初始化所有模块,便于处理依赖关系和出错处理。
@mei ren, C 语言在上世纪末,就引入了单行注释的语法。 @SpringBrother, 采用 USING 这个宏,就是在语法上看起来好象一种包依赖关系的定义。 但是我不主张过度使用宏,比如 MFC 里用宏干的一些事情。
各位的回复真是高深莫测,阐述技术方面少用术语,不是很牛的东西一用术语说出来令人毛骨悚然,我第一次听说“心跳包”的时候,那个心跳的啊,屎都出来了,其实术语远远比其描述的技术还高深。
我们现在的做法是每个模块在定义的时候指定它依赖于那些其他模块,然后main启动的时候根据所有模块的依赖关系做一次拓扑排序,然后按顺序将所有模块都启动好。
pidgin的plugins貌似就是这么实现的?如果没记错的话。
可以简单的说就是哪个模块用到什么模块就自己去初始化一下,而不用管这个模块是否已经被初始化了。管理重复初始化的工作并不应该由调用者来关心,而是被调用者的责任,在这里由衍生出一个USING,就是把这部分的工作统一交由这个入口来处理。而且统一一个入口,就可以在入口的地方有统一的判断重复的代码,这样减少代码的重复。以及可以方便的记录哪些模块初始化过,直接在一个地方就可以查看所有初始化过的模块,比如打印出来之类的。
风哥。既然是纯C的话,为啥你的代码里还有C++风格的注释呢。
回答3楼的问题:我觉得在全局初始化所有模块这种设计方法理论上是可以的,但在大型系统中往往模块比较多,有些模块并不是一开始就要用到的,比如linux中的某个设备驱动,而是用到才要加载,这样节省了内存,对于嵌入式系统来说也降低了硬件成本,也一定程度上加快了系统的启动速度。 我们公司开发的嵌入式系统就是采用的动态加载模块的方法,系统框架里有一个类似注册表的模块登记表,系统启动时所有模块只是登记,不加载,等到要用到此模块,比如视频解码模块,再加载其二进制的bin,用完立即卸载,这样可以做到内存总量需求很低。
另外,模块的卸载很多时候还是需要的,例如写组件或是插件的时候,当你的程序被卸载的时候必须做好清理工作,否则就在宿主程序里留下一堆垃圾没人清理了。
虽然我用C++,但是我很反对用单件, 我喜欢在程序的入口处例如main函数里把所有需要用到的模块统一进行初始化,在程序退出的地方按顺序删除模块。这样模块间有一个确定的初始化和析构顺序,程序用到哪些模块也一目了然,避免出现各种麻烦问题。 另外,在并行程序中模块初始化更复杂一些,有些模块是全局共享的,应该在main里初始化,有些模块是每线程独享的,应该在线程入口的地方初始化。
因为你不一定使用全部提供的模块, 而你的项目可能并不只一个程序入口。它可能由多个程序构成。还包括各种内部工具等等。所谓的 program logic 也是一个模块而已。不应在任何时候放弃模块化的准则。 即使放在一起写,也有谁先谁后的问题。重复初始化的问题。
我有一个问题:为什么不在一个全局的地方把所有模块初始化好? 如: int main() { memory_init(); log_init(); foo_init(); bar_init(); program_logic(); return 0; }
感觉lua所采用的方式就很不错,泾渭、层次分明。很期待新的书啊,就算是从blog里面取出来一些稍加整理也好,不用怕写的太随意误人子弟,这么多经验不整理出来对于翘首以待blog更新的我等实在才是杯具啊。
你每天都是这个时候POST?

Post a comment

非这个主题相关的留言请到:留言本