代码简洁之道 - 读书笔记

1. 简洁代码

  • 优雅,高效;减少依赖;完善错误处理代码;处理专一事务 - Bjarne Stroustrup
  • 简单直接,干净利落的抽象(crisp abstraction),直截了当的控制语句 - Grady Booch
  • 可由其他开发者阅读和增补;应有单元测试和验收测试;有意义的命名;单一的做一件事的途径;经量少的依赖关系;清晰但尽量少的API - Dave Thomas
  • 几乎没有改进的余地 - Michael Feathers
  • 通过所有测试;不重复;体现系统中的全部设计理念;尽量少的实体 - Ron Jefferies
  • 代码让编程语言看起来像是专门为解决那个问题而存在 - Ward Cunningham

2. 命名

  • 清晰,有意义的命名能有效降低代码的模糊度

  • 避免留下掩盖代码本意的错误线索

  • 有意义的区分

    • Product,ProductInfo,ProductData无实质性区别
  • 使用可以读出来的名称

  • 可搜索的名称

    • 名称长短与其作用域大小相对应
  • 避免使用编码,如

    • 类型标记,Hungarian Notation, 在实际名称前加入类型标记
    • 命名前缀,m_,以标识成员变量
    • 接口与实现,I,接口前缀
  • 类名和对象名应为名词或名词短语

  • 方法名应为动词或动词短语

  • 别用与文化紧密相关的命名

  • 每个概念对应一个词

    • 不要使用概念意义相似的多个词,造成命名意义混乱
  • 不要使用双关语,要一词一义

    • 避免同一命名用于不同概念
  • 不要使用解决方案领域名称,尽量用计算机科学术语

  • 迫不得已,可以使用所涉问题领域的名称

  • 可加入有意义的语境使命名更具指向性

    • 类名,函数名,命名空间
    • 命名前缀
  • 不要添加无用指向性作用的语境

3. 函数

  • 短小
    • 每个函数都只做一件事
    • 而且会依序把读者带到下一个函数
    • 缩进层级不该多于两层
  • 只做一件事
    • 是否可以拆分出一个函数以改变抽象层级
    • 而不仅仅重新诠释了代码
  • 每个函数一个抽象层级
    • 向下规则:每个函数后面跟着位于下一个抽象层的函数
  • 使用描述性名称
    • 长而具有描述性比短而令人费解要好

3.1 switch

  • 单一权责原则(Single Responsibility Principle,SRP):已知有修改的理由
  • 开放闭合原则(Open Closed Principle,OCP):每当添加新类型必须修改函数
  • 解决办法:把switch埋藏在抽象工厂内

3.2 函数参数

  • 最理想参数数量为0;其次是1,2;极其特殊情况下才能用3个以上
  • 参数数量直接影响到各种参数组合运行的测试用例数量
  • 输出参数比输入参数更难以理解

3.2.1 单参数函数

  • 判断关于参数的问题的答案
  • 转换为其他东西,然后返回
  • 事件(event)
  • 避免使用输出参数

3.2.2 标识参数

  • 避免使用标识参数
  • 可分解成两个函数

3.2.3 双参数

  • 参数的天然顺序
  • 转换为单参数
    • 把其中一个参数变为类的成员变量
    • 构造一个新类,在其构造器中采用一个参数;原函数变为该类的一个单参数方法

3.2.4 三参数

  • 尽全力避免

3.2.5 参数对象

  • 可考虑将其中一些参数包装成类

3.2.6 参数列表

  • 参数列表本质上是一个类型为List的单个参数
  • 可变参数的函数根据实际情况可归类为单参数,双参数或者三参数

3.2.7 动词和关键词

  • 单参数函数,函数和参数应形成良好的动/名对形式
    • write(name)
    • writeField(name),更优,明示了name的类型
  • 关键字(keyword)形式,把参数名称编码进函数名
    • assertExpectedEqualsActual(expected, actual)

3.3 避免副作用

  • 避免函数在执行过程中输出隐藏的结果,导致时序性耦合及顺序依赖
  • 如有,在函数名中明确示意
  • 输出参数
    • 应改为修改所属对象的状态

3.4 分隔指令与询问

  • 修改对象状态vs返回对象状态的有关信息
  • 两者同时进行会引起混乱
  • 应把两者拆分成单独函数

3.5 使用异常替代返回错误码

  • 分离有助于把错误处理代码与主干代码隔离开
  • 抽离 try/catch 代码块,另外形成函数
  • 错误处理就是单一事务
    • try是改函数的第一个单词
    • catch/finally代码块后不该有其他内容
  • 避免错误码依赖

3.6 重复代码

  • 重复是软件中一切邪恶的根源
  • 把重复的代码整合成一个或多个函数

4. 注释

  • 别给糟糕的代码加注释--重新写吧 - Brian W.Kernighan 与 P.J Plaugher
  • 注释是为了弥补在用代码表达意图时的失败
  • 注释存在时间越长,与其描述的代码的偏差就越大
  • 程序员不能坚持维护注释
  • 注释不会总随着代码变动
  • 不准确的注释比没注释更糟糕
  • 真实只存在代码中

4.1 注释不能美化糟糕代码

  • 如果代码过分糟糕以至于需要注释来理清逻辑,最好的办法是优化代码

4.2 用代码来解释

  • 如创建一个与注释所言事物相同的函数

4.3 好的注释

4.3.1 法律信息

  • 版权与著作声明
  • 不应是合同或法典
  • 如有可能,指向一份标准许可或其他外部文档
  • 而不是把所有条款放在注释中

4.3.2 提供信息的注释

  • 如解释某抽象函数的返回值
  • 最好的方式是利用函数名传达信息

4.3.3 对意图的解释

  • 如反映一个决定

4.3.4 阐释

  • 把参数或返回值的意义翻译成某种可读形式
  • 如在使用某种表达不清的标准库或无不能修改的代码的时候,注释以阐释代码含义就会有意义

4.3.5 警示

  • 用于警示其他程序员可能出现的某种后果
  • 如,运行函数会占用大量资源

4.3.6 TODO

  • 记录应该做,但由于某些原因还没做的工作
  • 如,删除某个不必要的特性
  • 要求他人注意某个问题
  • 定期查看,删除不再需要的TODO

4.3.7 凸显某种代码的意义

  • 凸显代码中看似不合理之处的实际意义

4.4 坏注释

4.4.1 喃喃自语

  • 表达不清晰,留给其他程序员更多的疑问而不是答案

4.4.2 多余的注释

  • 注释不及代码精准
  • 将代码搞得含混不清

4.4.3 误导性注释

  • 不够精准,且有误导嫌疑的注释

4.4.4 循规式注释

  • 如,要求每个Java函数都要有Javadoc注释,徒添混乱

4.4.5 日志式注释

  • 记录代码修改的日志
  • 应使用源代码管理系统取代

4.4.6 废话注释

4.4.7 可怕的废话

4.4.8 能用函数或变量示意时就别用注释

4.4.9 位置标记

  • 避免滥用

4.4.10 括号后的注释

  • 如果发现自己想标记右括号,就应该缩短函数

4.4.11 归属与署名

  • 使用源代码控制系统取代

4.4.12 注释掉的代码

  • 合理使用源代码控制系统
  • 应删掉注释掉代码,避免其他程序员不敢删除

4.4.13 HTML注释

  • 造成不必要的注释阅读障碍
  • 该由工具自动添加合适的HTML标签

4.4.14 非本地信息

  • 确保注释描述的是离它最近的代码片段
  • 不要在本地注释的上下文中写系统级的注释

4.4.15 信息过多

4.4.16 不明显的关系

  • 注释应能解释未能自行解释的代码

4.4.17 函数头

  • 为短函数选个好名字,通常比写函数头注释好

4.4.18 非公共代码中的Javadoc

  • 为系统中的类和函数生成Javadoc并非总有用

5. 格式

  • 选用一套管理代码格式的简单规则,然后贯彻这些规则
  • 团队应该一致同意采用一套简单的格式规则

5.1 格式的目的

  • 代码格式关乎沟通,而沟通是专业开发者的头等大事
  • 代码可能在版本更替中被修改,而代码的可读性对以后的修改行为影响深远
  • 代码被修改,但代码风格和可读性仍会影响代码的可维护性和可延展性

5.2 垂直格式

  • 行数
  • 短文件比长文件更易于理解
  • 使用多数为200行,最长500行的单文件也可以构造出出色的系统

5.2.1 向报纸学习

报纸

  • 第一段,整体故事的大纲,粗线条描述,隐藏细节
  • 之后的部分细节逐步增加

源文件

  • 名称应简单且一目了然
  • 名称本身应足以告诉读者是否在正确的模块中
  • 源文件顶部应给出高层次的概念和算法

报纸

  • 由多篇文章组成,短小精悍,虽然有些稍长,很少占满一整页
  • 充斥毫无组织的实事,日期,名字,就没人会去读它

5.2.2 垂直方向上的概念隔离

  • 空白行 在封装声明,导入声明和每个函数之间
  • 每个空行都标识出新的独立概念

5.2.3 垂直方向上的靠近

  • 紧密相关的代码应该相互靠近
  • 如,相关的实体变量间不应该添加注释导致相关代码间的割断

5.2.4 垂直距离

  • 关系密切的概念应该互相靠近
  • 不要把关系密切的概念放在不同的文件中
  • 对概念可读性的影响程度决定不同概念的间隔距离

变量声明

  • 应尽可能靠近使用位置
  • 短函数中,本地变量应在函数顶部出现

实体变量

  • 应该在类的顶部声明
  • 这类变量会被大多数方法使用

相关函数

  • 若存在调用关系,就应该放在一起
  • 调用者在被调用者上方

常量

  • 要放在适当级别
  • 不该把常量埋藏在不太合适的底层函数中
  • 要放在更易于找到的位置,然后再传递到真实使用的位置

概念相关

  • 相关性建立在直接依赖的基础上
  • 也可能来至执行相似操作的一组函数
  • 拥有相似命名模式的一组函数,属于执行同一基础任务的不同变种、

5.2.5 垂直顺序

  • 自上而下展示函数调用依赖顺序
  • 最重要的概念先出现,以包含最少细节的方式表述
  • 而底层细节最后出现

5.3 横向格式

  • 单行字数(宽度)
  • 应尽量保持代码行适当长度
  • 无需拖动混动条即可看到最右边的代码
  • 作者建议:120字

5.3.1 水平方向上的区隔与靠近

  • 空格
  • 赋值操作符周围加上空格字符
  • 函数名与左括号之间不添加空格,以显示函数与其参数密切相关
  • 括号中参数需要空格一一隔开,表示参数是相互分离的
  • 加空格以强调低优先级的运算符

5.3.2 水平对齐

  • 无需对齐声明与赋值
  • 如果列表较长需要做对齐处理,那问题就是在列表的长度上,应该把类拆分

5.3.3 缩进

  • 源文件是一种继承结构,而不是大纲结构
  • 要让继承结构可见
  • 类方法缩进
  • 方法的实现相对方法缩进
  • 代码块的实现相对其容器缩进

5.3.4 空范围

  • 空范围体缩进,以避免造成误会

5.4 团队规则

  • 括号放置位置
  • 缩进字符数
  • 类,变量,方法命名规则
  • 好的软件系统需要由一系列读起来不错的代码文件组成
  • 使读者在一个源文件中看到的格式风格在其它文件中也适用

6. 对象和数据结构

6.1 数据抽象

  • 隐藏实现
  • 即便变量私有,通过取值器和赋值器使用变量也会把实现暴露
  • 隐藏实现并非单单在变量间加上一层函数
  • 隐藏实现关乎抽象
  • 类并不简单地使用取值器和赋值器将变量推向外界
  • 暴露抽象接口,以便用户无需了解数据的实现即可操作数据本体(essence)
  • 最好的方式呈现对象包含的数据需要进行严肃的思考
  • 随意加取值器和赋值器是最坏的选择

6.2 数据,对象的反对称性

  • 对象把数据隐藏在抽象之后,暴露操作数据的函数
  • 数据结构暴露其数据,并不提供有意义的函数
  • 对象与数据结构之间的二分原理
    • 过程式代码(使用数据结构的代码)便于在不改动既有数据结构的前提下添加新函数
    • 面向对象代码便于在不改动既有函数的前提下添加新类
  • 如此类推
    • 过程式代码难以添加新数据结构,因为添加就必须修改所有函数
    • 面向对象代码难以添加新函数,因为添加就必须修改所有类

6.3 得墨忒定律(The Law of Demeter)

  • 模块不应了解他所操作对象的内部情形
  • 对象隐藏数据,暴露操作
  • 对象不应该通过存取器暴露其内部结构
  • 得墨忒认为,一个类名为C得方法f只应该调用以下对象的方法:
    • C
    • f所创建的对象
    • 作为参数传递给f的对象
    • C的实体变量持有的对象
  • 方法不应该调用任何函数返回的对象的方法

6.3.1 火车失事

  • 连串的调用通常被认为是肮脏的风格
  • 是否违反得墨忒定律,取决于被调用的是对象还是数据结构
  • 可以把连串调用封装为该调用对象的方法,避免内部结构的暴露

6.3.2 混杂

  • 混合结构:部分对象,部分数据结构
  • 这种结构拥有执行操作的函数,也有公共变量或公共访问器及改值器
  • 公共变量及改值器会把私有变量公开,诱导外部函数以过程式程序使用数据结构的方式使用这些变量
  • 这种结构既增加了添加新函数的难度
  • 也增加了添加新数据结构的难度
  • 起因大多是作者不确定,甚至无视对函数或类型的保护

6.3.3 隐藏结构

  • 分析结构暴露的最终目的
  • 把实现该目的的操作封装成对象的函数,通过暴露接口而非暴露数据结构的方式实现编程目的

6.4 数据传送对象

  • 最精炼的数据结构,是一个只有公共变量,没有函数的类
  • 这种结构有时被称为数据传送对象(Data Transfer Objects)DTO
  • 常用于与数据库通信或者解析套接字传递的消息之类的场景中
  • 作用于将原始数据转换成数据库的翻译过程中

Active Record

  • 一种特殊的DTO形式
  • 拥有公共变量
  • 拥有类似save和find这样的可浏览方法
  • 一般是对数据库表或者其他数据源的直接翻译
  • 不该把这类数据结构当成对象使用,在其中塞入业务规则方法
  • 应额外创建包含业务规则,隐藏数据的独立对象(可以是Active Record的实体)

7. 错误处理

  • 当错误发生时,程序员有责任确保代码照常运作
  • 错误处理很重要,但应避免搞乱了代码逻辑

7.1 使用异常而非返回码

  • 使用返回码限制了调用者必须在调用后即可检查错误(而这一步往往容易被忽略)
  • 使用分离异常使得业务代码和错误处理逻辑

7.2 先写 try-catch-finally 语句

  • try表明了代码块中业务可随时取消执行,并在catch语句中继续
  • 在编写可能抛出异常的代码时,先写出 try-catch-finally 语句
  • 用 try-catch 结构定义范围,用测试驱动开发(TDD)方法构建剩余的代码逻辑
  • 编写强行抛出异常的测试,再往处理逻辑中添加行为,使之满足测试要求

7.3 使用未检异常

  • 已检异常(checked exception):
    • 每个方法的签名都列出它可能传递给调用者的异常
    • 如果签名与代码实际所做的事不符,代码在字面上就无法编译
  • 已检异常的代价是违反开放/闭合原则
    • 如果在方法中抛出已检异常,而catch语句在3个层级以上,就需要在catch语句和抛出异常处之间的每个方法签名中声明该异常
    • 导致对软件中低层级的修改,都将波及高层级的签名
    • 而修改好的模块需要重新构建,发布(即便它们自身执行的事务并未改动)
    • 每个函数都要去了解底层的异常细节
  • 对于关键代码库,已检异常或许有用:必须捕获异常
  • 对于一般开发,往往成本高于收益

7.4 给出异常发生的环境说明

  • 抛出的异常,都应当提供足够的环境说明,以判断错误的来源和位置
  • Java中,有栈踪迹(stack trace),但它无法准确指出失败操作的原由
  • 创建信息充分的错误信息,与异常一同传递出去
  • 包括失败操作和失败类型
  • 如需要,把信息传递给catch块,在日志系统中记录下来

7.5 依调用者需要定义异常类

  • 对异常的分类有很多方式
    • 来源分类:来之组件的异常
    • 类型分类:设备错误,网络错误,编程错误
  • 最重要的考量点:它们如何被捕获
  • 通过打包类把可能返回得多个异常统一,返回通用异常,以简化调用代码
  • 将第三方API打包是个良好的实践
    • 方便将来改用其他代码库
    • 方便测试时,模拟第三方调用

7.6 定义常规流程

  • 遵顼以上建议,在业务逻辑与错误处理代码之间就会有良好的区隔
  • 大量代码变为整洁而简朴的算法

特例模式(Special Case Pattern)

  • 创建一个类或配置一个对象,来处理特例(把异常行为封装到特例对象中)
  • 使得客户代码无需应对异常行为

7.7 别返回 null 值

  • 返回null值容易引发错误
  • 只要一处调用没检测null值,程序就有可能失控
  • 不如抛出异常,或是返回特例对象
  • 需要调用第三方API中可能返回null的方法:
    • 考虑使用新方法进行打包,在新方法中抛出异常或特例对象
  • 如果适用,可以返回空列表

7.8 别传递 null 值

  • 不要把null传递给其他方法(除非API要求你这么做)
  • 大多数编程语言,没有良好的方案应对由调用者意外传入的null值

8. 边界

  • 将外来代码干净利落地整合进自己的代码中

8.1 使用第三方代码

  • 接口提供者和使用者之间的矛盾
    • 接口提供者追求普遍适用性,使之能在多种环境中工作,吸引广泛的用户
    • 使用者则想要得到集中满足特定需求的接口
  • 通过封装适当降低第三方边界接口灵活性以确保程序的稳定性

8.2 浏览和学习边界

  • 第三方代码使在更少时间发布更丰富的功能成为可能
  • 要为使用到的第三方代码编写测试
  • 不要在生产代码中试用新东西
  • 通过编写测试来遍览和理解第三方代码(学习性测试 learning test)
  • 通过核对试验来检测对API的理解程度
  • 测试聚焦于想从API得到的东西

8.3 学习 log4j

  • 了解如何初始化一个简单的控制台日志器,并封装成一个日志类
  • 将应用程序的其他部分与log4j的边界接口隔离开来

8.4 学习性测试的好处

  • 编写测试时获得API知识的途径
  • 学习性测试是一种精确试验
  • 当第三方程序发布新版本,运行学习性测试可以低成本验证程序包是否有不兼容的更新
  • 需要一系列与生产代码中调用方式一致的输出测试来支持整洁的边界
  • 边界测试可以减轻迁移的压力

8.5 使用尚未存在的代码

  • 有一种边界是把已知和未知分隔开的边界
  • 在第三方接口未为被开发前,先按照自身程序需要自定义所需接口,有助于保持客户代码可读性
  • 在第三方接口开发出来后,通过 Adapter 封装与该接口的互动

8.6 整洁的边界

  • 边界是改动发生的地方
  • 良好的软件设计能有效降低修改代价
  • 在使用无法控制的代码时,必须考虑未来的修改代价
  • 边界上的代码需要清晰的分割和定义了期望的测试
  • 避免自有代码过多地了解第三方代码中的特定信息
  • 通过控制少数几处引用第三方边界接口的位置来管理第三方边界
  • 两种主要方案:
    • 封装成适用的类,以控制变量
    • 通过Adapter模式把自有接口转化成第三方接口

9. 单元测试

9.1 TDD 三定律

  • 第一定律 在编写不能通过的单元测试前,不可编写生产代码
  • 第二定律 只可编写刚好无法通过的单元测试,不能编译也算通不过
  • 第三定律 只可编写刚好足以通过当前失败测试的生产代码
  • 这三条定律将你限制在大概30秒的一个循环中

9.2 保持测试整洁

  • 测试代码和生产代码一样重要
  • 同洋需要思考,设计和维护,该像生产代码一样保持整洁
  • 单元测试使代码可扩展,可维护,可复用
  • 测试覆盖率越高,代码改动时就更有信心
  • 覆盖了生产代码的自动化单元测试程序组能尽可能地保持设计和架构地整洁

9.3 整洁的测试

  • 整洁的测试地主要要素:可读性
  • 以尽可能少地文字表达大量内容
  • 测试可拆分为三个环节:构造-操作-检验(BUILD-OPERATE-CHECK)模式
    • 第一个环节构造测试数据
    • 第二个环节操作测试数据
    • 第三个环节检验操作是否得到期望地结果

9.3.1 面向特定领域的测试语言

  • 不直接使用程序员用来对系统进行操作的API,而是打造一套包装这些API的函数和工具代码
  • 形成一种测试语言,帮助程序员编写测试,也方便后来者阅读测试
  • 这类测试API并非起初就设计好,而是对令人充满迷惑细节的测试代码进行重构后逐渐演进的

9.3.2 双重标准

  • 有些实践在生产环境中应尽量避免,但在测试环境中完全没问题
  • 测试代码应简单,精悍,具表达力,与生产代码一样有效
  • 由于测试环境跟生产环境不尽相同,环境允许情况下,测试代码的效率可以不必做限制
  • 测试代码中可以破环思维映射规则以换取一目了然

9.4 每个测试一个断言

  • 每个测试函数都应该有且只有一个断言语句
  • 但在有些情况下这一限制会导致重复代码的出现
  • 可以利用模板方法(Template Method)模式,将 given/when 部分放入基类中,将 then 放入派生类消除代码重复问题
  • 也可以创建一个完整的单独测试类,把 given 和 when 放到 @Before 函数中,把 when 放到每个@Test 函数中
  • 更好的规则是,每个测试中的断言数量应该最小化

每个测试一个概念

  • 每个测试函数中只测试一个概念
  • 使函数名命名更直截了当

9.5 F.I.R.S.T.

  • Fast. 测试应该能快速运行,运行缓慢的测试终将导致测试无法频繁运行,代码问题不能得到及时发现并修正
  • Independent. 测试应该相互独立,某个测试不应该为下一个测试设定条件
  • Repeatable. 测试可以在任何环境中重复通过
  • Self-Validating. 测试应该有布尔值输出
  • Timely. 测试应及时编写。测试代码应该恰好在使其通过的生产代码之前编写。如果在编写生产代码之后编写测试,生产代码将会难以测试

10. 类

10.1 类的组织

  • 由一组变量列表开始
    • 公共静态变量
    • 私有静态变量
    • 私有实体变量
    • 公共变量
  • 公共函数
  • 公共函数调用的私有函数

10.2 类应该短小

  • 类的名称应当描述其权责
  • 命名有助于帮助判断类的长度是否恰当
    • 如果无法为一个类用精确的名称命名,这个类大概太长了

10.2.1 单一权责原则

  • SRP 单一权责原则 类或模块应该有且仅有一条加以修改的理由
  • 鉴别权责(修改的理由)常常帮助我们在代码中认识到并创建出更好的抽象
  • 去耦式单元
  • 每个达到一定规模的系统都会包括大量逻辑和复杂性,而管理这种复杂性的首要手段就是加以组织

10.2.2 内聚

  • 类应该只有少量实体变量
  • 类的每个方法都应该操作一个或多个这种变量
  • 通常方法操作的变量越多,就越内聚到类上
  • 最大内聚性,方法中的每个变量都被每个方法所使用
  • 一般而言,创造最大内聚性既不可取也不可能
  • 应尽量把内聚性保持在较高的位置
  • 内聚性高,意味着类中的方法和变量相互依赖,互相结合成一个逻辑整体

10.2.3 保持内聚性就会得到许多短小的类

  • 当类失去内聚性,就拆分它
  • 将大函数拆分成许多小函数,往往也是将类拆分为多个小类的时机
  • 重构后程序大概路会更多行数
    • 更长,更具有描述性的变量名
    • 函数和类声明当作是代码注释的一种手段
    • 采用空格和格式化技巧使程序更可读

10.3 为了修改而组织

  • 对于多数系统,修改将一直持续
  • OCP 开放闭合原则。类应当对扩展开放,对修改封闭 - Open Closed Principle
  • 精心组织系统,从而在添加或修改特性时尽可能少惹麻烦
  • 在理想系统中,我们通过扩展系统而非修改现有代码来添加新特性

隔离修改

  • 需求会改变,代码也会改变
  • 具体类包含实现细节,抽象类则只呈现概念
  • 借助接口和抽象类来隔离修改具体类细节时的风险
  • 部件之间的解耦代表系统中的元素相互隔离得很好
  • DIP 依赖倒置原则 类应当依赖抽象而不是依赖具体细节 - Dependency Inversion Principle

To be continue