《代码大全》CODECOMPLETE2
apollowangjunbo/Code-Complete-reading-note: 《代码大全》读书笔记 (github.com)
第一章 欢迎进入软件构建的世界
软件开发过程中各种不同的活动:
- 定义问题(problem definition)
- 需求分析(requirements development)
- 规划构建(construction plannning)
- 软件架构(software architecture)或 高层设计(high-level design)
- 详细设计(detailed design)
- 编码调试(coding and debugging)
- 单元测试(unit testing)
- 集成测试(integration testing)
- 集成(integration)
- 系统测试(system testing)
- 保障维护(corrective maintenance)
-
软件构建是软件开发的核心活动, 构建活动是每个项目中唯一一项必不可少的工作。
-
软件构建的主要活动包括: 详细设计、编码、调试、集成、开发者测试(developer testing)(包括单元测试和集成测试)。
-
构建也常被称为“编码”或“编程”。
-
构建活动的质量对软件的质量有着实质性的影响。
第二章 用隐喻来更充分地理解软件开发
-
隐喻的重要性:通过把你不太理解的事物和一些你较为理解、且十分类似的事物做比较,你可以对这些不太理解的东西产生更加深刻的理解。这种使用隐喻的方法叫做“建模(modeling)”
-
写作的隐喻对于个人规模的软件开发工作乃至小型项目是足够了,然而对于较大的软件开发过程来说太过简单。写作的隐喻暗示着软件开发过程是一种代价昂贵的试错。
-
培植系统的隐喻:优点在于增量思维,缺点在于培植的过程无法人为干预,而软件开发是可以的。
-
通过把软件的构建过程比作是房屋的建设过程,我们可以发现,仔细地准备是必要的,而大型项目和小型项目之间也是有差异的。 软件架构->建筑学(不同意,个人感觉建筑结构更贴切) 支撑性测试代码->脚手架 构件->建设
-
建造一个房子的时候,你不会去试着建造那些能买得到的现成的东西。当开发软件时,你也会这么做的。你会大量使用高级语言所提供的功能,而不会自己去编写操作系统层次的代码。你可能还要用些现成的程序库,比如说一些容器类(container classes)、科学计算函数、用户界面组件、数据库访问组件,等等。总之,自己编写那些能买得到的现成的代码通常是没有意义的。
-
建筑业中,盖间仓库或者工具房,或是一座医院或者核反应站,你在规划、设计及质量保证方面所需达到的程度是不一样的。盖一座学校、一幢摩天大楼,或一座三居室的小别墅,所用的方法也不会相同。同理,在软件开发中,通常你只需要用灵活的、轻量级的(lightweight)方法,但有时你就必须得用严格的、重量级的开发方法,以达到所需的安全性目标或其他什么目标。
-
软件的变动在建筑领域也有类似事物。把一堵承重墙移动半尺所需花费的成本,肯定要比仅仅移动一面隔墙更高。同样,对软件进行结构性的修改所需花费的成本,肯定也比仅仅增删一些周边功能更高。
-
通过把软件开发中的实践比作是智慧工具箱中的工具,我们又发现,因地制宜地选择正确工具是成为有效编程的程序员的关键。
第三章 三思而后行:前期准备
3.1 前期准备的重要性
- 就像修建建筑物一样,项目的成败很大程度上在构建活动开始之前就已经注定了。如果地基没打好,或者计划不充分,那么你在构建期间能做得无非是尽量让损害最小罢了。
- 软件构建活动差不多占整个项目成本的65%。最糟糕的软件项目最终会进行两三次(甚至更多)构建。而良好的前期准备可以很大程度降低这种风险。其实它的中心目标就是降低风险。首先确保你在做正确的事情,后续正确地做事(构建)才有意义和产生正向价值。
- 与“先做一个错误的东西出来,然后扔掉并从头来过”的成本相比,花费比理想情况下更多的力气,找出他们真正想要的东西这种方式成本更加低廉,更值得投入。
3.2 辨明你所从事的软件的类型
- 不同种类的软件项目,需要在“准备工作”和“构建活动”之间做出不同的平衡。
- 迭代方法往往能够减少“前期准备不足”造成的负面影响,但是它不能完全消除此影响。
3.3 问题定义的先决条件
- 问题定义->需求->架构->构建->系统测试->将来的改进
- “未能定义问题”的处罚是,你浪费了大量时间去解决错误的问题,这是床冲出发,因为你也没有解决正确的问题。
3.4 需求的先决条件
- 明确的需求有助于确保是用户驾驭系统的功能,而不是程序员,明确的需求避免程序员去猜用户想要什么。
- 明确的需求还有助于避免争论。
- 重视需求有助于减少开始编程开发之后的系统变更情况。
- 需求像水,如果冻结了就容易在上面开展建设,一旦需求稳定,项目就能以有序的,可预测的,平稳的方式,完成从架构到编码达到测试等一系列工作。
- 需求变更的主要来源:开发过程帮助客户更好的理解自己的需求。
3.5 架构的先决条件
- 软件架构是软件设计的高层部分,用于支撑更细节的设计框架。架构也称为“系统架构”、“高层设计”或“顶层设计”。
- 架构的质量决定了系统的“概念完整性”,决定了系统的最终质量。
- 一个经过慎重考虑的架构为“从顶层到底层围护系统的概念完整性”提供了必备的结构和体系,他将工作氛围几个部分,使多个开发者或者这多个开发团队可以独立工作。
- 维护设计的缘由至少与维护设计本身一样重要。
- 架构应该定义程序的主要构造块,根据程序规模不同,各个构造块可能是但各类,也可能是有许多类组成的一个子系统。应该明确定义各个造块的责任,每个构造块应该负责某一个区域的事情,并且对其他构造块负责的区域知道的越少越好。应该明确定义每个构造块的通信规则,对于每个构造块,架构应该描述他能直接调用哪些构造块,能间接使用哪些构造块,不能使用哪些构造块。
- 架构应该包含或者考虑:主要的类、数据设计、业务规则、用户界面设计、资源管理、安全性、性能、可伸缩性、互用性、国际化、输入输出、错误处理、容错性、可行性、过度工程、那些使用现有轮子,哪些自己实现、关于该复用的决策、变更策略。
3.6 花费在前期准备上的时间长度
- 花费在问题定义、需求分析、架构上的时间,依据项目的需要而变化。一般说来,一个运作良好的项目会在需求、架构以及其他前期时间方面突入10%20%的工作量和20%30%的时间。这些数字不包括详细设计的时间——那是构建活动的一部分。
第四章 关键的构建决策
4.1选择编程语言
-
一套好的的符号系统能把大脑从所有非必要的工作中解脱出来、集中精力去对付更高级的问题,从功效上看,能够有效地提升人类的智慧。
-
当程序员用“使用了三年以上的语言”编写代码是,生产效率比“同等经验但使用新语言”的程序员高30%
-
对编程语言有相当丰富经验的程序员的生产率比几乎没有经验的程序员高3倍
-
C语言具有机器无关性,被称为“可移植的汇编语言”
-
C++在兼容C语言的基础上还提供了类、多态、异常处理、模板和更健壮的类型检查功能、以及一套内容广泛的标准库
启发:std::swap std::sort std::find 等封装好的标准方法将开发者从常用基础算法中解放出来,去实现更加复杂有意义的程序逻辑,同样智能指针帮助开发者管理生命周期,节约更多的精力去对付更高级的问题
4.2 编程约定
-
编码约定的细节要达到这样的精确度:在编写完软件之后,几乎不可能改变(翻新)软件所遵循的编码约定。
-
成功编程的一个关键就在于避免随意地变化,这样你的大脑可以专注于那 些真正需要的变化。
启发:编码时应该理解并维护已有架构的约定,保持统一的编程的风格
4.3 你在技术浪潮中的位置
- 浪潮早期的编程工具往往很原始。
- 在浪潮的后期,我们有大量的编程语言可供选择,拥有能对这些语言的代码进行完善的错误检查的工具、强大的调试工具以及自动的可靠的性能优化工具。
- 如果处在浪潮的后期,你就可以计划用大部分时间稳定持续地编写新功能。如果你处在浪潮的前期,可以预期你将要花很大一部分时间,用来找出文档中未加说明的编程语言特性、调试程序库代码缺陷带来的错误、修订代码以适应厂商提供的新版本函数库等。
- “在一种语言上编程”的程序员将他们的思想限制于“语言直接支持的那些构件”。如果语言工具是初级的,那么程序员的思想也是初级的。
- “深入一种语言去编程”的程序员首先决定他要表达的思想是什么,然后决定如何使用特定语言提供的工具来表达这些思想。
4.4 选择主要的构建实践方法
- “构建的实践方法”的种类比任何单个项目能用到的要多。有意识地选择最适合你的项目的实践方法。
第五章 软件构建中的设计
5.1设计中的挑战
- 设计是一个“险恶(wicked)的问题”,即只有通过解决或部分解决才能被明确的问题
- 设计是个了无章法的过程:需要不断试错,设计很难界定完成的节点
- 设计能得出清爽的成果
- 设计就是确定取舍和调整顺序的过程
在设计期的进行快速试错能够避免在编码期花费时间在不合理的方案上,从而从整体上节约时间。
在以往的工作中,总是把做出完美的设计作为设计期的目标,但是每次的设计结果都不是很理想。
通过这一节的阅读,设计确实很难做到完美,它非常依赖经验,过程中需要试错、讨论、验证、评估、取舍、迭代改进,很难回答什么时候算是设计完成,具有不确定性,但是好的设计能够得出清爽的成果。
5.2关键的设计概念
软件的首要技术使命:管理复杂度
- 软件的首要技术使命就是管理复杂度。以简单性作为努力目标的设计方案对此最有帮助。
- 复杂度分为偶然复杂度和本质复杂度,本质复杂度不可减少,源自于问题本身,偶然复杂度源自于事务附属的、非必要的或偶然出现的性质。
- 管理复杂度:把任何人在同一时间需要处理的本质(essential)复杂度的量减到最少,不要让偶然性(accidental)的复杂度无谓地快速增长。
- 设计技术的目标都是把复杂问题分解成简单的部分,精心设计的对象关系使关注点相互分离。
本质复杂度源自于问题本身的复杂度,不可以减少,好的设计会将复杂的问题分解,并保证需要程序员同一时间关注的本质复杂度最少,降低理解成本,确保有限注意力能够用于处理想要处理的问题。
好的设计会应该限制偶然复杂度的增长,当没有人知道修改一处代码会对其他代码产生什么样的影响的时候,维护就会变得异常艰难。
理想的设计特征
- 理想的设计特征:最小复杂度、易于维护、松散耦合、可扩展性、可复用性、高扇入、底扇出、可移植性、精简性、层次性、标准技术
避免做出“聪明”的设计方案,这类方案往往难以理解,难以维护,应该做成简单且易于理解的。
好的设计应该尽可能多的使用标准的技术,让尽可能多的人更容易理解,降低学习成本和理解维护成本,也能够降低偶然复杂度。
设计的层次
- 设计的层次:软件系统、分解为子系统和包、分解包中的类、分解为类中的数据和子程序、子程序内部
- 子系统层次的通信设计应该是无环图。
在子系统层次要限制系统之间的通信和依赖,从而降低系统之间的耦合,降低维护时的理解成本,测试成本。降低偶然复杂度。
5.3设计构造块:启发式方法
- 找出现实世界中的对象:
- 辨识对象及其属性(方法(method)和数据(data))。
- 确定可以对各个对象进行的操作。
- 确定各个对象能对其他对象进行的操作。
- 确定对象的哪些部分对其他对象可见一—哪些部分可以是公用(public)的,
- 哪些部分应该是私用(private)的。
- 定义每个对象的公开接口(public interface)。
- 形成一致的抽象:抽象是一种能让你在关注某一概念的同时可以放心地忽略其中一些细节的能在不同的层次处理不同的细节。
- 封装实现细节:封装是说,不只是让你能用简化的视图来看复杂的概念,同时还不能让你看到复杂概念的任何细节。你能看得到的就是你能全部得到的
- 当继承能简化设计时就继承:继承的好处在于他能很好地辅佐抽象的概念,是面向对象编程中最强大的工具之一
- 隐匿秘密:信息隐藏是结构化程序设计与面向对象设计的基础之一:
- 隐藏复杂度
- 隐藏变化源,将其影响限制在局部范围内。
- 找出容易变化的区域:找出看起来容易变化的项目、把容易变化的项目分离出来、把看起来容易变化的项目隔离开来
- 不要使用布尔量作为状态变量,改用枚举类型,因为给状态变量增加一个状态是很常见的。
- 保持松散的耦合
- 查阅常用的设计模式:设计模式精练了众多现成的解决方案
- 设计模式通过现成的抽象来减少复杂度
- 设计模式通过把常见解决方案的细节予以制度化来减少出错
- 设计模式通过通过提供多种设计方案而带来启发性的价值
- 设计模式通过把设计对话提升到一个更高的层次上来简化交流
- 常见设计模式
模式 | 描述 |
---|---|
Abstract Factory(抽象工厂) | 通过指定对象组的种类而非单个对象的类型来支持创建 |
Adapter(适配器) | 把一个类的接口转变成为另一个接口 |
Bridge(桥接) | 把接口和实现分离开来,使它们可以独立地变化 |
Composite(组合) | 创建一个包含其他同类对象的对象,使得客户代码可以与最上层对象交互而无须考虑所有的细节对象 |
Decrorator(装饰器) | 给一个对象动态地添加职责,而无须为了每一种可能的职责配置情况去创建特定的子类(派生类) |
Facade(外观) | 为没有提供一致接口的代码提供一个一致的接口 |
Factory Method | 做特定基类的派生类的实例化时,除了在FactoryMethod内部之外均无须了解各派生对象的具体类型 |
Iterator(迭代器) | 提供一个服务对象来顺序地访问一组元素中的各个元素 |
Observer(观察者) | 使一组相关对象相互同步,方法是让另一个对象负责:在这组对象中的任何一个发生改变时,由它把这种变化通知给这个组里的所有对象 |
Singleton(单件) | 为有且仅有一个实例的类提供一种全局访问功能 |
Strategy(策略) | 定义一组算法或者行为,使得它们可以动态地相互替换 |
Template Method(模板方法) | 定义一个操作的算法结构,但是把部分实现的细节留给子类(派生类) |
设计模式能够提供现成的解决方案,帮助更快完成易于理解的设计方案,难点在于找出最合适的模式,不应该为了使用设计模式而使用,这就要求充分理解每一种设计模式,知道其所适用于解决的问题。
5.4设计实践
介绍了迭代、分而治之、自上而下、自下而上、建立实验性原型等方法论
5.5对流行的设计方法的评论
略
第六章 可以工作的类
成为高效程序员的一个关键就在于,当你开发程序任一部分的代码时,都能安全地忽视程序中尽可能多的其余部分。而类就是实现这一目标的首要工具。
6.1类的基础:抽象数据类型(ADTs)
- 抽象数据类型(ADT,abstract data type)是指一些数据以及对这些数据所进行的操作的集合。
- 使用抽象数据类型的益处:可以隐藏实现细节、改动不会影响到整个程序、让接口提供更多信息、更容易提高性能、让程序的正确性更显而易见、程序更具自我说明性、无需在程序内到处传递数据、像在现实世界中那样操作实体。
- 类=抽象数据类型+继承+多态
6.2良好的类接口
- 类的接口应该展现一致的抽象层次
- 尽可能让接口可编程:一个接口中任何无法通过编译器强制实施的部分,就是一个可能被误用的部分
- 尽可能地限制类和成员的可访问性
- 不要公开暴露成员和数据
类的接口能够帮助使用者快速理解这个类的设计初衷
编码过程中注意思考对外提供哪些接口、接口如何命名、可访问性如何、注释等可以降低代码的阅读难度
6.3有关设计和实现的问题
包含
- “包含”表示一个类含有一个基本的数据元素或对象,是面向对象编程中的主力技术
- 对于数据成员超过7个的类,考虑要不要把这些数据成员分解为几个更小的类
继承
- “继承”是说一个类是另一个累的特化,继承时应该考虑成员函数是否对派生类可见、是否允许被覆盖,数据成员是否对派生类可见。
- 错误的继承给程序增加了复杂度,只有符合Liskov替换原则的继承才能降低复杂度。
- 继承的层次最好不要超过2-3层,派生类的数量最好不要超过7个
相对于包含而言,继承有更多地注意事项,使用时有更多需要考虑清楚的事项,从控制复杂度的角度来看,应尽量避免使用继承
成员函数和数据成员
略
构造函数
- 尽可能早构造函数中初始化所有数据成员(防御目的)
- 单例模式下构造函数private
- 拷贝构造逐一考虑采用深拷贝
6.4创建类的原因
创建类的理由:
- 对现实世界中的对象建模
- 对抽象对象建模
- 降低复杂度
- 隔离复杂度
- 隐藏实现细节
- 限制变化所影响的范围
- 隐藏全局数据
- 让参数传递更顺畅
- 创建中心控制点
- 让代码更易于重用
- 为程序族做计划
- 把相关操作放到一起
- 实现特定的重构
6.5与具体编程语言相关的问题
略
6.6超越类:包
略
第七章 高质量的子程序
子程序也算得上是计算机科学中一项最为重大的发明了。 子程序的使用使得程序变得更加易读,更易于理解,比任何编程语言的任何功能特性都更容易。
7.1创建子程序的正当理由
- 降低复杂度:可以通过创建子程序来隐藏一些信息,这样你就不必再去考虑这些信息了。缩小代码规模、改善可维护性、提高正确性
- 引入中间、易懂的抽象:把一段代码放入一个命名恰当的子程序内,是说明这段代码用意最好的方法之一。
- 避免代码重复
- 支持子类化
- 隐藏顺序
- 隐藏指针操作
- 提高可移植性
- 简化复杂的逻辑判断
- 改善性能 除此之外,创建类的很多理由也是创建子程序的理由:
- 隔离复杂度
- 隐藏实现细节
- 限制变化所带来的影响
- 隐藏全局数据
- 形成中央控制点
- 促成可重用的代码
- 达到特定的重构目的
通过函数可以减少重复的代码,好的函数命名能够大幅提高代码的可读性。
在工作中经常见到有的函数中只有几行甚至一行代码,代码评审时会讨论这么短的函数是否有必要封成函数。通过书中的介绍明白了这样可以提高代码可读性,并且在这个短函数被调用次数比较多的情况下,可以实现一处修改,全局统一变化的好处,从而降低维护过程的复杂度。
7.2 在子程序层上设计
- 内聚性是指子程序中各种操作之间联系的紧密程度
- 功能的内聚性(functional cohesion)是最强也是最好的一种内聚性,也就是说让一个子程序仅执行一项操作。
- 不够理想的内聚性:
- 顺序上的内聚性
- 通信上的内聚性
- 临时的内聚性
- 不可取的内聚性:
- 过程上的内聚性
- 逻辑上的内聚性
- 巧合的内聚性
在读代码中见到过一些函数名称中有“and”,对阅读代码造成了非常大的困难,这类函数具有不可取的“过程上的内聚性”,因为外界的调用往往是两个过程一起顺序调用,所以直接写在了一起。将and前后做的两件事情拆开写到两个函数中可以以很好的实现“功能的内聚性”,让一个函数只完成一个功能,降低理解的门槛。
7.3 好的子程序名字
- 描述子程序所做的所有事情
- 避免使用无意义的、模糊或表述不清的动词有些动词
反例:Handlecalculation()、 PerformServices()、 OutputUser()、 ProcessInput()和 DealWithoutput() - 不要仅通过数字来形成不同的子程序名字
反例:OutputUser、 OutputUser1和 OutputUser22 - 函数命名时要对返回值有所描述(对象名称清晰时可以省略宾语)
- 给过程起名时使用语气强烈的动词加宾语的形式
- 准确使用对仗词,eg:
begin/end
insert/delete
show/hide create/destroy
lock/unlock
source/target
first/last
min/max
start/stop
get/put
next/previous
up/down
get/set
old/new - 为常用操作确立命名规则
好的命名能够清晰地表述函数的作用,从而封装复杂度,阅读代码的人通过名称就能够明白用意, 而无需关心内部的实现。
起名字时确实容易偷懒,使用Handle、Process、Deal导致维护的人对函数的作用理解不清,应该尽量避免。
7.4 子程序可以写多长
在超过200行后,你迟早会在可读性方面遇到问题
7.5 如何使用子程序参数
- 按照输入修改-输出的顺序排列参数(暗含了子程序的内部操作顺序)
- 考虑自己创建IN和OUT关键词(利用宏,说明作用,编译器不强制)
- 如果几个子程序都用了类似的一些参数,应该让这些参数的排列顺序保持一致
- 使用所有的参数
- 把状态或出错变量放在最后(附属于程序的主要功能)
- 不要把子程序的参数用做工作变量(不要修改输入变量,可通过const限制)
- 在接口中对参数的假定加以说明
- 把子程序的参数个数限制在大约7个以内
- 考虑对参数采用某种表示输入、修改、输出的命名规则
- 为子程序传递用以维持其接口抽象的变量或对象
参数的设置能够帮助读者理解接口的功能和用意,在代码中见到过有非常多参数的接口,在没有必要理由的情况下尽量避免,可以将参数聚类为一个对象传入。
7.6 使用函数时要特别考虑的问题
- 除非万不得已,谨慎使用宏, 替代技术:
const可以用于定义常量 inline可以用于定义可被编译为内嵌的代码(n1ne《inInecode)的函数 template可以用于以类型安全的方式定义各种标准操作,如min、max等 enum可以用于定义枚举类型 typedef可以用于定义简单的类型替换
第八章 防御式编程
8.1 保护程序免遭非法输入数据的破坏
对已形成产品的软件而言,仅仅“垃圾进,垃圾出”还不够。不管进来什么,好的程序都不会生成垃圾,而是做到“垃圾进,什么都不出”、“进来垃圾,出去是出错提示”或“不许垃圾进来”。 通常有三种方法来处理进来垃圾的情况:
- 检查所有来源于外部的数据的值
- 检查子程序所有输入参数的值
- 决定如何处理错误的输入数据
8.2 断言
- 用错误处理代码来处理预期会发生的状况,用断言来处理绝不应该发生的状况:
- 断言是用来检查永远不该发生的情况
- 断言是用于检查代码中的bug
- 可以把断言看做是可执行的注解
- 用断言来注解并验证前条件和后条件前条件(相当于与其余部分都形成了一份契约)
- 对于高健壮性的代码,应该先使用断言再处理错误(两种措施同时使用)
个人理解:断言相当于能够运行的注释,是代码之间的一份“契约”,当其他开发者的修改破坏了“契约”,断言能够主动指出修改存在问题。
但是目前使用Release模式开发的产品没有办法使用这一便利的工具。
8.3 错误处理技术
- 断言可以用于处理代码中不应发生的错误,错误处理技术处理那些预料中可能要发生的错误
- 可以采用的措施:
- 返回中立值
- 换用下一个正确数据
- 返回与前次相同的值
- 换用最接近的有效值
- 在日志文件中记录警告信息
- 返回一个错误码
- 调用错误处理子程序或对象
- 显示出错信息或者关闭程序
- 或把这些技术结合起来使用
- 权衡健壮性与正确性:有的场景宁可返回,也不能返回错误的数值,此时需要牺牲健壮性来保证正确性;相反有的时候需要有返回值来保证软件的正常运行不崩溃,牺牲正确性来保证健壮性。
难点在与根据所处的场景选择合适的处理措施,不合适的处理措施将会成为非常隐蔽的bug
8.4 异常
异常是把代码中的错误或异常事件传递给调用方代码的一种特殊手段,如一个子程序遇到了预料之外的情况,不知道该如何处理的情况,此时需要抛出异常。审慎明智地使用异常,它可以降低复杂度;而草率粗心地使用时,只会让代码变得几乎无法理解。
- 用异常通知程序的其他部分,发生了不可忽略的错误(能够避免错误扩散)
- 只在真正例外的情况下才抛出异常(否则会增加复杂性、弱化封装性)
- 不能用异常来推卸责任(如果某种的错误情况可以在局部处理,那就应该在局部处理掉它。)
- 避免在构造函数和析构函数中抛出异常,除非你在同一地方把它们捕获(构造未完成会导致析构无法调用)
- 在恰当的抽象层次抛出异常
- 在异常消息中加入关于导致异常发生的全部信息
- 避免使用空的 catch语句(至少要记录到日志里)
- 了解所用函数库可能抛出的异常(未捕获,可能会崩溃)
- 考虑创建一个集中的异常报告机制
- 把项目中对异常的使用标准化
- 考虑异常的替换方案
8.5 隔离程序,使之包容由错误造成的损害
通过对穿越安全区域边界的数据进行合法性校验,并进行清理,实现隔离
8.6 辅助调试的代码
- 在调试时可以适当的牺牲一些运行速度和资源,来换取更顺畅的开发体验
- 通过辅助代码,进攻性的暴露问题,在开发阶段处理
- 计划移除调试辅助代码(发布前确保移除)
8.7 确定在产品代码中该保留多少防御式代码
- 保留那些检查重要错误的代码
- 去掉检查细微错误的代码
- 去掉可以导致程序硬性崩溃的代码
- 保留可以让程序稳妥地崩溃的代码
- 为你的技术支持人员记录错误信息
- 确认留在代码中的错误消息是友好的
8.8 对防御式编程采取防御的姿态
过度的防御式编程也会引起问题。
读后感:商业软件需要充分考虑健壮性,只是实现了主流程,当用户输入异常的数据的时候就会“garbage in, garbage out”。
软件运行时出现的问题相较于主流场景比较不常见,往往难以完全预料,通过辅助代码可以帮助开发者在编码期间提前发现问题,发现错误。
断言可以用于处理代码中不应发生的错误
错误处理技术处理预料到的,能够想到处理方式的
异常用于抛出不知如何处理的错误
第九章 伪代码编程过程
9.1 创建类和子程序的步骤概述
创建一个类的关键步骤如下:
- 创建类的总体设计
- 创建类中的子程序
- 复审并测试整个类
创建子程序的步骤:
开始↓ 设计子程序 <-> 检查设计↑ ↓
复审并测试代码 <-> 编写子程序的代码 ↓完成
9.2 伪代码
- 伪代码使得评审更容易
- 伪代码支持反复迭代精化的思想
- 伪代码使变更更加容易
- 伪代码能使给代码作注释的工作量减到最少
- 伪代码比其他形式的设计文档更容易维护
伪代码是比较理想的详细设计的工具
9.3 通过伪代码编程过程创建子程序
设计子程序
- 检查先决条件
- 定义子程序要解决的问题(承接高层次的设计,决定要隐藏的信息,输入输出,前置条件)
- 为子程序命名
- 决定如何测试子程序
- 在标准库中搜寻可用的功能
- 考虑错误处理
- 考虑效率问题
- 研究算法和数据类型
- 编写伪代码
- 考虑数据
- 检查伪代码
- 在伪代码中试验一些想法,留下最好的想法(迭代)
感想:事实上这也是设计期需要输出详细设计文档的条目
编写子程序的代码
检查代码
收尾工作
按照需要重复上述步骤
感想:本节详细介绍了编写子程序应该的执行的具体步骤,好在我们处于技术浪潮的后期,具有完备的IDE工具,能够帮助我们处理许多繁杂的工作。
9.4 伪代码编程过程的替代方案
- 测试先行开发
- 重构
- 契约式设计
- 东拼西凑
感想:代码编程过程是一个不断迭代的过程,通过伪代码可以降低试错成本,加快迭代周期,是理想的详细设计工具。
第十章 使用变量的一般事项
10.1 数据认知
感想:这一节是一个认知测试,自测了一下17分,符合中级程序员的标准~作者很皮,在测试里面加入了杜撰的选项,用来测“诚实”
10.2 轻松掌握变量定义
- 隐式变量声明对于任何一种语言来说都是最具危险性的特性之一(变量混淆)
感想:隐式变量声明确实会带来一些难以理解的问题,比如全局变量占用名称后,局部使用同名变量可能期望生命一个同名的局部变量,却会使用全局的变量。显示变量生命可以让编译器来发现拼写错误的问题。
10.3 变量初始化原则
变量不初始化或者错误的初始化可能会引入一系列的问题,建议:
- 在声明变量的时候初始化
- 在靠近变量第一次使用的位置初始化
- 想情况下,在靠近第一次使用变量的位置声明和定义该变量
- 在可能的情况下使用fina或者const(可以防止该变量在初始化之后再被赋值)
- 特别注意计数器和累加器
- 在类的构造函数里初始化该类的数据成员
- 检查是否需要重新初始化
- 一次性初始化具名常量;用可执行代码来初始化变量
- 使用编译器设置来自动初始化所有变量
- 利用编译器的警告信息
- 检查输入参数的合法性
- 使用内存访问检查工具来检查错误的指针
- 在程序开始时初始化工作过内存
感想:Qt的数据类型会自动初始化,但是应该警惕不会自动初始化的数据类型。
10.4 作用域
使变量引用局部化
避免不当地修改了这个变量,或者阅读代码的人可能会忘记该变量应有的值——主要好处是提高程序的可读性
尽可能缩短变量的“存活”时间
- 减小攻击窗口
- 使你能对自己的代码有更准确的认识
- 少了初始化错误的可能
- 代码容易拆分和重构
- 更强的可读性
减小作用域的一般原则
- 在循环开始之前再去初始化该循环里使用的变量,而不是在该循环所属的子程序的开始处初始化这些变量
- 直到变量即将被使用时再为其赋值
- 把相关语句放到一起
- 把相关语句组提取成单独的子程序
- 开始时采用最严格的可见性,然后根据需要扩展变量的作用域
有关缩小变量作用域的说明
“方便性”和“智力可管理性”两种理念之间的区别,归根结底来源于侧重写程序还是读程序之间的区别。使作用域最大化可能真的会让程序写起来比较容易,但相对于子程序功能划分明确的程序,一个允许任何其子程序在任何时间使用任何变量的程序是更难于理解的。对于这种程序,你不能只去理解一个子程序;你还必须要理解其他所有使用了相同全局数据的子程序才行。这种程序无论阅读、调试还是修改起来都很困难。
感想:全局变量虽然在写代码的时候很方便,但是对于阅读的人来说需要的代码才能理解用意。也不方便重构,可移植性会下降。类中的私有成员变量也是一样的,如果一个类特别大,这个成员变量可能在各种意想不到的地方被修改,给阅读造成困扰。如非必要尽可能使用作用域小的手段解决问题。
10.5 持续性
“持续性”是对一项数据的生命期的另一种描述 “持续性”从短到长:
- 特定代码段或子程序的生命期: 循环里声明的变量
- 只要你允许,它就会持续下去:new出来的变量
- 程序的生命周期:static变量
- 永久持续:存到数据库里的变量
合理的“持续性”能够避免在一个变量正常的生命期结束之后访问它的数据,为了确保生命周期有如下措施:
- 调试代码或者断言判断变量取值是否合理
- 准备抛弃变量时给它们赋上“不合理的数值”,例如删除一个指针后把它的值设为null
- 编写代码时要假设数据并没有持续性
- 养成在使用所有数据之前声明和初始化的习惯
10.6 绑定时间
- 编码时(使用神秘数值)
- 编译时(使用具名常量)
- 加载时(从 Windows注册表、Java属性文件等外部数据源中读取数据)
- 对象实例化时(例如在每次窗体创建的时候读取数据)
- 即时(例如在每次窗体重绘的时候读取数据) 绑定时间越早灵活性就会越差,但复杂度也会越低
10.7 数据类型和控制结构之间的关系
- 序列型数据翻译为程序中的顺序语句:如果你从文件中读取了员工的姓名、社会安全号码、住址、电话号码和年龄
- 选择型数据翻译为程序中的if和case语句
- 迭代型数据翻译成程序中的for、 repeat、 while等循环结构
10.8 为变量指定单一用途
-
每个变量只用于单一用途
-
避免让代码具有隐含含义
-
避免让代码具有隐含含义
第十一章 使变量名的力量
11.1选择好变量名的注意事项
- 为变量命名时最重要的考虑事项是,该名字要完全、准确地描述出该变量所代表的事物。获得好名字的一种实用技巧就是用文字表达变量所代表的是什么。通常,对变量的描述就是最佳的变量名这种名字很容易阅读,因为其中并不包含晦涩的缩写,同时也没有歧义。但不能太长
- 以问题为导向,一个好记的名字反映的通常都是问题,而不是解决方案。
- 最适当的名字长度,变量名的平均长度在10到16个字符调试程序所需花费的气力是最小的
- 较长的名字适用于很少用到的变量或者全局变量,而较短的名字则适用于局部变量或者循环变量
- 对位于全局命名空间中的名字加以限定词,尽可能避免命名冲突
- 变量名中的计算值限定词尽可能放在名字的最后,一致性可以提高可读性,简化维护工作。
- 使用常用对仗词,避免产生歧义:
begin/end
first/last
alocked/unlocked
min/max
next/previous
old/new
opened/closed
visible/invisible
source/target
source/destination
up/down
感想:公司目前实行的编码规范对变量命名做了非常详尽的约定,但是没有考虑变量的使用次数,这个维度也应该考虑起来,对于只在局部临时使用一次的变量,可以起一个简短的名字,而对于全局变量、成员变量这种需要一个能够完整描述清楚含义的名字。
11.2为特定类型的数据命名
为循环下标命名
- i,j,k用在比较短的循环中,长的循环中建议使用更加有意义的名字。
- 不要在其他场合使用i,j,k命名变量,这些已经是深入人心的简单循环中的循环下标变量名称
为状态变量命名
- 为状态变量取一个比flag更好的名字
为临时变量命名
程序中大多数变量都是临时性的,即使是临时变量也要尽可能提供更多信息。
为布尔变量命名
好的布尔变量命名:done、error、found、success、OK 不好的布尔变量命名:status 使用肯定的布尔变量名,反例notFound
为枚举类型命名
- 通过前缀来明确表示该类型的成员都同属于一个组
为常量命名
命名需要表明常亮的含义
11.3命名规则的力量
为什么要有规则
- 要求你更多地按规矩行事
- 有助于在项目之间传递知识
- 有助于你在新项目中更快速地学习代码
- 有助于减少名字增生(避免同样的含义的变量在不同地方出现两个名字)
- 弥补编程语言的不足之处
- 强调相关变量之间的关系
何时采用命名规则
略
正式程度
略
11.4非正式命名规则
与语言无关的命名规则的指导原则
- 区分变量名和子程序名字
- 区分类和对象
- 标识全局变量
- 标识成员变量
- 标识类型声明
- 标识具名常量
- 标识枚举类型的元素
- 在不能保证输入参数只读的语言里标识只读参数
- 格式化命名以提高可读性(大小写和分隔符来分隔单词)
与语言相关的命名规则的指导原则
以下是围绕着C++编程形成的命名规则。
- i、j是整数下标
- p是指针
- 常量、typedef和预处理宏全部大写(ALL_CAPS)。
- 类和其他类型的名字混合大小写(MixedUpperAndLowerCase())
- 变量名和函数名中的第一个单词小写,后续每个单词的首字母大写,例: variableorRoutineName.
- 不把下画线用做名字中的分隔符,除非用于全部大写的名字以及特定的前缀中(如用于标识全局变量的前缀)。
11.5标准前缀
标准化的前缀使名字变得更加紧凑
11.6创建具备可读性的短名字
缩写的一般指导原则:
- 使用标准的缩写(列在字典中的那些常见缩写)。
- 去掉所有非前置元音。(computer变成cmpt, screen变成scrn, apple变成appl, integer变成 intgr。)
- 去掉虚词and,or,the等
- 使用每个单词的第一个或前几个字母。
- 统一地在每个单词的第一、第二或者第三个(择最合适的一个)字母后截断。
- 保留每个单词的第一个和最后一个字母。
- 使用名字中的每一个重要单词,最多不超过三个。
- 去除无用的后缀ing,ed等。
- 保留每个音节中最引人注意的发音。
- 确保不要改变变量的含义。
- 反复使用上述技术,直到你把每个变量名的长度缩减到了8到20个字符,或者达到你所用的编程语言对变量名的限制字符数。 有关缩写的评论
- 不要用从每个单词中删除一个字符的方式来缩写
- 缩写要一致
- 创建你能读出来的名字
- 避免使用容易看错或者读错的字符组合
- 使用辞典来解决命名冲突(冲突的时候换同义词)
- 在代码里用缩写对照表解释极短的名字的含义
- 在一份项目级的“标准缩写”文档中说明所有的缩写
- 名字对于代码读者的意义要比对作者更重要
11.7应该避免的名字
- 避免使用令人误解的名字或缩写
- 避免使用具有相似含义的名字
- 避免使用标准类型、变量和子程序的名字
读后感:作者在这一章非常详尽的介绍了关于变量命名的事项。代码阅读的次数远远多于编写的次数,所以确保取的名字应该更侧重于阅读方便而不是编写方便。
第十二章 基本数据类型
基本数据类型是构建其他所有数据类型的构造块
12.1数值概论
- 避免使用魔法数字:方便修改,更容易,代码更可读(循环和递增中的0,1不算魔法数字)
- 预防除0
- 尽可能使用显示类型转换
- 避免混合类型的比较
- 注意编译器的警告
12.2整数
- 检查整数除法:7/10不等于0.7它总是等于0
- 检查整数溢出
- 检查中间结果溢出
12.3浮点数
- 避免数量级相差巨大的数之间的加减运算
- 避免等量判断(要考虑误差)
- 处理舍入误差
- 检查语言和函数库对特定数据类型的支持
12.4字符和字符串
- 避免使用神秘字符和神秘字符串神秘字符
- 不要越界
- 尽早决定国际化/本地化策略
- 如果你需要支持多种语言,请使用 Unicode
- 采用某种一致的字符串类型转换策略
12.5布尔变量
略
12.6枚举类型
- 用枚举类型来提高可读性
- 用枚举类型来提高可靠性
- 用枚举类型来简化修改
- 将枚举类型作为布尔变量的替换方案
- 检查非法数值
- 定义出枚举的第一项和最后一项,以便用于循环边界
- 把枚举类型的第一个元素留做非法值(捕捉没有合理初始化)
感想:除非确定只有两个状态的情况下,使用枚举代替布尔变量做标记会具有更好的扩展性。
12.7具名常量
略
12.8数组
- 确保不越界
- 考虑用容器来取代数组
- 检查数组的边界点(是否正确的找到第一个元素)
12.9创建你自己的类型(类型别名)
略
第十三章 不常见的数据类型
13.1 结构体
- “结构体”这一术语指的是使用其他类型组建的数据。
- 类相对于结构体的优势:除了公用数据成员外,还能利用类所提供的私密性和功能性。
- 使用结构体的理由:
- 用结构体来明确数据之间的关系
- 用结构体简化对数据块的操作(如交换两个结构体对象的数据)
- 用结构体来简化参数列表
- 用结构体来减少维护(参数列表传结构体,添加或删除参数的时候无需调整所有的参数列表)
感想:在C++中结构体也可以拥有自己的成员函数,与类的区别在于默认情况下结构体的所有方法与成员都是public的,继承时也是默认public继承
13.2 指针
每一个指针都包含两个部分:内存中的某处位置,以及如何解 释该位置中的内容
正确使用指针的双向策略:
- 避免造成指针错误(预防性措施)
- 尽快地检测出指针错误来
使用指针的一般技巧
- 把指针操作限制在子程序或者类里面(封装复杂度)
- 同时声明和定义指针(避免被错误的在定义前使用)
- 在与指针分配相同的作用域中删除指针(函数里new却指望外部释放)
- 在使用指针之前检查指针
- 先检查指针所引用的变量再使用它
- 用狗牌字段来检测损毁的内存
- 增加明显冗余
- 用额外的指针变量来提高代码清晰(声明名称清晰地临时变量,降低理解成本)
- 简化复杂的指针表达式
- 按照正确的顺序删除链表中的指针
- 分配一片保留的内存后备区域
- 粉碎垃圾数据
- 在删除或者释放指针之后把它们设为空值
- 在删除变量之前检查非法指针
- 跟踪指针分配情况
- 编写覆盖子程序,集中实现避免指针问题的策略
- 采用非指针的技术
C++ 指针与引用
- 引用必须总是引用一个对象,而指针则可以指向空值,还有,引用所指向的对象在该引用初始化之后不能改变
- 把指针用于“按引用传递”参数,把cons引用用于“按值传递”参数
- 使用auto_ptr(已经被 unique_ptr 取代)
感想:
之前见到的情况:1.主流程调用的子程序new了指针A,主程序没有记得释放指针A。2.主流程调用的子程序new了指针A,主程序记得释放指针A,但是在释放前指针的被另一个子程序拷贝给了指针B,导致调用指针B的时候已经被释放了,崩溃。3.主流程调用的子程序new了指针A,另一个子程序对指针A所指的对象进行加工存进了指针B,并对指针A进行了释放,主程序不知道A已经被释放,再次delete指针A,崩溃。4.满世界传智能指针,导致效率问题。
好的习惯:初始化的时候不想new的时候初始化为nullptr,释放之后顺手给指针赋值nullptr,使用之前就可以直接判断指针是否等于nullptr
13.3 全局数据
全局数据违背信息隐藏和模块化的原则,增加理解和维护成本,不推荐使用,使用更好的方式取代。
全局数据带来的问题:
- 无意间修改了全局数据
- 与全局数据有关的奇异的和令人激动的别名问题(全局变量被当作参数传入时,会有两个变量实际上是一个变量)
- 与全局数据有关的代码重入(re-entrant)问题(多个线程、多个程序访问同一个全局变量)
- 全局数据阻碍代码重用(依赖全局环境了,拆不出来)
- 与全局数据有关的非确定的初始化顺序事宜
- 全局数据破坏了模块化和智力上的可管理性
使用全局数据的理由
- 保存全局数值(概念上用于整个程序的数据)
- 模拟具名常量
- 模拟枚举类型
- 简化对极其常用的数据的使用(避免出现在每一处参数列表里面)
- 消除流浪数据(一个数据经过层层传递才被使用)
只有万不得已时才使用全局数据
替代方案:
- 首先把每一个变量设置为局部的,仅当需要时才把变量设置为全局的(局部变量->成员变量->全局变量,谨慎的逐步放开)
- 区分全局变量和类变量(模块化)
- 使用访问器子程序,你用全局数据能做的任何事情,都可以用访问器子程序做得更好,优点:
- 获得了对数据的集中控制
- 可以确保对变量的所有引用都得到了保护
- 可以自动获得信息隐藏的普遍益处
- 访问器子程序可以很容易地转变为抽象数据类型
如何使用访问器子程序
把数据隐藏到类里面。用static关键字或者它的等价物来声明该数据以确保只存在该数据的单一实例。写出让你可以查看并且修改该数据的子程序来。要求类外部的代码使用该访问器子程序来访问该数据,而不是直接操作它。
- 要求所有的代码通过访问器子程序来存取数据
- 不要把你所有的全局数据都扔在一处(丧失了信息隐藏和抽象数据类型所带来的好处)
- 用锁定来控制对全局变量的访问(开发阶段发现多处同时使用的防范措施)
- 在你的访问器子程序里构建一个抽象层(获取下一个全局id)
- 使得对一项数据的所有访问都发生在同一个抽象层上
如何降低使用全局数据的风险
- 创建一种命名规则来突出全局变量
- 为全部的全局变量创建一份注释良好的清单
- 不要用全局变量来存放中间结果
- 不要把所有的数据都放在一个大对象中并到处传递,以说明你没有使用全局变量
第十四章 组织直线型代码
14.1必须有明确顺序的语句
- 首先要尽力写没有顺序依赖关系的代码。
- 其次尽力写依赖关系明显的代码。
- 通过名称暗示 调用顺序要求(init一定最先调用,计算一定在读取之后,输出一定在计算之后)
- 通过传递相同的参数暗示顺序处理同一个对象
- 上一行的输出是下一行的输入强制要求依赖
- 如果你还担心某一项依赖关系不够清楚,那么就用文档说明它。
- 用断言或者错误处理代码来检查依赖关系
14.2顺序无关的语句
把相关操作放在一起,使代码易于自上而下地阅读
读后感:
本书最短的一章了,特别喜欢通过参数列表表达依赖顺序的这个点子,在调整其他人的代码的时候能够清楚的了解到参数的流动过程是一种非常理想的阅读体验
第十五章 使用条件语句
15.1 if语句
使用指导原则:
- 首先写正常代码路径;再处理不常见情况
- 确保对于等量的分支是正确(判断条件正确严谨)
- 把正常情况的处理放在if后面而不要放在else后面
- 让if子句后面跟随一个有意义的语句(不能为空)
- 考虑else子句
- 测试else子句的正确性
- 检查if和else子句是不是弄反了
if-then–else语句串(连续的else if)
- 利用布尔函数调用简化复杂的检测(提高可读性)
- 把最常见的情况放在最前面
- 确保所有的情况都考虑到了
15.2 case语句
为case选择最有效的排列顺序:
- 按字母顺序或按数字顺序排列各种情况
- 把正常的情况放在前面
- 按执行频率排列case子句
技巧:
- 简化每种情况对应的操作(代码结构更清晰)
- 不要为了使用case语句而刻意制造一个变量
- 把 default子句只用于检查真正的默认情况(不是最后一种情况,default不是else)
- 利用 default子句来检测错误
- 避免代码执行越过一条case子句的末尾(避免“聪明的”不适用break语句增加阅读困难,或者用错了,否则给个注释)
感想:之前见过为了对字符串进行switch-case,对字符串进行MD5计算,似乎这样做是不对的
第十六章 控制循环
“循环”是一个非正式的术语,用来指代任意一种迭代控制结构(iterative control structure)任一能够导致应用程序反复执行一段代码的结构。
16.1 选择循环的种类
- 计数循环(执行的次数是一定的)
- 连续求值的循环(迭代时检查是否应该结束)
- 无限循环(心脏起搏器中就会用)
- 迭代器循环(对容器类里面的每个元素执行)
- 灵活度:
严格——循环执行的次数是一定的,
灵活——在每次迭代的时候检查循环有没有完成 - 检查循环是否执行完毕的位置:
循环的开始,循环中的代码不一定被执行 中间,检查之前的代码一定被执行 结尾处,代码至少执行一次
什么时候使用 while循环
如果你预先并不知道循环要迭代多少次,那么就使用 while循环
- while在开始时检查条件
- do-while在结束时检查条件
什么时候用带退出的循环
正常的带退出循环
在检查前的代码至少要执行一次的情况使用带退出的循环
关注细节:
- 把所有的退出条件放在一处,易读
- 不直接支持带退出循环的语言里使用带退出循环时,用注释来阐明操作意图
非正常的带退出循环
用goto中途闯进一个循环(恐怖)
何时使用for循环
需要简单的、固定次数的循环
- 不要在内部修改下标影响循环
- 在头部写好循环条件后,内部无需关注循环控制问题
何时使用 foreach循环
很适用于对数组或者其他容器的各项元素执行操作,它的优势在于消除了循环内务处理算术,从而也就消除了任何由循环控制算术导致出错的可能性。
感想:使用循环是程序员的基本功之一,foreach用起来很方便,也能够提高代码的可读性,使阅读者理解编码者要对容器中每个元素进行操作。目前使用的foreach是Qt提供的;std库提供了for_each模板函数;个人比较喜欢使用C++11标准中提供了标准的for(auto item:container){;}。
16.2循环控制
循环会出显得错误:
- 忽略或错误地对循环执行初始化
- 忽略了对累加变量或其他与循环有关的变量执行初始化
- 不正确的嵌套
- 不正确的循环终止
- 忽略或者错误地增加了循环变量的值
- 用不正确的循环下标访问数组元素 阻止上述错误的策略:
- 减少能影响该循环各种因素的数量
- 把循环内部当做一个子程序看待
感想:“当做一个子程序看待”,但不是真的循环里放一个子程序,会带来没办法break的问题,C++11中std::for_each()函数就有这个问题,循环体在lambda表达式中,不能提前终止循环。
进入循环
- 只从一个位置进入循环
- 把初始化代码紧放在循环前面(就近原则,易于修改)
- 用 while(true)表示无限循环(想象一下用在心脏起搏器中的场景,不能使用一个大数)
- 在适当的情况下多使用for循环(控制集中,易于修改)
- 在 while循环更适用的时候,不要使用for循环(for循环头部只放循环控制语句)
处理好循环体
- 用“{”和“}”把循环中的语句括起来(增加可读性)
- 避免空循环(避免循环内部没有语句,功能在控制时执行)
- 一个循环只做一件事(除非影响效率)
退出循环
- 设法确认循环能够终止
- 使循环终止条件看起来很明显
- 不要为了终止循环而胡乱改动for循环的下标(for循环内部不要控制循环本身)
- 避免出现依赖于循环下标最终取值的代码(下标作用域超出循环范围)
- 考虑使用安全计数器(防御以避免死循环)
提前退出循环
- continue不会让程序从循环退出,而是让程序跳过循环体的余下部分,从该循环的下一次迭代的开始位置继续执行。而break会直接退出循环。
- 考虑在 while循环中使用 break语句而不用布尔标记(增加可读性)
- 在循环开始处用 continue进行判断(可以避免整个循环放在if里面)
- 使用 break和 continue时要小心谨慎(增加了复杂度,除非必要尽量少用)
检查端点
脑海中模拟并计算,确保边界条件正确
感想:低效的程序员会通过尝试写出能够正确工作的代码,不知道代码为什么正确,后面就更不知道为什么,为什么出错了
使用循环变量
- 用整数或者枚举类型表示数组和循环的边界(别用浮点数)
- 在嵌套循环中使用有意义的变量名来提高其可读性
- 用有意义的名字来避免循环下标串话(避免用错下标)
- 把循环下标变量的作用域限制在本循环内
循环应该有多长
- 循环要尽可能地短,以便能够一目了然
- 把嵌套限制在3层以内
- 把长循环的内容移到子程序里
- 要让长循环格外清晰
感想:通过这一节的阅读,我发现编程语言对循环的限制确实不多,还能够写出这么多奇形怪状的循环,作者始终在强调避免使用这些奇怪的方式,提高代码的可读性和可维护性。事实上循环应该有的样子已经非常深入人心了,奇怪的写法不单读起来头大,写起来也很别扭的。目前只见过foreach循环容器的过程中删除容器元素一种比较难受的情况下。
16.3轻松创建循环由内而外
先写单一情况,再在外面套上循环
16.4循环和数组的关系
略
读后感:使用主流的方式使用循环,不要写“聪明”代码,保证代码的可读性和可维护性。
第十七章 不常见的控制结构
17.1 子程序中的多处返回
- 如果能增强可读性,那么就使用 return(得到结果立即返回)
- 用防卫子句来简化复杂的错误处理(避免过多的嵌套,提前用return掉异常场景,然后在处理正常场景)
- 减少每个子程序中 return的数量
17.2 递归
在递归( recursion)里面,一个子程序自己负责解决某个问题的一小部分,它还把问题分解成很多的小块,然后调用自己来分别解决每一小块。当问题的小部分很容易解决,而问题的大部分也很容易分解成众多的小部分时,常常会用到递归。
对于某一小范围内的问题,使用递归会带来简单、优雅的解。在稍大一些范围里,使用递归会带来简单、优雅但是难懂的解对于大多数问题,它所带来的解将会是极其复杂的——在那些情况下,使用简单的迭代通常会比较容易理解。因此要有选择地使用递归。 使用递归的技巧:
- 确认递归能够停止(通过检查避免无穷递归)
- 使用安全计数器防止出现无穷递归
感想:记得python中就设置了默认递归上限,C++中可以自己通过assert实现
- 把递归限制在一个子程序内
- 留心栈空间
避免栈溢出,特别要留意那些内存消耗大的对象,用new在堆(heap)上创建对象,而不要让编译器在栈上面自动创建对象 - 不要用递归去计算阶乘或者斐波纳契数列 速度缓慢,并且无法预测运行期间的内存使用状况、更难理解
- 递归可以做到的同样也可以用栈和循环来做到,注意方案选择
17.3 goto
- 用不用goto是一个信仰问题
感想:这一节中作者尽可能中立的描述了goto的优点和缺点,作者个人持有的观点是除非万不得已,尽可能不使用goto。事实上,goto绝大多数情况下是可被替代的,在C++语言多数情况下有不止一种手段能够替换掉goto,goto影响阅读体验,不合理的使用可能带来许多问题,个人不赞成使用。
17.4 针对不常见控制结构的观点
略
第十八章 表驱动法
从表里面查找信息而不使用逻辑语 句(if和case)。事实上,凡是能通过逻辑语句来选择的事物,都可以通过查表来选择。对简单的情况而言,使用逻辑语句更为容易和直白。但随着逻辑链的越来越复杂,查表法也就愈发显得更具吸引力。
18.1 表驱动法使用总则
在适当的环境下,采用表驱动法,所生成的代码会比复杂的逻辑代码更简单、更容易修改,而且效率更高。 使用表驱动法的两个问题:
- 怎么从表中查询条目
- 直接访问
- 索引访问
- 阶梯访问
- 应该在表里存些什么
18.2 直接访问表
可以将数据作为键值直接访问表 当数据和键值不是一一对应的时候的办法:
- 复制信息从而能够直接使用键值(产生冗余信息,增加存在错误的可能)
- 转换键值以使其能够直接使用
- 把键值转换提取成独立的子程序
18.3 索引访问表
优点:
- 数据很大时可以节约空间
- 降低操作成本,易于维护(同一张表可以有多个索引表,增加查询方式)
18.4 阶梯访问表
比索引访问表节省空间,适合处理无规则数据 注意:
- 留心端点
- 考虑用二分查找取代顺序查找
- 考虑用索引访问来取代阶梯技术(减少访问耗时)
- 把阶梯表查询操作提取成单独的子程序
18.5 表查询的其他示例
略
读后感:这是读这本书以来,第一个对标题没有概念的章节。也是第一次听说表驱动法。表驱动法通过表查询的方式取缔了大量同级别的if语句,从而帮助开发者将精力用在解决实际问题上,而不是处理逻辑判断。通过表驱动法,可以实现数据驱动,即将表保存在文件中,程序启动时主动加载,这样在需要修改时修改数据文件而无需修改代码,影响范围会变得更为可控。
第十九章 一般控制问题
19.1布尔表达式
用true和 false做布尔判断
(而不要用0和1等数值,易混淆,可读性差)
- 隐式地比较布尔值与true和 false(要while(a>b),不要while((a>b)==true))
简化复杂的表达式
- 拆分复杂的判断并引入新的布尔变量
- 把复杂的表达式做成布尔函数(即使只调用一次,把复杂的判断逻辑放在命名良好函数里也能大大提高代码的可读性)
- 用决策表代替复杂的条件(表驱动法)
编写肯定形式的布尔表达式
多重否定增加阅读难度,可以使用狄摩根定理简化否定的布尔判断(!A||!B)=!(A&B)
用括号使布尔表达式更清晰
理解布尔表达式是如何求值的
感想:C++采用短路的方式计算布尔表达式的值,所以平时才能方便的这样写: if((pContainer != nullptr) & (pContainer->hasChildren)) {}在第一个条件为是的时候才会执行第二个语句,如果没有这个机制,执行第二个语句的时候可能因为访问空指针崩溃
按照数轴的顺序编写数值表达式
从左到右,从小大大。“min < x & x < max” 比 "x > min & max > x"更容易理解。
与0比较的指导原则
编程语言把0用做很多目的。它是一个数值,是字符串中的零终止符,是空 指针的取值,是枚举的第一个元素的取值,是逻辑表达式中的 false既然它有 如此多的用途,因此你写的代码中就应该彰显0的特定用法。
- 隐式地比较逻辑变量
- 把数和0相比较(表达变量数据类型)
- 把指针与NULL相比较(C++11推荐使用nullptr)
感想:比较的过程也要考虑数据类型,不能偷懒都按数字去比较,尽可能给读代码的人更多确定的信息
布尔表达式的常见问题
- C家族语言中,应该把常量放在比较的左端(通过编译器报错来避免误把“==”写成“=”)
感想:布尔判断是开发者最常用的控制方式,事实上工作中遇到的相当多的bug是由布尔判断出错造成的。优化判断语句的可读性对于代码的阅读和维护都有很大的帮助。
19.2复合语句(语句块)
“复合语句”或“语句块”指的是一组语句,该组语句被视为一条单一的语 句,用于控制程序流。
- 把括号对一起写出(先写好一堆括号再填充内容,避免落下)
- 用括号来把条件表达清楚(if后面执行语句在括号里,哪怕只有一句)
19.3空语句
C++中一个“;”就是一个空语句,可以用来占位,如while后必须有一条语句,可以用“;”。
- 小心使用空语句
- 为空语句创建一个 DoNothing()预处理宏或者内联函数(说明确实什么都不想做)
- 考虑如果换用一个非空的循环体,是否会让代码更清晰
感想:我能想到的场景:读文本文件,一开始希望先跳过前面的几行,通过循环执行n次的read使文件指针指向希望行数。
19.4驯服危险的深层嵌套
软件首要技术使,命管理复杂度,很少有人能够理解超过3层的if嵌套 避免深层嵌套的方法:
- 通过重复检测条件中的某一部分来简化嵌套的if语句
- 用 break块来简化嵌套if
- 把嵌套if转换成一组if-then-else语句
- 把嵌套if转换成case语句
- 把深层嵌套的代码抽取出来放进单独的子程序
- 使用一种更面向对象的方法(利用继承和多态)
- 重新设计深层嵌套的代码
- 用状态变量重写代码
- 用防卫子句来退出子程序,从而使代码的主要路径更为清晰
- 使用异常
感想:深层的嵌套写起来方便,读起来难受,代码读的次数要比写的次数多,所以写的时候要更多的考虑读者的感受。另一方面,深层的嵌套会影响自动化的代码覆盖率,比较内层的条件判断很难被覆盖。
19.5编程基础:结构化编程
一个应用程序应该只采用一些单入单出的控制结构,不会做不可预知的随便跳转
结构化编程的三个组成部分
- 顺序
- 选择
- 迭代 结构化编程的中心论点是,任何一种控制流都可以由顺序、选择和迭代这三种结构生成
19.6控制结构与复杂度
控制流是影响复杂度的最大的因素之一,永远也不可能有能力应对如此巨大的复杂度,因此只有尽可能地采取措施来降低复杂度
读后感:非常喜欢本章的一句话:“程序员有时候会倾向于使用那些更方便的语
言结构,但是编程这一领域却似乎更多地是在对我们能用编程语言做些什么加以
限制的过程中取得发展的。”。对于我而言,读代码的时间一般都比写代码的时间还要长,改bug、扩展功能都需要对原油代码充分的理解。结构化编程的思想将结构限制在了顺序、选择和迭代三种,使得复杂度得以被控制,阅读代码比goto满天飞的时代要轻松很多,减少了花样写法的偶然复杂度,有更多的精力去应对业务逻辑带来的实际复杂度。
第二十章 软件质量概述
20.1软件质量的特性
20.2改进软件质量的技术
20.3质量保证技术的相对效能
20.4何时进行质量保证
20.5软件指令的普遍原理
第二十一章 协同构建
21.1协同开发实践概述
21.2结对编程
21.3正式审查
21.4其他类型的协同开发实践
第二十二章 开发者测试
测试是最常见的改善质量的活动
- 单元测试(Unit testing)
- 组件测试( Component testing)
- 集成测试(Integration testing)
- 回归测试(Regression testing)
- 系统测试(System testing) 测试也分为黑盒测试和白盒测试
22.1开发者测试在软件质量中的角色
- 对于任何软件质量规划来说,测试都是一个重要的组成部分,每个独立的测试步骤通常只能够找到现有错误的50%不到
- 测试的目标是找出错误,与其他开发活动的目标背道而驰
- 测试永远不可能彻底证明程序中没有错误
- 测试本身并不能改善软件的质量
- 测试时要求你假设会在代码里面找到错误(自我实现)
- 开发者测试应该占整个项目时间的8%~25%
怎样利用开发者测试的结果?
- 评估正在开发的产品的可靠性
- 用于指导对软件的修正
- 帮助于你归纳出程序中最常见错误的类型,帮助去选择适当的培训课程、指引今后的技术复查活动,设计未来的测试用例。
构建中测试
白盒测试的好处:除了观察它的输入输出,还要察看内部的源代码。如果知道盒子里面的情况,可以更彻底地测试这个类 黑盒测试的好处:能测出开发过程中的盲点
- 独立运行测试相较于继承后的测试能够更简单的发现问题
感想:1. 有的时候开发人员与测是人员沟通起来会觉得测试的场景难以想象,实际上这就是黑盒测试带来的好处,能够找出开发过程中的盲点2. 子程序、类甚至是子模块在集成之前的测试通常会更加容易的发现问题,在集成之前,这部分代码 不受外部条件的影响,条件相对简单,更容易观察。另一方面没有连接界面之前测试用例的运行效率(接口测试)会高很多(相较于Ui自动化)。对于开发者而言,独立运行的部分编译和调试的速度都会快很多,能够快速试错,更容易的修正一个问题。但是独立运行需要剥离开外部的环境依赖,需要从设计时就考虑清楚。
22.2开发者测试的推荐方法
- 对每一项相关的需求进行测试,以确保需求都已经被实现
- 对每一个相关的设计关注点进行测试,以确保设计已经被实现
- 用基础测试( basis testing)来扩充针对需求和设计的详细测试用例
- 增加数据流测试(data–flow test),然后补充其他所需的测试用例
- 使用一个检查表,其中记录着你在本项目迄今为止所犯的,以及在过去的项目中所犯的错误类型
在设计产品的时候设计测试用例,这样可以帮助避免在需求和设计中产生错 误,修正这些错误的代价往往比修正编码错误更昂贵越早修复这些缺陷,成本 就越低,因此,要尽可能早地对测试进行规划并找出缺陷。
测试先行还是测试后行(答案:测试先行)
- 首先写测试用例可以将从引入缺陷到发现并排除缺陷之间的时间缩减至最短
- 在开始写代码之前先写测试用例,并不比之后再写要多花功夫,只是调整了一下测试用例编写活动的工作顺序而已
- 假如你首先编写测试用例,那么你将可以更早发现缺陷,同时也更容易修正它们
- 首先编写测试用例,将迫使你在开始写代码之前至少思考一下需求和设计
- 先编写测试用例,能更早地把需求上的问题暴露出来
开发者测试的局限性
- 开发者测试倾向于“干净测试”,开发人员往往去做一些检验代码能否工作的测试(干净测试, clean tests),而不是做所有可能让代码失效的测试(肮脏测试,dirty tests),肮脏应该更多
- 开发者测试对覆盖率有过于乐观的估计
- 开发者测试往往会忽略一些更复杂的测试覆盖率类型(所有分支覆盖) 开发者测试是有价值的,但对于提供足够的质量保证而言,仅仅进行开发者测试是不够的。我们需要补充其他的实践,包括独立测试(independent testing)技术以及协同构建collaborative construction)技术。
22.3测试技巧锦囊
通过测试来证明程序的正确性是不可能的呢
不完整的测试
需要集中注意力挑选出那些能告诉你不同答案的测试用例,而不选出一堆总是告诉你相同答案的测试用例。
结构化的基础测试
你需要去测试程序中的每一条语句至少一次,如果语句是一个逻辑语句,例如if语句或者 while语句,那么你就需要根据if者中表达式的复杂程度 来修改测试,以确保这个语句完全经过了测试,要确保你已经覆盖了所有的基础情况,这种测试能够向你保证所有的代码都得到执行但它并不能说明数据的变化情况。
数据流测试
数据使用的出错几率至少不亚于控制流,数据的状态应该按照“已定义”->“已使用”->“已销毁”的顺序变化,重复的定义和销毁、错乱的顺序都会引起bug
等价类划分
一个好的测试用例应该覆盖可输入数据中的很大一部分,如果两个用例能揭示的错误完全相同,那么只要一个就够了。
猜测错误
在猜测程序会在哪里出错使用软件隐的基础之上建立测试用例,基于直觉或者过去的经
边界值分析
可以发现分析off-by-one错误
复合边界值
当边界条件涉及到互相关联的多个变量的时,例如,两个变量相乘,它们的值都是大的正数
几类坏数据
- 数据太少(没有数据)
- 太多的数据
- 错误的数据情况(无效数据)
- 长度错误的数据
- 未初始化的数据
eg:薪水是负数、人数是负数
几类好数据
测试用例需要测试正常的数据是否能够正常工作,包括:
- 正常的情形大路正中间,所期望的值
- 最小的正常局面
- 最大的正常局面
- 与旧数据的兼容性
采用容易手工检查的测试用例
手工计算过程犯错的几率跟你在程序中发现错误的几率差不多,会增加测试的难度,选择容易手动计算的情况,也能帮助发现同样的错误
22.4典型错误
哪些类包含最多的错误
- 80%的错误存在于项目20%的类或者子程序当中
- 50%的错误被发现存在于项目5%的类当中
- 项目中20%的子程序占用了80%的开发成本
- 提高质量就能缩短开发周期,同时降低开发成本(避免卷入到那些烦人的子程序中,序,就是那些极那么你就可以省下近80%的成本从而节约一大段开发时间)
错误的分类
- 大多数错误的影响范围是相当有限的(85%的错误可以在修改不超过一个子程序的范围内得以修正)
- 许多错误发生在构建的范畴之外(缺乏应用领域知识,频繁变动且相互矛盾的需求,以及沟通和协调的失效)
- 大多数的构建期错误是编程人员的失误造成的(程序员造成的占95%,系统2%,其他软件2%,硬件1%)
- 笔误(拼写错误)是一个常见的问题根源(占36%)
- 错误理解设计(占16%-19%)
- 大多数错误都很容易修正(大约85%的错误可以在几个小时的时间内修正)
不完善的构建过程引发错误所占的比例
- 在小型项目里面, 75%的错误由编码造成,10%的错误源自需求,以及15%源自设计
- 无论项目规模如何,构建缺陷至少占了总缺陷的35%
你期望能发现多少错误
软件质量的普遍原则:开发高质量的软件,比开发低质量软件然后修正的成本要低廉
测试本身的错误
开发人员在编写测试用例,在没有经过仔细地设计和构建的前提下,测试用例可能包含同被测代码同样多,甚至是更多的错误,应对建议:
- 谨慎的开发测试用例并检查
- 开发软件的时候就要计划好测试用例
- 保留你的测试用例
- 将单元测试纳入测试框架
22.5测试支持工具
为测试各个类构造脚手架
在软件中搭建脚手架只有一个目的,那就是更方便地测试代码。 脚手架可以实现:
- 立刻返回控制权,不做任何动作
- 检查传给它的数据;
- 输出诊断信息,可能是显示所传入的参数,或者是将信息记录到日志文件中;
- 返回用户交互输入的值;
- 不管输入是什么都返回一个标准的响应;
- 消耗原本分配给真实对象或者真实子程序的时钟周期
- 以某种慢速、臃肿、简单或粗略的方式实现真实对象或者子程序的功能。
另一种脚手架类型,是调用待测试的真实函数的伪造函数。这种脚手架称为 “驱动函数”,有时也称为“测试夹具”。这种脚手架可以:
- 用固定的一组输入调用对象;
- 提示用户输入,然后根据输入去调用对象;
- 从命令行取得参数(如果操作系统支持)去调用对象
- 从文件中读入参数,并据此调用对象;
- 用一集预先定义的输入数据去多次调用有关的对象。
感想:目前在做的造价云适配任务里面就有一个前人搭好的脚手架GMPCloudServiceMoc,能够模拟造价云的返回结果,起作用是:不管输入是什么都返回一个标准的响应,使得测试过程变得更加便利
Diff Tools
一个能自动对比实际输出与期望输出的工具,便于进行回归测试(这个在重构的时候应该很有用,保证重构后的程序关键输出不变)
测试数据生成器
作者在开发加密解密程序的时候使用了这个技术,覆盖到了意想不到的场景,而且可以不消耗人力持续测试
覆盖率监视器
用来检测现有测试用例是否能够彻底地对代码进行测试,那些没有测量代码覆盖率的测试,通常只测试到了大约50%到60%的代码
数据记录器/日志记录器
略
符号调试器
完全按照计算机的方式来演绎代码的执行(这个不应该算是下一章调试的内容么)
系统干扰器
发现忘记初始化的问题
- 内存填充
- 内存抖动
- 选择性内存失败
- 内存访问检查
错误数据库
指的是目前在使用的jira
22.6改善测试过程
有计划的测试
重新测试(回归测试)
自动化测试
- 自动化测试发生错误的几率比手动测试要小。
- 一旦你把一个测试自动化了,那么你只需稍下功夫,就很容易在项目的剩余部分继续实施自动化。
- 如果测试是自动进行的,那么就可以频繁地运行
- 自动化测试可以提高问题刚产生就被发现的可能性
- 为大规模代码修改提供了一张安全网
22.7保留测试记录
指的是目前在使用的jira,通过记录数据更宏观的观察项目的趋势。
读后感:目前尝试过单元测试、接口测试、记录日志等自测手段,有了测试手段之后修改或者重构就更加安心,原来可改可不改的代码就有更足够的底气去重构了,否则有可能为了遵从良知拉着整个团队下水。在平台更深刻地感觉到提早发现问题的好处,一个接口及得错误一旦发布给产品,产品发布给用户,修改起来需要适配,还可能需要处理用户数据的升级,本来在发布之前发现可能需要1个小时就能够改正的错误,发现的晚甚至可能消耗一周。
第二十三章 调试
调试是确定错误根本原因并纠正此错误的过程
23.1调试概述
调试在软件质量中所扮演的角色
同测试一样,调试本身并不是改进代码质量的方法,而是诊断代码缺陷的一种方法。软件的质量必须从开始逐步建立开发高质量软件产品的最佳途径是精确描述需求,完善设计,并使用高质量的代码编写规范。调试只是迫不得已时采用的手段。
调试效率的巨大差异
经验丰富的程序员找出缺陷所用的时间大约只是缺乏经验的程序员们的1/20
让你有所收获的缺陷
程序中的错误为你提供了学习很多东西的绝好机会:
- 理解你正在编写的程序
- 明确你犯了哪种类型的错误
- 从代码阅读者的角度分析代码质量
- 审视自己解决问题的方法
- 审视自己修正缺陷的方法
调试其实是一片极其富饶的土地,它孕育着你进步的种子
一种效率低下的调试方法
错误的调试方法:猜测、不理解问题本身、用最唾手可得的方式修正错误、迷信式调试
23.2寻找缺陷
调试包括了寻找缺陷和修正缺陷。寻找缺陷——并且理解缺陷通常占到了整个调试工作的90%
科学的调试方法
一种寻找缺陷的有效方法:
- 将错误状态稳定下来(bug要可重复,可稳定复现)
- 确定错误的来源 a. 收集产生缺陷的相关数据
b. 分析所收集的数据,并构造对缺陷的假设
c. 确定怎样去证实或证伪这个假设,可以对程序进行测试或是通过检查代码
d. 对假设做出最终结论 - 修补缺陷
- 对所修补的地方进行测试
- 查找是否还有类似的错误
寻找缺陷的一些小建议
- 在构造假设时考虑所有的可用数据
- 提炼产生错误的测试用例
- 自己的单元测试族中测试代码
- 采用多种不同的方法重现错误
- 用更多的数据生成更多的假设
- 利用否定性测试用例的结果
- 对可能的假设尝试头脑风暴
- 把需要尝试的事情逐条列出
- 缩小嫌疑代码的范围
- 对之前出现过缺陷的类和子程序保持警惕
- 检查最近修改过的代码
- 扩展嫌疑代码的范围
- 增量式集成
- 同其他人讨论问题(小黄鸭调试法!组织语言的时候就理清了,最近改的一个bug就用的这个)
- 抛开问题,休息一下
蛮力调试
快速尝试可能解决,蛮力调试(包括直接重写)往往会奏效。花费2个小时去调试一个30分钟就能写出来的代码就是坚持快速尝试的后果,有的时候蛮力调试更有效。
语法错误
- 不要过分信任编译器信息中的行号
- 不要迷信编译器信息
- 不要轻信编译器的第二条信息(先看第一条!)
- 分而治之
- 找出没有配对的注释或者引号
23.3修正缺陷
- 在动手之前先要理解问题
- 理解程序本身,而不仅仅是问题
- 验证对错误的分析
- 放松一下(压力大匆忙提交可能会引入新的问题,这条对于系统测试凌晨改bug很适用,欲速则不达)
- 保存最初的源代码(方便回滚)
- 治本,而不是治标
- 修改代码时一定要有恰当的理由
- 一次只做一个改动
- 检查自己的改动
- 增加能暴露问题的单元测试
- 搜索类似的缺陷
23.4调试中的心理因素
对熟悉东西的先入为主的判断可能是错误的,需要保持警惕
23.5调试工具
- 源代码比较工具
- 编译器的警告消息
- 增强的语法检查和逻辑检查
- 执行性能剖测器
- 测试框架/脚手架
- 调试器
读后感:调试技巧能够大幅提高定位问题的速度,我在第一次知道调试时左侧的箭头可以前后拖动的时候感觉打开了新世界的大门,从此很多场景不用因为一不小心错过了重新操作一遍复现路径了,类似的还有条件断点。
第二十四章 重构
在整个项目生命周期中代码都会不断地演化
24.1软件演化的类型
- 程序构建过程中的演化:系统处于高度动态阶段,出现错误的代价较小
- 维护过程中的修改
软件演化是无法避免且具有重要意义的现象,演化一开始就充满危险,但同时也是使你的软件开发接近完美的天赐良机,软件演化的基本准则就是,演化应当提升程序的内在质量。
24.2重构简介
在不改变软件外部行为的前提下,对其内部结构进行改变,使之更容易理解 并便于修改
重构的理由
- 代码重复
感想:“复制粘贴即设计之谬”,修改代码时不得不记得同时修改多处,后人维护的时候一旦忘记就会引入bug,之前做过一个小需求,修改软件显示的两个字,原本只需要改一个字符串,结果全局好多处都在通过这个字符串做条件判断,这两个字在软件中应用场景很多,又不能通过搜索替换的凡是批量处理。实际上我们有很多方式能够避免重复,子程序、继承、模板 甚至动态库静态库。
- 子程序太长
感想:模块化,封成更短的,功能单一的子程序,之前写过一个600行的初始化UI的子程序,自己都不想看
- 循环太长或者嵌套太深(循环内部的复杂代码常常具备转换为子程序的潜质)
- 类的内聚性太差(某个类大包大揽了许多彼此无关的任务)
- 类的接口的抽象层次不一致
- 参数表中参数太多(参数是否应该封为结构体)
- 类的内部修改往往局限于某个部分(类是否能够拆分为多个类)
- 需要对多个类进行并行修改(类是否可以重新组织)
- 对继承体系的并行修改
- 需要对多个case语句进行并行修改(采用继承也许更合适)
- 相关的数据项只是被放在一起,没有组织到类中
- 成员函数更多地使用了其他类的功能,而非自身类的(这个函数也许不应该属于这个类)
- 过多使用基本数据类型
- 一个类不做什么事(重构之后没清理,后面的程序员会困惑)
- 一连串传递流浪数据的子程序
- 中间人对象什么也不干
- 某个类同其他类关系过于密切
- 子程序的命名太差
- 数据成员被设置为公用
- 派生类仅仅使用了基类的一小部分成员函数(是否应该把派生类改为基类的数据成员)
- 用注释来掩饰拙劣的代码
- 使用了全局变量
- 在子程序调用前使用设置代码,调用后使用收尾代码(接口的抽象不合理)
- 程序包含的某些代码似乎在将来某个时候才会被用到(不应该超前设计,尽可能将满足当前需求的代码清晰直白地表现出来,使未来的程序员理解这些代码到底完成了什么功能,没有完成什么功能)
感想:编码很难在一开始就想清楚所有的事情,随着编码的进行,开发者对事情逻辑的理解会逐渐深入;
或者完成编码后的一段时间回头看可能会有新的理解;
或者一段程序经过太多补丁处理,已经非常难以理解了,这些都是可以是重构的理由,
重构的目的一定是要提升代码的质量。
24.3特定的重构
数据级的重构
- 用具名常量来代替神秘数值
- 用更明确或更具信息量的名字来重命名变量
- 将表达式内联化
- 用函数来代替表达式
- 引入中间变量
- 将多用途变量转换为多个单一用途变量
- 使用局部变量实现局部用途而不是使用参数
- 将基础数据类型转化为类
- 将一组类型码转化为类或是枚举类型
- 将一组类型码转化为含派生类的类
- 将数组转化为对象
- 封装群集
- 用数据类替代传统记录
语句级的重构
- 分解布尔表达式
- 将复杂的的布尔表达式转换为命名精确的布尔函数
- 将条件语句中不同部分中的重复代码合并
- 使用 break或 return而不是循环控制变量
- 在嵌套的if-thenlse语句中一旦知道结果就立刻退出,而不是仅仅赋一个返回值
- 用多态来代替条件语句(尤其是重复的case语句)
- 创建并使用空对象代替对空值的检测
子程序级的重构
- 提取子程序
- 将子程序代码内联化
- 将冗长的子程序转化为类
- 用简单的算法替代复杂算法
- 增加参数
- 减少参数
- 将查询操作同修改操作区分开来
- 合并功能相似的子程序,并用参数来区分他们
- 通过传递不同的参数使子程序体现不同的功能
- 传递整个对象而非特定成员
- 传递特定成员而非整个对象
- 封装向下转型操作
类实现的重构
- 将值对象改为引用对象
- 将引用对象改为值对象
- 用数据初始化来代替虚函数
- 改变成员函数或数据的位置
- 将特定代码提出生成派生类
- 将相似的代码合并起来放到基类中
类接口的重构
- 将某成员子程序放到另一个类中
- 将一个类转化成两个
- 删除某个类
- 隐藏委托关系
- 去掉中间人
- 用委托代替继承
- 用继承代替委托
- 引入外部子程序
- 引入扩展类
- 封装暴露在外的成员变量
- 对不能修改的成员去掉set()函数
- 隐藏在类的外部不会使用的成员函数
- 封装不会用到的成员函数
- 如果基类和派生类的代码实现相似,将二者合并
系统级的重构
- 为无法控制的数据创建明确的索引源
- 将单向类联系改为双向类联系
- 将双向的类联系改为单向类联系
- 使用工厂函数而非简单的构造函数
- 用异常代替错误代码,或者反其道而行之
24.4安全的重构
- 重构的步伐请小些
- 同一时间只做一项重构
- 把要做的事情一条条列出来
- 利用编译器警告信息
- 重新测试
- 增加测试用例
- 检查对代码的修改
- 根据重构风险级别来调整重构方法
- 避免用重构代替重写
24.5重构策略
感想:
真实的世界是复杂的,我们的代码经常需要处理复杂的业务规则而混乱,
我们通过接口将复杂的因素处理成理想的情况,在通过清晰的逻辑去处理理想的情况,
在重构的过程中尽可能扩大逻辑清晰的理想情况的代码的边界,
减少对接复杂业务逻辑的混乱的代码。
读后感:经过最近的任务,更深刻的理解了重构的代价,底层平台的重构,中台需要适配,产品有也需要适配,如果产品已经发版,还需要考虑升级,可能一个非常小的接口改动,都需要(配置产品环境,适配代码,打版,测试)*n的工作量。但是如果一个平台因为这些负担停止了代码的演化则无法持续交付价值,所以需要在不被负担拖垮的前提下重构,开发阶段重构的成本是最低的,所以需要尽早发现问题。
第二十五章 代码调整策略
本章讨论程序性能够调整-效率与程序的可读性和可维护性取舍的策略
25.1 性能概述
质量特性和性能
相对于代码质量,用户更关心的是程序的外在特性。对用户来说,程序员按时交付软件,提供一个清爽的用户界面,避免系统死机常常比程序的性能更为重要。
性能和代码调整
程序需求
在花费时间处理一个性能问题之前,请想清楚你的确是在解决一个确实需要解决的问题。
程序的设计
在架构上考虑程序的的速度与资源占用目标
类和子程序的设计
程序同操作系统的交互
代码编译
优秀的编译器能将清晰的高级语言代码转换为经过优化后的机器码。如果选 择了合适的编译器,你可能无须再考虑如何进一步优化程序的运行速度,直接就 能获得满意的程序性能了。(往往容易被人忽视)
硬件
代码调整
以上6个层次的调整都会直接影响程序的性能,而且是累乘关系,
出现性能问题,往往第一想到的是代码调整,可以多考虑一下其他层次,
有一次切SDK爆出一堆性能问题,结果只是打SDK没关优化
个人觉得性能分析考虑用接口测试覆盖更合适,从本节可以看出,影响程序性能的因素非常多,
通过接口测试可以屏蔽部分干扰,让性能测试的结果更准确,更好地帮助开发者发现和修改问题
25.2 代码调整简介
不是改进性能的最为有效的方法,不是最方便的改善性能的方法,不是成本最低的方法,但是可以给开发者带来成就感 法则:
- 小部分子程序占用了程序大部分的执行时间,
- 当某个程序绝大多数的代码都是由诸如 Python这样的解释型语言编写时,程序员同样应该把其中最关键的部分用C这样的编译型语言重写
- 程序员们首先应该实现程序应该具备的所有功能,然后再使程序臻于完美
- 在高级语言中,减少代码的行数与运行速度没有直接关系
- 程序员应当使用高质量的设计,把程序编写正确。使之模块化并易于修改,让后期的维护工作变得很容易。在程序经完成并正确之后,再去检查系统的性能。如果程序运行迟钝,那么再设法让它更快更小除非你对需要完成的工作
- 每一种编译器都拥有和别的编译器所不同的优势和弱点
- 与那些充满技巧的代码相比,编译器的优化功能对那些平铺直叙的代码更见效
25.3 蜜糖和哥斯拉
这一节介绍加快程序运行速度和优化体积的方法
- 输入/输出操作 (访问内存比访问文件快上1000倍)
- 引发操作系统交换内存页面的运算会比在内存同一页中进行的运算慢许多
- 调用系统子程序的代价常常是十分可观的
- 解释型语言(作者给的参考数据Python速度比C++慢100倍)
- 程序性能的终极麻烦就是代码中的错误(没有去掉调试代码、忘了释放内存、数据库表设计失误、轮询并不存在的设备直至超时)
25.4 性能测量
经验对性能优化也没有太大的帮助,需要通过测量来证明,有的时候看起来低效的代码经过编译器优化可以一样高效且更易懂
25.5 反复调整
大部分优化方法单独看起来都收效甚微,但累计起来,效果是惊人的
25.6 代码调整方法总结
- 用设计良好的代码来开发软件,从而使程序易于理解和修改。
- 如果程序性能很差。 a. 保存代码的可运行版本,这样你才能回到“最近的已知正常状态”;
b. 对系统进行分析测量,找出热点;
c. 判断性能拙劣是否源于设计、数据类型或算法上的缺陷,确定是否应该
做代码调整,如果不是,请跳回到第一步;
d. 对步骤c中所确定的瓶颈代码进行调整;
e. 每次调整后都对性能提升进行测量;
f. 如果调整没有改进代码的性能,就恢复到步骤a保存的代码(通常而言,超过一半的调整尝试都只能稍微改善性能甚至造成性能恶化) - 重复步骤2
读后感:通过这一章的阅读让我意识到了平时非常无感的编译器优化的作用与重要性,编译器优化能够带来40%以上的效率提升,能够帮助我们实现代码可读性与性能的双丰收同时作者给出了几个有意思的结论:1. 经验对性能优化也没有太大的帮助2. 代码调整既不是最为有效、也不是最方便,还不是成本最低的提高效率的方法,只是开发者比较青睐3. 极少部分代码运行占用了程序大部分时间,找出并优化这一下部分往往能够解决实际的问题4. 在不是对效率要求特别敏感的时候,先写好程序,再考虑运行效率即可
第二十六章 代码调整技术
以牺牲程序内部结构的某些特性来换取更高的性能
26.1逻辑
在知道答案后停止判断
- 多个条件判断的时候,其中一个条件已经能够决定最重的个结果,不必计算后续条件,
- 循环或者子程序已经得到了想要的结果的时候无需继续运行后续计算
按照出现频率来调整判断顺序
安排判断的顺序,让运行最快和判断结果最有可能为真的判断首先被执行
相似逻辑结构之间的性能比较
通过测量结果选择更高效的方法
用查询表替代复杂表达式
使用惰性求值
26.2循环
将判断外提
合并多个循环
展开
尽可能减少在循环内部做的工作
哨兵值
找到需要的结果后直接退出循环
把最忙的循环放在最内层
削减强度
削减强度意味着用多次轻量级运算来代替一次代价高昂的运算
26.3数据变换
使用整型数而不是浮点数
数组维度尽可能少
尽可能减少数组引用
如果需要多次访问同一个值,用临时变量接一下
使用辅助索引
使用缓存机制
这种方式用空间换时间,而且会增加缓存内容维护的工作过量,恰当地使用可以避免重复的读写和计算从而给程序提升效率
26.4表达式
利用代数恒等式
如sqrt(x) < sqrt(y) 可以用更高效的 x < y 代替
削弱运算强度
编译期初始化
编码时计算结果并存到常量中
小心系统函数
26.5子程序
将子程序重写为内联
26.6用低级语言重写代码
略
26.7变得越多,事情反而越没变
代码调整无可避免地为性能改善的良好愿望而付出复杂性、可读性、简单性、 可维护性方面的代价。由于每一次调整后需要对性能进行重新评估,代码调整还 引入了巨额的管理维护开销。
本章介绍的做法与前面章节的标准相反,甚至给出了一些作者不推荐的结果。
性能优化有的时候会牺牲代码的可读性,破坏一些封装,
不应该在在不必要和没有证明确确实有效之前使用,
事实上至今为止我还没有接触到需要牺牲可读性来换性能的工作,
遇到的性能问题多数出在不良的设计。
作者再次强调测量效率比经验更为重要