1.引言
最近看完了约翰.奥斯特豪特的《软件设计的哲学》,过去工作中遇到过不少与书中类似的问题,书中的见解和启示很值得去探讨和实践。
软件的复杂性主要分为两个层面:软件系统层面的复杂性和软件研发流程层面的复杂性。软件系统很难一开始就做出完美的设计,只有通过一个个功能模块衍生迭代,系统才会逐步形成,然后随着需求变多,再逐步演进迭代。所以软件本质上是一点点“生长”出来的,其间伴随着复杂性的不断积累和增长。在软件研发流程层面,一个简单的改动,哪怕只涉及一行代码,也需要经历完整的流程,牵涉到多个团队、多个工具体系的相互协作。对于大型软件来讲,复杂才是常态,不复杂反而不正常。软件架构和建筑架构有着巨大的差异。建筑架构的设计和生产活动是可以分开的,而软件的特殊性在于“设计活动”和“制作活动”彼此交融,无法分开,软件架构只能在其实现过程中不断迭代,复杂性也在不断积累。在任何程序的生命周期中,复杂性都会不可避免地增加。如果我们想让编写软件变得更容易,从而以更低的成本构建更强大的系统,就必须想办法让软件本身变得更简单。
好的软件设计的重要因素之一,是在处理编程任务时所采用的思维方式。为了尽快实现需求,我们通常会采用战术思维。但是想要一个好的设计,就必须采取一种更具战略性的方式,即投入时间进行简洁的设计并修复问题。战术性编程的关注点是让某些东西能工作,比如新特性和缺陷修复,这似乎完全合理,还有什么比编写能正常工作运行的代码更重要的呢?然而,这种编程方式几乎不可能产生好的系统软件。需要认识到能工作的代码不够的,为了更快地完成当前任务而引入不必要的复杂性,这是不可接受的,最重要的是系统的长期结构,并且能为未来的扩展提供便利。战略性编程的首要目标是得到一个能运行的卓越设计,这需要一种投资心态,必须投入时间来改进系统设计,短期内会使进度放慢一些,但从长远来看会加快进度。最好的方法是持续进行大量小额投资,将开发时间的10%~20%用于系统设计的微小改进,以此来控制复杂性。
2.复杂性的本质
复杂性是指与软件系统结构有关的、使人难以理解和修改系统的所有因素。复杂性有三种表现:1)变更放大,一个看似简单的变更需要在许多不同的地方修改代码。2)认知负担,开发者需要了解多少知识才能完成任务。3)不知道未知,不清楚必须修改哪些代码才能完成任务,或者不清楚开发者必须掌握哪些信息才能成功完成任务。
造成复杂性的原因有两个:1)依赖关系:当某一段代码不能孤立理解和修改时,就存在依赖关系。2)模糊性:重要的信息不明显。
降低复杂性的方法一般有两种:1)通过使代码更简单,更显而易见来降低复杂性。比如消除特例或者以一致性的方式使用标识符。2)将复杂性封装起来,这样在处理系统就不会同时接触到系统的所有复杂性;即模块化设计,模块之间相对独立,在处理一个模块时,无须了解其他模块的细节。
3.模块化设计
最好的模块是那些接口比实现简单得多的模块。首先,简单的接口可以最大限度地降低对系统其他部分部分造成的复杂性。其次,如果修改模块的方式不改变其接口,其他模块就不会受到修改的影响。在模块化编程中,每个模块都以接口的形式提供抽象。接口展示模块的简化视图;从模块抽象的角度来看,实际的细节并不重要,因此接口中省略了这些细节。最好的模块是深模块,它们在简单的接口后面隐藏了大量的功能,深模块就是一个很好的抽象,用户只能看到其内部复杂性的一小部分。同时,模块深度是一种关于成本与收益的思考方式,成本优势在于其功能,越多越好;收益是其接口,接口越小越简单,越易于使用,给系统其他部分带来的复杂性越小。而且,接口并一定不是越多越好。在设计类和其他模块时,最重要的使它们成为深类和深模块,这样可以最大限度地隐藏复杂性。
3.1信息隐藏
信息隐藏包括与机制相关的数据结构和算法、包括与页面大小等底层细节、包括更抽象的高层概念。信息隐藏从两方面降低了复杂性,首先简化了模块接口,减轻了使用模块的开发者的认知负担,其次使系统更容易演进。某个信息被隐藏起来,其他模块就不存在对该信息的依赖关系,与该信息相关的设计变更就只会影响到一个模块。信息隐藏的反面是信息泄露,当一个设计决策反映在多个模块中,就会发生了信息泄露,这就在模块之间产生了一种依赖关系,对该设计决策任何修改都需要对所有相关模块进行修改。信息泄露的一个常见原因是时序分解,执行顺序反映在代码结构中:在不同时间进行的操作位于不同的方法和类中;如果在执行的不同时间点使用到相同的信息,这些信息就会被编码到多个地方,造成泄露。因此,结构和信息需要隐藏一致。另外,将代码分成类大量的浅类,也会导致类之间的信息泄露,通常可以将一个类稍放大类改进信息隐藏。
3.2 通用模块
过度的专用性可能是造成软件复杂性的最多原因,而通用性更强的代码则更简单、更干净、更容易理解。简化代码的有效方法之一就是消除特例。采用通用方式,即实现一种可用于解决广泛问题的机制,不仅仅是当前重要的问题。实现新模块的最佳方式是采用“有点通用”,模块的功能反映你当前的需求,但它的接口应该足够通用以支持多种用途。“有点”要掌握好度,不要做得太过,建立一个通用性太强的东西,以至于难以满足当前的需求。多给自己提出一些问题,这有助于你在接口的通用性和专用性之间找到适当的平衡:什么是能满足当前所有需求的最简单接口?这个方法会在多少种情况下使用?对于当前的需求来说,这个接口容易使用吗?大多数软件系统都不可避免地有一些专用代码。专用代码和通用代码要清晰地分离,分离专用代码的方法有两种:一种方法是将它向上推,应用程序的顶层提供专用特性,针对这些特性进行专用化,一种方法是向下推专用代码,如设备驱动程序就是一个例子,操作系统通常要支持很多种不同的设备类型,会定义了一个接口;对于每个不同的设备,设备驱动程序模块使用该设备的专用特性来实现通用接口。
3.3 分层和抽象
软件系统是分层的,高层使用低层提供的设施。在设计良好的系统中,每一层都提供了上下各层不同的抽象;一个操作在各层上下移动,如果你通过调用方法来跟踪,那么每一次方法调用都会改变抽象。当相邻层具有相似的抽象时,问题往往会以“直通方法”的形式表现出来。直通方法是指除了调用另一个方法,几乎不做其他事情的方法。直通方法使类变得更浅,它们增加了类的接口复杂性,从而增加了复杂性,但并没有增加系统的总体功能。一个方法调用另一个具有相同签名的方法有时是有用的,比如调度器。调度器是一种方法,它使用参数从其他几个方法中选择一个来调用,然后将大部分或全部参数传递给所选的方法,因此调度器还是提供了有用的功能。另一个反例是装饰器,它是一种鼓励跨层调用重复API的设计模式,为少量的新功能引入了大量的重复引用,装饰器类通常包含了许多直通方法,如果过度使用,就会导致浅类激增。除了直通方法,直通变量也是一种跨层API重复的形式,它会增加复杂性。消除直通变量的方法,一种是查看最上面和最下面的方法之间是否有共享的对象,如果存在这样一个对象,那么可能它就是一个直通变量;另一种是将信息存储在全局变量中,引入上下文对象是一种有利的解决方案,上下文对象统一了对所有系统全局信息的处理。
3.4 合并和分开
软件设计中的基本问题之一:给定两个功能片段,它们是应该在同一个地方实现,还是应该分开实现?决定合并还是分开,目标是降低整个系统的复杂性,提高其模块化程度。实现这一目标的的最佳方式似乎是将系统划分为大量的小组件,因为组件越小,每个单独的组件可能越简单。然而,细分的行为会产生更多的复杂性,包括:产生更多的组件和接口难于跟踪;需要额外的代码来管理;组件之间存在依赖关系,调用时导致错误;导致重复等。
如果代码片段之间关系密切,那么将它们整合在一起是有益的,可以采用以下规则来考虑做合并,包括:1)如果共享信息,则合并;2)如果可以简化接口,则合并;3)如果能消除重复,则合并。
拆分方法并不是长度本身考虑,只用从总体上能带来更简洁的抽象,拆分才有意义,可以才有以下的方法:1)提取子任务:细分成一个包含子任务的子方法和一个包含原来方法剩余任务的父方法,父方法调用子方法;新父方法的接口与原来的相同;子方法相对通用,除了父方法,可以被其他的方法调用。2)将一个方法拆分成两个独立的方法,每个方法都对调用者可见。如果原有的方法过于复杂且实现了多个并不密切相关的功能,这种拆分是合理的;但是如果拆分后产生了多个方法需要调用者调用,则有可能出现了浅接口,如果方法之间来回传递状态,就要慎重。拆分应根据是否能简化调用者的工作来判断。
3.5 异常处理
异常处理是软件系统复杂性的较大来源之一。处理特殊情况的代码比处理正常情况的代码更难编写。异常会扰乱代码的正常流程,意味着某些功能未按预期运行。出现异常时,通常有两种处理方法,第一种不顾异常继续向前,完成正在进行的工作;第二种中止正在进行的操作,并向上报告异常。这两种方法都可能很复杂,异常处理代码会造成更多的异常。比如,第一种方法去恢复异常,恢复过程中出现的次要异常往往比主要异常更微妙、更复杂。第二种方法中止操作,必须将其作为另一个异常报告给调用者,为了防止无休止地出现一连串异常,开发者必须找到一种方法来处理。
不必要的异常会加剧与异常相关的问题,这是一种过度防御的风格。任何看起来有点可疑的东西都会被异常拒绝,从而导致不必要的异常激增,增加了系统的复杂性。
消除异常处理复杂性的第一个技巧是定义API使它不存在需要处理的异常,即定义错误不存在。将错误定义为存在并通过抛出异常捕捉到一些软件缺陷,但必须要编写额外的代码来避免或忽略错误,这会增加软件复杂性,也增加了出现错误的可能性。减少异常处理的第二个技巧是异常屏蔽,异常情况会在系统的较低层次被检测到并得到处理,而较高层次的软件不需要知道这种情况。这种方法减少了用户需要注意的异常,简化了接口,使模块更深。降低异常处理复杂性的第三个技巧是异常聚合,用一段代码处理多个异常,与其为多个单独的异常编写不同的处理程序,不如用一个处理程序在一个地方处理异常。一种能处理多种情况的通用机制,也说明了通用机制的好处。降低异常处理复杂性的第四个技巧是让应用程序崩溃。如果错误很难处理或无法处理,而且不经常出现,应对的最简单方法就是打印诊断信息,然后中止应用程序。但是否接受因特定错误而崩溃,取决于应用程序。
对于异常,必须要确定哪些是重要的,哪些是不重要的。不重要的东西应该隐藏起来,而且隐藏得越多越好。当某些重要的东西,就必须将其暴露出来。
4.让代码显而易见
解决模糊性问题的方法是在编写代码时让代码显而易见,代码阅读者会更容易理解,也会减少误解和缺陷的可能性。因此,要使代码显而易见,必须确保代码阅读者始终掌握理解代码所需的信息。可以通过三种方式做到:第一,最好的方法是使用抽象和消除特例等技巧减少所需的信息量;第二,可以利用代码阅读者已有的信息;第三,可以使用一些技术,如好的名称和注释等,在代码中展示重要的信息。
4.1注释
代码内文档在软件设计中起着至关重要的作用,注释有助于开发者理解系统和高效工作。没有注释,就无法无法隐藏复杂性,编写注释的过程正确,实际上会改进系统设计。注释是抽象的基础,而抽象的目的是隐藏复杂性,抽象是实体的简化视图,它保留了基本信息,但省略了可以安全忽略的细节。注释记录了调用者需要的附加信息,隐藏了实现细节,同时完成了简化视图。注释虽然不如代码精确,但却提供了更强的表达能力,可以创建简单、直观的描述。
注释的总体思路是捕捉设计者头脑中的信息,这些信息无法在代码中体现。良好的文档和注释有助于解决认知负担、不清楚哪些代码需要修改的问题,而这些都是软件复杂性的体现。复杂性的主要原因是依赖关系和模糊性,好的文档可以澄清依赖关系,填补缺失的信息,从而消除模糊性。当然,良好的软件设计可以减少对注释的需求,但注释提供的信息截然不同,这些信息无法用代码来表示。
大多数注释可以分为4类:接口级注释、数据结构成员级注释、实现级注释、跨模块注释。其中,接口注释提供了在使用类或方法时需要了解的信息,它们定义了抽象;实现级注释描述类或方法如果通过内部工作来实现抽象,是帮助代码阅读者理解代码在做什么;跨模块注释描述模块的依赖关系。
注释的目的是确保系统的结构和行为对代码阅读者显而易见,这样他们就可以快速找到所需的信息,并有信心对系统进行修改。
4.2名称
为变量、方法和其他实体选择名称是软件设计中最容易被低估的。好的名称就是一种文档,它们使代码更容易被理解,减少了对其他文档的需求,使错误更容易被发现。相反,糟糕的名称会增加代码的复杂性,产生歧义和误解。
在选择名称时,目的是在代码阅读者的脑海中建立一个关于被命名事物性质的形象,一个好的名称能传达大量信息,说明基本实体是什么,而不是什么。名称是一种抽象形式,它们提供了一种简化的方式来思考更为复杂的基本实体。好的名称有两个特性:精确性和一致性。如果很难为一个变量或方法找到一个简单的名称,为所代表的对象创建一个清晰的形象,这就暗示该对象可能没有一个简洁的设计。通过找出名称的弱点,选择好名称的过程可以改进设计。一致的命名方式可以减少认知负担,代码阅读者在一个上下文中见过该名字,就可重复使用已有的知识。因此,一致性就要求始终将通用名字用于指定目标;不能将通用名字用于指定目标之外的任何其他目标;确保目标足够狭窄,以至于所有使用该名称的变量都有相同的行为。
4.3 一致性
一致性是降低系统复杂性并使其行为更加明显的方法,一致性创造了认知杠杆,一旦在一个地方理解了功能如何实现的,就可以利用这些知识立即理解其他使用相同方法的地方。这样会减少花费的时间和减少错误。
除了名称之外,一致性还包括:编码风格、接口、设计模式、不变量。要确保一致性,需要付出一些额外的努力,比如:确定约定、创建自动检查器、寻找类似的情况并在新代码中进行模仿、在代码评审时对团队进行教育等等。
5.结语
优秀软件设计的重要要素之一就是将重要和不重要的区分开来。围绕重要的事情去构建软件系统。对应不重要的东西,尽量减少对系统其他部分的影响,重要的东西应该得到强调,并使其明显,不重要的东西尽量隐藏起来。重要的问题解决能使许多其他问题得到解决,知道一个重要的信息就能使许多其他信息变得容易理解。尽量减少重要的东西,会让系统更简单,而不重要的东西会使设计变得杂乱无章,增加复杂性和认知负担。专注于重要的东西是一种很好的人生哲学,区分重要和不重要东西的能力是成为一名优秀软件设计师的重要因素。
处理复杂性是软件设计中最重要的挑战,良好的设计就是要去发现一个既简单又强大的解决方案,用简单的结构来解决特定问题,简洁明了的设计是一种美。