计算机 · 2021年8月12日 0

Clean Code

一、整洁代码

对于想要写出整洁代码的人来说,最重要的一点是用心或者在意,即你在想着用各种方法要去写好自己的代码,心里始终有着这种需求。

代码确然是我们最终用来表达需求的那种语言。
所以代码永存。

勒布朗(LeBlanc)法则:稍后等于永不(Later equals never)。

程序员遵从不了解混乱风险的经理的意愿,也是不专业的做法。

什么是整洁代码?

Bjarne Stroustrup:

我喜欢优雅和高效的代码。代码逻辑应当直截了当,叫缺陷难以隐藏;尽量减少依赖关系,使之便于维护;依据某种分层战略完善错误处理代码;性能调至最优,省得引诱别人做没规矩的优化,搞出一堆混乱来。整洁的代码只做好一件事。

Ron Jeffries:

  • 能通过所有测试;
  • 没有重复代码;
  • 体现系统中的全部设计理念;
  • 包含尽量少的实体,比如类、方法、函数等。

二、有意义的命名

  • 名副其实
  • 避免误导
  • 做有意义的区分
    • 尽量让每个变量的名字有意义,不要搞些a1,a2这种名字,这种名字为读代码的人提供不了有意义的信息
    • 避免废话,比如一个CustomerObject这种命名里的Object就是废话
  • 使用读得出来的名称
  • 使用可搜索的名称
    • 作用域越大名字越长
    • 同一类型/类别的变量/函数可以有相同的前缀(自己的经验,觉得这样可以方便查找同一类别的变量/函数)
  • 避免使用编码
    • 匈牙利语标记法
      Hungarian Notation,HN,像这种东西是以前变量名长度有限制或者编译器不能做类型检查的产物,现在不应该再使用这种东西(?待讨论,其实在C/C++中这种命名法还是有用的,在Java中则不需要);
    • 成员前缀
      我也很好奇,为什么有些人喜欢为类的成员变量加前缀,尤其是在类的构造函数中参数名和成员变量同名的时候,明明有this关键字来帮助区分的。
  • 接口和实现
    本书作者反对IShapeFactory这类的接口命名,认为应该用ShapeFactory来命名接口,然后用ShapeFactoryImpl和CShapeFactory这类命名来命名实现。
  • 避免思维映射

聪明程序员和专业程序员之间的区别在于,专业程序员了解,明确是王道。专业程序员善用其能,编写其他人能理解的代码。

  • 类名尽量少用动词
  • 方法名用动宾短语;遵从Javabean标准;
  • 别扮可爱
    不要乱取名字,显得白痴
  • 别用双关语
    其实就是要找到最准确的那个词来命名,像作家写作一样,不要用泛泛的词语来描述或者命名。
  • 使用解决方案领域名称
    因为代码是写给程序员读的,所以尽情地使用专业术语来命名
  • 使用源自所涉问题领域的名称
    当我们找不到程序员熟悉的术语来命名,可以使用从所涉问题领域而来的名称(或者和业务相关的名称)
  • 添加有意义的语境
    对于复杂的函数或者代码片段,可以采取封装一些类的方式来让代码的组织更有调理
  • 不要添加没用的语境
    虽然命名要名副其实,即在名字里体现出变量或者函数的含义,但是我们也不想让代码写得冗长,能短就短。

取好名字最难的地方在于需要良好的描述技巧和共有文化背景。

与其说这是一种技术,不如说是一种表达能力,一种沟通技巧。

三、函数

  • 短小
  • 做好一件事且仅做好这一件事
  • 每个函数一个抽象层级
    函数中的语句都要在同一抽象层级上,这样方便读代码的时候一眼就能看出来这个函数干了些什么工作;我们读源代码时往往是自上向下的,函数里的语句都在同一个抽象级别方便我们先看明白程序/函数的大意,然后再根据需要去查看被调用的函数的更细致的实现。
  • 关于switch语句
    这段没读懂
  • 使用描述性的名称
  • 函数参数
    • 函数参数个数越少越好
    • 如果可以尽量把有多个参数的函数分解为参数个数少一些的函数
    • 输入参数过多就可以考虑封装成专门的类了
  • 无副作用
    不要做多余的事以及尽量少使用输出参数。
  • 分隔指令与询问
  • 使用异常替代返回错误码
    • 抽离Try/Catch代码块
    • 错误处理就是一件事 如果关键字在某个函数中存在,它就该是这个函数的第一个单词,而且在catch/finally代码块后面也不该有其他内容。
    • 错误码的使用(枚举或者类)会导致其他的类都依赖这个类,当这个错误码的类修改时,就使得其他的类需要重新编译和部署,使用异常代替错误码,新异常可以从异常类派生出来,无需重新编译或重新部署(不懂这个说法)。
  • 消除重复
    重复的代码就应该归纳抽象成函数
  • 不强求一次性就按照这些规则写好函数,可以慢慢修改以遵循这些有益的规则

四、注释

  • 好的代码本身才是关键而不是靠注释美化糟糕的代码
  • 代码本身应该是具有描述性、准确性的,最好能通过直接读代码读懂代码的逻辑,尽量少依赖注释
  • 可以添加的注释
    • 法律信息/版权信息
    • 一些特殊信息,如警告信息、采用非常规做法的理由
    • todo list
  • 不要把代码通过注释的方式留下来,这样别人很需要勇气才能把这些注释掉的代码删掉
  • 由版本控制系统可以实现的功能就不要再通过注释实现了,比如作者信息、版本更迭等
  • Javadoc基本上是好的模板,除了HTML注释(主要是表格),因为HTML格式的代码比较难读,不过这本书也没指出好的解决办法

五、格式

  • 善用IDE的自动格式化功能
  • 遵循一致的代码格式
  • 垂直方向上:
    • 每个代码文件不要太大(太多行);每个函数、类不要太大;拆分,模块化
    • 代码行数多不多,可以用需要翻几次屏才能看完代码作标准;
    • 越底层的函数放在代码的越后面,这样符合读代码时从上至下的方式;
  • 水平方向
    • 每行代码不要太长
    • 缩进规则
    • 对于大括号的使用,我一般喜欢把左括号和函数放在同一行;
  • 遵从团队订制的规则

六、对象和数据结构

  • 不要暴露实现而是通过函数提供操作方法
  • 对于用于传输目的的对象的类最好是只有公共变量而没有函数的类

七、错误处理

  • 使用异常处理而非错误码,主要是因为可能忽略返回的错误码
  • 可控异常(checked exception)
    对于签名中包含了可控异常的底层函数,其改动会波及上层代码。这违反了封装原则。
  • 给出异常发生的环境说明
    就是说异常里要包含足够的信息方便判断抛出这个异常的原因
  • 不要返回null值
    避免增加检查null值这种代码/工作量
  • 不要传递null值

八、边界

如何干净利落地使用来自第三方的库/软件包?

  • 可以通过封装来减少第三放库可能被替代/修改带来的影响
  • 学习性测试(learning test),通过测试我们想要使用的API/功能来学习第三方库的使用方法
  • 使用尚不存在的代码
    不要因为别人没有写出实现相应功能的代码而阻塞自己的进度,可以写点简单的模拟代码来解决这个阻塞
  • 整洁的边界
    • 良好的软件设计会使得我们使用的不可控制的第三方代码发生改动时对我们的代码影响尽可能小
    • 避免我们的代码过多地了解第三方代码中的特定信息

九、单元测试

个人觉得写库的时候写单元测试挺好的,写业务逻辑相关的东西单元测试真是没法弄。

  • TDD(Test Driven Development)
    觉得比较适合写库
  • 保持测试整洁
    测试代码应该像生产代码一样保证质量
  • 双重标准
    可以根据测试代码和生产代码的实际不同的运行环境采用不同的代码质量的标准
  • 面向特定领域的测试语言
    像assertTrue、assertFalse这种在测试里经常要用到的api/函数就是所谓的测试语言了
  • 每个测试一个断言
    其实就是一个测试测一个东西,就像一个函数只做一件事情一样
  • F.I.R.S.T.
    • 快速(Fast)
    • 独立(Independent)
    • 可重复(Repeatable)
    • 自足验证(Self-Validating)
      测试结果应该直接通过布尔值表达不出来,不要输出一堆东西让使用者还得费时费力比较输出和标准输出一不一样。

十、类

  • 类应该短小
    • 单一权责原则(SRP),类应该只有一条加以修改的理由
    • 内聚
      类的成员变量应该尽量少

十一、系统

  • 将系统的构造与使用分开

软件系统应将起始过程和起始过程之后的运行时逻辑分离开,在起始过程中构建应用对象,也会存在互相缠结的依赖关系(这一小句没看懂)。
就是说延迟初始化/赋值这类技巧其实对于软件的结构来说有不好的影响。

  • 扩容

与物理系统相比软件系统比较独特。它们的架构都可以递增式地增长,只要我们持续将关注面恰当地切分。

  • Java代理
    反射的应用?InvocationHandler?
  • 纯Java AOP框架
  • 测试驱动系统架构

先做大设计(Big Design Up Front, BDUF)
最佳的系统架构由模块化的关注面领域组成,每个关注面均用纯Java(或其他语言)对象实现。不同的领域之间用最不具有侵害性的方面或类方面工具整合起来。这种架构能测试驱动,就像代码一样。

  • 优化决策
    拥有模块化关注面的POJO系统提供的敏捷能力,允许我们基于最新的知识做出优化的、时机刚好的决策。决策的复杂性也降低了。
  • 明智使用添加了可论证价值的标准
    有了标准,就更易复用想法和组件、雇用拥有相关经验的人才、封装好点子,以及将组件连接起来。不过,创立标准的过程有时却漫长到行业等不及的程度,有些标准没能与它要服务的采用者的真实需求相结合。
  • 系统需要特定领域特定语言
    DSL在有效使用时能提升代码惯用法和设计模式之上的抽象层次。它允许开发者在恰当的抽象层级上直指代码的初衷。
    领域特定语言允许所有抽象层级和应用程序中的所有领域,从高级策略到底层细节,使用POJO表达。

一句尤其重要的话:
无论是设计系统或单独的模块,别忘了使用大概可工作的最简单方案

十二、 迭进

Kent Beck关于简单设计的四条规则(按重要程度排序)可以帮助创建具有良好设计的软件:

  1. 运行所有测试

测试消除了对清理代码就会破坏代码的恐惧

  1. 不可重复
  2. 表达了程序员的意图
  3. 尽可能减少类和方法的数量

十三、 并发编程

  • 为什么要并发 并发是一种解耦策略。它帮助我们把什么(目的)和何时(时机)做分解开。
  • 并发防御原则
    • 单一权责原则
      分离并发相关代码与其他代码
      • 推论1:限制数据作用域
      • 推论2:使用数据复本
      • 推论3:线程应尽可能地独立
  • 并发编程中的基础概念
概念基础定义
限定资源并发环境中有着固定尺寸或者数量的资源。例如数据库连接和固定尺寸读/写缓存等
互斥每一时刻仅有一个线程能访问共享数据或共享资源
线程饥饿一个或一组线程在很长时间内或永久被禁止。例如,总是让执行得快的线程先运行,假如执行得快的线程没完没了,则执行时间长的线程就会“挨饿”
死锁两个或多个线程互相等待执行结束。每个线程都拥有其他线程需要的资源,得不到其他线程拥有的资源,就无法终止
活锁执行次序一致的线程,每个都想要起步,但发现其他线程已经“在路上”。由于竞步的原因,线程会持续尝试起步,但在很长时间内却无法如愿,甚至永远无法启动
  • 并发编程中的经典问题
    • 生产者-消费者模型
    • 读者-作者模型
    • 宴席哲学家
  • 警惕同步方法之间的依赖
  • 保持同步区域微小 关键字synchronized制造了锁。同一个锁维护的所有代码区域在任一时刻保证只有一个线程执行。锁是昂贵的,因为它们带来了延迟和额外开销。避免锁定不需要锁定的代码,减少开销。
  • 很难编写正确的关闭代码 就是说你要考虑会不会向你想象中那样正常退出。
  • 测试线程代码
    1. 将伪失败看作可能的线程问题 不要放过任何可能存在的问题,不要存在侥幸心理,不要将系统错误归咎于偶发事件(比如宇宙射线、太阳黑子、硬件错误上)。
    2. 先使非线程代码可工作 不要同时追踪非线程缺陷和线程缺陷。确保代码在线程之外可工作。
    3. 编写可插拔的线程代码 编写可在数个配置环境下运行的线程代码:
      • 单线程与多个线程在执行时不同的情况;
      • 线程代码与实物或测试替身互动;
      • 用运行快速、缓慢和有变动的测试替身执行;
      • 将测试配置为能运行一定数量的迭代。
    4. 编写可调整的线程代码
      运行修改线程数量、线程优先级,允许程序根据运行环境动态地自我调整线程相关参数。
    5. 运行多于处理器/核数量的线程
      把线程数目设置得比较大的时候,就可以促使任务交换的发生,任务交换越频繁,越有可能找到错过临界区或导致死锁的代码。
    6. 在不同平台上运行
    7. 装置试错代码 就是修改原有代码,进行基于变异的测试,比如随机地在代码里插入yield这种方法的调用
      • 手工硬编码变异
      • 自动化,使用现有的库、框架

十四、 逐步改进

代码能工作还不够,能工作的代码经常会严重崩溃。满足于仅仅让代码能工作的程序员不够专业。他们会害怕没时间改进代码的结构和设计,我不敢苟同。没什么能比糟糕的代码给开发项目带来更深远和长期的损害了。进度可以重订,需求可以重新定义,团队动态可以修正。但糟糕的代码只是一直腐败发酵,无情地拖着团队的后腿。我无数次看到开发团队蹒跚前行,只因为他们匆匆搞出一片代码沼泽,从此之后命运再也不受自己控制。

十七、 味道与启发

本章列出了一些让人不舒服的代码“味道”,其实也就是一些不推荐的代码风格/习惯,在前面的章节基本都或多或少地提到过。

  • 注释
    • 不恰当的信息
    • 废弃的注释
    • 冗余注释
    • 糟糕的注释
    • 注释掉的代码
  • 环境
    • 需要多步才能实现的构建
    • 需要多步才能做到的测试
  • 函数
    • 过多的参数
    • 输出参数
    • 标识参数
    • 死函数(永不被调用的方法)
  • 一般性问题
    • 一个源文件中存在多种语言
    • 明显的行为未被实现(遵循正常人的逻辑) 最小惊异原则(The Principle of Least Surprise)
    • 不正确的边界问题
    • 忽视安全
    • 重复
    • 在错误的抽象层级上的代码
    • 基类依赖于派生类
    • 信息过多
    • 死代码
    • 垂直分隔 变量和函数应该在被使用的地方定义
    • 前后不一致
    • 混淆视听 没有实现的默认构造器、没有用到的变量、从不掉用的函数、没有信息量的注释;这些不应存在的东西干扰我们对代码的理解
    • 人为耦合 不互相依赖的东西不该耦合。 不要乱用内部类,因为这样为了使用这个类你还得去了解包含这个内部类的类,产生了多余的依赖关系。
    • 特性依恋
      如果一个方法频繁(且仅仅)地用一个类的set/get方法,那么可以考虑把这个方法变为这个类的方法了。
    • 选择算子参数
      类似标识参数,不如把使用这种参数的函数拆分为几个名字更有意义的函数
    • 晦涩的意图
    • 位置错误的权责
      变量、函数要根据其作用放在合适的位置。
    • 不恰当的静态方法
    • 使用解释性变量
      就是说用有意义的单词命名的变量来存储计算过程的中间值。
    • 函数名称应该表达其行为
      要名副其实,言行一致
    • 理解算法
    • 把逻辑依赖改为物理依赖
      就是说要让依赖关系符合自然规律,符合人的常规思维,不要搞出一些奇怪的类的属性间的依赖?
    • 用多态替代If/Else或Switch/Case
      对于给定的选择类型,不应有多于一个switch语句。在那个switch语句中的多个case,必须创建多态对象,取代系统中其他类似switch语句
    • 遵循标准约定
    • 用命名常量替代魔术数
    • 准确 在代码中做决定时,确认自己足够准确。明确自己为何要这么做,如果遇到异常情况如何处理。别懒得理会决定的准确性。如果你打算调用可能返回null的函数,确认自己检查了null值。如果查询你认为是数据库中唯一的记录,确保代码检查不存在其他记录。如果要处理货币数据,使用整数,并恰当地处理四舍五入。如果有并发更新,确认你实现了某种锁机制。
      代码中的含糊和不准确要么是意见不同的结果,要么源于懒惰。无论原因是什么,都要消除。
    • 结构甚于约定 坚守结构甚于约定的设计决策。例如,用到良好命名的枚举的switch/case要弱于拥有抽象方法的基类。没人会被强迫每次都以同样方式实现switch/case语句,但基类却让具体类必须实现所有抽象方法。
    • 封装条件
      if (shouldBeDeleted(timer))要好于if (timer.hasExpired() && !timer.isRecurrent())
    • 避免否定性条件
    • 函数只该做一件事
    • 掩蔽时序耦合
      就是说有些代码在被调用的时候是有逻辑上的先后顺序的,但是光从纸面上是看不出这个先后顺序的,我们可以修改这个代码使得人可以一眼看出这些代码在被调用时是需要有顺序的。
      比如修改前的:
public class MoogDiver {
    Gradient gradient;
    List<Spline> splines;

    public void dive(String reason) {
            saturateGradient();
            reticulateSplines();
            diveForMoog(reason);
    }
    ...
} 

修改后:

public class MoogDiver {
    Gradient gradient;
    List<Spline> splines;

    public void dive(String reason) {
            Gradient gradient = saturateGradient();
            List<Spline> splines = reticulateSplines(gradient);
            diveForMoog(splines, reason);
    }
    ...
} 

这样就通过创建顺序队列暴露了时序耦合。每个函数都产生下一个函数所需的结果,这样一来就没理由不按顺序调用了。

  • 别随意
    写每一行代码时都想想为什么要这样写,为什么要写在这里,有没有更好的做法。
    • 封装边界条件
    • 函数应该只在一个抽象层级上
    • 在较高层级放置可配置数据
      不要出现很多a.getB().getC().getD()这样的语句,这会很影响修改设计和架构。 正确的做法是让直接协作者提供所需的全部服务。
  • Java
    • 通过使用通配符避免过长的导入清单 指定导入包是种硬依赖,而通配符导入则不是。如果你具体指定导入某个类,该类必须存在。但如果你用通配符导入某个包,则不需要存在具体的类。导入语句只是在搜寻名称时把这个包列入查找路径。所以,这种导入并未构成真正的依赖,也就让我们的模块较少耦合。
    • 不要继承常量
    • 常量 vs. 枚举 (too ooooold!)
  • 名称
    • 采用描述性名称
    • 名称应与抽象层级相符
    • 尽可能使用标准命名法
      一个以前没明确提出的小知识,类的命名可以参考所使用的设计模式,比如Decorator,Factory这种。
    • 无歧义的名称
    • 为较大作用范围选用较长名称
    • 避免编码
      对于用Java的人来说,不要用匈牙利语命名法污染你的名称。
    • 名称应该说明副作用 不要用简单的动词来描述做了不止一个简单动作的函数。 比如:
public ObjectOutputStream getOos() throws IOException {
    if (m_oos == null) { 
        m_oos = new ObjectOutputStream(m_socket.getOutputStream()); }
    return m_oos; 
}

更好的名称大概是createOrReturnOos(不过这样命名也太啰嗦了)。

  • 测试
    • 测试不足
    • 使用覆盖率工具
    • 别略过小测试
    • 被忽略的测试就是对不确定事物的疑问
    • 测试边界条件
    • 全面测试相近的缺陷 缺陷趋向于扎堆。在某个函数中发现一个缺陷时,最好全面测试那个函数。你可能会发现缺陷不止一个。
    • 测试失败的模式有启发性 你可以通过扎到测试用例失败的模式来诊断问题所在。
    • 测试覆盖率的模式有启发性 查看被或未被已通过的测试执行的代码,往往能发现失败的测试为何失败的线索。
    • 测试应该快速
      慢速的测试是不会被运行的测试。时间一紧,较慢的测试就会被摘掉。所以,竭尽所能让测试够快。