« 资源的内存管理及多线程预读 | 返回首页 | 本周游戏 »

良好的模块设计

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

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

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

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

首先说说正交性:

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

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

我们的模块目标文件可以是标准的 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 文件中。这样做有时候会让人不太舒服,比如单个源文件过大,可是这正是说明设计出了问题。当我们把本该放在一起的代码,人为的分开(而不是按模块自然分离)时,就已经开始隐藏而不是解决设计问题了。

Comments

to Arty, certain在这里和必然性没有关系, a certain是个短语,就是指某种程度

限制不仅提倡了经济性,而且某种程度上提倡了设计的优雅

刚才查了一下英文原文,是这样的:
constraint has encouraged not only economy, but also a certain elegance of design
这样翻译就把原文的其实都失去了。应该翻译成:
限制不仅提倡了经济性,而且更必然是优雅的设计

全局变量并不等于不能重用代码,只不过全局变量要求代码必须是进程级重用而不能是对象级。进程级重用,的确要容易得多。

进程级代码重用就要求进程必须是小巧的。

windows确实是进程消耗大,所以要用线程。
但是linux既然跟进也开始重视线程,说明线程有它的优势。
至于哲学这回事,是无所谓有无所谓无的。毕竟大多数人是用来做事情的,而不是做学术研究的。

需要学的东西很多

“WINDOWS不提供FORK是因为线程做的好. X系统提供FORK是因为没有线程........... 后来X系统有了线程库, 是因为进程实在太浪费了.”

我怎么记得,Windows才是因为进程太浪费才用线程的呀,而unix下进程的开销很小,另外和Unix的哲学有关,很多人不适应几个程序一起用(配上参数无数),然后用管道相连的方式

说些题外话, WINDOWS的安全做的比***X那些好多了.....

WINDOWS不提供FORK是因为线程做的好.
***X系统提供FORK是因为没有线程...........
后来***X系统有了线程库, 是因为进程实在太浪费了.

底层的 要控制的是硬件或是其它的资源,尽可能的设计的很开放。它需要的只是软件控制它的方法。所以它的本质是方法,所以就是用C来组织的,而不象OO的逻辑,因为它本质就不是OO

OO的代码组织,个人以为还是比较方便.

可以这样看,c++只是提供了一些语言特性的工具,我们在合适的时机选用一部分.

d对中国设计的那段评论很经典!

模块尺寸方面一直没有仔细考虑,都是跟着感觉走。我查了一下最近维护的一些代码,最大的模块 500 多行一个,400 多行的有两三个。然后 100+ , 200+ , 300+ 的平均分布。只有一个 <100 行的。

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

这些模块每一个都有单独的单元测试吗?

2000 年底的时候曾经在 Symbian 上做过点开发,当时还用这个骗了些投资。

C++ 的 OS 接口还是让人耳目一新的。

可是可是,这个东西实在是滥啊,现在想起来是设计这个玩意的一帮人中了 MFC 的毒害了。

Symbian 系统上的东西不好用,我是深受其害, http://blog.codingnow.com/2006/07/eoeueoiaee.html

现在用 treo 650 ,舒服多了。当年跟投资方接触时一口一个 palm os 不如 epoc ,还真是惭愧。

to ATry:
《Unix痛恨者手册》很有意思,哈哈

对了有一点大家不要误解。操作系统的API基本都用过程式语言,除了开发时的时代背景外,主要是为了兼容性,能同时兼顾多类语言。作为操作系统的设计者不能去限制API调用者所用的语言类型,如果他这么做那他不但失去了这部分开发者还失去了已有代码的使用者。可能对于我们来说无所谓,做一下移植就好了,但如果一个公司购买了一套软件,如果那个平台不兼容那他就不会去接受这个新平台,并抱怨影响了他的资产保值。
只有新兴操作系统才可能比较少的考虑兼容性,比如Nokia的Symbian就是纯OO的API,因为来的都是首次开发,且由于开发环境大相径庭其他平台上的已有代码能再利用的可能性很低,所以历史包袱很轻。

to 乐水:

《Unix痛恨者手册》上有一句话。“通过控制进程的产生与终止来进行内存管理,这就如同通过控制人的生死来对付疾病”

设计的好坏,跟什么语言没有关系。
复杂的语言也可以作出简洁的设计。不使用复杂的语言,只是对其他人设计能力不信任的一种表现而已。

都是在内存足够你们挥霍的情况下才不去主动的考虑释放资源。如果试试只有1,2m甚至只有几百k的手机上开发游戏,你能想象得到及时合理的使用内存的重要性么……

[quote]OO,其实有点像是给代码组织能力不够的人,在程序规模达到一定程度时使用的某种解决方法[/quote]
missdeer这段话深有体会,因为最近的一个毕设项目,一开始是不用类的,后来实在看不下去了,就用类重组了一遍,顺利解决了

to d:你说得有道理,谢谢你。我应该说找到了较为清晰的方向。
看云风的blog差不多一年,虽然我对游戏开发没什么兴趣,我对网络开发兴趣较浓点,但还是很喜欢他写的东西。甚至还翻出几年前的一些文章来看。

不知道是不是我的理解能力有限,
我觉得所谓的“设计模式” 也不是人们说得那么神吧 ?

相反,我觉unix的K.I.S.S更管用些。模块接口我都写的尽量简洁、清晰,已经足够啦。

看云风的主页/Blog 也有几年了,忍不住引用小平的那句话:不管白猫黑猫,抓住老鼠就素好猫 :) 俺也素个实用主义者,同意 analyst 大大的说~

慢即是快。

作为项目负责人和团队负责人,我比项目中任何人都关心项目进度。

项目进行的效率和编码的效率不是一会事,项目只有做完并达到预期目标才算成功不是吗?

d同学说的有点道理,OO就是用分解对象的方法思考问题,用OO比较容易描述系统的结构。不管用不用OO,在大多数的程序里你总是要面对一大堆的对象的,OO是一种相对廉价低成本的处理对象问题的方法,用C显然是要比C++付出更多的代价。

对于像云风这样,恨不得把操作系统也自己实现了,把代码看的比项目更重要的来说,廉价的当然未必就是好的,这是一个价值取向的问题。

但是对于我这种比较懒惰的实用主义程序员来说,比较在意每行代码的成本,能用现成的就用现成的,尽可能的用简单快速的方法解决问题,我不会排斥硬编码、copy-paste代码,前提是他们能够提高效率。

学着象机器一样思考,机器才会象人一样工作

中国开发者欠缺在工程设计方面,从机械工程,电子工程,到现在的软件工程我们都落后于西方国家,很多西方软件大师以前是做电子/机械设计出身的(甚至还有建筑设计出身,记得“设计模式”一书中有提到)。软件行业的落后,是中国在各个工程设计领域长期落后的必然结果。对于软件这样的新一代工程领域,不要相信"重新洗牌""重新站在同一起跑线上"这种话,没有捷径,欠下的总是要补上的。

OO可以看作是一套 language feature pack。不要为了OO而OO,如果你的代码需要其中某些个feature那就用它,如果不需要就不用,很简单。任何东西的滥用都适得其反。还有一点我对中国把OO翻译成"面向对象"的做法极为不解,本意应该是"对象导向的",西方国家地广人稀且达官贵人常有封地而住在郊外,所以常会在沿路各个路口设置箭头指示牌,这才是oriented这个词的原型。OO的目的不是要独占你的视野(You don't have to face it),而是提供一种领向项目成功的指引。这一曲解真是害人不浅。

longtrue:
每个"大师"都有他们的见解和偏好或者说"学派",允许甚至保护不同的声音是西方国家的价值观。初学者常困惑于同时听到不同的甚至是互相矛盾的"圣言",一般来说不管选哪条路都比摇摆不定要走得远。

说的非常好。

Once And Only Once
零重复代码

真有“大师”认为一个模块应该小于100行代码。我还以为我这样做是我自己变态呢。

我太爱这位大师了,这位大师是谁啊?

看到良好设计的代码,感觉就如一道美丽的风景令人百看不厌。<br><br>
一直以来都将函数的接口设计得比较复杂(正如云风文中所说的,比较欣赏Windows API),以为通用就是好,不过最近看来有时候这样不是良策。今天看了云风的文章,有点感触。<br><br>
曾经好像看过某位大师建议模块代码<=100行比较合适,偶虽然没有什么大型项目的经验,但自己感觉这样的设计会比较好...<br><br>
另外,感觉对于简单的OO代码,现在的编译器已经能将其编译得如精心设计的C语言版本一样好了。一次看到一个bfs搜索的程序经过G++编译之后居然比类似的C版本还要快,汗一个!

Atry的心理过程,也是我看本文的心情过程,呼呼……

我写了一些代码,一共100个文件左右,最大的一个文件158行。

不是用不用 OO 闹的,主要是线程安全给闹的。花的时间嘛,慢即是快 :D

追求简洁是对的,但是在一个基本的资源管理模块上折腾那么多时间,设计也没有什么新颖之处,估计就是不用OO给闹的吧。

模块尺寸方面一直没有仔细考虑,都是跟着感觉走。我查了一下最近维护的一些代码,最大的模块 500 多行一个,400 多行的有两三个。然后 100+ , 200+ , 300+ 的平均分布。只有一个 <100 行的。

不管是否同意OO的人,都同意划分模块,抽象出接口。问题是模块的尺度应该是多大?

一个模块的尺度应该是多少行代码?
我指的一个模块,是指的一组正交并且操作同一数据的接口。TAOUP里面认为是200-400行。为什么不是更小?

读本文的时候,看到OO之处开始有一种冲动想要冲上来辩论两句,看到单件一段这种冲动更强烈了。

当我把这种冲动抑制住之后,我却不得不在内心承认也许你是对的。我感觉到我自己的程序水平和博主的差距……

老大,你在网易是不是作通用的底层平台(库)的阿,感觉你的大部分设计都不是针对具体产品的。如果网易可以把所有的软件产品构建在统一的底层平台,真该刮目相看了

练精化气,练气化神,练神反虚,练虚合道……对不起,最近玄幻小说读多了。

嗯,我比较喜欢这些哲学意味浓一点的文章

期待云风吹汇编的BLOG出炉。

C++和OO是用来解决大规模程序项目也就是所谓的软件危机的解决方案.而大规模的程序项目究竟有多大我想在国内的一般软件公司都不会涉及到.所以产生诸如C++的批判.其实不同的工具应用于不同的方向罢了.

在看snort的源代码,那么多个文件,那么多的结构,函数,我真佩服有这样控制能力的人。
怎样修练才能达到这样的水平呢?大侠们来说说吧。我还是学生。

现今我们内存“足够”的情况下,确实可以不用精确的释放内存。

感觉有点轮回的味道

http://go.culture.163.com/reporter/article.jsp?hash=c779ccedd37893df2aacbcf887d5cf10
这个云风应该不是你吧?
网易叫云风的还不只一个哦:)

OO,其实有点像是给代码组织能力不够的人,在程序规模达到一定程度时使用的某种解决方法。云风能用C来做这样规模的项目,这点上还真是值得让人佩服的。记得上一次这样的感觉是几年前,还在学校的时候看MINIX2.0的代码,不停地感叹为什么他用C能写出这样通俗易懂,干净整洁的系统来。


非常同意

Post a comment

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