《代码之丑》笔记

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,体会更明显。

编程不是体力活,开始写之前,还是要或多或少设计一下,不要一上来就咔咔干。写代码是个理论和实践螺旋上升的过程,不断实践得出新的理论,然后在实践中检验并升华,如此周而复始。


---- 分割线 ----


对待烂代码,有至少三种境界:

  1. 意识不到问题,烂代码当做好代码
  2. 意识到问题,但没找到好的解决办法,好的办法指不影响其他模块、高效、快速的处理方式
  3. 意识到问题,并又快又好解决

希望大家包括我自己都不只是停留在第2阶段,而是尽快成长到第3阶段,发现一个问题就尽快重构优化它!

相关推荐
程序员爱钓鱼34 分钟前
Go语言实战案例-创建模型并自动迁移
后端·google·go
javachen__39 分钟前
SpringBoot整合P6Spy实现全链路SQL监控
spring boot·后端·sql
阿珊和她的猫3 小时前
v-scale-scree: 根据屏幕尺寸缩放内容
开发语言·前端·javascript
uzong6 小时前
技术故障复盘模版
后端
GetcharZp7 小时前
基于 Dify + 通义千问的多模态大模型 搭建发票识别 Agent
后端·llm·agent
加班是不可能的,除非双倍日工资7 小时前
css预编译器实现星空背景图
前端·css·vue3
桦说编程7 小时前
Java 中如何创建不可变类型
java·后端·函数式编程
IT毕设实战小研7 小时前
基于Spring Boot 4s店车辆管理系统 租车管理系统 停车位管理系统 智慧车辆管理系统
java·开发语言·spring boot·后端·spring·毕业设计·课程设计
wyiyiyi7 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip8 小时前
vite和webpack打包结构控制
前端·javascript