阅读 77

代码整洁之道

  • 《Clean Code》代码整洁之道 一书相关读书笔记,整洁的代码是自解释的,阅读代码应该如同阅读一篇优秀的文章,见字知意,能够一下子明白大概的代码功能。代码首先要能读懂,其次才去要求功能实现。

  • 作为开发者来说,在现在基本都讲究团队合作的工作方式下,规范远比功能重要,开发的功能也许在版本迭代中就不复存在了,但是规范却是一直存在的。

  • 为了整洁的代码,就必须遵循一些统一的团队规范。混乱不堪连命名都无法取好的代码,只会给增加后续接手人员维护的成本

第一章 整洁代码

1.1-1.9

本章主要表述糟糕的代码对项目进度的提升并无太多的帮助,只会如同一堆岌岌可危的柴火一样,后续开发者又会理不清关系经常直接接着往上面扔其它柴火,最终结果就是轰然倒塌无法维护。所以在一开始我们便要最大程度的保持代码整洁。

所谓的整洁代码,个人理解大概是符合以下要求: 1.代码格式统一一种规范,比如缩进、空行、空格等等; 2.使用有意义的命名,包含类名、变量名、函数名等等,能够一眼就基本看出所要完成的大概操作; 3.类、函数功能单一简短,避免臃肿以及过多嵌套;

第二章 有意义的命名

2.1-2.6

能够读出单词意思,符合实际用处的变量命名让代码阅读过程更加愉悦,再简洁的代码一旦命名糟糕都会导致模糊度增加,难以读懂意思。

2.7-2.18

类名、对象名通常都是名词/名词短语,方法名一般都是动词/动词短语。命名时结合上下文的关联性进行命令,可以产生有意义的语境。例如score和student、exam等词语在一起时,容易让人联想到score代表学生考试的成绩,当然也有可以添加必要的前缀增加语境,例如examScore

第三章 函数

3.1-3.3

函数的第一规则是短小,第二规则是更短小,函数长度最好不超过一屏幕,否则需要来回滚动,阅读不方便;

每个函数功能单一,嵌套不超过2层,只做一件事,做好这件事。判断是否功能单一,可以看是否还可以再抽出一个函数;

越是短小的函数,功能越是单一,命名就更加针对性;

自顶向下的阅读规则,每个函数一个层级并调用下一个层级函数;

3.4-3.5

switch语句的最好在较低的抽象层级上使用,用于创建多态。 使用描述性的名称,别害怕名称太长。长而具有描述性的名称要比短而令人费解的名称好。

3.6-3.7

最理想的参数数量是零,没有足够的理由不要使用3个及以上的参数数量,不仅增加了理解成本,而且从测试角度看,参数组合对编写单元测试来讲便是麻烦事。当参数数量太多,应该考虑整合部分参数为类对象。

部分函数所执行的处理可能隐藏着与函数名不相符的操作,这种函数具有破坏性,违反了单一原则。比如在void checkPassword(String username, String password)函数中如果隐藏着Session.Initialize()会话初始化操作,可能会导致引用的人只是想判断用户名和密码是否正确,结果却导致了当前正常的会话出现了问题,正确的做法可以是该函数命名为checkPasswordAndSessionInitialize(String username, String password)

上述这2点也是平时编程中容易犯的错误: 参数数量的过多,一方面增加了阅读理解函数名称的时间成本,在阅读具体实现中,还要时刻注意各个参数的具体引用位置与效果; 在一个api中处理过多的事情,而名称有没有描述清楚,会导致不清楚具体实现的人进行调用后而出现的未知的错误;

3.8-3.15

消除重复代码,重复是软件中的罪恶之源,许多原则与实践都是为了消除重复而创建。去除过多的冗余,让代码逻辑一目了然,也让后续修改维护更加简单。

像写一篇文章对待代码,初稿也许粗陋无序,你就斟酌推敲,直到达到你心目中的样子。

第四章 注释

4.1 - 4.3

写注释的动机之一是为了因为糟糕代码的存在,怕别人看不懂而决定添加点注释,但最好的处理是把代码弄干净,而不是去添加那些没有必要的注释,要相信好的代码是"自说明"的;

注释都有可能存在误导、不适用或者提供错误的信息,也许源于一开始就表述错误的注释,也许因为后续代码已经修改但却未及时更新注释等等原因导致;

对于引用某些无法修改的第三方库文件返回的晦涩难懂的返回值,适当给予注释说明,阐释含义有助于理解;

对于相对复杂的处理流程,注释解释作者的意图可以提供理解相关实现的有用信息;

编写公共API就该为它编写良好的标准的java doc说明。

4.4 - 4.5

坏的注释存在以下情况: 1.多余的注释:简单明了的函数,其函数注释说明纯属多余,读注释的时间可能多余读函数代码的时间;

2.误导性的注释:注释不够精确,存在与实际运行不相符的情况,误导使用者进行错误的处理;

3.日志性的注释:在每个模块开始添加每一次修改的注释性日志已经没有必要,现在很多代码维护工具提供更好的修改记录;

4.废话性的注释:比如private int dayofMonth ; 注释为 the day of the month ;

5.循规性的注释: 每个函数或者每个变量都有javadoc标准的注释完全没有必要,除非是编写公共API;

能用函数和变量自说明的时候,就别添加任何注释。注释本身是为了解释未能自行解释的代码,如果注释本身还需要解释,那就毫无作用。

第五章 格式

5.1

代码格式很重要,因为代码的可读性会对以后可能发生的修改行为产生深远的影响。我也曾认为"让代码能工作"才是头等大事,后面慢慢的会发现,所编写的功能在下一个版本也许就被修改掉不复存在了,但代码格式规范确实一直存在下去的。良好的代码格式可以促进理解沟通,在协同合作的当下,注意规范很重要。

5.2 - 5.5

每个人可能偏好的代码格式不同,但是团队合作中就该商量统一一种好的代码格式,所有人都按照这种标准进行开发工作。对于代码的垂直格式,比如类文件尽量功能简单,注意能拆分的进行拆分,以免类过大会变得臃肿难以维护。在功能描述上注重从名称上就可以大概了解主要处理的功能点。对于代码的水平格式,一行代码尽量不超过一个屏幕的显示范围。

第六章 对象与数据结构

6.1 - 6.5

这章主要讲的是对象与数据结构优缺点。对象主要提供接口而隐藏其内部实现,而数据结构提供纯数据读写而不带任何有其它意义的函数操作。

在我的理解上: 过程式编程主要是单独使用业务类去处理各种数据结构,这就导致了在各个方法中传参为Object对象然后需要用判断语句判断每一种数据结构对应的类型,才好方便做到根据传入的不同数据结构类型来返回不同的结果。这种方式好处就是业务类中增加了新的操作函数,并不需要去修改已存在的数据结构类,但是如果增加了新的数据结构类型则需要在业务类中的每个操作函数中增加该结构类型的判断;

面向对象因为推崇接口式编程,如果一开始接口没考虑清楚,那么增加减少接口就会导致所有实现类都需要进行修改,但是相比较下面向对象方便在创建新的类对象时,不影响其它对象的操作接口,因为实现上是多态的。 前者方便修改已存在的操作函数但难以增加类,后者方便增加类,但难以修改已存在的接口。并没有孰优孰劣,需要根据实际去决定采用哪种。

第七章 错误处理

7.1-7.2

这2个小节主要讲了错误处理中的2个建议,第1个是使用异常代替返回码,第2个是对于要捕获的异常先书写Try-Catch-Finally语句。 对于第1个书上说是为了代码书写过程中不需要每次调用都要去判断返回的各种错误码的情况,影响代码整洁会打乱代码逻辑。但有时候实际使用过程,还是经常会有将错误码定义为返回值的情况,对调用者来讲方便判断结果,一般这种情况的错误码都会有很多种类型,甚至存在不同的错误代码可能都当做成功处理的情况,只是单单使用抛出异常的方式有时候并不是太好。 对于第2个,这是一种好的代码规范,对于需要捕获错误的函数,理论上try开头,finally结尾。

7.3-7.10

如果底层api已经被广泛使用,需要抛出新异常,建议内部封装处理好,避免上层每一个环节都需要重新throw或catch该新的异常。根据实际使用的情况,对于主动抛出的自定义异常最好进行分类,并且携带异常说明,方便排查是什么原因导致。函数的处理上,别最好避免传入null和返回null的情况,避免引用的地方都要增加非null判断,影响整洁。

第八章 边界

8.1-8.7

使用第三方代码或者开源库的时候,避免直接在生产代码中验证功能,可以尝试编写对应的单元测试来熟悉其使用方法,理解提供的api接口,这样当开源库版本发生变化,可以快速根据之前编写的单元测试来验证功能是否会受到影响。对于开源库/第三方提供的接口,可以尝试再进行一层封装,使用方便自己理解的接口名,避免代码各处直接引用其提供的接口,这样以后即便替换为不同的开源库或者开源库接口发生变更,也可以做到改动最小。

第九章 单元测试

9.1-9.3

这几个小节主要讲述单元测试的重要性,单元测试与生产代码并行甚至优先于生产代码诞生,这样保证项目完成之时有着覆盖各个方面的单元测试体系。不能因为是单元测试就双重标准判断,不去注重单元测试的整洁。一套好的单元测试可以让你无所顾忌的修改现有代码的框架,也是你进行大改动的底气,而同生产代码一样,整洁的单元测试是必不可少的,否则一旦成套的单元测试臃肿不堪难以维护,会导致单元测试无法跟上生产代码的更新速度,最终变为效率上的累赘,一旦抛弃又无法保证主体功能的稳定。

实际开发中,其实往往忽略了单元测试的编写,我感觉这不仅仅是因为项目周期赶的问题,而是我们一开始就没有将单元测试的周期安排进去。由于这习惯性的忽略,往往是每次修改,反而需要耗费更多时间去自测。

9.4-9.7

单元测试用应该要尽可能少的断言操作,并且保证测试用例只覆盖一个点。整洁的测试用例符合"First"原则: 快速(Fast),测试速度快;

独立(Independent),用例之间相互独立,互不影响和依赖;

可重复(Repeatable),测试可在任务环境下重复使用,比如有网络、无网络,生产环境或者质检环境;

自足验证(Self-Validating),用例不应该需要人工额外操作来判断是否通过,比如查看日志文件等等。用例本身应该直接通过断言判断是否成功。

及时(Timely),测试用例应该及时编写,并且最好优先于生产代码编写。否则先编写生产代码的话,往往会发现生产代码难以测试。
复制代码

第十章 类

10.1 - 10.2.1

类的规则与函数类似,第一规则是短小,第二规则还是短小。越是短小的类越能符合单一职责原则。 项目工程达到一定程度后,类一不小心就会臃肿,需要再将类进行分割成一个个单一职责的小类,整洁的系统就该由许多短小的类而不是少量巨大的类来组成。

10.2.2-10.4

类的设计应该体现内聚性,即模块内的各个元素应该紧密的联系在一起。这边包含类中成员变量与方法命名上的要有关联性,类中成员变量应该尽量被更多的类方法所操作,这样会使得方法与变量更加粘聚。内聚性高,意味着方法与变量相互依赖,互相结合成一个单一整体,职责也就越单一。 如果类中某些变量只被少数几个方法共享,意味着这几个方法和变量可以再单独拆分出一个小类出来,否则堆积了越来越多的这种变量将会导致内聚性的降低。

第十一章 系统

11.1 - 11.2

这几个小节主要讲“将构造与使用相互分离"的好处,书本描述得有点难以理解,意思大概就是: 构造与使用分离,会使得类之间的耦合度下降,方便后续进行实现上的替换修改。 举个相对简单的例子说明:

            public class HandlerImpl {
                public void handler() {
                    Component component= new component();
                    component.handle();
                }
            }
复制代码

在这个例子中,由于构造与使用没有分离,即HandlerImpl即是Component的构造者(调用了构造函数),又是Component的执行者(调用了方法),在之后假设需要将Component的实现替换为接口实现类,或者其它类,就会导致整个系统中很多类似这样的地方都需要进行替换为 Component component= new ComponentImpl()、Component2 component= new Component2();这样修改太多,耦合度太高,影响后续维护。

类似"分离构造与使用"可以使用类似工厂模式+面向接口编程,如:

            public class HandlerImpl {
                public void handler() {
                    Component component= ComponentFactory.getComponent();
                    component.handle();
                }
            }
            
            public class ComponentFactory {
                public static Component getComponent() {
                    Component component= new componentImpl1();
                    return;
                }
            }
复制代码

后续即便替换Component的实现,或者增加对使用Component条件的判断,都只需要修改构造器ComponentFactory而不需要取修改所有使用的地方:

            public class ComponentFactory {
                public static Component getComponent() {
                    if (...) {
                       return new componentImpl1(); 
                    }
                    if (...) {
                        return new componentImpl2(); 
                    }
                }
            }
复制代码

11.3 - 11.12

这几个小节主要讲述了系统整洁,要达到系统整洁,需要注意模块功能的划分,注意简单的Been对象不与业务逻辑耦合在一起,做到职责单一。项目初始阶段,就将未来系统方方面面考虑周全,提供一大堆目前尚不要求的API或者功能点是不必要的,所要做到的是划分好各个功能模块,注意各个模块之间的可扩展性与相互隔离。这样方便后面不断进行扩展和重构优化,快速迭代敏捷开发。

横贯式关注面? 持久化? AOP ?EJB?表示这章有点懵,看不太懂?
复制代码

第12章 迭进

如何达到简单整洁的设计,其实有四条规则可以遵守,按重要性从大到小排序为: 1.运行所有的测试: 这意味着需要编写单元测试,也是很多人所欠缺的,完整的单元测试会迫使你在编码过程注重可测性,也就必须划分好模块功能,遵守好单一职责。完善的测试也是不断重构的底气。

2.消除重复: 重复是良好设计的天敌,各种优秀的设计模式也是为了消除重复。不光是雷同的代码段重复,还有其它行为的重复,比如功能上可以支持一样的效果(像是int getSize()和boolean isEmpty(),这时候其实isEmpty()没什么必要),过多的重复会有不必要的风险和增加额外的复杂度。

3.表达清楚意图: 这就要求命名要准确明了,上下文关联性强,能够阅读出作者的意图。

4.尽可能减少类和方法的数量: 各种设计模式和功能上的划分往往会伴随类和方法数量的增加,但并不是说类和方法越多越好。有些可以避免的就该避免掉,像是为每个类都创建接口类其实并没有必要。

第13章 并发

13.1 -13.3

并发编发可以对"时机"和"目的"解耦,不像单线程将2者紧密耦合在一起。解耦2者的目的,可以明显改进应用程序的吞吐量和结构。结构上看更像是多台计算机同时工作,而不是一个大循环,系统因此更容易被理解,能够更好地切分关注面。并发编程的是一把双刃剑,大部分情况下可以显著提高性能,但是也有需要注意的地方: 1.数据共享后存在多线程访问修改的同步问题; 2.根据情况也可使用数据复本代替同步数据块来避免共享数据,后续统一汇总复本合并修改; 3.并发会在性能和编写额外代码上增加一些开销; 4.每个线程尽可能只运行在自己的小世界,尽量不与其它线程共享数据

13.4 - 13.11

讲述了并发编程可能会导致死锁、互斥、等待等等状态,要编写好的并发代码,需要遵循单一职责原则。将系统切分为线程相关代码和与线程无关的代码的POJO,确保代码简单,目的集中。需要了解和学习相关并发库的知识,学会分析并发问题产生的原因。注意锁定的代码区域尽可能小,避免降低性能。测试多线程需要注意在不同平台下、数量不等的线程以及使用各种wait、sleep、yield、priority装置代码,改变代码的运行顺序,用以尽早的发现各种问题。

第14章 逐步改进

前面章节有讲述过好的代码就像好的散文一样,能够让人愉快的阅读。而散文往往需要经历过各种草稿阶段方能成正稿,代码一样是这个道理,需要持续优化修改,方能符合整洁之道。 这章强调的是逐步改进,每改进一步,就完整运行伴随生产代码产生的各种测试用例,确保每一步修改都不会留下问题,不会影响下一步修改,循循渐进,最终完成代码整体的重构。也再一次说明,完善的测试用例很重要。

第15章 JUnit内幕

这章以几个单元测试用例为例,讲解用例从凌乱臃肿到整洁合理的修改过程。主要是对前面几章讲解的代码整洁之道的应用,方法单一职责、命名准确优雅,从方法、变量以及排版上重构出整洁的代码

第16章 重构SerialDate

依旧举个例子讲解修改过程: 完善测试用例,逐步修改逐步测试; 去除一些多余的注释,对于星期使用枚举代替int类型避免需要进行有效值判断; 使用工厂模式完成实体创建,实现构造与使用的分离; 优化方法和变量的命名去除重复的代码、消除魔术数的存在;

第17章 味道与启发

章节17.1 -17.4

讲述总结了需要优化的地方:

对于注释: 1.不恰当的信息,比如修改历史; 2.废弃的注释,即过时、无关或者不正确的注释; 3.冗余的注释,例如除了函数签名什么都没有注明的javadoc; 4.注释掉的代码块,不明其意的糟糕注释;

对于函数: 1.参数越少越好,最多不超过3个; 2.避免输出参数,应使用返回值或者参数对象状态代替; 3.避免标识参数,例如布尔值参数; 4.去除没有调用的方法;

一般性问题: 1.去除重复代码块; 2.保证边界代码正确执行; 3.正确的抽象层级; 4.多态代替多重判断操作; 5.命名清楚表达意图; 等等

章节17.5 -17.9

java中: 1.可以说使用通配符来避免过长的导入清单,比如import package.*; 2.避免从最低继承体系中继承常量; 3.使用合适的枚举代替常量;

名称: 1.采用准确的描述性名称,统一规范; 2.避免有歧义的名称,不畏惧使用较长的名称;

测试: 1.测试用例先行,早于生产代码一步; 2.边界测试完善; 3.不忽略小细节的测试; 4.测试速度要足够快速; 5.对于失败的测试用例要更加全面的进行测试;

附录 A 并发编程

A.1 - A.2

高并发网络通信性能测试中如何有效提高服务器吞吐量,主要取决于服务器处理的处理方法是与I/O有关的话就可以通过创建尽可能多的线程来达到性能的提升,当然也要做个最大量的限制避免超过JVM允许的范围。如果是因为CPU数量限制的话,那只能通过提升硬件来解决了。2者常见的处理范围: I/O---使用套接字、链接到数据库、等待虚拟内存交换等; 处理器---数值计算、正则表达式处理、垃圾回收等; 另外编写服务器代码时,需要注意对各个功能职责的划分,避免堆砌在一起。

高并发下还需要注意代码执行路径的问题,未加锁的代码在多线程访问的情况下,往往获取到的结果前期百怪,很大程度上是普通的java语句生成字节码后往往是一句java对应多句字节码,并发编程下,线程访问就有可能处在各个字节码步骤下。

A.3了解类库 - A.4方法之间的依赖可能破坏并发代码

1.java本身就提供了很多并发的类库处理,像是Executor线程池框架,我们可以直接使用方便支持一些简单的并发操作; 2.Synchronized锁定操作以及CAS可以很大程度上保证并发下资源访问的线程安全,大部分情况下CAS的效率都会比Synchronized高; 3.有些类操作并不是线程安全的,比如数据库连接、部分容器类操作等; 4.对于多个单方法线程安全的操作,组合起来就不一定是安全的,方法之间的依赖可能破坏并发代码,比如以下2个例子:

案例一:

对于HashTable map,存在以下2个线程安全的操作:
public synchronize boolean containsKey(key);
public synchronize void put(key, value);

但是组合起来要实现假设map不存在才允许添加的操作时:
if(!map.containsKey(key)) {
        map.put(key, valie);
    }
复制代码

单线程执行下并不会有啥问题,但是高并发下就有概率出现线程1通过containsKey的判断后,可能cpu时间片切换等问题,线程1等待,轮到线程2执行也通过了containsKey,这时候就会出现线程1和线程2都会排队执行到put操作,与原本期待不符合。

解决办法:

可以是整合成一个api操作进行锁定:
public synchronize void putIfAbsent(key, value) {
    if(!map.containsKey(key)) {
        map.put(key, valie);
    }
}
或者受到调用前手动锁定代码块:
synchronize(map) {
    if(!map.containsKey(key)) {
        map.put(key, valie);
    }
}
复制代码

案例二:

public class IntegerIterator {
    Integer value = 0;
    public synchronize boolean hasNext(){
        if (value < 100){
            return true;
        }
        return false;
    }
    
    public Integer Integer next() {
        if(value == 100) {
            throw new xxxException();
        }
        return value++;
    }
}
复制代码

使用:

IntegerIterator iterator = new IntegerIterator();
while(iterator.hasNext()) {
    int value = iterator.next();
    // ...
}
复制代码

该例子同样在高并发下容易出问题,但是可能需要很久才能出现并且难以跟踪,因为需要再边界时value = 99,此时多个线程同时通过了hasNext判断后,后续就会抛出对应的异常。解决方法类似案例一

A.5 - A.10

多线程下对有限资源的争夺容易造成死锁、互斥、循环等待等等异常情况。在实际使用中我们可以优化代码减少此类情况的发生,比如满足线程资源的抢先机制,规划好各线程资源获取的顺序等等。由于并发引起的问题难以发现与模拟,可以尝试借用一些工具进行测试,比如IBM ConTest(表示没下载到)

微信公众号

关注下面的标签,发现更多相似文章
评论