不同软件规模下的代码设计原则
声明:本文题目所指的“原则”为本文作者原创,系本文作者在多年的软件开发实践中所摸索出来的成果。
背景
软件工程在理论研究与实践应用方面都经过了很长时间的发展,甚至与软件本身的历史一样长。伴随着软件工程的长时间的发展,代码设计的内容也变得越来越丰富,复杂度也越来越高,甚至显得有些复杂的过头了。近十年来,敏捷开发思想悄然流行,说明大量的开发者趋向于认同如下的观点:软件工程上的大量原则过于复杂,其复杂度导致开发者遵循这些原则所付出的代价比不遵循这些原则而带来的额外的开发和调试代价还要大。但是如果完全不顾工程上的原则,所付出的代价可能很大,甚至影响一个软件产品本身的生命期。那么当我们面对一个具体的软件项目,工程上的原则究竟应用到什么程度才是适度的,也就是说既是足够,也不过头?本文试图在这方面提供一些可供参考的信息。
人均错误率
每一个人在编码中都会犯错误,错误率E大致可认为是两个变量的函数 ,其中 表示个体差异, 表示代码复杂度系数。很明显,不同的人执行同样的任务有着不同的正确率,这取决于态度、性格、思维模式等等个体因素。另外一个因素就是代码本身的结构状态,比如模块化的程度、功能抽象的程度,模块关联关系,同步策略等等。很明显,如果让一个人一天做一万次整数加减法,绝大多数人都难免会犯错误,尽管更为“细心”的人总是能得到“更少”的错误,但是让普通人能够达到我们目标预期的正确率正是软件工程的目标之一。
我们的目标
假定一个开发群体的平均编码错误率为E,目标软件产品的规模为W,那么可预期的总体错误率就是 ,这个结果可以最终对应到bug的数目上。规则是这样,但是结果并不总是能被人们所接受的。比如当我们开发一个软件产品,它的代码规模是以前的十倍,那么我们的老板和我们的客户是否能够接受:它的bug数目是以前的十倍?bug数目的增多伴随而来的结果是产品不能正常工作的时间增多,也意味着客户额外浪费的时间增多,所以实际上市场能够认可的产品,它的bug总数是不随产品规模的增长而增长的。这就要求,在产品开发阶段,对于规模越庞大的软件产品,就要使用更加严格、更加保守的实现策略,这不仅包含代码设计上的,还包含软件工程的方方面面。
我们面对的现实
在XX公司自主开发的产品中,客户端总体代码规模大约有20-50万行这样的规模,这是在没有使用中间层的情况下,如果基于GamePlay中间层来开发,那么客户端总体规模很可能要突破50万行,达到50-100万行的规模区间,大约相当于10个人1年的编码工作量,这仅仅是编码,按照通常的经验,单纯的编码性工作只能占到总体开发时间的三分之一左右,其它时间用于调试、功能调整、因考虑不周带来的方法和策略调整等等。这样一个游戏的客户端的总体开发量相当于10个人3年的全时开发。这是在从零开始开发的一个大略的估计。因为我们的软件模块都尽力朝着可复用的方向发展,包括游戏引擎、GamePlay中间层等等,都具有一定的复用性,所以具体的游戏的开发周期要比这个预期短一些,只是第一代产品开发的周期要长一些。
以单个人为单位,不加设计的代码如果能够达到可被最终用户使用的程度,其规模应该不超过一千行。简而言之,一个“Hello World”不需要应用任何代码设计。一千到一万行这样规模的软件模块需要简单设计,包括模块化分割、接口包装、类层次抽象等等。一万行到十万行需要执行彻底的模块分割策略、高度的类抽象设计,保守的同步策略等等。十万行以上超出本人视野范围。这里所指的多少行指的是开发团队中一个人会直接参与编写调试的模块以及与该模块高度耦合的模块加在一起的总规模,而不是指最终软件产品的总规模。这是因为代码设计的工程原则是针对降低个人的出错概率而提出来的,因此衡量代码规模的标准是一个人而不是整个产品。
对于有一定历史的软件公司而言,公司有许多产品都是在长年的发展中慢慢发展起来的,从最初的小规模产品,最终发展到大规模的产品。在某个具体的产品的发展历程中,早期的小规模导致代码上的粗略设计,后来代码规模渐渐庞大了,但是相应的软件结构却往往没有做相应调整以适应日益增大的软件规模,这往往是成本上的原因,毕竟代码重构的代价在大多时候都是大家承受不起的。当然这也不是绝对,有时对产品未来生命期的良好预期以及重构产品所能获取的潜在回报有时也让开发商去选择重构产品。
无论如何,软件设计在软件开发上的是不容忽视的,在软件开发的每个阶段,开发团队的领导者与决策者们都需要经常review已经应用的设计,并且评估是否需要调整该设计。而不是一旦设计完成,就等着看该设计把产品烘焙“出笼”。
一些推荐的软件设计原则
针对XX公司的大部分软件开发项目从规模上来说已经比较大了,所以本人在这里就这种开发环境提一些个人建议。
1. 彻底的模块分割策略。模块的功能要单纯化。比如对象的创建,要么是所有的对象都是同一个模块创建的,要么每一种对象都有一个对应的创建模块,这是两种不同的设计模式,根据对象的复杂性而选择。但是如果一部分对象由某个公共模块创建,另一些对象要单独的模块来创建,这就是不好的设计了。模块的划分不仅是空间上的,也包括时间上的。比如某个Factory类,专门用于对象创建,可是这个类的不同成员函数运行在不同的线程中,那么我们还是不能把这个类认为是一个模块。这个原则要求我们,不仅要降低模块的空间耦合性,也要降低模块的时间耦合性,而不是依赖于同步锁来提高可靠性。
2. 高度的类抽象设计。这一原则是说,类结构的设计对目标系统对象的描述能力要足够的好,以至于绝大多数的类成员函数的规模都在一百行以下,并且,代码拷贝的现象被基本杜绝,而灵活性依然能满足要求。比如在游戏引擎中为了描述资源对象,那么它是内存资源还是磁盘资源,这是第一个区分点,可渲染资源还是不可渲染资源,动画资源还是非动画资源,光照资源还是贴图资源,二维贴图还是三维贴图,每一个区分点在类结构图上都代表一个二叉树的节点,那么整个资源对象的类结构图就会有七八个层次,类结构是静态结构,类层次的增加并不会带来运行时间的增加,当代码规模变得庞大,目标系统变得复杂,就要果断的提高类层次深度才能准确描述目标系统。
3. 保守的同步策略。其实最好的同步在设计上,最可靠的系统也来源于设计上。其实我们不应该过多地依赖于程序员本人的细心与谨慎,不同的线程互相之间不存在时间上与空间上的交叉与冲突才是一个可靠系统的根本保障。这就是我所说的保守性同步的概念。
一点特别说明,在年轻的开发群体中要特别注意线程滥用的问题。很多时候,一些开发者都通过创建线程来实现异步的执行效果,但是对于线程所带来的额外的执行开销(包括线程创建、销毁、切换的开销)和它所带来的同步开销没有很清晰的把握。这两种开销中,前一种是运行时开销,将会影响软件运行时的效率,后一种是开发开销,将会影响代码设计的复杂度,增大软件的开发周期和调试周期。线程作为操作系统级的调度单位,其创建与销毁都有一定的系统开销,在线程切换的时候,也会有显著的系统开销,因为系统要完成一个完整的上下文切换。另一方面,在开发的过程中,由于运行时间的不确定性,代码必须作频繁互锁和同步,这也增大了开发和调试的难度。对于同一个任务来说,所划分的线程越多,那么往往不同线程竞争相同数据的机会也就越多,因此带来的同步次数就要增多。对于服务器端的程序来说,尤其需要注意线程的使用问题。假如一台服务器能够承受十万个用户的同时在线访问,但是如果服务器端为每个用户开一个线程来处理,一个系统中有十万个线程同时运行,天知道系统性能会变成什么样。