关于编码的一切 ——《代码大全》 上

拿到它的时候,它已印刷了13年之久;能在京东上找到,也是极大的幸运。

《代码大全》是一部又大又全的工具书,它涵盖了关于编程各个环节的充分经验,可以作为日程编程工作的指导。将其他教我如何编程的书中的内容做了一个打包。数月研读下来,难免有所遗忘。这里将其中的精华尽量记录下来,也作为对全书内容的一个回顾。

打好基础

第1部分主要围绕构建为读者讲解什么是构建,和关于构建我们需要了解和准备的。也为后面展开具体编程细节和设计艺术打基础。

走进软件构建

构建是软件开发的核心,构建的质量对软件质量有实质影响。

隐喻理解软件开发

用隐喻可以帮助理解软件开发的过程。

  • 一个好的隐喻应该是简单的,忽略了不必要的细节,对概念进行内化和抽象,让人从更高层面思考问题,从而避免低层次错误
  • 隐喻更像启示,而非算法
  • 对于编程来说,还是将问题概念化
  • 有一些常见的软件隐喻
    • 写作/耕作:这些隐喻不太合适
    • 养殖:增量、迭代、自适应、演进的成长概念
    • 建造:规划设计文档,使用现成组件
  • 组合各种隐喻,不要过度引申隐喻,带来误导

提前准备

提前准备,降低风险。

  • 前期准备的必要性
    • 降低风险
    • 通过逻辑、类比、数据说服项目经理
      • 开始大项目前需要制定计划
      • 程序员是食物链的最后一环,架构师吃掉需求,设计师吃掉架构,程序员消化设计
      • 发现错误的时间要尽可能接近引入错误的时间,可以尽量降低修复时间
  • 判别你所在的软件领域
    • 在软件开发中,适用迭代式开发法比适用序列式开发法的情况多得多
  • 先清楚定义问题
  • 再正确认清需求
    • 正式详尽地描述需求,是项目成功的关键
      • 面向目标、契约式编程
    • 稳定需求是可望而不可即的
      • 开发过程会帮助客户更好地理解自己的需求,这也是需求变更的主要来源
    • 应对需求变更
      • 核对当前需求的质量(需要有一个需求质量的核对表),及时回退到需求设计环节
      • 确保每个人都知道变更的代价
      • 建立变更的控制流程
      • 要放弃么?
      • 考虑项目的商业价值
  • 考虑架构设计
    • 架构指整个系统的设计约束,不会细节到子系统或类的设计约束
    • 架构的组成部分
      • 程序组织
      • 主要的类和类的继承体系
      • 数据结构设计
      • 业务规则描述
      • UI设计
      • 资源管理:数据库连接、线程、句柄
      • 安全
      • 性能
      • 可扩展性
      • 国际化
      • 错误处理:纠正还是检测、主动还是被动
      • 输入输出
      • 容错性
      • 过度工程:明确设立期望目标
      • “买”还是“造”:如果架构选择自己做,那么一定要证明自己定制的组件在某方面胜过现有的
      • 变更策略:如何应对变更
    • 架构的总体质量
      • 和所解决的问题和谐一致,看起来自然
      • 描述所有主要的决策动机
      • 优秀的架构很大程度和机器与编程语言无关
  • 投入的时间一般在20%-30%

关键的构建决策

选择语言、技术、构建实践。

  • 高级语言表达力更强
    • 你思考的能力取决于你是否知道可以表达该思想的词汇
  • 提前讲好使用的编程约定,去统一编程语言的细节
  • 找准在技术浪潮中的位置
    • 如果在浪潮后期,就可以持续使用稳定的功能;在浪潮前期,则需要花时间找到文档中没有说明的编程语言特性
    • “深入一种语言去编程”,程序员现决定想表达的思想是什么,再决定如何使用特定语言的工具去表达这些思想
  • 选择构建实践

创建高质量代码

这一部分主要讲解类和子程序的设计和编码。

如何做设计

一些启发式准则和idea

  • 设计的挑战
    • 有的问题需要“解决”一边,才能明确定义它,然后再次解决
    • 设计成功应该是组织良好且清爽的,不过设计过程却并非如此
    • 设计需要取舍,受到限制
    • 设计需要启发式思维,但也是不断评估、讨论、调试实验中诞生的
  • 设计的关键概念
    • 管理复杂度
      • 软件开发的本质复杂性来自复杂无序的现实世界,精确完整地识别依赖关系和意外情况,设计完全正确而不是部分正确的方案
      • 软件需要管理复杂度,在组织程序的时候便于在一个时刻专注于一个特定的部分,另外不遗漏暂时忽视的部分
      • 应对复杂度:减少本质复杂度到最小、避免偶然复杂度的无谓增长
    • 理想的设计特征
      • 最小复杂度
      • 易于维护
      • 松耦合
      • 可扩展、可重用
      • 高扇入(类被其他类大量使用)、低扇出(类少量使用其他类)
      • 可移植性
      • 精简性
      • 标准化
    • 设计的层次
      • 软件系统 > 子系统或包 > 类 > 子程序 > 子程序内
      • 常用子系统:业务规则、用户界面、数据库访问、OS抽象层
  • 设计构造块:启发式方法
    • 寻找现实对象:想想系统要模仿什么
      • 辨识对象和其属性
      • 确定可以对对象做的操作
      • 确定对象能对其他对象进行的操作
      • 确定对象的可见范围
      • 定义对象接口
    • 形成一致的抽象:让你关注某概念的时候忽略不必要的细节
    • 封装实现细节:封装帮你掩盖不需要你看到的复杂度
    • 继承能简化设计就继承
    • 隐藏秘密信息
      • 保证接口最小且完备
      • 隐藏复杂度和变化源
    • 找出容易改变的区域
      • 业务规则、硬件依赖、输入输出、非标准的预演特性、状态变量、糟糕或复杂的设计
      • 将容易变化的部分隔离开,让变化的影响范围和变化的可能性成反比
    • 保持松散耦合
      • 耦合种类:简单数据参数、简单对象、对象参数、语义耦合(过多假设)
    • 了解常用的设计模式
      • 设计模式提供了现成的抽象来减少复杂度
      • 设计模式将抽象SOP化
      • 设计模式可以起到启发性作用
      • 设计模式将设计对话提高到更高层次来简化交流
    • 其他启发式方法
      • 高内聚
      • 契约式设计
      • TDD
      • 创建中央控制点,集中管控
      • 拿不准时,使用蛮力突破
      • 画一个图
      • 设计模块化
    • 使用启发式方法的原则
      • 先理解问题
      • 找出现有数据和未知量之间的联系
      • 寻找之前的类似问题,或者解决一些相关问题
      • 执行计划
      • 回顾解
  • 设计实践
    • 迭代:第二个尝试往往会好于第一个
    • 分而治之,增量式改进
    • 自上而下设计和自下而上设计
    • 建立试验性原型:原型要足够简单可抛弃,又足以验证效果
    • 记录你的设计成果:wiki、邮件、UML图

设计类

  • 类是一组数据和子程序的聚合,有内聚的明确定义的职责
  • 抽象数据类型(ADT)
    • ADT可以让你像现实世界一样操作实体,而不必在底层实现上摆弄实体
    • ADT的好处
      • 隐藏实现细节
      • 改动不需要影响整个程序
      • 接口语义更强
      • 更容易提高性能
    • 在非面向对象环境,也可以使用ADT
  • 良好的类接口
    • 好的抽象
      • 类接口应该有一致的抽象层次
      • 要理解类的抽象是什么
      • 考虑提供成对的服务,如打开/关闭、添加/删除
      • 尽可能让接口可编程,而不仅是表达语义
      • 谨防在修改时破坏接口抽象
      • 同时考虑抽象性和内聚性
    • 好的封装
      • 封装比抽象更强,它直接阻止你看到细节
      • 尽可能限制类和成员的可访问性
      • 不要公开暴露成员数据
      • 不要将实现细节暴露在接口上
      • 不要对类的使用者做任何假设
      • 让阅读代码比编写更方便,代码的阅读次数比编写多得多
      • 不要透过接口来编程,仅仅看类的接口文档无法得知如何使用一个类的话,正确的做法不是拉出类的源代码,查看内部实现,而是联系类作者。对于类作者来讲,正确的做法不是面对面告诉答案,而是去修改类的接口文档
  • 设计和实现
    • 通过包含来实现“有一个”的关系
      • 警惕超过7个成员的类
    • 通过继承实现“是一个”的关系
      • 用public继承
      • 要么使用继承并详细说明,要么就不要使用它
      • 遵循Liskov替换原则,即对基类的子程序,在它的所有派生类上含义都应该是相同的,在调用时只用看基类无需考虑是哪一个派生类
      • 只继承需要继承的部分
      • 只有一个实例的类值得怀疑
      • 只有一个派生类的类也值得怀疑
      • 派生中覆盖了某个子程序,但是其中没做任何操作,也值得怀疑
        • 很可能修改了基类接口的语义,慢慢地从基类接口很难理解派生类上的行为
      • 避免过深地继承:降低复杂度
      • 尽量使用多态,避免类型检查
      • 适度使用继承
        • 多个类共享数据而非行为 => 创建类包含的公用对象
        • 多个类共享行为而非数据 => 都从基类派生,在基类中定义公用的子程序
        • 多个类既公用数据也公用行为 => 都从基类派生,在基类中定义公用的子程序和数据
        • 通过基类控制接口 => 继承
        • 自己控制接口 => 包含
    • 成员函数和数据成员
      • 减少子程序
      • 进制不必要的成员和运算符
      • 减少对其他类子程序的间接调用
    • 构造函数
      • 尽可能早构造函数中初始化所有数据成员
      • 用私有构造函数来实现单例数据
      • 优先使用深拷贝,除非需要,才使用浅拷贝
  • 创建类的原因
    • 为现实/抽象世界的对象建模
    • 降低/隔离复杂度
    • 隐藏实现细节
    • 限制变动的影响范围
    • 建立中心控制点
    • 将相关操作包装在一起
    • 避免的类:万能类、无关紧要类、动词命名类
  • 超越类:包

设计子程序

  • 子程序是为了实现特定目的编写的方法或过程
  • 编写子程序的正当理由
    • 降低复杂度
    • 引入中间、易懂的抽象
    • 避免代码重复
    • 支持派生类覆盖
    • 隐藏指针操作
    • 改善性能
    • 增加可读性
  • 子程序上的设计
    • 一个子程序只做一件事
    • 考虑靠近纯函数或纯副作用函数
    • 内聚性
      • 功能上的、顺序上的、通信上的
      • 避免临时的内聚性(只是需要同时执行才放在一起操作的子程序),如贫血的startup()方法
      • 避免逻辑上的、巧合的内聚性
  • 起个好名字
    • 描述所做的事情,而非做事情的过程
    • 避免使用模糊的动词
    • 不要仅用数字区分子程序名
    • 函数名不要过长
    • 考虑描述返回值
    • 使用预期强烈的动词 + 宾语
    • 使用对仗词,如add/remove
    • 为常用操作统一命名
  • 子程序长度:最好少于100行,可以接受100 - 200行
  • 子程序入参
    • 按输入、修改、输出顺序排列参数
    • 如果子程序使用了相似的参数,考虑让他们的排列顺序一致
    • 删掉没有使用到的参数
    • 不要给入参重新赋值
    • 限制入参数(有的说3个,有的说7个)
    • 考虑给参数名增加前缀、后缀
    • 入参和子程序需要在一个抽象层级下
    • 使用具名参数
  • 宏子程序和内联子程序
    • 将宏表达式整个包含在括号内
    • 一般来讲,是不会用宏代替子程序的
    • 节制使用inline子程序,在确认有性能改进后再使用inline子程序

防御式编程

防御式编程让错误更容易发现和修改,并减小破坏。

  • 断言
    • 主要用于开发和维护的阶段
    • 用错误处理代码处理预期中的状况,用断言处理绝不该出现的状况
    • 避免把需要执行的代码放在断言中
    • 对于高健壮性代码,应该先断言再处理错误
  • 错误处理技术
    • 返回中立值,如空串、0
    • 换用下一个正确数据,如获取温度
    • 返回上一个正确数据,如屏幕重绘
    • 使用最接近的合法值
    • 打印警告信息到日志文件中
    • 返回错误码
    • 显示出错信息
    • 关闭程序
    • 平衡正确性和健壮性
  • 异常
    • 通知程序其他部分,发生了不可忽略的错误
    • 只在真正例外的情况下才抛出异常
    • 避免再构造函数或析构函数中抛出异常
    • 在恰当抽象层次抛出异常
    • 在异常信息中加上导致异常的所有信息
    • 避免空的catch语句
    • 异常标准化 & 异常报告机制
  • 辅助调试的代码
    • 进攻式编程:让问题更早暴露
    • 方便地移除调试代码
  • 保留防御式代码的程度
    • 保留检查重要错误的代码
    • 去掉检查细微错误的代码
    • 保留让程序稳妥崩溃的代码
    • 记录错误信息

伪代码编写

  • 创建一个类
    • 创建类的总体设计
    • 创建类中的子程序
    • 复审并测试
  • 伪代码
    • 使用类似英语的用法描述准确操作
    • 避免使用特定编程语言的语法元素,防止陷入到代码本身的层级上设计
    • 在略高于代码的层次上进行设计
  • 通过伪代码创建子程序
    • 检查先决条件
    • 定义子程序要解决的问题
    • 决定如何测试
    • 在第三方库中搜寻可用功能
    • 考虑错误处理
    • 编写伪代码
    • 将伪代码转为高层次的注释
    • 在注释下填充代码
    • 检查代码是否需要进一步分解
    • 使用lint或编译器检查错误
    • 去掉冗余注释

变量

这一部分深入到代码细节,围绕如何正确使用变量展开。

一般事项

  • 初始化的一些建议
    • 声明的时候初始化
    • 靠近变量使用的时候初始化
    • 考虑对常量使用finalconst
    • 注意累加器和计数器的重置
    • 用可执行代码初始化
    • 检查合法性
  • 作用域
    • 将变量引用点集中起来可以提高可读性,这样可以减少大脑缓存
    • 减少变量的“存活时间”(从第一条引用语句到最后一条)
    • 减少作用域的一些原则
      • 循环开始时初始化循环变量
      • 变量使用前再赋值
      • 对于变量先采用最严格的可见性,再逐渐放宽
  • 持续性
    • 子程序内/手动回收前/程序运行时/持久存储
    • 为变量选择合适的持续性
  • 绑定时间
    • 编码时/编译时/加载时/实例化时/运行时
    • 越晚绑定越有灵活
    • 选择合适的灵活度
  • 和控制结构匹配的数据结构
    • 序列型数据 -> 顺序语句
    • 选择型数据 -> if case语句
    • 迭代型数据 -> 循环语句
  • 变量单一用途
    • 只用作一件事
    • 使用所有已声明变量

取名

取名是个学问。

  • 几个原则
    • 信达雅
    • 以问题为导向,面向目的而不是手段
    • 适当的长度,小于20个字符,大于8个字符
    • 作用域越小,变量名越短;使用较少的变量或全局变量适用较长的名字
    • 使用限定词(如min、avg、max)和对仗词
  • 特定类型的变量名
    • 循环下标:i,j,k,在嵌套循环时建议使用表意的变量名
    • 状态变量:取个比xxxflag更好的名字
    • 临时变量:避免用临时名字
    • 布尔变量:名字要蕴含真假的意义
      • done/error/found/success
      • 不建议使用is前缀
      • 使用表示肯定的名字,理解成本低
    • 枚举变量:缺少组前缀的需要加上前缀
    • 常量:不使用magic number或magic string
  • 组内需要确定一个命名规范
  • 标准前缀
    • 用户自定义类型缩写,UDT缩写
    • 正交化、便于检索
  • 如何缩写
    • 使用标准缩写
    • 去掉非前置元音
    • 去掉虚词,and
    • 去掉无用后缀
    • 使用每个单词的第一或前几个字母
    • 不提倡语音缩写
    • 缩写要能读出来
    • 避免容易看错或读错的字符组合
  • 应该避免的名字
    • 令人误解
    • 具有不同含义但有相似名字
    • 发音相近
    • 出现数字,这是不好的征兆
    • 拼写错误
    • 仅靠大小写区分
    • 使用易混淆的字符,如0o1l
  • 代码阅读次数要远远多于编写次数

基本数据类型

  • 数值
    • 避免magic number
    • 避免除0
    • 避免混合类型比较哦
  • 整数
    • 检查整数除法
    • 检查整数溢出
  • 浮点数
    • 避免数据级相差巨大的数之间的加减
    • 避免相等比较
    • 避免舍入误差
  • 字符串
    • 避免magic string
    • 考虑国际化
    • unicode支持
    • C语言的字符串
      • 注意字符串指针和字符数组的差异
      • 注意字符串长度声明为CONSTANT + 1
      • null初始化避免无结束符
      • 建议使用字符数组
  • 布尔变量
    • 使用布尔中间变量简化复杂判断
  • 枚举类型
    • 带来类型提示和提升可读性
    • 简化修改
    • 作为布尔变量的可扩展性方案
    • 枚举类型的第一个元素留作非法制
  • 具名常量:“参数化”程序
    • 统一使用
  • 数组
    • 确认数组下标
    • 顺序访问元素,不建议随机访问
    • 数组边界点
  • 自定义类型:typedef作为类的轻量级方案

不常见的数据类型

  • 结构体:数据组合,没有行为的类
    • 用前一问:可以用类么
    • 简化数据块操作
    • 简化参数列表
  • 指针:灵活但容易出错
    • 用前一问:有访问器子程序或防御式编程么
    • 标识内存中某个位置某种内容
    • 一般技巧
      • 同时声明和定义
      • 使用前检查
      • 使用前判断内存是否损毁
      • 在提高代码清晰度上,不要节约使用指针
      • 简化指针表达式
      • 正确删除链表中的指针
      • 删除或释放前设为空值
      • 删除前检查是否非法
      • 统一跟踪分配情况
      • 统一在子程序里,集中实现上述策略
    • C++指针
      • 理解指针和引用
      • 指针用于“按引用传递”,const引用用于“按值传递”
      • 使用shared_ptr
    • C指针
      • 使用显式类型
      • 避免强制类型转换
      • 遵循参数传递的*规则
      • 内存分配时使用sizeof()确定变量大小
  • 全局数据:风险较大
    • 用前一问:有更好的方法么
    • 常见问题
      • 多线程重入问题
      • 阻碍代码重用
      • 破坏模块化和智力上的可管理性
    • 使用理由
      • 简化极常用的数据使用
      • 消除流浪数据(调用链中间的子程序不使用数据)
    • 用访问器子程序取代全局数据
      • 在访问前锁定控制
      • 在访问器子程序里构建一个抽象层
      • 对数据的所有访问限制在一个抽象层

语句

在了解了数据视角的变量元素后,这一部分围绕语句组织展开。

直线型代码

  • 直线型代码即按先后顺序放置语句和语句块
  • 必须明确先后顺序的语句
    • 想办法明确展示语句的依赖关系
      • 组织代码
      • 使用子程序名/子程序参数凸显依赖
      • 使用注释
      • 通过断言或错误处理来检查
  • 顺序无关的语句
    • 使代码易于从上向下阅读,避免跳来跳去
    • 将相关语句组织在一起

条件语句

  • if语句
    • if-then语句
      • 先写正常代码,再写不常见情况
      • 不要在if后跟随空子句
      • 看看是不是不需要else子句
    • if-then-else语句
      • 利用布尔函数简化复杂的检测
      • 把常见情况放在最前面
      • 检查是否考虑了所有情况
  • case语句
    • 选择最有效的排列顺序,如执行频率
    • 简化每种case下的操作
    • 最好能搭配枚举类型一起使用
    • 使用default子句检查默认情况或错误
    • 注意有些语言的case会有fallthrough,需要加break
  • 循环语句
    • 分为计数循环、连续求值循环、无限循环、迭代器循环。分别适用forwhileforeach语句
    • 循环控制
      • 应该把循环体看作黑盒子,外围程序只知道它的控制条件
      • 合理判断使用forwhile的地方
      • 尽量避免空循环
      • 循环内务(包括索引增加)要么放在循环开始,要么放在循环最后
      • 让循环终止条件看起来明显
      • 不要为了终止循环改动for循环的下标
      • 小心散布了很多break的循环,小心谨慎使用breakcontinue
      • 检查循环端点是否会有off-by-one的问题
      • 在嵌套循环中使用有意义的变量名增强可读性
      • 循环要尽可能短,便于一目了然
      • 把嵌套限制在3层以内

不常见的控制结构

  • 多处返回:指程序中途的return或exit
    • 只在能增强可读性时,使用中途的return
    • 用防卫子句提前退出,简化复杂的错误处理
    • 减少程序中的return数目
  • 递归:将复杂问题分而治之
    • 确认终止条件
    • 使用安全计数器防止出现无穷递归
    • 把递归限制在一个子程序里,避免循环调用
    • 留意栈空间
    • 可以用循环结构等价式的先考虑循环结构,如阶乘和斐波那契数列
  • goto
    • 反对随意使用goto
    • goto灵活度太高,不容易用好,在可以使用其他控制结构时,不使用goto
    • 在错误处理中,可以用状态变量、try finally语句实现跳出正常流
    • 如果在那1%的情况下需要使用goto,注意以下几点
      • 尽量一个子程序只使用一个goto
      • 尽量向前跳转而非向后
      • 确保所有的goto标号都被执行到
      • 确认goto不会产生执行不到的代码

表驱动法

表驱动法是空间换时间的一种编程模式,使用数据结构模拟逻辑结构,将大部分复杂度放到容易被理解的数据结构中,从而提升代码可读性。下面是一个代码范例。

1
2
3
4
5
6
7
8
9
10
11
12
13
if ((('a' <= inputChar) && (inputChar <= 'z')) ||
(('A' <= inputChar) && (inputChar <= 'Z'))) {
charType = CharacterType.Letter;
}
else if ((inputChar == ' ') || (inputChar == ',') ||
(inputChar = '.') || (inputChar == '!') || (inputChar == '(') ||
(inputChar = ')') || (inputChar == ':') || (inputChar == ';') ||
(inputChar = '?') || (inputChar == '-')) {
charType = CharacterType.Punctuation;
}
else if (('0' <= inputChar) && (inputChar <= '9')) {
charType = CharacterType.Digit;
}

使用一个查询表建立每个字符和它的字符类型的关联后,代码可以简化为

1
charType = charTypeTable[inputChar];
  • 查表方法
    • 直接访问:如查询每月天数,或不同年龄对应的保险费率
      • 有的时候键值要预先处理后才能直接使用,如可能很多年龄对应相似的费率,这时最好先将年龄换算到一个更好的key上
      • 进一步,我们可以把键值转换提取为独立的子程序
    • 索引访问表:和直接访问的区别在于,对于不易换算到键值的情况,提供一个额外的索引表,先映射到索引表再查到数据
    • 阶梯访问表:主要针对表中的记录是对数据范围而非数据点生效的情况,使用端点作为key
      • 留心端点带来的off-by-one情况
      • 可以使用二分查找代替顺序查找
      • 也可以使用索引访问技术

一般性问题

布尔表达式

  • 使用truefalse作判断
  • 简化复杂的表达式
    • 使用中间变量或布尔函数
    • 使用决策表替代复杂的判断逻辑
  • 编写肯定的布尔表达式,会让布尔表达式更易理解
  • 用括号分割较长的布尔表达式
  • 注意短路求值或惰性求值的情况
  • 按照数轴的顺序编写数值表达式,类似MIN_VALUE <= i and i <= MAX_VALUE,可读性好很多
  • 在C语言中最好把常量放在左边
  • 注意区分Java中a==ba.equals(b)

空语句

  • 小心使用
  • 使用doNothing()函数或noop()函数
  • 考虑能否换用非空的循环体

优化深层嵌套

  • 优化重复的if检查
  • 使用break简化嵌套if,如防卫子句
  • 转换成一组if-then-else结构
  • 转换成case语句
  • 将深度嵌套的语句抽离成子程序
  • 借助多态
  • 借助异常来跳出正常流

结构化编程

结构化编程的思路是仅使用顺序选择迭代的思路描述程序流,避免使用breakcontinuereturntry-catch来打断。

降低复杂度

  • 程序复杂度的一个衡量标准是,为了理解程序,必须在同一时间记忆的智力实体数目,即理解程序花费的精力
  • 控制流的复杂度和不可靠的代码以及频繁出现的错误息息相关
  • 可以通过计算子程序的“决策点”粗估子程序的复杂度
    • 从1开始,遇到ifwhilerepeatforandor加一,为每一种case加一