关于编码的一切 ——《代码大全》 上
拿到它的时候,它已印刷了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或编译器检查错误
- 去掉冗余注释
变量
这一部分深入到代码细节,围绕如何正确使用变量展开。
一般事项
- 初始化的一些建议
- 声明的时候初始化
- 靠近变量使用的时候初始化
- 考虑对常量使用
final
或const
- 注意累加器和计数器的重置
- 用可执行代码初始化
- 检查合法性
- 作用域
- 将变量引用点集中起来可以提高可读性,这样可以减少大脑缓存
- 减少变量的“存活时间”(从第一条引用语句到最后一条)
- 减少作用域的一些原则
- 循环开始时初始化循环变量
- 变量使用前再赋值
- 对于变量先采用最严格的可见性,再逐渐放宽
- 持续性
- 子程序内/手动回收前/程序运行时/持久存储
- 为变量选择合适的持续性
- 绑定时间
- 编码时/编译时/加载时/实例化时/运行时
- 越晚绑定越有灵活
- 选择合适的灵活度
- 和控制结构匹配的数据结构
- 序列型数据 -> 顺序语句
- 选择型数据 ->
if case
语句 - 迭代型数据 -> 循环语句
- 变量单一用途
- 只用作一件事
- 使用所有已声明变量
取名
取名是个学问。
- 几个原则
- 信达雅
- 以问题为导向,面向目的而不是手段
- 适当的长度,小于20个字符,大于8个字符
- 作用域越小,变量名越短;使用较少的变量或全局变量适用较长的名字
- 使用限定词(如min、avg、max)和对仗词
- 特定类型的变量名
- 循环下标:
i
,j
,k
,在嵌套循环时建议使用表意的变量名 - 状态变量:取个比
xxxflag
更好的名字 - 临时变量:避免用临时名字
- 布尔变量:名字要蕴含真假的意义
done
/error
/found
/success
- 不建议使用
is
前缀 - 使用表示肯定的名字,理解成本低
- 枚举变量:缺少组前缀的需要加上前缀
- 常量:不使用magic number或magic string
- 循环下标:
- 组内需要确定一个命名规范
- 标准前缀
- 用户自定义类型缩写,UDT缩写
- 正交化、便于检索
- 如何缩写
- 使用标准缩写
- 去掉非前置元音
- 去掉虚词,
and
等 - 去掉无用后缀
- 使用每个单词的第一或前几个字母
- 不提倡语音缩写
- 缩写要能读出来
- 避免容易看错或读错的字符组合
- 应该避免的名字
- 令人误解
- 具有不同含义但有相似名字
- 发音相近
- 出现数字,这是不好的征兆
- 拼写错误
- 仅靠大小写区分
- 使用易混淆的字符,如
0
和o
,1
和l
- 代码阅读次数要远远多于编写次数
基本数据类型
- 数值
- 避免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
- 循环语句
- 分为计数循环、连续求值循环、无限循环、迭代器循环。分别适用
for
、while
、foreach
语句 - 循环控制
- 应该把循环体看作黑盒子,外围程序只知道它的控制条件
- 合理判断使用
for
和while
的地方 - 尽量避免空循环
- 循环内务(包括索引增加)要么放在循环开始,要么放在循环最后
- 让循环终止条件看起来明显
- 不要为了终止循环改动
for
循环的下标 - 小心散布了很多
break
的循环,小心谨慎使用break
和continue
- 检查循环端点是否会有off-by-one的问题
- 在嵌套循环中使用有意义的变量名增强可读性
- 循环要尽可能短,便于一目了然
- 把嵌套限制在3层以内
- 分为计数循环、连续求值循环、无限循环、迭代器循环。分别适用
不常见的控制结构
- 多处返回:指程序中途的return或exit
- 只在能增强可读性时,使用中途的
return
- 用防卫子句提前退出,简化复杂的错误处理
- 减少程序中的
return
数目
- 只在能增强可读性时,使用中途的
- 递归:将复杂问题分而治之
- 确认终止条件
- 使用安全计数器防止出现无穷递归
- 把递归限制在一个子程序里,避免循环调用
- 留意栈空间
- 可以用循环结构等价式的先考虑循环结构,如阶乘和斐波那契数列
goto
- 反对随意使用
goto
goto
灵活度太高,不容易用好,在可以使用其他控制结构时,不使用goto
- 在错误处理中,可以用状态变量、
try finally
语句实现跳出正常流 - 如果在那1%的情况下需要使用
goto
,注意以下几点- 尽量一个子程序只使用一个
goto
- 尽量向前跳转而非向后
- 确保所有的goto标号都被执行到
- 确认
goto
不会产生执行不到的代码
- 尽量一个子程序只使用一个
- 反对随意使用
表驱动法
表驱动法是空间换时间的一种编程模式,使用数据结构模拟逻辑结构,将大部分复杂度放到容易被理解的数据结构中,从而提升代码可读性。下面是一个代码范例。
1 | if ((('a' <= inputChar) && (inputChar <= 'z')) || |
使用一个查询表建立每个字符和它的字符类型的关联后,代码可以简化为
1 | charType = charTypeTable[inputChar]; |
- 查表方法
- 直接访问:如查询每月天数,或不同年龄对应的保险费率
- 有的时候键值要预先处理后才能直接使用,如可能很多年龄对应相似的费率,这时最好先将年龄换算到一个更好的key上
- 进一步,我们可以把键值转换提取为独立的子程序
- 索引访问表:和直接访问的区别在于,对于不易换算到键值的情况,提供一个额外的索引表,先映射到索引表再查到数据
- 阶梯访问表:主要针对表中的记录是对数据范围而非数据点生效的情况,使用端点作为key
- 留心端点带来的off-by-one情况
- 可以使用二分查找代替顺序查找
- 也可以使用索引访问技术
- 直接访问:如查询每月天数,或不同年龄对应的保险费率
一般性问题
布尔表达式
- 使用
true
或false
作判断 - 简化复杂的表达式
- 使用中间变量或布尔函数
- 使用决策表替代复杂的判断逻辑
- 编写肯定的布尔表达式,会让布尔表达式更易理解
- 用括号分割较长的布尔表达式
- 注意短路求值或惰性求值的情况
- 按照数轴的顺序编写数值表达式,类似
MIN_VALUE <= i and i <= MAX_VALUE
,可读性好很多 - 在C语言中最好把常量放在左边
- 注意区分Java中
a==b
和a.equals(b)
空语句
- 小心使用
- 使用
doNothing()
函数或noop()
函数 - 考虑能否换用非空的循环体
优化深层嵌套
- 优化重复的
if
检查 - 使用
break
简化嵌套if
,如防卫子句 - 转换成一组
if-then-else
结构 - 转换成
case
语句 - 将深度嵌套的语句抽离成子程序
- 借助多态
- 借助异常来跳出正常流
结构化编程
结构化编程的思路是仅使用顺序、选择、迭代的思路描述程序流,避免使用break
,continue
,return
,try-catch
来打断。
降低复杂度
- 程序复杂度的一个衡量标准是,为了理解程序,必须在同一时间记忆的智力实体数目,即理解程序花费的精力
- 控制流的复杂度和不可靠的代码以及频繁出现的错误息息相关
- 可以通过计算子程序的“决策点”粗估子程序的复杂度
- 从1开始,遇到
if
,while
,repeat
,for
,and
,or
加一,为每一种case
加一
- 从1开始,遇到