《Clean Code》- 原则、模式和实践
2018-12-27 本文已影响28人
nimw
0. 内容提要
- 软件质量,不但依赖于架构及项目管理,而且与代码质量紧密相关。
- 代码质量与其整洁度成正比。干净的代码,既在质量上较为可靠,也为后期维护、升级奠定了良好基础。
- 我们往往见不到人们把对细节的关注当作编程艺术的基础要件。我们过早地放弃了在代码上的工作,并不是因为它业已完成,而是因为我们的价值体系关注外在表现甚于关注要交付之物的本质。疏忽最终结出了恶果:坏东西一再出现。
1. 整洁代码
- 我们永远抛不掉代码,因为代码呈现了需求的细节。在某些层面上,这些细节无法被忽略或抽象,必须明确之。将需求明确到机器可以执行的细节程度,就是编程要做的事。而这种规约正是代码。
- 即便是人类,倾其全部的直觉和创造力,也造不出满足客户模糊感觉的成功系统来。我们永远无法抛弃必要的精准性——所以代码永存。
- 当时他们赶着推出产品,代码写得乱七八糟。特征越加越多,代码也越来越烂,最后再也没法管理这些代码了。是糟糕的代码毁了这家公司。
- 我们趟过代码的水域。我们穿过灌木密布、瀑布暗藏的沼泽地。我们拼命想找到出路,期望有点什么线索能启发我们到底发生了什么事;但目光所及,只是越来越多死气沉沉的代码。
- 我们都曾经瞟一眼自己亲手造成的混乱,决定弃之不顾,走向新一天。我们都曾经看到自己的烂程序居然能运行,然后断言能运行的烂程序总比什么都没有强。我们都曾经说过有朝一日再回头清理。当然,在那些日子里,我们都没听过勒布朗法则:
Later equals naver
。
1.3 混乱的代价
- 代码混乱时,对代码的每次修改都影响到其他两三处代码。修改无小事。每次添加或修改代码,都得对那堆扭纹柴了然于心,这样才能往上扔更多的扭纹柴。这团乱麻越来越大,再也无法清理,最后束手无策。
- 假使你是位医生,病人请求你在给他做手术前别洗手,因为那会化太多时间,你会照办吗?医生绝对应该拒绝遵从。医生比病人更了解疾病和感染的风向。医生如果按病人说的办,就是一种不专业的态度。同理,程序员遵从不了解混乱风险的经理的意愿,也是不专业的态度。
- 开发期限的压力并不是制造混乱的理由。制造混乱无助于赶上期限。混乱只会立即拖慢你,叫你错过期限。赶上期限的唯一办法——做得快的唯一办法——就是始终尽可能保持代码整洁。
- 写整洁代码就像是绘画。多数人都知道一幅画是好是坏。但能分辨优劣并不表示懂得绘画。能分辨整洁代码和肮脏代码,也不意味着会写整洁代码!
- 什么是整洁代码?
(1) 优雅和高效的代码。代码逻辑直截了当;尽量减少依赖关系;完善错误处理代码;性能调至最优。整洁的代码只做好一件事。
(2) 糟糕的代码想做太多事,意图混乱、目的含糊。整洁的代码力求集中。每个函数、每个类和每个模块都全神贯注于一事,完全不受四周细节的干扰和污染。
(3) 整洁的代码简单直接,如同优美的散文。整洁的代码从不隐藏设计者的意图,充满了干净利落的抽象和直截了当的控制语句。
(4) 易于阅读和增补;有单元测试和验收测试;有意义的命名;只提供一种而非多种做一件事的途径;尽量少的依赖关系;明确、清晰、简洁的API;代码自身表达清晰。
(5) 能通过所有测试;没有重复代码;体现所有系统所有设计理念;尽量少的实体,比如类、方法、函数等。
(6) 不要重复代码,只做一件事,表达力,小规模抽象。
(7) 如果每个例程都让你感到深合己意,那就是整洁代码。如果代码让编程语言看起来像是专为解决那个问题而存在,就可以称之为漂亮的代码。 - 写新代码时,读与写花费时间的比例超过10:1。既然比例如此之高,我们就想让读的过程变得轻松,即便那会使得编写过程更难。要想轻松写代码,先让代码易读吧。
2. 有意义的命名
- 名副其实
如果名称需要注释来补充,那就不算名副其实。 - 避免引导
避免留下掩藏代码本意的错误线索。 - 做有意义的区分
假设你有一个Product
类。如果还有一个ProductInfo
或ProductData
类,那他们的名称虽然不同,意义却无区别。Info
和Data
就像a
、an
和the
一样,是意义含混的废话。
废话都是冗余。Variable
一词永远不应当出现在变量名中。Table
一词永远不应当出现在表名中。 - 使用读得出来的名称
不要傻乎乎的自造词,使用恰当的英语单词命名。 - 使用可搜索的名称
单字母名称和数字常量很难被搜索到。
长名称更易于被搜索到,名称长短应与其作用域大小相对应。 - 类名
类名和对象名应该是名词或名词短语。类名不应当是动词。 - 方法名
方法名应当是动词或动词短语,并加上get
、set
和is
前缀。 - 每个概念对应一个词
给每个抽象概念选一个词,并且一以贯之。
函数名称应当独一无二,而且要保持一致。例如:一堆代码中有controller
,又有manager
还有driver
,就会让人困惑。 - 别用双关语
应遵循“一词一义”规则。
我们想要那种大众化的作者尽责写清楚的平装书模式;我们不想要那种学者挖地三尺才能明白个中意义的学院派模式。 - 添加有意义的语境
可以添加前缀addrFirstName
、addrLastName
、addrState
等,以此提供语境。 - 不要添加没用的语境
只要短名称足够清楚,就要比长名称好。别给名称添加不必要的语境。
3. 函数
- 在编程的早年岁月,系统由程序和子程序组成。后来,在
Fortran
和PL/1
的年代,系统由程序、子程序和函数组成。如今,只有函数存活下来。函数是所有程序中的第一组代码。 - 短小
(1) 函数的第一规则是要短小。
(2) 函数不应有100行那么长,20行封顶最佳。
(3)if
语句、else
语句、while
语句等,其中的代码块应该只有一行。该行大抵应该是一个函数调用语句。这样不但能保持函数短小,而且,因为块内调用的函数拥有较具说明性的名称,从而增加了文档上的价值。
(4) 函数不应该大到足以容纳嵌套结构。所以,函数的缩进层级不该多于一层或两层。 - 只做一件事
函数应该做一件事。做好这件事。只做这件事。
如果函数只是做了该函数名下同一抽象层上的步骤,则函数还是只做了一件事。编写函数毕竟是为了把大一些的概念(换言之,函数的名称)拆分为另一抽象层上的一系列步骤。
- 每个函数一个抽象层级
要确保函数只做一件事,函数中的语句都要在同一抽象层级上。这是保持函数短小,确保只做一件事的要诀。
函数中混杂不同抽象层级,往往让人迷惑。读者可能无法判断某个表达式是基础概念还是细节。 -
switch
语句
写出短小的switch
语句很难。写出只做一件事的switch
语句也很难。
switch
天生要做N
件事,我们可以利用多态确保每个switch
都埋藏在较低的抽象层级,并且永不重复。
对于switch
语句,如果只出现一次,用于创建多态对象,而且隐藏在某个继承关系中,在系统其他部分看不到,就还能容忍。 - 使用描述性的名称
长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的长注释好。 - 函数参数
零参数函数最理想,其次是单参数函数,再次是双参数函数,应尽量避免三参数函数,有足够的理由才能用多(三个以上)参数函数。
标识函数丑陋不堪。这样做,方法签名立刻变得复杂起来,大声宣布本函数不止做一件事。
如果函数看来需要两个、三个或三个以上参数,就说明其中一些函数应该封装为类了。 - 无副作用
函数承诺只做一件事,但还是会做其他被藏起来的事,产生副作用。 - 分隔指令与询问
函数要么做什么事,要么回答什么事,但二者不应该得兼。 - 使用异常替代返回错误码
- 抽离
Try/Catch
代码块
最好把try
和catch
代码块的主体部分抽离出来,另外形成函数。 - 如何写函数
写代码像写文章,一开始都冗长而复杂,然后打磨这些代码,分解函数、修改名称、消除重复。 - 小结
大师级程序员把系统当作故事来讲,而不是当做程序来写。函数的目标在于讲述系统的故事,你编写的函数必须干净利落地拼装到一起,形成一种准确而清晰的语言。
4. 注释
- 注释并不像幸德勒的名单。他们并不“纯然地好”。实际上,注释最多也就是一种必须的恶。注释的恰当用法是弥补我们在用代码表达意图时遭遇的失败。
- 我为什么要极力贬低注释?因为注释会撒谎。注释存在的时间越久,就离其所描述的代码越远,因为程序员不能坚持维护注释。
- 不准确的注释要比没注释坏得多。他们满口胡言。只有代码能忠实地告诉你它做的事。那是唯一真正准确的信息来源。
- 注释不能美化糟糕的代码
带有少量注释的整洁而有表达力的代码,要比带有大量注释的零碎而复杂的代码像样得多。与其花时间编写解释你搞出的糟糕的代码的注释,不如花时间清洁那堆糟糕的代码。 - 好注释
有些注释是必须的,也是有利的。例如:法律信息、提供信息的注释、对意图的解释、阐释、警示、TODO
注释、放大等等。 - 坏注释
坏注释都是糟糕代码的支撑或借口。例如:喃喃自语、多余、误导性、循规式、日志式、废话、能用变量或函数时、位置标记、括号后面的注释、归属和署名、注释掉的代码、信息过多等等。
5. 格式
- 保持良好的代码格式,选用一套管理代码格式的简单规则,使用格式规则的自动化工具。
- 代码格式关乎沟通,而沟通是专业开发者的头等大事,而不是“让代码能工作”。
- 今天编写的代码,极有可能在下一版本被修改,代码的可读性会对以后可能发生的修改行为产生深远影响。原始代码修改之后很久,其代码风格和可读性仍会影响到可维护性和扩展性。即使代码不复存在,你的风格和律条存活下来。
- 用大多数为200行、最长500行的单个文件构造出色的系统。
- 向报纸学习
源文件最顶部应该给出高层次概念和算法。细节应该往下依次展开,直到找到源文件中最底层的该函数和细节。 - 概念间垂直方向上的区隔
在封包声明、导入声明和每个函数之间,都有空白行隔开。 - 垂直方向上的靠近
如果说空白行隔开了概念,靠近的代码行则暗示了他们之间的紧密关系。 - 垂直距离
(1) 关系紧密的概念应该互相靠近。
(2) 变量声明应尽可能靠近其使用位置。
(3) 如果某个函数调用另一个,应该把他们放到一起。
(4) 概念相关的代码应该放到一起。 - 垂直顺序
被调用的函数应该放在执行调用的函数下面。 - 应该尽力保持代码短小。
遵循无需拖动滚动条到右边的原则。 - 水平方向的间隔和靠近
(1) 赋值操作符(=
)周围加空格。
(2) 函数名和左小括号((
)之间不加空格。
(3) 函数小括号中参数一一隔开。
6. 对象和数据结构
- 数据抽象
隐藏实现,通过暴露抽象接口,以便用户无需了解数据的实现就能操作数据本体。 - 数据、对象的反对称性
(1)对象把数据隐藏于抽象之后,暴露操作数据的函数。数据结构暴露其数据,没有提供有意义的函数。
(2) 代码演示
过程式代码便于在不改动既有数据结构的前提下添加新函数;难以添加新数据结构(子类),因为必须修改所有函数。
class Square {
topLeft;
side;
}
class Rectangle {
topLeft;
height;
width;
}
class Circle {
center;
radius;
}
class Geometry {
PI = 3.1415926;
area(shape) {
if(shape instanceof Square) {
return shape.side * shape.side
} else if (shape instanceof Rectangle) {
return shape.width * shape.height
} else if(shape instanceof Circle) {
return this.PI * shape.radius * shape.radius
}
}
}
面向对象代码便于在不改动既有函数的前提下添加新类;难以添加新函数,因为必须修改所有类。
class Shape {
area() {}
}
class Square extends Shape {
topLeft;
side;
area(shape) {
return shape.side * shape.side
}
}
class Rectangle extends Shape {
topLeft;
height;
width;
area(shape) {
return shape.width * shape.height
}
}
class Circle extends Shape {
center;
radius;
PI = 3.1415926;
area(shape) {
return this.PI * shape.radius * shape.radius
}
}
(3) 在复杂系统中,当需要添加新数据类型而不是新函数时,面向对象更合适。当需要添加新函数而不是数据类型时,过程式代码更合适。
- 德墨忒尔律
方法不应调用任何函数返回的对象的方法。
下面代码违反了德墨忒尔律:
const outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath()
这类代码被称作火车失事,最好做如下切分:
const opts = ctxt.getOptions()
const scratchDir = opts.getScratchDir()
const outputDir = scratchDir.getAbsolutePath()
以上代码是否违反德墨忒尔律取决于opts
、scratchDir
、outputDir
是对象还是数据结构,如果只是数据结构,没有任何行为,德墨忒尔律就不适用了。
只跟朋友谈话,不与陌生人谈话。
- 对象暴露行为,隐藏数据。便于添加新对象类型而无需修改既有行为,同时也难以在既有对象中添加新行为。数据结构暴露数据,没有明显的行为。便于向既有数据结构添加新行为,同时也难以向既有函数添加新数据结构。
7. 错误处理
- 错误处理很重要,但如果它搞乱了代码逻辑,就是错误的做法。
- 使用异常而非返回码
使用返回码错误标识搞乱了调用者代码。调用者必须在调用之后立即检查错误,并且这个步骤很容易被遗忘。
遇到错误时,最好抛出一个异常。调用代码很整洁,逻辑不会被错误处理搞乱。 - 先写
Try - Catch - Finally
语句
异常的妙处之一是,他们在程序中定义了一个范围。执行Try - Catch - Finally
语句中try
部分的代码时,你是在表明可随时取消执行,并在catch
语句中接续。 - 别返回
null
值
如果你打算在方法中返回null
值,不如抛出异常,或是返回特例对象。这样编码,就能尽量避免NullPointerException
的出现。 - 别传递
null
值
在方法中返回null
值是糟糕的做法,但将null
值传递给其他方法就更糟糕了。除非API
要求你向它传递null
值,否则就要尽可能避免传递null
值。 - 整洁代码是可读的,但也要强固。可读和强固并不冲突。如果将错误处理隔离看待,独立于主要逻辑之外,就能写出强固而整洁的代码。
8. 边界
- 在接口提供者和使用者之间,存在与生俱来的张力。第三方框架提供者追求普适性,这样就能在多个环境中工作,吸引广泛的用户。而使用则想要集中满足特定需求的接口。这种张力会导致系统边界上出现问题。
- 边界上的代码需要清晰的分割和定义了期望的测试。应该避免我们的代码过多地了解第三方代码中的特定信息。
9. 单元测试
-
TDD
三定律
定律一:在编写不能通过的单元测试前,不可编写生产代码。
定律二:只可编写刚好无法通过的单元测试,不能编译也算不通过。
定律三:只可编写刚好足以通过当前失败测试的生产代码。
测试与生产代码一起编写,测试只比生产代码早写几秒钟。 - 测试代码和生产代码一样重要。它可不是二等公民。它需要被思考、被设计和被照料。它该像生产代码一般保持整洁。
- 覆盖了生产代码的自动化单元测试程序组能尽可能地保持设计和架构的整洁。测试代码应明确,简洁,还有足够的表达力。
-
F-I-R-S-T
(1) 快速(Fast
)
测试应该够快。
(2) 独立(Independent
)
测试应该相互独立。你应该可以单独运行每个测试,及以任何顺序运行测试。
(3) 可重复(Repeatable
)
测试应当可在任何环境中重复通过。
(4) 自足验证(Self-Validating
)
测试应该由布尔值输出。
(5) 及时(Timely
)
测试应及时编写。单元测试应该恰好在使其通过的生产代码之前编写。
10. 类
- 类应该短小
关于类的第一条规则是类应该短小。 - 我们以权责来衡量类的大小
类的名称应当描述其权责。如果无法为某个类命以精确的名称,这个类大概就太长了。类名越含混,该类越有可能拥有过多权责。例如:如果类名中包括含义模糊的词,如Process
或Manager
或Super
,这种现象往往说明有不恰当的权责聚集情况出现。 - 单一权责原则(
SRP
)
单一权责原则认为,类或模块应有且只有一条加以修改的理由。类只应有一个权责——只有一条修改的理由。
SRP
是OO
设计中最为重要的概念之一,也是最容易被破坏的类设计原则。 - 让软件能工作和让软件保持整洁,是两种截然不同的工作。我们中的大多数人脑力有限,只能更多地把精力放在让代码能工作上,而不是放在保持代码有组织和整洁上。
- 大多人在程序能工作时就以为万事大吉了。没能把思维转向有关代码组织和整洁的部分。不再回头将臃肿的类切分为只有单一权责的去耦式单元。
- 系统应该由许多短小的类而不是少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为。
-
内聚
① 类应该只有少量实体变量。
② 类中每个方法都应该操作一个或多个这种变量。
③ 通常而言,方法操作的变量越多,就越粘聚到类上。
④ 如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。
⑤ 内聚性高,意味着类中的方法和变量互相依赖、互相结合成一个逻辑整体。
⑥ 我们希望类保持较高的内聚性。
⑦ 将较大的函数切割为小函数,为保持内聚性,就会得到许多短小的类。
11. 系统
- 复杂要人命。它消磨开发者的声明,让产品难以规划、构建和测试。
- 软件系统应将启动过程和启动过程之后的运行时逻辑分离开,在启动过程中构建应用对象,也会存在互相缠结的依赖关系。每个应用程序都该留意启始过程。
- 一开始就做对系统纯属神话。我们应该只去实现今天的用户故事,然后重构,明天再扩展系统、实现新的用户故事。
- 无论是设计系统或单独的模块,别忘了使用大概可工作的最简单方案。
12. 迭进
- 假使有4条简单的规矩,跟着做就能帮助你创建优良的设计。
① 运行所有测试。
② 不可重复。
③ 表达了程序员的意图。
④ 尽可能减少类和方法的数量。 - 测试消除了对清理代码就会破坏代码的恐惧。
- 消除重复
① 重复是拥有良好设计系统的大敌。
② 重复代表着额外的工作、额外的风险和额外且不必要的复杂度。
③ 要想创建整洁的系统,需要有消除重复的意愿,即使只有短短几行。 - 保证表达力
① 大多数人都经历过费解代码的纠缠。大多数人都编写过费解的代码。
② 软件项目的主要成本在于长期维护。把代码写的越清晰,其他人花在理解代码上的时间也就越少,从而减少缺陷,缩短维护成本。
③ 可以通过好名称、保持函数和类尺寸短小、标准命名法、编写良好的测试来表达。
④ 做到有表达力的最重要方式即是尝试。
⑤ 时时照拂自己创建的东西。用心最珍贵的资源。 - 尽可能减少类和方法
① 保持类和函数短小。
② 主张类和函数数量要少。
13. 并发编程
- 对象是过程的抽象。线程是调度的抽象。
- 并发是一种解耦策略。它帮助我们把做什么(目的)和何时(时机)做分解开。在单线程应用中,目的和时机紧密耦合,很多时候只要查看堆栈追踪即可断定应用程序的状态。
- 解耦目的与时机能明显地改进应用程序的吞吐量和结构。从结构的角度来看,应用程序看起来更像是许多台协同工作的计算机,而不是一个大循环。
- 关于并发
① 并发会在性能和编写额外代码上增加一些开销。
② 正确的并发是复杂的,即便对于简单的问题也是如此。
③ 并发缺陷并非总能重现,所以常被看做偶发事件而忽略,未被当做真的缺陷看待。
④ 并发常常需要对设计策略的根本性修改。 - 单一权责原则。
单一权责原则(SRP
)认为,方法/类/组件应当只有一个修改的理由。
并发设计自身足够复杂到成为修改的理由。
建议:分离并发相关代码和其他代码。