🐳 📖

《代码大全》阅读笔记之五:代码改善

📆 2021-05-21

来源:《代码大全》

🏷 编程阅读

🖍 软件质量和质量保证的方法。

🏂 正文 👇

虽然整本书都是关于提高软件质量的,但是这一部分关注的是质量和保证质量本身。首先是软件质量的概述,然后叙述了协同构建,测试和调试的一些实用建议。

软件质量概述

软件同时拥有外在的和内在的质量特性。外在的特性指的是用户所能感受到的部分:正确性;可用性;效率;可靠性;完整性;适应性;精确性;健壮性;质量的外在特性是用户关心的唯一软件特性。用户只会关心软件是否容易使用,而不会关心程序修改起来是否容易。而程序员除了关心软件质量的外在特性之外,还要关心它的内在特性:可维护性;灵活性;可移植性;可重用性;可读性;可测试性;可理解性。内在和外在特性并不能完全割裂开来,因为在某些层次上,内在特性会影响某些外在特性。我们需要弄清楚,哪一种特性是什么,什么时候这些特性之间会发生什么样的相互作用。要让所有特性都能表现得尽善尽美是绝无可能的。需要根据一组互相竞争的目标寻找出一套优化的解决方案,正是这种情况使软件开发成为一个真正的工程科学。软件质量保证是一个需要预先计划的,系统性的活动,其目标就是为了确保系统具备人们所期望的特性。就软件质量保证而言,除了专注于产品本身,还需要关心软件开发的过程。软件质量中的一些要素包括:

  • 软件质量目标,明确定义出软件质量的目标。如果没有一个明确的目标,那么程序员去极力增强的特性就可能同你所强调的特性有别;
  • 明确定义质量保证工作,在保证质量的工作中,一个最常见的问题是质量被认为是次要目标。在某些组织当中,快速而糟糕(quick and dirty)的编程已经成了普遍现象,而非另类。甚至胡乱堆砌劣质代码并能快速“完成”程序的程序员要比编写完善程序,并在程序发布之前确保程序能正常工作的程序员可能获得更高的报酬。如果你发现这些组织里面的程序员没有把质量作为他们工作的头等大事,没什么好奇怪的。组织本身必须向程序员们说明,质量应当放在第一位。
  • 测试策略;
  • 软件工程指南;
  • 非正式技术复查;
  • 正式技术复查;
  • 外部审查;

其他一些并非显而易见的软件质量保证活动,也一样会对软件质量产生影响:

  • 对变更进行控制的过程;
  • 结果的量化;
  • 制作原型;

明确设置质量目标是开发高质量软件的一个简单而清晰的步骤,但它常常被忽视。一旦程序员知道目标是什么,并且这些目标合理的话,他们将会这么做。程序员有很高的的成就激励:他们会向明确的目标进发,但必须有人告诉他们目标是什么。各种质量保证方法的效能并不相同。这里作者列举出了很多检测缺陷的方法,单独使用任何一个方法,其典型缺陷检出率都没有超过 75%,并且平均来说这一数值在 40%左右。最常用的缺陷方法———单元测试以及集成测试,它们的一般检测率仅仅在 30%到 35%之间。这些数据强烈提醒我们,如果项目的开发者要向更高的缺陷检测率发起冲击,他们需要综合运用各种技术。类似于极限编程这样的具备更规范缺陷检测技术的开发方法,能够让程序员体验到比过去更高的缺陷排除水平。某些缺陷检测方法的成本比其他方法要高,大部分研究都发现,检查比测试的成本更小。能够尽早发现错误的检测方法可以降低修正缺陷的成本。一个有效的软件质量项目的底线,必须包括在开发的所有阶段量化和使用多种技术。一套推荐的阵容如下:

  • 对所有的需求,架构以及系统关键部分的设计进行正式检查;
  • 建模或者创建原型;
  • 代码阅读或者检查;
  • 执行测试;

错误越早引入到软件当中,问题就会越复杂,修正这个错误的代价也更高,因为错误会牵涉到系统的更多部分。需求中的一个缺陷会孕育出设计上的一个或多个缺陷,而这些设计错误又会繁殖出更多的代码缺陷。尽早捕获错误才能有效地节省成本。缺陷可能在任何阶段渗透到软件中。因此,你需要在早期阶段就开始强调质量保证工作,并且将其贯彻到项目的余下部分中。软件质量普遍原理就是改善质量以降低开发成本。理解这一原理依赖于理解一个很容易观察到的关键事实:提高生产效率和改善质量的最佳途径就是减少花在代码返工上的时间,无论返工的代码是由需求,设计改变还是调试引起的。绝大多数项目的最大规模的一种活动就是调试以及修正那些无法正常工作的代码。调试和与此相关的重构或者其他返工工作,在传统的不成熟的软件开发周期当中可能消耗大约 50%的时间。只要避免引入错误,就可以减少调试时间,从而提高生产力。因此,效果最明显的缩短开发周期的方法就是改善产品的质量,由此减少花费在调试和软件返工上面的时间总量。有相关领域的数据可以证明这一分析结论。更多的质量保证工作能降低错误率,但不会增加开发的总成本。与传统的“编码——测试——调试”相比,先进的软件质量计划可能更省钱。这种计划把投入到调试和重构的资源重新分配到前期的质量保证工作中,而前期工作在产品质量体现的作用会比后期工作更为明显。因此把时间投入到前期工作中,能让程序员在后期工作中节省更多时间。这一方法的最终效果是软件的缺陷更少,开发时间更短,成本也更低。

协同构建

所有的协同构建技术都试图通过这样或那样的途径,将展示你工作的过程正式化,以便把错误暴露出来。协同构建包括结对编程,正式检查,非正式技术复查,文档阅读,以及其他让开发人员共同承担创建代码及其他工作产品责任的技术。协同构建的首要目的就是改善软件的质量。它的另一个好处是可以缩短开发周期,从而降低开发成本。全程采用结对编程的成本可能比单人开发要高大约 10% ~ 25%,但开发周期大概会缩短 45%。减少软件中的缺陷数量的同时,开发周期也能得到缩短。协同开发不但在捕获错误方面比测试的效能更高,所能发现的错误类型也不同于测试。“由人进行的复查能够发现不明显的错误信息,不恰当的注释,硬编码的变量值,以及重复出现的需要进行统一的代码模式,这些是测试发现不了的。(Karl Wiegers)”协同开发的另一个作用是让人们意识到他们的工作会被复查,这样他们会小心谨慎地检查自己的工作。复查是一个很重要的机制,它可以让程序员得到关于他们自己代码的反馈。程序员除了需要得到他们是否很好地遵循了标准的反馈之外,还需要得到程序设计主观方面的回馈,例如格式,注释,变量名,局部变量和全局变量的使用,设计方法以及“我们这里采用的解决方法”。复查也是培养新人以提高其代码质量的好机会。

结对编程。在进行结对编程的时候,一位程序员敲代码,另外一位注意有没有出现错误,并考虑某些策略性的问题。虽然结对编程的基本概念很简单,但是要从中获得收益,就需要遵守下面几条准则:

  • 用编码规范来支持结对编程;应该对风格格式化,以便程序员将精力集中到“本质”任务上
  • 不要让结对编程变成旁观;
  • 不要强迫在简单的问题上使用结对编程;
  • 有规律地对结对人员和分配的工作任务进行轮换;
  • 鼓励双方跟上对方的步伐;
  • 确认两个人都能够看到显示器;
  • 不要强迫程序员与自己关系紧张的人组对;
  • 避免新手组合;
  • 指定一个组长;来协调工作的分配,对结果负责以及负责与项目外其他人的联系

结对编程有很多好处:能够使人们在压力之下保持更好的的状态;鼓励双方保持代码的高质量;缩短进度时间表;传播公司文化;指导初级程序员;培养集体归属感。

正式检查。详查(正式检查)是一种特殊的复查,种种迹象表明它在侦测缺陷方面特别有效,并且相对测试来说更加经济合理。独立的详查通常能够捕捉到 60%的缺陷,这比除了原型和大规模 beta 测试之外的其他技术都要好。对设计和代码都进行详查的项目,详查会占到项目预算的 10%到 15%,并且通常会降低项目的整体成本。详查的一个关键特征就是每个人都要扮演某一个明确的角色:主持人,作者,评论员,记录员,经理。详查由几个明显的阶段组成:计划,概述,准备,详查会议(不要在开会的过程中讨论解决方案,小组应该把注意力保持在识别缺陷上。通常会议不要超过两个小时),详查报告,返工,跟进,第三个小时的会议(讨论解决方案)。进行详查的目的是发现设计或者代码中的缺陷,而不是探索替代方案,或者争论谁对谁错,其目的绝不应该是批评作者的设计或者代码。这一过程中的团队参与使程序得到了明显改善,对所有参与者都是一个学习的过程。

其他类型的协同开发实践。简单介绍了其他一些还没有积累足够的实践经验作为支持的协作方法:

  • 走查;
  • 代码阅读;代码阅读比测试能更多的发现缺陷。代码阅读与详查,走查之间的区别,就在于代码阅读更多地关注对代码进行的独立复查,而不是关注会议本身。其结果是每一个评论员的时间更多地花费在从代码中找出错误上面,而将较少的时间花费在会议上。
  • 公开演示;

有数十年的数据对正式检查的有效性提供事实证据支持,结对编程则没有。但初始的数据表明它的效果与详查不相上下。如果能产生类似的效果,那么在这两者之间进行选择与其说是技术性问题,还不如说是个人风格问题。有些人更喜欢单独工作,仅仅需要偶尔为了详查会议从个人模式切换出来,而其他人更喜欢花大部分时间直接与其他人共同工作。可以依据团队中特定开发人员的工作风格,在这两种技术之间进行选择,甚至可以允许团队中的小团体选择以他们所喜欢的方式来完成大部分工作。

开发者测试

测试是最常见的改善质量的活动。软件可以通过许多的方法进行测试,某些测试通常由开发人员进行,而有些则更多由专门的测试人员进行。这里聚焦于由开发者进行的测试,这通常包括单元测试,组件测试和集成测试,但有的时候还会包括回归测试和系统测试。许多进一步的测试由专门的测试人员进行,很少由开发人员来完成,包括 beta 测试,客户验收测试,性能测试,配置测试,平台测试,压力测试以及易用性测试。测试通常分为两大类:黑盒测试(black-box testing)和白盒(white-box,或者玻璃盒 glass-box)测试。黑盒测试指的是测试者无法了解测试对象内部工作机制的测试。白盒测试指的是测试者清楚待测对象内部工作机制的测试。测试和调试不同,测试是一种检查错误的方法,而调试意味着错误已经被发现,要做的是诊断错误并消灭造成这些错误的根本原因。对于任何软件质量规划来说,测试都是一个重要的组成部分,并且在许多情况下它是唯一的组成部分。这是非常不幸的,因为各种形式的协同开发实践都表现出比测试更高的错误检测率,而且发现一条错误的成本不到测试的二分之一。每个独立的测试步骤(单元测试,组件测试以及集成测试)通常只能够找到现有错误的 50%不到,联合多个测试步骤通常只能够找到少于 60%的现有错误。一个关键的问题是,在一个典型的项目里面, 开发者测试应该占多长时间?根据项目大小和复杂程度的不同,开发者测试应该占整个项目时间的 8% ~ 25%。采用系统化的开发者测试方法,能最大限度提高你发现各种错误的能力,同时让你的花费也最少。请确保你可以做到下面所有要点:

  • 对每一项相关的需求进行测试,以确保需求都已经被实现;
  • 对每一个相关的设计关注点进行测试,以确保设计已经被实现;
  • 用基础测试来扩充针对需求和设计的详细测试用例;
  • 使用一个检查表,其中记录着你在本项目迄今为止所犯的,以及在过去的项目中所犯的错误类型;

测试先行是一个比较好的策略,首先写测试用例可以将从引入缺陷到发现并排除缺陷之间的时间缩减到最短。由于进行完全测试实际上是不可能的,因此测试的窍门就在于选择那些最有可能找到错误的测试用例。有一些方法可以用来有效地覆盖程序的基本情况:

  • 结构化的基础测试;其思想是,你需要去测试程序中的每一条语句至少一次。“代码覆盖”测试或者“逻辑覆盖”测试,这是测试穿过程序里的所有路径的两种方法。
  • 数据流测试;
  • 等价类划分;如果两个用例能揭示的错误完全相同,那么只要一个就够了。“等价类划分”的概念是这一想法的严格表达形式,应用它有助于减少所需用例的数量。
  • 猜测错误;基于直觉或者过去的经验,如果你保留了一份过去所犯错误种类的列表,那么你就能提高“猜测错误”的命中率
  • 边界值分析;
  • 采用容易手工检查的测试用例;

需要注意测试用例本身也可能存在错误。一些测试的支持工具:

  • 脚手架工具;
  • Diff 工具;
  • 测试数据生成器;
  • 覆盖率监视器;
  • 数据记录器/日志记录器;
  • 符号调试器;
  • 系统干扰器;
  • 错误数据库;

有效测试的关键之一,就是在待测试项目开始之初就拟定测试计划。就重要性而言,测试应当与设计和编码平起平坐,这就要求项目为测试分配时间,重视测试并保障这一过程的质量。

调试

调试是确定错误根本原因并纠正此错误的过程。在一些项目中,调试可能占到整个开发周期的 50%。对很多程序员来说,调试是程序设计中最为困难的部分。调试原本不应成为最难解决的问题。如果严格遵照本书所提供的建议,你几乎没有什么错误需要调试。同测试一样,调试本身并不是改进代码质量的方法,而是诊断代码缺陷的一种方法。软件的质量必须从开始逐步建立:开发高质量软件产品的最佳途径是精确描述需求,完善设计,并使用高质量的代码编写规范。调试只是迫不得已时采用的手段。针对同样一组缺陷,经验丰富的程序员找出缺陷所用的时间大约只是缺乏经验的程序员们的 1/20,并且这些程序员能够找出更多的缺陷,并能更为准确地对这些缺陷进行修改。我们应该正视自己造成代码缺陷的错误,你可以从错误中得到以下好处:

  • 理解你正在编写的程序;
  • 明确你犯了哪种类型的错误;
  • 从代码阅读者的角度分析代码质量;
  • 审视自己解决问题的方法;
  • 审视自己修正缺陷的方法;

调试其实是一片极其富饶的土地,它孕育着你进步的种子。这片土地也是所有软件构建之路所交织的地方:可读性,设计,软件质量,凡是你能想到的无所不包。编写优秀代码所得到的回报,如果你能精于此道,你甚至无须频繁调试。效率低下的调试方法:凭猜测找出缺陷,把 print 语句随机地散布在程序中;不在理解问题上花费时间;用拙劣的方法掩盖问题的表象;怨天尤人。调试包括了寻找缺陷和修正缺陷。寻找缺陷———并且理解缺陷———通常占到了整个调试工作的 90%。高效的程序员会使用科学的调试方法,会经历如下步骤:

  1. 通过可重复的试验收集数据;
  2. 根据相关数据的统计构造一个假说;
  3. 设计一个实验来证明或反证这个假说;
  4. 证明或反正假说;
  5. 根据需要重复进行上面的步骤;

调试过程中最让人头疼的部分是寻找缺陷。修正缺陷则是较为简单的部分。但正是因为它太过简单才让人们经常对它掉以轻心。至少已经有一项调查发现程序员在第一次对缺陷进行修正的时候,有超过 50%的几率会出错。下面给出一些如何减少出错几率的建议:

  • 在动手之前先要理解问题;
  • 理解程序本身,而不仅仅是问题;
  • 验证对错误的分析;
  • 放松一下;匆忙动手解决问题是你所能做的最低效的事情之一。
  • 保存最初的源代码;
  • 治本,而不是治标;
  • 修改代码时一定要有恰当的理由;
  • 一次只做一个改动;
  • 检查自己的改动;
  • 增加能暴露问题的单元测试;
  • 搜索类似的缺陷;

一些特定的心理因素,可能会导致你调试时的盲目。例如变量名称的不当的“心理距离”。此外还有一些可用的调试工具:源代码比较工具;编译器的警告消息;增强的语法检查和逻辑检查;执行性能剖测器;测试框架/脚手架;调试器。

重构

现实中的代码总是会处于变动之中,因为需求往往会发生改变。现在的开发方法增强了代码在构造阶段中改变的潜力。如今的开发方法更多地以代码为中心。在整个项目生命周期中代码都会不断地演化。区分软件演化类型的关键,就是程序的质量在这一过程中是提高了还是降低了。如果你能在开发过程中认识到软件演化是无法避免且具有重要意义的现象,并对其细加谋划,就可能使这一过程有益于你的开发。在刚开始编写程序时,你绝对不会对程序有深入的理解。一旦有机会重新审视你的程序,就要用自己的全部所学去改进它。软件演化的基本准则就是,演化应当提升程序的内在质量。要实现软件演化的基本准则,最关键的策略就是重构。Martin Fowler 将其定义为“在不改变软件外部行为的前提下,对其内部结构进行改变,使之更容易理解并便于修改”。存在一些代码需要被重构的信号(代码中的臭味 smells):

  • 代码重复;“复制粘贴即设计之谬”
  • 冗长的子程序;
  • 循环过长或嵌套过深;
  • 内聚性太差的类;
  • 类的接口未能提供层次一致的抽象;
  • 拥有太多参数的参数列表;
  • 类的内部修改往往被局限在某个部分;
  • 变化导致对多个类的相同修改;
  • 对继承体系的同样修改;
  • case 语句需要做相同的修改;
  • 同时使用的相关数据并未以类的方式进行组织;
  • 成员函数使用其他类的特征比使用自身类的特征还要多;
  • 过多使用基本数据类型;
  • 某个类无所事事;
  • 一系列传递流浪数据的子程序;
  • 中间人对象无事可做;
  • 某个类同其他类关系过于亲密;
  • 子程序命名不恰当;
  • 数据成员被设置为公用;
  • 某个派生类只使用了基类的很少一部分成员函数;
  • 注释被用于解释难懂的代码;
  • 使用了全局变量;
  • 在子程序调用前使用了设置代码,或在调用后使用了收尾代码;
  • 程序中的一些代码似乎是在将来的某个时候才会用到的;对未来需求有所准备的办法并不是去编写空中楼阁式的代码,而是尽可能将满足当前需求的代码清晰直白地表现出来,使未来的程序员理解这些代码到底完成了什么功能,没有完成什么功能,从而根据他们的需求进行修改。

对于特定的重构,作者主要总结和罗列了《Refactoring》这本代码重构的扛鼎之作中自己认为最有用的重构方法。包括在数据级的重构,语句级的重构,子程序级的重构,类实现的重构,类接口的重构,系统级重构等。 重构是一种改善代码质量的强有力的技术。但正如所有功能强大的工具一样,如果使用不当,重构给你带来的麻烦会比它所带来的好处还要多。下面是一些简短的建议能够让你避免错误地使用重构:

  • 保存初始代码;
  • 重构的步伐请小一些;
  • 同一时间只做一项重构;
  • 把要做的事情一条条列出来;
  • 设置一个停车场;
  • 多使用检查点;
  • 利用编译器警告信息;
  • 重新测试;
  • 增加测试用例;
  • 检查对代码的修改;
  • 根据重构风险级别来调整重构方法

真实世界混乱不堪并不等于你的代码也得同样糟糕。将你的系统看做理想代码,混乱的真实世界,以及从前者到后者的接口的结合。改善产品代码的策略之一就是在拿到拙劣的遗产代码时对其重构,由此使其告别混乱不堪的真实世界。

代码调整策略

程序性能调整问题一直以来都是一个富有争议的话题。过分专注于性能会损害程序的可读性和可维护性。一旦你选择把效率作为头等大事,无论重点是在处理速度还是在处理代码所占用的资源上,你都应该考虑一下其他可能选择,而且应当是在代码一级选择提高速度还是减少资源占用之前去做。请从以下方面来思考效率问题:

  • 程序需求;在花费时间处理一个性能问题之前,请想清楚你的确是在解决一个确实需要解决的问题
  • 程序的设计;
  • 类和子程序的设计;
  • 程序同操作系统的交互;
  • 代码编译;
  • 硬件;
  • 代码调整;

代码调整的问题在于,高效的代码并不一定就是“更好”的代码。除非你对需要完成的工作一清二楚,否则绝不要对程序做优化。如果有了优化能力很强的编译器,你的代码速度通常可以提高 40%甚至翻上一倍。一些常见的低效率之源:

  • 输入/输出操作;
  • 分页;引发操作系统交换内存页面的运算会比在内存同一页进行的运算慢许多
  • 系统调用;
  • 解释型语言;解释型语言似乎应当为系统性能所受到的损害做出解释。😆
  • 错误;

由于程序中某些小部分常常会耗费同自身体积不成比例的运算时间,所以你应当测量代码性能,找出代码中的热点。一旦你发现了这样的区域并对该处的代码进行了优化,就再一次进行测量,看看你到底有了多少改进。经验对性能优化没有太大的帮助。除非对效果进行测量评估,否则你永远也无法确定某次优化所带来的影响。如果还对代码调整能够有助于提高某个程序的性能心存疑虑。按照以下的步骤去做吧:

  1. 用设计良好的代码来开发软件,从而使程序易于理解和修改;
  2. 如果程序性能很差。
    1. 保存代码的可运行版本,这样你才能回到“最近的已知正常状态”;
    2. 对系统进行分析测量,找出热点;
    3. 判断性能拙劣是否源于设计,数据类型或算法上的缺陷,确定是否应该做代码调整,如果不是,请跳回到第一步;
    4. 对步骤 2.3 中所确定的瓶颈代码进行调整;
    5. 每次调整后都对性能提升进行测量;
    6. 如果调整没有改进代码的性能,就恢复到步骤 2.1 保存的代码
  3. 重复步骤 2

一些具体的代码调整技术。这样的调整虽然与“重构”有些类似,但是将其称之为“反重构”而非“改善内部结构”或许更恰如其分。这种改变是以牺牲程序内部结构的某些特性来换取更高的性能。就定义而言,这种说法并无不妥。即使所做改变没有损害程序的内部结构,我们也不应认为这是一种优化。我们就是这样使用,而且把这种改变当做是标准编程实践的一部分。具体的调整策略包括:

  • 逻辑操作方面
    1. 知道答案后就应该停止判断;例如“短路求值”和搜索循环
    2. 按照出现频率来调整判断顺序;
    3. case 语句和 if-then-else 语句特定条件下可能存在不同的执行效率;
    4. 用查询表替代复杂表达式;
    5. 使用惰性求值;
  • 循环
    1. 将判断外提;
    2. 合并循环;
    3. 展开循环语法;
    4. 尽可能减少在循环内部做的工作;
    5. 设置哨兵值;查找循环中,通过在数据结构的末尾放置一个哨兵值,来减少循环体中需要的判断次数
    6. 把最忙的循环放在最内层;
    7. 削减强度;例如用加法替换乘法
  • 数据变换
    1. 使用整型数而不是浮点数;
    2. 数组维度尽可能少;
    3. 尽可能减少数组引用;
    4. 使用辅助索引;
    5. 使用缓存机制;
  • 表达式
    1. 利用代数恒等式;
    2. 削弱运算强度;
    3. 编译器初始化;
    4. 小心系统函数;系统函数运行起来很慢,提供的精度常常也是根本不需要的
    5. 使用正确的常量类型;
    6. 预先算出结果;
    7. 删除公共子表达式;
  • 子程序
    1. 将子程序重写为内联;
    2. 用低级语言重写代码;

代码调整无可避免地为性能改善的良好愿望而付出复杂性,可读性,简单性,可维护性方面的代价。由于每一次调整后需要对性能进行重新评估,代码调整还引入了巨额的管理维护开销。恪守“对每一次的改进进行量化”的准则,是抵御思考成熟前匆忙优化的诱惑的法宝。未经测量的代码优化对性能上的改善充其量是一次投机,然而,其对可读性等产生的负面影响则确凿无疑。优化结果在不同的语言,编译器和环境下有很大差异。如果没有对每一次的优化进行测量,你将无法判断优化到底是帮助还是损害了这个程序。第一次优化通常不会是最好的。即使找到了效果很不错的,也不要停下扩大战果的步伐。

👐 The End 🎉

🏠下一篇《代码大全》阅读笔记之四:语句 

👇 💬