关于编码的一切 ——《代码大全》 下
软件质量
在了解最基本的变量和语句组织后,这一部分围绕提高软件质量展开。
概述
软件的外在质量特性体现在:
- 正确性
- 可用性
- 效率
- 可靠性
- 健壮性
- 适应性
- 完整性
- 精确性
内在质量特性体现在:
- 可维护性
- 可扩展性
- 可移植性
- 可读性
- 可测试性
- 可理解性
而改善软件质量可以有很多技术:
- 确定目标
- 测试策略
- 非正式走查
- 正式技术复查
- 外部审查
开发过程中可以通过量化结果、制作原型、控制变更等手段提升质量。
- 不同的质量保障手段效率各不相同,多种缺陷检测办法结合、人工和计算机检测结合,效果会比单打独斗要好;然而没有任何一种错误检测办法可以解决全部问题
- 越早引入软件的问题,修正代价越大,尽早开始代码质量保障工作
- 需求或架构上的错误相比编码阶段会带来更广泛的影响
- 改善质量可以降低开发成本
协同构建
协同构建即在同行的帮助下完成代码构建。方式包括但不限于结对编程、正式检查、非正式技术复查、文档阅读等等。结对编程和技术性复查可以缩短开发周期,排查出更多错误,减少代码的维护时间。同时,同行间的协同构建也有助于快速提升公司开发者的开发水平。
- 结对编程
- 结对编程中,一名程序员敲代码,另外一名注意有没有出现错误,以及考虑策略性问题
- 成功秘诀
- 事先确定编程规范,避免琐碎争吵
- 不编程的成员不能变成旁观者
- 不需要在简单问题上使用结对编程
- 灵活对结对成员轮换,让大家熟悉不同系统
- 避免新手组队
- 正式检查
- 一种特殊的复查,与会主持人负责组织事宜,按计划、概述、准备、与会人(非作者)阐述代码、详查报告、跟进等步骤推进
- 针对代码而非作者
- 最终由作者负责如何处理缺陷
- 走查
- 走查是形式和流程都比较宽松的代码复查方式,时间较短,重点也在检查错误而非修正它们
- 代码阅读
- 类似Code Review,关注点主要在代码本身,而非会议
- 公开演示:类似showcase
开发者测试
测试分单元测试、组件测试、集成测试、回归测试、系统测试,前两部分通常由开发者进行,后三部分由专门的测试人员进行。测试按是否了解测试对象内部工作原理也可以分为黑盒测试和白盒测试。最后要注意,测试 ≠ 调试。
对于开发者而言,测试天生有些特别:
- 测试的目标是找出错误而非实现功能
- 测试绝不可能彻底证明程序里没有错误
- 测试无法改善软件质量,它本身只是一个指示器
开发者测试在整个项目时间中大概占8% - 25%,在测试时有一些tips:
- 写代码前先写测试用例,不会比后写多花功夫
- 不止进行“干净测试”,也要考虑“肮脏测试”
- 很容易对测试覆盖率过度乐观
在现实世界中,要穷尽所有可能的输入是不可能的,测试不可能完全,有些方法会起到作用:
- 使用路径数计算计算用例的最少数目,它可以保证所有代码的执行
- 通过子程序时,开始记1
- 遇到
if
、while
、repeat
、for
、and
、or
或等价物时,+1 - 遇到每一个
case
语句,+1;没有缺省分支时,再+1
- 数据流测试可以覆盖到数据的变化情况
- 数据有已定义、已使用、已销毁状态
- 子程序控制流有已进入、已退出状态
- 测试所有的已定义、已定义-已使用组合,注意其他的组合顺序
- 一个好的测试用例可以覆盖可数据数据的一大部分
- 用启发式方法去猜测错误
- 留意边界值,如数组边界的off-by-one错误
- 考察好数据和坏数据
- 好数据:期望输入、最小正常值、最大正常值、旧数据兼容性
- 坏数据:没有数据、过多数据、无效数据、长度错误、未初始化
- 使用容易验证结果的测试数据
关于错误,也有一些先验的规律:
- 符合八二法则、绝大多数错误通常和几个具有严重缺陷的子程序有关
- 大多数错误影响范围有限
- 大多数构建错误来自编程人员的错误,多从自身想问题
- 笔误是一个常见的问题根源
- 错误理解需求也是常见原因
- 大多数的错误都较易修正
- 业界经验是,平均1000行代码发现1-25个错误,发布产品大概是1000行代码0.5个,
- 同样留意,测试用例本身是否有误
在当前的编程环境和工作条件下,容易找到很多测试框架,它们会包含脚手架、diff工具、测试数据生成器、覆盖率监测、日志记录、系统干扰器等。另外,自动化测试、测试问题复盘等对测试质量也有提升帮助。
调试
调试(debug)是寻找错误根本原因和纠正错误的过程。它和测试一样,本身不是提升代码质量的方法,而是诊断代码缺陷的手段。
- 调试の误区
- 纯靠猜测找到问题所在
- 不去花时间理解程序和问题
- 暂时性的掩盖问题
- 把问题推给硬件,而不思考是不是出在自己身上
- 寻找缺陷的步骤
- 让错误状态稳定下来 => 稳定复现
- 收集相关数据,构造错误原因假说
- 通过测试或检查代码证实或证伪假说
- 一个无法稳定重现的问题,可能和初始化或和时间有关系
- 寻找缺陷的tips
- 构建缺陷假说时,要能合理解释所有测试用例
- 定位缺陷困难时,及时补充更多的测试用例复现问题,用多视图的方式盲人摸象定位缺陷
- 测试用例过于发散时,及时用用例否定一些假说
- 二分法缩小嫌疑范围
- 检查最近修改或最近出过错的代码
- 小黄鸭调试法
- 暂时休息一下
- 蛮力调试
- 抛弃有问题的代码,从头设计和编码
- 抛弃整个程序,从头开始设计和编码
- 不放过任何一个编译器错误
- 手动遍历所有的循环
- 更换编译环境或比那一起
- 持续自动化测试
- 显示代码中所有的打印日志信息
- 给启发式调试法一个deadline
- 调试中避免心理惯性:人们总期望一个新现象类似他们见过的某种现象
- 修正缺陷
- 修正问题前确保已经很好地理解了
- 理解程序而不仅是问题
- 验证对错误的分析或理解
- 保留最初的源代码
- 治本,而不要治标
- 一次只改一个地方
- 搜寻代码中还有没有类似的缺陷
- 调试工具
- 源代码diff
- 编译警告信息
- lint和代码自动修正
- 性能剖测(profile)
- 测试脚手架
- 调试器
重构
更多重构介绍可以参考这篇blog重构 —— 代码的实用性与艺术性
重构即在不改变软件外部行为的基础上,改变其内部结构。即便是管理完善的项目,每个月都会发生需求变化,稳定不变的需求是个童话。
代码出现以下“坏味道”(smell)时,代表需要重构了。
- 代码重复
- 子程序冗长
- 嵌套过深
- 内聚性差
- 参数列表过长
- 类和继承关系不合理
- 基本数据类型过多
- “流浪数据”传递
- 无所事事的类
- 命名不当
- 难理解的注释
- 全局变量
- 子程序需要前置或后置处理
- 过早设计或过度设计
- …
重构分级别有下面一些手段
- 数据级
- 具名常量
- 更可读的变量
- 函数替代表达式
- 中间变量
- 减少重复使用变量
- 类型码转成类或枚举类型
- 类封装
- 语句级
- 分解布尔表达式
- 用可读名字的布尔函数替代布尔表达式
- 合并条件语句中的重复代码片段
break
或return
替代循环控制变量- 多态替换条件语句
- null对象替代空值检测
- 子程序
- 内莲花
- 提炼子程序
- 转化为类
- 增/删参数
- 合并/拆分子程序
- 读写操作分离
- 传递成员/类
- 类实现
- 值/引用对象转化
- 成员函数/成员数据位置移动
- 相似代码提炼到基类
- 差异代码拆分到派生类
- 类接口
- 类拆分/合并
- 删除类
- 去掉中间人
- 继承替代委托
- 委托替代继承
- 引入外部成员函数
- 引入扩展类
- 封装不使用的成员函数
- 系统级
- 为无法控制的数据创建索引源
- 工厂模式
- 异常/错误处理代码选型
要想让重构不影响日常功能开发,需要考虑
- 有一个代码版本管理工具
- 重构步伐小一点
- 同一时间只做一个重构
- 重新测试
- 增加测试用例
- 检查代码更改
- 根据重构风险选择重构方法
- 不要把重构当成糟糕设计的挡箭牌
- 避免用重构代替重写
重构可以在修改代码的时候进行,不论是增加修改子程序还是类,或者是修复缺陷。对于从未重构的糟糕代码,可以用一部分混乱的代码隔离复杂度,把理想规整的代码和混乱不堪的真实世界隔离开。
代码调整策略
代码调整指出于性能考虑,对代码进行实现上的调整。本章主要讨论策略。
- 动手前的考虑
- 性能≠代码速度
- 想清楚你确实在解决一个需要解决的问题
- 调整考量
- 程序设计:设计架构时考虑整体性能,再为每个子系统、类设计要达到的资源占用目标
- 和操作系统的交互
- 代码编译
- 硬件
- 类和子程序设计
- 代码调整
- 帕累托法则,又称八二法则,程序中20%的子程序耗费了80%的执行时间
- 一些错误认知
- 减少代码行数就可以减少资源占用
- 特定的写法会比其他的更快,代码也会更小(要看编译环境)
- 应当随时随地优化(不成熟的优化不如不优化)
- 运行速度和正确性同样重要
- 先提升代码可维护性,在程序完成且表现正确后,再去提升系统性能
- 常见的低效率来源
- IO操作
- 内存分页
- 系统API调用
- 脚本语言
- 性能测量:没有准确的性能测量就不要去做优化
- 代码调整需要反复尝试,才能达到理想的性能提高
代码调整技术
文接上章,本章讨论具体调整手段。
- 代码调整和重构相反,大多数情况下是以牺牲程序可读性为代价换取更高的性能
- 调整手段
- 逻辑
- 知道答案后停止判断
- 按照出现频率调整判断顺序,把容易为真的判断放在最前面
- 表驱动法代替复杂表达式
- 惰性求值
- 循环
- 把判断提出循环体
- 展开小循环
- 合并循环
- 减少循环体内的操作
- 用哨兵值提前结束循环
- 把最忙的循环放在最内侧
- 用低强度的计算代替高强度计算,如加法替代乘法,乘法代替幂运算
- 数据
- 使用整型而非浮点数
- 减少数组维度
- 使用辅助索引
- 使用缓存
- 表达式
- 利用恒等式简化代码复杂度
- 削弱计算强度
- 编译期初始化
- 小心系统函数,为了兼容最糟情况,系统函数会比较复杂
- 事先算出结果
- 删除公共表达式
- 子程序
- 子程序改为内联
- 用低级语言重写
- 逻辑
- 再次强调,没有性能优化测量就没有代码调整
系统考虑
这一部分站在系统的角度考虑构建过程中的程序规模、集成、工具等问题。
程序规模带来的影响
软件规模的扩大可能会为你带来意料之外的大量问题。
- 如果你习惯于开发小项目,那么你的第一个大型项目很可能会严重失控
- 沟通交流:项目成员的扩大带来的交流路径不是加性的,是乘性的
- 错误:项目规模的扩大也会带来更高的缺陷密度
- 生产率:大项目会带来更低的生产率
- 工作量:软件构建的工作量和项目大小是线性关系,而其他活动的工作量则是非线性增加
- 不同规模的代码从小到大可以称作程序、产品、系统、系统产品,没能意识到它们间的不同也是估算偏差的出现来源
- 项目越正规,就越重视方法论,不得不写的文档也会更多,撰写的文档也会更正规
构建管理
构建管理是软件管理中的一部分。
- 鼓励良好的编程实践
- 逐行复查
- 代码签名
- 鼓励最佳实践
- 配置管理:系统化定义项目工件和处理流程
- 需求和设计变更
- 遵循系统化的变更手续
- 成组变更
- 评估变更成本
- 坏味道:频繁大量变更
- 软件变更:版本控制软件
- 机器配置变更:机器镜像
- 需求和设计变更
- 评估构建进度表
- 评估项目规模和工作量是软件项目管理中最具挑战性的部分,平均水平的大型软件都要超时1年,超预算100%才能完成
- 清楚说明软件需求
- 使用不同方法评估再对比
- 定期更新评估
- 以下因素会影响软件开发进度,但不易被量化
- 开发者的经验和能力
- 团队的动力
- 管理质量
- 可重用的代码数目
- 人员流动性
- 需求变更
- 文档量
- 分级安全环境
- 如果进度落后了要怎么办
- 扩充团队在项目任务不能分割并一一击破时,只会增加项目交流复杂度,并加速项目延期
- 缩减项目范围,有限保证核心功能
- 度量:对项目特征进行度量可以评估项目进度和风险,当然保证ddl比收集度量数据更重要
- 把程序员当人看
- 程序员1天大概有30%时间花费在“对项目没有直接好处”的非技术活动中
- 不同程序员间的努力和能力差异很大,不同团队在软件质量和生产率的差异上也很大,好的和坏的程序员都倾向于聚集在一起
- 在代码风格的信仰问题上,使用“建议”或大多数人达成统一的指导规范
- 优良的物理工作环境有助于提升程序员的生产率和生产质量
- 管理你的管理者:向上管理
集成
集成方式也会影响到集成的质量。从频率上分,有阶段式集成和增量集成,在阶段式集成中,分为单元开发和系统集成两个阶段。增量集成中,程序是一点一点写出来并一点点拼接起来的。对比阶段式集成,增量集成有下面一些好处:
- 易于定位错误
- 更早在项目中取得系统级结果
- 对项目结果更好的监控
- 能在更短的开发计划中建造出整个系统
而增量集成有下面一些常见策略:
- 自顶向下集成:先集成系统设计,再集成具体实现;优点是能更早有整个系统的大局观,缺点是在前期会加入很多底层的mock代码,且将调试过程推迟到项目后期
- 自底向上集成:和自顶向下相反,优点是很容易定位错误,缺点是丧失全局的认识,系统设计的问题在后期修改成本较高
- 三明治集成:先集成顶部的高层业务对象和底部的基础工具类,再集成中间层代码,整合了自顶向下和自底向上
- 风险导向的集成:鉴别不同类的风险级别,先集成风险高的
- 功能导向的集成:一次集成一组功能,它基本上不需要脚手架,且每次集成都能增强系统的功能性,且和面向对象设计比较好协同工作
- T型集成:在集成高层对象后,先选中某个特定功能块,完成一次所有类的集成,这样可以作为MVP演练整个系统
结合冒烟测试的“每日构建”(daily build)是软件集成的一种最佳实践。它能让产品每天都有进步,且让项目保持一个固定的脉搏。做好daily build,有下面一些建议
- 不放过失败的build,保证每次build都能通过冒烟测试
- 每天进行冒烟测试
- 冒烟测试需要和代码一样“与时俱进”
- 让daily build和冒烟测试自动化
- 要求开发人员构建前进行冒烟测试
- 将修订保持合适的合并节奏,不要太密,也不要太疏
- 在早上发布build,给潜在问题留下修复时间
- 顶住需求压力,保证daily build和冒烟测试
在daily build的基础上,可以很轻易地做到1日多次的持续集成。
编程工具
工欲善其事,必先利其器
现代化的编程环境下,有很多可以采用的编程工具:
- 设计工具
- 源代码工具
- IDE
- 文本替换工具
- diff工具
- merge工具
- 源代码美化器
- 接口文档生成
- 代码模板/代码生成
- 命令行
- 代码质量分析
- linter
- metrics报告
- 重构代码
- 重构器
- 代码翻译器
- 版本控制工具
- 数据字典
- 可执行码工具
- 目标码生成
- 编译器、链接器
- build工具,如make、ant
- 程序库/第三方库
- 代码生成向导
- 安装指引
- 目标码生成
- 调试
- 测试
- 代码调整
- 性能剖测
- 汇编和反汇编
在Unix这样的工具导向环境下就更容易孕育编程工具,如grep、diff、sort、make、tar、line、sed、awk、vi等。几乎所有的大型组织都有自己的内部工具和支持团队,不少比市面上的还要优秀。针对特定项目,有时候也会开发特定的项目工具,如航天、保险、医疗等。对于个人开发,也可以使用脚本这种自动执行重复性杂务的工具。
最后要澄清一个事实,编程工具并不能消灭人在编程里的核心地位,只是不断重塑(reshape)编程的含义。连接到其他软硬件的复杂接口,规章制度、业务规则这些计算机编程之外的复杂之源还是要人来应对。而被用来填补真实世界和解决问题的计算机之间鸿沟的人,被称作程序员。
软件工艺
编程是硬件与艺术的融合体,软件工艺是编程美学的一种体现。
布局与风格
编排出色的代码会带来视觉上和思维上的愉悦。
- 基本原则
- 好的布局可以凸显程序的逻辑结构,也更符合人类直觉
- 傻子都会写计算机理解的代码,而优秀程序员写的是人能看懂的代码
- 高手的机型并非天生优于新手,而是高手具备某种知识结构,这种结构有助于高手记住特定类型的信息;因此当信息符合这些结构时,就可以被轻易的理解
- 布局非信仰,要保持头脑开放,接受已被证实更好的方法
- 布局技术
- 空白
- 分组
- 空行
- 缩进
- 括号
- 空白
- 布局风格:同一层级的语句缩进相同
- 纯块结构
- 模仿块结构
- 花括号指定边界
- 行尾布局(不推荐)
- 控制结构布局
- 段落间的空行
- 复杂的表达式拆分条件到多行
- 不用
goto
- 单行语句布局
- 控制长度
- 使用空格
- 后续行缩进统一
- 后续行结尾统一
- 一行一条语句
- 减少复杂度
- 读代码仅需自上而下
- 不要在单行中多个操作
- 一行一个声明
- 注释风格
- 缩进和代码一致
- 用空行和代码隔开
- 子程序布局
- 空行分段
- 参数按标准缩进
- 类布局
- 头部注释 -> 构造函数/析构函数 -> public子程序 -> protected子程序 -> private子程序和成员
- 文件布局
- 一个文件一个类
- 文件命名和类有关
- 在文件中清晰分隔各子程序
自说明代码
本节专注于文档的特殊补充形式,即“注释”。
- 在代码中起主要作用的并非注释,而是好的编程风格
- 注释的哲学
- 注释能提供更高层级的抽象
- 重复注释根本没用
- 注释写的不合适只会起反作用
- 注释类别:在代码完工后,只允许出现后三种
- 重复代码(Bad case)
- 解释代码:当代码过于复杂到需要解释时,最好是改进代码,而不是添加注释
- 代码标记,如
TODO
,FIXME
- 概述代码
- 意图说明
- 传达代码以外的信息,如版权声明、保密要求
- 高效注释
- 用伪代码法减少注释时间
- 将注释如何到开发风格中
- 注释技术
- 注释单行
- 去掉无关注是
- 减少行尾注释
- 行尾注释只用于数据声明、维护标记、标记行尾等场景
- 注释代码段
- 应表达why而非how
- 代码本身应尽力组做好说明
- 注明非常规用法
- 错误或语言环境独特点要加注释
- 注释数据声明
- 数值单位
- 允许范围
- 输入限制
- 全局数据
- 注释控制结构
- 循环结束的行尾注释是代码太复杂的征兆
- 注释子程序
- 注释要靠近说明的代码
- 用简短的话进行说明
- 注释声明参数
- 可以使用Javadoc这种工具
- 说明子程序的全局作用
- 注释类、文件、程序
- 注释单行
个人性格
软件工程是纯粹的脑力劳动。软件工程师研究工具和原材料的本质时,实际上是在研究人的智力、性格这种无形的东西。
- 编程工作本质上是项难以监督的工作,你也需要对自己负责
- 聪明和谦虚
- 求知欲
- 形成自我意识
- 实验
- 学习成功项目
- 阅读文档
- 和同行交流
- 不屈不挠的诚实感
- 交流和合作:编程首先是与人交流,然后才是和计算机交流
- 创造力和纪律
- 懒惰:避免“实在懒”,追求“开明懒”和“一劳永逸的懒”
- 可能不那么明显的性格
- 坚持:要时不时抬头开清方向
- 经验:不同于其他行业,软件开发行业的经验比书本知识价值要小,基础知识变化很快,不存在越老越吃香的情况。不持续学习跟上潮流,仅靠经验吃饭,会被逐渐淘汰。
- 习惯
- 好习惯很重要
- 不要用“没有习惯”替代“坏习惯”
软件工艺探讨的话题
《代码大全》全书都着重于软件构建的细节,本章从抽象的关注点出发,看看哪些方面会影响软件的工艺。
- 软件开发的核心是致力于降低复杂度,管理复杂度是软件的核心使命,之前各章节提过了很多具体办法。各种形式的抽象都是管理复杂度的强大工具。
- 划分子系统
- 仔细定义类接口
- 保持接口抽象性
- 避免全局变量
- 避免深层次继承
- 避免深度嵌套和循环
- 不用
goto
- 子程序短小精悍
- 使用清晰明了的变量名
- 使用规范和约定减少理解负担
- 软件开发和其过程密不可分,在多程序员参与的项目里,组织性的重要性超过了个人技能
- 坏的过程只会损耗脑力,好的过程则可以开发脑力到极限
- 首先为人写程序,然后才是机器,强调代码可读性,便于与同行沟通
- 深入一门语言去编程,不浮于表面
- 杰出的程序员会考虑他们要干什么,然后才是怎么用手头的工具实现目标
- 借助规范集中注意力
- 基于问题域编程
- 将程序划分为不同层级的抽象
- 第0层:操作系统的操作和机器指令
- 第1层:编程语言结构和工具
- 第2层:底层实现结构,如算法和数据结构
- 第3层:低层问题域,这一层已经有问题域相关的操作原语可以使用
- 第4层:高层问题域,你的非技术用户某种程度也应该可以看懂你的代码
- 将程序划分为不同层级的抽象
- 编程是科学和艺术融合的一门工程学科
- 迭代在软件开发中是很正常的现象。软件设计是一个逐步精化的过程。
- 将软件和信仰分离开
- 不要盲目跟风
- 保持折中态度
- 权衡各种技术,再做决定
- 基于实验,保持开放心态
–END–