代码整洁之道
快来分享你的内容吧~
<<代码之丑>>13 | 落后的代码风格:使用“新”的语言特性和程序库升级你的代码
上一讲,我们讲的是因为代码不一致造成的坏味道,其中我提到的“方案不一致”,是因为随着时间的流逝,总会有一些新的方案产生,替换原有的方案。这其中,最明显的一个例子就是程序设计语言。没有哪门语言是完美的,所以,只要有一个活跃的社区,这门语言就会不断地演进。 从 C++ 11 开始,C++ 开始出现了大规模的演化,让之前学习 C++ 的人感觉自己就像没学过这门语言一样;Python 2 与 Python 3 甚至是不兼容的演化;Java 也是每隔一段时间就会出现一次大的语言演进。 也正是因为语言本身的演化,在不同时期接触不同版本的程序员写出来的程序,甚至不像是在用同一门语言在编程。所以,我们有机会看到在同一个代码库中,各种不同时期风格的代码并存。 通常来说,新的语言特性都是为了提高代码的表达性,减少犯错误的几率。所以,在实践中,我是非常鼓励你采用新的语言特性写代码的。我准备讨论的是 Java 8 的语言特性,按照官方的标准,这是一个已经到了生命周期终点的版本,只不过,从语言特性上来说,Java 8 是最近有重大变更的一个版本,而很多程序员的编码习惯停留在更早的版本。 ### Optional ```java String name = book.getAuthor().getName(); ``` 这是我们在讲“ 缺乏封装”时用到的一个例子,我们这里暂且不考虑缺乏封装的问题。即便如此,严格地说,这段代码依然是有问题的。因为它没有考虑对象可能为 null 的场景。 所以,这段代码更严谨的写法是这样: ```java Author author = book.getAuthor(); String name = (author == null) ? null : author.getName(); ``` 然而,在很多真实的项目中,这种严格的写法却是稀有的,所以,在实际的运行过程中,我们总会惊喜地发现各种空指针异常。如果你要问程序员为什么不写对象为 null 的判断,答案很可能出乎你意料:他们忘了。是的,忘了,就是这么简单得令人发指的理由。 不用过于责备这些程序员缺乏职业素养,因为这不是个体问题,而是行业整体的问题,IT行业每年都会因此造成巨大的损失。空指针的发明者 Tony Hoare 将其称为“自己犯下的十亿美元错误”。 对于这个如此常见的问题,Java 8 中已经给出了一个解决方案,它就是 Optional。Optional 提供了一个对象容器,你需要从中“取出(get)”你所需要的对象,但在取出之前,你需要判断一下这个对象容器中是否真的存在一个对象。用这个思路可以这样改写这段代码: ```java class Book { public Optional<Author> getAuthor() { return Optioanl.ofNullable(this.author); } ... } Optional<Author> author = book.getAuthor(); String name = author.isPresent() ? author.get() : null; ``` 这种做法和之前做法的最大差别在于,你不会忘掉判断对象是否存在的过程,因为你需要从 Optional 这个对象容器中取出存在里面的对象。正是这多出来的一步,减少了“忘了”的概率。 也是因为多了 Optional 这个类,这段代码其实还有更简洁的写法: ```java Optional<Author> author = book.getAuthor(); String name = author.orElse(null); ``` 有了 Optional,我们可以在项目中做一个约定,所有可能为 null 的返回值,都要返回Optional,以此减少犯错的几率。 事实上,鉴于空对象是一个普遍存在的问题,一些程序设计语言甚至为此专门设计了语法,比如,类似的代码用 Kotlin 或 Groovy 写出来的话,应该是这下面这样: ```java val author = book.author val name = author?.name ``` ### 函数式编程 Optional 是 Java 8 引入的新特性,它的出现改变了编写 Java 代码的习惯用法。接下来,我们来看看另外一个改变我们代码习惯用法的特性。在讲“ 滥用控制语句”那一讲时,我留下了一个尾巴,说循环语句本身就是一个坏味道。接下来,我们就来说一下这个问题。我们还是先从一段代码开始: ```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)。之所以循环语句成了坏味道,一个重要的原因就是函数式编程的兴起。**不是我们不需要遍历集合,而是我们有了更好的遍历集合的方式。** 了解了这些,你就知道为什么循环语句是坏味道了,因为大部分循环语句都是在对一个元素集合进行操作,而这些操作基本上都可以用列表操作进行替代。再者,一般来说,采用列表转换写出来的代码相较于传统的循环语句写出来的代码,表达性更好,因为它们都是描述做什么,而传统的循环语句是在描述怎么做。我在这个专栏已经多次说过了,这是两种不同的抽象层次,描述做什么比怎么做的代码,在表达性上要好得多。 有了这些基础,我们再来看这段代码。这段代码中有一个循环语句,正如前面所说,这个循环语句在处理的是一个集合中的元素,所以,这个循环语句是可以用列表转换的方式代替的。这段代码可以改写成这样: ```java 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 中写过多的代码。 作为一个精进的程序员,我们要不断地学习“新”的代码风格,改善自己的代码质量,不要故步自封,让自己停留在上一个时代。如果今天的内容你只能记住一件事,那请记住:**不断学习“新”的代码风格,不断改善自己的代码。**
<<代码之丑>>12 | 不一致的代码:为什么你的代码总被吐槽难懂?
今天,我们再来看一类需要你打起精神的坏味道,它们的出发点也是来自同一个根源:一致性。 大多数程序员都是在一个团队中工作,对于一个团队而言,一致性是非常重要的一件事。因为不一致会造成认知上的负担,在一个系统中,做类似的事情,却有不同的做法,或者起到类似作用的事物,却有不同的名字,这会让人产生困惑。所以,即便是不甚理想的标 准,也比百花齐放要好。 大部分程序员对于一致性本身的重要性是有认知的。但通常来说,大家理解的一致性都表现在比较大的方面,比如,数据库访问是叫 DAO 还是叫 Mapper,抑或是 Repository,在一个团队内,这是有统一标准的,但编码的层面上,要求往往就不是那么细致了。所以,我们才会看到在代码细节上呈现出了各种不一致。我们还是从一段具体的代码来分析问题。 ### 命名中的不一致 有一次,我在代码评审中看到了这样一段代码: ```java enum DistributionChannel { WEBSITE KINDLE_ONLY AL } ``` 这段代码使用标记作品的分发渠道,从这段代码的内容上,我们可以看到,目前的分发渠道包括网站(WEBSITE)、只在 Kindle(KINDLE_ONLY),还是全渠道(ALL)。 面对这段代码,我有些疑惑,于是我提了一个问题: >我:这里的 WEBSITE 和 KINDLE_ONLY 分别表示的是什么? >同事:WEBSITE 表示作品只会在我们自己的网站发布,KINDLE_ONLY 表示这部作品只 >会在 Kindle 的电子书商店里上架。 >我:二者是不是都表示只在单独一个渠道发布? >同事:是啊! >我:既然二者都有只在一个平台上架发布的含义,为什么不都叫 XXX 或者 >XXX_ONLY? >同事:呃,你说得有道理。 我之所以会注意到这里的问题,一个主要的原因就是,在这里 WEBSITE 和KINDLE_ONLY 两个名字的不一致。 按照我对一致性的理解,表示类似含义的代码应该有一致的名字,比如,很多团队里都会把业务写到服务层,各种服务的命名也通常都是 XXXService,像 BookService、ChapterService 等等。而一旦出现了不一致的名字,通常都表示不同的含义,比如,对于那些非业务入口的业务组件,它们的名字就会不一样,会更符合其具体业务行为,像BookSender ,它表示将作品发送到翻译引擎。 一般来说,枚举值表示的含义应该都有一致的业务含义,一旦出现不同,我就需要确定不同的点到底在哪里,这就是我提问的缘由。 显然,这段代码的作者给这两个枚举值命名时,只是分别考虑了它应该起什么名字,却忽略了这个枚举值在整体中扮演的角色。 ### 方案中的不一致 还是在一次代码评审中,我看到了这样一段代码: ```java public String nowTimestamp() { DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); Date now = new Date(); return format.format(now); } ``` 这是一段生成时间戳的代码,当一个系统向另外一个系统发送请求时,需要带一个时间戳过去,这里就是把这个时间戳按照一定格式转成了字符串类型,主要就是传输用,便于另外的系统进行识别,也方便在开发过程中进行调试。这段代码本身的实现是没有问题的。它甚至考虑到了 SimpleDateFormat 这个类本身存在的多线程问题,所以,它每次去创建了一个新的SimpleDateFormat 对象。 那我为什么还说它是有问题的呢?因为这种写法是 Java 8 之前的写法,而我们用的 Java版本是 Java 8 之后的。 在很长的一段时间里,Java 的日期时间解决方案一直是一个备受争议的设计,它的问题很多,有的是概念容易让人混淆(比如:Date 和 Calendar 什么情况下该用哪个),有的是接口设计的不直观(比如:Date 的 setMonth 参数是从 0 到 11),有的是实现容易造成问题(比如:前面提到的 SimpleDateFormat 需要考虑多线程并发的问题,需要每次构建一个新的对象出来)。 这种乱象存在了很长时间,有很多人都在尝试解决这个问题Java 8 开始,Java 官方的 SDK 借鉴了各种程序库,引入了全新的日期时间解决方案。这套解决方案与原有的解决方案是完全独立的,也就是说,使用这套全新的解决方案完全可以应对我们的所有工作。 我们现在的这个项目是一个全新的项目,我们使用的版本是 Java 11,这就意味着我们完全可以使用这套从 Java 8 引入的日期时间解决方案。所以,我们在项目里的约定就是所有的日期时间类型就是使用这套新的解决方案。 现在你可能已经知道我说的问题在哪里了,在这个项目里,我们的要求是使用新的日期时间解决方案,而这里的 SimpleDateFormat 和 Date 是旧解决方案的一部分。所以,虽然这段代码本身的实现是没有问题的,然而,放在项目整体中,这却是一个坏味道,因为它没有和其它的部分保持一致。 之所以会出现这样的问题,主要是因为一个项目中,应对同一个问题出现了多个解决方案,如果没有一个统一的约定,项目成员会根据自己写代码时的感觉随机选择一个方案,这样的结果就是出现方案上的不一致。 为什么一个项目中会出现多个解决方案呢?一个原因就是时间。随着时间流逝,人们会意识到原有解决方案存在的各种问题,于是,有人就会提出新的解决方案,像我们这里提到的 Java 日期时间的解决方案,就是 JDK 本身随时间演化造成的。有的项目时间比较长,也会出现类似的问题,尤其是像 C/C++ 这种自造轮子的重灾区。 有时,程序员也会因为自己的原因引入不一致。比如,在代码中引入做同一件事情类似的程序库。像判断字符串是否为空或空字符串,Java 里常用的程序库就有 Guava 和 Apache 的 Commons Lang,它们能做类似的事情,所以,程序员也会根据自己的熟悉程度选择其中之一来用,造成代码中出现不一致。 这两个程序库是很多程序库的基础,经常因为引入了其它程序库,相应的依赖就出现在我们的代码中。所以,我们必须约定,哪种做法是我们在项目中的标准做法,以防出现各自为战的现象。比如,在我的团队中,我们就选择 Guava 作为基础库,因为相对来说,它的风格更现代,所以,团队就约定类似的操作都以 Guava 为准。 ### 代码中的不一致 ```java public void createBook(final List<BookId> bookIds) throws IOException { List<Book> books = bookService.getApprovedBook(bookIds) CreateBookParameter parameter = toCreateBookParameter(books) HttpPost post = createBookHttpRequest(parameter) httpClient.execute(post) } ``` 这是一段在翻译引擎中创建作品的代码。首先,根据要处理的作品 ID 获取其中已经审核通过的作品,然后,发送一个 HTTP 请求在翻译引擎中创建出这个作品。 这么短的一段代码有什么问题吗?问题就在于这段代码中的不一致。你可能会想:“不一致?不一致体现在哪里呢?”答案就是,这些代码不是一个层次的代码。 通过了解这段代码的背景,你可能已经看出一些端倪了。首先是获取审核通过的作品,这是一个业务动作,接下来的三行其实是在做一件事,也就是发送创建作品的请求。具体到代码上,这三行代码分别是创建请求的参数,根据参数创建请求,最后,再把请求发送出去。这三行代码合起来完成了一个发送创建作品请求这么一件事,而这件事才是一个完整的业务动作。 所以,我说这个函数里的代码并不在一个层次上,有的是业务动作,有的是业务动作的细节。理解了这一点,我们就可以把这些业务细节的代码提取到一个函数里: ```java public void createBook(final List<BookId> bookIds) throws IOException { List<Book> books = bookService.getApprovedBook(bookIds) createRemoteBook(books) } private void createRemoteBook(List<Book> books) throws IOException { CreateBookParameter parameter = toCreateBookParameter(books) HttpPost post = createBookHttpRequest(parameter) httpClient.execute(post) } ``` 从结果上看,原来的函数(createBook)里面全都是业务动作,而提取出来的函数(createRemoteBook)则都是业务动作的细节,各自的语句都是在一个层次上了。能够分清楚代码处于不同的层次,基本功还是分离关注点, 一旦我们将不同的关注点分解出来,我们还可以进一步调整代码的结构。像前面拆分出来的这个方法,我们已经知道它的作用是发出一个请求去创建作品,本质上并不属于这个业务类的一部分。所以,我们还可以通过引入一个新的模型,将这个部分调整出去: ```java 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 这个类,这个方法就变成了一个公开方法,我们就可以按照一个公开方法去测试了,所有的问题迎刃而解。 **很多程序员纠结的技术问题,其实是一个软件设计问题,**不要通过奇技淫巧去解决一个本来不应该被解决的问题。 如果今天的内容你只能记住一件事,那请记住:**保持代码在各个层面上的一致性**
<<代码之丑>>11 | 依赖混乱:你可能还没发现问题,代码就已经无法挽救了
这一讲,我们要讲的坏味道就不属于一下子就能看出来的,需要你稍微仔细一点看代码才会发现问题,那就是依赖关系。 我前面在讲“大类”这个坏味道的时候曾经说过,为了避免同时面对所有细节,我们需要把程序进行拆分,分解成一个又一个的小模块。但随之而来的问题就是,我们需要把这些拆分出来的模块按照一定的规则重新组装在一起,这就是依赖的缘起。一个模块要依赖另外一个模块完成完整的业务功能,而到底怎么去依赖,这里就很容易产生问题。 ### 缺少防腐层 ```java @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 之所以弄得如此“里外不是人”,主要就是因为它只能扮演一个层中的模型,所以,我们只要再引入一个模型就可以破解这个问题。 ```java 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,可以这么做: ```java class NewBookRequest { public NewBookParameters toNewBookRequest(long userId) { ... } } @PostMapping("/books") public NewBookResponse createBook(final NewBookRequest request, final Authenti authentication){ long userId = getUserIdentity(authentication); boolean result = this.service.createBook(request.toNewBookParameter(userId)) ... } ``` 我们之所以能注意到这个坏味道,就是从依赖关系入手发现的问题。我当初注意到这段代码,因为我团队内部的约定是,所有的请求对象都属于 resource 层,但在这段代码里,service 层出现了 resource 层的对象,它背离了我们对依赖关系设计的约定,所以,这个问题就浮出了水面。 实际上,这个问题也是一个典型的软件设计问题:缺少防腐层。 而很多人初见这个例子,可能压根想不到它与防腐层的关系,那只不过是因为你对这种结构太熟悉了。其实,resource 层就是外部请求和核心业务之间的防腐层。只要理解了这一点,你就能理解这里要多构建出一个业务参数对象的意义了。那下面这段代码,想必你也能轻易地发现问题: ```java @Entity @Table(name = "user") @JsonIgnoreProperties(ignoreUnknown = true) class User { ... } ``` 这是一个 User 类的声明,它有 @Entity 这个 Anntation,表示它是一个业务实体的对象,但它的上面还出现了 @JsonIgnoreProperties,这是就是处理 JSON 的一个Annotation。JSON 会在哪用到,通常都是在传输中。业务实体和传输对象应该具备的特质在同一个类中出现,显然,这也是没有构建好防腐层的结果,把两个职责混在了一起。 ### 业务代码里的具体实现 ```java @Task public void sendBook() { try { this.service.sendBook(); } catch (Throwable t) { this.feishuSender.send(new SendFailure(t))); throw t; } } ``` 这是我们在“ 重复代码”那一讲中提到的一个发送作品信息的函数,这里的重点在于,一旦发送过程出了问题,要通过即时通信工具发送给相关人等,以防系统出现问题无人发觉。只不过,这里给出的是它最初的样子,也就是通过飞书进行消息发送。 因为需求是通过飞书发送,所以,这里就写了飞书发送。这看上去简直是一个合理得不能再合理的做法了。但是,请稍等!这是一种符合直觉的做法,然而,它却不符合设计原则,它违反了依赖倒置原则。 我之所以会注意到这段代码,因为在一段业务处理中出现了一个具体的实现,也就是这里的 feishuSender。 你需要知道,**业务代码中任何与业务无关的东西都是潜在的坏味道** 在这里,飞书肯定不是业务的一部分,它只是当前选择的一个具体实现。换言之,是否选择飞书,与团队当前的状态是相关的,如果哪一天团队切换即时通信软件,这个实现就需要换掉。但是,团队是不可能切换业务的,一旦切换,那就是一个完全不同的系统了。 **识别一个东西是业务的一部分,还是一个可以替换的实现,我们不妨问问自己,如果不用它,是否还有其它的选择?** 就像这里,飞书是可以被其它即时通信软件替换的。另外,常见的中间件,比如,Kafka、Redis、MongoDB 等等,通常也都是一个具体的实现,其它中间件都可以把它替换掉。所以,它们在业务代码里出现,那一定就是一个坏味道了。 既然我们已经知道了,这些具体的东西是一种坏味道,那该怎么解决呢?你可以引入一个模型,也就是这个具体实现所要扮演的角色,通过它,将业务和具体的实现隔离开来。 ```java interface FailureSender { void send(SendFailure failure); } class FeishuFailureSenderS implements FailureSender { ... } ``` 这里我们通过引入一个 FailureSender,业务层只依赖于这个 FailureSender 的接口就好,而具体的飞书实现可以通过依赖注入的方式注入进去。 依赖关系是软件开发中非常重要的一个东西,然而,很多程序员在写代码的时候,由于开发习惯的原因,常常会忽略掉依赖关系这件事本身。现在已经有一些工具,可以保证我们在写代码的时候,不会出现严重破坏依赖关系的情况,比如,像前面那种 service 层调用resource 层的代码。 在 Java 世界里,我们就可以用 ArchUnit 来保证这一切。看名字就不难发现,它是把这种架构层面的检查做成了单元测试,下面就是这样的一个单元测试: ```java @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。 如果今天的内容你只能记住一件事,那请记住:**代码应该向着稳定的方向依赖。**
<<代码之丑>>10 | 变量声明与赋值分离:普通的变量声明,怎么也有坏味道?
这一讲,我们再来挑战一个很多人习以为常的编程习惯:变量的声明与赋值。 变量声明是写程序不可或缺的一部分,我并不打算让你戒掉变量声明,严格地说,我们是要把变量初始化这件事做好。 ### 变量的初始化 ```java 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 状态的作品,还需要再次尝试创建。 我们这次的重点在 status 这个变量上,虽然 status 这个变量在声明的时候,就赋上了一个 null 值,但实际上,这个值并没有起到任何作用,因为 status 的变量值,其实是在经过后续处理之后,才有了真正的值。换言之,从语义上说,第一行的变量初始化其实是没有用的,这是一次假的初始化。 按照我们通常的理解,一个变量的初始化是分成了声明和赋值两个部分,而我这里要说的就是,**变量初始化最好一次性完成。**这段代码里的变量赋值是在声明很久之后才完成的,也就是说,变量初始化没有一次性完成。 **这种代码真正的问题就是不清晰,变量初始化与业务处理混在在一起。**通常来说,这种代码后面紧接着就是一大堆更复杂的业务处理。当代码混在一起的时候,我们必须小心翼翼地从一堆业务逻辑里抽丝剥茧,才能把逻辑理清,知道变量到底是怎么初始化的。很多代码难读,一个重要的原因就是把不同层面的代码混在了一起。 这种代码在实际的代码库中出现的频率非常高,只不过,它会以各种变形的方式呈现出来。有的变量甚至是在相隔很远的地方才做了真正的赋值,完成了初始化,这中间已经夹杂了很多的业务代码在其中,进一步增加了理解的复杂度。 所以,我们编程时要有一个基本原则:**变量一次性完成初始化。** ```java final CreateEpubResponse response = createEpub(request); final EpubStatus status = toEpubStatus(response); private EpubStatus toEpubStatus(final CreateEpubResponse response) { if (response.getCode() == 201) { return EpubStatus.CREATED; } return EpubStatus.TO_CREATE; } ``` 在这段改进的代码中,我们提取出了一个函数,将 response 转成对应的内部的 EPUB 状态。 其实,很多人之所以这样写代码,一个重要的原因是很多人的编程习惯是从 C 语言来的。C 语言在早期的版本中,一个函数用到的变量必须在整个函数的一开始就声明出来。 在 C 语言诞生的年代,当时计算机能力有限内存小,编译器技术也处于刚刚起步的阶段,把变量放在前面声明出来,有助于减小编译器编写的难度。到了 C++ 产生的年代,这个限制就逐步放开了,所以,C++ 程序是支持变量随用随声明的。对于今天的大多数程序设计语言来说,这个限制早就不存在了,但很**多人的编程习惯却留在了那个古老的年代。** 还有一点不知道你注意到了没有,在新的变量声明中,我加上了 final,在 Java 的语义中,一个变量加上了 final,也就意味着这个变量不能再次赋值。对,我们需要的正是这样的限制。上一讲,我们讲了可变的数据会带来怎样的影响,其中的一个结论是,尽可能编写不变的代码。这里其实是这个话题的延伸,**尽可能使用不变的量。** 如果我们能够按照使用场景做一个区分,把变量初始化与业务处理分开,你会发现,在很多情况下,变量只在初始化完成之后赋值,就足以满足我们的需求了,在一段代码中,需要使用可变量的场景并不多。这个原则其实可以推广一下,**在能够使用 final 的地方尽量使用 final,限制变量的赋值。** 这里说的“能够使用”,不仅包括普通的变量声明,还包含参数声明,还有类字段的声明,甚至还可以包括类和方法的声明。当然,我们这里改进的考量主要还是在变量上。你可以尝试着调整自己现有的代码,给变量声明都加上 final,你就会发现许多值得改进的代码。 对于 Java 程序员来说,还有一个特殊的场景,就是异常处理的场景,强迫你把变量的声明与初始化分开,就像下面这段代码: ```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 的写法,代码就可以更简洁了: ```java try (InputStream is = new FileInputStream(...)) { ... } ``` ### 集合初始化 ```java 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 之后的写法: ```java List<Permission> permissions = List.of( Permission.BOOK_READ, Permission.BOOK_WRITE ); check.grantTo(Role.AUTHOR, permissions); ``` 如果你的项目还没有升级 Java 9 之后的版本,使用 Guava(Google 提供的一个 Java库)也是可以做成类似的效果: ```java List<Permission> permissions = ImmutableList.of( Permission.BOOK_READ, Permission.BOOK_WRITE ); check.grantTo(Role.AUTHOR, permissions); ``` 不知道你注意到没有,第二段代码里的 List 用的是一个 ImmutableList,也就是一个不可变的 List,实际上,你查看第一段代码的实现就会发现,它也是一个不变的 List。这是什么意思呢?也就是说,这个 List 一旦创建好了,就是不能修改了,对应的实现就是各种添加、删除之类的方法全部都禁用了。 初看起来,这是限制了我们的能力,但我们对比一下代码就不难发现,很多时候,我们对于一个集合的使用,除了声明时添加元素之外,后续就只是把它当作一个只读的集合。所以,在很多情况下,一个不变集合对我们来说就够用了。 其实,这段代码,相对来说还是比较清晰的,稍微再复杂一些的,集合的声明和添加元素之间隔了很远,不注意的话,甚至不觉得它们是在完成一次初始化。 ```java 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 块就是不需要的: ```java private static Map<Locale, String> CODE_MAPPING = ImmutableMap.of( LOCALE.ENGLISH, "EN", LOCALE.CHINESE, "CH" ); ``` 对比我们改造前后的代码,二者之间还有一个更关键的区别:前面的代码是命令式的代码,而后面的代码是声明式的代码。 命令式的代码,就是告诉你“怎么做”的代码,就像改造前的代码,声明一个集合,然后添加一个元素,再添加一个元素。而声明式的代码,是告诉你“做什么”的代码,改造后就是,我要一个包含了这两个元素的集合。 声明式的代码体现的意图,是更高层面的抽象,把意图和实现分开,从某种意义上来说,也是一种分离关注点。所以,**用声明式的标准来看代码,是一个发现代码坏味道的重要参考。** 回想一下今天讲的坏味道,无论是变量的声明与赋值分离,还是初始化一个集合的分步骤,其实反映的都是不同时代编程风格的烙印。变量的声明是 C 早期的编程风格,异常处理是 Java 早期的风格,而集合声明也体现出不同版本 Java 的影子。 **我们学习编程不仅仅是要学习实现功能,编程的风格也要与时俱进。** ### 总结时刻 今天我们继续挑战着很多人习惯的编程方式,讲了变量初始化带来的问题。变量的初始化包含变量的声明和赋值两个部分,一个编程的原则是“变量要一次性完成初始化”。 这就衍生出一个坏味道:变量的声明和赋值是分离的。二者分离带来的问题就是,把赋值的过程与业务处理混杂在一起。发现变量声明与赋值分离一个做法就是在声明前面加上final,用“不变性”约束代码。 我们还谈到了集合的初始化,传统的集合初始化方式是命令式的,而今天我们完全可以用声明式的方式进行集合的初始化,让初始化的过程一次性完成。再进一步,以声明式的标准来看代码,会帮助我们发现许多的坏味道。 如果今天的内容你只能记住一件事,那请记住:**一次性完成变量的初始化。**
<<代码之丑>>09 | 可变的数据:不要让你的代码“失控”
这一讲,我们再来说一类这样的坏味道:可变的数据。 对于程序,最朴素的一种认知是“程序 = 数据结构 + 算法”,所以,数据几乎是软件开发最核心的一个组成部分。在一些人的认知中,所谓做软件,就是一系列的 CRUD 操作,也就是对数据进行增删改查。再具体一点,写代码就把各种数据拿来,然后改来改去。我们学习编程时,首先学会的,也是给变量赋值,写出类似 a = b + 1之类的代码。改数据,几乎已经成了很多程序员写代码的标准做法。然而,这种做法也带来了很多的问题。这一讲,我们还是从一段问题代码开始。 ### 满天飞的 Setter 还记得我们在 开篇词里提到过的一个坏味道吗?我们复习一下: ```java public void approve(final long bookId) { ... book.setReviewStatus(ReviewStatus.APPROVED); ... } ``` 这是一段对作品进行审核的代码,通过 bookId,找到对应的作品,接下来,将审核状态设置成了审核通过。我当时之所以注意到这段代码,就是因为这里用了 setter。setter 往往是缺乏封装的一种做法。对于缺乏封装的坏味道,我们上节课已经用了一讲的篇幅在说,我提到,很多人在写代码时,写完字段就会利用 IDE 生成 getter,实际情况往往是,生成 getter 的同时,setter 也生成了出来。setter 同 getter 一样,反映的都是对细节的暴露。 这就意味着,你不仅可以读到一个对象的数据,还可以修改一个对象的数据。相比于读数据,修改是一个更危险的操作。 我在《 软件设计之美》专栏里讲函数式编程的不变性时,曾经专门讨论过可变的数据会带来许多问题,简言之,你不知道数据会在哪里被何人以什么方式修改,造成的结果是,别人的修改会让你的代码崩溃。与之相伴的还有各种衍生出来的问题,最常见的就是我们常说的并发问题。 可变的数据是可怕,但是,**比可变的数据更可怕的是,不可控的变化,**而暴露 setter 就是这种不可控的变化。把各种实现细节完全交给对这个类不了解的使用者去修改,没有人会知道他会怎么改,所以,这种修改完全是不可控的。 **缺乏封装再加上不可控的变化,在我个人心目中,setter 几乎是排名第一的坏味道。** 在开篇词里,我们针对代码给出的调整方案是,用一个函数替代了 setter,也就是把它用行为封装了起来: 作为这个类的使用者,你并不需要知道这个类到底是怎么实现的。更重要的是,这里的变化变得可控了。虽然审核状态这个字段还是会修改,但你所有的修改都要通过几个函数作为入口。有任何业务上的调整,都会发生在类的内部,只要保证接口行为不变,就不会影响到其它的代码。 setter 破坏了封装,相信你对这点已经有了一定的理解。不过,有时候你会说,我这个setter 只是用在初始化过程中,而并不需要在使用的过程去调用,就像下面这样: ```java Book book = new Book(); book.setBookId(bookId); book.setTitle(title); book.setIntroduction(introduction); ``` 实际上,对于这种只在初始化中使用的代码,压根没有必要以 setter 的形式存在,真正需要的是一个有参数的构造函数: ```java Book book = new Book(bookId, title, introduction); ``` 消除 setter ,有一种专门的重构手法,叫做移除设值函数(Remove Setting Method)。总而言之,setter 是完全没有必要存在的。 在今天的软件开发中,人们为了简化代码的编写做出了各种努力,用 IDE 生成的代码是一种,还有一种常见的做法就是,通过工具和框架生成相应代码的。在 Java 世界中,Lombok 就是这样的一种程序库,它可以在编译的过程中生成相应的代码,而我们需要做的,只是在代码上加上对应的 Annotation。它最大的优点是不碍眼,也就是不会产生大量可以看见的代码。因为它的代码是在编译阶段生成的,所以,那些生成的代码在源码级别上是不存在的。 不写 setter 的代码并不代表没有 setter。因为 @Setter 的存在,其它代码还是可以调用这个类的 setter,存在的问题并不会改变。所以,一个更好的做法是禁用 @Setter。 ### 可变的数据 我们反对使用 setter,一个重要的原因就是它暴露了数据,我们前面说过,暴露数据造成的问题就在于数据的修改,进而导致出现难以预料的 Bug。在上面的代码中,我们把setter 封装成一个个的函数,实际上是把不可控的修改限制在一个有限的范围内。 那么,这个思路再进一步的话,如果我们的数据压根不让修改,犯下各种低级错误的机会就进一步降低了。没错,在这种思路下,可变数据(Mutable Data)就成了一种坏味道,这是 Martin Fowler 在新版《重构》里增加的坏味道,它反映着整个行业对于编程的新理解。 这种想法源自函数式编程这种编程范式。在函数式编程中,数据是建立在不改变的基础上的,如果需要更新,就产生一份新的数据副本,而旧有的数据保持不变。随着函数式编程在软件开发领域中的地位不断提高,人们对于不变性的理解也越发深刻,不变性有效地解决了可变数据产生的各种问题。 所以,Martin Fowler 在《重构》第二版里新增了可变数据作为一种坏味道,这其实反映了行业的理解也是在逐渐推进的。不过,MartinFowler 对于可变数据给出的解决方案,基本上是限制对于数据的更新,降低其风险,这与我们前面提到的对 setter 的封装如出一辙。 **解决可变数据,还有一个解决方案是编写不变类。** 函数式编程的不变性,其中的关键点就是设计不变类。Java 中的 String 类就是一个不变类,比如,如果我们把字符串中的一个字符替换成另一个字符,String 类给出的函数签名是这样的: ```java String replace(char oldChar, char newChar); ``` 其含义是,这里的替换并不是在原有字符串上进行修改,而是产生了一个新的字符串那么,在实际工作中,我们怎么设计不变类呢?要做到以下三点: * 所有的字段只在构造函数中初始化; * 所有的方法都是纯函数; * 如果需要有改变,返回一个新的对象,而不是修改已有字段。 回过头来看我们之前改动的“用构造函数消除 setter”的代码,其实就是朝着这个方向在迈进。如果按照这个思路改造我们前面提到的 approve 函数,同样也可以: ```java class Book { public void approve() { return new Book(..., ReviewStatus.APPROVED, ...); } } ``` 这里,我们创建出了一个“其它参数和原有 book 对象一模一样,只是审核状态变成了APPROVED ”的对象。 在 JDK 的演化中,我们可以看到一个很明显的趋势,新增的类越来越多地采用了不变类的设计,比如,用来表示时间的类。原来的 Date 类里面还有各种 setter,而新增的LocalDateTime 则一旦初始化就不会再修改了。如果要操作这个对象,则会产生一个新的对象: ```java LocalDateTime twoDaysLater = now.plusDays(2); ``` 就目前的开发状态而言,想要完全消除可变数据是很难做到的,但我们可以尽可能地编写一些不变类。一个更实用的做法是,区分类的性质。我《软件设计之美》中讲 DDD 的战术设计时提到过,我们最核心要识别的对象分成两种,实体和值对象。实体对象要限制数据变化,而值对象就要设计成不变类。 如果你还想进一步提升自己对于不变性的理解,我们可以回到函数式编程这个编程范式的本质,它其实是对程序中的赋值进行了约束。基于这样的理解,**连赋值本身其实都会被归入到坏味道的提示,这才是真正挑战很多人编程习惯的一点。** 不过,我们现在看到,越来越多的语言中开始引入值类型,也就是初始化之后便不再改变的值,Martin Fowler 在《重构》中还提到一个与数据相关的坏味道:全局数据(Global Data)。如果你能够理解可变数据是一种坏味道,全局数据也就很容易理解了,它们处理手法基本上是类似的,这里我就不再做过多的阐述了。 ### 总结时刻 可变数据最直白的体现就是各种 setter。setter 一方面破坏了封装,另一方面它会带来不可控的修改,给代码增添许多问题。解决它的一种方式就是移除设值函数(Remove Setting Method),将变化限制在一定的范围之内。 可变数据是《重构》第二版新增的坏味道,这其实反映了软件开发行业的一种进步,它背后的思想是函数式编程所体现的不变性。解决可变数据,一种方式是限制其变化,另一种方式是编写不变类。 在实践中,完全消除可变数据是很有挑战的。所以,一个实际的做法是,区分类的性质。值对象就要设计成不变类,实体类则要限制数据变化。 函数式编程的本质是对于赋值进行了约束,我们甚至可以把赋值作为一种坏味道的提示。很多编程语言都引入了值类型,而让变量成为次优选项。 如果今天的内容你只能记住一件事,那请记住:**限制可变的数据。**
<<代码之丑>>08 | 缺乏封装:如何应对火车代码和基本类型偏执问题?
在程序设计中,一个重要的观念就是封装,将零散的代码封装成一个又一个可复用的模块。任何一个程序员都会认同封装的价值,但是,具体到写代码时,每个人对于封装的理解程度却天差地别,造成的结果就是:写代码的人认为自己提供了封装,但实际上,我们还是看到许多的代码散落在那里。 ### 火车残骸 ```java String name = book.getAuthor().getName(); ``` 这段代码表达的是“获得一部作品作者的名字”。作品里有作者信息,想要获得作者的名字,通过“作者”找到“作者姓名”,这就是很多人凭借直觉写出的代码,不过它是有问题的。如果你没看出这段代码的问题,说明你可能对封装缺乏理解。 你可以想一想,如果你想写出上面这段代码,是不是必须得先了解 Book 和 Author 这两个类的实现细节?也就是说,我们必须得知道,作者的姓名是存储在作品的作者字段里的。这时你就要注意了:当你必须得先了解一个类的细节,才能写出代码时,这只能说明一件事,这个封装是失败的。 Martin Fowler 在《重构》中给这种坏味道起的名字叫过长的消息链(MessageChains),而有人则给它起了一个更为夸张的名字: 火车残骸(Train Wreck),形容这样的代码像火车残骸一般,断得一节一节的。 解决这种代码的重构手法叫隐藏委托关系(Hide Delegate),说得更直白一些就是,把这种调用封装起来: ```java class Book { ... public String getAuthorName() { return this.author.getName(); } ... } String name = book.getAuthorName(); ``` 前面我说过,火车残骸这种坏味道的产生是缺乏对于封装的理解,因为封装这件事并不是很多程序员编码习惯的一部分,他们对封装的理解停留在数据结构加算法的层面上。在学习数据结构时,我们所编写的代码都是拿到各种细节直接操作,但那是在做编程练习,并不是工程上的编码方式。遗憾的是,很多人把这种编码习惯带到了工作中。 比如说,有人编写一个新的类,第一步是写出这个类要用到的字段,然后,就是给这些字段生成相应的 getter,也就是各种 getXXX。很多语言或框架提供的约定就是基于这种getter 的,就像 Java 里的 JavaBean,所以相应的配套工具也很方便。现在写出一个getter 往往是 IDE 中一个快捷键的操作,甚至不需要自己手工敲代码。 **要想摆脱初级程序员的水平,就要先从少暴露细节开始。**声明完一个类的字段之后,请停下生成 getter 的手,转而让大脑开始工作,思考这个类应该提供的行为,在软件行业中,有一个编程的指导原则几乎就是针对这个坏味道的,叫做 迪米特法则(Law of Demeter),这个原则是这样说的: * 每个单元对其它单元只拥有有限的知识,而且这些单元是与当前单元有紧密联系的; * 每个单元只能与其朋友交谈,不与陌生人交谈; * 只与自己最直接的朋友交谈。 这个原则需要我们思考,哪些算是直接的朋友,哪些算是陌生人。火车残骸般的代码显然就是没有考虑这些问题而直接写出来的代码。 或许你会说,按照迪米特法则这样写代码,会不会让代码里有太多简单封装的方法? 确实有可能,不过,这也是单独解决这一个坏味道可能带来的结果。正如我前面所说,这种代码的出现,根本的问题是缺乏对封装的理解,而一个好的封装是需要基于行为的,所以,如果把视角再提升一个角度,我们应该考虑的问题是类应该提供哪些行为,而非简简单单地把数据换一种形式呈现出来。 最后,还有一个问题我要提醒你一下。有些内部 DSL 的表现形式也是连续的方法调用,但DSL 是声明性的,是在说做什么(What),而这里的坏味道是在说怎么做(How),二者的抽象级别是不同的,不要混在一起。 ### 基本类型偏执 我们再来看一段代码 ```java public double getEpubPrice(final boolean highQuality, final int chapterSequenc) ... } ``` 这是我们上一讲用过的一个函数声明,根据章节信息获取 EPUB(一种电子书的格式) 的价格。也许你会问,这是一个看上去非常清晰的代码,难道这里也有坏味道吗?没错,有。问题就出在返回值的类型上,也就是价格的类型上。 那么,我们在数据库中存储价格的时候,就是用一个浮点数,这里用 double 可以保证计算的精度,这样的设计有什么问题吗? 确实,这就是很多人使用基本类型(Primitive)作为变量类型思考的角度。但实际上,**这种采用基本类型的设计缺少了一个模型。** 虽然价格本身是用浮点数在存储,但价格和浮点数本身并不是同一个概念,有着不同的行为需求。比如,一般情况下,我们要求商品价格是大于 0 的,但 double 类型本身是没有这种限制的。就以“价格大于 0”这个需求为例,如果使用 double 类型你会怎么限制呢?我们通常会这样写: ```java if (price <= 0) { throw new IllegalArgumentException("Price should be positive"); } ``` 问题是,如果使用 double 作为类型,那我们要在使用的地方都保证价格的正确性,像这样的价格校验就应该是使用的地方到处写的。 如果补齐这里缺失的模型,我们可以引入一个 Price 类型,这样的校验就可以放在初始化时进行: ```java 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 类里提供一个方法: ```java public double getDisplayPrice() { BigDecimal decimal = new BigDecimal(this.price); return decimal.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue(); } ``` 其实,使用基本类型和使用继承出现的问题是异曲同工的。大部分程序员都学过这样一个设计原则:组合优于继承,也就是说,我们不要写出这样的代码: ```java public Books extends List<Book> { ... } ``` 而应该写成组合的样子,也就是 ```java public Books { private List<Book> books; ... } ``` 之所以有人把 Books 写成了继承,因为在代码作者眼中,Books 就是一个书的集合;而有人用 double 做价格的类型,因为在他看来,价格就是一个 double。这里的误区就在于,一些程序员只看到了模型的相同之处,却忽略了差异的地方。Books 可能不需要提供 List的所有方法,价格的取值范围与 double 也有所差异。 但是,Books 的问题相对来说容易规避,因为产生了一个新的模型,有通用的设计原则帮助我们判断这个模型构建得是否恰当,而价格的问题却不容易规避,因为这里没有产生新的模型,也就不容易发现这里潜藏着问题。 这种以基本类型为模型的坏味道称为**基本类型偏执**(Primitive Obsession)。这里说的基本类型,不限于程序设计语言提供的各种基本类型,像字符串也是一个产生这种坏味道的地方。这里我稍微延伸一下,有很多人对于集合类型(比如数组、List、Map 等等)的使用也属于这种坏味道。 这一讲我们讲到的坏味道都是关于封装的。不过,正如我在开头所说,封装是一个人人都懂的道理,但具体到代码上,就千差万别了 **封装之所以有难度,主要在于它是一个构建模型的过程,**而很多程序员写程序,只是用着极其粗粒度的理解写着完成功能的代码,根本没有构建模型的意识;还有一些人以为划分了模块就叫封装,所以,我们才会看到这些坏味道的滋生。 这里我给出的坏味道,其实也是在挑战一些人对于编程的认知:那些习以为常的代码居然成了坏味道。而这只是一个信号,一个起点,告诉你这段代码存在问题,但真正要写好代码,还是需要你对软件设计有着深入的学习。 ### 总结时刻 这一讲,我们讨论的是与封装有关的坏味道: * 过长的消息链,或者叫火车残骸; * 基本类型偏执。 火车残骸的代码就是连续的函数调用,它反映的问题就是把实现细节暴露了出去,缺乏应有的封装。重构的手法是隐藏委托关系,实际就是做封装。软件行业有一个编程指导原则,叫迪米特法则,可以作为日常工作的指导,规避这种坏味道的出现。 基本类型偏执就是用各种基本类型作为模型到处传递,这种情况下通常是缺少了一个模型。解决它,常用的重构手法是以对象取代基本类型,也就是提供一个模型代替原来的基本类型。基本类型偏执不局限于程序设计语言提供的基本类型,字符串也是这种坏味道产生的重要原因,再延伸一点,集合类型也是。 这两种与封装有关的坏味道,背后体现的是对构建模型了解不足,其实,也是很多程序员在软件设计上的欠缺。想成为一个更好的程序员,学习软件设计是不可或缺的。如果今天的内容你只能记住一件事,那请记住:**构建模型,封装散落的代码。** ### 感受 链式调用不一定都是火车残骸。比如builder模式,每次调用返回的都是自身,不牵涉到其他对象,不违反迪米特法则。又比如java stream操作
<<代码之丑>>07 | 滥用控制语句:出现控制结构,多半是错误的提示
这节课我要讲的坏味道对于很多人来说,可能就有点挑战了。这并不是说内容有多难,相反,大部分人对这些内容简直太熟悉了。所以,当我把它们以坏味道的方式呈现出来时,这会极大地挑战很多人的认知。 这个坏味道就是滥用控制语句,也就是你熟悉的 if、for 等等很多人每天都用它们,却对问题毫无感知。今天我们就先从一个你容易接受的坏味道开始 ### 嵌套的代码 考虑到篇幅,我就不用这么震撼的代码做案例了,我们还是从规模小一点的代码开始讨论: ```java 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 循环处理的是一个集合,而循环里面处理的是这个集合中的一个元素。所以,我们可以把循环中的内容提取成一个函数,让这个函数只处理一个元素,就像下面这样: ```java 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 NestedConditional with Guard Clauses)。** 我们来看看改进后的 distributeEpub 函数: ```java 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 可以不写吗?可以。我们来看看下面的代码: ```java public double getEpubPrice(final boolean highQuality, final int chapterSequenc) 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,一个简单的处理手法就是让每个逻辑提前返回,这和我们前面提到的卫语句的解决方案如出一辙: 对于这种逻辑上还比较简单的代码,这么改造还是比较容易的,而对于一些更为复杂的代码,也许就要用到多态来改进代码了。不过在实际项目中,大部分代码逻辑都是逐渐变得复杂的,所以,最好在它还比较简单时,就把坏味道消灭掉。这才是最理想的做法。 无论是嵌套的代码,还是 else 语句,我们之所以要把它们视为坏味道,本质上都在追求简单,因为一段代码的分支过多,其复杂度就会大幅度增加。我们一直在说,人脑能够理解的复杂度是有限的,分支过多的代码一定是会超过这个理解范围。 ### 重复的 Switch 通过前面内容的介绍,你会发现,循环和选择语句这些你最熟悉的东西,其实都是坏味道出现的高风险地带,必须小心翼翼地使用它们。接下来,还有一个你从编程之初就熟悉的东西,也是另一个坏味道的高风险地带。我们来看两段代码: ```java 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(RepeatedSwitch)。** 之所以会出现重复的 switch,通常都是缺少了一个模型。所以,应对这种坏味道,重构的手法是:**以多态取代条件表达式(Relace Conditional with Polymorphism)**。具体到这里的代码,我们可以引入一个 UserLevel 的模型,将 switch 消除掉: ```java 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 去掉了 ```java 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); } ``` 其实,关于控制语句还有一个坏味道,那就是循环语句。没错,循环本身就是一个坏味道,但讲解它还需要一些知识的铺垫,所以,我会把它放到后面第 13 节,讲“落后的代码风格”时再来讲解。这里,你只要知道循环语句也是一个坏味道就够了。 ### 总结时刻 今天我们讲了程序员们最熟悉的控制语句:选择语句和循环语句。遗憾的是,这些语句今天都成了坏味道的高发地带,以各种形态呈现在我们面前: * 嵌套的代码; * else 语句; * 重复的 switch; * 循环语句。 嵌套的代码也好,else 语句也罢,二者真正的问题在于,它们会使代码变得复杂,超出人脑所能理解的范畴。我们可以通过提取单个元素操作,降低循环语句的复杂度,而用卫语句来简化条件表达式的编写,降低选择语句的复杂度。一个衡量代码复杂度的标准是圈复杂度,我们可以通过工具检查一段代码的圈复杂度。 重复的 switch 本质上是缺少了一个模型,可以使用多态取代条件表达式,引入缺少的模型,消除重复的 switch。 如果今天的内容你只能记住一件事,那请记住:**循环和选择语句,可能都是坏味道。**
<<代码之丑>>06 | 长参数列表:如何处理不同类型的长参数?
前面两讲,我们分别讲了长函数和大类,它们都是那种“我一说,你就知道是怎么回事”的坏味道,而且都让我们深恶痛绝,唯恐避之不及。这样典型的坏味道还有一个,就是长参数列表。 那么,函数为什么要有参数呢?我们知道,不同函数之间需要共享信息,于是才有了参数传递。其实,函数间共享信息的方式不止一种,除了参数列表,最常见的一种方式是全局变量。但全局变量会带给我们太多意想不到的问题,所以,在初学编程的时候,老师就会告诉我们,不要使用全局变量。从程序设计语言发展的过程中,我们也可以看到,取消全局变量已经成为了大势所趋。 但函数之间还是要传递信息的,既然不能用全局变量,参数就成了最好的选择,于是乎,只要你想到有什么信息要传给一个函数,就自然而然地把它加到参数列表中,参数列表也就越来越长了。 那么,长参数列表有啥问题呢?这个问题其实我在上一讲已经说过了,人脑能够掌握的内容有限,一旦参数列表变得很长,作为普通人,我们就很难对这些容进行把控了。既然长参数列表的问题是数量多,秉承我们一以贯之的思路,解决这个问题的关键就在于,减少参数的数量。 ### 聚沙成塔 ```java 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、作品类型、作品归属的频道、主角姓名、作品标签、作品是否已经完结等等。 如果你阅读这段代码,只是想理解它的逻辑,你或许会觉得这个函数的参数列表还挺合理,它把创建一部作品所需的各种信息都传给了函数,这是大部分人面对一段代码时理解问题的角度。不过,虽然这样写代码容易让人理解,但这不足以让你发现问题。 比如,如果你现在要在作品里增加一项信息,表明这部作品是否是签约作品,也就是这部作品是否可以收费,那你该怎么办?顺着前面的思路,我们很自然地就会想到给这个函数增加一个参数。但正如我在讲“ 长函数”那节课里说到的,很多问题都是这样,每次只增加一点点,累积起来,便不忍直视了。如果我们有了“坏味道”的视角,我们就会看到这里面的问题:这个函数的参数列表太长了。 怎么解决这个问题呢? 这里所有的参数其实都是和作品相关的,也就是说,所有的参数都是创建作品所必需的。所以,我们可以做的就是将这些参数封装成一个类,一个创建作品的参数类: ```java 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; ... } ``` 这样一来,这个函数参数列表就只剩下一个参数了,一个长参数列表就消除了: ```java public void createBook(final NewBookParamters parameters) { ... } ``` 这里你看到了一个典型的消除长参数列表的重构手法:**将参数列表封装成对象。** 或许你还有个疑问,只是把一个参数列表封装成一个类,然后,用到这些参数的时候,还需要把它们一个个取出来,这会不会是多此一举呢?就像这样: ```java 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); } ``` 如果你也有这样的想法,那说明一件事:你还没有形成对软件设计的理解。我们并不是简单地把参数封装成类,站在设计的角度,我们这里引入的是一个新的模型。**一个模型的封装应该是以行为为基础的。** 之前没有这个模型,所以,我们想不到它应该有什么行为,现在模型产生了,它就应该有自己配套的行为,那这个模型的行为是什么呢?从上面的代码我们不难看出,它的行为应该是构建一个作品对象出来。你理解了这一点,我们的代码就可以进一步调整了: ```java 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(); } } ``` 创建作品的函数就得到了极大的简化: ```java public void createBook(final NewBookParamters parameters) { ... Book book = parameters.newBook(); this.repository.save(book); } ``` 这里我们讨论消除长参数列表的一种方法,将参数列表封装成类。还记得我们前面提到的“如何扩展需求”这个问题吗?如果需求扩展,需要增加创建作品所需的内容,那这个参数列表就是不变的,相对来说,它就是稳定的。 ### 动静分离 把长参数列表封装成一个类,这能解决大部分的长参数列表,但并不等于所有的长参数列表都应该用这种方式解决,因为不是所有情况下,参数都属于一个类。 ```java 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),它们是不同的关注点,应该分离开来。 具体到这个场景下,静态不变的数据完全可以成为这个函数所在类的一个字段,而只将每次变动的东西作为参数传递就可以了。按照这个思路,代码可以改成这个样子: ```java 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); } ``` 这个坏味道其实是一个软件设计问题,代码缺乏应有的结构,所以,原本应该属于静态结构的部分却以动态参数的方式传来传去,无形之中拉长了参数列表。这个例子也给了我们一个提示,长参数列表固然可以用一个类进行封装,但能够封装出这个类的前提条件是:**这些参数属于一个类,有相同的变化原因。** 如果函数的参数有不同的变化频率,就要视情况而定了。对于静态的部分,我们前面已经看到了,它可以成为软件结构的一部分,而如果有多个变化频率,我们还可以封装出多个参数类来。 ### 告别标记 ```java public void editChapter(final long chapterId, final String title, final String content, final boolean apporved) { ... } ``` 这是我们在前面课程“ 重复代码”那一讲里提到过的一个函数,我们稍微复习一下,这几个参数分别表示,待修改章节的 ID、标题和内容,最后一个参数表示这次修改是否直接审核通过。前面几个参数是修改一个章节的必要信息,而这里的重点就在最后这个参数上。如果是作者进行编辑,之后要经过审核,而如果编辑来编辑的,那审核就直接通过,因为编辑本身扮演了审核人的角色。所以,这个参数实际上是一个标记,标志着接下来的处理流程会有不同。 使用标记参数,是程序员初学编程时常用的一种手法,不过,正是因为这种手法实在是太好用了,造成的结果就是代码里面彩旗(flag)飘飘,各种标记满天飞。这也是很多代码产生混乱的一个重要原因。在实际的代码中,我们必须小心翼翼地判断各个标记当前的值,才能做好处理。 解决标记参数,一种简单的方式就是,将标记参数代表的不同路径拆分出来。回到这段代码上,这里的一个函数可以拆分成两个函数,一个函数负责“普通的编辑”,另一个负责“可以直接审核通过的编辑”。 ```java // 普通的编辑,需要审核 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)。** 最近这三节课,我们讲了长函数、大类和长参数列表三种不同的坏味道,但在我们阐述了对于这些坏味道的理解之后,仔细想想这些坏味道,其实背后都是一件事:**我们应该编写“短小”的代码。** ### 总结时刻 今天我们讲解的坏味道是长参数列表,它同样是一个“我一说,你就知道是怎么回事”的坏味道。 应对长参数列表主要的方式就是减少参数的数量,一种最直接的方式就是将参数列表封装成一个类。但并不是说所有的情况都能封装成类来解决,我们还要分析是否所有的参数都有相同的变动频率。 * 变化频率相同,则封装成一个类。 * 变化频率不同的话: * 静态不变的,可以成为软件结构的一部分; * 多个变化频率的,可以封装成几个类。 除此之外,参数列表中经常会出现标记参数,这是参数列表变长的另一个重要原因。对于这种标记参数,一种解决方案就是根据这些标记参数,将函数拆分成多个函数。 如果今天的内容你只能记住一件事,那请记住:**减小参数列表,越小越好。**
<<代码之丑>> 05 | 大类:如何避免写出难以理解的大类?
这一讲,我们再来讲一个你一听名字就知道是怎么回事的坏味道:大类。 一听到大类,估计你的眼前已经浮现出一片无边无际的代码了。类之所以成为了大类,一种表现形式就是我们上节课讲到的长函数,一个类只要有几个长函数,那它就肯定是一眼望不到边了大类还有一种表现形式,类里面有特别多的字段和函数,也许,每个函数都不大,但架不住数量众多啊,这也足以让这个类在大类中占有一席之地。 ### 分模块的程序 我先来问你一个问题,为什么不把所有的代码都写到一个文件里?你可能会觉得这个问题很傻,心里想:除了像练习之类的特定场景,谁会在一个正经的项目上 把代码写到一个文件里啊?没错,确实没有人这么做,但你思考过原因吗?把代码都写到一个文件里,问题在哪里呢? 事实是,把代码写到一个文件里,一方面,相同的功能模块没有办法复用;另一方面,也是更关键的,把代码都写到一个文件里,其复杂度会超出一个人能够掌握的认知范围。简言之,**一个人理解的东西是有限的,没有人能同时面对所有细节。** 人类面对复杂事物给出的解决方案是分而治之。所以,我们看到几乎各种程序设计语言都有自己的模块划分方案,从最初的按照文件划分,到后来,使用面向对象方案按照类进行划分,本质上,它们都是一种模块划分的方式。这样,人们面对的就不再是细节,而是模块,模块的数量显然会比细节数量少,人们的理解成本就降低了。好,你现在已经理解了,对程序进行模块划分,本质上就是在把问题进行分解,而这种做法的背后原因,就是人类的认知能力是有限的。 理解了这一点,我们再回过头来看大类这个坏味道,你就知道问题出在哪了。**如果一个类里面的内容太多,它就会超过一个人的理解范畴,顾此失彼就在所难免了。** ### 大类的产生 想要理解怎么拆分一个大类,我们需要知道,这些类是怎么变成这么大的。 * 职责不单一 最容易产生大类的原因在于职责的不单一。我们先来看一段代码: ```java 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 类的全部。但是,即便只看这些内容,也足以让我们发现一些问题了。首先,普通的用户既不是作者,也不是编辑。作者和编辑这些相关的字段,对普通用户来说,都是没有意义的。其次,对于那些成为了作者的用户,编辑的信息意义也不大,因为作者是不能成为编辑的,反之亦然,编辑也不会成为作者,作者信息对成为编辑的用户也是没有意义的。 在这个类的设计里面,总有一些信息对一部分人是没有意义,但这些信息对于另一部分人来说又是必需的。之所以会出现这样的状况,关键点就在于,这里只有“一个”用户类。普通用户、作者、编辑,这是三种不同角色,来自不同诉求的业务方关心的是不同的内 容。只是因为它们都是这个系统的用户,就把它们都放到用户类里,造成的结果就是,任何业务方的需求变动,都会让这个类反复修改。这种做法实际上是违反了单一职责原则。 单一职责原则是衡量软件设计好坏的一把简单而有效的尺子,通常来说,很多类之所以巨大,大部分原因都是违反了单一职责原则。而想要破解“大类”的谜题,关键就是能够把不同的职责拆分开来。 回到我们这个类上,其实,我们前面已经分析了,虽然这是一个类,但其实,它把不同角色关心的东西都放在了一起,所以,它变得如此庞大。我们只要把不同的信息拆分开来,问题也就迎刃而解了。下面就是把不同角色拆分出来的结果 ```java public class User { private long userId; private String name; private String nickname; private String email; private String phoneNumber; ... } ``` ```java public class Author { private long userId; private AuthorType authorType; private ReviewStatus authorReviewStatus; ... } ``` ```java public class Editor { private long userId; private EditorType editorType; } ``` 这里,我们拆分出了 Author 和 Editor 两个类,把与作者和编辑相关的字段分别移到了这两个类里面。在这两个类里面分别有一个 userId 字段,用以识别这个角色是和哪个用户相关。这个大 User 类就这样被分解了。 * 字段未分组 大类的产生往往还有一个常见的原因,就是字段未分组。有时候,我们会觉得有一些字段确实都是属于某个类,结果就是,这个类还是很大。比如,我们看一下上面拆分的结果,那个新的 User 类: ```java 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 类的字段分个组,把不同的信息放到不同的类里面。 ```java public class User { private long userId; private String name; private String nickname; private Contact contact; ... } ``` ```java public class Contact { private String email; private String phoneNumber; ... } ``` 这里我们引入了一个 Contact 类(也就是联系方式),把 email 和 phoneNumber 放了进去,后面再有任何关于联系方式的调整就都可以放在这个类里面。经过这次调整,我们把不同的信息重新组合了一下,但每个类都比原来要小。 对比一下,如果说前后两次拆分有什么不同,那就是:前面是根据职责,拆分出了不同的实体,后面是将字段做了分组,用类把不同的信息分别做了封装。或许你已经发现了,**所谓的将大类拆解成小类,本质上在做的工作是一个设计工作。**我们分解的依据其实是单一职责这个重要的设计原则。没错,很多人写代码写不好,其实是缺乏软件设计的功底,不能有效地把各种模型识别出来。所以,想要写好代码,还是要好好学学软件设计的。 关于大类的讨论差不多就接近尾声了,但我估计结合这一讲最初的讨论,有些人心中会升起一些疑问:如果我们把大类都拆成小类,类的数量就会增多,那人们理解的成本是不是也会增加呢? 其实,这也是很多人不拆分大类的借口。 在这个问题上,程序设计语言早就已经有了很好的解决方案,所以,我们会看到在各种程序设计语言中,有诸如包、命名空间之类的机制,将各种类组合在一起。在你不需要展开细节时,面对的是一个类的集合。再进一步,还有各种程序库把这些打包出来的东西再进一步打包,让我们只要面对简单的接口,而不必关心各种细节。 ### 总结时刻 我们今天讲了大类这个坏味道,这是程序员日常感知最为深刻的坏味道之一。应对大类的解决方案,主要是将大类拆分成小类。我们需要认识到,模块拆分,本质上是帮助人们降低理解成本的一种方式。我们还介绍了两种产生大类的原因: * 职责不单一; * 字段未分组。 无论是哪种原因,想要有效地对类进行拆分,我们需要对不同内容的变动原因进行分析,而支撑我们来做这种分析的就是单一职责原则。将大类拆分成小类,本质上在做的是设计工作,所以,想要写好代码,程序员需要学好软件设计。有人觉得拆分出来的小类过多,不易管理,但其实程序设计语言早就为我们提供了各种构造类集合的方式,比如包、命名空间等,再进一步,还可以封装出各种程序库。如果今天的内容你只能记住一件事,那请记住:**把类写小,越小越好**。
<<代码之丑>>04 | 长函数:为什么你总是不可避免地写出长函数?
我们来讲一个你一定深恶痛绝的坏味道:长函数。 只要一提到长函数,无论是去被迫理解一个长函数的含义,还是要在一个长函数中,小心翼翼地找出需要的逻辑,按照需求微调一下,几乎所有程序员都会有不愉悦的回忆。不知道你在实际工作中遇到最长的函数有多长,几百上千行的函数肯定是不足以称霸的。 长函数是一个“我一说,你就知道怎么回事”的坏味道,我就不准备用一个典型的长函数来开启这一讲但是,为了统一认识,我准备先讨论一下多长的函数算是长函数,我们来看一个案例。 ### 多长的函数才算“长”? 有一次,我在一个团队做分享,讲怎么把一个长函数重构成小函数。现场演示之后,我问了大家一个问题:在你心目中,多长的函数才算长呢?一个现场听众很认真地思考了一下,给出了一个答案:100 行。我很尴尬地看了一下自己刚刚重构掉的两个函数,最长的一个都不到 100 行。换言之,以他的标准来看,这个函数根本就不是长函数,根本就没有必要重构。 ### 对于函数长度容忍度高,这是导致长函数产生的关键点。 如果一个人认为 100 行代码不算长,那在他眼中,很多代码根本就是没有问题的,也就更谈不上看到更多问题了,这其实是一个观察尺度的问题。这就好比,没有电子显微镜之前,人们很难理解疾病的原理,因为看不到病毒,就不可能理解病毒可以致病这个道理。 ### 一个好的程序员面对代码库时要有不同尺度的观察能力,看设计时,要能够高屋建瓴,看代码时,要能细致入微。 这里的要点就是,看具体代码时,一定要能够看到细微之处。关键点就是将任务拆解得越小越好,这个观点对代码同样适用。随着对代码长度容忍度的降低,对代码细节的感知力就会逐渐提升,你才能看到那些原本所谓细枝末节的地方隐藏的各种问题。 回到具体的工作中,“越小越好”是一个追求的目标,不过,没有一个具体的数字,就没办法约束所有人的行为。所以,通常情况下,我们还是要定义出一个代码行数的上限,以保证所有人都可以按照这个标准执行。 像 Python、Ruby 这样表达能力比较强的动态语言,大多数情况下, 一行代码(one-liner program)可以解决很多问题,所以,我对自己的要求大约是 5 行左右,并且能够用一行代码解决的问题,就尽量会用一行代码解决;而像Java 这样表达能力稍弱的静态类型语言,我也争取在 10 行代码之内解决问题。当然,这是我对自己的要求,在实际的项目中,可能不是每个人都能做到这一点,所以,我给了一个更为宽松的限制,在自己的标准上翻了番,也就是 20 行。 我知道,即便是以 20 行为上限,这也已经超过很多人的认知,具体的函数行数可以结合团队的实际情况来制定,但是,我非常不建议把这个数字放得很大,就像我前面说的那样,如果你放到 100 行,这个数字基本上是没有太多意义的,对团队也起不到什么约束作用。 我之所以要先讨论多长的函数算是长函数,是因为如果你不能认识到代码行的标准应该很低,那么在接下来的讨论中,有些代码示例可能在你看来,就根本不需要调整了。 ### 长函数的产生 不过,限制函数长度,是一种简单粗暴的解决方案。最重要的是你要知道,长函数本身是一个结果,如果不理解长函数产生的原因,还是很难写出整洁的代码。接下来,我们就来看看长函数是怎么产生的。 **以性能为由** 人们写长函数的历史由来已久。像 C 语言这种在今天已经是高性能的程序设计语言,在问世之初,也曾被人质疑性能不彰,尤其是函数调用。在一些写汇编语言的人看来,调用函数涉及到入栈出栈的过程,显然不如直接执行来得性能高。这种想法经过各种演变流传到今天,任何一门新语言出现,还是会以同样的理由被质疑。所以,在很多人看来,把函数写长是为了所谓性能。不过,这个观点在今天是站不住的。**性能优化不应该是写代码的第一考量。** **平铺直叙** 除了以性能为由把代码写长,还有一种最常见的原因也会把代码写长,那就是写代码平铺直叙,把自己想到的一点点罗列出来。 ```java public void executeTask() { ObjectMapper mapper = new ObjectMapper(); CloseableHttpClient client = HttpClients.createDefault(); List<Chapter> chapters = this.chapterService.getUntranslatedChapters(); for (Chapter chapter : chapters) { // Send Chapter 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(sendChap sendChapterPost.setEntity(new StringEntity(sendChapterRequestText) sendChapterHttpResponse = client.execute(sendChapterPost); HttpEntity sendChapterEntity = sendChapterPost.getEntity(); SendChapterResponse sendChapterResponse = mapper.readValue(sendCha chapterId = sendChapterResponse.getChapterId(); } catch (IOException e) { throw new RuntimeException(e); } finally { try { if (sendChapterHttpResponse != null) { sendChapterHttpResponse.close(); } } catch (IOException e) { // ignore } } // Translate Chapter HttpPost translateChapterPost = new HttpPost(translateChapterUrl); CloseableHttpResponse translateChapterHttpResponse = null; try { TranslateChapterRequest translateChapterRequest = new TranslateCha translateChapterRequest.setChapterId(chapterId); String translateChapterRequestText = mapper.writeValueAsString(tratranslateChapterPost.setEntity(new StringEntity(translateChapterRe translateChapterHttpResponse = client.execute(translateChapterPost HttpEntity translateChapterEntity = translateChapterHttpResponse.g TranslateChapterResponse translateChapterResponse = mapper.readVal 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) { // ignore } } } } ``` 这段代码之所以很长,主要原因就是把前面所说的逻辑全部平铺直叙地摆在那里了,这里既有业务处理的逻辑,比如,把章节发送给翻译引擎,然后,启动翻译过程;又有处理的细节,比如,把对象转成 JSON,然后,通过 HTTP 客户端发送出去。 从这段代码中,我们可以看到平铺直叙的代码存在的两个典型问题: * 把多个业务处理流程放在一个函数里实现; * 把不同层面的细节放到一个函数里实现 这里发送章节和启动翻译是两个过程,显然,这是可以放到两个不同的函数中去实现的,所以,我们只要做一下提取函数,就可以把这个看似庞大的函数拆开,而拆出来的几个函数规模都会小很多,像下面这样 ```java 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); } } ``` 拆出来的部分,实际上就是把对象打包发送的过程,我们以发送章节为例,先来看拆出来的发送章节部分: ```java 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) { // ignore } } return chapterId; } private HttpPost sendChapterRequest(final ObjectMapper mapper, final SendChapt 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 sendChaptString chapterId;) HttpEntity entity = sendChapterPost.getEntity(); SendChapterResponse response = mapper.readValue(entity.getContent(), SendC chapterId = response.getChapterId(); return chapterId; } ``` 当然,这个代码还算不上已经处理得很整洁了,但至少同之前相比,已经简洁了一些。我们只用了最简单的提取函数这个重构手法,就把一个大函数拆分成了若干的小函数。长函数往往还隐含着一个命名问题。如果你看修改后的 sendChapter,其中的变量命名明显比之前要短,理解的成本也相应地会降低。 平铺直叙的代码,一个关键点就是没有把不同的东西分解出来。如果我们用设计的眼光衡量这段代码,这就是“分离关注点”没有做好,把不同层面的东西混在了一起,既有不同业务混在一起,也有不同层次的处理混在了一起。**关注点越多越好,粒度越小越好。** **一次加一点** 有时,一段代码一开始的时候并不长,就像下面这段代码,它根据返回的错误进行相应地错误处理: ```java if (code == 400 || code == 401) { // 做一些错误处理 } ``` 然后,新的需求来了,增加了新的错误码,它就变成了这个样子: ```java if (code == 400 || code == 401 || code == 402) { // 做一些错误处理 } ``` 一个有生命力的项目经常会延续很长时间,于是,这段代码有很多次被修改的机会,日积月累,它就成了让人不忍直视的代码,比如: ```java if (code == 400 || code == 401 || code == 402 || ... || code == 500 || ... || ... || code == 10000 || ...) { // 做一些错误处理 } ``` 后来人看到这段代码就想骂人了。当他从版本控制的历史中找到这些代码的作者,去询问这些处理的来龙去脉时,每个人其实都很委屈,他们当时也没做太多,只是加了一个判断条件而已。任何代码都经不起这种无意识的累积,每个人都没做错,但最终的结果很糟糕。对抗这种逐渐糟糕腐坏的代码,我们需要知道“童子军军规”: >让营地比你来时更干净。 >—— 童子军军规 简言之,我们应该看看自己对于代码的改动是不是让原有的代码变得更糟糕了,如果是,那就改进它。但这一切的前提是,你要能看出自己的代码是不是让原有的代码变得糟糕了,所以,学习代码的坏味道还是很有必要的。 我们看到了代码变长的几种常见原因: * 以性能为由; * 平铺直叙; * 一次加一点 你会发现,代码变长根本是一个无意识的问题,写代码的人没有觉得自己把代码破坏了。但只要你认识到长函数是一个坏味道,后面的许多问题就自然而然地会被发掘出来,至于解决方案,你已经看到了,大部分情况下,就是拆分成各种小函数。 ### 总结时刻 今天我们讲了程序员最深恶痛绝的坏味道:长函数。没有人愿意去阅读长函数,但许多人又会不经意间写出长函数。 毫无疑问,长函数是一个坏味道。对于团队而言,一个关键点是要定义出长函数的标准。不过,过于宽泛的标准是没有意义的,想要有效地控制函数规模,几十行的函数已经是标准的上限了,这个标准越低越好。 我们还分析了长函数产生的原因: * 以性能为由; * 平铺直叙; * 一次加一点。 * 有人以性能为借口; * 有人把代码平铺直叙地摊在那里; * 有人只是每次增加了一点点。 其中,平铺直叙是把函数写长最常见的原因。之所以会把代码平摊在那里,一方面是把多个业务写到了一起,另一方面是把不同层次的代码写到了一起。究其根因,那是“分离关注点”没有做好。 每次增加一点点,是另外一个让代码变长的原因,应对它的主要办法就是要坚守“童子军军规”,但其背后更深层次的支撑就是要对坏味道有着深刻的认识。如果今天的内容你只能记住一件事,那请记住:把函数写短,越短越好。
