代码整洁之道 Clean Code 读书笔记
代码整洁之道 Clean Code
第一章 整洁代码
-
- 代码的重要性
- 我们永远抛不掉代码,因为代码呈现了需求的细节。在某些层面上,这些细节无法被忽略或抽象,必须明确之。将需求明确到机器可以执行的细节程度,就是编程要做的事。而这种规约正是代码。
-
- 混乱的代价
- 随着混乱的增加,团队生产力持续下降,趋向于零。
- 花时间保持代码整洁不但有关效率,还有关生存。
- 制造混乱无助于赶上期限。混乱只会立刻拖慢你,叫你错过期限。赶上期限的唯一方法——做得快的唯一方法 ——就是始终尽可能保持代码整洁。
- 写整洁代码,需要遵循大量的小技巧,贯彻刻苦习得的“整洁感”。这种“代码感”就是关键所在。
- 简言之,编写整洁代码的程序员就像是艺术家,他能用一系列变换把一块白板变作由优雅代码构成的系统。
-
- 什么是整洁代码
-
Bjarne Stroustrup,C++语言发明者,C++Programming Language(中译版《C++程序设计语言》)一书作者。
我喜欢优雅和高效的代码。代码逻辑应当直截了当,叫缺陷难以隐藏;尽量减少依赖关系,使之便于维护;依据某种分层战略完善错误处理代码;性能调至最优,省得引诱别人做没规矩的优化,搞出一堆混乱来。整洁的代码只做好一件事。 -
Grady Booch,Object Oriented Analysis and Design with Applications(中译版《面向对象分析与设计》)一书作者。
整洁的代码简单直接。整洁的代码如同优美的散文。整洁的代码从不隐藏设计者的意图,充满了干净利落的抽象和直截了当的控制语句。 -
Michael Feathers,Working Effectively with Legacy Code(中译版《修改代码的艺术》)一书作者。
我可以列出我留意到的整洁代码的所有特点,但其中有一条是根本性的。整洁的代码总是看起来像是某位特别在意它的人写的。几乎没有改进的余地。代码作者什么都想到了,如果你企图改进它,总会回到原点,赞叹某人留给你的代码——全心投入的某人留下的代码。 -
Ron Jeffries,Extreme Programming Installed(中译版《极限编程实施》)以及 Extreme Programming Adventures in C#(中译版《C#极限编程探险》)作者。
能通过所有测试;
没有重复代码;
体现系统中的全部设计理念;
包括尽量少的实体,比如类、方法、函数等。
第二 三章 命名与函数
-
- 命名规范
-
名副其实
变量、函数或类的名称应该已经答复了所有的大问题。它该告诉你,它为什么会存在,它做什么事,应该怎么用。如果名称需要注释来补充,那就不算是名副其实。 -
避免误导
程序员必须避免留下掩藏代码本意的错误线索。应当避免使用与本意相悖的词。例如,hp、aix和sco都不该用做变量名,因为它们都是UNIX平台或类UNIX平台的专有名称。
别用accountList来指称一组账号,除非它真的是List类型。List一词对程序员有特殊意义。如果包纳账号的容器并非真是个List,就会引起错误的判断。所以,用accountGroup或bunchOfAccounts,甚至直接用accounts都会好一些。
误导性名称真正可怕的例子,是用小写字母l和大写字母O作为变量名,尤其是在组合使用的时候。当然,问题在于它们看起来完全像是常量“壹”和“零”。 -
做有意义的区分
如果程序员只是为满足编译器或解释器的需要而写代码,就会制造麻烦。
例如,因为同一作用范围内两样不同的东西不能重名,你可能会随手改掉其中一个的名称。有时干脆以错误的拼写充数,结果就是出现在更正拼写错误后导致编译器出错的情况
光是添加数字系列或是废话远远不够,即便这足以让编译器满意。如果名称必须相异,那其意思也应该不同才对。以数字系列命名(a1、a2,……aN)是依义命名的对立面。这样的名称纯属误导——完全没有提供正确信息;没有提供导向作者意图的线索。如果参数名改为source和destination,这个函数就会像样许多。
废话是另一种没意义的区分。假设你有一个 Product 类。如果还有一个 ProductInfo 或ProductData类,那它们的名称虽然不同,意思却无区别。Info和Data就像a、an和the一样,是意义含混的废话。注意,只要体现出有意义的区分。废话都是冗余。Variable一词永远不应当出现在变量名中。Table一词永远不应当出现在表名中。NameString会比Name好吗?难道Name会是一个浮点数不成?如果是这样,就触犯了关于误导的规则。设想有个名为Customer的类,还有一个名为CustomerObject的类。区别何在呢?哪一个是表示客户历史支付情况的最佳途径? -
使用读的出来的名称
人类长于记忆和使用单词。 -
使用可搜索的名称
窃以为单字母名称仅用于短方法中的本地变量。名称长短应与其作用域大小相对应[N5]。若变量或常量可能在代码中多处使用,则应赋其以便于搜索的名称。 -
类名 方法名
类名和对象名应该是名词或 名词短语,方法名应当是动词或动词短语。 -
每个概念对应一个词
给每个抽象概念选一个词,并且一以贯之。例如,使用fetch、retrieve和get来给在多个类中的同种方法命名。你怎么记得住哪个类中是哪个方法呢? -
别用双关语
避免将单词用于不同目的。在多个类中都有add方法,该方法通过增加或连接两个现存值来获得新值。假设要写个新类,该类中有一个方法,把单个参数放到群集(collection)中。该把这个方法叫做 add 吗?这样做貌似和其他 add 方法保持了一致,但实际上语义却不同,应该用 insert或append之类词来命名才对。把该方法命名为add,就是双关语了。 -
使用领域名称
给对象取个技术性的名称,通常是最靠谱的做法。如果不能用程序员熟悉的术语来给手头的工作命名,就采用从所涉问题领域而来的名称吧。至少,负责维护代码的程序员就能去请教领域专家了。
-
- 函数规范
-
短小
函数的第一规则是要短小。第二条规则是还要更短小。函数也不该有100行那么长,20行封顶最佳。if语句、else语句、while语句等,其中的代码块应该只有一行。该行大抵应该是一个函数调用语句。这样不但能保持函数短小,而且,因为块内调用的函数拥有较具说明性的名称,从而增加了文档上的价值。这也意味着函数不应该大到足以容纳嵌套结构。所以,函数的缩进层级不该多于一层或两层。当然,这样的函数易于阅读和理解。 -
只做一件事
函数应该只做一件事,做好一件事,只做这一件事 -
每个函数一个抽象层级
要确保函数只做一件事,函数中的语句都要在同一抽象层级上。 -
Switch语句
该问题的解决方案是将switch语句埋到抽象工厂底下,不让任何人看到。该工厂使用switch语句为Employee的派生物创建适当的实体,而不同的函数,如calculatePay、isPayday和deliverPay等,则藉由Employee接口多态地接受派遣。
对于switch语句,我的规矩是如果只出现一次,用于创建多态对象,而且隐藏在某个继承关系中,在系统其他部分看不到,就还能容忍。当然也要就事论事,有时我也会部分或全部违反这条规矩。 -
使用描述性的名称
别害怕长名称。长而具有描述性的名称,要比短而令人费解的名称好。长而具有描述性的名称,要比描述性的长注释好。使用某种命名约定,让函数名称中的多个单词容易阅读,然后使用这些单词给函数取个能说清其功用的名称。 -
函数参数
有足够特殊的理由才能用三个以上参数(多参数函数)——所以无论如何也不要这么做。如果函数看来需要两个、三个或三个以上参数,就说明其中一些参数应该封装为类了。软件开发领域的所有创新都是在不断尝试从源代码中消灭重复。 -
错误处理
如果使用异常替代返回错误码,错误处理代码就能从主路径代码中分离出来,Try/catch代码块丑陋不堪。它们搞乱了代码结构,把错误处理与正常流程混为一谈。最好把try和catch代码块的主体部分抽离出来,另外形成函数。函数应该只做一件事。错误处理就是一件事。因此,处理错误的函数不该做其他事。这意味着(如上例所示)如果关键字try在某个函数中存在,它就该是这个函数的第一个单词,而且在catch/finally代码块后面也不该有其他内容。 -
别重复自己
重复可能是软件中一切邪恶的根源。许多原则与实践规则都是为控制与消除重复而创建。 -
如何写出这样的函数
写代码和写别的东西很像。在写论文或文章时,你先想什么就写什么,然后再打磨它。初稿也许粗陋无序,你就斟酌推敲,直至达到你心目中的样子。我写函数时,一开始都冗长而复杂。有太多缩进和嵌套循环。有过长的参数列表。名称是随意取的,也会有重复的代码。不过我会配上一套单元测试,覆盖每行丑陋的代码。然后我打磨这些代码,分解函数、修改名称、消除重复。我缩短和重新安置方法。有时我还拆散类。同时保持测试通过。最后,遵循本章列出的规则,我组装好这些函数。我并不从一开始就按照规则写函数。我想没人做得到。
第四 五章注释与格式
-
- 注释
-
关于注释
若编程语言足够有表达力,或者我们长于用这些语言来表达意图,就不那么需要注释——也许根本不需要。注释的恰当用法是弥补我们在用代码表达意图时遭遇的失败。注释存在的时间越久,就离其所描述的代码越远,越来越变得全然错误。原因很简单。程序员不能坚持维护注释。代码在变动,在演化。从这里移到那里。彼此分离、重造又合到一处。很不幸,注释并不总是随之变动——不能总是跟着走。不准确的注释要比没注释坏得多。它们满口胡言。它们预期的东西永不能实现。它们设定了无需也不应再遵循的旧规则。真实只在一处地方有:代码。只有代码能忠实地告诉你它做的事。那是唯一真正准确的信息来源。所以,尽管有时也需要注释,我们也该多花心思尽量减少注释量
带有少量注释的整洁而有表达力的代码,要比带有大量注释的零碎而复杂的代码像样得多。与其花时间编写解释你搞出的糟糕的代码的注释,不如花时间清洁那堆糟糕的代码。 -
好注释
- 法律信息 提供基本信息的注释 对意图的解释 阐释 警示
- TODO 注释 防止将要完成的工作描述
- 放大某种不合理或者其他特点
-
坏注释
多余的注释,无具体信息的注释 代码作者 把代码注释掉 过多信息的注释
-
- 格式
-
格式的目的
先明确一下,代码格式很重要。代码格式不可忽略,必须严肃对待。代码格式关乎沟通,而沟通是专业开发者的头等大事。你今天编写的功能,极有可能在下一版本中被修改,但代码的可读性却会对以后可能发生的修改行为产生深远影响。原始代码修改之后很久,其代码风格和可读性仍会影响到可维护性和扩展性。即便代码已不复存在,你的风格和律条仍存活下来。 -
垂直格式
几乎所有的代码都是从上往下读,从左往右读。每行展现一个表达式或一个子句,每组代码行展示一条完整的思路。这些思路用空白行区隔开来。在封包声明、导入声明和每个函数之间,都有空白行隔开。这条极其简单的规则极大地影响到代码的视觉外观。每个空白行都是一条线索,标识出新的独立概念。往下读代码时,你的目光总会停留于空白行之后那一行。
如果说空白行隔开了概念,靠近的代码行则暗示了它们之间的紧密关系。所以,紧密相关的代码应该互相靠近
实体变量应该在类的顶部声明。相关函数。若某个函数调用了另外一个,就应该把它们放到一起,而且调用者应该尽可能放在被调用者上面。这样,程序就有个自然的顺序。若坚定地遵循这条约定,读者将能够确信函数声明总会在其调用后很快出现。概念相关。概念相关的代码应该放到一起。相关性越强,彼此之间的距离就该越短。
一般而言,我们想自上向下展示函数调用依赖顺序。也就是说,被调用的函数应该放在执行调用的函数下面。这样就建立了一种自顶向下贯穿源代码模块的良好信息流。 -
横向格式
应该尽力保持代码行短小。死守80个字符的上限有点僵化,而且我也并不反对代码行长度达到100个字符或120个字符。再多的话,大抵就是肆意妄为了。我一向遵循无需拖动滚动条到右边的原则。但近年来显示器越来越宽,而年轻程序员又能将显示字符缩小到如此程度,屏幕上甚至能容纳200个字符的宽度。别那么做
第六章 对象和数据结构
- 数据抽象
隐藏实现并非只是在变量之间放上一个函数层那么简单。隐藏实现关乎抽象!类并不简单地用取值器和赋值器将其变量推向外间,而是曝露抽象接口,以便用户无需了解数据的实现就能操作数据本体。
得墨忒耳律认为,类C的方法f只应该调用以下对象的方法:
C
由f创建的对象;
作为参数传递给f的对象;
由C的实体变量持有的对象。
方法不应调用由任何函数返回的对象的方法。换言之,只跟朋友谈话,不与陌生人谈话。
- 数据抽象
第七章 错误处理
-
- 优雅的处理错误代码
-
使用异常而非返回码
搞乱了调用者代码。调用者必须在调用之后即刻检查错误。不幸的是,这个步骤很容易被遗忘。所以,遇到错误时,最好抛出一个异常。调用代码很整洁,其逻辑不会被错误处理搞乱。 -
先写Try Catch Try 语句
-
使用非检查异常
如果你在编写一套关键代码库,则可控异常有时也会有用:你必须捕获异常。但对于一般的应用开发,其依赖成本要高于收益。 -
给出异常的环境说明 依调用者需要定义异常类
-
别返回null值 别传递null值
第八章 边界
-
- 保持软件边界整洁
-
使用第三方代码
在接口提供者和使用者之间,存在与生俱来的张力。第三方程序包和框架提供者追求普适性,这样就能在多个环境中工作,吸引广泛的用户。而使用者则想要集中满足特定需求的接口。这种张力会导致系统边界上出现问题。
在学习性测试中,我们如在应用中那样调用第三方代码。我们基本上是在通过核对试验来检测自己对那个API的理解程度。测试聚焦于我们想从API得到的东西。
学习性测试毫无成本。无论如何我们都得学习要使用的API,而编写测试则是获得这些知识的容易而不会影响其他工作的途径。学习性测试是一种精确试验,帮助我们增进对 API的理解。学习性测试不光免费,还在投资上有正面的回报。当第三方程序包发布了新版本,我们可以运行学习性测试,看看程序包的行为有没有改变。学习性测试确保第三方程序包按照我们想要的方式工作。一旦整合进来,就不能保证第三方代码总与我们的需要兼容。原作者不得不修改代码来满足他们自己的新需要。他们会修正缺陷、添加新功能。风险伴随新版本而来。如果第三方程序包的修改与测试不兼容,我们也能马上发现。 -
使用尚不存在的代码
还有另一种边界,那种将已知和未知分隔开的边界。在代码中总有许多地方是我们的知识未及之处。有时,边界那边就是未知的(至少目前未知)。有时,我们并不往边界那边看过去。
第九章 单元测试
-
- 单元测试
-
保持测试整洁
脏测试等同于——如果不是坏于的话——没测试。问题在于,测试必须随生产代码的演进而修改。测试越脏,就越难修改。测试代码越缠结,你就越有可能花更多时间塞进新测试,而不是编写新生产代码。修改生产代码后,旧测试就会开始失败,而测试代码中乱七八糟的东西将阻碍代码再次通过。于是,测试变得就像是不断翻番的债务。随着版本递进,团队维护测试代码组的代价也在上升。最终,它变成了开发者最大的抱怨对象。当经理们问及为何超支如此巨大,开发者们就归咎于测试。最后,他们只能扔掉了整个测试代码组
如果测试不能保持整洁,你就会失去它们。没有了测试,你就会失去保证生产代码可扩展的一切要素。你没看错。正是单元测试让你的代码可扩展、可维护、可复用。原因很简单。有了测试,你就不担心对代码的修改!没有测试,每次修改都可能带来缺陷。无论架构多有扩展性,无论设计划分得有多好,没有了测试,你就很难做改动,因为你担忧改动会引入不可预知的缺陷。
有了测试,愁云一扫而空。测试覆盖率越高,你就越不担心。哪怕是对于那种架构并不优秀、设计晦涩纠缠的代码,你也能近乎没有后患地做修改。实际上,你能毫无顾虑地改进架构和设计!所以,覆盖了生产代码的自动化单元测试程序组能尽可能地保持设计和架构的整洁。测试带来了一切好处,因为测试使改动变得可能。如果测试不干净,你改动自己代码的能力就有所牵制,而你也会开始失去改进代码结构的能力。测试越脏,代码就会变得越脏。最终,你丢失了测试,代码开始腐坏。 -
TODO 怎么写整洁测试 测试规范
第十章 类
-
- 类的规范
-
类的组织
类应该从一组变量列表开始。如果有公共静态常量,应该先出现。然后是私有静态变量,以及私有实体变量。很少会有公共变量。公共函数应跟在变量列表之后。我们喜欢把由某个公共函数调用的私有工具函数紧随在该公共函数后面。这符合了自顶向下原则,让程序读起来就像一篇报纸文章。封装我们喜欢保持变量和工具函数的私有性,但并不执着于此。 -
类应该短小
单一权责原则(SRP)[2]认为,类或模块应有且只有一条加以修改的理由。该原则既给出了权责的定义,又是关于类的长度的指导方针。类只应有一个权责——只有一条修改的理由。系统应该由许多短小的类而不是少量巨大的类组成。每个小类封装一个权责,只有一个修改的原因,并与少数其他类一起协同达成期望的系统行为。
内聚 类应该只有少量实体变量。类中的每个方法都应该操作一个或多个这种变量。通常而言,方法操作的变量越多,就越黏聚到类上。如果一个类中的每个变量都被每个方法所使用,则该类具有最大的内聚性。一般来说,创建这种极大化内聚类是既不可取也不可能的;另一方面,我们希望内聚性保持在较高位置。内聚性高,意味着类中的方法和变量互相依赖、互相结合成一个逻辑整体。保持函数和参数列表短小的策略,有时会导致为一组子集方法所用的实体变量数量增加。出现这种情况时,往往意味着至少有一个类要从大类中挣扎出来。你应当尝试将这些变量和方法分拆到两个或多个类中,让新的类更为内聚。
为了修改而组织 我们希望将系统打造成在添加或修改特性时尽可能少惹麻烦的架子。在理想系统中,我们通过扩展系统而非修改现有代码来添加新特性。
依赖倒置 通过降低连接度,我们的类就遵循了另一条类设计原则,依赖倒置原则(Dependency Inversion Principle,DIP)[6]。本质而言,DIP认为类应当依赖于抽象而不是依赖于具体细节。
第十一章 系统
-
- 系统层次的整洁
-
将系统的构造与使用分开
软件系统应将启始过程和启始过程之后的运行时逻辑分离开,在启始过程中构建应用对象,也会存在互相缠结的依赖关系。每个应用程序都该留意启始过程。那也是本章中我们首先要考虑的问题。将关注的方面分离开,是软件技艺中最古老也最重要的设计技巧。将构造与使用分开的方法之一是将全部构造过程搬迁到main或被称之为main的模块中,设计系统的其余部分时,假设所有对象都已正确构造和设置(如图11-1所示)。
其他还有工厂 依赖注入 //TODO -
扩容
注意,持久化之类关注面倾向于横贯某个领域的天然对象边界。你会想用同样的策略来持久化所有对象,例如,使用DBMS[6]而非平面文件,表名和列名遵循某种命名约定,采用一致的事务语义,等等。原则上,你可以从模块、封装的角度推理持久化策略。但在实践上,你却不得不将实现了持久化策略的代码铺展到许多对象中。我们用术语“横贯式关注面”来形容这类情况。同样,持久化框架和领域逻辑,孤立地看也可以是模块化的。问题在于横贯这些领域的情形。实际上,EJB架构处理持久化、安全和事务的方法是“预期”面向方面编程(aspect-oriented programming,AOP)[7],而AOP是一种恢复横贯式关注面模块化的普适手段。在AOP中,被称为切面(aspect)的模块构造指明了系统中哪些点的行为会以某种一致的方式被修改,从而支持某种特定的场景。这种说明是用某种简洁的声明或编程机制来实现的。以持久化为例,可以声明哪些对象和属性(或其模式)应当被持久化,然后将持久化任务委托给持久化框架。行为的修改由AOP框架以无损方式[8]在目标代码中进行。下面来看看Java中的三种方面或类似方面的机制。 -
Java代理 纯Java AOP机制 AspectJ
第十二章 迭进
-
- 通过迭进设计达到整洁目的
- 简单设计规则
运行所有测试;不可重复;表达了程序员的意图;尽可能减少类和方法的数量;以上规则按其重要程度排列。
第十三章 并发编程
-
- 并发防御原则
-
单一职责原则
单一权责原则(SRP)[5]认为,方法/类/组件应当只有一个修改的理由。并发设计自身足够复杂到成为修改的理由,所以也该从其他代码中分离出来。不幸的是,并发实现细节常常直接嵌入到其他生产代码中。下面是要考虑的一些问题:
并发相关代码有自己的开发、修改和调优生命周期;开发相关代码有自己要对付的挑战,和非并发相关代码不同,而且往往更为困难;即便没有周边应用程序增加的负担,写得不好的并发代码可能的出错方式数量也已经足具挑战性。
建议:分离并发相关代码与其他代码[6]。 -
推论 限制数据作用域
谨记数据封装;严格限制对可能被共享的数据的访问。 -
推论 使用数据副本 线程应尽可能独立
-
警惕同步方法之间的依赖 保持同步区域微小