好的代码特征可以分为外部特征和内部特征。其中,外部特征是高质量代码应有的外在表现,从结果角度衡量。对这些特征的判断无须深入代码,即使是一个不懂软件的人,也能从外部感知到。内部特征则体现了代码是否“专业”,从代码的内部质量角度衡量。经验丰富的软件工程师,只需要大致读一下代码,就能感知到代码的大致质量。
优质代码的外部特征可以概括为以下5条。
.实现了期望的功能。(实现了需求要求的功能)
。.缺陷尽量少。 (问题单尽量少)
.易于理解。 (交付的代码同事之间容易看懂)
.易于演进。
.易于复用。
前两条和代码的外部质量有关,关注当下的效益,软件开发人员和项目管理人员给予最高程度的重视;后3条和代码的演进有关,关注长期的价值,却也是最容易被软件工程师所忽略的。但往往是造成软件项目后期腐化无法复用的根本原因。
软件理应实现期望的功能,这无须多言。如果开发的软件不能给业务相关人带来价值,那对它所做的一切开发活动自然都是浪费。不过,碍于软件所解决问题的复杂性和人类沟通的复杂性,实现期望的功能并不那么容易。在实际工作中,我们经常会遇到下面这些情况。.用户心目中的目标和用户能描述出的内容并不一致。.产品经理所理解的用户表述,和用户描述的内容存在偏差。.产品经理编写了需求文档,但未能进行精确的需求表述。.即使需求文档表述准确,开发者也有可能产生误解。.即使开发者的理解是正确的,也可能存在用户没有描述出来的隐含需求。**也就是是说在开发需求传递的每一个环节都有可能造成有关于需求信息的遗失以及扭曲。**这个现象是不是有点类似于卷积神经网络的梯度消失问题。既然类似那么也可以参考梯度消失的解决方案。将每一个环节传递的信息归档并传递到下一个环节。此外,用户真正需要的和用户描述的往往并不一致,经过层层加工,信息更是进一步失真,最终不仅成本大量超出预算,所开发的东西也不能真正满足用户的需要。作为研发人员也需要参与到产品需求的分析于设计中去。
软件解决的是现实世界的复杂问题:需求问题如此普遍的最根本原因是软件解决的是现实世界的问题。现实世界有多复杂,软件就有可能多复杂。即使是用户自己,其表达出来的需求也和自身真正的期望相去甚远。开发软件的过程是一个持续建立认知的过程。我们不能寄希望于业务人员一开始就对问题有直达本质的认知,随着开发过程的展开,渐渐地弄明白问题是很正常的。同样,我们也不能寄希望于开发人员一开始就能有直达本质的解决方案,随着开发过程的展开,渐渐地弄明白方案也是正常的。要真正产出有价值的软件,需要关注以下两个重要的方面。(结果不可能一蹴而就,要有演进的思维)
.加快认知的过程。
.增加设计的弹性,在出现问题时能较快调整。
高质量沟通是困难的,也是容易被忽略的:需求问题产生的另一个重要维度:沟通。在现实世界中,每一次信息传递都意味着一次信息损耗。在综艺节目中有一个常见的“拷贝不走样”游戏。这个游戏之所以有趣,是因为信息在前后传递的过程中,很可能会产生偏差。在软件开发中,情况也非常类似,即使一开始的认知是正确的,可由于信息传递过程中不可避免的偏差,也常常导致最终的产出和预期相去甚远。如何降低信息在传递过程中的失真,也是软件开发人员不得不面对的一个问题。实现高质量的沟通非常困难,身在其中的人往往并不自知。一个经常发生的现象是:业务人员觉得自己已经交代得很清楚了,开发人员也觉得自己理解得很清楚了—结果是表面上一致,事实上却谬之千里。此外,“实现正确的需求”并不仅仅局限在系统层次。系统的某个局部可能是由多个开发者协作完成的,这个局部也存在需求问题,并且越是局部问题,细节就越多,这些问题也就更加值得重视。
优秀的开发者会关注自己开发的软件的真正价值,而不只是盲目地接收到手的需求。实践表明,开发者的积极投入是高效理解需求、提升设计质量的关键。没有来自开发者的积极沟通,需求设计的质量就很难提升,开发工作的结果自然也不可能太好。(研发人员参与需求分析)
结构化的探索:件开发从本质上讲是“从无到有”的过程,在这整个过程中,一个客户或业务方脑海中的想法,逐步变为真实运行的软件系统。“从无到有”意味着探索,而探索是需要结构化的方法的。如果没有清晰的探索方法,探索效率就不可能高。一件事情的确定性越弱,所需要的结构化思维能力就越强。例如,面对需求的不确定性问题,需求分析金字塔就是有效探索需求的方法。此外,领域模型,则是增强认知、加强沟通的重要工具。
注重沟通:优秀的软件工程师往往也是沟通的高手。不得不承认,许多人可能对此有偏见。如果认为编程就只是和机器打交道,那就忽略了软件其实是解决现实问题的工具这一本质。软件工程师不一定都开朗活泼、妙语连珠,但是在尊重他人、认真理解对方意图、准确达成一致方面,优秀软件工程师的表现至少和其他行业的沟通高手相差无几,甚至远远超出后者。(学会沟通,沟通是手段不是目的)
强调设计契约:契约不是让客户“签字画押”。在认知不足的时候“签字画押”只能是一种双输行为。契约的本质是信息明确、以终为始。只有尽可能地强调明确,才可以发现需求的模糊性,提升在早期发现问题的概率。有大量围绕设计契约展开的开发实践。例如,如何把接口表述为清晰的设计契约,如何通过测试前置的方式,促成需求或者设计契约的明确化,并对理解一致性展开早期验证。
做到演进式设计:如果软件设计得足够好,那么完全可以在用户需求发生变化时随机应变。与此相对的是惧怕变化。设计有“刚性”和“柔性”之分。刚性的设计无论考虑得如何周全,也仅能适应预先认知的场景。柔性的设计恰恰相反,它可以灵活地适应环境的变化。好的设计应该是柔性的。做到演进式设计极其重要但是并不容易,它需要坚实的设计基础和卓越的设计实践。
优质的软件应该只有极少的缺陷。缺陷意味着客户满意度降低和修复成本增加,并且远远不限于此。特别是在今天这种竞争剧烈的业务环境中,如果缺陷太多,修复缺陷不仅需要花费金钱,还会耽误宝贵的时间,继而影响业务竞争,这可能造成潜在的商业损失。
关于软件缺陷的第一个事实是:缺陷不可能完全避免。要做到低缺陷率非常不容易,但这并不意味着无法减少缺陷带来的损失。这是关于软件缺陷的第二个事实:缺陷带来的影响和发现缺陷的时机密切相关。要规避缺陷造成的影响,最重要的原则是尽量早地发现缺陷。现实中做法往往是给问题单分级。高优先级问题单必须尽早发现尽早解决。
缺陷不可能完全避免:缺陷不可能完全避免意味着对待缺陷的正确态度:谨慎而专业。常常把“我的代码没有缺陷,不需要测试”挂在嘴边的程序员,非但大概率不是高手,其编写的代码也往往会在后续测试阶段错误频出。真正的软件工程师懂得尊重软件的复杂性,在编程时“如履薄冰”,时刻把产出高质量的代码放在思考的第一优先级。不过,“如履薄冰”和“战战兢兢”完全是两码事。正是因为“如履薄冰”,才会非常审慎地对待自己接收到的需求、自己编写的代码、团队的产出。例如,专业的软件工程师会非常重视自动化测试,而且会做到测试先行。通过使用恰当的工具和思考方法,软件设计质量可以得到大幅提升。专业的软件工程师,可以完美地融合专业能力、信心、勇气和谨慎为一体,产出低缺陷率的代码。
尽量早地发现缺陷:缺陷并不可怕,真正可怕的是没有能力及时发现缺陷。优秀的软件工程师都知道:软件是非常复杂的,人类思维又是有局限的。苛求代码在刚被写出来的一瞬间就一定是对的完全不现实。因此,我们要做的不是要规避缺陷,而是要通过加快反馈,在缺陷造成实质性影响之前,就把它消灭于无形。当我们说缺陷率的时候,更多是在讨论那些没有被及时发现的缺陷,特别是出现在后期,如系统测试、用户接收测试、正式发布这些阶段的缺陷。而对那些刚有出现苗头、就立即被修复的缺陷,如软件工程师在编码过程中发现的刚刚犯下的错误,往往没有什么人会关心。只关注那些延迟发现的缺陷是非常合理的策略,其背后的理论基础是缺陷成本递增原理。下图是McConnell在其经典著作《代码大全》中给出的缺陷成本递增曲线。我们可以发现,无论是哪个阶段注入的缺陷,只要我们能在当前阶段立即发现,缺陷成本都是非常低的。发现阶段越往后移,问题的复现、定位越困难,影响面也越宽,缺陷导致的成本就越高。
虽然不能完全避免缺陷,但是提前发现缺陷可以大幅降低它带来的不良影响。基于这样的事实,我们就可以得到如下的应对策略。.缩短缺陷的发现周期。.降低缺陷的发现成本和修复成本。.缩小缺陷的影响面。缩短缺陷的发现周期保证缺陷能被及时发现的关键点在于缩短问题的反馈周期。测试前置的策略,即“I模型”。测试前置保障了需求以及外部接口描述和理解的清晰,并且通过测试先行,可以及时发现缺陷,在源头上降低发生功能性缺陷的可能性。降低缺陷的发现成本和修复成本发现、调查和修复缺陷往往会消耗较高的成本。如果能降低缺陷在各个环节的成本,缺陷带来的影响也就相应地减小了。全面的自动化测试、更小的迭代是降低发现、调查和修复缺陷成本的有效方法。自动化测试是核心内容,而通过让设计持续演进和持续集成,可以把发现和修复缺陷的周期降到小时,甚至分钟这种级别。缩小缺陷的影响面:在大型商场等公共场所中都设置有防火墙,它可以在一个区域着火的时候紧急阻断火势,避免影响其他区域。软件设计也一样:有没有什么办法能及时阻断缺陷问题的传播?通过把软件划分为更合理的设计单元,定义清楚设计单元之间的依赖、接口和契约,并采取契约式设计等手段,就可以起到防火墙的效果,从而降低缺陷带来的影响。在我们其它文章讨论内存的分布时也常常能够看到内存中存在空白的缓冲区用于隔离错误的内存访问。
代码是一种很特殊的产品。一旦它被写出来,就会被一遍遍地阅读。代码被反复阅读的原因是多样的,有时候是为了修复缺陷,有时候是为了理解其背后的原理或者实现了什么功能,还有时候是为了复用或者是在原来的基础上增加新的功能。研究数据表明,代码在其生命周期中被阅读的时间,是编写代码所用时间的10倍。所以,如果代码编写得不容易理解,那么即使它实现了所需的功能,也很难被称为好的代码。《计算机程序的构造和解释》的作者Harold Abelson有一个著名的观点:计算机程序首先是用来给人读的,只是顺便用于机器执行。
写出易于理解的代码不容易,写出不容易让别人理解的代码却是再容易不过。这是代码的天性使然。代码天生充满各种细节,每一行代码都有它的意义。尽管如此,高质量的软件设计一定会刻意且安全地隐藏细节(分层和抽象),从而提升代码的可理解性。
不良代码充斥着细节和意外:虽然代码充满各种细节,但这绝对不意味着没有理解某一行代码,就不能理解整体代码的具体工作。这样的代码是一种灾难,因为它挑战的是人类的记忆能力和认知能力。好的代码一定会隐藏一切细节,并且是安全地隐藏这些细节,即没有“意外”。我们的理解力依赖于抽象、层次化、刻意地忽略这些认知技巧。如果代码缺少封装,导致内部状态可以被任意修改,就必然会带来意外,影响抽象。我们还会“望文生义”:如果一个类的名字叫作订单,那我们一般不会从里面寻找和用户管理相关的信息,所以如果哪个工程师把用户管理相关的职责混进了订单类中,就给以后维护代码的人留下了陷阱。不要有意外,不要强迫他人为细节阅读代码。这是优质代码结构的力量,也是优秀工程师的素养。换句话说,代码的设计结构应该最大化地降低理解负担、尽量减少阅读代码的必要性。例如,让工程师:.能通过阅读API声明去理解代码,就不要去阅读API是如何实现的;.能通过观察代码结构(如类名、包名、方法名)去理解代码,就不需要去阅读代码的内部实现;.能通过阅读直接理解代码,就不需要去阅读文档和注释。
范式或概念不一致:相信不少读者有过这样的经历:初学面向对象编程时,看到代码中有数百个类会觉得无从下手。同样,刚从面向对象编程转为函数式编程时,也会觉得比较别扭。这是因为不同编程范式之间的话语体系不一样。不仅缺乏语言范式的共识会影响代码的可理解性,在编程规范、实现惯例、架构模式或设计模式,以及技术框架的应用等方面也同样需要共识。业务概念的共识也是一个重要方面。例如,在维护一个订餐系统时,代码维护人可能需要了解菜单这个功能是如何实现的,但他可能找不到代码,因为代码中出现了一个不同的名字:餐品列表。这就是陷阱了。菜单和餐品列表或许并无二致,也不存在哪个概念更为精确的问题,但是如果大家使用的是两种语言,彼此语言不通,那么代码的可理解性就会受到影响
面向对象编程在今天已是主流。但是,在面向对象刚刚兴起的时候,开发者社区中有些人认为面向对象并不容易理解,因为“类实在是太多了”。多态也让代码变得更复杂,如果不运行,都不知道代码走到哪里了。在这些人看来,反倒是面向过程的代码更容易理解,因为不论函数有多长,只要耐心阅读就能了解一切细节。产生这种感觉的原因就是他们试图用一种编程范式来解读另一种编程范式。面条代码(spaghetti code)[插图]描述的是一种不良的代码设计风格,常常出现在缺乏封装的过程式设计中。不良的设计中总会出现一些很长的函数,它们如同彼此“缠绕”的面条一样,所以称为面条代码。面条代码把所有的业务逻辑都放在一起,尽管复杂,但是只要你有足够的耐心,一行一行地读下去,总能理解代码实现了什么,这是它的优势。而如果你习惯了面条代码,自然会养成一行一行阅读代码的习惯,带着这种思维模式去阅读面向对象的代码,往往会觉得找不到线索。馄饨代码(ravioli code)是面条代码的反面,它认为结构是编程世界中的主导因素。在面向对象程序中,程序的本质是对象和对象的协作。一般来说,好的对象式设计包含许多小而独立的类,每个类分别实现一个比较有限的功能,通过组合这些功能有限的类,就可以实现丰富多彩的功能。面向对象代码强调的是“概念”,是结构和协作。如果把关注点从关心“如何实现”的流程,转移到关心“做什么”的对象结构和对象协作上,那么理解面向对象的代码时就会更加快捷。打个比方,你打开目录,发现对“由外而内的设计”这一章特别感兴趣,就直接定位到这一章开始阅读,这便是面向对象的理解方法。如果你不关心目录,而是从第一页开始逐行阅读,逐行找到对代码质量的介绍,就更类似于面向过程的理解方法。理解面向对象代码的关键是由这个目录,而现实开发中存在的问题是往往没有这个目录,或者说目录和代码的演进脱钩。还有一种情况是这个目录存在于开发者自己的脑中,而其它后续负责维护代码的同事则没有这个目录。这也是造成阅读面向对象代码困难的一个重要原因。
降低代码的复杂性,是提升代码可理解性的关键。正如著名的计算机科学家Tony Hoare所说:“我相信设计软件的方式有两种:一种是使软件足够简单而明显没有缺陷;另一种是使它足够复杂,以至于没有明显的(可被轻易发现的)缺陷。”Hoare表面上说的是缺陷,其本质就是代码的可理解性。降低复杂性和许多编程实践密切相关,如更好的命名、一致的业务概念、更好的设计结构、尽量减少不必要的设计元素、减少重复、增加设计契约和测试的描述能力等。有一些优秀实践可以借鉴,例如,在极限编程中有一个结对编程的实践。在结对编程实践中,由于是两个人一起编程,所以他们需要随时考虑对方能否理解当前的代码。这会不断增强“代码将要被其他人阅读”的意识,迫使自己选择更合适的命名、简化设计结构、增加必要的注释等,从而有效提升代码的可理解性。