初始化方案。这个惰性初始化也不是万能的,并且有些额外的开销。(多线程环境中尤其需要注意)
我使用 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; }
至于模块的卸载,大部分需求下是不需要的。今天在这里就不论证这一点了。
浅谈 c 语言中模块化设计的范式
今天继续谈模块化的问题。这个想慢慢写成个系列,但是不一定连续写。基本是想起来了,就整理点思路出来。主要还是为以后集中整理做点铺垫。
我们都知道,层次分明的代码最容易维护。你可以轻易的换掉某个层次上的某个模块,而不用担心对整个系统造成很大的副作用。 层次不清的设计中,最糟糕的一种是模块循环依赖。即,分不清两个模块谁在上,谁在下。这个时候,最容易牵扯不清,其结果往往是把两者看做一体去维护算了。这里面还涉及一些初始化次序等繁杂的细节。
其次,就是越层的模块联系。当模块 a 是模块 b 的上层,而模块 b 又是模块 c 的上层,这个时候,让模块 c 对模块 a 可见,在模块 a 中有对 c 导出接口的直接调用,对于清晰的设计是很忌讳的一件事。虽然,我们很难完全避免这个问题,去让 a 对 c 的调用完全通过 b 。但通常应尽力为之。(注:以后写书的话,我争取补充一些实际的例子来说明)不过,对语言不原生支持的数据类型,以及基础设施,但却有必要创造出来给系统用的。可以有些例外。比如内存管理,log 管理,字符串(c 语言用原始库函数管理比较麻烦)等等,我们可能以基础模块的形式提供。但却可能被不同层次的模块直接使用。但,上到一定层次后,还是需要去隐藏它们的。 下面来一点更实际的分析。
以 c 语言为例,由于 c 语言缺乏 namespace 的原生支持,我们通常给 api 加上统一前缀来区分。这倒也不麻烦。
那么模块 a 看起来就是一堆 a_xxxxx 为名字的方法。我个人主张单个模块不宜过大,在实现时适合放在同一个 .c 文件里即可。通常,一个模块会围绕一类对象处理。这些对象可以用整数 handle 来表示,也可以用一个特定类型的对象指针。两种方案各有千秋。先来谈 对象指针的方案。
一个模块 a 的接口描述文件很可以是这样的(希望以后能补上更现实的代码): #ifndef _a_h #define _a_h struct a; struct b;
struct a* a_create(void);
void a_release(struct a *self);
void a_bind(struct a *self , struct b *b); void a_commit(struct a *self);
void a_update(void); int a_init(void); #endif
这里,我们定义了 a 这种数据类型。我个人反对用 typedef 或宏来减少代码输入。除非有特别的理由,都写上 struct 前缀,而不是定义出新类型。尤其是在较底层的模块设计时更是如此。在接口描述时,struct a 的细节是绝对不应该暴露出来的,它的数据结构应该仅存在于实现的文件 a.c 中。
关于 a 的接口通常分两类,一类是对 struct a* 做一些处理的,那么就让第一个参数传入 self 指针。这相当于 c++ 的 this 指针。比如上例中的a_commit ;另一类接近于 c++ 类的静态成员函数,通常用于对这一类对象全部做一个处理,如a_update 。
注:我无意用 c 去模拟 c++ ,但基于一类数据类型做一些处理的方法,对于 c ,这样的写法也是一个常规的范式而已。至于面向对象等在构建复杂系统时常用到的方法,以后我会谈谈我自己常用的另一些范式。或许像 c++ ,也可以不像。怎么写更好,是个见任见智的问题。不用过于拘泥。
这里的例子中,我们还提到了另一个数据类型 b 。显然,它是放在 b 模块中的。
我们通常不会在 a.h 中去 include b.h ,而只是声明一下 struct b 。(对于 c 语言来说,这并不必要,但写上是个好习惯)。这是因为,如果 b 是位于 a 之下的模块,既在 a 模块的实现中,会用到 b 的方法,我们通常不会让用到 a 模块的人,可以看见 b 的接口。包含 a.h 的同时隐式包含 b.h 就是不必要的了。
从范例代码中,我们可以猜想,struct a 是对 struct b 的某种封装,可以通过对 a 的操作,间接操作到其中的 b 类型。在 a 的模块初始化 a_init 中一定就会初始化 b 了。如果是这样,b 的层次就位于 a 之下。
往往 struct b 中还会保留一个 struct a 类型的引用。首先,我们应该尽力避免这种情况。即:位于下层的 b 应该对上层的 a 一无所知是最好的。如果在 b 模块中必须出现 struct a,那么我们应该至少保证,仅仅是 struct a * ,一个引用,而绝对不能出现任何对 a 模块内接口的调用。不要认为使用巧妙的方法,绕过循环依赖初始化问题就够了。这应该是一个设计原则,不要去违反。
btw, 草率的接口设计往往是日后系统脆弱的根源。图一时之快,随意暴露一些接口,或是自以为聪明的用一些“巧妙”的方法,甚至是语法糖来绕过设计原则,都是很危险的。
一个常见的难处理的问题是:如果 struct a 和 struct b 相互有双向引用。怎样建立这个引用关系?这个建立的过程,到底是 a 的方法,还是 b 的方法?我的答案是,谁在上层,就是谁的方法。
但是 a 和 b 相互都看不见内部数据布局的细节,让 b 的内部对 a 类型做一个引用,比如也需要从 b 模块中暴露一个接口出来。这个接口,可能仅供 a 使用。在这个例子里, 就是仅供a_bind 这个方法去使用。
如果是 c++ ,我们或许会采用 friend 。也可能使用其它一些技巧。反正 c++ 里可以挖掘的语法太多了。但 c 怎么办?下面给个我自己的方案。
原本,我们在 b 中导出的 api 是这样的: void b_set_a(struct b *self,struct a * a); 现在写成: struct i_a;
void b_set_a(struct b *self,struct i_a *a);
在 b.c 的实现中,加一个函数用于 struct i_a * 到 struct a * 的转换。 static inline struct a * a(struct i_a *a) { return (struct a *)a; } 然后在 a.c 的实现中,加一个类似函数用于转换 struct a * 到 struct i_a * 。
这样,在 a.c 之外,其它模块因为不能得到任何 struct i_a 类型,而不会错误的使用 b_set_a 这个接口了。 我所偏爱的 c 语言面向对象编程范式
面向对象编程不是银弹。大部分场合,我对面向对象的使用非常谨慎,能不用则不用。相关的讨论就不展开了。
但是,某些场合下,采用面向对象的确是比较好的方案。比如 ui 框架,又比如 3d 渲染引擎中的场景管理。c 语言对面向对象编程并没有原生支持,但没有原生支持并不等于不适合用 c 写面向对象程序。反而,我们对具体实现方式有更多的选择。
大部分用 c 写面向对象程序的程序员受 c++ 影响颇深。企图用宏模拟出一个常见 c++ 编译器已经实现的对象模型。于我愚见,这并不是一个好的方向。c++ 的对象模型,本质上是为了追求实现层的性能,并直接体现出来。就有如在 c++ 中被滥用的 inline ,的确有效,却破坏了分离原则。c++ 的继承是过紧的耦合。
我所理解的面向对象,是让不同的数据元有共同的操作方式,适合成组的处理。根据操作方式的不同,我们会对数据元做不同的分组。一个数据可能出现在这个组里,也可以出现在那个组里。这取决于你从不同的方面提取的共性。这些可供统一操作的共性称之为接口(interface),接口在 c 语言中,表现为一组函数指针的集合。放在 c++ 中,即为虚表。 我所偏爱的面向对象实现方式(使用 c 语言)是这样的:
若有一组数据,我们需要让他们看起来都有一种叫作 foo 的共性。把符合这样的数据都称为 foo_object 。通常,我们会有如下 api 去操控foo_object 。 struct foo_object;
struct foo_object * foo_create();
void foo_release(struct foo_object *);
void foo_dosomething(struct foo_object *);
在具体实现时,会在一个叫 foo.c 的实现文件中,定义出
foo_object 结构,里面有一些 foo_dosomething 所需的数据成员。 但是,以上还不能满足要求。因为,我们会有不同的数据,他们只是表现出 foo_object 某些方面的特性。对于不同的数据,它们在 dosomething 时,实际所做的操作也有所区别。这时,我们需要定义出一个接口,供 foo.c 内部使用。那么,以上的头文件就需要做一些修改,把接口i_foo 的定义加进去,并修改 create 函数。 struct i_foo {
void (*foobar)(void *); };
struct foo_object * foo_create(struct i_foo *iface, void *data);
这里稍做解释。i_foo 是供 foo_dosomething 内部使用的一组接口。构造 foo_object 时,我们把一个外部数据 data 和为foo_object 相关特性定义出的i_foo 接口捆绑在一起,传入构造函数foo_create 。一般,我还会会每个符合foo_object 特性的对象实现一个方法来得到对应的i_foo ,如: struct foobar;
struct i_foo * foobar_foo(void);
struct foobar * foobar_create(void); void foobar_release(struct foobar *);
创建一个 foo_object 对象的代码看起来是这样: struct foobar *foobar = foobar_create();
struct foo_object * fobj = foo_create(foobar_foo() , foobar);