来自网易杭州研发技术总监“云风”BLOG的几篇面向对象设计的文章
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;
}
至于模块的卸载,大部分需求下是不需要的。今天在这里就不论证这一点了。
浅谈 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 的模块初始化
中一定就会初始化 B 了。如果是这样,B 的层次就位于 A 之下。
A_init
往往 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);
struct foo_object
的定义中,必然要记录 i_foo
的接口指针和 data 数据指针。从 C++ 的观点看,foo_object
是基类,它也会有一些基类成员和非虚的成员函数。具体的派生类在实现时,改写了虚表i_foo
的内容(重载了虚函数)。data 数据是在对基类foo_object
继承时扩展的数据成员。但,在这里,我们使用了组合的方式来扩展成员。这增加了一层间接性,但提供了更低的耦合。其中的优劣暂且不讨论了。
通常看起来会是这样:
struct foo_object {
struct i_foo * vtbl;
void * data;
void * others;
};
void
foo_dosomething(struct foo_object *fobj)
{
fobj->vtbl->foobar(fobj->data);
// do something else
}
此处还有另一个问题:data 的生命期该由谁来负责?
生命期管理是个很大的课题。也是大多数使用 C/C++ 开发的软件的复杂度重要来源。我个人倾向于把生命期管理独立出来解决。所以 foo_object
模块一般并不负责 data 的生命期管理。它只负责struct foo_object
的资源释放。
自己经营自己,是我的 C 语言软件开发的观点之一。我倾向于采用混合语言编程来更好的解决这个问题。比如 C 和 Lua ,或者 C 和 C++ 。如果不采用混合语言编程,那么也可以在之后,增加一个同样用 C 语言编写的层次来管理。这个话题,留到下次来讲。
剥离出生命期管理,代码量可以减少很多,也不容易犯错误。
ps. C 语言是一个弱类型的语言。至少比 C++ 要弱一些。这表现在:
void * 在 C 语言中可以指代任意数据指针。你可以把任意数据指针赋值给一个 void * 变量,也可以把一个 void * 变量赋给特定的指针类型变量。(这在 C++ 中不推荐,并会被编译器警告)
C 语言中的函数指针也比较有趣。通常,不同类型的函数指针相互赋值是会引起编译器警告的(类型不同)。当然,我们可以用一个 void * 来解决问题。但有时候,我们期望让类型检查严格一些,至少我们不希望把一个数据指针赋值给一个函数指针。但希望编译器不要理会函数参数的差异。
在 C 语言中,void (*foo)() 可以被赋予任意返回 void 的函数指针。即,你可以把 void foobar(int) 的地址赋予前面的 foo 变量(这是由 C 标准的参数传递规则保证的)。
所以,在 C 语言编程中需要注意。如果你想定义一个不接受参数的函数,并让编译器帮你检查出那些错误的多传递了参数的语句。你必须在 .h 文件中严格定义 void foo(void) 以示 foo 函数不接受参数。
在传统的 C 语言中,对结构初始化需要非常小心。这里,我们的 i_foo
接口定义就使用了 C 里的结构。这需要非常谨慎小心。(没有 C++ 编译器帮你做这件事)
C99 新增加的语法增强了这点(在初始化结构时,可以不依赖次序,而写出成员的名字)。值得采用。