软件设计是一件困难的事情,因此你第一次想到的模块或系统结构方案,极不可能就是最佳设计。对于每一个重要的设计决策,如果能够认真考虑多个备选方案,你最终往往会得到更好的结果:
设计两次(design it twice)。
假设你正在为一个 GUI 文本编辑器设计负责管理文件文本内容的类。第一步是定义这个类向编辑器其他部分提供的接口。不要刚想到一个方案就立即采用,而应该先考虑几个不同的可能性。
一种选择是面向行(line-oriented)的接口,提供插入、修改和删除整行文本的操作。另一种选择是基于单个字符插入和删除的接口。第三种选择是面向字符串范围(string-oriented)的接口,它作用于任意字符区间,而这些区间可以跨越多行边界。
此时你并不需要把每种方案的所有细节都设计完整;只需要草拟几个最重要的方法即可。尽量选择彼此差异足够大的方案。这样你能够学到更多东西。即使你确信只有一种方案是合理的,也仍然应该强迫自己再设计第二种方案,无论你觉得它有多糟糕。思考这种方案的缺点,并将其与其他方案的特点进行比较,本身就是一种有价值的训练。在粗略设计出多个备选方案之后,列出它们各自的优缺点。
对于接口而言,最重要的考量因素是它对高层软件来说是否容易使用。以上面的文本编辑器为例,无论是面向行的接口还是面向字符的接口,都会让使用该文本类的软件承担额外工作。
面向行的接口要求高层软件在执行部分行操作或跨多行操作(例如剪切和粘贴选区)时自行拆分和合并行。面向字符的接口则要求高层软件使用循环来完成那些会修改多个字符的操作。
同时还值得考虑其他因素:
例如在文本编辑器例子中,面向字符的方案很可能明显慢于其他方案,因为每处理一个字符都需要单独调用一次文本模块。
比较完多个方案之后,你就更容易判断哪个设计更优秀。最佳方案可能就是其中某个备选方案;也可能是在比较过程中发现能够将多个方案的优点融合起来,形成一个比所有原始方案都更好的新设计。有时所有备选方案都不够理想。遇到这种情况时,尝试设计新的方案。利用你在原有方案中发现的问题来推动新设计的产生。
如果你在设计文本类时只考虑了面向行和面向字符两种方案,那么你可能会发现:两种方案都显得笨拙,因为它们都要求高层软件承担额外的文本处理工作。
这其实是一个危险信号。如果系统中已经存在一个文本类,那么它就应该负责所有文本处理工作。为了消除这些额外处理逻辑,文本接口必须更贴近高层软件真实执行的操作。而这些操作并不总是对应于单个字符或单独一行。沿着这个思路继续推导,就会得到一种面向范围(range-oriented)的文本 API,它能够消除前面两种设计中的问题。
设计两次原则可以应用于系统中的许多层面。对于一个模块来说,可以首先利用这种方法选择接口设计(如前面所述)。
然后在设计实现时再次使用它。例如对于文本类,可以比较链表实现、固定大小字符块实现、Gap Buffer(间隙缓冲区)等不同方案。此时目标与接口设计阶段有所不同:对于实现而言,最重要的因素是简单性和性能。在系统更高层面上,多方案比较同样有价值。
例如:
在这些场景中,只要能够比较几个备选方案,就更容易找到最佳设计。设计两次并不会额外耗费太多时间。对于较小的模块(例如一个类),通常只需要额外花费一两个小时来思考替代方案。与随后数天甚至数周的实现时间相比,这点投入微不足道。最初的设计探索往往会产生明显更优秀的设计,而这完全值得你投入的额外时间。对于更大的模块,你需要花费更多时间进行前期设计探索;但实现周期本身也会更长,因此优秀设计所带来的收益也会更高。我注意到,对于真正聪明的人来说,接受“设计两次”原则有时反而比较困难。
在成长过程中,聪明人往往发现:对于大多数问题,他们脑海中最先出现的想法就足以拿到高分;没有必要去考虑第二种或第三种可能性。这种经历容易形成不良习惯。然而,随着职业发展,人们会进入面对更复杂问题的环境。最终,每个人都会遇到这样一个阶段:
第一想法已经不够好了。如果想获得真正优秀的结果,无论你多么聪明,都必须考虑第二种可能,甚至第三种可能。大型软件系统的设计正属于这一类问题:
没有人能够第一次就把它设计正确。
遗憾的是,我经常看到一些聪明人坚持实现自己脑海中最先出现的方案。这使他们无法发挥真正的潜力(同时也让他们变得难以合作)。也许他们潜意识里相信:
“聪明人第一次就能做对。”
因此,如果自己需要比较多个设计方案,就说明自己其实不够聪明。但事实并非如此。
问题不在于你不聪明,而在于:
这些问题本来就非常困难。
而且,这其实是一件好事。解决一个需要谨慎思考的难题,远比处理一个根本不需要动脑筋的简单问题更有乐趣。设计两次不仅能够改善你的设计成果,也能够提升你的设计能力。
不断构思并比较多个方案的过程,会帮助你理解:什么因素会让一个设计变好,什么因素会让一个设计变坏。随着经验积累,你会越来越容易排除糟糕方案,并快速识别真正优秀的设计。