« C 语言对模块化支持的欠缺 | 返回首页 | 招行虽然烂,但至少可以用 »

浅谈 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 这个接口了。

Comments

你好! 对“在接口描述时,struct A 的细节是绝对不应该暴露出来的,它的数据结构应该仅存在于实现的文件 a.c 中。”, 不理解,我不知道是怎么做到的,如果结构体定义放在a.c文件里的话,编译通不过。能不能详细的给我讲讲。。。我水平较低,谢谢!

你好! 对“在接口描述时,struct A 的细节是绝对不应该暴露出来的,它的数据结构应该仅存在于实现的文件 a.c 中。”, 不理解,我不知道是怎么做到的,如果结构体定义放在a.c文件里的话,编译通不过。能不能详细的给我讲讲。。。我水平较低,谢谢!

实践证明,90%的代码重用设计都是做无用功,不过模块化是成熟的。

"如果 struct A 和 struct B 相互有双向引用".
在现实世界中,关系,也是有来有往,相互的联系。增加中间层,是不是,有违简单设计

@limantian

增加模块C也不算是个好办法,这样C就是AB的上层,AB就不能知道C,这样外界就完全不能直接访问AB,都要通过C,如果AB接口繁杂,更新频繁,C的维护代价就很高,而且C还要完全了解AB,也就是说最后就变成云风所说的"把两者看做一体去维护算了".
当然有时无法不用这种手段去设计,这样AB就可以看作是C的子模块,而不能作为一个完整的模块去看待了.

如果需要A和B联合起来完成一个操作,是不是可以增加一个C,由C负责调用A和B实现,通过C负责协调A和B的调用次序和状态,这样A和B都不用互相知道彼此的状态,简化A和B的设计。

自身的经验是,循环引用绝对要消除,也绝对是可消除的。
设计的最重要手段,就是调整分析时产生的依赖关系,做到下层不依赖上层,上层不依赖下下层,就是俗语说的高内聚低耦合。
(就目前实现手段来看,分析和设计还是有不可逾越的鸿沟,所以分析时合理的结构,往往在实现时依赖关系混乱、耦合严重,所以分析之后需要设计)
对于循环依赖,大部分情况是个伪问题,也即好的设计根本就没有循环引用。

对于 A <--> B 的情况,往往其实应该是:

A -----> B
|     |
|     +
+---> BA<-+

其中 BA 属于 B 所在层次,A往往实现 BA接口且调用 B ,但是 B 对 A 一无所知。
A如何实现BA接口,与使用语言有关,如果是C语言,就是云风文中的方式。

看过《大规模C++程序设计》没有?就是杯具的小贝看过的那本。感觉讨论的问题很相似啊,关键是解决模块之间的依赖关系:逻辑依赖和物理依赖。

@limantian
如果不用友元,要么不暴露,这样实现起来会非常麻烦甚至无法实现某些功能;要么完全public,那样不是更糟么.

@dwing
先引用云风的话“任何被人看得见的接口,都有误用的可能。”,一旦A成为B的友元,则B的一切都暴露给A了,而不是仅仅某些函数,它恰好破坏了类的变量及函数的权限。

@iLibra

void * 是个重要的东西,下次谈 OO 时我会详细谈。

不过这里, void * 和i_A 的转换是截然相反的作用。

使用 i_A 是为了做一些额外的限制,是起的一个编译时授权。而采用 void * 则是对任意的代码授权。

你没有理解这篇 blog 想谈的问题.

@abla,

类似的问题,我觉得在面向接口编程时遇到的会比较多。这类问题我也计划写写我自己常用的方法。过两天会写。

法无定法,今天写 blog 也是为了征求大家的意见,以后成书可以考虑周全一些。

Hi,Cloud,
我不太认同你对void*的看法。
1.要数据内部细节的封装,那么就应该采用void*,void*本身也可以在形式上解除源代码对数据定义的依赖。
2.如果我没有理解错,你不赞同用void*的主要原因就是用void*失去了编译器对数据类型的检查。在我看来这个也是有办法解决的:
在定义所有的数据结构的时候在数据内部加入对结构类型的描述,这样对所有的void*引用时,可以同一种统一的方式来在运行时检查。
例如
struct
{
int structType;
....
}A
structType在create时赋值。
这样在引用任何void* X时候
都先检查(int)(*X)是否是我们要的structType。
而每一种struct都该带有全局唯一的structType id.
这个东西在直接看内存数据的时候也有好处,可以直接看出数据类型。

当然这样增加了额外的代码,但是相对于
static inline struct A * A(struct i_A *a) { return (struct A *)a; }
这样迫不得已而为之的代码,我觉得运行时检查还是可以忍的。而且形式上要优美一点。

恩,以上就是我个人的一点观点。欢迎指教。

Cloud
理解了。另外请教个问题,有时候需要低一层的模块向上一层模块通知消息,一般采用回调的方式或者发送消息然后由上一层主动去操作。回调的方式效率会高一些,不过在层次上来说等于就是由低一层的模块去驱动上一层的模块,并且在多任务的环境下,编码的时候就会有很多要注意的地方,应该说是不太好的设计。能不能谈一下你对类似问题上的考虑或者处理的方法?

"任何语言上的技巧,都是下策。"

这句话得顶!

@abia

首先这种情况应该避免,只有避免不了才想办法。

void * 多用在 C 实现面向对象中。这个以后会谈到的。把 void * 转成别的指针,和把别的指针转成 void* 都是不给警告的。

void * 更象是动态语言中的 var 。

例子已经举过了。就是插头和插槽的例子。这个连接的过程两边都需要修改在内部记录引用的。这是个双向的关系。不可能靠单方面的代码可以完成。

最终的结果是由一边驱动另一边做。但是从接口上看,位于底层一点的模块暴露给上层的是一个专有接口,只为了协助上一层做这件事。

至于会不会误用。我的观点是,任何被人看得见的接口,都有误用的可能。

为什么要在底层记录对更上层模块中定义出来的类型的引用?

大多数情况下需要从设计上规避。但总有规避不了的时候。退而求其次,使用 void * 去替代,只是改变了问题,并没有解决问题。反而把潜在的问题隐藏的更深。

再具体一点,A 位于 B 的上层,但 B 里要记录 A 类型的引用,可能仅仅是 A 的框架决定了,要建立跟 B 的联系,把每个 A 类型的引用放到 B 中,然后从另一个位置,还是在 A 中,把一个 B 对象里的 A 引用再取回来操作。

这个过程 B 是不调用 A 的方法的。强调 A 类型本身,也是强调 B 的一部分功能为 A 所专用。

文中提防的问题是,如果存在第三方 C ,在 A 的更上层。又不甚越层使用 B 的方法时,不会调用到那些仅供 A 使用的 B 方法。

A 啊 B 啊 C 啊的说或许还不直观。不过目前我不方便拿我实际工作的代码贴出来举例子。或许以后写书,会整理一些精简的真实世界的版本。

遇到过此类问题的人体会会更深刻。

任何语言上的技巧,都是下策。

那相比用struct A*或者struct i_A*,直接用void *或者整数handle这样的如何呢?错误的使用接口似乎看起来并不是那么严重的问题,毕竟这个问题是因为模块设计的时候无法分割出来而引入的,否则整个B模块应该都是只对A可见,其根本原因应该是模块设计的问题,何况如果错误的引用了这个接口,是不是也说明了这个接口是对别的模块看起来也是有用的呢?个人觉得更大的意义在于避免数据结构对外暴露。不过有点不明白,既然出现 struct A *,那就是需要用到这个引用,那怎么绝对不能出现任何对 A 模块内接口的调用?按我的理解是只有A模块内部才能看到具体的细节,那如果不对A模块的接口调用,这个引用的用途在哪?还是理解错了,只是对模块A的内部使用的接口不能调用,而不是对模块A的接口不能调用?能不能举个例子说明一下?

最近读 <修改代码的艺术> 和 <uml+oopc ...> 有感,有个问题想问一下,为了测试的方便,是否有考虑 将function 封装在一个struct 里,测试或模块交替开发时使用伪实现替换,我想听听老大在C中针对模块测试和单元测试的一些想法

@cloud
谢谢回答。

充分利用了C语言不那么强的类型系统,来完成尽量符合ADT的设计。呵呵。

@asking

有问题,因为这个 api 仅供 A 模块调用,如果不由 A 模块调用就会有问题。

i_A 是对 A 的一个授权,A 模块之外的部分无法正确得到 i_A 类型的引用。

这解决的是,需要联合 A/B 一同完成的操作。因为一部分功能需要在 A 中实现(了解 A 的细节),一部分功能需要在 B 中实现(了解 B 的细节)。最终的接口从 A 导出。

如果 B 完全隐藏在 A 之下,这个设计是多余的。

如果 B 还可能被第 3 者使用(往往是因为越层的模块关系,其实也是不好的味道),那么就有一定意义。

原来这个接口:void B_set_A(struct B *self,struct A * a);

中,只是知道struct A*,并没有保留结构A的实现细节,有问题吗?为何要新定义一个i_A?

定义一个新的结构i_A,是为了避免在接口层面模块A和B之间的相互引用,而把这种相互引用关系隐藏在模块实现中,是这样吗?谢谢!

@limantian
C++的friend是很有用的.
例如A类包含B类对象,B类的某些函数只想让A调用,而不想暴露给外部,这时只能用friend解决.
当然完全公开也不是不可以.但C++加了很多的保留字和语法就是为了限制变量及函数的权限以解决复杂程序中的不可控问题,便于早期发现程序上的缺陷.

@Atry

问题就在于,不应有要求“ B 必须对 C 的所有功能都做封装”

改变设计,让 A 不需要去用 C 的接口才是要考虑的事情。而不是换种方式去满足它:包装 B ,让 A 通过 B 去使用。

如果做这个包装,就成了我说的:草率的设计决定。

这也是一般我们常计较的:为什么增加接口容易,减少却难的缘故。

增加接口意味着加一点体力,不用全盘的考虑问题;不增加接口,却有新的需求无法解决的话,就要考虑,新的需求是否合理,如果合理,原来的设计到底出了什么问题,很可能涉及全部的结构上的改动。

所以做设计总是很艰难的,需要很多的经验和对问题的充分理解。

@limantian

你对文章内容有误解。

i_A 到 A 的转换并没有暴露 A 的信息,B 还是看不到 A 的任何内部细节。只是拿到了一个对 A 的公开接口调用的授权。

即,“B确实不应该直接修改A的内部数据,而应该严格通过A的公开接口去操作A。”

这里更进一步的,严格规定,A 的部分接口只能 B 去调用,其它模块不可以碰。就是说,有些被 A 公开的接口,只应该存在于 A/B 之间。

举个更形象的例子,把 A 和 B 看成插头和插槽。如果有一个方法是连接。那么这个连接的方法要做的是,在 A 对象里记录 B 的引用,在 B 对象里记录 A 的引用。

在这个例子里,连接方法选择放在 A 中,但 B 的公开接口里也需要有一个置入 A 的引用 。可这个设置方法,不应该被 A 之外的模块误调用。否则会出现半连接状态(B 里有 A 的引用,但 A 里没有 B 的引用)

越层的模块联系并不一定是坏事。
在 A 依赖 B, B 依赖 C 的情况下,如果要求 B 必须对 C 的所有功能都做封装,往往会增大 B 的代码量,增加 B 的接口复杂度。而如果允许 A 直接使用 C ,B 就可以做成一个小巧的模块。

个人感觉现实的情况常常是B仅仅需要A的部分数据,因此没有必要在i_A和A类型之间转换来转换去,而是定义一个struct data_for_B,在A里提供方法struct data_for_B* get_data_for_B(),在B中的方法接口类似这样foo(struct B *self,struct data_for_B* A_data),这样做的问题是B无法直接修改A的内部数据,但是B确实不应该直接修改A的内部数据,而应该严格通过A的公开接口去操作A。
另外个人意见是绝对不在c++中使用友元friend。

最近在做c机顶盒的项目,同感

Post a comment

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