first commit
This commit is contained in:
101
专栏/代码之丑/00开篇词这一次,我们从“丑”代码出发.md
Normal file
101
专栏/代码之丑/00开篇词这一次,我们从“丑”代码出发.md
Normal file
@ -0,0 +1,101 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
00 开篇词 这一次,我们从“丑”代码出发
|
||||
开篇词 这一次,我们从“丑”代码出发## 开篇词 这一次,我们从“丑”代码出发
|
||||
|
||||
你好,我是郑晔!我又回来了!
|
||||
|
||||
我在“极客时间”里已经写了两个专栏,分别是《[10x 程序员工作法]》和《[软件设计之美]》,从工作原则和设计原则两个方面对软件开发的各种知识进行了探讨,帮助你搭建了一个开启程序员精进之路的框架。
|
||||
|
||||
不过,无论懂得多少道理,程序员依然要回归到写代码的本职工作上。所以,这次我准备和你从代码的坏味道出发,一起探讨如何写代码。
|
||||
|
||||
千里之堤毁于蚁穴
|
||||
|
||||
为什么要讲这个话题,就让我们先从一次代码评审讲起。在一次代码评审中,我注意到了这样一段代码:
|
||||
|
||||
public void approve(final long bookId) {
|
||||
...
|
||||
book.setReviewStatus(ReviewStatus.APPROVED);
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
这是在一个服务类里面写的,它的主要逻辑就是从仓库中找出一个作品,然后,将它的状态设置为审核通过,再将它存回去。前后的代码都属于常规的代码,但是,设置作品评审状态的代码引起了我的注意,于是有了下面这段对话。
|
||||
|
||||
我:这个地方为什么要这么写?
|
||||
|
||||
同事:我要将作品的审核状态设置为审核通过。
|
||||
|
||||
我:这个我知道,但为什么要在这里写 setter 呢?
|
||||
|
||||
同事:你的意思是?
|
||||
|
||||
我:这个审核的状态是作品的一个内部状态,为什么服务需要知道它呢?也就是说,这里通过 setter,将一个类的内部行为暴露了出来,这是一种破坏封装的做法。
|
||||
|
||||
同事被我说动了,于是这段代码变成了下面这个样子:
|
||||
|
||||
public void approve(final long bookId) {
|
||||
...
|
||||
book.approve();
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
之所以我注意到这段代码,完全是因为这里用到了 setter。在我看来,setter 就是一个坏味道,每次一看到 setter,我就会警觉起来。
|
||||
|
||||
setter 的出现,是对于封装的破坏,它把一个类内部的实现细节暴露了出来。我在《软件设计之美》中讲过,面向对象的封装,关键点是行为,而使用 setter 多半只是做了数据的聚合,缺少了行为的设计,这段代码改写后的 approve 函数,就是这里缺少的行为。
|
||||
|
||||
再扩展一步,setter 通常还意味着变化,而我在《软件设计之美》中讲函数式编程时也说过,一个好的设计应该尽可能追求不变性。所以,setter 也是一个提示符,告诉我们,这个地方的设计可能有问题。
|
||||
|
||||
你看,一个小小的 setter,背后却隐藏着这么多的问题。而所有这些问题,都会让代码在未来的日子变得更加不可维护,这就是软件团队陷入泥潭的开始。
|
||||
|
||||
我也一直和我团队的同学说,“写代码”有两个维度:正确性和可维护性,不要只关注正确性。能把代码写对,是每个程序员的必备技能,但能够把代码写得更具可维护性,这是一个程序员从业余迈向职业的第一步。
|
||||
|
||||
将坏味道重构为整洁代码
|
||||
|
||||
或许你也认同代码要有可维护性,也看了很多书,比如《程序设计实践》《代码整洁之道》等等,这些无一不是经典中的经典,甚至连怎么改代码,都有《重构》等着我们。没错,这些书我都读过,也觉得从中受益匪浅。
|
||||
|
||||
不过,回到真实的工作中,我发现了一个无情的事实:程序员们大多会认同这些书上的观点,但每个人对于这些观点的理解却是千差万别的。
|
||||
|
||||
比如书上说:“命名是要有意义的”,但什么样的命名才算是有意义的呢?有的人只理解到不用 xyz 命名,虽然他起出了自认为“有意义”的名字,但这些名字依然是难以理解的。事实上,大部分程序员在真实世界中面对的代码,就是这样难懂的代码。
|
||||
|
||||
这是因为,很多人虽然知道正面的代码是什么样子,却不知道反面的代码是什么样子。这些反面代码,Martin Fowler 在《重构》这本书中给起了一个好名字,代码的坏味道(Bad Smell)。
|
||||
|
||||
在我写代码的这 20 多年里,一直对代码的坏味道非常看重,因为它是写出好代码的起点。有对代码坏味道的嗅觉,能够识别出坏味道,接下来,你才有机会去“重构(Refactoring)”,把代码一点点打磨成一个整洁的代码(Clean Code)。Linux 内核开发者 Linus Torvalds 在行业里有个爱骂人的坏名声,原因之一就是他对于坏味道的不容忍。
|
||||
|
||||
所以,我也推荐那些想要提高自己编程水平的人读《重构》,如果时间比较少,就去读第三章“代码的坏味道”。
|
||||
|
||||
不过,《重构》中的“代码的坏味道”意图虽好,但却需要一个人对于整洁代码有着深厚的理解,才能识别出这些坏味道。否则,即使你知道有哪些坏味道,但真正有坏味道的代码出现在你面前时,你仍然无法认得它。
|
||||
|
||||
比如,你可以看看 Info、Data、Manager 是不是代码库经常使用的词汇,而它们往往是命名没有经过仔细思考的地方。在很多人眼中,这些代码是没有问题的。正因如此,才有很多坏味道的代码才堂而皇之地留在你的眼皮底下。
|
||||
|
||||
所以,我才想做一个讲坏味道的专栏,把最常见的坏味道直接用代码形式展现出来。在这个专栏里,我给你的都是即学即用的“坏味道”,我不仅会告诉你典型的坏味道是什么,而且也能让你在实际的编程过程中发现它们。比如前面那个例子里面的 setter,只要它一出现,你就需要立即警觉起来。
|
||||
|
||||
这里我也整理了一份“坏味道自查表”,把一些明显的“坏味道”信号列了出来,你可以和自己的代码做对比。
|
||||
|
||||
|
||||
|
||||
除了为你列出来哪些代码有坏味道之外,我还会给你讲支撑这些“坏味道”之所以为“坏味道”的原因,比如说:长方法和大类之所以为坏味道,因为它们都违背了单一职责的原则。
|
||||
|
||||
有坏味道的代码需要经过重构才能长成新的样子,在这个专栏里,我也会提到一些重构的手法,比如,改名(Rename)、提取方法(Extract Method)等等。在今天,拜许多能力强大的 IDE 所赐,重构已经变得越来越自动化,《重构》里的很多手法已经成为了 IDE 中的一个选项。
|
||||
|
||||
我还想给你一个安全提示,即便 IDE 功能再强大,也不要忘了重构的重要根基:测试。即便像 Java 这样,IDE 功能已经非常强大了,依然会有一些像反射之类的场景可能会从自动化重构的鼻子底下溜走。所以,重构一段代码之前,最好能够给它写下测试,确保改动前后的代码,功能上是一致的。
|
||||
|
||||
如果你订阅过我的《[10x 程序员工作法]》和《[软件设计之美]》,你就会发现,三个专栏一脉相承,这些背后的道理恰恰就是我在那两个专栏中已经提到过的内容。所以,三个专栏一并服用,效果会更佳。
|
||||
|
||||
写在最后
|
||||
|
||||
最后,还是要做一个自我介绍。我叫郑晔,一个写代码超过二十年的程序员,做过与软件开发的各种工作:编代码、带团队、做咨询、写开源。正如前面所说,我已经在极客时间平台上写了两个专栏,分享我在软件开发中的各种思考。这次,我会带你进入到我的基本功里,帮你一起写好代码。
|
||||
|
||||
十年前,我在 InfoQ 写过一个专栏《代码之丑》,把一些真实世界的代码展示了出来,让大家看到丑陋代码是什么样子的。
|
||||
|
||||
不少读者都表示,那个专栏让他们受益匪浅。不过,那个系列只是我日常工作的随手之作,没有更好地整理。这个专栏就是脱胎于 InfoQ 上的《代码之丑》,我对相关内容进行了更系统地整理,保证即便看过那个《代码之丑》专栏,你依然能够在这里有所收获。
|
||||
|
||||
这是一条通往代码精进之路,我愿意与你一起前行,成为你在这条路上的向导。如果你想摆脱平庸的小白程序员状态,成为一个更优秀的程序员,那么,请加入我的专栏,让我们一起修炼,日益精进写代码的手艺!
|
||||
|
||||
|
||||
|
||||
|
178
专栏/代码之丑/01缺乏业务含义的命名:如何精准命名?.md
Normal file
178
专栏/代码之丑/01缺乏业务含义的命名:如何精准命名?.md
Normal file
@ -0,0 +1,178 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
01 缺乏业务含义的命名:如何精准命名?
|
||||
你好,我是郑晔。
|
||||
|
||||
讲写代码的书通常都会从命名开始讲,《[程序设计实践]》如此,《[代码整洁之道]》亦然。所以,我们这个讲代码坏味道的专栏,也遵循传统,从命名开始讲。
|
||||
|
||||
不过,也许你会说:“我知道,命名不就是不能用 abcxyz 命名,名字要有意义嘛,这有什么好讲的。”然而,即便懂得了名字要有意义这个道理,很多程序员依然无法从命名的泥潭中挣脱出来。
|
||||
|
||||
不精准的命名
|
||||
|
||||
我们先来看一段代码:
|
||||
|
||||
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(处理章节),这个函数确实是在处理章节,但是,这个名字太过宽泛。如果说“将章节的翻译状态改成翻译中”叫做处理章节,那么“将章节的翻译状态改成翻译完”是不是也叫处理章节呢?“修改章节内容”是不是也叫处理章节呢?换句话说,如果各种场景都能够叫处理章节,那么处理章节就是一个过于宽泛的名字,没有错,但不精准。
|
||||
|
||||
这就是一类典型的命名问题,从表面上看,这个名字是有含义的,但实际上,它并不能有效地反映这段代码的含义。如果说我在做的是一个信息处理系统,你根本无法判断,我做是一个电商平台,还是一个图书管理系统,从沟通的角度看,这就不是一个有效的沟通。要想理解它,你需要消耗大量认知成本,无论是时间,还是精力。
|
||||
|
||||
命名过于宽泛,不能精准描述,这是很多代码在命名上存在的严重问题,也是代码难以理解的根源所在。
|
||||
|
||||
或许这么说你的印象还是不深刻,我们看看下面这些词是不是经常出现在你的代码里:data、info、flag、process、handle、build、maintain、manage、modify 等等。这些名字都属于典型的过于宽泛的名字,当这些名字出现在你的代码里,多半是写代码的人当时没有想好用什么名字,就开始写代码了。我相信,只要稍微仔细想想,类似的名字你一定还能想出不少来。
|
||||
|
||||
回到前面那段代码上,如果它不叫“处理章节”,那应该叫什么呢?首先,命名要能够描述出这段代码在做的事情。这段代码在做的事情就是“将章节修改为翻译中”。那是不是它就应该叫 changeChapterToTranslating 呢?
|
||||
|
||||
不可否认,相比于“处理章节”,changeChapterToTranslating 这个名字已经进了一步,然而,它也不算是一个好名字,因为它更多的是在描述这段代码在做的细节。我们之所以要将一段代码封装起来,一个重要的原因就是,我们不想知道那么多的细节。如果把细节平铺开来,那本质上和直接阅读代码细节差别并不大。
|
||||
|
||||
所以,一个好的名字应该描述意图,而非细节。
|
||||
|
||||
就这段代码而言, 我们为什么要把翻译状态修改成翻译中,这一定是有原因的,也就是意图。具体到这里的业务,我们把翻译状态修改成翻译中,是因为我们在这里开启了一个翻译的过程。所以,这段函数应该命名 startTranslation。
|
||||
|
||||
public void startTranslation(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);
|
||||
}
|
||||
|
||||
|
||||
用技术术语命名
|
||||
|
||||
我们再来看一段代码:
|
||||
|
||||
List<Book> bookList = service.getBooks();
|
||||
|
||||
|
||||
可以说这是一段常见得不能再常见的代码了,但这段代码却隐藏另外一个典型得不能再典型的问题:用技术术语命名。
|
||||
|
||||
这个 bookList 变量之所以叫 bookList,原因就是它声明的类型是 List。这种命名在代码中几乎是随处可见的,比如 xxxMap、xxxSet。
|
||||
|
||||
这是一种不费脑子的命名方式,但是,这种命名却会带来很多问题,因为它是一种基于实现细节的命名方式。
|
||||
|
||||
我们都知道,编程有一个重要的原则是面向接口编程,这个原则从另外一个角度理解,就是不要面向实现编程,因为接口是稳定的,而实现是易变的。虽然在大多数人的理解里,这个原则是针对类型的,但在命名上,我们也应该遵循同样的原则。为什么?我举个例子你就知道了。
|
||||
|
||||
比如,如果我发现,我现在需要的是一个不重复的作品集合,也就是说,我需要把这个变量的类型从 List 改成 Set。变量类型你一定会改,但变量名你会改吗?这还真不一定,一旦出现遗忘,就会出现一个奇特的现象,一个叫 bookList 的变量,它的类型是一个 Set。这样,一个新的混淆就此产生了。
|
||||
|
||||
那有什么更好的名字吗?我们需要一个更面向意图的名字。其实,我们在这段代码里真正要表达的是拿到了一堆书,所以,这个名字可以命名成 books。
|
||||
|
||||
List<Book> books = service.getBooks();
|
||||
|
||||
|
||||
也许你发现了,这个名字其实更简单,但从表意的程度上来说,它却是一个更有效的名字。
|
||||
|
||||
虽然这里我们只是以变量为例说明了以技术术语命名存在的问题,事实上,在实际的代码中,技术名词的出现,往往就代表着它缺少了一个应有的模型。
|
||||
|
||||
比如,在业务代码里如果直接出现了 Redis:
|
||||
|
||||
public Book getByIsbn(String isbn) {
|
||||
Book cachedBook = redisBookStore.get(isbn);
|
||||
if (cachedBook != null) {
|
||||
return cachedBook;
|
||||
}
|
||||
Book book = doGetByIsbn(isbn);
|
||||
redisBookStore.put(isbn, book);
|
||||
return book;
|
||||
}
|
||||
|
||||
|
||||
通常来说,这里真正需要的是一个缓存。Redis 是缓存这个模型的一个实现:
|
||||
|
||||
public Book getByIsbn(String isbn) {
|
||||
Book cachedBook = cache.get(isbn);
|
||||
if (cachedBook != null) {
|
||||
return cachedBook;
|
||||
}
|
||||
|
||||
Book book = doGetByIsbn(isbn);
|
||||
cache.put(isbn, book);
|
||||
return book;
|
||||
}
|
||||
|
||||
|
||||
再进一步,缓存这个概念其实也是一个技术术语,从某种意义上说,它也不应该出现在业务代码中。这方面做得比较好的是 Spring。使用 Spring 框架时,如果需要缓存,我们通常是加上一个 Annotation(注解):
|
||||
|
||||
@Cacheable("books")
|
||||
public Book getByIsbn(String isbn) {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
程序员之所以喜欢用技术名词去命名,一方面是因为,这是大家习惯的语言,另一方面也是因为程序员学习写代码,很大程度上是参考别人的代码,而行业里面优秀的代码常常是一些开源项目,而这些开源项目往往是技术类的项目。在一个技术类的项目中,这些技术术语其实就是它的业务语言。但对于业务项目,这个说法就必须重新审视了。
|
||||
|
||||
如果这个部分的代码确实就是处理一些技术,使用技术术语无可厚非,但如果是在处理业务,就要尽可能把技术术语隔离开来。
|
||||
|
||||
用业务语言写代码
|
||||
|
||||
无论是不精准的命名也好,技术名词也罢,归根结底,体现的是同一个问题:对业务理解不到位。
|
||||
|
||||
我在《[10x 程序员工作法]》专栏中曾经说过,编写可维护的代码要使用业务语言。怎么才知道自己的命名是否用的是业务语言呢?一种简单的做法就是,把这个词讲给产品经理,看他知不知道是怎么回事。
|
||||
|
||||
从团队的角度看,让每个人根据自己的理解来命名,确实就有可能出现千奇百怪的名字,所以,一个良好的团队实践是,建立团队的词汇表,让团队成员有信息可以参考。
|
||||
|
||||
团队对于业务有了共同理解,我们也许就可以发现一些更高级的坏味道,比如说下面这个函数声明:
|
||||
|
||||
public void approveChapter(long chapterId, long userId) {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这个函数的意图是,确认章节内容审核通过。这里有一个问题,chapterId 是审核章节的 ID,这个没问题,但 userId 是什么呢?了解了一下背景,我们才知道,之所以这里要有一个 userId,是因为这里需要记录一下审核人的信息,这个 userId 就是审核人的 userId。
|
||||
|
||||
你看,通过业务的分析,我们会发现,这个 userId 并不是一个好的命名,因为它还需要更多的解释,更好的命名是 reviewerUserId,之所以起这个名字,因为这个用户在这个场景下扮演的角色是审核人(Reviewer)。
|
||||
|
||||
public void approveChapter(long chapterId, long reviewerUserId) {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
从某种意义上来说,这个坏味道也是一种不精准的命名,但它不是那种一眼可见的坏味道,而是需要在业务层面上再进行讨论,所以,它是一种更高级的坏味道。
|
||||
|
||||
我初入职场的时候,有一次为一个名字陷入了沉思,一个工作经验丰富的同事对此的评价是:你开始进阶了。确实,能够意识到自己的命名有问题,是程序员进阶的第一步。
|
||||
|
||||
总结时刻
|
||||
|
||||
我们今天讲了两个典型的命名坏味道:
|
||||
|
||||
命名是软件开发中两件难事之一(另一个难事是缓存失效),不好的命名本质上是增加我们的认知成本,同样也增加了后来人(包括我们自己)维护代码的成本。
|
||||
|
||||
好的命名要体现出这段代码在做的事情,而无需展开代码了解其中的细节,这是最低的要求。再进一步,好的命名要准确地体现意图,而不是实现细节。更高的要求是,用业务语言写代码。
|
||||
|
||||
至此,我们已经对命名有了一个更深入的认识。下一讲,我们来说说国外那些经典的讲编码的书都不曾覆盖到的一个话题:英文命名。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:好的命名,是体现业务含义的命名。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
前面我们提到了一些代码中常见的不精准的命名所用的词汇,你还能想到哪些词呢?欢迎在留言区分享你的想法。也欢迎你把这节课分享给你身边对命名问题感到困惑的朋友。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
193
专栏/代码之丑/02乱用英语:站在中国人的视角来看英文命名.md
Normal file
193
专栏/代码之丑/02乱用英语:站在中国人的视角来看英文命名.md
Normal file
@ -0,0 +1,193 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
02 乱用英语:站在中国人的视角来看英文命名
|
||||
你好,我是郑晔。
|
||||
|
||||
上一讲,我们讲了两种常见的命名的坏味道,这一讲的话题还是命名,只不过,这个主题是国外那些经典编程书籍所不曾涵盖的话题:英语命名。
|
||||
|
||||
现在主流的程序设计语言都是以英语为基础的,且不说欧美人设计的各种语言,就连日本人设计的 Ruby、巴西人设计的 Lua,各种语法采用的也全都是英语。所以,想要成为一个优秀的程序员,会用英语写代码是必要的。
|
||||
|
||||
这里并不是说,程序员的英语一定要多好,但最低限度的要求是写出来的代码要像是在用英语表达。
|
||||
|
||||
或许你听说过,甚至接触过国内的一些程序员用汉语拼音写代码,这就是一种典型的坏味道。鉴于现在的一些程序设计语言已经支持了 UTF-8 的编码格式,用汉语拼音写代码,还不如用汉字直接写代码。
|
||||
|
||||
当然,这个坏味道实在是太低级了,我就不在这里深入讨论了。让我们来看看还有哪些可能会不经意间忽略的坏味道。
|
||||
|
||||
违反语法规则的命名
|
||||
|
||||
我们来看一段代码:
|
||||
|
||||
public void completedTranslate(final List<ChapterId> chapterIds) {
|
||||
|
||||
List<Chapter> chapters = repository.findByChapterIdIn(chapterIds);
|
||||
chapters.forEach(Chapter::completedTranslate);
|
||||
repository.saveAll(chapters);
|
||||
|
||||
}
|
||||
|
||||
|
||||
初看之下,这段代码写得还不错,它要做的是将一些章节的信息标记为翻译完成。似乎函数名也能反映这个意思,但仔细一看你就会发现问题。
|
||||
|
||||
因为 completedTranslate 并不是一个正常的英语函数名。从这个名字你能看出,作者想表达的是“完成翻译”,因为是已经翻译完了,所以,他用了完成时的 completed,而翻译是 translate。这个函数名就成了 completedTranslate。由此,你可以看到,作者已经很用心了,但遗憾的是,这个名字还是起错了。
|
||||
|
||||
一般来说,常见的命名规则是:类名是一个名词,表示一个对象,而方法名则是一个动词,或者是动宾短语,表示一个动作。
|
||||
|
||||
以此为标准衡量这个名字,completedTranslate 并不是一个有效的动宾结构。如果把这个名字改成动宾结构,只要把“完成”译为 complete,“翻译”用成它的名词形式 translation 就可以了。所以,这个函数名可以改成 completeTranslation:
|
||||
|
||||
public void completeTranslation(final List<ChapterId> chapterIds) {
|
||||
|
||||
List<Chapter> chapters = repository.findByChapterIdIn(chapterIds);
|
||||
chapters.forEach(Chapter::completeTranslation);
|
||||
repository.saveAll(chapters);
|
||||
|
||||
}
|
||||
|
||||
|
||||
这并不是一个复杂的坏味道,但这种坏味道在代码中却时常可以见到,比如,一个函数名是 retranslation,其表达的意图是重新翻译,但作为函数名,它应该是一个动词,所以,正确的命名应该是 retranslate。
|
||||
|
||||
其实,只要你懂得最基本的命名要求,知道最基本的英语规则,就完全能够发现这里的坏味道。比如,判断函数名里的动词是不是动词,宾语是不是一个名词?这并不需要英语有多么好。自己实在拿不准的时候,你就把这个词放到字典网站中查一下,确保别用错词性就好。
|
||||
|
||||
对于大多数国内程序员来说,字典网站是我们的好朋友,是我们在写程序过程中不可或缺的一个好伙伴。不过,有些人使用字典网站也会很随意。
|
||||
|
||||
不准确的英语词汇
|
||||
|
||||
有一次,我们要实现一个章节审核的功能,一个同事先定义出了审核的状态:
|
||||
|
||||
public enum ChapterAuditStatus {
|
||||
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED;
|
||||
}
|
||||
|
||||
|
||||
你觉得这段代码有问题吗?如果看不出来,一点都不奇怪。如果你用审核作为关键字去字典网站上搜索,确实会得到 audit 这个词。所以,审核状态写成 AuditStatus 简直是再正常不过的事情了。
|
||||
|
||||
然而,看到这个词的时候,我的第一反应就是这个词好像不太对。因为之前我实现了一个作品审核的功能,不过我写的定义是这样的:
|
||||
|
||||
public enum BookReviewStatus {
|
||||
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED;
|
||||
|
||||
}
|
||||
|
||||
|
||||
抛开前缀不看,同样是审核,一个用了 audit,一个用了 review。这显然是一种不一致。本着代码一致性的考虑,我希望这两个定义应该采用同样的词汇。
|
||||
|
||||
于是,我把 audit 和 review 同时放到了搜索引擎里查了一下。原来,audit 会有更官方的味道,更合适的翻译应该是审计,而 review 则有更多核查的意思,二者相比,review 更适合这里的场景。于是,章节的审核状态也统一使用了 review:
|
||||
|
||||
public enum ChapterReviewStatus {
|
||||
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED;
|
||||
|
||||
}
|
||||
|
||||
|
||||
相比之下,这个坏味道是一个高级的坏味道,英语单词用得不准确。但这个问题确实是国内程序员不得不面对的一个尴尬的问题,我们的英语可能没有那么好,体会不到不同单词之间的差异。
|
||||
|
||||
很多人习惯的做法就是把中文的词扔到字典网站,然后从诸多返回的结果中找一个自己看着顺眼的,而这也往往是很多问题出现的根源。这样写出来的程序看起来就像一个外国人在说中文,虽然你知道他在说的意思,但总觉得哪里怪怪的。
|
||||
|
||||
在这种情况下,最好的解决方案还是建立起一个业务词汇表,千万不要臆想。一般情况下,我们都可以去和业务方谈,共同确定一个词汇表,包含业务术语的中英文表达。这样在写代码的时候,你就可以参考这个词汇表给变量和函数命名。
|
||||
|
||||
下面是一个词汇表的示例,从这个词汇表中你不难看出:一方面,词汇表给出的都是业务术语,同时也给出了在特定业务场景下的含义;另一方面,它也给出了相应的英文,省得你费劲心思去思考。当你遇到了一个词汇表中没有的术语怎么办呢?那就需要找出这个术语相应的解释,然后,补充到术语表里。
|
||||
|
||||
|
||||
|
||||
建立词汇表的另一个关键点就是,用集体智慧,而非个体智慧。你一个人的英语可能没那么好,但一群人总会找出一个合适的说法。我在《[软件设计之美]》里讲到领域驱动设计时,曾经讲过通用语言,其实,业务词汇表也应该是构建通用语言的一部分成果。
|
||||
|
||||
英语单词的拼写错误
|
||||
|
||||
我再给你看一段曾经让我迷惑不已的代码:
|
||||
|
||||
public class QuerySort {
|
||||
|
||||
private final SortBy sortBy;
|
||||
private final SortFiled sortFiled;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
初看这段代码时,我还想表扬代码的作者,他知道把查询的排序做一个封装,比起那些把字符串传来传去的做法要好很多。
|
||||
|
||||
但仔细看一下代码,我脑子里就冒出了一系列问号。sortFiled 是啥?排序文件吗?为啥用的还是过去式?归档?
|
||||
|
||||
被这段代码搞晕的我只好打开提交历史,找出这段代码的作者,向他求教。
|
||||
|
||||
我:这个字段是啥意思?
|
||||
|
||||
同事:这是排序的字段啊。
|
||||
|
||||
我:排序的字段?
|
||||
|
||||
同事:你看,这个查询排序类有两个字段,一个是排序的方式,升序还是降序,另一个就是排序的字段。
|
||||
|
||||
我:字段这个单词是这么拼吗?
|
||||
|
||||
同事:不是吗?哦!是 field,拼错了,拼错了。
|
||||
|
||||
你看,是他把单词拼错了。
|
||||
|
||||
其实,偶尔的拼写错误是不可避免的,这就像我们写文章的时候,出现错别字也是难免的。之所以要在这个专栏中把拼写错误作为一种独立的坏味道,是因为在很多国内程序员写的程序中,见到的拼写错误比例是偏高的。
|
||||
|
||||
在这个故事里面,我都已经当面指出了问题,这个同事甚至都没有第一时间意识到自己的拼写是错误的,这其实说明了一种尴尬的现状:很多程序员对英语的感觉并没有那么强。
|
||||
|
||||
事实上,这个同事不止一次在代码里出现拼写错误了,一些拼写错误是很直白的,一眼就能看出来,所以,通常在代码评审的时候就能发现问题。这次的拼写错误刚好形成了另外一个有含义的单词,所以,我也被困住了。
|
||||
|
||||
对今天的程序员来说,工具已经很进步了,像 IntelliJ IDEA 这样的 IDE 甚至可以给你提示代码里有拼写错误(typo),不少支持插件的工具也都有自己的拼写检查插件,比如Visual Studio Code 就有自己的拼写检查插件。在这些工具的帮助之下,我们只要稍微注意一下,就可以修正很多这样低级的错误。
|
||||
|
||||
这一讲的内容几乎是完全针对国内程序员的。对于国外程序员来说,他们几乎不会犯这些错误。英语是程序员无论如何也绕不过去的一关,越是想成为优秀程序员,越要对英语有良好的感觉。当然,这里并不强求所有人的英语都能达到多好的程度,至少看到一些明显违反英语规则的代码,自己应该有能力看出来。
|
||||
|
||||
英语和程序设计语言其实是一样的,想用好,唯有多多练习。我自己的英语水平也算不上多好,但我读过很多技术文档,也看了很多开源的代码。之前因为参加开源项目和在外企工作的经历,也写过很多的英语邮件和文档,逐渐对程序中的英语有了感觉。
|
||||
|
||||
有些人注意到,我的开源项目 Moco 的文档是用英语写的,这其实是我强迫自己练习的结果。如果说英语是一门全功能的程序设计语言,那么程序中用到的英语就是一门 DSL(领域特定语言)。相比起完全掌握好英语,掌握程序中用到的英语就要简单一些了。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了几个英语使用不当造成的坏味道:
|
||||
|
||||
违反语法规则的命名;
|
||||
|
||||
不准确的英语词汇;
|
||||
|
||||
英语单词的拼写错误。
|
||||
|
||||
这是国内程序员因为语言关系而造成的坏味道,英语是目前软件开发行业的通用语言,一个程序员要想写好程序,要对程序中用到的英语有一个基本的感觉,能够发现代码中的这些坏味道。
|
||||
|
||||
其实,还有一些常见的与语言相关的坏味道,因为比较初级,我只放在这里给你提个醒,比如:
|
||||
|
||||
使用拼音进行命名;
|
||||
|
||||
使用不恰当的单词简写(比如,多个单词的首字母,或者写单词其中的一部分)。
|
||||
|
||||
我们还讨论了如何从实践层面上更好地规避这些坏味道:
|
||||
|
||||
制定代码规范,比如,类名要用名词,函数名要用动词或动宾短语;
|
||||
|
||||
要建立团队的词汇表(是的,我们在上一讲也提到了);
|
||||
|
||||
要经常进行代码评审。
|
||||
|
||||
命名之所以如此重要,因为它是一切代码的基础。就像写文章一样,一个错别字满天飞的文章,很难让人相信它会是一篇好的文章,所以,命名的重要性是如何强调都不为过的。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:编写符合英语语法规则的代码。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
我们在这一讲里讲到了程序员和英语之间的关系,我想请你分享一下,你在工作中与英语的关系,无论是遇到的问题,或是自我提升的经验,都行。欢迎在留言区分享你的经验,也欢迎你把这节课的内容分享给团队的小伙伴,大家一起精进“英语命名”。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
189
专栏/代码之丑/03重复代码:简单需求到处修改,怎么办?.md
Normal file
189
专栏/代码之丑/03重复代码:简单需求到处修改,怎么办?.md
Normal file
@ -0,0 +1,189 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
03 重复代码:简单需求到处修改,怎么办?
|
||||
你好,我是郑晔。
|
||||
|
||||
前面两讲,我们讨论了命名中的坏味道。今天,我们来讨论另外一个常见的坏味道:重复代码。
|
||||
|
||||
记得我刚开始工作的时候,有人开玩笑说,编程实际上就是 CVS(CVS 是当时流行的一个版本控制工具,相当于今天的 Git),也就是 Ctrl+C、Ctrl+V、Ctrl+S,或许你已经听出来了,这是在调侃很多程序员写程序依靠的是复制粘贴。
|
||||
|
||||
时至今日,很多初级程序员写代码依然规避不了复制粘贴,基本的做法就是把一段代码复制过来,改动几个地方,然后,跑一下没有太大问题就万事大吉了。殊不知,这种做法就是在给未来挖坑。
|
||||
|
||||
通常情况下,只要这些复制代码其中有一点逻辑要修改,就意味着所有复制粘贴的地方都要修改。所以,我们在实际的项目中,常常看见这样的情况:明明是一个简单的需求,你却需要改很多的地方,需要花费很长的时间,结果无论是项目经理,还是产品经理,对进度都很不满意。
|
||||
|
||||
更可怕的是,只要你少改了一处,就意味着留下一处潜在的问题。问题会在不经意间爆发出来,让人陷入难堪的境地。
|
||||
|
||||
复制粘贴是最容易产生重复代码的地方,所以,一个最直白的建议就是,不要使用复制粘贴。真正应该做的是,先提取出函数,然后,在需要的地方调用这个函数。
|
||||
|
||||
其实,复制粘贴的重复代码是相对容易发现的,但有一些代码是有类似的结构,这也是重复代码,有些人对这类坏味道却视而不见。
|
||||
|
||||
重复的结构
|
||||
|
||||
我们看一下下面的几段代码:
|
||||
|
||||
@Task
|
||||
public void sendBook() {
|
||||
try {
|
||||
this.service.sendBook();
|
||||
} catch (Throwable t) {
|
||||
this.notification.send(new SendFailure(t)));
|
||||
throw t;
|
||||
}
|
||||
}
|
||||
|
||||
@Task
|
||||
public void sendChapter() {
|
||||
try {
|
||||
this.service.sendChapter();
|
||||
} catch (Throwable t) {
|
||||
this.notification.send(new SendFailure(t)));
|
||||
throw t;
|
||||
}
|
||||
}
|
||||
|
||||
@Task
|
||||
public void startTranslation() {
|
||||
try {
|
||||
this.service.startTranslation();
|
||||
} catch (Throwable t) {
|
||||
this.notification.send(new SendFailure(t)));
|
||||
throw t;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这三段函数业务的背景是:一个系统要把作品的相关信息发送给翻译引擎。所以,结合着代码,我们就不难理解它们的含义,sendBook 是把作品信息发出去,sendChapter 就是把章节发送出去,而 startTranslation 则是启动翻译。
|
||||
|
||||
这几个业务都是以后台的方式在执行,所以,它们的函数签名上增加了一个 Task 的 Annotation,表明它们是任务调度的入口。然后,实际的代码执行放到了对应的业务方法上,也就是 service 里面的方法。
|
||||
|
||||
这三个函数可能在许多人看来已经写得很简洁了,但是,这段代码的结构上却是有重复的,请把注意力放到 catch 语句里。
|
||||
|
||||
之所以要做一次捕获(catch),是为了防止系统出问题无人发觉。捕获到异常后,我们把出错的信息通过即时通讯工具发给相关人等,代码里的 notification.send 就是发通知的入口。相比于原来的业务逻辑,这个逻辑是后来加上的,所以,这段代码的作者不厌其烦地在每一处修改了代码。
|
||||
|
||||
我们可以看到,虽然这三个函数调用的业务代码不同,但它们的结构是一致的,其基本流程可以理解为:
|
||||
|
||||
当你能够发现结构上的重复,我们就可以把这个结构提取出来。从面向对象的设计来说,就是提出一个接口,就像下面这样:
|
||||
|
||||
private void executeTask(final Runnable runnable) {
|
||||
try {
|
||||
runnable.run();
|
||||
} catch (Throwable t) {
|
||||
this.notification.send(new SendFailure(t)));
|
||||
throw t;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
有了这个结构,前面几个函数就可以用它来改写了。对于支持函数式编程的程序设计语言来说,可以用语言提供的便利写法简化代码的编写,像下面的代码就是用了 Java 里的方法引用(Method Reference):
|
||||
|
||||
@Task
|
||||
public void sendBook() {
|
||||
executeTask(this.service::sendBook);
|
||||
}
|
||||
|
||||
@Task
|
||||
public void sendChapter() {
|
||||
executeTask(this.service::sendChapter);
|
||||
}
|
||||
|
||||
@Task
|
||||
public void startTranslation() {
|
||||
executeTask(this.service::startTranslation);
|
||||
}
|
||||
|
||||
|
||||
经过这个例子的改写,如果再有一些通用的结构调整,比如,在任务执行前后要加上一些日志信息,这样的改动就可以放到 executeTask 这个函数里,而不用四处去改写了。
|
||||
|
||||
这个例子并不复杂,关键点在于,能不能发现结构上的重复。因为相比于直接复制的代码,结构上的重复看上去会有一些迷惑性。比如,在这个例子里,发送作品信息、发送章节、启动翻译看起来是三件不同的事,很难让人一下反应过来它也是重复代码。
|
||||
|
||||
一般来说,参数是名词,而函数调用,是动词。我们传统的程序设计教育中,对于名词是极度重视的,但我们必须认识到一点,动词也扮演着重要的角色,尤其是在函数式编程兴起之后。那你就需要知道,动词不同时,并不代表没有重复代码产生。
|
||||
|
||||
理解到这一点,我们就容易发现结构上的相似之处。比如在上面的例子中,发送作品信息、发送章节、启动翻译之所以看上去是三件不同的事,只是因为它们的动词不同,但是除了这几个动词之外的其它部分是相同的,所以,它们在结构上是重复的。
|
||||
|
||||
做真正的选择
|
||||
|
||||
我们再来看一段代码:
|
||||
|
||||
if (user.isEditor()) {
|
||||
service.editChapter(chapterId, title, content, true);
|
||||
} else {
|
||||
service.editChapter(chapterId, title, content, false);
|
||||
}
|
||||
|
||||
|
||||
这是一段对章节内容进行编辑的代码。这里有一个业务逻辑,章节只有在审核通过之后,才能去做后续的处理,比如,章节的翻译。所以,这里的 editChapter 方法最后那个参数表示是否审核通过。
|
||||
|
||||
在这段代码里面,目前的处理逻辑是,如果这个章节是由作者来编辑的,那么这个章节是需要审核的,如果这个章节是由编辑来编辑的,那么审核就直接通过了,因为编辑本身同时也是审核人。不过,这里的业务逻辑不是重点,只是帮助你理解这段代码。
|
||||
|
||||
问题来了,这个 if 选择的到底是什么呢?
|
||||
|
||||
相信你和我一样,第一眼看到这段代码的感觉一定是,if 选择的一定是两段不同的业务处理。但只要你稍微看一下,就会发现,if 和 else 两段代码几乎是一模一样的。在经过仔细地“找茬”之后,才能发现,原来是最后一个参数不一样。
|
||||
|
||||
只有参数不同,是不是和前面说的重复代码是如出一辙的?没错,这其实也是一种重复代码。
|
||||
|
||||
只不过,这种重复代码通常情况下是作者自己写出来的,而不是粘贴出来的。因为作者在写这段代码时,脑子只想到 if 语句判断之后要做什么,而没有想到这个 if 语句判断的到底是什么。但这段代码客观上也造就了重复。
|
||||
|
||||
写代码要有表达性。把意图准确地表达出来,是写代码过程中非常重要的一环。显然,这里的 if 判断区分的是参数,而非动作。所以,我们可以把这段代码稍微调整一下,会让代码看上去更容易理解:
|
||||
|
||||
boolean approved= user.isEditor();
|
||||
service.editChapter(chapterId, title, content, approved);
|
||||
|
||||
|
||||
请注意,这里我把 user.isEditor() 判断的结果赋值给了一个 approved 的变量,而不是直接作为一个参数传给 editChapter,这么做也是为了提高这段代码的可读性。因为 editChapter 最后一个参数表示的是这个章节是否审核通过。通过引入 approved 变量,我们可以清楚地看到,一个章节审核是否通过的判断条件是“用户是否是一个编辑”,这种写法会让代码更清晰。
|
||||
|
||||
如果将来审核通过的条件改变了,变化的点全都在 approved 的这个变量的赋值上面。如果你追求更有表达性的做法,甚至可以提取一个函数出来,这样,就把变化都放到这个函数里了,就像下面这样:
|
||||
|
||||
boolean approved = isApproved(user);
|
||||
|
||||
service.editChapter(chapterId, title, content, approved);
|
||||
private boolean isApproved(final User user) {
|
||||
return user.isEditor();
|
||||
}
|
||||
|
||||
|
||||
为了说明问题,我特意选择了一段简单的代码,if 语句的代码块里只有一个语句。在实际的工作中,if 语句没有有效地去选择目标是经常出现的,有的是参数列表比较长,有的是在 if 的代码块里有多个语句。
|
||||
|
||||
所以,只要你看到 if 语句出现,而且 if 和 else 的代码块长得又比较像,多半就是出现了这个坏味道。如果你不想所有人都来玩“找茬”游戏,赶紧消灭它。
|
||||
|
||||
重复是一个泥潭,对于程序员来说,时刻提醒自己不要重复是至关重要的。在软件开发里,有一个重要的原则叫做 Don’t Repeat Yourself(不要重复自己,简称 DRY),我在《[软件设计之美]》中也讲到过它,而更经典的叙述在《[程序员修炼之道]》中。
|
||||
|
||||
在一个系统中,每一处知识都必须有单一、明确、权威地表述。
|
||||
|
||||
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
|
||||
|
||||
写代码要想做到 DRY,一个关键点是能够发现重复。发现重复,一种是在泥潭中挣扎后,被动地发现,还有一种是提升自己识别能力,主动地发现重复。这种主动识别的能力,其实背后要有对软件设计更好的理解,尤其是对分离关注点的理解(如果你对“分离关注点”的知识感兴趣,可以参考我在《软件设计之美》中的[02]讲)。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲我们讲到重复代码,讲到了几个典型的坏味道:
|
||||
|
||||
复制粘贴的代码;
|
||||
|
||||
结构重复的代码;
|
||||
|
||||
if 和 else 代码块中的语句高度类似。
|
||||
|
||||
很多重复代码的产生通常都是从程序员偷懒开始的,而这些程序员的借口都是为了快,却为后续工作埋下更多地隐患,真正的“欲速而不达”。
|
||||
|
||||
复制粘贴的代码和结构重复的代码,虽然从观感上有所差异,但本质上都是重复,只不过,一个是名词的微调,一个是动词的微调。
|
||||
|
||||
程序员千万不要复制粘贴,如果需要复制粘贴,首先应该做的是提取一个新的函数出来,把公共的部分先统一掉。
|
||||
|
||||
if 和 else 的代码块中的语句高度类似,通常是程序员不经意造成的,但这也是对于写代码没有高标准要求的结果。让 if 语句做真正的选择,是提高代码表达准确性的重要一步。
|
||||
|
||||
作为一个精进中的程序员,我们一定要把 DRY 原则记在心中,时时刻刻保持对“重复”的敏感度,把各种重复降到最低。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:不要重复自己,不要复制粘贴。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
这一讲的主题是重复代码,你在实际工作中都遇到过什么样的重复代码,你是怎样处理它们的呢?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
287
专栏/代码之丑/04长函数:为什么你总是不可避免地写出长函数?.md
Normal file
287
专栏/代码之丑/04长函数:为什么你总是不可避免地写出长函数?.md
Normal file
@ -0,0 +1,287 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
04 长函数:为什么你总是不可避免地写出长函数?
|
||||
你好,我是郑晔。
|
||||
|
||||
这一讲,我们来讲一个你一定深恶痛绝的坏味道:长函数。
|
||||
|
||||
有一个关于程序员的段子,说程序员一定要用大屏显示器,而且一定要竖起来用,这样才能看到一个函数的全貌。这显然是在调侃函数很长,小屏甚至横屏都不足以看到整个函数,只有竖起来才行。
|
||||
|
||||
只要一提到长函数,无论是去被迫理解一个长函数的含义,还是要在一个长函数中,小心翼翼地找出需要的逻辑,按照需求微调一下,几乎所有程序员都会有不愉悦的回忆。可以这么说,没有人喜欢长函数,但在实际工作中,却不得不去与各种长函数打交道。
|
||||
|
||||
不知道你在实际工作中遇到最长的函数有多长,几百上千行的函数肯定是不足以称霸的。在我的职业生涯中,经常是我以为自己够见多识广了,但只要新接触到一个有悠久历史的代码库,就总会有突破认知的长函数出现。
|
||||
|
||||
长函数是一个“我一说,你就知道怎么回事”的坏味道,我就不准备用一个典型的长函数来开启这一讲了,否则,这一讲的篇幅都不够了。但是,为了统一认识,我准备先讨论一下多长的函数算是长函数,我们来看一个案例。
|
||||
|
||||
多长的函数才算“长”?
|
||||
|
||||
有一次,我在一个团队做分享,讲怎么把一个长函数重构成小函数。现场演示之后,我问了大家一个问题:在你心目中,多长的函数才算长呢?
|
||||
|
||||
一个现场听众很认真地思考了一下,给出了一个答案:100 行。我很尴尬地看了一下自己刚刚重构掉的两个函数,最长的一个都不到 100 行。换言之,以他的标准来看,这个函数根本就不是长函数,根本就没有必要重构。
|
||||
|
||||
对于函数长度容忍度高,这是导致长函数产生的关键点。
|
||||
|
||||
如果一个人认为 100 行代码不算长,那在他眼中,很多代码根本就是没有问题的,也就更谈不上看到更多问题了,这其实是一个观察尺度的问题。这就好比,没有电子显微镜之前,人们很难理解疾病的原理,因为看不到病毒,就不可能理解病毒可以致病这个道理。
|
||||
|
||||
一个好的程序员面对代码库时要有不同尺度的观察能力,看设计时,要能够高屋建瓴,看代码时,要能细致入微。
|
||||
|
||||
这里的要点就是,看具体代码时,一定要能够看到细微之处。我在《[10x 程序员工作法]》专栏中讲到过“任务分解”,关键点就是将任务拆解得越小越好,这个观点对代码同样适用。随着对代码长度容忍度的降低,对代码细节的感知力就会逐渐提升,你才能看到那些原本所谓细枝末节的地方隐藏的各种问题。
|
||||
|
||||
回到具体的工作中,“越小越好”是一个追求的目标,不过,没有一个具体的数字,就没办法约束所有人的行为。所以,通常情况下,我们还是要定义出一个代码行数的上限,以保证所有人都可以按照这个标准执行。
|
||||
|
||||
我自己写代码的习惯是这样的。像 Python、Ruby 这样表达能力比较强的动态语言,大多数情况下,一行代码(one-liner program)可以解决很多问题,所以,我对自己的要求大约是 5 行左右,并且能够用一行代码解决的问题,就尽量会用一行代码解决;而像 Java 这样表达能力稍弱的静态类型语言,我也争取在 10 行代码之内解决问题。
|
||||
|
||||
当然,这是我对自己的要求,在实际的项目中,可能不是每个人都能做到这一点,所以,我给了一个更为宽松的限制,在自己的标准上翻了番,也就是 20 行。
|
||||
|
||||
这不是一个说说就算的标准,我们应该把它变成一个可执行的标准。比如,在 Java 中,我们就可以把代码行的约束加到 CheckStyle 的配置文件中,就像下面这样:
|
||||
|
||||
<module name="MethodLength">
|
||||
<property name="tokens" value="METHOD_DEF"/>
|
||||
<property name="max" value="20"/>
|
||||
<property name="countEmpty" value="false"/>
|
||||
</module>
|
||||
|
||||
|
||||
这样,在我们提交代码之前,执行本地的构建脚本,就可以把长函数检测出来(关于 CheckStyle,我在《[10x 程序员工作法]》中讲[项目自动化]时专门做过介绍,你有兴趣不妨了解一下)。如果你用的是其它的程序设计语言,不妨也找一下相应的静态检查工具,看看是否提供类似的配置。
|
||||
|
||||
我知道,即便是以 20 行为上限,这也已经超过很多人的认知,具体的函数行数可以结合团队的实际情况来制定,但是,我非常不建议把这个数字放得很大,就像我前面说的那样,如果你放到 100 行,这个数字基本上是没有太多意义的,对团队也起不到什么约束作用。
|
||||
|
||||
我之所以要先讨论多长的函数算是长函数,是因为如果你不能认识到代码行的标准应该很低,那么在接下来的讨论中,有些代码示例可能在你看来,就根本不需要调整了。
|
||||
|
||||
长函数的产生
|
||||
|
||||
不过,限制函数长度,是一种简单粗暴的解决方案。最重要的是你要知道,长函数本身是一个结果,如果不理解长函数产生的原因,还是很难写出整洁的代码。接下来,我们就来看看长函数是怎么产生的。
|
||||
|
||||
人们写长函数的历史由来已久。在《软件设计之美》专栏里,我讲过[程序设计语言的发展历史]。像 C 语言这种在今天已经是高性能的程序设计语言,在问世之初,也曾被人质疑性能不彰,尤其是函数调用。
|
||||
|
||||
在一些写汇编语言的人看来,调用函数涉及到入栈出栈的过程,显然不如直接执行来得性能高。这种想法经过各种演变流传到今天,任何一门新语言出现,还是会以同样的理由被质疑。
|
||||
|
||||
所以,在很多人看来,把函数写长是为了所谓性能。不过,这个观点在今天是站不住的。性能优化不应该是写代码的第一考量。
|
||||
|
||||
一方面,一门有活力的程序设计语言本身是不断优化的,无论是编译器,还是运行时,性能都会越来越好;另一方面,可维护性比性能优化要优先考虑,当性能不足以满足需要时,我们再来做相应的测量,找到焦点,进行特定的优化。这比在写代码时就考虑所谓性能要更能锁定焦点,优化才是有意义的。
|
||||
|
||||
除了以性能为由把代码写长,还有一种最常见的原因也会把代码写长,那就是写代码平铺直叙,把自己想到的一点点罗列出来。比如下面这段代码(如果你不想仔细阅读,可以直接跳到后面):
|
||||
|
||||
public void executeTask() {
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
CloseableHttpClient client = HttpClients.createDefault();
|
||||
|
||||
List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
|
||||
|
||||
for (Chapter chapter : chapters) {
|
||||
SendChapterRequest sendChapterRequest = new SendChapterRequest();
|
||||
sendChapterRequest.setTitle(chapter.getTitle());
|
||||
sendChapterRequest.setContent(chapter.getContent());
|
||||
HttpPost sendChapterPost = new HttpPost(sendChapterUrl);
|
||||
CloseableHttpResponse sendChapterHttpResponse = null;
|
||||
String chapterId = null;
|
||||
try {
|
||||
String sendChapterRequestText = mapper.writeValueAsString(sendChapterRequest);
|
||||
sendChapterPost.setEntity(new StringEntity(sendChapterRequestText));
|
||||
sendChapterHttpResponse = client.execute(sendChapterPost);
|
||||
HttpEntity sendChapterEntity = sendChapterPost.getEntity();
|
||||
|
||||
SendChapterResponse sendChapterResponse = mapper.readValue(sendChapterEntity.getContent(), SendChapterResponse.class);
|
||||
chapterId = sendChapterResponse.getChapterId();
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
try {
|
||||
if (sendChapterHttpResponse != null) {
|
||||
sendChapterHttpResponse.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
}
|
||||
}
|
||||
|
||||
HttpPost translateChapterPost = new HttpPost(translateChapterUrl);
|
||||
CloseableHttpResponse translateChapterHttpResponse = null;
|
||||
try {
|
||||
TranslateChapterRequest translateChapterRequest = new TranslateChapterRequest();
|
||||
translateChapterRequest.setChapterId(chapterId);
|
||||
|
||||
String translateChapterRequestText = mapper.writeValueAsString(translateChapterRequest);
|
||||
translateChapterPost.setEntity(new StringEntity(translateChapterRequestText));
|
||||
|
||||
translateChapterHttpResponse = client.execute(translateChapterPost);
|
||||
HttpEntity translateChapterEntity = translateChapterHttpResponse.getEntity();
|
||||
|
||||
TranslateChapterResponse translateChapterResponse = mapper.readValue(translateChapterEntity.getContent(), TranslateChapterResponse.class);
|
||||
|
||||
if (!translateChapterResponse.isSuccess()) {
|
||||
logger.warn("Fail to start translate: {}", chapterId);
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
if (translateChapterHttpResponse != null) {
|
||||
try {
|
||||
translateChapterHttpResponse.close();
|
||||
} catch (IOException e) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这段代码的逻辑是,把没有翻译过的章节发到翻译引擎,然后,启动翻译过程。在这里翻译引擎是另外一个服务,需要通过 HTTP 的形式向它发送请求。相对而言,这段代码还算直白,当你知道了我上面所说的逻辑,你是很容易看懂这段代码的。
|
||||
|
||||
这段代码之所以很长,主要原因就是把前面所说的逻辑全部平铺直叙地摆在那里了,这里既有业务处理的逻辑,比如,把章节发送给翻译引擎,然后,启动翻译过程;又有处理的细节,比如,把对象转成 JSON,然后,通过 HTTP 客户端发送出去。
|
||||
|
||||
从这段代码中,我们可以看到平铺直叙的代码存在的两个典型问题:
|
||||
|
||||
把多个业务处理流程放在一个函数里实现;
|
||||
|
||||
把不同层面的细节放到一个函数里实现。
|
||||
|
||||
这里发送章节和启动翻译是两个过程,显然,这是可以放到两个不同的函数中去实现的,所以,我们只要做一下提取函数,就可以把这个看似庞大的函数拆开,而拆出来的几个函数规模都会小很多,像下面这样:
|
||||
|
||||
public void executeTask() {
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
CloseableHttpClient client = HttpClients.createDefault();
|
||||
|
||||
List<Chapter> chapters = this.chapterService.getUntranslatedChapters();
|
||||
|
||||
for (Chapter chapter : chapters) {
|
||||
String chapterId = sendChapter(mapper, client, chapter);
|
||||
translateChapter(mapper, client, chapterId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
拆出来的部分,实际上就是把对象打包发送的过程,我们以发送章节为例,先来看拆出来的发送章节部分:
|
||||
|
||||
private String sendChapter(final ObjectMapper mapper,
|
||||
final CloseableHttpClient client,
|
||||
final Chapter chapter) {
|
||||
|
||||
SendChapterRequest request = asSendChapterRequest(chapter);
|
||||
CloseableHttpResponse response = null;
|
||||
String chapterId = null;
|
||||
|
||||
try {
|
||||
HttpPost post = sendChapterRequest(mapper, request);
|
||||
response = client.execute(post);
|
||||
chapterId = asChapterId(mapper, post);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
} finally {
|
||||
try {
|
||||
if (response != null) {
|
||||
response.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
}
|
||||
}
|
||||
return chapterId;
|
||||
}
|
||||
|
||||
private HttpPost sendChapterRequest(final ObjectMapper mapper, final SendChapterRequest sendChapterRequest) throws JsonProcessingException, UnsupportedEncodingException {
|
||||
|
||||
HttpPost post = new HttpPost(sendChapterUrl);
|
||||
String requestText = mapper.writeValueAsString(sendChapterRequest);
|
||||
post.setEntity(new StringEntity(requestText));
|
||||
return post;
|
||||
|
||||
}
|
||||
|
||||
private String asChapterId(final ObjectMapper mapper, final HttpPost sendChapterPost) throws IOException {
|
||||
String chapterId;
|
||||
HttpEntity entity = sendChapterPost.getEntity();
|
||||
|
||||
SendChapterResponse response = mapper.readValue(entity.getContent(), SendChapterResponse.class);
|
||||
chapterId = response.getChapterId();
|
||||
return chapterId;
|
||||
}
|
||||
|
||||
private SendChapterRequest asSendChapterRequest(final Chapter chapter) {
|
||||
SendChapterRequest request = new SendChapterRequest();
|
||||
request.setTitle(chapter.getTitle());
|
||||
request.setContent(chapter.getContent());
|
||||
return request
|
||||
}
|
||||
|
||||
|
||||
当然,这个代码还算不上已经处理得很整洁了,但至少同之前相比,已经简洁了一些。我们只用了最简单的提取函数这个重构手法,就把一个大函数拆分成了若干的小函数。
|
||||
|
||||
顺便说一下,长函数往往还隐含着一个命名问题。如果你看修改后的 sendChapter,其中的变量命名明显比之前要短,理解的成本也相应地会降低。因为变量都是在这个短小的上下文里,也就不会产生那么多的命名冲突,变量名当然就可以写短一些。
|
||||
|
||||
平铺直叙的代码,一个关键点就是没有把不同的东西分解出来。如果我们用设计的眼光衡量这段代码,这就是“分离关注点”没有做好,把不同层面的东西混在了一起,既有不同业务混在一起,也有不同层次的处理混在了一起。我在《软件设计之美》专栏中,也曾说过,关注点越多越好,粒度越小越好。
|
||||
|
||||
有时,一段代码一开始的时候并不长,就像下面这段代码,它根据返回的错误进行相应地错误处理:
|
||||
|
||||
if (code == 400 || code == 401) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
然后,新的需求来了,增加了新的错误码,它就变成了这个样子:
|
||||
|
||||
if (code == 400 || code == 401 || code == 402) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
你知道,一个有生命力的项目经常会延续很长时间,于是,这段代码有很多次被修改的机会,日积月累,它就成了让人不忍直视的代码,比如:
|
||||
|
||||
if (code == 400 || code == 401 || code == 402 || ...
|
||||
|
||||
|| code == 500 || ...
|
||||
|
||||
|| ...
|
||||
|
||||
|| code == 10000 || ...) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
后来人看到这段代码就想骂人了。当他从版本控制的历史中找到这些代码的作者,去询问这些处理的来龙去脉时,每个人其实都很委屈,他们当时也没做太多,只是加了一个判断条件而已。
|
||||
|
||||
任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”:
|
||||
|
||||
Robert Martin 把它借鉴到了编程领域,简言之,我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进它。但这一切的前提是,你要能看出自己的代码是不是让原有的代码变得糟糕了,所以,学习代码的坏味道还是很有必要的。
|
||||
|
||||
至此,我们看到了代码变长的几种常见原因:
|
||||
|
||||
你会发现,代码变长根本是一个无意识的问题,写代码的人没有觉得自己把代码破坏了。但只要你认识到长函数是一个坏味道,后面的许多问题就自然而然地会被发掘出来,至于解决方案,你已经看到了,大部分情况下,就是拆分成各种小函数。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了程序员最深恶痛绝的坏味道:长函数。没有人愿意去阅读长函数,但许多人又会不经意间写出长函数。
|
||||
|
||||
毫无疑问,长函数是一个坏味道。对于团队而言,一个关键点是要定义出长函数的标准。不过,过于宽泛的标准是没有意义的,想要有效地控制函数规模,几十行的函数已经是标准的上限了,这个标准越低越好。
|
||||
|
||||
我们还分析了长函数产生的原因:
|
||||
|
||||
有人以性能为借口;
|
||||
|
||||
有人把代码平铺直叙地摊在那里;
|
||||
|
||||
有人只是每次增加了一点点。
|
||||
|
||||
其中,平铺直叙是把函数写长最常见的原因。之所以会把代码平摊在那里,一方面是把多个业务写到了一起,另一方面是把不同层次的代码写到了一起。究其根因,那是“分离关注点”没有做好。
|
||||
|
||||
每次增加一点点,是另外一个让代码变长的原因,应对它的主要办法就是要坚守“童子军军规”,但其背后更深层次的支撑就是要对坏味道有着深刻的认识。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:把函数写短,越短越好。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
你在实际的工作中遇到过长函数吗?讲讲你和长函数斗争的故事,欢迎在留言区写下你的经历。如果你身边有人正在为“长函数”苦恼,也欢迎你把这节课分享给他。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
213
专栏/代码之丑/05大类:如何避免写出难以理解的大类?.md
Normal file
213
专栏/代码之丑/05大类:如何避免写出难以理解的大类?.md
Normal file
@ -0,0 +1,213 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
05 大类:如何避免写出难以理解的大类?
|
||||
你好,我是郑晔。
|
||||
|
||||
上一讲我们讲了长函数,一个让你感受最直观的坏味道。这一讲,我们再来讲一个你一听名字就知道是怎么回事的坏味道:大类。
|
||||
|
||||
一听到大类,估计你的眼前已经浮现出一片无边无际的代码了。类之所以成为了大类,一种表现形式就是我们上节课讲到的长函数,一个类只要有几个长函数,那它就肯定是一眼望不到边了(长函数的话题,我们上一讲已经讨论过了,这里就不再赘述了)。
|
||||
|
||||
大类还有一种表现形式,类里面有特别多的字段和函数,也许,每个函数都不大,但架不住数量众多啊,这也足以让这个类在大类中占有一席之地。这一讲,我们就主要来说说这种形式的大类。
|
||||
|
||||
分模块的程序
|
||||
|
||||
我先来问你一个问题,为什么不把所有的代码都写到一个文件里?
|
||||
|
||||
你可能会觉得这个问题很傻,心里想:除了像练习之类的特定场景,谁会在一个正经的项目上把代码写到一个文件里啊?
|
||||
|
||||
没错,确实没有人这么做,但你思考过原因吗?把代码都写到一个文件里,问题在哪里呢?
|
||||
|
||||
事实是,把代码写到一个文件里,一方面,相同的功能模块没有办法复用;另一方面,也是更关键的,把代码都写到一个文件里,其复杂度会超出一个人能够掌握的认知范围。简言之,一个人理解的东西是有限的,没有人能同时面对所有细节。
|
||||
|
||||
人类面对复杂事物给出的解决方案是分而治之。所以,我们看到几乎各种程序设计语言都有自己的模块划分方案,从最初的按照文件划分,到后来,使用面向对象方案按照类进行划分,本质上,它们都是一种模块划分的方式。这样,人们面对的就不再是细节,而是模块,模块的数量显然会比细节数量少,人们的理解成本就降低了。
|
||||
|
||||
好,你现在已经理解了,对程序进行模块划分,本质上就是在把问题进行分解,而这种做法的背后原因,就是人类的认知能力是有限的。
|
||||
|
||||
理解了这一点,我们再回过头来看大类这个坏味道,你就知道问题出在哪了。如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免了。
|
||||
|
||||
按照这个思路,解决大类的方法也就随之而来了,就是把大类拆成若干个小类。你可能会想,这我也知道,问题是,怎么拆呢?
|
||||
|
||||
大类的产生
|
||||
|
||||
想要理解怎么拆分一个大类,我们需要知道,这些类是怎么变成这么大的。
|
||||
|
||||
最容易产生大类的原因在于职责的不单一。我们先来看一段代码:
|
||||
|
||||
public class User {
|
||||
|
||||
private long userId;
|
||||
|
||||
private String name;
|
||||
|
||||
private String nickname;
|
||||
|
||||
private String email;
|
||||
|
||||
private String phoneNumber;
|
||||
|
||||
private AuthorType authorType;
|
||||
|
||||
private ReviewStatus authorReviewStatus;
|
||||
|
||||
private EditorType editorType;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这个 User 类拥有着一个大类的典型特征,其中包含着一大堆的字段。面对这样一个类时,我们要问的第一个问题就是,这个类里的字段都是必需的吗?
|
||||
|
||||
我们来稍微仔细地看一下这个类,用户 ID(userId)、姓名(name)、昵称(nickname) 之类应该是一个用户的基本信息,后面的邮箱(email)、电话号码(phoneNumber) 也算是和用户相关联的。今天的很多应用都提供使用邮箱或电话号码登录的方式,所以,这个信息放在这里,也算是可以理解。
|
||||
|
||||
再往后看,作者类型(authorType),这里表示作者是签约作者还是普通作者,签约作者可以设置作品的付费信息,而普通作者不能。后面的字段是作者审核状态(authorReviewStatus),就是说,作者成为签约作者,需要有一个申请审核的过程,这个状态就是审核的状态。
|
||||
|
||||
再往后,又出现了一个编辑类型(editorType),编辑可以是主编,也可以是小编,他们的权限是不一样的。
|
||||
|
||||
这还不是这个 User 类的全部。但是,即便只看这些内容,也足以让我们发现一些问题了。
|
||||
|
||||
首先,普通的用户既不是作者,也不是编辑。作者和编辑这些相关的字段,对普通用户来说,都是没有意义的。其次,对于那些成为了作者的用户,编辑的信息意义也不大,因为作者是不能成为编辑的,反之亦然,编辑也不会成为作者,作者信息对成为编辑的用户也是没有意义的。
|
||||
|
||||
在这个类的设计里面,总有一些信息对一部分人是没有意义,但这些信息对于另一部分人来说又是必需的。之所以会出现这样的状况,关键点就在于,这里只有“一个”用户类。
|
||||
|
||||
普通用户、作者、编辑,这是三种不同角色,来自不同诉求的业务方关心的是不同的内容。只是因为它们都是这个系统的用户,就把它们都放到用户类里,造成的结果就是,任何业务方的需求变动,都会让这个类反复修改。这种做法实际上是违反了单一职责原则。
|
||||
|
||||
在《软件设计之美》中,我曾经专门用了一讲的篇幅讲[单一职责原则],它让我们把模块的变化纳入考量。单一职责原则是衡量软件设计好坏的一把简单而有效的尺子,通常来说,很多类之所以巨大,大部分原因都是违反了单一职责原则。而想要破解“大类”的谜题,关键就是能够把不同的职责拆分开来。
|
||||
|
||||
回到我们这个类上,其实,我们前面已经分析了,虽然这是一个类,但其实,它把不同角色关心的东西都放在了一起,所以,它变得如此庞大。我们只要把不同的信息拆分开来,问题也就迎刃而解了。下面就是把不同角色拆分出来的结果:
|
||||
|
||||
public class User {
|
||||
|
||||
private long userId;
|
||||
|
||||
private String name;
|
||||
|
||||
private String nickname;
|
||||
|
||||
private String email;
|
||||
|
||||
private String phoneNumber;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
public class Author {
|
||||
|
||||
private long userId;
|
||||
|
||||
private AuthorType authorType;
|
||||
|
||||
private ReviewStatus authorReviewStatus;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
public class Editor {
|
||||
|
||||
private long userId;
|
||||
|
||||
private EditorType editorType;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里,我们拆分出了 Author 和 Editor 两个类,把与作者和编辑相关的字段分别移到了这两个类里面。在这两个类里面分别有一个 userId 字段,用以识别这个角色是和哪个用户相关。这个大 User 类就这样被分解了。
|
||||
|
||||
大类的产生往往还有一个常见的原因,就是字段未分组。
|
||||
|
||||
有时候,我们会觉得有一些字段确实都是属于某个类,结果就是,这个类还是很大。比如,我们看一下上面拆分的结果,那个新的 User 类:
|
||||
|
||||
public class User {
|
||||
|
||||
private long userId;
|
||||
|
||||
private String name;
|
||||
|
||||
private String nickname;
|
||||
|
||||
private String email;
|
||||
|
||||
private String phoneNumber;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
前面我们分析过,这些字段应该都算用户信息的一部分。但是,即便相比于原来的 User 类小了许多,这个类依然也不算是一个小类,原因就是,这个类里面的字段并不属于同一种类型的信息。比如,userId、name、nickname 几项,算是用户的基本信息,而 email、phoneNumber 这些则属于用户的联系方式。
|
||||
|
||||
从需求上看,基本信息是那种一旦确定就不怎么会改变的内容,而联系方式则会根据实际情况调整,比如,绑定各种社交媒体的账号。所以,如果我们把这些信息都放到一个类里面,这个类的稳定程度就要差一些。所以,我们可以根据这个理解,把 User 类的字段分个组,把不同的信息放到不同的类里面。
|
||||
|
||||
public class User {
|
||||
|
||||
private long userId;
|
||||
|
||||
private String name;
|
||||
|
||||
private String nickname;
|
||||
|
||||
private Contact contact;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
public class Contact {
|
||||
|
||||
private String email;
|
||||
|
||||
private String phoneNumber;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里我们引入了一个 Contact 类(也就是联系方式),把 email 和 phoneNumber 放了进去,后面再有任何关于联系方式的调整就都可以放在这个类里面。经过这次调整,我们把不同的信息重新组合了一下,但每个类都比原来要小。
|
||||
|
||||
对比一下,如果说前后两次拆分有什么不同,那就是:前面是根据职责,拆分出了不同的实体,后面是将字段做了分组,用类把不同的信息分别做了封装。
|
||||
|
||||
或许你已经发现了,所谓的将大类拆解成小类,本质上在做的工作是一个设计工作。我们分解的依据其实是单一职责这个重要的设计原则。没错,很多人写代码写不好,其实是缺乏软件设计的功底,不能有效地把各种模型识别出来。所以,想要写好代码,还是要好好学学软件设计的。
|
||||
|
||||
学了这一讲,如果你还想有些极致的追求,我给你推荐《ThoughtWorks 文集》这本书里“对象健身操”这一篇,这里提到一个要求:每个类不超过 2 个字段。《ThoughtWorks 文集》是我当年参与翻译的一本书,今天看来,里面的内容大部分都过时了,但“对象健身操”这一篇还是值得一读的。
|
||||
|
||||
关于大类的讨论差不多就接近尾声了,但我估计结合这一讲最初的讨论,有些人心中会升起一些疑问:如果我们把大类都拆成小类,类的数量就会增多,那人们理解的成本是不是也会增加呢?
|
||||
|
||||
其实,这也是很多人不拆分大类的借口。
|
||||
|
||||
在这个问题上,程序设计语言早就已经有了很好的解决方案,所以,我们会看到在各种程序设计语言中,有诸如包、命名空间之类的机制,将各种类组合在一起。在你不需要展开细节时,面对的是一个类的集合。再进一步,还有各种程序库把这些打包出来的东西再进一步打包,让我们只要面对简单的接口,而不必关心各种细节。
|
||||
|
||||
如此层层封装,软件不就是这样构建出来的吗?
|
||||
|
||||
总结时刻
|
||||
|
||||
我们今天讲了大类这个坏味道,这是程序员日常感知最为深刻的坏味道之一。
|
||||
|
||||
应对大类的解决方案,主要是将大类拆分成小类。我们需要认识到,模块拆分,本质上是帮助人们降低理解成本的一种方式。
|
||||
|
||||
我们还介绍了两种产生大类的原因:
|
||||
|
||||
无论是哪种原因,想要有效地对类进行拆分,我们需要对不同内容的变动原因进行分析,而支撑我们来做这种分析的就是单一职责原则。将大类拆分成小类,本质上在做的是设计工作,所以,想要写好代码,程序员需要学好软件设计。
|
||||
|
||||
有人觉得拆分出来的小类过多,不易管理,但其实程序设计语言早就为我们提供了各种构造类集合的方式,比如包、命名空间等,再进一步,还可以封装出各种程序库。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:把类写小,越小越好。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
你在实际工作中遇到过多大的类,你分析过它是怎样产生的吗?又是如何拆分的呢?欢迎在留言区分享你的经历。如果你身边有同事总是写出大类,你不妨把这节课分享给他,帮他解决大类的烦恼。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
296
专栏/代码之丑/06长参数列表:如何处理不同类型的长参数?.md
Normal file
296
专栏/代码之丑/06长参数列表:如何处理不同类型的长参数?.md
Normal file
@ -0,0 +1,296 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
06 长参数列表:如何处理不同类型的长参数?
|
||||
你好,我是郑晔。
|
||||
|
||||
前面两讲,我们分别讲了长函数和大类,它们都是那种“我一说,你就知道是怎么回事”的坏味道,而且都让我们深恶痛绝,唯恐避之不及。这样典型的坏味道还有一个,就是长参数列表。
|
||||
|
||||
好吧,我知道你的脑子里已经出现了一个长长的参数列表了。每个程序员只要想到,一个函数拥有几十甚至上百个参数,内心就难以平静下来。
|
||||
|
||||
那么,函数为什么要有参数呢?我们知道,不同函数之间需要共享信息,于是才有了参数传递。
|
||||
|
||||
其实,函数间共享信息的方式不止一种,除了参数列表,最常见的一种方式是全局变量。但全局变量会带给我们太多意想不到的问题,所以,在初学编程的时候,老师就会告诉我们,不要使用全局变量。从程序设计语言发展的过程中,我们也可以看到,取消全局变量已经成为了大势所趋。
|
||||
|
||||
但函数之间还是要传递信息的,既然不能用全局变量,参数就成了最好的选择,于是乎,只要你想到有什么信息要传给一个函数,就自然而然地把它加到参数列表中,参数列表也就越来越长了。
|
||||
|
||||
那么,长参数列表有啥问题呢?这个问题其实我在上一讲已经说过了,人脑能够掌握的内容有限,一旦参数列表变得很长,作为普通人,我们就很难对这些内容进行把控了。
|
||||
|
||||
既然长参数列表的问题是数量多,秉承我们一以贯之的思路,解决这个问题的关键就在于,减少参数的数量。
|
||||
|
||||
既然知道了解决方案的方向,那我们接下来就具体看看,有哪些方法可以减少参数的数量。
|
||||
|
||||
聚沙成塔
|
||||
|
||||
我们来看一段代码:
|
||||
|
||||
public void createBook(final String title,
|
||||
final String introduction,
|
||||
final URL coverUrl,
|
||||
final BookType type,
|
||||
final BookChannel channel,
|
||||
final String protagonists,
|
||||
final String tags,
|
||||
final boolean completed) {
|
||||
...
|
||||
Book book = Book.builder
|
||||
.title(title)
|
||||
.introduction(introduction)
|
||||
.coverUrl(coverUrl)
|
||||
.type(type)
|
||||
.channel(channel)
|
||||
.protagonists(protagonists)
|
||||
.tags(tags)
|
||||
.completed(completed)
|
||||
.build();
|
||||
this.repository.save(book);
|
||||
}
|
||||
|
||||
|
||||
这是一个创建作品的函数,我们可以看到,这个函数的参数列表里,包含了一部作品所要拥有的各种信息,比如:作品标题、作品简介、封面 URL、作品类型、作品归属的频道、主角姓名、作品标签、作品是否已经完结等等。
|
||||
|
||||
如果你阅读这段代码,只是想理解它的逻辑,你或许会觉得这个函数的参数列表还挺合理,它把创建一部作品所需的各种信息都传给了函数,这是大部分人面对一段代码时理解问题的角度。不过,虽然这样写代码容易让人理解,但这不足以让你发现问题。
|
||||
|
||||
比如,如果你现在要在作品里增加一项信息,表明这部作品是否是签约作品,也就是这部作品是否可以收费,那你该怎么办?
|
||||
|
||||
顺着前面的思路,我们很自然地就会想到给这个函数增加一个参数。但正如我在讲“[长函数]”那节课里说到的,很多问题都是这样,每次只增加一点点,累积起来,便不忍直视了。
|
||||
|
||||
如果我们有了“坏味道”的视角,我们就会看到这里面的问题:这个函数的参数列表太长了。
|
||||
|
||||
怎么解决这个问题呢?
|
||||
|
||||
这里所有的参数其实都是和作品相关的,也就是说,所有的参数都是创建作品所必需的。所以,我们可以做的就是将这些参数封装成一个类,一个创建作品的参数类:
|
||||
|
||||
public class NewBookParamters {
|
||||
|
||||
private String title;
|
||||
|
||||
private String introduction;
|
||||
|
||||
private URL coverUrl;
|
||||
|
||||
private BookType type;
|
||||
|
||||
private BookChannel channel;
|
||||
|
||||
private String protagonists;
|
||||
|
||||
private String tags;
|
||||
|
||||
private boolean completed;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这样一来,这个函数参数列表就只剩下一个参数了,一个长参数列表就消除了:
|
||||
|
||||
public void createBook(final NewBookParamters parameters) {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里你看到了一个典型的消除长参数列表的重构手法:将参数列表封装成对象。
|
||||
|
||||
或许你还有个疑问,只是把一个参数列表封装成一个类,然后,用到这些参数的时候,还需要把它们一个个取出来,这会不会是多此一举呢?就像这样:
|
||||
|
||||
public void createBook(final NewBookParamters parameters) {
|
||||
|
||||
...
|
||||
|
||||
Book book = Book.builder
|
||||
.title(parameters.getTitle())
|
||||
.introduction(parameters.getIntroduction())
|
||||
.coverUrl(parameters.getCoverUrl())
|
||||
.type(parameters.getType())
|
||||
.channel(parameters.getChannel())
|
||||
.protagonists(parameters.getProtagonists())
|
||||
.tags(parameters.getTags())
|
||||
.completed(parameters.isCompleted())
|
||||
.build();
|
||||
this.repository.save(book);
|
||||
}
|
||||
|
||||
|
||||
如果你也有这样的想法,那说明一件事:你还没有形成对软件设计的理解。我们并不是简单地把参数封装成类,站在设计的角度,我们这里引入的是一个新的模型。我在《软件设计之美》讨论[模型封装]的时候曾经说过,一个模型的封装应该是以行为为基础的。
|
||||
|
||||
之前没有这个模型,所以,我们想不到它应该有什么行为,现在模型产生了,它就应该有自己配套的行为,那这个模型的行为是什么呢?从上面的代码我们不难看出,它的行为应该是构建一个作品对象出来。你理解了这一点,我们的代码就可以进一步调整了:
|
||||
|
||||
public class NewBookParamters {
|
||||
|
||||
private String title;
|
||||
|
||||
private String introduction;
|
||||
|
||||
private URL coverUrl;
|
||||
|
||||
private BookType type;
|
||||
|
||||
private BookChannel channel;
|
||||
|
||||
private String protagonists;
|
||||
|
||||
private String tags;
|
||||
|
||||
private boolean completed;
|
||||
|
||||
public Book newBook() {
|
||||
|
||||
return Book.builder
|
||||
.title(title)
|
||||
.introduction(introduction)
|
||||
.coverUrl(coverUrl)
|
||||
.type(type)
|
||||
.channel(channel)
|
||||
.protagonists(protagonists)
|
||||
.tags(tags)
|
||||
.completed(completed)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
创建作品的函数就得到了极大的简化:
|
||||
|
||||
public void createBook(final NewBookParamters parameters) {
|
||||
|
||||
...
|
||||
|
||||
Book book = parameters.newBook();
|
||||
this.repository.save(book);
|
||||
}
|
||||
|
||||
|
||||
好,这里我们讨论消除长参数列表的一种方法,将参数列表封装成类。还记得我们前面提到的“如何扩展需求”这个问题吗?如果需求扩展,需要增加创建作品所需的内容,那这个参数列表就是不变的,相对来说,它就是稳定的。
|
||||
|
||||
或许你会问,那这个类就会不断膨胀,变成一个大类,那该怎么办呢?关于这一点,你可以回顾一下我们的[前一讲],看看怎么解决大类的问题。
|
||||
|
||||
动静分离
|
||||
|
||||
把长参数列表封装成一个类,这能解决大部分的长参数列表,但并不等于所有的长参数列表都应该用这种方式解决,因为不是所有情况下,参数都属于一个类。
|
||||
|
||||
我们再来看一段代码:
|
||||
|
||||
public void getChapters(final long bookId,
|
||||
final HttpClient httpClient,
|
||||
final ChapterProcessor processor) {
|
||||
|
||||
HttpUriRequest request = createChapterRequest(bookId);
|
||||
HttpResponse response = httpClient.execute(request);
|
||||
List<Chapter> chapters = toChapters(response);
|
||||
processor.process(chapters);
|
||||
}
|
||||
|
||||
|
||||
这个函数的作用是根据作品 ID 获取其对应的章节信息。如果,单纯以参数个数论,这个函数的参数数量并不算多。
|
||||
|
||||
如果你只是看这个函数,可能很难发现直接的问题。即便我们认为有问题,也可以用一个类把这个函数的参数都封装起来。不过,秉承我在这个专栏里讨论的一贯原则,绝对的数量并不是关键点,参数列表也应该是越少越好。针对这个函数,我们需要稍微分析一下这几个参数。
|
||||
|
||||
在这几个参数里面,每次传进来的 bookId 都是不一样的,是随着请求的不同而改变的。但 httpClient 和 processor 两个参数都是一样的,因为它们都有相同的逻辑,没有什么变化。
|
||||
|
||||
换言之,bookId 的变化频率同 httpClient 和 processor 这两个参数的变化频率是不同的。一边是每次都变,另一边是不变的。
|
||||
|
||||
我在《软件设计之美》中讲[分离关注点]时曾经讲到过,不同的数据变动方向也是不同的关注点。这里表现出来的就是典型的动数据(bookId)和静数据(httpClient 和 processor),它们是不同的关注点,应该分离开来。
|
||||
|
||||
具体到这个场景下,静态不变的数据完全可以成为这个函数所在类的一个字段,而只将每次变动的东西作为参数传递就可以了。按照这个思路,代码可以改成这个样子:
|
||||
|
||||
public void getChapters(final long bookId) {
|
||||
|
||||
HttpUriRequest request = createChapterRequest(bookId);
|
||||
HttpResponse response = this.httpClient.execute(request);
|
||||
List<Chapter> chapters = toChapters(response);
|
||||
this.processor.process(chapters);
|
||||
}
|
||||
|
||||
|
||||
这个坏味道其实是一个软件设计问题,代码缺乏应有的结构,所以,原本应该属于静态结构的部分却以动态参数的方式传来传去,无形之中拉长了参数列表。
|
||||
|
||||
这个例子也给了我们一个提示,长参数列表固然可以用一个类进行封装,但能够封装出这个类的前提条件是:这些参数属于一个类,有相同的变化原因。
|
||||
|
||||
如果函数的参数有不同的变化频率,就要视情况而定了。对于静态的部分,我们前面已经看到了,它可以成为软件结构的一部分,而如果有多个变化频率,我们还可以封装出多个参数类来。
|
||||
|
||||
告别标记
|
||||
|
||||
我们再来看一个例子:
|
||||
|
||||
public void editChapter(final long chapterId,
|
||||
final String title,
|
||||
final String content,
|
||||
final boolean apporved) {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这是我们在前面课程“[重复代码]”那一讲里提到过的一个函数,我们稍微复习一下,这几个参数分别表示,待修改章节的 ID、标题和内容,最后一个参数表示这次修改是否直接审核通过。
|
||||
|
||||
前面几个参数是修改一个章节的必要信息,而这里的重点就在最后这个参数上。
|
||||
|
||||
之所以要有这么个参数,从业务上说,如果是作者进行编辑,之后要经过审核,而如果编辑来编辑的,那审核就直接通过,因为编辑本身扮演了审核人的角色。所以,你发现了,这个参数实际上是一个标记,标志着接下来的处理流程会有不同。
|
||||
|
||||
使用标记参数,是程序员初学编程时常用的一种手法,不过,正是因为这种手法实在是太好用了,造成的结果就是代码里面彩旗(flag)飘飘,各种标记满天飞。不仅变量里有标记,参数里也有。很多长参数列表其中就包含了各种标记参数。这也是很多代码产生混乱的一个重要原因。
|
||||
|
||||
在实际的代码中,我们必须小心翼翼地判断各个标记当前的值,才能做好处理。
|
||||
|
||||
解决标记参数,一种简单的方式就是,将标记参数代表的不同路径拆分出来。回到这段代码上,这里的一个函数可以拆分成两个函数,一个函数负责“普通的编辑”,另一个负责“可以直接审核通过的编辑”。
|
||||
|
||||
public void editChapter(final long chapterId,
|
||||
final String title,
|
||||
final String content) {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
public void editChapterWithApproval(final long chapterId,
|
||||
final String title,
|
||||
final String content) {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
标记参数在代码中存在的形式很多,有的是布尔值的形式,有的是以枚举值的形式,还有的就是直接的字符串或者整数。无论哪种形式,我们都可以通过拆分函数的方式将它们拆开。在重构中,这种手法叫做移除标记参数(Remove Flag Argument)。
|
||||
|
||||
最近这三节课,我们讲了长函数、大类和长参数列表三种不同的坏味道,但在我们阐述了对于这些坏味道的理解之后,仔细想想这些坏味道,其实背后都是一件事:我们应该编写“短小”的代码。
|
||||
|
||||
这是由人类理解复杂问题的能力决定的,只有短小的代码,我们才能有更好地把握,而要写出短小的代码,需要我们能够“分离关注点”。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲解的坏味道是长参数列表,它同样是一个“我一说,你就知道是怎么回事”的坏味道。
|
||||
|
||||
应对长参数列表主要的方式就是减少参数的数量,一种最直接的方式就是将参数列表封装成一个类。但并不是说所有的情况都能封装成类来解决,我们还要分析是否所有的参数都有相同的变动频率。
|
||||
|
||||
变化频率相同,则封装成一个类。
|
||||
|
||||
变化频率不同的话:
|
||||
|
||||
静态不变的,可以成为软件结构的一部分;
|
||||
|
||||
多个变化频率的,可以封装成几个类。
|
||||
|
||||
除此之外,参数列表中经常会出现标记参数,这是参数列表变长的另一个重要原因。对于这种标记参数,一种解决方案就是根据这些标记参数,将函数拆分成多个函数。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:减小参数列表,越小越好。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
你曾经遇到的长参数列表有多长呢?你是怎样解决它的呢?欢迎在留言区分享你的经历。
|
||||
|
||||
也建议你“在教中学”,充分吸收理解这一讲的内容,并讲给自己的团队听。
|
||||
|
||||
感谢阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
299
专栏/代码之丑/07滥用控制语句:出现控制结构,多半是错误的提示.md
Normal file
299
专栏/代码之丑/07滥用控制语句:出现控制结构,多半是错误的提示.md
Normal file
@ -0,0 +1,299 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
07 滥用控制语句:出现控制结构,多半是错误的提示
|
||||
你好,我是郑晔。
|
||||
|
||||
在前面几讲,我们已经讲了不少的坏味道,比如长函数、大类等。对于有一定从业经验的程序员来说,即便不能对这些坏味道有一个很清楚的个人认知,但至少一说出来,通常都知道是怎么回事。
|
||||
|
||||
但这节课我要讲的坏味道对于很多人来说,可能就有点挑战了。这并不是说内容有多难,相反,大部分人对这些内容简直太熟悉了。所以,当我把它们以坏味道的方式呈现出来时,这会极大地挑战很多人的认知。
|
||||
|
||||
这个坏味道就是滥用控制语句,也就是你熟悉的 if、for 等等,这个坏味道非常典型,但很多人每天都用它们,却对问题毫无感知。今天我们就先从一个你容易接受的坏味道开始,说一说使用控制语句时,问题到底出在哪。
|
||||
|
||||
嵌套的代码
|
||||
|
||||
我给你看一张让我印象极其深刻的图,看了之后你就知道我要讲的这个坏味道是什么了。
|
||||
|
||||
|
||||
|
||||
图片来源于网络
|
||||
|
||||
相信不少同学在网上见过这张图,是的,我们接下来就来讨论嵌套的代码。
|
||||
|
||||
考虑到篇幅,我就不用这么震撼的代码做案例了,我们还是从规模小一点的代码开始讨论:
|
||||
|
||||
public void distributeEpubs(final long bookId) {
|
||||
|
||||
List<Epub> epubs = this.getEpubsByBookId(bookId);
|
||||
|
||||
for (Epub epub : epubs) {
|
||||
if (epub.isValid()) {
|
||||
boolean registered = this.registerIsbn(epub);
|
||||
if (registered) {
|
||||
this.sendEpub(epub);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这是一段做 EPUB 分发的代码,EPUB 是一种电子书格式。在这里,我们根据作品 ID 找到要分发的 EPUB,然后检查 EPUB 的有效性。对于有效的 EPUB,我们要为它注册 ISBN 信息,注册成功之后,将这个 EPUB 发送出去。
|
||||
|
||||
代码逻辑并不是特别复杂,只不过,在这段代码中,我们看到了多层的缩进,for 循环一层,里面有两个 if ,又多加了两层。即便不是特别复杂的代码,也有这么多的缩进,可想而知,如果逻辑再复杂一点,缩进会成什么样子。
|
||||
|
||||
这段代码之所以会写成这个样子,其实就是我在讲“[长函数]”那节课里所说的:“平铺直叙地写代码”。这段代码的作者只是按照需求一步一步地把代码实现出来了。从实现功能的角度来说,这段代码肯定没错,但问题在于,在把功能实现之后,他停了下来,而没有把代码重新整理一下。那我们就来替这段代码作者将它整理成应有的样子。
|
||||
|
||||
既然我们不喜欢缩进特别多的代码,那我们就要消除缩进。具体到这段代码,一个着手点是 for 循环,因为通常来说,for 循环处理的是一个集合,而循环里面处理的是这个集合中的一个元素。所以,我们可以把循环中的内容提取成一个函数,让这个函数只处理一个元素,就像下面这样:
|
||||
|
||||
public void distributeEpubs(final long bookId) {
|
||||
|
||||
List<Epub> epubs = this.getEpubsByBookId(bookId);
|
||||
|
||||
for (Epub epub : epubs) {
|
||||
this.distributeEpub(epub);
|
||||
}
|
||||
}
|
||||
|
||||
private void distributeEpub(final Epub epub) {
|
||||
|
||||
if (epub.isValid()) {
|
||||
boolean registered = this.registerIsbn(epub);
|
||||
if (registered) {
|
||||
this.sendEpub(epub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里我们已经有了一次拆分,分解出来 distributeEpub 函数每次只处理一个元素。拆分出来的两个函数在缩进的问题上,就改善了一点。
|
||||
|
||||
第一个函数 distributeEpubs 只有一层缩进,这是一个正常函数应有的样子,不过,第二个函数 distributeEpub 则还有多层缩进,我们可以继续处理一下。
|
||||
|
||||
if 和 else
|
||||
|
||||
在 distributeEpub 里,造成缩进的原因是 if 语句。通常来说,if 语句造成的缩进,很多时候都是在检查某个先决条件,只有条件通过时,才继续执行后续的代码。这样的代码可以使用卫语句(guard clause)来解决,也就是设置单独的检查条件,不满足这个检查条件时,立刻从函数中返回。
|
||||
|
||||
这是一种典型的重构手法:以卫语句取代嵌套的条件表达式(Replace Nested Conditional with Guard Clauses)。
|
||||
|
||||
我们来看看改进后的 distributeEpub 函数:
|
||||
|
||||
private void distributeEpub(final Epub epub) {
|
||||
|
||||
if (!epub.isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean registered = this.registerIsbn(epub);
|
||||
if (!registered) {
|
||||
return;
|
||||
}
|
||||
this.sendEpub(epub);
|
||||
}
|
||||
|
||||
|
||||
改造后的 distributeEpub 就没有了嵌套,也就没有那么多层的缩进了。你可能已经发现了,经过我们改造之后,代码里只有一层的缩进。当代码里只有一层缩进时,代码的复杂度就大大降低了,理解成本和出现问题之后定位的成本也随之大幅度降低。
|
||||
|
||||
函数至多有一层缩进,这是“对象健身操(《ThoughtWorks 文集》书里的一篇)”里的一个规则。前面讲“[大类]”的时候,我曾经提到过“对象健身操”这篇文章,其中给出了九条编程规则,下面我们再来讲其中的一条:不要使用 else 关键字。
|
||||
|
||||
没错,else 也是一种坏味道,这是挑战很多程序员认知的。在大多数人印象中,if 和 else 是亲如一家的整体,它们几乎是比翼齐飞的。那么,else 可以不写吗?可以。我们来看看下面的代码:
|
||||
|
||||
public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
|
||||
|
||||
double price = 0;
|
||||
|
||||
if (highQuality && chapterSequence > START_CHARGING_SEQUENCE) {
|
||||
price = 4.99;
|
||||
} else if (sequenceNumber > START_CHARGING_SEQUENCE
|
||||
&& sequenceNumber <= FURTHER_CHARGING_SEQUENCE) {
|
||||
price = 1.99;
|
||||
} else if (sequenceNumber > FURTHER_CHARGING_SEQUENCE) {
|
||||
price = 2.99;
|
||||
} else {
|
||||
price = 0.99;
|
||||
}
|
||||
return price;
|
||||
}
|
||||
|
||||
|
||||
这是一个根据 EPUB 信息进行定价的函数,它的定价逻辑正如代码中所示。
|
||||
|
||||
如果是高品质书,而且要是章节序号超过起始付费章节,就定价 4.99;
|
||||
|
||||
对一般的书而言,超过起始付费章节,就定价 1.99;超过进一步付费章节,就定价 2.99。
|
||||
|
||||
缺省情况下,定价 0.99。
|
||||
|
||||
就这段代码而言,如果想不使用 else,一个简单的处理手法就是让每个逻辑提前返回,这和我们前面提到的卫语句的解决方案如出一辙:
|
||||
|
||||
public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
|
||||
|
||||
if (highQuality && chapterSequence > START_CHARGING_SEQUENCE) {
|
||||
return 4.99;
|
||||
}
|
||||
|
||||
if (sequenceNumber > START_CHARGING_SEQUENCE
|
||||
&& sequenceNumber <= FURTHER_CHARGING_SEQUENCE) {
|
||||
return 1.99;
|
||||
}
|
||||
|
||||
if (sequenceNumber > FURTHER_CHARGING_SEQUENCE) {
|
||||
return 2.99;
|
||||
}
|
||||
return 0.99;
|
||||
}
|
||||
|
||||
|
||||
对于这种逻辑上还比较简单的代码,这么改造还是比较容易的,而对于一些更为复杂的代码,也许就要用到多态来改进代码了。不过在实际项目中,大部分代码逻辑都是逐渐变得复杂的,所以,最好在它还比较简单时,就把坏味道消灭掉。这才是最理想的做法。
|
||||
|
||||
无论是嵌套的代码,还是 else 语句,我们之所以要把它们视为坏味道,本质上都在追求简单,因为一段代码的分支过多,其复杂度就会大幅度增加。我们一直在说,人脑能够理解的复杂度是有限的,分支过多的代码一定是会超过这个理解范围。
|
||||
|
||||
在软件开发中,有一个衡量代码复杂度常用的标准,叫做圈复杂度(Cyclomatic complexity,简称 CC),圈复杂度越高,代码越复杂,理解和维护的成本就越高。在圈复杂度的判定中,循环和选择语句占有重要的地位。圈复杂度可以使用工具来检查,比如,在 Java 世界中,有很多可以检查圈复杂度的工具,我们之前提到过的 Checkstyle 就可以做圈复杂度的检查,你可以限制最大的圈复杂度,当圈复杂度大于某个值的时候,就会报错。
|
||||
|
||||
只要我们能够消除嵌套,消除 else,代码的圈复杂度就不会很高,理解和维护的成本自然也就会随之降低。
|
||||
|
||||
重复的 Switch
|
||||
|
||||
通过前面内容的介绍,你会发现,循环和选择语句这些你最熟悉的东西,其实都是坏味道出现的高风险地带,必须小心翼翼地使用它们。接下来,还有一个你从编程之初就熟悉的东西,也是另一个坏味道的高风险地带。我们来看两段代码:
|
||||
|
||||
public double getBookPrice(final User user, final Book book) {
|
||||
|
||||
double price = book.getPrice();
|
||||
|
||||
switch (user.getLevel()) {
|
||||
case UserLevel.SILVER:
|
||||
return price * 0.9;
|
||||
case UserLevel.GOLD:
|
||||
return price * 0.8;
|
||||
case UserLevel.PLATINUM:
|
||||
return price * 0.75;
|
||||
default:
|
||||
return price;
|
||||
}
|
||||
}
|
||||
|
||||
public double getEpubPrice(final User user, final Epub epub) {
|
||||
|
||||
double price = epub.getPrice();
|
||||
|
||||
switch (user.getLevel()) {
|
||||
case UserLevel.SILVER:
|
||||
return price * 0.95;
|
||||
case UserLevel.GOLD:
|
||||
return price * 0.85;
|
||||
case UserLevel.PLATINUM:
|
||||
return price * 0.8;
|
||||
default:
|
||||
return price;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这两段代码,分别计算了用户在网站上购买作品在线阅读所支付的价格,以及购买 EPUB 格式电子书所支付的价格。其中,用户实际支付的价格会根据用户在系统中的用户级别有所差异,级别越高,折扣就越高。
|
||||
|
||||
显然,这两个函数里出现了类似的代码,其中最类似的部分就是 switch,都是根据用户级别进行判断。事实上,这并不是仅有的根据用户级别进行判断的代码,各种需要区分用户级别的场景中都有类似的代码,而这也是一种典型的坏味道:重复的 switch(Repeated Switch)。
|
||||
|
||||
之所以会出现重复的 switch,通常都是缺少了一个模型。所以,应对这种坏味道,重构的手法是:以多态取代条件表达式(Relace Conditional with Polymorphism)。具体到这里的代码,我们可以引入一个 UserLevel 的模型,将 switch 消除掉:
|
||||
|
||||
interface UserLevel {
|
||||
|
||||
double getBookPrice(Book book);
|
||||
|
||||
double getEpubPrice(Epub epub);
|
||||
|
||||
}
|
||||
|
||||
class RegularUserLevel implements UserLevel {
|
||||
|
||||
public double getBookPrice(final Book book) {
|
||||
return book.getPrice();
|
||||
}
|
||||
|
||||
public double getEpubPrice(final Epub epub) {
|
||||
return epub.getPrice();
|
||||
}
|
||||
|
||||
class GoldUserLevel implements UserLevel {
|
||||
|
||||
public double getBookPrice(final Book book) {
|
||||
return book.getPrice() * 0.8;
|
||||
}
|
||||
|
||||
public double getEpubPrice(final Epub epub) {
|
||||
return epub.getPrice() * 0.85;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SilverUserLevel implements UserLevel {
|
||||
|
||||
public double getBookPrice(final Book book) {
|
||||
return book.getPrice() * 0.9;
|
||||
}
|
||||
|
||||
public double getEpubPrice(final Epub epub) {
|
||||
return epub.getPrice() * 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
class PlatinumUserLevel implements UserLevel {
|
||||
|
||||
public double getBookPrice(final Book book) {
|
||||
return book.getPrice() * 0.75;
|
||||
}
|
||||
|
||||
public double getEpubPrice(final Epub epub) {
|
||||
return epub.getPrice() * 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
有了这个基础,前面的代码就可以把 switch 去掉了:
|
||||
|
||||
public double getBookPrice(final User user, final Book book) {
|
||||
UserLevel level = user.getUserLevel()
|
||||
return level.getBookPrice(book);
|
||||
}
|
||||
|
||||
public double getEpubPrice(final User user, final Epub epub) {
|
||||
UserLevel level = user.getUserLevel()
|
||||
return level.getEpubPrice(epub);
|
||||
}
|
||||
|
||||
|
||||
我在《软件设计之美》讲[开放封闭原则]的时候,用的例子和这段代码是类似的,里面也有调整的过程,你有兴趣的话,不妨去看一下。只不过,在那个例子里面,我们看到的是一连串的“ if..else”。我们都知道,switch 其实就是一堆“ if..else” 的简化写法,二者是等价的,所以,这个重构手法,以多态取代的是条件表达式,而不仅仅是取代 switch。
|
||||
|
||||
其实,关于控制语句还有一个坏味道,那就是循环语句。没错,循环本身就是一个坏味道,但讲解它还需要一些知识的铺垫,所以,我会把它放到后面第 13 节,讲“落后的代码风格”时再来讲解。这里,你只要知道循环语句也是一个坏味道就够了。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了程序员们最熟悉的控制语句:选择语句和循环语句。遗憾的是,这些语句今天都成了坏味道的高发地带,以各种形态呈现在我们面前:
|
||||
|
||||
嵌套的代码;
|
||||
|
||||
else 语句;
|
||||
|
||||
重复的 switch;
|
||||
|
||||
循环语句。
|
||||
|
||||
嵌套的代码也好,else 语句也罢,二者真正的问题在于,它们会使代码变得复杂,超出人脑所能理解的范畴。我们可以通过提取单个元素操作,降低循环语句的复杂度,而用卫语句来简化条件表达式的编写,降低选择语句的复杂度。一个衡量代码复杂度的标准是圈复杂度,我们可以通过工具检查一段代码的圈复杂度。
|
||||
|
||||
重复的 switch 本质上是缺少了一个模型,可以使用多态取代条件表达式,引入缺少的模型,消除重复的 switch。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:循环和选择语句,可能都是坏味道。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
今天讨论的坏味道挑战了很多人习以为常的编码方式,我想请你谈谈你和这些语句的故事,是不舍也好,是纠结也罢,欢迎在留言区分享你的看法。
|
||||
|
||||
如果这节课的内容确实颠覆了你的认知,也欢迎你把它分享出去,让更多人知道。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
181
专栏/代码之丑/08缺乏封装:如何应对火车代码和基本类型偏执问题?.md
Normal file
181
专栏/代码之丑/08缺乏封装:如何应对火车代码和基本类型偏执问题?.md
Normal file
@ -0,0 +1,181 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
08 缺乏封装:如何应对火车代码和基本类型偏执问题?
|
||||
你好,我是郑晔。
|
||||
|
||||
上一讲,我们讲的是控制语句体现出的坏味道,它们不是一种坏味道,而是一类坏味道。这一讲,我们再来讲一类代码的坏味道:缺乏封装。
|
||||
|
||||
在程序设计中,一个重要的观念就是封装,将零散的代码封装成一个又一个可复用的模块。任何一个程序员都会认同封装的价值,但是,具体到写代码时,每个人对于封装的理解程度却天差地别,造成的结果就是:写代码的人认为自己提供了封装,但实际上,我们还是看到许多的代码散落在那里。
|
||||
|
||||
这一讲,我们就来看看,那些被封装遗忘的角落。
|
||||
|
||||
火车残骸
|
||||
|
||||
我们先从一段你可能很熟悉的代码开始:
|
||||
|
||||
String name = book.getAuthor().getName();
|
||||
|
||||
|
||||
这段代码表达的是“获得一部作品作者的名字”。作品里有作者信息,想要获得作者的名字,通过“作者”找到“作者姓名”,这就是很多人凭借直觉写出的代码,不过它是有问题的。
|
||||
|
||||
如果你没看出这段代码的问题,说明你可能对封装缺乏理解。
|
||||
|
||||
你可以想一想,如果你想写出上面这段代码,是不是必须得先了解 Book 和 Author 这两个类的实现细节?也就是说,我们必须得知道,作者的姓名是存储在作品的作者字段里的。这时你就要注意了:当你必须得先了解一个类的细节,才能写出代码时,这只能说明一件事,这个封装是失败的。
|
||||
|
||||
这段代码只是用来说明这种类型坏味道是什么样的,在实际工作中,这种在一行代码中有连续多个方法调用的情况屡见不鲜,数量上总会不断突破你的认知。
|
||||
|
||||
Martin Fowler 在《重构》中给这种坏味道起的名字叫过长的消息链(Message Chains),而有人则给它起了一个更为夸张的名字:火车残骸(Train Wreck),形容这样的代码像火车残骸一般,断得一节一节的。
|
||||
|
||||
解决这种代码的重构手法叫隐藏委托关系(Hide Delegate),说得更直白一些就是,把这种调用封装起来:
|
||||
|
||||
class Book {
|
||||
|
||||
...
|
||||
|
||||
public String getAuthorName() {
|
||||
return this.author.getName();
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
String name = book.getAuthorName();
|
||||
|
||||
|
||||
前面我说过,火车残骸这种坏味道的产生是缺乏对于封装的理解,因为封装这件事并不是很多程序员编码习惯的一部分,他们对封装的理解停留在数据结构加算法的层面上。
|
||||
|
||||
在学习数据结构时,我们所编写的代码都是拿到各种细节直接操作,但那是在做编程练习,并不是工程上的编码方式。遗憾的是,很多人把这种编码习惯带到了工作中。
|
||||
|
||||
比如说,有人编写一个新的类,第一步是写出这个类要用到的字段,然后,就是给这些字段生成相应的 getter,也就是各种 getXXX。很多语言或框架提供的约定就是基于这种 getter 的,就像 Java 里的 JavaBean,所以相应的配套工具也很方便。现在写出一个 getter 往往是 IDE 中一个快捷键的操作,甚至不需要自己手工敲代码。
|
||||
|
||||
诸如此类种种因素叠加,让暴露细节这种事越来越容易,封装反而成了稀缺品。
|
||||
|
||||
要想摆脱初级程序员的水平,就要先从少暴露细节开始。声明完一个类的字段之后,请停下生成 getter 的手,转而让大脑开始工作,思考这个类应该提供的行为。
|
||||
|
||||
每个单元对其它单元只拥有有限的知识,而且这些单元是与当前单元有紧密联系的;
|
||||
|
||||
每个单元只能与其朋友交谈,不与陌生人交谈;
|
||||
|
||||
只与自己最直接的朋友交谈。
|
||||
|
||||
这个原则需要我们思考,哪些算是直接的朋友,哪些算是陌生人。火车残骸般的代码显然就是没有考虑这些问题而直接写出来的代码。
|
||||
|
||||
或许你会说,按照迪米特法则这样写代码,会不会让代码里有太多简单封装的方法?
|
||||
|
||||
确实有可能,不过,这也是单独解决这一个坏味道可能带来的结果。正如我前面所说,这种代码的出现,根本的问题是缺乏对封装的理解,而一个好的封装是需要基于行为的,所以,如果把视角再提升一个角度,我们应该考虑的问题是类应该提供哪些行为,而非简简单单地把数据换一种形式呈现出来。
|
||||
|
||||
最后,还有一个问题我要提醒你一下。有些内部 DSL 的表现形式也是连续的方法调用,但 DSL 是声明性的,是在说做什么(What),而这里的坏味道是在说怎么做(How),二者的抽象级别是不同的,不要混在一起。
|
||||
|
||||
基本类型偏执
|
||||
|
||||
我们再来看一段代码:
|
||||
|
||||
public double getEpubPrice(final boolean highQuality, final int chapterSequence) {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这是我们上一讲用过的一个函数声明,根据章节信息获取 EPUB(一种电子书的格式) 的价格。也许你会问,这是一个看上去非常清晰的代码,难道这里也有坏味道吗?
|
||||
|
||||
没错,有。问题就出在返回值的类型上,也就是价格的类型上。
|
||||
|
||||
那么,我们在数据库中存储价格的时候,就是用一个浮点数,这里用 double 可以保证计算的精度,这样的设计有什么问题吗?
|
||||
|
||||
确实,这就是很多人使用基本类型(Primitive)作为变量类型思考的角度。但实际上,这种采用基本类型的设计缺少了一个模型。
|
||||
|
||||
虽然价格本身是用浮点数在存储,但价格和浮点数本身并不是同一个概念,有着不同的行为需求。比如,一般情况下,我们要求商品价格是大于 0 的,但 double 类型本身是没有这种限制的。
|
||||
|
||||
就以“价格大于 0”这个需求为例,如果使用 double 类型你会怎么限制呢?我们通常会这样写:
|
||||
|
||||
if (price <= 0) {
|
||||
throw new IllegalArgumentException("Price should be positive");
|
||||
}
|
||||
|
||||
|
||||
问题是,如果使用 double 作为类型,那我们要在使用的地方都保证价格的正确性,像这样的价格校验就应该是使用的地方到处写的。
|
||||
|
||||
如果补齐这里缺失的模型,我们可以引入一个 Price 类型,这样的校验就可以放在初始化时进行:
|
||||
|
||||
class Price {
|
||||
|
||||
private long price;
|
||||
|
||||
public Price(final double price) {
|
||||
if (price <= 0) {
|
||||
throw new IllegalArgumentException("Price should be positive");
|
||||
}
|
||||
this.price = price;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这种引入一个模型封装基本类型的重构手法,叫做以对象取代基本类型(Replace Primitive with Object)。一旦有了这个模型,我们还可以再进一步,比如,如果我们想要让价格在对外呈现时只有两位,在没有 Price 类的时候,这样的逻辑就会散落代码的各处,事实上,代码里很多重复的逻辑就是这样产生的。而现在我们可以在 Price 类里提供一个方法:
|
||||
|
||||
public double getDisplayPrice() {
|
||||
BigDecimal decimal = new BigDecimal(this.price);
|
||||
return decimal.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue();
|
||||
|
||||
}
|
||||
|
||||
|
||||
其实,使用基本类型和使用继承出现的问题是异曲同工的。大部分程序员都学过这样一个设计原则:组合优于继承,也就是说,我们不要写出这样的代码:
|
||||
|
||||
public Books extends List<Book> {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
而应该写成组合的样子,也就是:
|
||||
|
||||
public Books {
|
||||
|
||||
private List<Book> books;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
之所以有人把 Books 写成了继承,因为在代码作者眼中,Books 就是一个书的集合;而有人用 double 做价格的类型,因为在他看来,价格就是一个 double。这里的误区就在于,一些程序员只看到了模型的相同之处,却忽略了差异的地方。Books 可能不需要提供 List 的所有方法,价格的取值范围与 double 也有所差异。
|
||||
|
||||
但是,Books 的问题相对来说容易规避,因为产生了一个新的模型,有通用的设计原则帮助我们判断这个模型构建得是否恰当,而价格的问题却不容易规避,因为这里没有产生新的模型,也就不容易发现这里潜藏着问题。
|
||||
|
||||
这种以基本类型为模型的坏味道称为基本类型偏执(Primitive Obsession)。这里说的基本类型,不限于程序设计语言提供的各种基本类型,像字符串也是一个产生这种坏味道的地方。
|
||||
|
||||
这里我稍微延伸一下,有很多人对于集合类型(比如数组、List、Map 等等)的使用也属于这种坏味道。之前课程里我提到过“对象健身操(出自《ThoughtWorks 文集》)”这篇文章,里面有两个与此相关的条款,你可以作为参考:
|
||||
|
||||
这一讲我们讲到的坏味道都是关于封装的。不过,正如我在开头所说,封装是一个人人都懂的道理,但具体到代码上,就千差万别了。
|
||||
|
||||
封装之所以有难度,主要在于它是一个构建模型的过程,而很多程序员写程序,只是用着极其粗粒度的理解写着完成功能的代码,根本没有构建模型的意识;还有一些人以为划分了模块就叫封装,所以,我们才会看到这些坏味道的滋生。
|
||||
|
||||
这里我给出的坏味道,其实也是在挑战一些人对于编程的认知:那些习以为常的代码居然成了坏味道。而这只是一个信号,一个起点,告诉你这段代码存在问题,但真正要写好代码,还是需要你对软件设计有着深入的学习。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲,我们讨论的是与封装有关的坏味道:
|
||||
|
||||
火车残骸的代码就是连续的函数调用,它反映的问题就是把实现细节暴露了出去,缺乏应有的封装。重构的手法是隐藏委托关系,实际就是做封装。软件行业有一个编程指导原则,叫迪米特法则,可以作为日常工作的指导,规避这种坏味道的出现。
|
||||
|
||||
基本类型偏执就是用各种基本类型作为模型到处传递,这种情况下通常是缺少了一个模型。解决它,常用的重构手法是以对象取代基本类型,也就是提供一个模型代替原来的基本类型。基本类型偏执不局限于程序设计语言提供的基本类型,字符串也是这种坏味道产生的重要原因,再延伸一点,集合类型也是。
|
||||
|
||||
这两种与封装有关的坏味道,背后体现的是对构建模型了解不足,其实,也是很多程序员在软件设计上的欠缺。想成为一个更好的程序员,学习软件设计是不可或缺的。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:构建模型,封装散落的代码。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
这一讲提到的坏味道可以说是在代码里随处可见,也挑战了很多人的编程习惯。我想请你结合实际的工作,谈谈你对这一讲内容的理解,欢迎在留言区分享你的看法。
|
||||
|
||||
|
||||
|
||||
|
178
专栏/代码之丑/09可变的数据:不要让你的代码“失控”.md
Normal file
178
专栏/代码之丑/09可变的数据:不要让你的代码“失控”.md
Normal file
@ -0,0 +1,178 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
09 可变的数据:不要让你的代码“失控”
|
||||
你好,我是郑晔。
|
||||
|
||||
最近几讲,我们讨论的坏味道挑战了很多人的编程习惯,明明很习惯的编码方式,如今却成了坏味道。这一讲,我们再来说一类这样的坏味道:可变的数据。
|
||||
|
||||
对于程序,最朴素的一种认知是“程序 = 数据结构 + 算法”,所以,数据几乎是软件开发最核心的一个组成部分。在一些人的认知中,所谓做软件,就是一系列的 CRUD 操作,也就是对数据进行增删改查。再具体一点,写代码就把各种数据拿来,然后改来改去。我们学习编程时,首先学会的,也是给变量赋值,写出类似 a = b + 1之类的代码。
|
||||
|
||||
改数据,几乎已经成了很多程序员写代码的标准做法。然而,这种做法也带来了很多的问题。这一讲,我们还是从一段问题代码开始。
|
||||
|
||||
满天飞的 Setter
|
||||
|
||||
还记得我们在[开篇词]里提到过的一个坏味道吗?我们复习一下:
|
||||
|
||||
public void approve(final long bookId) {
|
||||
...
|
||||
book.setReviewStatus(ReviewStatus.APPROVED);
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
这是一段对作品进行审核的代码,通过 bookId,找到对应的作品,接下来,将审核状态设置成了审核通过。
|
||||
|
||||
我当时之所以注意到这段代码,就是因为这里用了 setter。setter 往往是缺乏封装的一种做法。对于缺乏封装的坏味道,我们上节课已经用了一讲的篇幅在说,我提到,很多人在写代码时,写完字段就会利用 IDE 生成 getter,实际情况往往是,生成 getter 的同时,setter 也生成了出来。setter 同 getter 一样,反映的都是对细节的暴露。
|
||||
|
||||
这就意味着,你不仅可以读到一个对象的数据,还可以修改一个对象的数据。相比于读数据,修改是一个更危险的操作。
|
||||
|
||||
我在《[软件设计之美]》专栏里讲函数式编程的不变性时,曾经专门讨论过可变的数据会带来许多问题,简言之,你不知道数据会在哪里被何人以什么方式修改,造成的结果是,别人的修改会让你的代码崩溃。与之相伴的还有各种衍生出来的问题,最常见的就是我们常说的并发问题。
|
||||
|
||||
可变的数据是可怕,但是,比可变的数据更可怕的是,不可控的变化,而暴露 setter 就是这种不可控的变化。把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以,这种修改完全是不可控的。
|
||||
|
||||
缺乏封装再加上不可控的变化,在我个人心目中,setter 几乎是排名第一的坏味道。
|
||||
|
||||
在开篇词里,我们针对代码给出的调整方案是,用一个函数替代了 setter,也就是把它用行为封装了起来:
|
||||
|
||||
public void approve(final long bookId) {
|
||||
|
||||
...
|
||||
book.approve();
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
通过在 Book 类里引入了一个 approve 函数,我们将审核状态封装了起来。
|
||||
|
||||
class Book {
|
||||
|
||||
public void approve() {
|
||||
this.reviewStatus = ReviewStatus.APPROVED;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
作为这个类的使用者,你并不需要知道这个类到底是怎么实现的。更重要的是,这里的变化变得可控了。虽然审核状态这个字段还是会修改,但你所有的修改都要通过几个函数作为入口。有任何业务上的调整,都会发生在类的内部,只要保证接口行为不变,就不会影响到其它的代码。
|
||||
|
||||
setter 破坏了封装,相信你对这点已经有了一定的理解。不过,有时候你会说,我这个 setter 只是用在初始化过程中,而并不需要在使用的过程去调用,就像下面这样:
|
||||
|
||||
Book book = new Book();
|
||||
|
||||
book.setBookId(bookId);
|
||||
book.setTitle(title);
|
||||
book.setIntroduction(introduction);
|
||||
|
||||
|
||||
实际上,对于这种只在初始化中使用的代码,压根没有必要以 setter 的形式存在,真正需要的是一个有参数的构造函数:
|
||||
|
||||
Book book = new Book(bookId, title, introduction);
|
||||
|
||||
|
||||
消除 setter ,有一种专门的重构手法,叫做移除设值函数(Remove Setting Method)。总而言之,setter 是完全没有必要存在的。
|
||||
|
||||
在今天的软件开发中,人们为了简化代码的编写做出了各种努力,用 IDE 生成的代码是一种,还有一种常见的做法就是,通过工具和框架生成相应代码的。在 Java 世界中,Lombok 就是这样的一种程序库,它可以在编译的过程中生成相应的代码,而我们需要做的,只是在代码上加上对应的 Annotation。它最大的优点是不碍眼,也就是不会产生大量可以看见的代码。因为它的代码是在编译阶段生成的,所以,那些生成的代码在源码级别上是不存在的。下面就是一个例子:
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
class Book {
|
||||
|
||||
private BookId bookId;
|
||||
|
||||
private String title;
|
||||
|
||||
private String introduction;
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里的 @Getter 表示为这个类的字段生成 getter,相应地,@Setter 表示生成 setter。也是因为这些 Annotation 的存在,让代码看上去清爽了不少。所以,像 Lombok 这样的程序库赢得了许多人的喜爱。
|
||||
|
||||
不过,我想说的是,不写 setter 的代码并不代表没有 setter。因为 @Setter 的存在,其它代码还是可以调用这个类的 setter,存在的问题并不会改变。所以,一个更好的做法是禁用 @Setter。下面是 lombok.config 的配置,通过它,我们就可以禁用 @Setter 了:
|
||||
|
||||
lombok.setter.flagUsage = error
|
||||
|
||||
lombok.data.flagUsage = error
|
||||
|
||||
你或许注意到了,这里除了 @Setter,我还禁用了 @Data,这是 Lombok 中另外一个 Annotation,表示的是同时生成 getter 和 setter。既然我们禁用 @Setter 是为了防止生成 setter,当然也要禁用 @Data 了。
|
||||
|
||||
可变的数据
|
||||
|
||||
我们反对使用 setter,一个重要的原因就是它暴露了数据,我们前面说过,暴露数据造成的问题就在于数据的修改,进而导致出现难以预料的 Bug。在上面的代码中,我们把 setter 封装成一个个的函数,实际上是把不可控的修改限制在一个有限的范围内。
|
||||
|
||||
那么,这个思路再进一步的话,如果我们的数据压根不让修改,犯下各种低级错误的机会就进一步降低了。没错,在这种思路下,可变数据(Mutable Data)就成了一种坏味道,这是 Martin Fowler 在新版《重构》里增加的坏味道,它反映着整个行业对于编程的新理解。
|
||||
|
||||
这种想法源自函数式编程这种编程范式。在函数式编程中,数据是建立在不改变的基础上的,如果需要更新,就产生一份新的数据副本,而旧有的数据保持不变。随着函数式编程在软件开发领域中的地位不断提高,人们对于不变性的理解也越发深刻,不变性有效地解决了可变数据产生的各种问题。
|
||||
|
||||
所以,Martin Fowler 在《重构》第二版里新增了可变数据作为一种坏味道,这其实反映了行业的理解也是在逐渐推进的。不过,Martin Fowler 对于可变数据给出的解决方案,基本上是限制对于数据的更新,降低其风险,这与我们前面提到的对 setter 的封装如出一辙。
|
||||
|
||||
解决可变数据,还有一个解决方案是编写不变类。
|
||||
|
||||
我在《[软件设计之美]》专栏中已经讲过函数式编程的不变性,其中的关键点就是设计不变类。Java 中的 String 类就是一个不变类,比如,如果我们把字符串中的一个字符替换成另一个字符,String 类给出的函数签名是这样的:
|
||||
|
||||
String replace(char oldChar, char newChar);
|
||||
|
||||
|
||||
其含义是,这里的替换并不是在原有字符串上进行修改,而是产生了一个新的字符串。
|
||||
|
||||
那么,在实际工作中,我们怎么设计不变类呢?要做到以下三点:
|
||||
|
||||
所有的字段只在构造函数中初始化;
|
||||
|
||||
所有的方法都是纯函数;
|
||||
|
||||
如果需要有改变,返回一个新的对象,而不是修改已有字段。
|
||||
|
||||
回过头来看我们之前改动的“用构造函数消除 setter”的代码,其实就是朝着这个方向在迈进。如果按照这个思路改造我们前面提到的 approve 函数,同样也可以:
|
||||
|
||||
class Book {
|
||||
|
||||
public Book approve() {
|
||||
return new Book(..., ReviewStatus.APPROVED, ...);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这里,我们创建出了一个“其它参数和原有 book 对象一模一样,只是审核状态变成了 APPROVED ”的对象。
|
||||
|
||||
在 JDK 的演化中,我们可以看到一个很明显的趋势,新增的类越来越多地采用了不变类的设计,比如,用来表示时间的类。原来的 Date 类里面还有各种 setter,而新增的 LocalDateTime 则一旦初始化就不会再修改了。如果要操作这个对象,则会产生一个新的对象:
|
||||
|
||||
LocalDateTime twoDaysLater = now.plusDays(2);
|
||||
|
||||
|
||||
就目前的开发状态而言,想要完全消除可变数据是很难做到的,但我们可以尽可能地编写一些不变类。
|
||||
|
||||
一个更实用的做法是,区分类的性质。我《[软件设计之美]》中讲 DDD 的战术设计时提到过,我们最核心要识别的对象分成两种,实体和值对象。实体对象要限制数据变化,而值对象就要设计成不变类。
|
||||
|
||||
如果你还想进一步提升自己对于不变性的理解,我们可以回到函数式编程这个编程范式的本质,它其实是对程序中的赋值进行了约束。基于这样的理解,连赋值本身其实都会被归入到坏味道的提示,这才是真正挑战很多人编程习惯的一点。
|
||||
|
||||
不过,我们现在看到,越来越多的语言中开始引入值类型,也就是初始化之后便不再改变的值,比如,Java 的 Valhalla 项目,更有甚者,像 Rust 这样的语言中,缺省都是值类型,而如果你需要一个可以赋值的变量,反而要去专门的声明。
|
||||
|
||||
Martin Fowler 在《重构》中还提到一个与数据相关的坏味道:全局数据(Global Data)。如果你能够理解可变数据是一种坏味道,全局数据也就很容易理解了,它们处理手法基本上是类似的,这里我就不再做过多的阐述了。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们又讲了一类与很多人编程习惯不符的坏味道:可变的数据。
|
||||
|
||||
可变数据最直白的体现就是各种 setter。setter 一方面破坏了封装,另一方面它会带来不可控的修改,给代码增添许多问题。解决它的一种方式就是移除设值函数(Remove Setting Method),将变化限制在一定的范围之内。
|
||||
|
||||
可变数据是《重构》第二版新增的坏味道,这其实反映了软件开发行业的一种进步,它背后的思想是函数式编程所体现的不变性。解决可变数据,一种方式是限制其变化,另一种方式是编写不变类。
|
||||
|
||||
在实践中,完全消除可变数据是很有挑战的。所以,一个实际的做法是,区分类的性质。值对象就要设计成不变类,实体类则要限制数据变化。
|
||||
|
||||
函数式编程的本质是对于赋值进行了约束,我们甚至可以把赋值作为一种坏味道的提示。很多编程语言都引入了值类型,而让变量成为次优选项。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:限制可变的数据。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
这一讲我们讲了可变的数据,你在实际工作遇到过因为数据变动而产生的问题吗?或者你设计过不变类吗?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
210
专栏/代码之丑/10变量声明与赋值分离:普通的变量声明,怎么也有坏味道?.md
Normal file
210
专栏/代码之丑/10变量声明与赋值分离:普通的变量声明,怎么也有坏味道?.md
Normal file
@ -0,0 +1,210 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
10 变量声明与赋值分离:普通的变量声明,怎么也有坏味道?
|
||||
你好,我是郑晔。
|
||||
|
||||
我们已经用连续几讲的篇幅在挑战很多人固有的编程习惯了,从各种控制语句,到 getter 和 setter,甚至连直接使用基本类型都已经成了坏味道,这一讲,我们再来挑战一个很多人习以为常的编程习惯:变量的声明与赋值。
|
||||
|
||||
我估计有人已经露出了惊讶的表情。你可能会想:要说前面几种坏味道可能确实是编码没有做好,该封装没封装,该返回没返回,一个变量声明怎么还会有坏味道啊?难道是变量声明都不让用了吗?
|
||||
|
||||
诚然,变量声明是写程序不可或缺的一部分,我并不打算让你戒掉变量声明,严格地说,我们是要把变量初始化这件事做好。
|
||||
|
||||
变量的初始化
|
||||
|
||||
我们先来看一段代码:
|
||||
|
||||
EpubStatus status = null;
|
||||
CreateEpubResponse response = createEpub(request);
|
||||
|
||||
if (response.getCode() == 201) {
|
||||
status = EpubStatus.CREATED;
|
||||
} else {
|
||||
status = EpubStatus.TO_CREATE;
|
||||
}
|
||||
|
||||
|
||||
这段代码在做的事情是向另外一个服务发请求创建 EPUB(一种电子书格式),如果创建成功,返回值是 HTTP 的 201,也就表示创建成功,然后就把状态置为 CREATED;而如果没有成功,则把状态置为 TO_CREATE。后面对于 TO_CREATE 状态的作品,还需要再次尝试创建。
|
||||
|
||||
这里,我们暂且把是否要写 else 放下,这是我们在前面已经讨论过的一个坏味道。
|
||||
|
||||
我们这次的重点在 status 这个变量上,虽然 status 这个变量在声明的时候,就赋上了一个 null 值,但实际上,这个值并没有起到任何作用,因为 status 的变量值,其实是在经过后续处理之后,才有了真正的值。换言之,从语义上说,第一行的变量初始化其实是没有用的,这是一次假的初始化。
|
||||
|
||||
按照我们通常的理解,一个变量的初始化是分成了声明和赋值两个部分,而我这里要说的就是,变量初始化最好一次性完成。这段代码里的变量赋值是在声明很久之后才完成的,也就是说,变量初始化没有一次性完成。
|
||||
|
||||
这种代码真正的问题就是不清晰,变量初始化与业务处理混在在一起。通常来说,这种代码后面紧接着就是一大堆更复杂的业务处理。当代码混在一起的时候,我们必须小心翼翼地从一堆业务逻辑里抽丝剥茧,才能把逻辑理清,知道变量到底是怎么初始化的。很多代码难读,一个重要的原因就是把不同层面的代码混在了一起。
|
||||
|
||||
这种代码在实际的代码库中出现的频率非常高,只不过,它会以各种变形的方式呈现出来。有的变量甚至是在相隔很远的地方才做了真正的赋值,完成了初始化,这中间已经夹杂了很多的业务代码在其中,进一步增加了理解的复杂度。
|
||||
|
||||
所以,我们编程时要有一个基本原则:变量一次性完成初始化。
|
||||
|
||||
有了这个理解,我们可以这样来修改上面这段代码:
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
在这段改进的代码中,我们提取出了一个函数,将 response 转成对应的内部的 EPUB 状态。
|
||||
|
||||
其实,很多人之所以这样写代码,一个重要的原因是很多人的编程习惯是从 C 语言来的。C 语言在早期的版本中,一个函数用到的变量必须在整个函数的一开始就声明出来。
|
||||
|
||||
在 C 语言诞生的年代,当时计算机能力有限内存小,编译器技术也处于刚刚起步的阶段,把变量放在前面声明出来,有助于减小编译器编写的难度。到了 C++ 产生的年代,这个限制就逐步放开了,所以,C++ 程序是支持变量随用随声明的。对于今天的大多数程序设计语言来说,这个限制早就不存在了,但很多人的编程习惯却留在了那个古老的年代。
|
||||
|
||||
还有一点不知道你注意到了没有,在新的变量声明中,我加上了 final,在 Java 的语义中,一个变量加上了 final,也就意味着这个变量不能再次赋值。对,我们需要的正是这样的限制。
|
||||
|
||||
上一讲,我们讲了可变的数据会带来怎样的影响,其中的一个结论是,尽可能编写不变的代码。这里其实是这个话题的延伸,尽可能使用不变的量。
|
||||
|
||||
如果我们能够按照使用场景做一个区分,把变量初始化与业务处理分开,你会发现,在很多情况下,变量只在初始化完成之后赋值,就足以满足我们的需求了,在一段代码中,需要使用可变量的场景并不多。
|
||||
|
||||
这个原则其实可以推广一下,在能够使用 final 的地方尽量使用 final,限制变量的赋值。
|
||||
|
||||
这里说的“能够使用”,不仅包括普通的变量声明,还包含参数声明,还有类字段的声明,甚至还可以包括类和方法的声明。当然,我们这里改进的考量主要还是在变量上。你可以尝试着调整自己现有的代码,给变量声明都加上 final,你就会发现许多值得改进的代码。
|
||||
|
||||
对于 Java 程序员来说,还有一个特殊的场景,就是异常处理的场景,强迫你把变量的声明与初始化分开,就像下面这段代码:
|
||||
|
||||
InputStream is = null;
|
||||
|
||||
try {
|
||||
is = new FileInputStream(...);
|
||||
...
|
||||
} catch (IOException e) {
|
||||
...
|
||||
} finally {
|
||||
if (is != null) {
|
||||
is.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
之所以要把 InputStream 变量 is 单独声明,是为了能够在 finanlly 块里面访问到。其实,这段代码写成这样,一个重要的原因是 Java 早期的版本只能写成这样,而如果采用 Java 7 之后的版本,采用 try-with-resource 的写法,代码就可以更简洁了:
|
||||
|
||||
try (InputStream is = new FileInputStream(...)) {
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
这样一来,InputStream 变量的初始化就一次性完成了,我们的原则就统一了,不需要在这种特殊的场景下纠结了。
|
||||
|
||||
集合初始化
|
||||
|
||||
接下来,我们在来看一段代码:
|
||||
|
||||
List<Permission> permissions = new ArrayList<>();
|
||||
|
||||
permissions.add(Permission.BOOK_READ);
|
||||
permissions.add(Permission.BOOK_WRITE);
|
||||
|
||||
check.grantTo(Role.AUTHOR, permissions);
|
||||
|
||||
|
||||
这是一段给作者赋予作品读写权限的代码,逻辑比较简单,但这段代码中也存在一些坏味道。我们把注意力放在 permissions 这个集合上。之所以要声明这样一个 List,是因为 grantTo 方法要用到一个 List 作为参数。
|
||||
|
||||
我们来看这个 List 是怎样生成的。这里先给 permission 初始化成了一个 ArrayList,这个时候,permissions 虽然存在了,但我们并不会把它传给 grantTo 方法,它还不能直接使用,因为它还缺少必要的信息。然后,我们将 BOOK_READ 和 BOOK_WRITE 两个枚举对象添加了进去,这样,这个 permissions 对象才是我们真正需要的那个对象。
|
||||
|
||||
这种代码是非常常见的,声明一个集合,然后,调用一堆添加的方法,将所需的对象添加进去。
|
||||
|
||||
我们不难发现,其实 permissions 对象一开始的变量声明,并没有完成这个集合真正的初始化,只有当集合所需的对象添加完毕之后,这个集合才是它应有的样子。换言之,只有添加了元素的集合才是我们需要的。
|
||||
|
||||
这样解释这段代码,你是不是就发现了,这和我们前面所说的变量先声明后赋值,本质上是一回事,都是从一个变量的声明到初始化成一个可用的状态,中间隔了太远的距离。
|
||||
|
||||
之所以很多人习惯这么写,一个原因就是在早期的 Java 版本中,没有提供很好的集合初始化的方法。像这种代码,也是很多动态语言的支持者调侃 Java 啰嗦的一个靶子。
|
||||
|
||||
现如今,Java 在这方面早已经改进了许多,各种程序库已经提供了一步到位的写法,我们先来看看 Java 9 之后的写法:
|
||||
|
||||
List<Permission> permissions = List.of(
|
||||
|
||||
Permission.BOOK_READ,
|
||||
|
||||
Permission.BOOK_WRITE
|
||||
|
||||
);
|
||||
|
||||
|
||||
check.grantTo(Role.AUTHOR, permissions);
|
||||
|
||||
如果你的项目还没有升级 Java 9 之后的版本,使用 Guava(Google 提供的一个 Java 库)也是可以做成类似的效果:
|
||||
|
||||
List<Permission> permissions = ImmutableList.of(
|
||||
|
||||
Permission.BOOK_READ,
|
||||
|
||||
Permission.BOOK_WRITE
|
||||
|
||||
);
|
||||
|
||||
check.grantTo(Role.AUTHOR, permissions);
|
||||
|
||||
|
||||
经过改进,这段代码是不是看上去就清爽多了!
|
||||
|
||||
不知道你注意到没有,第二段代码里的 List 用的是一个 ImmutableList,也就是一个不可变的 List,实际上,你查看第一段代码的实现就会发现,它也是一个不变的 List。这是什么意思呢?也就是说,这个 List 一旦创建好了,就是不能修改了,对应的实现就是各种添加、删除之类的方法全部都禁用了。
|
||||
|
||||
初看起来,这是限制了我们的能力,但我们对比一下代码就不难发现,很多时候,我们对于一个集合的使用,除了声明时添加元素之外,后续就只是把它当作一个只读的集合。所以,在很多情况下,一个不变集合对我们来说就够用了。
|
||||
|
||||
其实,这段代码,相对来说还是比较清晰的,稍微再复杂一些的,集合的声明和添加元素之间隔了很远,不注意的话,甚至不觉得它们是在完成一次初始化。
|
||||
|
||||
private static Map<Locale, String> CODE_MAPPING = new HashMap<>();
|
||||
|
||||
...
|
||||
|
||||
static {
|
||||
|
||||
CODE_MAPPING.put(LOCALE.ENGLISH, "EN");
|
||||
CODE_MAPPING.put(LOCALE.CHINESE, "CH");
|
||||
|
||||
}
|
||||
|
||||
|
||||
这是一个传输时的映射方案,将不同的语言版本映射为不同的代码。这里 CODE_MAPPING 是一个类的 static 变量,而这个类的声明里还有其它一些变量。所以,隔了很远之后,才有一个 static 块向这个集合添加元素。
|
||||
|
||||
如果我们能够用一次性声明的方式,这个单独的 static 块就是不需要的:
|
||||
|
||||
private static Map<Locale, String> CODE_MAPPING = ImmutableMap.of(
|
||||
|
||||
LOCALE.ENGLISH, "EN",
|
||||
LOCALE.CHINESE, "CH"
|
||||
|
||||
);
|
||||
|
||||
|
||||
对比我们改造前后的代码,二者之间还有一个更关键的区别:前面的代码是命令式的代码,而后面的代码是声明式的代码。
|
||||
|
||||
命令式的代码,就是告诉你“怎么做”的代码,就像改造前的代码,声明一个集合,然后添加一个元素,再添加一个元素。而声明式的代码,是告诉你“做什么”的代码,改造后就是,我要一个包含了这两个元素的集合。
|
||||
|
||||
我在《软件设计之美》专栏中讲 [DSL] 时,曾经讲过二者的区别,声明式的代码体现的意图,是更高层面的抽象,把意图和实现分开,从某种意义上来说,也是一种分离关注点。
|
||||
|
||||
所以,用声明式的标准来看代码,是一个发现代码坏味道的重要参考。
|
||||
|
||||
回想一下今天讲的坏味道,无论是变量的声明与赋值分离,还是初始化一个集合的分步骤,其实反映的都是不同时代编程风格的烙印。变量的声明是 C 早期的编程风格,异常处理是 Java 早期的风格,而集合声明也体现出不同版本 Java 的影子。
|
||||
|
||||
我们学习编程不仅仅是要学习实现功能,编程的风格也要与时俱进。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们继续挑战着很多人习惯的编程方式,讲了变量初始化带来的问题。变量的初始化包含变量的声明和赋值两个部分,一个编程的原则是“变量要一次性完成初始化”。
|
||||
|
||||
这就衍生出一个坏味道:变量的声明和赋值是分离的。二者分离带来的问题就是,把赋值的过程与业务处理混杂在一起。发现变量声明与赋值分离一个做法就是在声明前面加上 final,用“不变性”约束代码。
|
||||
|
||||
我们还谈到了集合的初始化,传统的集合初始化方式是命令式的,而今天我们完全可以用声明式的方式进行集合的初始化,让初始化的过程一次性完成。再进一步,以声明式的标准来看代码,会帮助我们发现许多的坏味道。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:一次性完成变量的初始化。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
变量初始化可能是一个很多人都没有注意过的坏味道,你可以回去翻翻自己的代码,用这个标准衡量一下,你能发现什么问题吗?欢迎在留言区分享你的发现。
|
||||
|
||||
|
||||
|
||||
|
220
专栏/代码之丑/11依赖混乱:你可能还没发现问题,代码就已经无法挽救了.md
Normal file
220
专栏/代码之丑/11依赖混乱:你可能还没发现问题,代码就已经无法挽救了.md
Normal file
@ -0,0 +1,220 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
11 依赖混乱:你可能还没发现问题,代码就已经无法挽救了
|
||||
你好,我是郑晔。
|
||||
|
||||
我们前面已经讲了许多坏味道,无论是你很容易接受的,还是挑战你编程习惯的,它们都有相对直观的表现形式,属于你很容易一下子就看出来问题的。这一讲,我们要讲的坏味道就不属于一下子就能看出来的,需要你稍微仔细一点看代码才会发现问题,那就是依赖关系。
|
||||
|
||||
我前面在讲“大类”这个坏味道的时候曾经说过,为了避免同时面对所有细节,我们需要把程序进行拆分,分解成一个又一个的小模块。但随之而来的问题就是,我们需要把这些拆分出来的模块按照一定的规则重新组装在一起,这就是依赖的缘起。
|
||||
|
||||
一个模块要依赖另外一个模块完成完整的业务功能,而到底怎么去依赖,这里就很容易产生问题。
|
||||
|
||||
缺少防腐层
|
||||
|
||||
我们还是先来看一段代码:
|
||||
|
||||
@PostMapping("/books")
|
||||
public NewBookResponse createBook(final NewBookRequest request) {
|
||||
boolean result = this.service.createBook(request);
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这段代码是创建一部作品的入口,也就是说,它提供了一个 REST 服务,只要我们对 /books 这个地址发出一个 POST 请求,就可以创建一部作品出来。那么,这段代码有问题吗?
|
||||
|
||||
按照一般代码的分层逻辑,一个 Resource (有的团队称之为 Controller)调用一个 Service,这符合大多数人的编程习惯,所以看起来,这段代码简直是正常得不能再正常了,这能有什么问题?
|
||||
|
||||
从 Resource 调用 Service,这几乎是行业里的标准做法,是没有问题的,但问题出在传递的参数上。请问,这个 NewBookRequest 的参数类应该属于哪一层,是 resource 层,还是 service 层呢?
|
||||
|
||||
一般来说,既然它是一个请求参数,通常要承载着诸如参数校验和对象转换的职责,按照我们通常的理解,它应该属于 resource 层。如果这个理解是正确的,问题就来了,它为什么会传递给 service 层呢?
|
||||
|
||||
按照通常的架构设计原则,service 层属于我们的核心业务,而 resource 层属于接口。二者相较而言,核心业务的重要程度更高一些,所以,它的稳定程度也应该更高一些。同样的业务,我们可以用 REST 的方式对外提供,也可以用 RPC 的方式对外提供。
|
||||
|
||||
说到这,你就会发现一个问题,NewBookRequest 这个本来应该属于接口层的参数,现在成了核心业务的一部分,也就是说,即便将来我们提供了 RPC 的接口,它也要知道 REST 的接口长什么样子,显然,这是有问题的。
|
||||
|
||||
既然 NewBookRequest 属于 resource 层是有问题的,那我们假设它属于 service 层呢?正如我们前面所说,一般请求都要承担对象校验和转化的工作。如果说这个类属于 service 层,但它用在了 resource 的接口上,作为 resource 的接口,它会承载一些校验和对象转换的角色,而 service 层的参数是不需要关心这些的。如果 NewBookRequest 属于 service 层,那校验和对象转换的职责到底由谁来完成呢?
|
||||
|
||||
还有更关键的一点是,有时候 service 层的参数和 resource 层的参数并不是严格地一一对应。比如,创建作品时,我们需要一个识别作者身份的用户 ID,而这个参数并不是通过客户端发起的请求参数带过来,而是根据用户登录信息进行识别的。所以,用 service 层的参数做 resource 层的参数,就存在差异的参数如何处理的问题。
|
||||
|
||||
你有没有发现,我们突然陷入了一种两难的境地,如此一个简单的参数,放到哪个层里都有问题。
|
||||
|
||||
这是一种非常常见的代码,你去翻看自己的代码仓库,也许就能找到类似的代码。不过,很有可能在学习到这一课之前,你根本没有想过这种代码也是有问题的。
|
||||
|
||||
那这个问题该如何解呢?
|
||||
|
||||
其实,之所以我们这么纠结,一个关键点在于,我们缺少了一个模型。
|
||||
|
||||
NewBookRequest 之所以弄得如此“里外不是人”,主要就是因为它只能扮演一个层中的模型,所以,我们只要再引入一个模型就可以破解这个问题。
|
||||
|
||||
class NewBookParameter {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
class NewBookRequest {
|
||||
|
||||
public NewBookParameters toNewBookRequest() {
|
||||
...
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@PostMapping("/books")
|
||||
public NewBookResponse createBook(final NewBookRequest request) {
|
||||
boolean result = this.service.createBook(request.toNewBookParameter());
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
这里我们引入了一个 NewBookParameter 类,把它当作 service 层创建作品的入口,而在 resource 中,我们将 NewBookRequest 这个请求类的对象转换成了 NewBookParameter 对象,然后传到 service 层。
|
||||
|
||||
在这个结构中,NewBookParameter 属于 service 层,而 NewBookRequest 属于 resource 层,二者相互独立,我们之前纠结的问题也就不复存在了。
|
||||
|
||||
好,现在我们理解了,通过增加一个模型,我们就破解了依赖关系上的纠结。
|
||||
|
||||
也许你会说,虽然它们成了两个类,但是,它们两个应该长得一模一样吧。这算不算是一种重复呢?但我的问题是,它们两个为什么要一样呢?有了两层不同的参数,我们就可以给不同层次上的模型以不同的约定了。
|
||||
|
||||
比如,对于 resource 层的请求对象,因为它的主要作用是传输,所以,一般来说,我们约定请求对象的字段主要是基本类型。而 service 的参数对象,因为它已经是核心业务的一部分,就需要全部转化为业务对象。举个例子,比如,同样表示价格,在请求对象中,我们可以是一个 double 类型,而在业务参数对象中,它应该是 Price 类型。
|
||||
|
||||
我们再来解决 resource 层参数和 service 层参数不一致的情况,现在二者分开了,那我们就很清楚地知道,其实,就是在业务参数对象构造的时候,传入必需的参数即可。比如,如果我们需要传入 userId,可以这么做:
|
||||
|
||||
class NewBookRequest {
|
||||
|
||||
public NewBookParameters toNewBookRequest(long userId) {
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/books")
|
||||
public NewBookResponse createBook(final NewBookRequest request, final Authentication authentication) {
|
||||
|
||||
long userId = getUserIdentity(authentication);
|
||||
boolean result = this.service.createBook(request.toNewBookParameter(userId));
|
||||
...
|
||||
}
|
||||
|
||||
|
||||
我们之所以能注意到这个坏味道,就是从依赖关系入手发现的问题。我当初注意到这段代码,因为我团队内部的约定是,所有的请求对象都属于 resource 层,但在这段代码里,service 层出现了 resource 层的对象,它背离了我们对依赖关系设计的约定,所以,这个问题就浮出了水面。
|
||||
|
||||
实际上,这个问题也是一个典型的软件设计问题:缺少防腐层。我在《[10x 程序员工作法]》和《[软件设计之美]》两个专栏都讲到过防腐层的概念,只不过,讲防腐层的时候,我举的例子都是与外部系统集成,其中的观点就是通过防腐层将外部系统和核心业务隔离开来。
|
||||
|
||||
而很多人初见这个例子,可能压根想不到它与防腐层的关系,那只不过是因为你对这种结构太熟悉了。其实,resource 层就是外部请求和核心业务之间的防腐层。只要理解了这一点,你就能理解这里要多构建出一个业务参数对象的意义了。那下面这段代码,想必你也能轻易地发现问题:
|
||||
|
||||
@Entity
|
||||
@Table(name = "user")
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class User {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这是一个 User 类的声明,它有 @Entity 这个 Anntation,表示它是一个业务实体的对象,但它的上面还出现了 @JsonIgnoreProperties,这是就是处理 JSON 的一个 Annotation。JSON 会在哪用到,通常都是在传输中。业务实体和传输对象应该具备的特质在同一个类中出现,显然,这也是没有构建好防腐层的结果,把两个职责混在了一起。
|
||||
|
||||
业务代码里的具体实现
|
||||
|
||||
好,我们再来看一段代码:
|
||||
|
||||
@Task
|
||||
public void sendBook() {
|
||||
|
||||
try {
|
||||
this.service.sendBook();
|
||||
} catch (Throwable t) {
|
||||
this.feishuSender.send(new SendFailure(t)));
|
||||
throw t;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
这是我们在“[重复代码]”那一讲中提到的一个发送作品信息的函数,这里的重点在于,一旦发送过程出了问题,要通过即时通信工具发送给相关人等,以防系统出现问题无人发觉。只不过,这里给出的是它最初的样子,也就是通过飞书进行消息发送。
|
||||
|
||||
因为需求是通过飞书发送,所以,这里就写了飞书发送。这看上去简直是一个合理得不能再合理的做法了。
|
||||
|
||||
但是,请稍等!这是一种符合直觉的做法,然而,它却不符合设计原则,它违反了依赖倒置原则。
|
||||
|
||||
我曾经在《[软件设计之美]》中专门用了一讲的篇幅讲解依赖倒置原则,这里我们简单回顾一下:
|
||||
|
||||
高层模块不应依赖于低层模块,二者应依赖于抽象。
|
||||
|
||||
High-level modules should not depend on low-level modules. Both should depend on abstractions.
|
||||
|
||||
抽象不应依赖于细节,细节应依赖于抽象。
|
||||
|
||||
Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
|
||||
|
||||
我之所以会注意到这段代码,因为在一段业务处理中出现了一个具体的实现,也就是这里的 feishuSender。
|
||||
|
||||
你需要知道,业务代码中任何与业务无关的东西都是潜在的坏味道。
|
||||
|
||||
在这里,飞书肯定不是业务的一部分,它只是当前选择的一个具体实现。换言之,是否选择飞书,与团队当前的状态是相关的,如果哪一天团队切换即时通信软件,这个实现就需要换掉。但是,团队是不可能切换业务的,一旦切换,那就是一个完全不同的系统了。
|
||||
|
||||
识别一个东西是业务的一部分,还是一个可以替换的实现,我们不妨问问自己,如果不用它,是否还有其它的选择?
|
||||
|
||||
就像这里,飞书是可以被其它即时通信软件替换的。另外,常见的中间件,比如,Kafka、Redis、MongoDB 等等,通常也都是一个具体的实现,其它中间件都可以把它替换掉。所以,它们在业务代码里出现,那一定就是一个坏味道了。
|
||||
|
||||
既然我们已经知道了,这些具体的东西是一种坏味道,那该怎么解决呢?你可以引入一个模型,也就是这个具体实现所要扮演的角色,通过它,将业务和具体的实现隔离开来。
|
||||
|
||||
interface FailureSender {
|
||||
|
||||
void send(SendFailure failure);
|
||||
|
||||
}
|
||||
|
||||
class FeishuFailureSenderS implements FailureSender {
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
这里我们通过引入一个 FailureSender,业务层只依赖于这个 FailureSender 的接口就好,而具体的飞书实现可以通过依赖注入的方式注入进去。
|
||||
|
||||
依赖关系是软件开发中非常重要的一个东西,然而,很多程序员在写代码的时候,由于开发习惯的原因,常常会忽略掉依赖关系这件事本身。现在已经有一些工具,可以保证我们在写代码的时候,不会出现严重破坏依赖关系的情况,比如,像前面那种 service 层调用 resource 层的代码。
|
||||
|
||||
在 Java 世界里,我们就可以用 ArchUnit 来保证这一切。看名字就不难发现,它是把这种架构层面的检查做成了单元测试,下面就是这样的一个单元测试:
|
||||
|
||||
@Test
|
||||
public void should_follow_arch_rule() {
|
||||
|
||||
JavaClasses clazz = new ClassFileImporter().importPackages("...");
|
||||
ArchRule rule = layeredArchitecture()
|
||||
.layer("Resource").definedBy("..resource..")
|
||||
.layer("Service").definedBy("..service..")
|
||||
.whereLayer("Resource").mayNotBeAccessedByAnyLayer()
|
||||
.whereLayer("Service").mayOnlyBeAccessedByLayers("Resource");
|
||||
rule.check(clazz);
|
||||
}
|
||||
|
||||
|
||||
在这里,我们定义了两个层,分别是 Resource 层和 Service 层,而且我们要求 Resource 层的代码不能被其它层访问,而 Service 层的代码只能由 Resource 层方法访问。这就是我们的架构规则,一旦代码里有违反这个架构规则的代码,这个测试就会失败,问题也就会暴露出来。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了由于代码依赖关系而产生的坏味道,一种是缺少防腐层,导致不同代码糅合在一起,一种是在业务代码中出现了具体的实现类。
|
||||
|
||||
缺少防腐层,会让请求对象传导到业务代码中,造成了业务与外部接口的耦合,也就是业务依赖了一个外部通信协议。一般来说,业务的稳定性要比外部接口高,这种反向的依赖就会让业务一直无法稳定下来,继而在日后带来更多的问题。解决方案自然就是引入一个防腐层,将业务和接口隔离开来。
|
||||
|
||||
业务代码中出现具体的实现类,实际上是违反了依赖倒置原则。因为违反了依赖倒置原则,业务代码也就不可避免地受到具体实现的影响,也就造成了业务代码的不稳定。识别一段代码是否属于业务,我们不妨问一下,看把它换成其它的东西,是否影响业务。解决这种坏味道就是引入一个模型,将业务与具体的实现隔离开来。
|
||||
|
||||
最后,我们还谈到了有些简单的依赖关系,可以通过工具来进行维护,比如 ArchUnit。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:代码应该向着稳定的方向依赖。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
今天我们讲到了依赖关系,你可以用今天讲到的坏味道衡量一下自己的代码,看有哪些代码是有问题的,欢迎在留言区分享你的发现,也欢迎你把学到的知识分享给你的朋友。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
210
专栏/代码之丑/12不一致的代码:为什么你的代码总被吐槽难懂?.md
Normal file
210
专栏/代码之丑/12不一致的代码:为什么你的代码总被吐槽难懂?.md
Normal file
@ -0,0 +1,210 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
12 不一致的代码:为什么你的代码总被吐槽难懂?
|
||||
你好,我是郑晔。
|
||||
|
||||
上一讲,我们讲了从依赖关系引申出来的坏味道,从代码本身看,这些坏味道并不如之前讲的那些,有非常明显的标识,一眼就能看出问题,但它们都属于问题高发的地带,一不小心就陷入其中,却不知所以。对于这类的问题,我们需要额外打起精神来发现问题。
|
||||
|
||||
今天,我们再来看一类需要你打起精神的坏味道,它们的出发点也是来自同一个根源:一致性。
|
||||
|
||||
大多数程序员都是在一个团队中工作,对于一个团队而言,一致性是非常重要的一件事。因为不一致会造成认知上的负担,在一个系统中,做类似的事情,却有不同的做法,或者起到类似作用的事物,却有不同的名字,这会让人产生困惑。所以,即便是不甚理想的标准,也比百花齐放要好。
|
||||
|
||||
大部分程序员对于一致性本身的重要性是有认知的。但通常来说,大家理解的一致性都表现在比较大的方面,比如,数据库访问是叫 DAO 还是叫 Mapper,抑或是 Repository,在一个团队内,这是有统一标准的,但编码的层面上,要求往往就不是那么细致了。所以,我们才会看到在代码细节上呈现出了各种不一致。我们还是从一段具体的代码来分析问题。
|
||||
|
||||
命名中的不一致
|
||||
|
||||
有一次,我在代码评审中看到了这样一段代码:
|
||||
|
||||
enum DistributionChannel {
|
||||
WEBSITE,
|
||||
KINDLE_ONLY,
|
||||
ALL
|
||||
}
|
||||
|
||||
|
||||
这段代码使用标记作品的分发渠道,从这段代码的内容上,我们可以看到,目前的分发渠道包括网站(WEBSITE)、只在 Kindle(KINDLE_ONLY),还是全渠道(ALL)。
|
||||
|
||||
面对这段代码,我有些疑惑,于是我提了一个问题:
|
||||
|
||||
我:这里的 WEBSITE 和 KINDLE_ONLY 分别表示的是什么?
|
||||
|
||||
同事:WEBSITE 表示作品只会在我们自己的网站发布,KINDLE_ONLY 表示这部作品只会在 Kindle 的电子书商店里上架。
|
||||
|
||||
我:二者是不是都表示只在单独一个渠道发布?
|
||||
|
||||
同事:是啊!
|
||||
|
||||
我:既然二者都有只在一个平台上架发布的含义,为什么不都叫 XXX 或者 XXX_ONLY?
|
||||
|
||||
同事:呃,你说得有道理。
|
||||
|
||||
我之所以会注意到这里的问题,一个主要的原因就是,在这里 WEBSITE 和 KINDLE_ONLY 两个名字的不一致。
|
||||
|
||||
按照我对一致性的理解,表示类似含义的代码应该有一致的名字,比如,很多团队里都会把业务写到服务层,各种服务的命名也通常都是 XXXService,像 BookService、ChapterService 等等。而一旦出现了不一致的名字,通常都表示不同的含义,比如,对于那些非业务入口的业务组件,它们的名字就会不一样,会更符合其具体业务行为,像 BookSender ,它表示将作品发送到翻译引擎。
|
||||
|
||||
一般来说,枚举值表示的含义应该都有一致的业务含义,一旦出现不同,我就需要确定不同的点到底在哪里,这就是我提问的缘由。
|
||||
|
||||
显然,这段代码的作者给这两个枚举值命名时,只是分别考虑了它应该起什么名字,却忽略了这个枚举值在整体中扮演的角色。
|
||||
|
||||
理解这一点,改动是很容易,后来,代码被统一成了一个形式:
|
||||
|
||||
enum DistributionChannel {
|
||||
WEBSITE,
|
||||
KINDLE,
|
||||
ALL
|
||||
}
|
||||
|
||||
|
||||
方案中的不一致
|
||||
|
||||
还是在一次代码评审中,我看到了这样一段代码:
|
||||
|
||||
public String nowTimestamp() {
|
||||
|
||||
DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
|
||||
Date now = new Date();
|
||||
return format.format(now);
|
||||
}
|
||||
|
||||
|
||||
这是一段生成时间戳的代码,当一个系统向另外一个系统发送请求时,需要带一个时间戳过去,这里就是把这个时间戳按照一定格式转成了字符串类型,主要就是传输用,便于另外的系统进行识别,也方便在开发过程中进行调试。
|
||||
|
||||
那我为什么还说它是有问题的呢?因为这种写法是 Java 8 之前的写法,而我们用的 Java 版本是 Java 8 之后的。
|
||||
|
||||
在很长的一段时间里,Java 的日期时间解决方案一直是一个备受争议的设计,它的问题很多,有的是概念容易让人混淆(比如:Date 和 Calendar 什么情况下该用哪个),有的是接口设计的不直观(比如:Date 的 setMonth 参数是从 0 到 11),有的是实现容易造成问题(比如:前面提到的 SimpleDateFormat 需要考虑多线程并发的问题,需要每次构建一个新的对象出来)。
|
||||
|
||||
这种乱象存在了很长时间,有很多人都在尝试解决这个问题(比如 Joda Time)。从 Java 8 开始,Java 官方的 SDK 借鉴了各种程序库,引入了全新的日期时间解决方案。这套解决方案与原有的解决方案是完全独立的,也就是说,使用这套全新的解决方案完全可以应对我们的所有工作。
|
||||
|
||||
我们现在的这个项目是一个全新的项目,我们使用的版本是 Java 11,这就意味着我们完全可以使用这套从 Java 8 引入的日期时间解决方案。所以,我们在项目里的约定就是所有的日期时间类型就是使用这套新的解决方案。
|
||||
|
||||
现在你可能已经知道我说的问题在哪里了,在这个项目里,我们的要求是使用新的日期时间解决方案,而这里的 SimpleDateFormat 和 Date 是旧解决方案的一部分。所以,虽然这段代码本身的实现是没有问题的,然而,放在项目整体中,这却是一个坏味道,因为它没有和其它的部分保持一致。
|
||||
|
||||
后来,同事用新的解决方案改写了原来的代码:
|
||||
|
||||
public String nowTimestamp() {
|
||||
|
||||
LocalDateTime now = LocalDateTime.now()
|
||||
return now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
|
||||
|
||||
}
|
||||
|
||||
|
||||
之所以会出现这样的问题,主要是因为一个项目中,应对同一个问题出现了多个解决方案,如果没有一个统一的约定,项目成员会根据自己写代码时的感觉随机选择一个方案,这样的结果就是出现方案上的不一致。
|
||||
|
||||
为什么一个项目中会出现多个解决方案呢?一个原因就是时间。随着时间流逝,人们会意识到原有解决方案存在的各种问题,于是,有人就会提出新的解决方案,像我们这里提到的 Java 日期时间的解决方案,就是 JDK 本身随时间演化造成的。有的项目时间比较长,也会出现类似的问题,尤其是像 C/C++ 这种自造轮子的重灾区。我曾经在 InfoQ 上讲过一个例子,在一段代码里同时出现了两种字符串类型。
|
||||
|
||||
有时,程序员也会因为自己的原因引入不一致。比如,在代码中引入做同一件事情类似的程序库。像判断字符串是否为空或空字符串,Java 里常用的程序库就有 Guava 和 Apache 的 Commons Lang,它们能做类似的事情,所以,程序员也会根据自己的熟悉程度选择其中之一来用,造成代码中出现不一致。
|
||||
|
||||
这两个程序库是很多程序库的基础,经常因为引入了其它程序库,相应的依赖就出现在我们的代码中。所以,我们必须约定,哪种做法是我们在项目中的标准做法,以防出现各自为战的现象。比如,在我的团队中,我们就选择 Guava 作为基础库,因为相对来说,它的风格更现代,所以,团队就约定类似的操作都以 Guava 为准。
|
||||
|
||||
代码中的不一致
|
||||
|
||||
我们再来看一段代码:
|
||||
|
||||
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 请求在翻译引擎中创建出这个作品。
|
||||
|
||||
这么短的一段代码有什么问题吗?问题就在于这段代码中的不一致。你可能会想:“不一致?不一致体现在哪里呢?”答案就是,这些代码不是一个层次的代码。
|
||||
|
||||
通过了解这段代码的背景,你可能已经看出一些端倪了。首先是获取审核通过的作品,这是一个业务动作,接下来的三行其实是在做一件事,也就是发送创建作品的请求。具体到代码上,这三行代码分别是创建请求的参数,根据参数创建请求,最后,再把请求发送出去。这三行代码合起来完成了一个发送创建作品请求这么一件事,而这件事才是一个完整的业务动作。
|
||||
|
||||
所以,我说这个函数里的代码并不在一个层次上,有的是业务动作,有的是业务动作的细节。理解了这一点,我们就可以把这些业务细节的代码提取到一个函数里:
|
||||
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
|
||||
从结果上看,原来的函数(createBook)里面全都是业务动作,而提取出来的函数(createRemoteBook)则都是业务动作的细节,各自的语句都是在一个层次上了。
|
||||
|
||||
能够分清楚代码处于不同的层次,基本功还是分离关注点,这一点,我在《[软件设计之美]》这个专栏里已经多次强调了。
|
||||
|
||||
一旦我们将不同的关注点分解出来,我们还可以进一步调整代码的结构。像前面拆分出来的这个方法,我们已经知道它的作用是发出一个请求去创建作品,本质上并不属于这个业务类的一部分。所以,我们还可以通过引入一个新的模型,将这个部分调整出去:
|
||||
|
||||
public void createBook(final List<BookId> bookIds) throws IOException {
|
||||
|
||||
List<Book> books = this.bookService.getApprovedBook(bookIds);
|
||||
|
||||
this.translationEngine.createBook(books);
|
||||
|
||||
}
|
||||
|
||||
class TranslationEngine {
|
||||
|
||||
public void createBook(List<Book> books) throws IOException {
|
||||
|
||||
CreateBookParameter parameter = toCreateBookParameter(books)
|
||||
|
||||
HttpPost post = createBookHttpRequest(parameter)
|
||||
|
||||
httpClient.execute(post)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
我估计,这段代码的调整,超出了很多人对于“代码应该怎么写”的认知范围。一说到分层,大多数人想到的只是模型的分层,很少有人会想到在函数的语句中也要分层。各种层次的代码混在一起,许多问题也就随之而来了,最典型莫过于我们之前讲过的长函数。
|
||||
|
||||
从本质上说,我们在做的依然是模型的分层,只不过,这次的出发点是函数的语句。这也是我一直强调的“分离关注点,越小越好”的意义所在。观察代码的粒度足够小,很多问题自然就会暴露出来。
|
||||
|
||||
这里我顺便说一个与测试相关的话题,程序员开始写测试时,有一个典型的问题:如何测试一个私有方法。有人建议用一些特殊能力(比如反射)去测试。我给这个问题的答案是,不要测私有方法。
|
||||
|
||||
之所以有测试私有方法的需求,一个重要的原因就是分离关注点没有做好,把不同层次的代码混在了一起。前面这段代码,如果要测试前面那个 createRemoteBook 方法还是有一定难度的,但调整之后,引入了 TranslationEngine 这个类,这个方法就变成了一个公开方法,我们就可以按照一个公开方法去测试了,所有的问题迎刃而解。
|
||||
|
||||
很多程序员纠结的技术问题,其实是一个软件设计问题,不要通过奇技淫巧去解决一个本来不应该被解决的问题。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了因为不一致导致的一些问题,对于一个团队来说,一致是非常重要的,是降低集体认知成本的重要方式。我们分别见识了:
|
||||
|
||||
我们知道了,类似含义的代码应该有类似的命名,不一致的命名表示不同的含义,需要给出一个有效的解释。
|
||||
|
||||
方案中的不一致,一方面是由于代码长期演化造成的,另一方面是项目中存在完成同样功能的程序库。无论是哪种原因,都需要团队先统一约定,保证所有人按照同一种方式编写代码。
|
||||
|
||||
代码中的不一致常常是把不同层次的代码写在了一起,最典型的就是把业务层面的代码和实现细节的代码混在一起。解决这种问题的方式,就是通过提取方法,把不同层次的代码放到不同的函数里,而这一切的前提还是是分离关注点,这个代码问题的背后还是设计问题。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:保持代码在各个层面上的一致性。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
对于一致性的认知,我相信大家都有。但是,对于不同层次的代码混在一起可能是很多人都没有注意过的,你可以查看一下自己的代码,看看有哪些不同层次的代码混在了一起。
|
||||
|
||||
欢迎在留言区写下你看到的代码,你身边要是有人写出了不一致的代码,也欢迎你把这节课分享给他。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
173
专栏/代码之丑/13落后的代码风格:使用“新”的语言特性和程序库升级你的代码.md
Normal file
173
专栏/代码之丑/13落后的代码风格:使用“新”的语言特性和程序库升级你的代码.md
Normal file
@ -0,0 +1,173 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
13 落后的代码风格:使用“新”的语言特性和程序库升级你的代码
|
||||
你好,我是郑晔。
|
||||
|
||||
上一讲,我们讲的是因为代码不一致造成的坏味道,其中我提到的“方案不一致”,是因为随着时间的流逝,总会有一些新的方案产生,替换原有的方案。这其中,最明显的一个例子就是程序设计语言。没有哪门语言是完美的,所以,只要有一个活跃的社区,这门语言就会不断地演进。
|
||||
|
||||
从 C++ 11 开始,C++ 开始出现了大规模的演化,让之前学习 C++ 的人感觉自己就像没学过这门语言一样;Python 2 与 Python 3 甚至是不兼容的演化;Java 也是每隔一段时间就会出现一次大的语言演进。
|
||||
|
||||
也正是因为语言本身的演化,在不同时期接触不同版本的程序员写出来的程序,甚至不像是在用同一门语言在编程。所以,我们有机会看到在同一个代码库中,各种不同时期风格的代码并存。
|
||||
|
||||
通常来说,新的语言特性都是为了提高代码的表达性,减少犯错误的几率。所以,在实践中,我是非常鼓励你采用新的语言特性写代码的。
|
||||
|
||||
这一讲,我们就以 Java 为例,讲讲如何使用“新”语言特性让代码写得更好。其实,这里的“新”只是相对的,我准备讨论的是 Java 8 的语言特性,按照官方的标准,这是一个已经到了生命周期终点的版本,只不过,从语言特性上来说,Java 8 是最近有重大变更的一个版本,而很多程序员的编码习惯停留在更早的版本。
|
||||
|
||||
Optional
|
||||
|
||||
我们先来看一段代码:
|
||||
|
||||
String name = book.getAuthor().getName();
|
||||
|
||||
|
||||
这是我们在讲“[缺乏封装]”时用到的一个例子,我们这里暂且不考虑缺乏封装的问题。即便如此,严格地说,这段代码依然是有问题的。因为它没有考虑对象可能为 null 的场景。
|
||||
|
||||
所以,这段代码更严谨的写法是这样:
|
||||
|
||||
Author author = book.getAuthor();
|
||||
|
||||
String name = (author == null) ? null : author.getName();
|
||||
|
||||
|
||||
然而,在很多真实的项目中,这种严格的写法却是稀有的,所以,在实际的运行过程中,我们总会惊喜地发现各种空指针异常。如果你要问程序员为什么不写对象为 null 的判断,答案很可能出乎你意料:他们忘了。
|
||||
|
||||
是的,忘了,就是这么简单得令人发指的理由。
|
||||
|
||||
不用过于责备这些程序员缺乏职业素养,因为这不是个体问题,而是行业整体的问题,IT 行业每年都会因此造成巨大的损失。空指针的发明者 Tony Hoare 将其称为“自己犯下的十亿美元错误”。
|
||||
|
||||
对于这个如此常见的问题,Java 8 中已经给出了一个解决方案,它就是 Optional。Optional 提供了一个对象容器,你需要从中“取出(get)”你所需要的对象,但在取出之前,你需要判断一下这个对象容器中是否真的存在一个对象。用这个思路可以这样改写这段代码:
|
||||
|
||||
class Book {
|
||||
|
||||
public Optional<Author> getAuthor() {
|
||||
return Optioanl.ofNullable(this.author);
|
||||
}
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
Optional<Author> author = book.getAuthor();
|
||||
String name = author.isPresent() ? author.get().getName() : null;
|
||||
|
||||
|
||||
这种做法和之前做法的最大差别在于,你不会忘掉判断对象是否存在的过程,因为你需要从 Optional 这个对象容器中取出存在里面的对象。正是这多出来的一步,减少了“忘了”的概率。
|
||||
|
||||
也是因为多了 Optional 这个类,这段代码其实还有更简洁的写法:
|
||||
|
||||
Optional<Author> author = book.getAuthor();
|
||||
|
||||
String name = author.map(Author::getName).orElse(null);
|
||||
|
||||
|
||||
有了 Optional,我们可以在项目中做一个约定,所有可能为 null 的返回值,都要返回 Optional,以此减少犯错的几率。关于 Optional,我在《软件设计之美》中花了[专门的篇幅]进行了介绍,你有兴趣的话,不妨进一步了解一下。
|
||||
|
||||
事实上,鉴于空对象是一个普遍存在的问题,一些程序设计语言甚至为此专门设计了语法,比如,类似的代码用 Kotlin 或 Groovy 写出来的话,应该是这下面这样:
|
||||
|
||||
val author = book.author
|
||||
val name = author?.name
|
||||
|
||||
|
||||
函数式编程
|
||||
|
||||
Optional 是 Java 8 引入的新特性,它的出现改变了编写 Java 代码的习惯用法。接下来,我们来看看另外一个改变我们代码习惯用法的特性。
|
||||
|
||||
在讲“[滥用控制语句]”那一讲时,我留下了一个尾巴,说循环语句本身就是一个坏味道。接下来,我们就来说一下这个问题。我们还是先从一段代码开始:
|
||||
|
||||
public ChapterParameters toParameters(final List<Chapter> chapters) {
|
||||
|
||||
List<ChapterParameter> parameters = new ArrayList<>();
|
||||
|
||||
for (Chapter chapter : chapters) {
|
||||
if (chapter.isApproved()) {
|
||||
parameters.add(toChapterParameter(chapter));
|
||||
}
|
||||
}
|
||||
return new ChapterParameters(parameters);
|
||||
|
||||
}
|
||||
|
||||
|
||||
这是一段向翻译引擎发送章节信息前准备参数的代码,这里首先筛选出审核通过的章节,然后,再把章节转换成与翻译引擎通信的格式,最后,再把所有得到的单个参数打包成一个完整的章节参数。
|
||||
|
||||
如果按照 Java 8 之前的版本理解,这段代码是一段很正常的代码。当 Java 的时代进入到 8 之后,这段代码就成了有坏味道的代码。
|
||||
|
||||
Martin Fowler 在《重构》的第二版中新增的坏味道就包括了循环语句(Loops)。之所以循环语句成了坏味道,一个重要的原因就是函数式编程的兴起。不是我们不需要遍历集合,而是我们有了更好的遍历集合的方式。
|
||||
|
||||
我在《软件设计之美》讲[函数式编程的组合性]时曾经提到过,函数式编程的一个重要洞见就是,大部分操作都可以归结成列表转换,其中,最核心的列表转换就是 map、filter 和 reduce。在函数式编程日益重要的今天,列表转换已经成为了每个程序员应该必备的基本功。
|
||||
|
||||
了解了这些,你就知道为什么循环语句是坏味道了,因为大部分循环语句都是在对一个元素集合进行操作,而这些操作基本上都可以用列表操作进行替代。
|
||||
|
||||
再者,一般来说,采用列表转换写出来的代码相较于传统的循环语句写出来的代码,表达性更好,因为它们都是描述做什么,而传统的循环语句是在描述怎么做。我在这个专栏已经多次说过了,这是两种不同的抽象层次,描述做什么比怎么做的代码,在表达性上要好得多。
|
||||
|
||||
有了这些基础,我们再来看这段代码。这段代码中有一个循环语句,正如前面所说,这个循环语句在处理的是一个集合中的元素,所以,这个循环语句是可以用列表转换的方式代替的。
|
||||
|
||||
具体怎么做呢?其实,这里的行为我们在前面已经分析过了,就是先筛选出审核通过的章节,这个过程对应着 filter,然后,把筛选出来的章节转换成通信中的参数,这个过程对应着 map,最后,把转换的结果搜集起来,这个过程对应着 reduce。所以,这段代码可以改写成这样:
|
||||
|
||||
public ChapterParameters toParameters(final List<Chapter> chapters) {
|
||||
|
||||
List<ChapterParameter> parameters = chapters.stream()
|
||||
.filter(Chapter::isApproved)
|
||||
.map(this::toChapterParameter)
|
||||
.collect(Collectors.toList());
|
||||
return new ChapterParameters(parameters);
|
||||
|
||||
}
|
||||
|
||||
|
||||
经过这样的改造,一个循环语句就彻底被一个列表转换的操作替换掉了(这里的 collect 函数对应着 reduce 操作)。在这段代码中,我们用到了 Java 8 提供的一些基础设施,比如,Stream、lambda 和方法引用等等。
|
||||
|
||||
或许有人会说,这段代码看着还不如我原来的循环语句简单。不过,你要知道,两种写法根本的差别是侧重点不同,循环语句是在描述实现细节,而列表转换的写法是在描述做什么,二者的抽象层次不同。
|
||||
|
||||
对于理解这段代码的人来说,二者提供的信息量是完全不同的,循环语句必须要做一次“阅读理解”知晓了其中的细节才能把整个场景拼出来,而列表转换的写法则基本上和我们用语言叙述的过程一一对应。所以,理解的难度是完全不同的。
|
||||
|
||||
这段代码只是为了说明问题,而选择了简单的代码,但在实际工作中,需求会比这复杂得多。而且,如果要添加新的需求,循环语句里的代码会随之变得越来越复杂,原因就是循环语句里都是细节,而列表转换则是一段一段的描述,就像在阅读一篇文章。
|
||||
|
||||
很多人之所以更喜欢使用循环语句而不是列表转换,一个重要原因是对于列表转换的基础还不了解。只要多写几次 filter、map 和 reduce,理解它们就会像理解选择语句和循环语句一样自然。
|
||||
|
||||
到这里有人会说:“你说得有点道理,但为什么我的感觉和你不一样,在实践中,我也使用了这种风格,为什么写出来的代码感觉更难理解了?”对于这一点,一个常见的原因就是,你在列表转换过程中写了太多代码。
|
||||
|
||||
自从 Java 里引入了 lambda,因为写起来实在是太容易了,很多人就直接在列表转换过程中写 lambda。lambda 本身相当于一个匿名函数,所以,很多人在写函数中犯的错误在 lambda 里也一样出现了,最典型的当然就是长函数。
|
||||
|
||||
在各种程序设计语言中,lambda 都是为了写短小代码提供的便利,所以,lambda 中写出大片的代码,根本就是违反 lambda 设计初衷的。最好的 lambda 应该只有一行代码。
|
||||
|
||||
那如果一个转换过程中有很多操作怎么办呢?很简单,提取出一个函数,就像前面代码中的 toChapterParameter,它负责完成从 Chapter 到 ChapterParameter 的转换。这样一来,列表转换的本身就完全变成了一个声明,这样的写法才是能发挥出列表转换价值的写法。
|
||||
|
||||
在这一讲中,我们以 Optional 和函数式编程为例,讲解了用“新”的代码风格改进代码,其实,我们在前面的内容中也已经讲了不少“新”的代码风格,比如,使用 Java 8 的时间日期类型、try-with-resource 等等。在讲解的过程中,我也提到过不少的编码风格实际上是停留在过去,比如,变量初始化的习惯。
|
||||
|
||||
你可以看到,代码风格有一个逐步演化的过程,每个程序员对此的理解程度都有所差异,所以,如果我们不加注意的话,各种代码风格会并存于代码之中,加剧代码的理解难度,这就是我们上一讲讲到的坏味道:不一致。
|
||||
|
||||
一种编程风格会过时,本质上是因为它存在问题,新代码风格就是用更好的方案解决它,就像今天讲到的 Optional。所以,我们要不断学习新引入的语言特性,了解它们给语言带来的“新”风格,而不要停留在原地。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们讲了“新”风格对于代码的改善。每一种有生命力的语言都会在自己的生命周期中不断地对语言本身进行改进,无论是引入新的语言特性,还是引入新的程序库,都会对代码的编写产生或多或少的影响。这一讲,我们用来讲解的例子是 Java 8 引入的 Optional 和函数式编程。
|
||||
|
||||
Optional 是一个对象容器,它的出现是为了规避空对象带来的各种问题。Optional 的引入可以减少由于程序员的忽略而引发对空对象的问题。团队内部可以约定,所有可能返回空对象的地方,都要返回 Optional,以此降低犯错的几率。
|
||||
|
||||
函数式编程是一个影响代码整体风格的重要编程范式,然而,对于很多 Java 程序员来说,Java 8 引入的函数式编程支持,只是引入了一些新的程序库。缺乏对于函数式编程的理解,尤其是对于列表转换思维的理解,让我们虽然有了很多很好的工具,却完全无法发挥其功效。
|
||||
|
||||
懂得列表转换思维,首先要懂得最基本的几个操作:map、filter 和 reduce,然后,就可以把大部分的集合操作转换成列表转换。想要使用这种思维写好代码,一方面,要懂得声明式代码的重要性,另一方面,要懂得写出短小的函数,不要在 lambda 中写过多的代码。
|
||||
|
||||
作为一个精进的程序员,我们要不断地学习“新”的代码风格,改善自己的代码质量,不要故步自封,让自己停留在上一个时代。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:不断学习“新”的代码风格,不断改善自己的代码。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
这一讲,我们讲到了不同的代码风格,你有体验过不同的代码风格对于代码库的影响吗?欢迎在留言区分享你的经验。
|
||||
|
||||
对于团队来说,逐步将统一将语言特性和程序库统一到新的风格上是一件很重要的事,欢迎你把这节课学到的知识,分享给你的团队。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
参考资料:
|
||||
|
||||
|
||||
|
||||
|
111
专栏/代码之丑/14多久进行一次代码评审最合适?.md
Normal file
111
专栏/代码之丑/14多久进行一次代码评审最合适?.md
Normal file
@ -0,0 +1,111 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
14 多久进行一次代码评审最合适?
|
||||
你好,我是郑晔。
|
||||
|
||||
前面我们讲了很多代码的坏味道,我们的关注点都在代码本身上。知道了什么样的代码是坏味道,有了具体的评判标准。那么,该如何去运用坏味道这把“尺子”呢?
|
||||
|
||||
有一个发现坏味道的实践,就是代码评审,也就是很多人熟悉的 Code Review,Wikipedia 上定义是这样的:
|
||||
|
||||
代码评审,是指对计算机源代码系统化地审查,常用软件同行评审的方式进行,其目的是在找出及修正在软件开发初期未发现的错误,提升软件质量及开发者的技术。
|
||||
|
||||
大多数程序员都经历过代码评审,也都能够初步理解代码评审本身存在的价值,这也是差不多全行业都认为有价值的一个实践。只不过,每个团队在代码评审的实践差别还挺大的,有的团队是在一个完整的开发周期结束之后,做一次代码评审;有的是安排每周的代码评审;有的则是每天都要做代码评审。之所以会有这样的差异,主要就是团队对于代码评审本身的理解有差异。
|
||||
|
||||
所以,这一讲我们就来谈谈,到底应该如何理解代码评审。
|
||||
|
||||
代码评审是一个沟通反馈的过程
|
||||
|
||||
关于代码评审,第一个问题就是,为什么要做代码评审?
|
||||
|
||||
这个问题其实比较简单,没有人能够保证自己写出来的代码是没有问题的,而规避个体问题的主要方式就是使用集体智慧,也就是团队的力量。
|
||||
|
||||
这个答案是从个体的角度在看问题,其实,看待代码评审还有一个团队视角,代码评审的过程,也是一个知识分享的过程,保证一些细节的知识不再是隐藏在某一个人的头脑中,而是放置到了团队的层面。
|
||||
|
||||
不过,无论是从哪个角度看代码评审,它的本质,就是沟通反馈的过程。我把我对这段代码的理解分享给你,你把你对这段代码的想法共享给我。有人给出代码实现的知识,有人贡献出对技术的理解。
|
||||
|
||||
如果我们理解了代码评审是一个沟通反馈的过程,那就可以把沟通反馈的一些原则运用到代码评审中。
|
||||
|
||||
我在《[10x 程序员工作法]》里,花了一个模块的篇幅讲了沟通反馈,我们希望沟通要尽可能透明,尽可能及时。把这样的理解放到代码评审中,就是要尽可能多暴露问题,尽可能多做代码评审。
|
||||
|
||||
暴露问题
|
||||
|
||||
我们先来说暴露问题。代码评审就是一个发现问题的过程,这是一个大家都能理解的事情。但问题就在于,要发现什么问题?
|
||||
|
||||
如果泛泛地回答,那自然就是代码实现中的各种问题。然而,这个答案还可以细化一下,做代码评审时,我们可以从下面几个角度来看代码:
|
||||
|
||||
我们一个一个来看,先来说实现方案。理论上说,实现方案应该是设计评审中关注的内容,但在实际工作中,并不是所有团队都能够很好地执行设计评审,而且设计评审有时也关注不到特别细的点,所以,一些实现方案的问题只有在代码评审中才能发现。
|
||||
|
||||
在一次代码评审中,我看到一个批量处理的 REST 接口,接到请求经过一些处理之后,它会调用另外一个服务,因为这个服务只支持单一的请求,所以,REST 接口只能一个一个地向这个服务发送请求。
|
||||
|
||||
如果一切正常的话,这个接口是没有问题的。但是,如果在处理过程中出现失败,没有把所有的请求发给另一个服务,这个接口的行为是什么样呢?是需要客户端重新发起请求,还是服务端本身重新调用接口?如果是服务端负责重试,那么,这个方案本身没有任何重试的机制,也就是说,一个请求一旦出错,它就丢了,业务不能顺利地完成。
|
||||
|
||||
当我把这个问题抛了出来时,同事一下子愣住了。显然,他只考虑了正常的情况,而没有考虑出现失败的情况。把它做成一个完整的方案,很可能还需要做一个后台服务,负责替未能得到有效处理的任务善后,显然,这就不是代码调整,而是整个方案的调整。
|
||||
|
||||
这是很多程序员,尤其是经验比较少的程序员写程序经常会出现的问题:正常情况一切顺利,异常情况却考虑不足。
|
||||
|
||||
我们再来说说算法正确性。
|
||||
|
||||
别看整个行业都十分重视算法,但那是在面试的过程中。真正到了实际工作里,算法复杂度常常被人忽略。
|
||||
|
||||
我们之前讲过嵌套的代码,对于循环语句,我们要把处理一个元素的代码提取出来。不过,这有时候也会带来一些意想不到的问题。
|
||||
|
||||
有一次代码评审,我看到了一段写得很干净的代码,就是把循环里对于一个元素的处理拆了出去。还没等我来赞美这段代码写得好,我就看到了单个元素处理的代码,每次都要查询一次数据库,找出相应的元素,做修改之后再存回去。
|
||||
|
||||
就这样,单独看每段代码都是对的,但合在一起就出了问题,本来可以通过一次查询解决的问题变成了 N 次查询。
|
||||
|
||||
我再给你讲一个让我印象深刻的故事。在我职业生涯的初期,我做过一段时间图像识别的工作。有一次,一个实习生说自己的代码太慢了,让我帮忙看看。
|
||||
|
||||
从表面上看,代码写得还不错,不是一眼能够看出问题。仔细看了半天,我在一个遍历图像像素点的循环里发现了一个图像复制的代码,也就是说,每循环一次,都要把整个图像复制一遍,代码慢就在所难免了。
|
||||
|
||||
我相信,如果这是一个算法练习,这两个同事都能够有效地解决这个问题,但放在工程里,就难免挂一漏万了。所以,算法正确性也是我们要在代码评审中关注的。
|
||||
|
||||
无论是实现方案的正确性,还是算法的正确性,对于大多数团队来说,都会关注到。但代码坏味道却是很多团队容易忽略的,这里面的关键点就是很多团队对于坏味道的标准太低了。
|
||||
|
||||
在这个专栏里,我讲了很多坏味道,有一些是你早就认同的,有一些则在挑战你的认知。也正是因为有这些挑战你认知的部分,所以很多代码即便经过评审,也依然会产生很多问题。关于坏味道,我们整个专栏都在说,更多的细节我就不在这里讨论了。
|
||||
|
||||
及时评审
|
||||
|
||||
说完代码评审中要暴露的问题,我们再来说说代码评审的另外一个方面,代码评审的频率。
|
||||
|
||||
不同的团队代码评审,频率是不一样的,最糟糕的肯定是不评审,整个团队闭着眼睛向前冲,这就不是我们关心的范畴。常见的评审频率是每个迭代评审一次,也有每周评审的。
|
||||
|
||||
我对评审的建议是,提升评审的频率,比如,每天评审一次。
|
||||
|
||||
评审周期过长是有问题的,周期过长,累积的问题就会增多,造成的结果就是太多问题让人产生无力感。如果遇到实现方案存在问题,要改动的代码就太多了,甚至会影响到项目的发布。
|
||||
|
||||
而提升评审的频率,评审的周期就会缩短,每个周期内写出来的代码就是有限的,人是有心力去修改的。学过我任何一个专栏的同学都知道,我在专栏中反复强调短小的价值,只有及时的沟通反馈,才有可能实现这一原则。
|
||||
|
||||
你或许会好奇,我们是不是可以再进一步提升评审的频率呢?
|
||||
|
||||
肯定可以,如果把代码评审推至极致,就是有个人随时随地来做代码评审。我在《[10x 程序员工作法]》讲过极限编程的理念,就是把好的实现推向极致,而代码评审的极致实践就是结对编程。
|
||||
|
||||
结对编程就是两个人一起写一段代码,一个人主要负责写,一个人则站在用外部视角保证这段代码的正确性。好的结对编程对两个人的精力集中度要求是很高的,两个人一起写一天代码其实是很累的一件事,不过,也正是因为代码是两个人一起写,代码质量会提高很多。
|
||||
|
||||
从我之前经历的一些团队实践来看,结对编程还有一个额外的好处,就是对于团队中的新人提升极大,这就是拜结对编程这种高强度的训练和反馈所赐。高强度的训练和反馈,本质上就是一种刻意练习,而刻意练习是一个人提升最有效的方式。
|
||||
|
||||
我知道,对于大多数团队来说,是没有条件做大规模的结对编程的。但对个体来说,创造一些机会与高手一起写代码也是很好的。即便不能一起写,去观摩高手写代码也能学到很多东西。再退一步,实在身边没有机会,去网上看看高手写代码也是一种学习方式。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天的加餐我们讨论了代码评审。对于很多人来说,代码评审只是一个发现问题的过程,而通过今天的讨论,我们知道了代码评审是一个沟通反馈的过程。站在沟通反馈的角度,我们关注的是,尽可能多地暴露问题,尽可能多地做代码评审。
|
||||
|
||||
代码评审可以从实现方案正确性、算法正确性和代码坏味道的角度去发现问题。代码评审的频率是越高越好,频率越高,发现和解决问题的难度越低,团队越容易坚持下去。
|
||||
|
||||
如果把代码评审推向极致就是随时随地做代码评审,这个实践就是结对编程。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:代码评审暴露的问题越多越好,频率越高越好。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
你在代码评审上有哪些经验,或者遇到过哪些让你印象深刻的问题代码,欢迎在留言区分享你的经验。如果你有所收获,也欢迎把这节课分享出去。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
245
专栏/代码之丑/15新需求破坏了代码,怎么办?.md
Normal file
245
专栏/代码之丑/15新需求破坏了代码,怎么办?.md
Normal file
@ -0,0 +1,245 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
15 新需求破坏了代码,怎么办?
|
||||
你好,我是郑晔。
|
||||
|
||||
我前面课程讲的所有坏味道都是告诉你如何在已有的代码中发现问题。不过你要明白,即便我们能够极尽所能把代码写整洁,规避各种坏味道,但我们小心翼翼维护的代码,还是可能因为新的需求到来,不经意间就会破坏。
|
||||
|
||||
一个有生命力的代码不会保持静止,新的需求总会到来,所以,写代码时需要时时刻刻保持嗅觉。
|
||||
|
||||
这一讲加餐,我来给你讲讲两个发生在真实项目中的故事。
|
||||
|
||||
一次驳回的实现
|
||||
|
||||
我们的系统里有这样一个功能,内容作品提交之后要由相应的编辑进行审核。既然有审核,自然就有审核通过和不通过的情况,这是系统中早早开发完成的功能。
|
||||
|
||||
有一天,新的需求来了:驳回审核通过的章节,让作品的作者重新修改。造成作品需要驳回的原因有很多,比如,审核标准的调整,这就会导致原先通过审核的作品又变得不合格了。
|
||||
|
||||
在实现这个需求之前,我们先来看看代码库里已经有怎样的基础。
|
||||
|
||||
首先,系统里已经有了审核通过和审核不通过的接口。
|
||||
|
||||
PUT /chapter/{chapterId}/review
|
||||
DELETE /chapter/{chapterId}/review
|
||||
|
||||
|
||||
在这个设计里,将章节(chapter)的审核(review)当作了一个资源。在创建章节的时候,章节的审核状态就创建好了。审核通过,就相当于对这次审核进行了修改,而审核不通过,就相当于删除了这个资源。
|
||||
|
||||
对应着这两个接口,就有两个对应的服务接口:
|
||||
|
||||
class ChapterService {
|
||||
|
||||
public void approve(final ChapterId chapterId) {
|
||||
...
|
||||
}
|
||||
|
||||
public void reject(final ChapterId chapterId) {
|
||||
...
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
顾名思义,approve 函数对应着审核通过,而 reject 对应着审核不通过。相应地,章节上有一个状态字段,标识现在章节处于什么样的状态。章节是待审核、审核通过,还是审核不通过,就是通过这个字段标记出来的。
|
||||
|
||||
class Chapter {
|
||||
|
||||
private Status status = Status.PENDING;
|
||||
|
||||
public void approve() {
|
||||
this.status = Status.APPROVED;
|
||||
}
|
||||
|
||||
public void reject() {
|
||||
this.status = Status.REJECTED;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
好,我们已经知道了这些基础了,那驳回的需求该怎么设计呢?
|
||||
|
||||
既然增加了一个驳回的功能,那就增加一个驳回的接口,然后,在服务中增加一个驳回的服务,最后,再在状态中增加一个驳回的状态。这么做,听上去非常合理,你是不是已经按捺不住自己蠢蠢欲动的双手,准备写代码了呢?
|
||||
|
||||
且慢!我嗅到了一丝坏味道,这个坏味道来自于我们要增加一个接口。
|
||||
|
||||
来一个新需求,增加一个新接口,对于很多人来说,这是一种常规操作。但我们必须对新增接口保持谨慎。
|
||||
|
||||
接口,是系统暴露出的能力,一旦一个接口提供出去,你就不知道什么人会以什么样的方式使用这个接口。
|
||||
|
||||
我们常常看到很多系统有很多接口,如果你仔细梳理一番,就会发现,有很多接口提供类似的功能,这会让初次接触到系统的新人一脸茫然。即便你打算对系统进行清理,当清理掉一个你以为根本没有人用的接口时,就会有人跑出来告诉你,这个接口调整影响了他们的业务。
|
||||
|
||||
所以,我们必须对接口的调整慎之又慎。最好的办法就是从源头进行限制,也就是说,当我们想对外提供一个接口时,我们必须问一下,真的要提供一个新接口吗?
|
||||
|
||||
回到这个案例上,我们面对这个需求的第一反应和大多数人一样,也是增加一个新的接口。但是,是否真的要增加一个新的接口呢?如果不增加新接口,这就意味着要复用已有的接口。但复用的前提是:新增的业务动作是可以通过已有的业务来完成的,或是对已有业务进行微调就可以。
|
||||
|
||||
那么,到底是需要新增,还是复用,真正要回答这个问题,还是要回到业务上。
|
||||
|
||||
在原有的业务中,审核通过会进入到下一个阶段,而审核不通过,就会退回到作者那里进行修改。那驳回之后呢?它也会要求作者去修改。
|
||||
|
||||
说到这里,你就不难发现了,驳回的动作和审核不通过,二者的后续动作是一样的。它们的差别只是起始的状态,如果原来的状态是待审核,经过一个审核不通过的动作,状态就变成了审核不通过;而如果原来的状态是审核通过,经过一个驳回的动作,状态就变成了驳回。所以,我们完全可以复用原来的审核不通过接口。
|
||||
|
||||
既然是复用接口,所有的变化就全部都是内部变化了,我们可以根据章节当前的状态进行判断,设置相应的状态。具体到代码上,我们既不需要增加驳回的接口,也不需要增加驳回的服务,只需要在 Chapter 类内部进行修改,代码改动量比原先预期的就小了很多。其代码结构大体如下所示:
|
||||
|
||||
class Chapter {
|
||||
|
||||
private Status status = Status.PENDING;
|
||||
|
||||
...
|
||||
|
||||
public void reject() {
|
||||
if (status == Status.PENDING) {
|
||||
this.status = Status.REJECTED;
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == Status.APPROVED) {
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
按照这个理解,我们只要增加一个驳回的状态,在当前状态是审核通过时,将这个新状态赋值上去就可以了。
|
||||
|
||||
看上去,我们已经把这次要改动的代码限制在一个最小的范围。但其实,我还想再问一个问题,我们真的需要这么一个状态吗?
|
||||
|
||||
是否增加一个驳回的状态,回答这个问题还是要回到业务上,驳回后续的处理与审核不通过的状态有什么不同。
|
||||
|
||||
按照产品经理本来的需求,他是希望做出一些不同来,比如,处于审核不通过的状态,编辑端是无法查看的,而处于驳回状态的,编辑是可以查看的。但在当前的产品状态下,我们是否可以将二者统一起来呢?也就是说,都按照审核不通过来处理呢?
|
||||
|
||||
产品经理仔细想了想,觉得其实也可以,于是,两种不同的状态在这里得到了统一,也就是说,我们根本没有增加这个驳回的新状态。
|
||||
|
||||
事情说到这里,你就会发现,在这次的业务调整中,后端服务的代码其实没有做任何修改,只是前端的代码在需要驳回时增加了一个对审核不通过的调用,而所有这一切的起点,只是我们对于增加一个新接口的嗅觉。
|
||||
|
||||
一次定时提交的实现
|
||||
|
||||
我再来给你讲另外的一个与“实现”有关的故事。
|
||||
|
||||
在我们的系统中,一般情况下,作者写完一章之后就直接提交了,这是系统中已经实现好的一个功能。现在来了新的需求,有时候,作者会囤一些稿子,为了保证自己每天都有作品提交,作者希望作品能够按自己设定的时间去提交,也就是说,一个章节在它创建的时候,并不会直接提交到编辑那里去审核,而是要到特定的时间之后,再来完成作品的提交。
|
||||
|
||||
实际上,“每天都有作品提交”就是一种连续的签到,通常来说,系统都会给连续签到以奖励,这也是对于作者的一种激励手段。
|
||||
|
||||
如果你面对这样一个需求,你会怎么实现呢?
|
||||
|
||||
与这个需求最直接相关的代码就是章节信息了:
|
||||
|
||||
class Chapter {
|
||||
|
||||
private ChapterId chapterId;
|
||||
|
||||
private String title;
|
||||
|
||||
private String content;
|
||||
|
||||
private Status status;
|
||||
|
||||
private ZonedDateTime createdAt;
|
||||
|
||||
private String createdBy;
|
||||
|
||||
private String modifiedBy;
|
||||
|
||||
private ZonedDateTime modifiedAt;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
显然,要实现这个需求,需要有一个定时任务,定期去扫描那些需要提交的作品。这个是没有问题的,但是,这些定时的信息要放在哪里呢?
|
||||
|
||||
我似乎已经看到你跃跃欲试的样子了。你可能会想:这个实现还不简单,在章节上加上一个调度时间就行了:
|
||||
|
||||
class Chapter {
|
||||
|
||||
...
|
||||
|
||||
private ZonedDateTime scheduleTime;
|
||||
|
||||
}
|
||||
|
||||
|
||||
确实,这么实现并不复杂。但我想请你稍微停顿一下,别急着写这段代码。这种做法我又嗅到了一丝坏味道,因为我们要改动实体了。
|
||||
|
||||
有需求就改动实体,这几乎是很多人不假思索的编码习惯,然而,对于一个业务系统而言,实体是其中最核心的部分,对它的改动必须有谨慎的思考。
|
||||
|
||||
随意修改实体,必然伴随着其它部分的调整,而经常变动的实体,就会让整个系统难以稳定下来。一般来说,一个系统的业务并不会经常改变,所以,核心的业务实体应该是一个系统中最稳定的部分。
|
||||
|
||||
不过,你可能会说:“我有什么办法,需求总在变,就总会改动到这个实体。”
|
||||
|
||||
需求总在变,这是没有错的,但它是否真的要改动到业务实体呢?很多时候,这只是应有的职责没有分析清楚而已。
|
||||
|
||||
具体到我们这个例子里面,我们需要的是定时提交一个章节,而这个定时信息并不是核心业务实体的一部分,只是在一种特定场景下所需要的信息而已。所以,它根本不应该添加到 Chapter 这个类里面。
|
||||
|
||||
不放在 Chapter 这个类里面,那要放到哪呢?很显然,这里少了一个模型,一个关于调度的模型。我们只要增加一个新的模型,让它和 Chapter 关联在一起就好了:
|
||||
|
||||
class ChapterSchedule {
|
||||
|
||||
private ChapterId chapterId;
|
||||
|
||||
private ZonedDateTime scheduleTime;
|
||||
|
||||
...
|
||||
|
||||
}
|
||||
|
||||
|
||||
有了这个模型,后续再有关于调度的信息就可以放到这个模型里面了,而更重要的是,我们的核心模型 Chapter 在这个过程中是保持不变的。
|
||||
|
||||
我们之所以要把定时提交的信息与章节本身分开,因为这二者改变的原因是不同的。你或许已经发现了,是的,如果将二者混在一起,就是违反了单一职责原则。对于一个程序员来说,深入理解单一职责原则是非常必要的。
|
||||
|
||||
到这里,定时提交的问题看上去已经得到了一个很合理的解决,有了基础的数据结构,修改对应的接口和服务,对大多数程序员来说,都是一件驾轻就熟的事情。那么,这个讨论就结束了吗?我们可能暂时还不能停下来。
|
||||
|
||||
我们新增的需求是定时发布,之所以要有这么个需求,因为这和作者的激励是相关的。要想确定作者的激励,就要确定章节的提交时间,问题是,我们怎么确定章节的提交时间呢?
|
||||
|
||||
在原来实现中,创建时间就是提交时间,因为章节是立即提交的,而现在创建时间和提交时间有可能不同了。
|
||||
|
||||
你可能会想到,创建时间不行,那就用修改时间。我告诉你,这也不行,修改时间是章节信息最后一次修改的时间,它有可能因为各种原因变更,最简单的就是编辑审核通过,这个时间就会变。
|
||||
|
||||
分析到这里,我们突然发现,模型里居然没有一个地方可以存放提交时间,是的,我们需要修改实体了,我们要给它增加一个提交时间:
|
||||
|
||||
class Chapter {
|
||||
|
||||
...
|
||||
|
||||
private ZonedDateTime submittedAt;
|
||||
|
||||
}
|
||||
|
||||
|
||||
到这里,估计有些人已经懵了。前面我们辛辛苦苦地讨论,为的就是不在 Chapter 里增加信息,而这里,我们竟然就增加了一个字段。
|
||||
|
||||
前面我们说了,一个字段该不该加在一个类上,取决于其改变的原因。前面的定时时间确实不该加,而这里的提交时间却是应该加的。提交时间本来就是章节的一个属性,只不过如前面所说,之前,这个信息与创建时间是共用的,而如今,因为定时提交的出现,二者应该分开了。
|
||||
|
||||
或许你还有一个疑问,我们难道不能直接用 submittedAt 去存储调度时间吗?严格地说,不行。因为调度时间可能与具体提交的时间有差异。我举个例子,因为某种原因,系统宕机了,启动之后,调度任务执行,这时可能已经过了调度时间很多了,但这个时候提交章节,它的时间就不会是调度时间。
|
||||
|
||||
至此,我们完整地分析完了定时提交的实现,你还记得我们为什么要做这个分析吗?没错,因为它要改动核心的实体,而这又是一个坏味道的高发地带。
|
||||
|
||||
总结时刻
|
||||
|
||||
这一讲,我用了两个例子给你讲了新需求到来时需要关注的地方,它们分别是:
|
||||
|
||||
接口和实体,其实也是一个系统对外界产生影响的重要部分,一个是对客户端提供能力,一个是产生持久化信息。所以,我们必须谨慎地思考它们的变动,它们也是坏味道产生的高发地带。
|
||||
|
||||
对于接口,我们对外提供得越少越好,而对于实体,我们必须仔细分析它们扮演的角色。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:谨慎地对待接口和实体的变动。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
你平时是怎么对待接口和实体的变动的呢?欢迎在留言区分享你的经验。
|
||||
|
||||
感谢阅读,我们下一讲再见!
|
||||
|
||||
|
||||
|
||||
|
121
专栏/代码之丑/16熊节:什么代码应该被重构?.md
Normal file
121
专栏/代码之丑/16熊节:什么代码应该被重构?.md
Normal file
@ -0,0 +1,121 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
16 熊节:什么代码应该被重构?
|
||||
你好,我是郑晔。
|
||||
|
||||
代码坏味道的说法源自《重构》这本书,坏味道和重构这两个概念几乎是如影随形。提及《重构》这本书,在国内谁还能比《重构》两版的译者熊节更了解它呢?所以,这一讲,我就请来了我的老朋友熊节,谈谈在他眼中看到的重构和坏味道。有请熊节老师!
|
||||
|
||||
你好,我是熊节。
|
||||
|
||||
自从翻译了《重构》以后,很多公司找我去做重构的培训,光是华为一家,这个主题在各个不同的部门就培训过好些次。每次讲这个主题,我都觉得挺为难的:重构这事有什么可培训的呢,不就是一个无脑模式匹配的事吗!然而跟各家公司的读者们一交流,我就发现事情并没有那么简单。
|
||||
|
||||
很多人一说到重构,就聊到虚无缥缈的事上了,像什么架构啦、文化啦,等等。我不得不先把他们拉住仔细问问,他们是怎么读《重构》这本书的?这一问我就发现,原来很多读者(恐怕是绝大多数读者),还没弄明白这本书到底应该怎么读。
|
||||
|
||||
什么代码应该被重构?
|
||||
|
||||
《重构》这本书,以及重构这门手艺,提纲挈领的部分,都在一个关键的问题上:什么代码应该被重构。
|
||||
|
||||
你可能会说,质量不好的代码需要被重构。没错,可是代码的质量到底应该如何评判呢?
|
||||
|
||||
首先我们要明确的是,代码的好与坏不应当用个人好恶、“含混的代码美学”来表达,因为这会带来两个困难:
|
||||
|
||||
第一,每个人对于“好”或“美”的观念可能相当不同;
|
||||
|
||||
第二,对于坏代码缺乏明确的“症状”判断,也就很难提出明确的改进措施。
|
||||
|
||||
即便是一些经典的程序设计原则,也有同样的问题。例如“高内聚低耦合”,尽管这是所有人都赞同的设计原则,但究竟什么样的代码呈现了“低内聚”、什么样的代码呈现了“高耦合” 、“低内聚”与“高耦合”是否总是同时出现、应该以何种办法提高内聚降低耦合……这些问题仍然是悬而未决的。
|
||||
|
||||
因此,对于真正在一线工作的人来说,“高内聚低耦合”很多时候就成了一句咒语,念完咒语后,呼唤出的其实还是每个人原本的编程习惯与风格,并不真正指导任何行为的改变。
|
||||
|
||||
而当我们去观察“低内聚高耦合”带来的问题时,事情就变得明朗了。比如,当我们仔细阅读《重构》第三章时,我们会发现,“低内聚”会直接引发的现象是“霰弹式修改(Shotgun Surgery)”:
|
||||
|
||||
每当需要对某个事情做出修改时,你都必须在许多不同的类内做出许多小修改,那么就可以确定,你所面临的坏味道是霰弹式修改。当需要修改的代码分散在多处,你不但很难找到它们,也很容易忘记某个重要的修改。
|
||||
|
||||
而“高耦合”直接引发的现象则是有某种相似性、但又表现不同的“发散式变化(Divergent Change)”:
|
||||
|
||||
如果某个类经常因为不同的原因在不同的方向上发生变化,发散式变化就出现了。
|
||||
|
||||
我再举另一个设计原则的例子。“迪米特原则”也是常被提及的面向对象设计原则之一,然而知道这个名称是一回事,知道如何识别不符合迪米特原则的代码,则又需要更多的个人经验。《重构》第三章则把这个原则表述为两个非常直观的症状:“过长的消息链(Message Chains)”和“中间人(Middle Man)”。
|
||||
|
||||
如果你看到用户向一个对象请求另一个对象,而后者再次请求另一个对象,然后再请求另一个对象……这就是消息链。在实际代码中,你看到的可能是一长串取值函数,或者一长串临时变量。
|
||||
|
||||
人们可能过度运用委托。你也许会看到某个类接口有一半的函数都委托给其他类,这样就是过度运用。
|
||||
|
||||
你发现没?对于开始我们提到的“什么代码应该被重构”这个关键问题,虽然《重构》作者 Martin Fowler 和 Kent Beck 非常客气地声称:“并不试图给你一个何时必须重构的精确衡量标准”,实际上,《重构》给出的 24 项“坏味道”(在《重构》第一版中是 22 项)已经形成了一个非常明确的代码质量检查清单。
|
||||
|
||||
尽管这本书从未声称这是一份完备的坏味道清单,但在实际工作中,还不用说完全识别并消除这份列表中的全部坏味道,只要能做到命名合理、没有重复、各个代码单元(类、函数等)体量适当、各个代码单元有明确且单一的职责、各个代码单元之间有恰当的交互,这就已经是质量相当高的代码了。
|
||||
|
||||
更重要的是,伴随着对具体症状的了解,对症的解决办法也变得明确。在《重构》第三章里非常明确地讲到:
|
||||
|
||||
对于“霰弹式修改”,解决的办法是使用“搬移函数”和“搬移字段”,把所有需要修改的代码放进同一个模块;
|
||||
|
||||
对于“发散式变化”,解决的办法是首先用“提炼函数”将不同用途的逻辑分开,然后用“搬移函数”将它们分别搬移到合适的模块;
|
||||
|
||||
对于“过长的消息链”,你应该使用“隐藏委托关系”;
|
||||
|
||||
对于“中间人”,对症的疗法则是“移除中间人”,甚至直接“内联函数”。
|
||||
|
||||
这就是我前面所说的“无脑模式匹配”。
|
||||
|
||||
讲到这里,你也就明白了,对于绝大多数程序员而言,阅读和使用《重构》这本书的正确方法就应该是:
|
||||
|
||||
打开任意一段代码(可以是自己刚写完的或者马上要动手修改的);
|
||||
|
||||
如有,则遵循该坏味道所列的重构手法,对该段代码进行重构;
|
||||
|
||||
上述过程不需要玄妙的理论和含混的代码美学,只需要机械的重复和简单的模式匹配。正因为此,重构才是一项完完全全具备可操作性、能够在任何遗留代码库上实践的技术。
|
||||
|
||||
培养对“坏味道”的判断力
|
||||
|
||||
当然,每位实践者仍然“必须培养出自己的判断力,学会判断一个类内有多少实例变量算是太大、一个函数内有多少行代码才算太长”。
|
||||
|
||||
就在最近,我看到某大厂的一位“代码委员会理事”在文章里说,某段代码“挺好的,长度没超过 80 行,逻辑比较清晰”。而在我看来,一个函数超过 7 行就已经是“太长”(这还是在考虑到 Java 语法比较啰嗦的前提下)。这就是不同实践者“自己的判断力”所体现的差异。
|
||||
|
||||
尽管从来没有明确指定对每个函数或类的代码行数要求,但“对象健身操”这篇文章(见于《ThoughtWorks 文集》)提出的 9 项规则已经有非常明确的指向:
|
||||
|
||||
方法中只允许使用一级缩进;
|
||||
|
||||
不允许使用 else 关键字;
|
||||
|
||||
封装所有的原生类型和字符串;
|
||||
|
||||
……
|
||||
|
||||
在这样的规则约束下,写出一个超过 10 行的函数将是相当困难的(实际上在“规则 6:保持实体对象简单清晰”中已经明确提出,每个类的长度不能超过 50 行)。
|
||||
|
||||
正如“对象健身操”这篇文章的作者 Jeff Bay 自己所说,这套“健身操”的意义在于:“在一个简单的项目里尝试一些比以前严格得多的编码标准……会迫使你更为严格地以面向对象的风格编写代码”,从而“以一种全新的方式思考你的代码”。
|
||||
|
||||
不过这得需要你刻意练习。正所谓“台上一分钟,台下十年功”,缺乏在受控环境下的刻意练习,很难通过工作中的自然积累提升判断力。
|
||||
|
||||
另外,对正确的代码构造足够熟悉,也是很重要的一个基本功,这个观点最早是 Kent Beck 的《实现模式》这本书中提到的。什么意思呢?
|
||||
|
||||
传说旧时民间古董店的学徒需要先在仓库里看真货,看得多了,见到假货时就会本能地提起警觉。对于代码也是一样:程序员需要熟悉正确的代码构造,在看到有问题的代码构造时才会本能地提起警觉。并且,“正确的代码构造”并非无穷无尽,实际上在单线程编程中,几十个常见的模式已经几乎能够完全覆盖所有场景。
|
||||
|
||||
Kent Beck 在前言里说“这是一本关于‘如何编写别人能懂的代码’的书”,尽管他还谦虚地说这本书“不是模式书籍”,但实际上《实现模式》充分地展现了“模式”的本意:它提供了一整套“用代码表述意图”的模式语言,这套语言能让程序员在最短的时间内学会如何写出具有表现力的代码,并且自然而然地远离坏味道。
|
||||
|
||||
从一开始就以合理的方式编程,从而使坏味道不要出现,我想这才是负责任的程序员应该采取的工作方式。
|
||||
|
||||
当然,极限编程的各种实践,尤其是工程技术实践彼此紧密相关。例如自动化测试、持续集成、集体代码所有制的缺失,都会导致代码的坏味道更容易堆积。而从另一个角度来看,这些实践从任何一个切入,又都会自然地引导出其他相关的实践。
|
||||
|
||||
一位“知行合一”的程序员最终会发现,极限编程是唯一合理且有效的软件开发方法。最终,只有采用以可工作的软件为核心的软件开发方法,才能得到高质量的可工作的软件,这就是《敏捷宣言》第二句关于坏味道的终极答案。
|
||||
|
||||
郑老师说
|
||||
|
||||
好了,熊节老师的分享就到这里,我是郑晔。
|
||||
|
||||
熊节老师对于问题的分析总是这么一针见血。重构就是一个模式匹配的过程,识别出坏味道,运用对应的重构手法解决问题。坏味道是一切重构的起点,而识别坏味道不是靠个人审美,而要依赖通用的标准。
|
||||
|
||||
我的这个专栏就是把一些坏味道用更直接的代码形式展现在你面前,让你可以日常的工作中,不断地锻炼自己的代码嗅觉。
|
||||
|
||||
思考题
|
||||
|
||||
经过熊节老师的讲解,你是不是对重构和坏味道有了新的认识呢?欢迎在留言区分享你更新过的想法。
|
||||
|
||||
感谢阅读,我们下一讲再见。
|
||||
|
||||
|
||||
|
||||
|
244
专栏/代码之丑/17课前作业点评:发现“你”代码里的坏味道.md
Normal file
244
专栏/代码之丑/17课前作业点评:发现“你”代码里的坏味道.md
Normal file
@ -0,0 +1,244 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
17 课前作业点评:发现“你”代码里的坏味道
|
||||
你好,我是郑晔。
|
||||
|
||||
在这个专栏刚开始的时候,我给你留了一个课前作业,实现一个待办事项管理的软件。许多同学都利用自己的业余时间完成了这个作业,感谢大家的付出!
|
||||
|
||||
学习代码的坏味道,听别人讲是一种方式,但这种方式总会让人有一种隔岸观火的感觉,虽然知道有问题,但感觉并不深刻。最直接受益的方式就是自己写了代码,然后,让别人来点评。其实,这就是某种形式的代码评审。
|
||||
|
||||
所以,这一讲,我们就来做一次“代码评审”,直接来看看代码中存在的问题。题目背景我就不再做过多的介绍了,如果没有来得及完成作业的同学,可以先到“[课前作业区]”回顾一下题目。
|
||||
|
||||
既然是指出问题,得罪大家可能就在所难免了,希望你不要介意,毕竟能够发现自己的问题是精进的第一步。好,我们开始!
|
||||
|
||||
从已知的坏味道出发
|
||||
|
||||
Item itemNew = new Item(item.getName());
|
||||
itemNew.setUserIndex(userIndex);
|
||||
itemNew.setIndex(initUserIndex);
|
||||
|
||||
|
||||
我们的业务需求是添加 TODO 项,这段代码就是在这个过程中创建一个新的 TODO 项对象。那这段代码有什么问题?一方面,这里有 setter,另一方面,这里的 setter 只在初始化的过程中用到。显然,我们可以用一个更完整的构造函数替换掉它。
|
||||
|
||||
其实,从这段代码出发,我们还能看到一些小问题,比如,这里创建 TODO 项设置了两个字段,一个是 userIndex,一个是 index。index 可以理解,表示这个 TODO 项的索引,但 userIndex 是什么呢?你需要仔细阅读代码才能发现,它其实是一个用户的标识,表示这个索引项是由某个用户创建的。既然是用户标识,按照通常的做法它可以叫 userId,这就降低了理解的难度。
|
||||
|
||||
这段代码所在类的声明也是一个让人出戏的地方:
|
||||
|
||||
public class ProcessTxtServiceImpl implements ProcessItemservice
|
||||
|
||||
|
||||
这个类实现了一个接口 ProcessItemservice,显然,这里的拼写是有问题的,它应该是 ProcessItemService,另外,它的名字叫做“处理(TODO)项的服务”,一方面,在一个服务名字上用了处理这个动词,另一方面,“处理”这个名字也是特别泛化的一个名字。如果是我来声明这个接口,它可能就叫 ItemService。
|
||||
|
||||
所以,你可以看到,仅仅是一个接口的命名,就有这么多的问题。
|
||||
|
||||
我们再来看这个类的命名 ProcessTxtServiceImpl,这个名字里有一个 Txt 是容易让人困惑的,一般来说,如果不是特别的原因,尽量不要用缩写。
|
||||
|
||||
我初看到这个名字时,着实想了半天它表示什么含义,一开始我以为是表示事务(Transaction),常有人把事务缩写成 Tx,如果它的含义是表示事务,那么这里就是一个拼写错误了。后来,我才想明白,这里的 Txt 表示的是文本(Text),仅仅省了一个字母,却造成了理解上更大的障碍,实在有些得不偿失。
|
||||
|
||||
如果 Txt 表示的是文本,这里就暴露出另外一个问题。这里为什么要有一个文本呢?其实是对应着另外一个数据库存储的实现,这是第四阶段的要求。
|
||||
|
||||
文本和数据库的差别到底是体现在哪里呢?体现在存储上。而在这段代码中,差别从服务层面就开始了,换言之,按照这段代码的逻辑,实现数据库存储,就需要把整个的业务逻辑重新写一遍。显然,这种做法是从结构上来看是有问题的,会造成大量的重复代码。
|
||||
|
||||
理解了文本和数据库只差别在存储这件事,我们再回过头来看这个类的声明。
|
||||
|
||||
public class ProcessTxtServiceImpl implements ProcessItemservice
|
||||
|
||||
|
||||
这个为数据库预留的实现根本就是不需要的,只有一个 ItemService 的实现就够了,换言之,也就没有必要声明出一个接口,这里的类层次这么复杂,根本就是没有必要的。
|
||||
|
||||
这里我再补充一个点,很多 Java 程序员给类命名有个不好的习惯,用“I” 打头给接口命名,用“Impl”给实现类结尾,这其实是早期的一种编程习惯,准确地说,这就是没有想好命名的偷懒方式。其实,它也是我们讲到的“[用技术术语命名]”的一种具体体现方式。后来的代码基本上就不这么做了,因为我们可以找到更准确的描述。但很多人的编程习惯却留在了早期,所以,这也算是一种遗毒的吧。
|
||||
|
||||
一个“静态”的问题
|
||||
|
||||
接下来,我们再来看一个很多人代码中都存在的问题。
|
||||
|
||||
下面是来自刘大明同学的一段代码,这是一个用以存放用户信息的类。单看这段代码本身,其实写得还是非常不错的,代码本身并不长,而且考虑了很多的细节。我们暂且忽略其它的细节,我注意到这段代码的主要原因是因为它用到了 static:
|
||||
|
||||
public class UserContext {
|
||||
|
||||
private static ThreadLocal<Integer> USERID = new ThreadLocal();
|
||||
|
||||
private UserContext() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
public static String getUserID() {
|
||||
return String.valueOf(USERID.get());
|
||||
}
|
||||
|
||||
public static void setUserID(Integer userID) {
|
||||
USERID.set(userID);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
在《10x 程序员工作法》讲到[测试驱动开发]时,我曾经讲了 static 函数的问题,简单总结一下就是:
|
||||
|
||||
从本质上说,static 函数是一种全局函数,static 变量是一种全局变量,全局的函数和变量是我们尽量规避的;
|
||||
|
||||
一个函数调用了 static 函数不好测试;
|
||||
|
||||
除了写程序库,日常开发尽可能不用 static 函数。
|
||||
|
||||
那怎么消除 static 函数呢?消除 static 函数,最简单的做法就是用普通的函数调用替换掉 static 函数,也就是把这里的 static 都去掉。涉及到相应的字段,也要去掉 static。这种做法没有问题,但通常这种做法太粗暴了。这里我们尝试着用重构的方式一步一步地把它替换掉。
|
||||
|
||||
首先,我要去掉这里的构造函数,因为这里的构造函数是私有的,无法调用,而我们要用普通的函数,自然就需要构造出一个对象来。
|
||||
|
||||
public class UserContext {
|
||||
|
||||
private static ThreadLocal<Integer> USERID = new ThreadLocal();
|
||||
|
||||
public static String getUserID() {
|
||||
return String.valueOf(USERID.get());
|
||||
}
|
||||
|
||||
public static void setUserID(Integer userID) {
|
||||
USERID.set(userID);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
然后,我们需要找到对应的调用点,这里就以其中的一个为例,下面就是在退出登录的地方调用了这里的 static 函数:
|
||||
|
||||
public class UserAccounts {
|
||||
|
||||
...
|
||||
|
||||
public void loginOut() {
|
||||
UserContext.setUserID(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
我们可以把它改成对象的调用:
|
||||
|
||||
public class UserAccounts {
|
||||
|
||||
...
|
||||
|
||||
public void loginOut() {
|
||||
new UserContext().setUserID(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
这样,我们就有了一个对象,因为原来的函数是 static 函数,所以,这里的调用,本质上还是原来的函数,所以不会有影响。
|
||||
|
||||
然后,我们把这个创建出的对象变成这个类的字段,如果你使用的是支持重构功能的 IDE,这就是一个快捷键的操作(引入字段,Introduce Field):
|
||||
|
||||
public class UserAccounts {
|
||||
|
||||
...
|
||||
|
||||
private UserContext context = new UserContext();
|
||||
|
||||
public void loginOut() {
|
||||
context.setUserID(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
如果在一个类有多个调用点,不妨都改成这个新字段的函数调用,正如我们前面所说,目前还是一个 static 函数,无论从哪个对象调用,调用的都是同一个函数。
|
||||
|
||||
通常来说,这个 static 函数应该不只是在一个类中使用,所以,它应该是在多个类中间共享的,为了保证多个类中间使用同一个 UserContext 对象,UserContext 对象的初始化就不能在这个类进行,而要在同一个地方初始化,所以,我们这里可以把 UserContext 对象作为构造函数的参数传进来:
|
||||
|
||||
public class UserAccounts {
|
||||
|
||||
...
|
||||
|
||||
private UserContext context;
|
||||
|
||||
public UserAccounts(..., final UserContext context) {
|
||||
|
||||
...
|
||||
|
||||
this.context = context;
|
||||
|
||||
}
|
||||
|
||||
public void loginOut() {
|
||||
context.setUserID(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
有了这个基础,我们再在 UserAccounts 这个对象初始化的时候,把这个 UserContext 对象传进来:
|
||||
|
||||
new UserAccounts(..., new UserContext());
|
||||
|
||||
|
||||
如此一来,UserContext 这个对象的初始化就放到对象组装的过程中了,这就可以在多个不同的对象组件中共享这个对象了。如此往复,将所有的调用点都这么修改,我们就消除了对于 static 函数的依赖。现在,我们可以动手消除 static 了:
|
||||
|
||||
public class UserContext {
|
||||
|
||||
private ThreadLocal<Integer> USERID = new ThreadLocal();
|
||||
|
||||
public String getUserID() {
|
||||
return String.valueOf(USERID.get());
|
||||
}
|
||||
|
||||
public void setUserID(Integer userID) {
|
||||
USERID.set(userID);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
消除 static 函数本身并不难,这里我是借着这个简单的例子,给你演示一下,如何一步一步地进行重构。可能这比很多人以为的大刀阔斧地修改代码来得要琐碎得多,但只有这样一点一点调整,代码足够安全,每一步都是能够停下来的。
|
||||
|
||||
无论如何,请别忘了,真正能给予我们修改有效性回答的是,单元测试。
|
||||
|
||||
估计很多人看到这里就会说,如果 static 都成了坏味道,那 Singleton 模式该怎么办呢?答案就是尽可能不用 Singleton 模式。我在《软件设计之美》中讲[可测试性]和[设计模式]时,都说到过 Singleton 模式,简单地说,系统里只有一个实例和限制系统里只能构建出一个实例,这是两件事,而且,如果一个函数牵扯到 Singleton 类也不好测试。
|
||||
|
||||
在一些同学的代码中,我也看到的 Singleton 模式的使用,处理手法其实与这里消除 static 函数是类似的,只不过,Singleton 稍微好一点的是,它的函数和字段本身都已经是普通的类成员了,我们只需要把那个限制实例唯一的 static 函数和字段消除就可以了。
|
||||
|
||||
说了半天的代码问题,我还想对很多人普遍忽略的小问题说上几句,这就是文档,对应到各位的代码库中,主要就是 README。
|
||||
|
||||
一个开源项目的好坏与否,同它的文档质量是强相关的。我知道,作为程序员,大家的普遍兴趣都是写代码,所以,文档就常常被忽略了。
|
||||
|
||||
如果我不了解这个项目的背景,很多人的 README 给我提供的信息量是非常有限的。
|
||||
|
||||
大家的 README 普遍存在的问题有两种,一种是信息量太少,比如,只写了如何构建一个项目,另一种是把 README 当成 blog,在里面写了自己的心得体会。无论是哪种,信息的有效性都很差。
|
||||
|
||||
README 文件是一个项目的门面,它应该给我们提供关于这个项目的背景信息,比如,这个项目是做什么的、当前的状态、如何入手等等。你可以找一些经典的开源项目,去看看好的 README 是怎么写的。好的程序员要学会表达,不仅仅会用代码表达,也要会用文字表达。
|
||||
|
||||
好,这就是大家作业中的所有问题了吗?当然不是,代码中存在的问题还很多。不过,你不用担心,即便这个专栏的正式更新结束了,我也会考虑以加餐的形式,继续我们这个云端的代码评审环节。所以,之前没来得及写代码的同学依然可以继续写,说不定下次就会谈到你的代码。
|
||||
|
||||
总结时刻
|
||||
|
||||
今天我们点评了大家代码中存在的一些问题,除了之前在专栏中讲到过的坏味道,今天我们还讲到了一些一眼就可以看出问题的坏味道:
|
||||
|
||||
使用缩写;
|
||||
|
||||
用 I 表示接口,用 Impl 表示实现;
|
||||
|
||||
使用 static 函数或变量;
|
||||
|
||||
使用 singleton 模式;
|
||||
|
||||
写得潦草的 README。
|
||||
|
||||
你在写代码时也要注意这些问题。
|
||||
|
||||
我还借着 static 函数的调整过程,给你演示了如何一步一步地重构代码,保证代码的安全。希望你能够理解,重构不是大开大合的过程,而就是这样细小而微的操作。
|
||||
|
||||
如果今天的内容你只能记住一件事,那请记住:尽量不使用 static。
|
||||
|
||||
|
||||
|
||||
思考题
|
||||
|
||||
我们今天谈到了文档,你平时写文档吗?或者,你平时阅读项目文档,发现什么值得改善的地方吗?欢迎在留言区分享你的经验。
|
||||
|
||||
|
||||
|
||||
|
85
专栏/代码之丑/结束语写代码是一件可以一生精进的事.md
Normal file
85
专栏/代码之丑/结束语写代码是一件可以一生精进的事.md
Normal file
@ -0,0 +1,85 @@
|
||||
|
||||
|
||||
因收到Google相关通知,网站将会择期关闭。相关通知内容
|
||||
|
||||
|
||||
结束语 写代码是一件可以一生精进的事
|
||||
你好,我是郑晔。
|
||||
|
||||
春节将至,祝你新春快乐!我们的专栏到这里也正好要更新完结了,在结束语这一讲,我想和你聊聊程序员精进的话题。
|
||||
|
||||
创作《[10x 程序员工作法]》之初,我曾经定下了“写下 100 篇”的宏伟目标。在第三个专栏结束的时候,这个当年许下的宏伟目标终于实现了。
|
||||
|
||||
如果为这 100 篇的内容找一个共同的主题,那就是程序员精进之路。
|
||||
|
||||
程序员精进之路
|
||||
|
||||
在很多人心目中,程序员是一个辛苦的职业,一方面,各种新东西层出不穷,程序员们要努力追随,另一方面,业务飞速发展,我们唯有积极应对。那么,是什么支撑你在这个富有挑战的行业里坚持前行呢?
|
||||
|
||||
于我而言,这个问题的答案是,热爱。
|
||||
|
||||
在我的心目中,编程是一项有趣的智力活动,从最初解决一个特定的小问题,到现在创造一个方案去解决一个系统的问题,无不需要费尽心力去探寻一个好的解决方案。时至今日,即便我写程序已经二十多年了,但每次程序运行通过时,我心里依然还是有一些小激动,因为支撑程序运行的每行代码里都有自己的思考在里面。
|
||||
|
||||
正是每次一点点积累起来的成就感,激励着我不断去探索更好的做法。一开始,为了解决特定的问题,我四处搜集着各种编程技巧。当我理解了基本功的重要性后,就去拼命地补齐各种基础知识,构建起一个知识体系。随着开源软件运动的兴起,我知道了,原来有各种工具和程序库可以简化自己的工作。因为见识过别人的运指如飞,我曾专门练习了各种快捷键和命令行。
|
||||
|
||||
当我已经能够很好地解决自己面对的各种功能问题时,我开始抬起头,有了更大的视野。
|
||||
|
||||
我学习了各种软件设计的知识,让自己的代码不仅仅是为了今天,也能够面对未来。我学习了各种程序设计语言,看到了隐藏在语言背后的编程范式和思考习惯。我学习了各种软件开发的最佳实践,懂得了怎样让一群人更好地协同。
|
||||
|
||||
我做的所有努力,都是为了更好地写代码。
|
||||
|
||||
有了对于软件开发更多的思考,回过头再来写代码时,我就能看到更多的维度,能意识到自己在写的代码对他人和未来的影响,这时自然会尽力把自己的代码写得更整洁。
|
||||
|
||||
时至今日,如果你问我,对自己写的代码满意吗?我的答案还是不满意。写代码是一门手艺,需要不断地打磨。一方面,坚持写代码,保持自己对于代码的体感;另一方面,保持对于代码的敏感度,不断思考对于代码的改进,寻找更好的写法。
|
||||
|
||||
经过一段时间,我总会发现代码中让我不满意的地方,这会成为新的驱动力,让我进一步扩充自己的知识,把新的理解注入到代码之中。每次拓展知识边界,与之相伴的都是极大的智力愉悦。也正是这种智力上的快感,让我得到了进一步前进的动力。一个正向反馈的循环就是这样逐步推进,让我在写了二十多年代码之后,依然乐此不疲。
|
||||
|
||||
我在《[软件设计之美]》中讲过,一个好的设计是在一个“小内核”上构建起来,然后,逐步添加更多模型。我们的知识拓展过程也是如此。我的“小内核”就是编写代码这件事,所有一切知识的拓展都是围绕这个内核展开的。
|
||||
|
||||
写代码是一件可以持续一生的事情,但前提条件是,找到自己的热爱,建立起自己的正向反馈。坚持写代码,发掘代码中值得改进的地方,不断拓展自己的知识边界,寻找更好的代码写法,这就是最朴素的程序员精进之路。
|
||||
|
||||
代码的敏感度
|
||||
|
||||
对于一个不断精进的程序员而言,发掘代码中值得改进的地方,需要对代码有细致入微的敏感度,这样才能体察代码间细微的差别。
|
||||
|
||||
我给你举个例子:注释。
|
||||
|
||||
代码该不该写注释呢?在一些人看来,没有注释的代码不值得写,这甚至成了一些程序员的宗教信仰。如果你问他们为什么要写注释,他们的回答多半是“让程序更加容易理解”。
|
||||
|
||||
但有另外一群人则将注释视为坏味道,他们会说,为什么不把代码写得更清楚,让代码不需要注释呢?被逼到角落的“注释程序员”依然不会束手就擒,他们不会承认自己不能把代码写清楚,而会说,有些代码必须要有注释才能解释清楚,比如一些算法。
|
||||
|
||||
好了,双方的主要观点陈述完毕。你怎么看待注释呢?
|
||||
|
||||
早在 1984 年,《计算机程序设计艺术》的作者 Donald Knuth 就给出了一个回答,他提出了“文学编程(Literate Programming)”的概念,其核心要义就是要将程序写得像用自然语言进行表达一样顺畅。虽然作为一种编程范式,它并没有流行起来,但它背后蕴含的思想却影响了很多人,也给我们提出了更高的技术追求。
|
||||
|
||||
一个好的程序应该像一篇优美的文章,读起来自然流畅,二者背后有诸多相通之处,你会看到,许多优秀程序员都有着优秀的表达能力。所以,回到写代码本身,把程序本身写得更清楚直白才应该是我们的追求。关于如何把代码写好,我在这个专栏已经讲了很多了。
|
||||
|
||||
具体“注释”这件事上,我的观点是,“注释”有其价值,但不应该是主力。我们没见过哪篇文章是要求把注脚作为主旨的,同样,过于强调注释,无异于本末倒置。写代码首先应该是把代码本身写好,至于那些确实无法用代码陈述清楚的部分,我们再考虑用注释。
|
||||
|
||||
所以,我赞同把“注释”当做坏味道的提示,先竭力把代码写到不需要用注释,而把注释当作最后的选择。确实有一些特定的处理需要注释,无论是一个精巧的算法,还是一个特殊的技巧。用这个标准要求自己,你会发现,大多数代码其实不需要注释,因为它们太普通了。
|
||||
|
||||
你看到了,即便像注释这么简单的东西,写与不写,背后都有着可以探究的各种细节。诚如我在前面所说,写代码是一门手艺,需要不断地打磨。唯有不限界地拓展自我,才可能对代码有细致入微地把握。
|
||||
|
||||
你发现了,打磨手艺,锤炼自己对于代码的敏感度,坏味道是一个不错的出发点。这也是我写这个专栏的初衷,帮你从识别出那些你曾视而不见的“坏味道”,提升你对代码的敏感度。
|
||||
|
||||
在这个专栏中,我给出的就是全部的坏味道吗?显然不是。只要拿出《重构》对照一下,你就会发现,坏味道还有许多,比如,霰弹式修改和发散式变化。我没有拿出来讲,不是它们不重要,而是它们不像我在这个专栏中罗列的这些坏味道那样,有非常直观的表现。
|
||||
|
||||
比如,霰弹式修改说的是一次变化要在很多类的内部做修改,但能否察觉出自己改了很多类,这就依赖于每个人的敏感度了。
|
||||
|
||||
同样,发散式变化说的是,不同的变化都会改到同样的模块上。发现这种坏味道,需要你意识到,对同一个模块的修改是由于不同的原因造成的,这对于敏感度的要求就更高了。
|
||||
|
||||
无论如何,“知道”有哪些坏味道是第一步的。我建议你在学习了本专栏之后,花上一点时间,通读一下《重构》的第三章“代码的坏味道”,在开篇词中我就提到过这件事,但与那时不同的是,现在你已经通关了我们这个专栏。
|
||||
|
||||
我在专栏里讲的所有这一切,一方面,让你对一些代码的坏味道有直观的认识;另一方面,也是更重要的,对于这些坏味道的分析,是为了帮你看到代码里的细微之处,帮助你提升对于代码的敏感度。有了不同的敏感度,再去通读“代码的坏味道”,你会有不一样的收获。
|
||||
|
||||
这些道理都是知易行难,今天我们的课程就告一段落了,但是你的精进之路并未停止。
|
||||
|
||||
有了“坏味道”的基础之后,接下来最重要的是,你要在实际的工作中反复地锤炼自己的编程手艺,用这些坏味道作为尺子,衡量自己的代码,不断地找到代码更好的写法。这个专栏以及它的两个“兄弟”,帮你开启了程序员的精进之路,但这条路,总归还是要自己去走!
|
||||
|
||||
这次的《代码之丑》的旅程就暂告一段落吧,如果以后有机会,我会再来与你分享我对软件开发的理解。
|
||||
|
||||
专栏结束了,你也可以在留言区提问,我看到还是会回复的。也欢迎你点击下面图片,为我的《代码之丑》评分、提建议。期待你的反馈,再见!
|
||||
|
||||
|
||||
|
||||
|
Reference in New Issue
Block a user