1. 开始
本文为极客时间《代码之丑》的学习笔记,原作十分精彩,推荐阅读原作。
我们经常听到xxx之美,比如《代码之美》、《数据结构之美》、《设计模式之美》,但理解什么是丑似乎更重要,知道什么是错的,才能做到正确。正如法律和道德,规定的是不允许做的行为,对应的正是"代码之丑"。
2. 开篇词
java
public void approve(final long bookId) {
...
book.setReviewStatus(ReviewStatus.APPROVED);
...
}
之所以我注意到这段代码,完全是因为这里用到了 setter。在我看来,setter 就是一个坏味道,每次一看到 setter,我就会警觉起来。
setter 的出现,是对于封装的破坏,它把一个类内部的实现细节暴露了出来。我在《软件设计之美》中讲过,面向对象的封装,关键点是行为,而使用 setter 多半只是做了数据的聚合,缺少了行为的设计,这段代码改写后的 approve 函数,就是这里缺少的行为。
再扩展一步,setter 通常还意味着变化,而我在《软件设计之美》中讲函数式编程时也说过,一个好的设计应该尽可能追求不变性。所以,setter 也是一个提示符,告诉我们,这个地方的设计可能有问题。
java
public void approve(final long bookId) {
...
book.approve();
...
}
"写代码"有两个维度:正确性和可维护性,不要只关注正确性。能把代码写对,是每个程序员的必备技能,但能够把代码写得更具可维护性,这是一个程序员从业余迈向职业的第一步。
在我写代码的这 20 多年里,一直对代码的坏味道非常看重,因为它是写出好代码的起点。有对代码坏味道的嗅觉,能够识别出坏味道,接下来,你才有机会去"重构(Refactoring)",把代码一点点打磨成一个整洁的代码(Clean Code)。Linux 内核开发者 Linus Torvalds 在行业里有个爱骂人的坏名声,原因之一就是他对于坏味道的不容忍。
《重构》中的"代码的坏味道"意图虽好,但却需要一个人对于整洁代码有着深厚的理解,才能识别出这些坏味道。否则,即使你知道有哪些坏味道,但真正有坏味道的代码出现在你面前时,你仍然无法认得它。
比如,你可以看看 Info、Data、Manager 是不是代码库经常使用的词汇,而它们往往是命名没有经过仔细思考的地方。在很多人眼中,这些代码是没有问题的。正因如此,才有很多坏味道的代码才堂而皇之地留在你的眼皮底下。
3. 笔记
3.1. 缺乏业务含义的命名:如何精准命名?
命名过于宽泛,不能精准描述,这是很多代码在命名上存在的严重问题,也是代码难以理解的根源所在。
java
public void processChapter(long chapterId) {
Chapter chapter = this.repository.findByChapterId(chapterId);
if (chapter == null) {
throw new IllegalArgumentException("Unknown chapter [" + chapterId + "]");
}
chapter.setTranslationState(TranslationState.TRANSLATING);
this.repository.save(chapter);
}
上面的 processChapter
其实应该命名为 startTranslation
,处理章节这个名字太宽泛。
命名要能够描述出这段代码在做的事情,一个好的名字应该描述意图,而非细节。
编写可维护的代码要使用业务语言,而不是技术语言。
3.2. 乱用英语:站在中国人的视角来看英文命名
java
public void completedTranslate(final List<ChapterId> chapterIds) {
List<Chapter> chapters = repository.findByChapterIdIn(chapterIds);
chapters.forEach(Chapter::completedTranslate);
repository.saveAll(chapters);
}
常见的命名规则是:类名是一个名词,表示一个对象,而方法名则是一个动词,或者是动宾短语,表示一个动作。
以此为标准衡量这个名字,completedTranslate
并不是一个有效的动宾结构。如果把这个名字改成动宾结构,只要把"完成"译为 complete
,"翻译"用成它的名词形式 translation
就可以了。所以,这个函数名可以改成 completeTranslation
:
英语使用不当造成的坏味道:
- 违反语法规则的命名
- 不准确的英语词汇
- 英语单词的拼写错误
3.3. 重复代码:简单需求到处修改,怎么办?
时至今日,很多初级程序员写代码依然规避不了复制粘贴,基本的做法就是把一段代码复制过来,改动几个地方,然后,跑一下没有太大问题就万事大吉了。殊不知,这种做法就是在给未来挖坑。
通常情况下,只要这些复制代码其中有一点逻辑要修改,就意味着所有复制粘贴的地方都要修改。所以,我们在实际的项目中,常常看见这样的情况:明明是一个简单的需求,你却需要改很多的地方,需要花费很长的时间,结果无论是项目经理,还是产品经理,对进度都很不满意。
更可怕的是,只要你少改了一处,就意味着留下一处潜在的问题。问题会在不经意间爆发出来,让人陷入难堪的境地。
复制粘贴是最容易产生重复代码的地方,所以,一个最直白的建议就是,不要使用复制粘贴。真正应该做的是,先提取出函数,然后,在需要的地方调用这个函数。
重复是一个泥潭,对于程序员来说,时刻提醒自己不要重复是至关重要的。在软件开发里,有一个重要的原则叫做 Don't Repeat Yourself(不要重复自己,简称 DRY)。
写代码要想做到 DRY,一个关键点是能够发现重复。发现重复,一种是在泥潭中挣扎后,被动地发现,还有一种是提升自己识别能力,主动地发现重复。这种主动识别的能力,其实背后要有对软件设计更好的理解,尤其是对分离关注点的理解。
重复代码:
- 复制粘贴的代码
- 结构重复的代码
- if 和 else 代码块中的语句高度类似
3.4. 长函数:为什么你总是不可避免地写出长函数?
对于函数长度容忍度高,这是导致长函数产生的关键点。
如果一个人认为 100 行代码不算长,那在他眼中,很多代码根本就是没有问题的,也就更谈不上看到更多问题了,这其实是一个观察尺度的问题。这就好比,没有电子显微镜之前,人们很难理解疾病的原理,因为看不到病毒,就不可能理解病毒可以致病这个道理。
一个好的程序员面对代码库时要有不同尺度的观察能力,看设计时,要能够高屋建瓴,看代码时,要能细致入微。
到具体的工作中,"越小越好"是一个追求的目标,不过,没有一个具体的数字,就没办法约束所有人的行为。所以,通常情况下,我们还是要定义出一个代码行数的上限,以保证所有人都可以按照这个标准执行。
我自己写代码的习惯是这样的。像 Python、Ruby 这样表达能力比较强的动态语言,大多数情况下,一行代码(one-liner program)可以解决很多问题,所以,我对自己的要求大约是 5 行左右,并且能够用一行代码解决的问题,就尽量会用一行代码解决;而像 Java 这样表达能力稍弱的静态类型语言,我也争取在 10 行代码之内解决问题。
重构手法:提取函数。
记住一句话:把函数写短,越短越好。
3.5. 大类:如何避免写出难以理解的大类?
为什么不把所有的代码都写到一个文件里?
一方面,相同的功能模块没有办法复用;另一方面,也是更关键的,把代码都写到一个文件里,其复杂度会超出一个人能够掌握的认知范围。简言之,一个人理解的东西是有限的,没有人能同时面对所有细节。
人类面对复杂事物给出的解决方案是分而治之。
最容易产生大类的原因在于职责的不单一。
大类的产生往往还有一个常见的原因,就是字段未分组。
所谓的将大类拆解成小类,本质上在做的工作是一个设计工作。我们分解的依据其实是单一职责这个重要的设计原则。
有些人心中会升起一些疑问:如果我们把大类都拆成小类,类的数量就会增多,那人们理解的成本是不是也会增加呢?
在这个问题上,程序设计语言早就已经有了很好的解决方案,所以,我们会看到在各种程序设计语言中,有诸如包、命名空间之类的机制,将各种类组合在一起。在你不需要展开细节时,面对的是一个类的集合。再进一步,还有各种程序库把这些打包出来的东西再进一步打包,让我们只要面对简单的接口,而不必关心各种细节。
如此层层封装,软件不就是这样构建出来的吗?
3.6. 长参数列表:如何处理不同类型的长参数?
函数间共享信息的方式不止一种,除了参数列表,最常见的一种方式是全局变量。但全局变量会带给我们太多意想不到的问题,所以,在初学编程的时候,老师就会告诉我们,不要使用全局变量。从程序设计语言发展的过程中,我们也可以看到,取消全局变量已经成为了大势所趋。
一个典型的消除长参数列表的重构手法:将参数列表封装成对象。
应对长参数列表主要的方式就是减少参数的数量,一种最直接的方式就是将参数列表封装成一个类。但并不是说所有的情况都能封装成类来解决,我们还要分析是否所有的参数都有相同的变动频率。
- 变化频率相同,则封装成一个类。
- 变化频率不同的话:
- 静态不变的,可以成为软件结构的一部分;
- 多个变化频率的,可以封装成几个类。
3.7. 滥用控制语句:出现控制结构,多半是错误的提示
无论是嵌套的代码,还是 else 语句,我们之所以要把它们视为坏味道,本质上都在追求简单,因为一段代码的分支过多,其复杂度就会大幅度增加。我们一直在说,人脑能够理解的复杂度是有限的,分支过多的代码一定是会超过这个理解范围。
在软件开发中,有一个衡量代码复杂度常用的标准,叫做圈复杂度(Cyclomatic complexity,简称 CC),圈复杂度越高,代码越复杂,理解和维护的成本就越高。在圈复杂度的判定中,循环和选择语句占有重要的地位。
3.8. 缺乏封装:如何应对火车代码和基本类型偏执问题?
java
String name = book.getAuthor().getName();
Martin Fowler 在《重构》中给这种坏味道起的名字叫过长的消息链(Message Chains),而有人则给它起了一个更为夸张的名字:火车残骸(Train Wreck),形容这样的代码像火车残骸一般,断得一节一节的。
解决这种代码的重构手法叫隐藏委托关系(Hide Delegate),说得更直白一些就是,把这种调用封装起来。
优化后:
java
class Book {
...
public String getAuthorName() {
return this.author.getName();
}
...
}
String name = book.getAuthorName();
要想摆脱初级程序员的水平,就要先从少暴露细节开始。
3.9. 可变的数据:不要让你的代码"失控"
可变的数据是可怕,但是,比可变的数据更可怕的是,不可控的变化,而暴露 setter 就是这种不可控的变化。把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以,这种修改完全是不可控的。
缺乏封装再加上不可控的变化,在我个人心目中,setter 几乎是排名第一的坏味道。
消除 setter ,有一种专门的重构手法,叫做移除设值函数(Remove Setting Method)。总而言之,setter 是完全没有必要存在的。
java
public void approve(final long bookId) {
...
book.setReviewStatus(ReviewStatus.APPROVED);
...
}
用一个函数替代了 setter,也就是把它用行为封装了起来,优化后:
java
public void approve(final long bookId) {
...
book.approve();
...
}
通过在 Book 类里引入了一个 approve 函数,我们将审核状态封装了起来。
java
class Book {
public void approve() {
this.reviewStatus = ReviewStatus.APPROVED;
}
}
3.10. 变量声明与赋值分离:普通的变量声明,怎么也有坏味道?
按照我们通常的理解,一个变量的初始化是分成了声明和赋值两个部分,而我这里要说的就是,变量初始化最好一次性完成。这段代码里的变量赋值是在声明很久之后才完成的,也就是说,变量初始化没有一次性完成。
这种代码真正的问题就是不清晰,变量初始化与业务处理混在在一起。通常来说,这种代码后面紧接着就是一大堆更复杂的业务处理。当代码混在一起的时候,我们必须小心翼翼地从一堆业务逻辑里抽丝剥茧,才能把逻辑理清,知道变量到底是怎么初始化的。很多代码难读,一个重要的原因就是把不同层面的代码混在了一起。
这种代码在实际的代码库中出现的频率非常高,只不过,它会以各种变形的方式呈现出来。有的变量甚至是在相隔很远的地方才做了真正的赋值,完成了初始化,这中间已经夹杂了很多的业务代码在其中,进一步增加了理解的复杂度。
所以,我们编程时要有一个基本原则:变量一次性完成初始化。
java
EpubStatus status = null;
CreateEpubResponse response = createEpub(request);
if (response.getCode() == 201) {
status = EpubStatus.CREATED;
} else {
status = EpubStatus.TO_CREATE;
}
提取出一个函数,将 response 转成对应的内部的 EPUB 状态。优化后:
java
final CreateEpubResponse response = createEpub(request);
final EpubStatus status = toEpubStatus(response);
private EpubStatus toEpubStatus(final CreateEpubResponse response) {
if (response.getCode() == 201) {
return EpubStatus.CREATED;
}
return EpubStatus.TO_CREATE;
}
上一讲,我们讲了可变的数据会带来怎样的影响,其中的一个结论是,尽可能编写不变的代码。这里其实是这个话题的延伸,尽可能使用不变的量。
3.11. 依赖混乱:你可能还没发现问题,代码就已经无法挽救了
今天我们讲了由于代码依赖关系而产生的坏味道,一种是缺少防腐层,导致不同代码糅合在一起 ,一种是在业务代码中出现了具体的实现类。
缺少防腐层,会让请求对象传导到业务代码中,造成了业务与外部接口的耦合,也就是业务依赖了一个外部通信协议。一般来说,业务的稳定性要比外部接口高,这种反向的依赖就会让业务一直无法稳定下来,继而在日后带来更多的问题。解决方案自然就是引入一个防腐层,将业务和接口隔离开来。
业务代码中出现具体的实现类,实际上是违反了依赖倒置原则。因为违反了依赖倒置原则,业务代码也就不可避免地受到具体实现的影响,也就造成了业务代码的不稳定。识别一段代码是否属于业务,我们不妨问一下,看把它换成其它的东西,是否影响业务。解决这种坏味道就是引入一个模型,将业务与具体的实现隔离开来。
代码应该向着稳定的方向依赖。
3.12. 不一致的代码:为什么你的代码总被吐槽难懂?
大多数程序员都是在一个团队中工作,对于一个团队而言,一致性是非常重要的一件事。因为不一致会造成认知上的负担,在一个系统中,做类似的事情,却有不同的做法,或者起到类似作用的事物,却有不同的名字,这会让人产生困惑。所以,即便是不甚理想的标准,也比百花齐放要好。
java
enum DistributionChannel {
WEBSITE,
KINDLE_ONLY,
ALL
}
表示类似含义的代码应该有一致的名字。优化后:
java
enum DistributionChannel {
WEBSITE,
KINDLE,
ALL
}
代码中的不一致:
java
public void createBook(final List<BookId> bookIds) throws IOException {
List<Book> books = bookService.getApprovedBook(bookIds)
CreateBookParameter parameter = toCreateBookParameter(books)
HttpPost post = createBookHttpRequest(parameter)
httpClient.execute(post)
}
这是一段在翻译引擎中创建作品的代码。首先,根据要处理的作品 ID 获取其中已经审核通过的作品,然后,发送一个 HTTP 请求在翻译引擎中创建出这个作品。
这么短的一段代码有什么问题吗?问题就在于这段代码中的不一致。你可能会想:"不一致?不一致体现在哪里呢?"答案就是,这些代码不是一个层次的代码。
通过了解这段代码的背景,你可能已经看出一些端倪了。首先是获取审核通过的作品,这是一个业务动作,接下来的三行其实是在做一件事,也就是发送创建作品的请求。具体到代码上,这三行代码分别是创建请求的参数,根据参数创建请求,最后,再把请求发送出去。这三行代码合起来完成了一个发送创建作品请求这么一件事,而这件事才是一个完整的业务动作。
所以,我说这个函数里的代码并不在一个层次上,有的是业务动作,有的是业务动作的细节。理解了这一点,我们就可以把这些业务细节的代码提取到一个函数里:
java
public void createBook(final List<BookId> bookIds) throws IOException {
List<Book> books = bookService.getApprovedBook(bookIds)
createRemoteBook(books)
}
private void createRemoteBook(List<Book> books) throws IOException {
CreateBookParameter parameter = toCreateBookParameter(books)
HttpPost post = createBookHttpRequest(parameter)
httpClient.execute(post)
}
一说到分层,大多数人想到的只是模型的分层,很少有人会想到在函数的语句中也要分层。各种层次的代码混在一起,许多问题也就随之而来了,最典型莫过于我们之前讲过的长函数。
很多程序员纠结的技术问题,其实是一个软件设计问题,不要通过奇技淫巧去解决一个本来不应该被解决的问题。
3.13. 落后的代码风格:使用"新"的语言特性和程序库升级你的代码
随着时间的流逝,总会有一些新的方案产生,替换原有的方案 。这其中,最明显的一个例子就是程序设计语言。没有哪门语言是完美的,所以,只要有一个活跃的社区,这门语言就会不断地演进。
从 C++ 11 开始,C++ 开始出现了大规模的演化,让之前学习 C++ 的人感觉自己就像没学过这门语言一样;Python 2 与 Python 3 甚至是不兼容的演化;Java 也是每隔一段时间就会出现一次大的语言演进。
也正是因为语言本身的演化,在不同时期接触不同版本的程序员写出来的程序,甚至不像是在用同一门语言在编程。所以,我们有机会看到在同一个代码库中,各种不同时期风格的代码并存。
通常来说,新的语言特性都是为了提高代码的表达性,减少犯错误的几率。所以,在实践中,我是非常鼓励你采用新的语言特性写代码的。
3.14. 多久进行一次代码评审最合适?
我在《10x 程序员工作法》里,花了一个模块的篇幅讲了沟通反馈,我们希望沟通要尽可能透明,尽可能及时。把这样的理解放到代码评审中,就是要尽可能多暴露问题,尽可能多做代码评审。
代码评审要暴露哪些问题?
- 实现方案的正确性;
- 算法的正确性;
- 代码的坏味道。
评审周期过长是有问题的,周期过长,累积的问题就会增多,造成的结果就是太多问题让人产生无力感 。如果遇到实现方案存在问题,要改动的代码就太多了,甚至会影响到项目的发布。
而提升评审的频率,评审的周期就会缩短,每个周期内写出来的代码就是有限的,人是有心力去修改的。
我在《10x 程序员工作法》讲过极限编程的理念,就是把好的实现推向极致,而代码评审的极致实践就是结对编程。
结对编程就是两个人一起写一段代码,一个人主要负责写,一个人则站在用外部视角保证这段代码的正确性。好的结对编程对两个人的精力集中度要求是很高的,两个人一起写一天代码其实是很累的一件事,不过,也正是因为代码是两个人一起写,代码质量会提高很多。
从我之前经历的一些团队实践来看,结对编程还有一个额外的好处,就是对于团队中的新人提升极大,这就是拜结对编程这种高强度的训练和反馈所赐。高强度的训练和反馈,本质上就是一种刻意练习,而刻意练习是一个人提升最有效的方式。
3.15. 新需求破坏了代码,怎么办?
一个有生命力的代码不会保持静止,新的需求总会到来,所以,写代码时需要时时刻刻保持嗅觉。
我用了两个例子给你讲了新需求到来时需要关注的地方,它们分别是:
- 增加新接口;
- 改动实体。
接口和实体,其实也是一个系统对外界产生影响的重要部分,一个是对客户端提供能力,一个是产生持久化信息。所以,我们必须谨慎地思考它们的变动,它们也是坏味道产生的高发地带。
对于接口,我们对外提供得越少越好,而对于实体,我们必须仔细分析它们扮演的角色。
3.16. 结束语 | 写代码是一件可以一生精进的事
写代码是一门手艺,需要不断地打磨。
我在《软件设计之美》中讲过,一个好的设计是在一个"小内核"上构建起来,然后,逐步添加更多模型。我们的知识拓展过程也是如此。我的"小内核"就是编写代码这件事,所有一切知识的拓展都是围绕这个内核展开的。
4. 第二曲线
4.1. 概念
任何事物的发展,都有一个生命周期, 在这个生命周期里,有起始期、成长期、 高峰期、下滑期和衰落期。
第二曲线,就是在高峰期到来或者消失 前,找到另一条新的高成长性曲线,获得持续性增长。
4.2. T型人才
成为T型人才
- T 型人才,简言之,一专多能
- 专,要有深度,能,要有广度
- 除了自身的专家技能之外,再有一些辅助技能
4.3. 表达:每个人都可以习得的技能
- 表达:把事情有结构的讲清楚,这是一种重要的职业技能, 也可以成为每个人的辅助技能
- 具备表达能力的人可以写文章、做演讲
- 表达能力强的人可以有另一种可能性
- 写自媒体
- 写专栏
- 讲课
- 表达能力是可以习得的
5. 总结
读完课程,受益匪浅。
其实有些点,已经在实践中了,比如:
- 精准命名
- 减少重复代码
- 减少长函数、大文件、圈复杂度(多重if/else)、大类
- 控制函数的过多参数
只是理解和思考的并没有那么深,或者说,还在潜意识里,尚未理论化、体系化,比如"人脑能够理解的复杂度有限,不能同时面对所有东西","重复代码只要少改一处,就会留下一处潜在的问题"。
还有一些点,完全没有想到,比如代码评审的重要作用是提前暴露问题,包括设计方案、算法、规范等。评审越频繁,改动量越小,越有利于项目健康发展。这一点在基础库中还是比较重要的。
另外,在代码命名一致性、层次一致性,变量初始化最好一次性完成,封装 setter、隐藏实现细节等方面,做得不够好,回想起来,有很多地方可以优化。
此外,新语言特性这一章也引起了我的思考。一些项目由 Javascript 迁移到 Typescript,能大幅增强项目健壮性和稳定性,其实就是因为社区足够活跃,技术不断演进的结果。对比下 Python2 到 Python3,体会更明显。
编程不是体力活,开始写之前,还是要或多或少设计一下,不要一上来就咔咔干。写代码是个理论和实践螺旋上升的过程,不断实践得出新的理论,然后在实践中检验并升华,如此周而复始。
---- 分割线 ----
对待烂代码,有至少三种境界:
- 意识不到问题,烂代码当做好代码
- 意识到问题,但没找到好的解决办法,好的办法指不影响其他模块、高效、快速的处理方式
- 意识到问题,并又快又好解决
希望大家包括我自己都不只是停留在第2阶段,而是尽快成长到第3阶段,发现一个问题就尽快重构优化它!