8.3 使用Lambda表达式的SOLID原则

SOLID原则是设计面向对象程序时的一些基本原则。原则的名字是个简写,分别代表了下面五个词的首字母:Single responsibility、Open/closed、Liskov substitution、Interface segregation和Dependency inversion。这些原则能指导你开发出易于维护和扩展的代码。

每种原则都对应着一系列潜在的代码异味,并为其提供了解决方案。有很多图书介绍这个主题,因此我不会详细讲解,而是关注如何在Lambda表达式的环境下应用其中的三条原则。在Java 8中,有些原则通过扩展,已经超出了原来的限制。

8.3.1 单一功能原则

程序中的类或方法只能有一个改变的理由。

软件开发中不可避免的情况是需求的改变。这可能是需要增加新功能,也可能是你对问题的理解或者客户发生变化了,或者你想变得更快,总之,软件会随着时间不断演进。

当软件的需求发生变化,实现这些功能的类和方法也需要变化。如果你的类有多个功能,一个功能引发的代码变化会影响该类的其他功能。这可能会引入缺陷,还会影响代码演进的能力。

让我们看一个简单的示例程序,该程序由资产列表生成BalanceSheet表格,然后输出成一份PDF格式的报告。如果实现时将制表和输出功能都放进同一个类,那么该类就有两个变化的理由。你可能想改变输出功能,输出不同的格式,比如HTML,可能还想改变BalanceSheet的细节。这为将问题分解成两个类提供了很好的理由:一个负责将BalanceSheet生成表格,一个负责输出。

单一功能原则不止于此:一个类不仅要功能单一,而且还需将功能封装好。换句话说,如果我想改变输出格式,那么只需改动负责输出的类,而不必关心负责制表的类。

这是强内聚性设计的一部分。说一个类是内聚的,是指它的方法和属性需要统一对待,因为它们紧密相关。如果你试着将一个内聚的类拆分,可能会得到刚才创建的那两个类。

既然你已经知道了什么是单一功能原则,问题来了:这和Lambda表达式有什么关系?

Lambda表达式在方法级别能更容易实现单一功能原则。让我们看一个例子,该段程序能得出一定范围内有多少个质数(例8-34)。

例8-34 计算质数个数,一个方法里塞进了多重职责

  1. public long countPrimes(int upTo) {
  2. long tally = 0;
  3. for (int i = 1; i < upTo; i++) {
  4. boolean isPrime = true;
  5. for (int j = 2; j < i; j++) {
  6. if (i % j == 0) {
  7. isPrime = false;
  8. }
  9. }
  10. if (isPrime) {
  11. tally++;
  12. }
  13. }
  14. return tally;
  15. }

很显然,在例8-34中我们同时干了两件事:计数和判断一个数是否是质数。在例8-35中,通过简单重构,将两个功能一分为二。

例8-35 将isPrime重构成另外一个方法后,计算质数个数的方法

  1. public long countPrimes(int upTo) {
  2. long tally = 0;
  3. for (int i = 1; i < upTo; i++) {
  4. if (isPrime(i)) {
  5. tally++;
  6. }
  7. }
  8. return tally;
  9. }
  10. private boolean isPrime(int number) {
  11. for (int i = 2; i < number; i++) {
  12. if (number % i == 0) {
  13. return false;
  14. }
  15. }
  16. return true;
  17. }

但我们的代码还是有两个功能。代码中的大部分都在对数字循环,如果我们遵守单一功能原则,那么迭代过程应该封装起来。改进代码还有一个现实的原因,如果需要对一个很大的upTo计数,我们希望可以并行操作。没错,线程模型也是代码的职责之一!

我们可以使用Java 8的集合流(如例8-36所示)重构上述代码,将循环操作交给类库本身处理。这里使用了range方法从0upTo计数,然后filter出质数,最后对结果做count

例8-36 使用集合流重构质数计数程序

  1. public long countPrimes(int upTo) {
  2. return IntStream.range(1, upTo)
  3. .filter(this::isPrime)
  4. .count();
  5. }
  6. private boolean isPrime(int number) {
  7. return IntStream.range(2, number)
  8. .allMatch(x -> (number % x) != 0);
  9. }

如果我们想利用更多CPU加速计数操作,可使用parallelStream方法,而不需要修改任何其他代码(如例8-37所示)。

例8-37 并行运行基于集合流的质数计数程序

  1. public long countPrimes(int upTo) {
  2. return IntStream.range(1, upTo)
  3. .parallel()
  4. .filter(this::isPrime)
  5. .count();
  6. }
  7. private boolean isPrime(int number) {
  8. return IntStream.range(2, number)
  9. .allMatch(x -> (number % x) != 0);
  10. }

因此,利用高阶函数,可以轻松帮助我们实现功能单一原则。

8.3.2 开闭原则

软件应该对扩展开放,对修改闭合。

— Bertrand Meyer

开闭原则的首要目标和单一功能原则类似:让软件易于修改。一个新增功能或一处改动,会影响整个代码,容易引入新的缺陷。开闭原则保证已有的类在不修改内部实现的基础上可扩展,这样就努力避免了上述问题。

第一次听说开闭原则时,感觉有点痴人说梦。不改变实现怎么能扩展一个类的功能呢?答案是借助于抽象,可插入新的功能。让我们看一个具体的例子。

我们要写的程序用来衡量系统性能,并且把得到的结果绘制成图形。比如,我们有描述计算机花在用户空间、内核空间和输入输出上的时间散点图。我将负责显示这些指标的类叫作MetricDataGraph

设计MetricDataGraph类的方法之一是将代理收集到的各项指标放入该类,该类的公开API如例8-38所示。

例8-38 MetricDataGraph类的公开API

  1. class MetricDataGraph {
  2. public void updateUserTime(int value);
  3. public void updateSystemTime(int value);
  4. public void updateIoTime(int value);
  5. }

但这样的设计意味着每次想往散点图中添加新的时间点,都要修改MetricDataGraph类。通过引入抽象可以解决这个问题,我们使用一个新类TimeSeries来表示各种时间点。这时,MetricDataGraph类的公开API就得以简化,不必依赖于某项具体指标,如例8-39所示。

例8-39 MetricDataGraph类简化之后的API

  1. class MetricDataGraph {
  2. public void addTimeSeries(TimeSeries values);
  3. }

每项具体指标现在可以实现TimeSeries接口,在需要时能直接插入。比如,我们可能会有如下类:UserTimeSeriesSystemTimeSeriesIoTimeSeries。如果要添加新的,比如由于虚拟化所浪费的CPU时间,则可增加一个新的实现了TimeSeries接口的类:StealTimeSeries。这样,就扩展了MetricDataGraph类,但并没有修改它。

高阶函数也展示出了同样的特性:对扩展开放,对修改闭合。前面提到的ThreadLocal类就是一个很好的例子。ThreadLocal有一个特殊的变量,每个线程都有一个该变量的副本并与之交互。该类的静态方法withInitial是一个高阶函数,传入一个负责生成初始值的Lambda表达式。

这符合开闭原则,因为不用修改ThreadLocal类,就能得到新的行为。给withInitial方法传入不同的工厂方法,就能得到拥有不同行为的ThreadLocal实例。比如,可以使用ThreadLocal生成一个DateFormatter实例,该实例是线程安全的,如例8-40所示。

例8-40 ThreadLocal日期格式化器

  1. // 实现
  2. ThreadLocal<DateFormat> localFormatter
  3. = ThreadLocal.withInitial(() -> new SimpleDateFormat());
  4. // 使用
  5. DateFormat formatter = localFormatter.get();

通过传入不同的Lambda表达式,可以得到完全不同的行为。比如在例8-41中,我们为每个Java线程创建了唯一、有序的标识符。

例8-41 ThreadLocal标识符

  1. // 或者这样实现
  2. AtomicInteger threadId = new AtomicInteger();
  3. ThreadLocal<Integer> localId
  4. = ThreadLocal.withInitial(() -> threadId.getAndIncrement());
  5. // 使用
  6. int idForThisThread = localId.get();

对开闭原则的另外一种理解和传统的思维不同,那就是使用不可变对象实现开闭原则。不可变对象是指一经创建就不能改变的对象。

“不可变性”一词有两种解释:观测不可变性实现不可变性。观测不可变性是指在其他对象看来,该类是不可变的;实现不可变性是指对象本身不可变。实现不可变性意味着观测不可变性,反之则不一定成立。

java.lang.String宣称是不可变的,但事实上只是观测不可变,因为它在第一次调用hashCode方法时缓存了生成的散列值。在其他类看来,这是完全安全的,它们看不出散列值是每次在构造函数中计算出来的,还是从缓存中返回的。

之所以在这样一本讲解Lambda表达式的书中谈及不可变对象,是因为它们都是函数式编程中耳熟能详的概念,这里也是Lambda表达式的发源地。它们生来就符合我在本书中讲述的编程风格。

我们说不可变对象实现了开闭原则,是因为它们的内部状态无法改变,可以安全地为其增加新的方法。新增加的方法无法改变对象的内部状态,因此对修改是闭合的;但它们又增加了新的行为,因此对扩展是开放的。当然,你还需留意不要改变程序其他部分的状态。

因其天生线程安全的特性,不可变对象引起了人们的格外注意。它们没有内部状态可变,因此可以安全地在不同线程之间共享。

如果我们回顾这几种方式,会发现已经偏离了传统的开闭原则。事实上,在Bertrand Meyer第一次引入这个原则时,原意是一旦实现后,类就不允许改动了。在现代敏捷开发环境中,完成一个类的说法很明显已经过时了。业务需求和使用方法的变化可能会让一个类的功能和当初设计的不同。当然这不成为忽视这一原则的理由,只是说明了所谓的原则只应作为指导,而不应教条地全盘接受,走向极端。

我认为还有一点值得思考,在Java 8中,使用抽象插入多个类,或者使用高阶函数来实现开闭原则其实是一样的。因为抽象需要使用一个接口或抽象类来定义方法,这其实就是一种多态的使用方式。

在Java 8中,任何传入高阶函数的Lambda表达式都由一个函数接口表示,高阶函数负责调用其唯一的方法,根据传入Lambda表达式的不同,行为也不同。这其实也是在用多态来实现开闭原则。

8.3.3 依赖反转原则

抽象不应依赖细节,细节应该依赖抽象。

让程序变得死板、脆弱、难于改变的方法之一是将上层业务逻辑和底层粘合模块的代码混在一起,因为这两样东西都会随着时间发生变化。

依赖反转原则的目的是让程序员脱离底层粘合代码,编写上层业务逻辑代码。这就让上层代码依赖于底层细节的抽象,从而可以重用上层代码。这种模块化和重用方式是双向的:既可以替换不同的细节重用上层代码,也可以替换不同的业务逻辑重用细节的实现。

让我们看一个具体的、自动化构建地址簿的例子,实现时使用了依赖反转原则达到上层的解耦。该应用以电子卡片作为输入,使用某种存储机制编写地址簿。

显然,我们可将代码分成如下三个基本模块:

  • 一个能解析电子卡片格式的电子卡片阅读器;
  • 能将地址存为文本文件的地址簿存储模块;
  • 从电子卡片中获取有效信息并将其写入地址簿的编写模块。

我们用图8-3来表示各模块之间的关系。

8.3 使用Lambda表达式的SOLID原则 - 图1

图8-3:依赖关系

在该系统中,重用编写模块很复杂,但是电子卡片阅读器和地址簿存储模块都不依赖于其他模块,因此很容易在其他系统中重用。还可以替换它们,比如用一个其他的阅读器,或者从人们的Twitter账户信息中读取内容;又比如我们不想将地址簿存为一个文本文件,而是使用数据库存储等其他形式。

为了具备能在系统中替换组件的灵活性,必须保证编写模块不依赖阅读器或存储模块的实现细节。因此我们引入了对阅读信息和输出信息的抽象,编写模块的实现依赖于这种抽象。在运行时传入具体的实现细节,这就是依赖反转原则的工作原理。

具体到Lambda表达式,我们之前遇到的很多高阶函数都符合依赖反转原则。比如map函数重用了在两个集合之间转换的代码。map函数不依赖于转换的细节,而是依赖于抽象的概念。在这里,就是依赖函数接口:Function

资源管理是依赖反转的另一个更为复杂的例子。显然,可管理的资源很多,比如数据库连接、线程池、文件和网络连接。这里我将以文件为例,因为文件是一种相对简单的资源,但是背后的原则可以很容易应用到更复杂的资源中。

让我们看一段代码,该段代码从一种假想的标记语言中提取标题,其中标题以冒号(:)结尾。我们的方法先读取文件,逐行检查,滤出标题,然后关闭文件。我们还将和读写文件有关的异常封装成接近待解决问题的异常:HeadingLookupException,最后的代码如例8-42所示。

例8-42 解析文件中的标题

  1. public List<String> findHeadings(Reader input) {
  2. try (BufferedReader reader = new BufferedReader(input)) {
  3. return reader.lines()
  4. .filter(line -> line.endsWith(":"))
  5. .map(line -> line.substring(0, line.length() - 1))
  6. .collect(toList());
  7. } catch (IOException e) {
  8. throw new HeadingLookupException(e);
  9. }
  10. }

可惜,我们的代码将提取标题和资源管理、文件处理混在一起。我们真正想要的是编写提取标题的代码,而将操作文件相关的细节交给另一个方法。可以使用Stream作为抽象,让代码依赖它,而不是文件。Stream对象更安全,而且不容易被滥用。我们还想传入一个函数,在读文件出问题时,可以创建一个问题域里的异常。整个过程如例8-43所示,而且我们将问题域里的异常处理和资源管理的异常处理分开了。

例8-43 剥离了文件处理功能后的业务逻辑

  1. public List<String> findHeadings(Reader input) {
  2. return withLinesOf(input,
  3. lines -> lines.filter(line -> line.endsWith(":"))
  4. .map(line -> line.substring(0, line.length()-1))
  5. .collect(toList()),
  6. HeadingLookupException::new);
  7. }

是不是想知道withLinesOf方法是什么样的?请看例8-44。

例8-44 定义withLinesOf方法

  1. private <T> T withLinesOf(Reader input,
  2. Function<Stream<String>, T> handler,
  3. Function<IOException, RuntimeException> error) {
  4. try (BufferedReader reader = new BufferedReader(input)) {
  5. return handler.apply(reader.lines());
  6. } catch (IOException e) {
  7. throw error.apply(e);
  8. }
  9. }

withLinesOf方法接受一个Reader参数处理文件读写,然后将其封装进一个BufferedReader对象,这样就可以逐行读取文件了。handler函数代表了我们想在该方法中执行的代码,它以文件中的每一行组成的Stream作为参数。另一个参数是error,输入输出有异常时会调用该方法,它会构建出与问题域有关的异常,出问题时就抛出该异常。

总结下来,高阶函数提供了反转控制,这就是依赖反转的一种形式,可以很容易地和Lambda表达式一起使用。依赖反转原则另外值得注意的一点是待依赖的抽象不必是接口。这里我们使用Stream对原始的Reader和文件处理做抽象,这种方式也适用于函数式编程语言中的资源管理——通常使用高阶函数管理资源,接受一个回调函数使用打开的资源,然后再关闭资源。事实上,如果Java 7就有Lambda表达式,那么Java 7中的try-with-resources功能可能只需要一个库函数就能实现。