代码改变世界

(转)良好的模块设计(云风)

2010-05-29 16:01  六水  阅读(491)  评论(0编辑  收藏  举报
转自http://blog.codingnow.com/
作者:云风

http://blog.codingnow.com/mt/mt-tb.cgi/262

良好的模块设计

这周程序写的比较兴奋,通宵过一次,另一天是四点下班的。做了两件事,一是研究怎样最好的做扩平台,二是做资源管理的模块。

第二个目标昨天达成了。觉得整个过程还是攒了些经验,值得写写。那就是“怎样才算设计良好的模块”。这个话题比较大,几次想总结经验都不敢下笔。

这个题目前辈已经论述的太多,而自己的感悟一但落到文字就少了许多东西,难免被方家取笑。

最正确的道理永远是简单的,却因为其简单,往往被人忽略。程序员还是要靠不停的写新的代码,以求有一天醍醐灌顶:原来自己一直懂的简单道理,其实才刚刚理解。

首先说说正交性:

我们都知道保持正交性,可以在非常复杂的设计中降低出错的几率。纯粹的正交设计中,模块内的任何动作都对外部无副作用。

道理简单,但是真正做到很难。所以我们用额外的约束来接近这一点。

我们的模块目标文件可以是标准的 dll 或 so (方便开发期调试),也可以是自定义格式。在自定义格式中,是没有导入导出表的,模块对外的接口只有入口点一个。而如果在开发期用系统的动态模块,则需要在编译时加上 --nostdlib (对应 cl 的 /nodefaultlib)。这样可以拿掉 libc 的隐式链接,并检查有无对外依赖关系。表面上看起来苛刻,但这是一个杜绝副作用的有效途径。这样,自然任何第三方库都不能直接使用了。这也是我不停的再造轮子的源头之一。这条原则我们严格执行了两年,回头看来,其实真正在造轮子上花的精力并不多,况且造好后就不会重复工作了。偶尔有不适用的轮子也可以轻易换掉,这得益于正交性的严格保证。

接下来更重要的是接口的简洁:

从《Unix 编程艺术》上读到一段话:(书中引用了)《C 程序设计语言》上的一句名言,“……限制不仅提倡了经济性,而且某种程度上提倡了设计的优雅”。为了达到这种简洁性,尽量不要去想一种语言或操作系统最多能做多少事情,而是尽量去想这种语言或操作系统最少能做的事情——不是带着假想行动,而是从零开始。

这段话我在不同的时期读过几次,相当的喜欢。为什么 Windows 的 API 看起来丑陋无比?因为它什么情况都能处理,几乎所有的 API 组都为将来留下了诸多扩展的可能性。有的 API 从出生到死亡,lpReserved 伴其一生都没被使用过。学生时代的我,很喜欢 Windows 的这种处世哲学:看吧,只要你会用,你手头的计算机的一切潜能我都能为你挖掘出来。即使现在不行,接口摆在这里,你可以通过接口看到未来的可能性。可惜的是,直到今天,我都不能在 windows 下 fork 一个进程。而 CreateProcess 每次用到都想吐血。

今年开始,我用 C 语言重构项目中 C++ 代码。好在在上一版里,C++ 代码中已经没有用任何高级特性了,故而这件事情做起来还算轻松。一边做一边思考哪些地方可以修改设计,去掉或合并多余的接口。

如今我爱 C 语言,就是爱它的限制。不能方便的 OO ,那么就限制我不去用它或少去用它。没有类定义和名字空间,减少 C 函数接口的数量就成了反复需要考虑的问题。重新用回 C 语言后,我不再 typedef 结构名。union 就是 union ,struct 就是 struct ,何必换个名字减少键击。精简的设计将减少更多的代码字节数。

至于对 OO 或是特定 OO 语言的批评,不能在 blog 里多写,不然一定变成找骂贴。当然已有一个不怕挨骂的前辈 Eric S.Raymond 已经在 TAOUP 4.5 中写了许多了。我想,不赞同这个观点的人即使耐心看完了依然会不赞同,因为这些道理靠文字是没有说服力的。

很惭愧自己在 02 年到 04 年的三年招聘中出了太多关于 C++ 和 OO 的笔试面试题,(02 年在私人名片上,我毫不遮掩的印上了自己对 C++ 的狂热),而我现在背叛了这些。不过对这些了解的再多也不为多,每次面临新的设计时,就可以找到足够的理由不用 C++ 不用 OO 。对,就算是用 OO ,我也可以保证对象设计的整洁清晰;但我更清楚的知道,其实我可以不用它。

做到以上两点,其它的一些就自然做到了:比如永远不要 copy-paste 代码,不要重复实现类似的东西。不要为尚不存在的需求做设计,你要相信你现在设计足够清晰,日后改起来也不吃力……

--------------------------------------------------------------------------------

接下来谈谈这几天的一个设计吧,说不上好,也没有什么特别之处。不过完成它还是费了些工夫,代码总行数不多。留下来的就 1000 来行,写了三四天,低于我的日均生产力 。速度比较慢是因为大量时间花在了不停的修订和裁减接口上了。

模块的需求不多,游戏的客户端在运行时需要加载大量的数据资源。总的字节数按 G 计算,所以面临两大问题:一是总体加载时间过长(由于大部分文件加载过程还需要再次解析,不能直接从硬盘做内存映射),二是 32 位系统的地址空间不够用。结论是,必须动态管理。

给用户暴露的接口理论上只需要一个:load ,这就够了。实际实现的时候,我追加了两个接口 lock 和 collect 。lock 的作用是,保证数据块的所有数据皆存在于内存,并保证其生命期至少维持到下一次的 collect 调用。而 load 本身只保证逻辑层需要的数据被加载进内存,而不保证渲染层需要的数据。(游戏中渲染层需要的数据才是尺寸上的大头)

另外我需要一个可替换和配置的处理加载方式的模块。它可以采用单线程阻塞调用,或是多线程预读模式。还可以允许用户不通过文件系统直接生成一些资源的内存对象,甚至以后有可能增加网络加载模式。另外,数据的来源可以是本地文件系统,也可以是数据库或自定义数据包。

我为游戏的资源设计了一种数据管理格式,可以描述出资源之间天然的依赖关系以及数据类型。这些方便预读模块自动做出合理的判定。在本地文件系统下,这套机制需要用额外的文本文件来模拟数据文件之间的关系。

每种文件根据格式的不同,有不同的解析方式。虽然最基本的文件可以通过直接把文件内容全部加载来完成。但是还有许多文件不能这样做。简单如贴图文件,需要做图象解码,复杂如场景需要在加载的同时生成复杂的内存对象。各种数据的解析器也应当是可配置的选择。

大体上就是要想办法粘合以上三个模块,提供出简洁的接口给不同的用户。有开发更高层次逻辑的程序员,也有编写不同加载策略的程序员,或是添加新的数据类型的程序员。

面对不同的二次开发人员,接口应该相互独立,隐藏所有可以隐藏的细节。对于外部可配置之模块的开发,不需开发者遵循太多的规则就可以避免潜在的副作用。

这里面比较麻烦的是数据解析模块的扩展接口,它要使后期开发人员可以无视线程模型,即不需要在代码使用任何锁相关的 api 也可以保证线程安全。最终的方案是让开发人员提供一个 parser 的 callback 函数。管理器对其回传三个参数:数据流指针,上下文数据,和内存池指针。回调函数的返回值就是生成好的数据块内存指针。

暂时我们的需求中,加载方案模块在运行期只有唯一的一个(这一个可以通过初始化阶段配置),所以我们可以从其中取到对数据流指针和内存池指针进行操作的方法。由加载方案提供者来保证其线程安全性。

上下文指针的存在是因为我们的资源数据读取被划分成两到三个阶段:数据头的读取和数据内容的读取。上下文指针指向的内容是由解析器自己定义的,并由它自己维护。内存池只能从中分配而不能释放,因为解析器也原本不承担生存期管理的责任。

整个方案的细节描述到此为止。

将其自行对比一开始的一套更为 dirty 的设计,以上要简洁许多。最明显的是一开始的头文件长度是现在的两倍。写代码的时候还曾经暗暗埋怨了一下 C 语言为什么没提供一个简单的 class 机制。直到最后反复考量,删除了一些不必要的方法后,心情好了许多。

--------------------------------------------------------------------------------

ps. 最近一段时间颇有感触的一点:当年认为准确的构造和销毁对象是一种美德。在对象层次过深的时候,这的确是保持正确设计的一种必须。今天看起来,其实大部分对象一旦存在于进程,他们的生存期都是一直维系到进程结束。那我们为什么要准确的,优美的,按照合理的次序,销毁它们?这本该是 os 的进程管理器做的事情啊。在应用层面看待资源的回收,和在 os 层面来看,复杂度完全不在一个数量级。不难理解, os 总是可以做的更好。

一旦我们在代码中正确的位置使用只分配永远不考虑释放的内存/资源管理策略。大量的代码将被简化。数据结构也可能简单许多。不必考虑次序,甚至不必做引用指针(许多引用的存在,都只是为了可以最终启动相应的销毁过程)。

单件或许并不邪恶,只是我们一直在邪恶的用它罢了。

--------------------------------------------------------------------------------

5 月 17 日

前两天估计有点累,昨天 23 点就回家睡了。今天起了个早,特精神。

想到一点补充:

组织代码的时候隐藏细节很重要,所以应该尽量减少头文件的数量和体积。任何信息如果不给模块使用者用,那么就永远不要写到头文件里。

函数原型声明只应该用于对外的接口描述,一切有依赖关系的代码,都可以用前后次序来保证。如果必须在源文件前面提前声明函数原型,或是做结构的前置声明。那说明代码中出现了间接递归。间接递归这个东西往往是坏味道的前奏。间接递归会导致多个东西相互依赖。写到这里,我想到了十几年前刚写程序时,跟人争论的一个问题:为什么 main 函数习惯上总是放在源代码的最后?当时我是说不出太多理由的。如今,我坚信这种习惯的正确性。

如果坚持去避免函数原型声明,马上会发现一个问题,那就是单个模块的主体都需要保持在一个 .c 文件中。这样做有时候会让人不太舒服,比如单个源文件过大,可是这正是说明设计出了问题。当我们把本该放在一起的代码,人为的分开(而不是按模块自然分离)时,就已经开始隐藏而不是解决设计问题了。