Java 设计模式最佳实践(五、函数式模式)

原文:Design Patterns and Best Practices in Java
协议:CC BY-NC-SA 4.0
贡献者:飞龙
本文来自【ApacheCN Java 译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。
本章的目的是学习函数模式,以及通过引入函数式编程风格(现在在最重要的编程语言中是可能的)对传统模式所做的更改。Java8 引入了一些函数式特性,增加了一个新的抽象级别,影响了我们编写一些面向对象设计模式的方式,甚至使其中一些模式变得无关紧要。在本章中,我们将看到设计模式是如何被新的语言特性所改变,甚至取代的。在他的论文《动态语言中的设计模式》中,Peter Norvig 注意到 23 种设计模式中有 16 种更简单,或者被动态语言中现有的语言特征所取代,比如 Dylan。全文见这个页面。在这一章中,我们将看到什么可以被取代,以及新出现的模式是怎样和怎样的。正如 peternorvig 在他的论文中所说的,很久以前,子程序调用只是一种模式,随着语言的发展,这些模式会发生变化或被替换。
为了运行本章中的代码,我们使用了 Java 中可用的 JShell REPL 工具,可以从 Windows 中的$JAVA_HOME/bin/jshell on Linux or %JAVA_HOME%/bin/jshell.exe访问该工具。
函数式编程简介 在 20 世纪 30 年代,数学家阿隆佐教会发展了 Lambda 微积分。这是函数式编程范式的起点,因为它提供了理论基础。下一步是 John McCarthy 于 1958 年设计的 LISP(简称列表编程)。LISP 是第一种函数式编程语言,它的一些风格,如 commonlisp,至今仍在使用。
在函数式编程(通常缩写为 FP)中,函数是一级公民;这意味着软件是通过将函数而不是对象组合为 OOP 来构建的。这是以声明的方式完成的,告诉而不请求它,通过组合函数,促进不变性,避免副作用和共享数据。这就产生了一个更简洁的代码,它对变化具有弹性、可预测性,并且更易于维护和业务人员阅读。
函数代码具有更高的信噪比;我们必须编写更少的代码才能实现与 OOP 相同的功能。通过避免副作用和数据突变,依靠数据转换,系统变得更简单,更易于调试和修复。另一个好处是可预测性。我们知道,对于同一个输入,同一个函数总是会给出相同的输出;因此,它也可以用于并行计算,在任何其他函数之前或之后调用(CPU/编译器不需要对调用顺序进行假设),其返回值一经计算就可以缓存,从而提高性能。
作为一种声明式编程类型,它更关注需要做什么,而命令式则侧重于应该如何做。样品流如下图所示:

函数式编程范式使用以下概念和原则:
  • Lambda 表达式
  • 纯函数
  • 参照透明度
  • 一阶函数
  • 高阶函数
  • 函数组合
  • 柯里化
  • 闭包
  • 不变性
  • 函子
  • 应用
  • 单子
Lambda 表达式 这个名字来自 Lambda 演算,希腊字母 Lambda(λ)用于将一个术语绑定到一个函数。Lambda 项可以是变量(x,例如,λ.x.M,其中M是函数或应用,其中两个项,MN相互应用。通过构造(合成)术语,现在可以进行表达式缩减和/或转换。Lambda 表达式缩减可以通过使用解释器进行在线测试,例如 Berkeley 的解释器。
以下是用于在已知xy坐标时计算圆半径平方的 Lambda 演算 Lambda 表达式的示例:

它在数学上定义为一个 n 元函数:

申请如下:

这是柯里化版本(注意额外的减少步骤):

在语句上使用 Lambda 表达式的主要好处是 Lambda 表达式可以组合并简化为更简单的形式。
Java8 引入了 Lambda 表达式(以前通过使用匿名类提供),实现使用了 Java8 中引入的 invoke 动态,而不是匿名类,以提高性能(需要加载太多生成的类)和定制(将来的更改)的原因。
纯函数 纯函数是一个没有副作用的函数,它的输出对于相同的输入是相同的(可预测的和可缓存的)。副作用是修改函数外部上下文的操作。这方面的例子包括:
  • 写入文件/控制台/网络/屏幕
  • 修改外部变量/状态
  • 调用非纯函数
  • 启动进程
副作用有时是不可避免的,甚至是需要的——I/O 或低级操作就是带有副作用的代码的例子(冯·诺依曼机器因为副作用而工作)。根据经验,尝试将有副作用的函数与代码的其余部分隔离开来。Haskell 和其他函数式编程语言使用 monad 来完成任务。稍后我们将有一个关于单子的介绍部分。
由于纯函数的输出是可预测的,因此也可以用缓存的输出替换它;这就是为什么纯函数被称为提供引用透明性的原因。Robert Martin 在他的书《Clean Code》中写道,纯函数更容易阅读和理解:
“事实上,花在阅读和写作上的时间之比远远超过 10:1。作为编写新代码的一部分,我们不断地阅读旧代码。。。[因此,]使阅读更容易,使写作更容易。”
在代码中使用纯函数可以提高工作效率,并允许新手花更少的时间阅读新代码,花更多的时间使用和修复新代码。
引用透明性 引用透明性是一个函数的属性,它可以用输入的返回值替换。好处是巨大的,因为这有利于记忆(缓存返回值)和对特定函数调用的并行化。测试这样的函数也很容易。
一阶函数 第一类函数是可以像面向对象编程中创建、存储、用作参数和作为值返回的对象一样处理的函数。
高阶函数 高阶函数是可以将其他函数作为参数,创建并返回它们的函数。它们通过使用现有的和已经测试过的小函数来促进代码重用。例如,在下面的代码中,我们计算给定温度(华氏度)的平均值(摄氏度):
jshell> IntStream.of(70, 75, 80, 90).map(x -> (x - 32)*5/9).average(); $4 ==> OptionalDouble[25.5]

注意在高阶map函数中使用 Lambda 表达式。相同的 Lambda 表达式可以在多个地方用于转换温度。
jshell> IntUnaryOperator convF2C = x -> (x-32)*5/9; convF2C ==> $Lambda$27/1938056729@4bec1f0c jshell> IntStream.of(70, 75, 80, 90).map(convF2C).average(); $6 ==> OptionalDouble[25.5] jshell> convF2C.applyAsInt(80); $7 ==> 26Function

组合 在数学中,函数是用一个函数的输出作为下一个函数的输入而组合起来的。同样的规则也适用于函数式编程,其中一阶函数由高阶函数使用。前面的代码已经包含了这样一个示例,请参见map函数中的andThen纯函数的用法。
为了使函数的组成更加直观,我们可以用andThen方法重写转换公式:
jshell> IntUnaryOperator convF2C = ((IntUnaryOperator)(x -> x-32)).andThen(x -> x *5).andThen(x -> x / 9); convF2C ==> java.util.function.IntUnaryOperator$$Lambda$29/1234776885@dc24521 jshell> convF2C.applyAsInt(80); $23 ==> 26

柯里化 柯里化是将一个 n 元函数转化为一系列或一元函数的过程,它是以美国数学家 Haskell Curry 的名字命名的。形式g:: x -> y -> zf :: (x, y) -> z的柯里化形式。对于前面给出的平方半径公式,f(x,y) = x2 + y2,一个柯里化版本,不使用双函数,将使用apply多次。一个函数的单一应用只会用一个值替换参数,正如我们前面看到的。下面的代码展示了如何创建一个双参数函数,对于n个参数,Function类的apply函数将有 n 个调用:
jshell> Function> square_radius = x -> y -> x*x + y*y; square_radius ==> $Lambda$46/1050349584@6c3708b3 jshell> List squares = Arrays.asList(new Tuple(1, 5), new Tuple(2, 3)).stream(). map(a -> square_radius.apply(a.y).apply(a.x)). collect(Collectors.toList()); squares ==> [26, 13]

闭包 闭包是实现词汇作用域的一种技术。词法范围允许我们访问内部范围内的外部上下文变量。假设在前面的例子中,y变量已经被赋值。Lambda 表达式可以保持一元表达式,并且仍然使用y作为变量。这可能会导致一些很难找到的 bug,如在下面的代码中,我们希望函数的返回值保持不变。闭包捕获一个对象的当前值,正如我们在下面的代码中看到的,我们的期望是,add100函数总是将 100 添加到给定的输入中,但是它没有:
jshell> Integer a = 100 a ==> 100 jshell> Function add100 = b -> b + a; add100 ==> $Lambda$49/553871028@eec5a4a jshell> add100.apply(9); $38 ==> 109 jshell> a = 101; a ==> 101 jshell> add100.apply(9); $40 ==> 110

在这里,我们期望得到 109,但是它用 110 回答,这是正确的(101 加 9 等于 110);我们的a变量从 100 变为 101。闭包需要谨慎使用,而且,根据经验,使用final关键字来限制更改。闭包并不总是有害的;在我们想要共享当前状态的情况下(并且在需要的时候能够修改它),闭包非常方便。例如,我们将在需要提供数据库连接(抽象连接)的回调的 API 中使用闭包;我们将使用不同的闭包,每个闭包提供基于特定数据库供应商设置的连接,通常从外部上下文中已知的属性文件读取。它可以用函数的方式实现模板模式。
不变性 在《Effective Java》中,Joshua Bloch 提出了如下建议:将对象视为不可变的。在 OOP 世界中需要考虑这个建议的原因在于可变代码有许多可移动的部分;它太复杂,不容易理解和修复。促进不变性简化了代码,并允许开发人员专注于流,而不是关注一段代码可能产生的副作用。最糟糕的副作用是,一个地方的微小变化可能会在另一个地方产生灾难性的结果(蝴蝶效应)。可变代码有时很难并行化,并且常常使用不同的锁。
函子 函子允许我们对给定的容器应用函数。他们知道如何从包装对象中展开值,应用给定的函数,并返回另一个包含结果/转换包装对象的函子。它们很有用,因为它们抽象了多种习惯用法,如集合、FuturePromise)和Optional。下面的代码演示了 Java 中的Optional函子的用法,其中Optional可以是一个给定的值,这是将函数应用于现有的包装值(5Optional的结果):
jshell> Optional a = Optional.of(5); a ==> Optional[5]

现在我们将函数应用于值为 5 的包装整数对象,得到一个新的可选保持值 4.5:
jshell> Optional b = a.map(x -> x * 0.9f); b ==> Optional[4.5] jshell> b.get() $7 ==> 4.5

Optional是一个函子,类似于 Haskell 的Maybe(只是| Nothing),它甚至有一个静态Optional.empty()方法,返回一个没有值(Nothing)的Optional
应用 应用添加了一个新级别的包装,而不是将函数应用于包装对象,函数也被包装。在下面的代码中,函数被包装在一个可选的。为了证明应用的一个用法,我们还提供了一个标识(所有内容都保持不变)选项,以防所需的函数(在我们的例子中是toUpperCase)为空。因为没有语法糖来自动应用包装函数,所以我们需要手动执行,请参阅get().apply()代码。注意 Java9 added 方法Optional.or()的用法,如果我们的输入Optional为空,它将延迟返回另一个Optional
jshell> Optional a = Optional.of("Hello Applicatives") a ==> Optional[Hello Applicatives] jshell> Optional upper = Optional.of(String::toUpperCase) upper ==> Optional[$Lambda$14/2009787198@1e88b3c] jshell> a.map(x -> upper.get().apply(x)) $3 ==> Optional[HELLO APPLICATIVES]

这是我们的应用,它知道如何将给定的字符串大写。让我们看看代码:
jshell> Optional identity = Optional.of(Function.identity()) identity ==> Optional[java.util.function.Function$$Lambda$16/1580893732@5c3bd550] jshell> Optional upper = Optional.empty() upper ==> Optional.empty jshell> a.map(x -> upper.or(() -> identity).get().apply(x)) $6 ==> Optional[Hello Applicatives]

前面的代码是我们的应用,它将标识函数(输出与输入相同)应用于给定的字符串。
单子 单子应用一个函数,将一个包装值返回给一个包装值。Java 包含了StreamCompletableFuture和已经出现的Optional等示例。flatMap函数通过将给定的函数应用于邮政编码映射中可能存在或不存在的邮政编码列表来实现这一点,如下代码所示:
jshell> Map codesMapping = Map.of(400500, "Cluj-Napoca", 75001, "Paris", 10115, "Berlin", 10000, "New York") codesMapping ==> {400500=Cluj-Napoca, 10115=Berlin, 10000=New York, 75001=Paris} jshell> List codes = List.of(400501, 75001, 10115, 10000) codes ==> [400501, 75001, 10115, 10000] jshell> codes.stream().flatMap(x -> Stream.ofNullable(codesMapping.get(x))) $3 ==> java.util.stream.ReferencePipeline$7@343f4d3d jshell> codes.stream().flatMap(x -> Stream.ofNullable(codesMapping.get(x))).collect(Collectors.toList()); $4 ==> [Paris, Berlin, New York]

Haskell 使用以下单子(在其他函数式编程语言中导入)。它们对于 Java 世界也很重要,因为它们具有强大的抽象概念:
  • 读取器单子允许共享和读取环境状态。它在软件的可变部分和不可变部分之间提供了边缘功能。
  • 写入器单子用于将状态附加到多个写入器,非常类似于记录到多个写入器(控制台/文件/网络)的日志过程。
  • 状态单子既是读取器又是写入器。
为了掌握函子、应用和单子的概念,我们建议您查阅这个页面和这个页面。在这个页面的 Cyclops React 库里也有一些函数式的好东西。
Java 函数式编程简介 函数式编程是基于流和 Lambda 表达式的,两者都是在 Java8 中引入的。像 RetroLambda 这样的库允许 Java8 代码在旧的 JVM 运行时运行,比如 Java5、6 或 7(通常用于 Android 开发)。
Lambda 表达式 Lambda 表达式是用于java.util.functions包接口的语法。最重要的是:
  • BiConsumer:一种使用两个输入参数而不返回结果的操作,通常用在forEach映射方法中。支持使用andThen方法链接BiConsumers
  • BiFunction:通过调用apply方法,接受两个参数并产生结果的函数。
  • BinaryOperator:对同一类型的两个操作数进行的一种操作,产生与操作数类型相同的结果,通过调用其继承的apply方法来使用。它静态地提供了minBymaxBy方法,返回两个元素中的较小值/较大值。
  • BiPredicate:由两个参数(也称为谓词)组成的布尔返回函数,用于调用其test方法。
  • Consumer:使用单个输入参数的操作。就像它的二进制对应项一样,它支持链接,并通过调用它的apply方法来应用,如下面的示例所示,其中使用者是System.out.println方法:
jshell> Consumer printToConsole = System.out::println; print ==> $Lambda$24/117244645@5bcab519 jshell> printToConsole.accept(9) 9

  • Function:接受一个参数并产生结果的函数。它转换输入,而不是变异。它可以通过调用其apply方法直接使用,使用andThen链接,使用compose方法组合,如下面的示例代码所示。这样,我们的代码就可以通过在现有函数的基础上构造新函数来保持 DRY(缩写为不要重复):
jshell> Function square = x -> x*x; square ==> $Lambda$14/1870647526@47c62251 jshell> Function toString = x -> "Number : " + x.toString(); toString ==> $Lambda$15/1722023916@77caeb3e jshell> toString.compose(square).apply(4); $3 ==> "Number : 16" jshell> square.andThen(toString).apply(4); $4 ==> "Number : 16"

  • Predicate:一个参数的布尔返回函数。在下面的代码中,我们将测试字符串是否完全小写:
jshell> Predicate isLower = x -> x.equals(x.toLowerCase()) isLower ==> $Lambda$25/507084503@490ab905 jshell> isLower.test("lower") $8 ==> true jshell> isLower.test("Lower") $9 ==> false

  • Supplier:这是一个值供应器:
jshell> String lambda = "Hello Lambda" lambda ==> "Hello Lambda" jshell> Supplier closure = () -> lambda closure ==> $Lambda$27/13329486@13805618 jshell> closure.get() $13 ==> "Hello Lambda"

  • UnaryOperator:作用于单个操作数的一种特殊函数,其结果与其操作数的类型相同;可以用Function代替。
流 流是一个函数管道,用于转换而不是变异数据。它们有创造者、中间者和终端操作。要从流中获取值,需要调用终端操作。流不是数据结构,不能重复使用,一旦被使用,如果第二次收集,它将保持关闭状态,java.lang.IllegalStateException异常:流已经被操作或关闭,将被抛出。
流创建操作 流可以是连续的,也可以是并行的。它们可以从Collection接口、JarFile、ZipFile 或位集创建,也可以从 Java9 开始从Optional class stream()方法创建。Collection类支持parallelStream()方法,该方法可以返回并行流或串行流。通过调用适当的Arrays.stream(...),可以构造各种类型的流,例如装箱原始类型(IntegerLongDouble)或其他类。为原始类型调用它的结果是以下特定流:IntStreamLongStreamDoubleStream。这些专用流类可以使用它们的静态方法之一来构造流,例如generate(...)of(...)empty()iterate(...)concat(...)range(...)rangeClosed(...)builder()。通过调用lines(...)方法可以很容易地从BufferedReader对象获取数据流,该方法也以静态形式存在于Files类中,用于从路径给定的文件获取所有行。Files类提供了其他流创建者方法,如list(...)walk(...)find(...)
Java9 除了前面提到的Optional之外,还添加了更多返回流的类,比如Matcher类(results(...)方法)或Scanner类(findAll(...)tokens()方法)。
流中间操作 中间流操作是延迟应用的;这意味着只有在终端操作被调用之后才进行实际调用。在下面的代码中,使用在网上使用随机生成的名称,一旦找到第一个有效名称,搜索将停止(只返回一个Stream对象):
jshell> Stream stream = Arrays.stream(new String[] {"Benny Gandalf", "Aeliana Taina","Sukhbir Purnima"}). ...> map(x -> { System.out.println("Map " + x); return x; }). ...> filter(x -> x.contains("Aeliana")); stream ==> java.util.stream.ReferencePipeline$2@6eebc39e jshell> stream.findFirst(); Map Benny Gandalf Map Aeliana Taina $3 ==> Optional[Aeliana Taina]

流中间操作包含以下操作:
  • sequential():将当前流设置为串行流。
  • parallel():将当前流设置为可能的并行流。根据经验,对大型数据集使用并行流,并行化可以提高性能。在我们的代码中,并行操作会导致性能下降,因为并行化的成本大于收益,而且我们正在处理一些否则无法处理的条目:
jshell> Stream stream = Arrays.stream(new String[] {"Benny Gandalf", "Aeliana Taina","Sukhbir Purnima"}). ...> parallel(). ...> map(x -> { System.out.println("Map " + x); return x; }). ...> filter(x -> x.contains("Aeliana")); stream ==> java.util.stream.ReferencePipeline$2@60c6f5b jshell> stream.findFirst(); Map Benny Gandalf Map Aeliana Taina Map Sukhbir Purnima $14 ==> Optional[Aeliana Taina]

  • unordered():无序处理输入。它使得序列流的输出顺序具有不确定性,并通过允许更有效地实现一些聚合函数(如去重复或groupBy),从而提高并行执行的性能。
  • onClose(..):使用给定的输入处理器关闭流使用的资源。Files.lines(...)流利用它来关闭输入文件,比如在下面的代码中,它是自动关闭的,但是也可以通过调用close()方法手动关闭流:
jshell> try (Stream stream = Files.lines(Paths.get("d:/input.txt"))) { ...> stream.forEach(System.out::println); ...> } Benny Gandalf Aeliana Taina Sukhbir Purnima

  • filter(..):应用谓词过滤输入。
  • map(..):通过应用函数来转换输入。
  • flatMap(..):使用基于映射函数的流中的值替换输入。
  • distinct():使用Object.equals()返回不同的值。
  • sorted(..):根据自然/给定比较器对输入进行排序。
  • peek(..):允许使用流所持有的值而不更改它们。
  • limit(..):将流元素截断为给定的数目。
  • skip(..):丢弃流中的前 n 个元素。
下面的代码显示了peeklimitskip方法的用法。它计算出商务旅行折合成欧元的费用。第一笔和最后一笔费用与业务无关,因此需要过滤掉(也可以使用filter()方法)。peek方法是打印费用总额中使用的费用:
jshell> Map exchangeToEur = Map.of(Currency.USD, 0.96, Currency.GBP, 1.56, Currency.EUR, 1.0); exchangeToEur ==> {USD=0.96, GBP=1.56, EUR=1.0} jshell> List travelExpenses = List.of(new Expense(10, Currency.EUR, "Souvenir from Munchen"), new Expense(10.5, Currency.EUR, "Taxi to Munich airport"), new Expense(20, Currency.USD, "Taxi to San Francisco hotel"), new Expense(30, Currency.USD, "Meal"), new Expense(21.5, Currency.GBP, "Taxi to San Francisco airport"), new Expense(10, Currency.GBP, "Souvenir from London")); travelExpenses ==> [Expense@1b26f7b2, Expense@491cc5c9, Expense@74ad ... 62d5aee, Expense@69b0fd6f] jshell> travelExpenses.stream().skip(1).limit(4). ...> peek(x -> System.out.println(x.getDescription())). ...> mapToDouble(x -> x.getAmount() * exchangeToEur.get(x.getCurrency())). ...> sum(); Taxi to Munich airport Taxi to San Francisco hotel Meal Taxi to San Francisco airport $38 ==> 92.03999999999999

除了前面介绍的Stream.ofNullable方法外,Java9 还引入了dropWhiletakeWhile。它们的目的是让开发人员更好地处理无限流。在下面的代码中,我们将使用它们将打印的数字限制在 5 到 10 之间。移除上限(由takeWhile设置)将导致无限大的递增数字打印(在某个点上,它们将溢出,但仍会继续增加–例如,在迭代方法中,使用x -> x + 100):
jshell> IntStream.iterate(1, x-> x + 1). ...> dropWhile(x -> x < 5).takeWhile(x -> x < 7). ...> forEach(System.out::println);

输出是 5 和 6,正如预期的那样,因为它们大于 5,小于 7。
流终端操作 终端操作是遍历中间操作管道并进行适当调用的值或副作用操作。它们可以处理返回的值(forEach(...)forEachOrdered(...)),也可以返回以下任意值:
  • 迭代器(例如iterator()spliterator()方法)
  • 集合(toArray(...)collect(...),使用集合toList()toSet()toColletion()groupingBy()partitioningBy()toMap()
  • 特定元素(findFirst()findAny()
  • 聚合(归约)可以是以下任何一种:
    • 算法:min(...)max(...)count()sum()average()summaryStatistics()只针对IntStreamLongStreamDoubleStream
    • 布尔值:anyMatch(...)allMatch(...)noneMatch(...)
    • 自定义:使用reduce(...)collect(...)方式。一些可用的收集器包括maxBy()minBy()reducing()joining()counting()
面向对象设计模式的再实现 在本节中,我们将根据 Java8 和 Java9 中提供的新特性来回顾一些 GOF 模式。
单子 使用闭包和Supplier可以重新实现单例模式。Java 混合代码可以利用Supplier接口,比如在下面的代码中,单例是一个枚举(根据函数编程,singleton 类型是那些只有一个值的类型,就像枚举一样)。以下示例代码与第 2 章“创建模式”中的代码类似:
jshell> enum Singleton{ ...> INSTANCE; ...> public static Supplier getInstance() ...> { ...> return () -> Singleton.INSTANCE; ...> } ...> ...> public void doSomething(){ ...> System.out.println("Something is Done."); ...> } ...> } | created enum Singleton jshell> Singleton.getInstance().get().doSomething(); Something is Done.

构建器 Lombock 库将生成器作为其功能的一部分引入。只要使用@Builder注解,任何类都可以自动获得对builder方法的访问权,如 Lombock 示例代码在这个页面中所示:
Person.builder().name("Adam Savage").city("San Francisco").job("Mythbusters").job("Unchained Reaction").build();

其他 Java8 之前的实现使用反射来创建通用生成器。Java8+ 泛型构建器版本可以通过利用供应器和BiConsumer组合来实现,如下代码所示:
jshell> class Person { private String name; ...> public void setName(String name) { this.name = name; } ...> public String getName() { return name; }} | replaced class Person | update replaced variable a, reset to null jshell> Supplier getPerson = Person::new getPerson ==> $Lambda$214/2095303566@78b66d36 jshell> Person a = getPerson.get() a ==> Person@5223e5ee jshell> a.getName(); $91 ==> null jshell> BiConsumer changePersonName = (x, y) -> x.setName(y) changePersonName ==> $Lambda$215/581318631@6fe7aac8 jshell> changePersonName.accept(a, "Gandalf") jshell> a.getName(); $94 ==> "Gandalf"

适配器 最好的例子是使用map函数,它执行从旧接口到新接口的自适应。我们将重用第 4 章中的示例“结构模式”,稍加改动;映射模拟适配器代码:
jshell> class PS2Device {}; | created class PS2Device jshell> class USBDevice {}; | created class USBDevice jshell> Optional.of(new PS2Device()).stream().map(x -> new USBDevice()).findFirst().get() $39 ==> USBDevice@15bb6bea

装饰器 装饰器可以通过利用函数组合来实现。例如,如前所示,可以使用stream.peek方法将日志添加到现有函数调用,并从提供给peekConsumer将日志记录到控制台。
我们的第 4 章“结构模式”,装饰器示例可以用函数式重写;注意装饰器用于使用与初始装饰器消费者相同的输入:
jshell> Consumer toASCII = x -> System.out.println("Print ASCII: " + x); toASCII ==> $Lambda$159/1690859824@400cff1a jshell> Function toHex = x -> x.chars().boxed().map(y -> "0x" + Integer.toHexString(y)).collect(Collectors.joining(" ")); toHex ==> $Lambda$158/1860250540@55040f2f jshell> Consumer decorateToHex = x -> System.out.println("Print HEX: " + toHex.apply(x)) decorateToHex ==> $Lambda$160/1381965390@75f9eccc jshell> toASCII.andThen(decorateToHex).accept("text") Print ASCII: text Print HEX: 0x74 0x65 0x78 0x74

责任链 责任链可以实现为处理器(函数)的列表,每个处理器执行一个特定的操作。下面的示例代码使用闭包和一系列函数,这些函数一个接一个地应用于给定的文本:
jshell> String text = "Text"; text ==> "Text" jshell> Stream.of(String::toLowerCase, x -> LocalDateTime.now().toString() + " " + x).map(f -> f.apply(text)).collect(Collectors.toList()) $55 ==> [text, 2017-08-10T08:41:28.243310800 Text]

命令 其目的是将一个方法转换成一个对象来存储它并在以后调用它,能够跟踪它的调用、记录和撤消。这是Consumer类的基本用法。
在下面的代码中,我们将创建一个命令列表并逐个执行它们:
jshell> List tasks = List.of(System.out::println, x -> System.out.println(LocalDateTime.now().toString() + " " + x)) tasks ==> [$Lambda$192/728258269@6107227e, $Lambda$193/1572098393@7c417213] jshell> tasks.forEach(x -> x.accept(text)) Text 2017-08-10T08:47:31.673812300 Text

解释器 解释器的语法可以存储为关键字映射,相应的操作存储为值。在第二章“创建模式”中,我们使用了一个数学表达式求值器,将结果累加成一个栈。这可以通过将表达式存储在映射中来实现,并使用reduce来累加结果:
jshell> Map operands = Map.of("+", (x, y) -> x + y, "-", (x, y) -> x - y) operands ==> {-=$Lambda$208/1259652483@65466a6a, +=$Lambda$207/1552978964@4ddced80} jshell> Arrays.asList("4 5 + 6 -".split(" ")).stream().reduce("0 ",(acc, x) -> { ...> if (operands.containsKey(x)) { ...> String[] split = acc.split(" "); ...> System.out.println(acc); ...> acc = split[0] + " " + operands.get(x).applyAsInt(Integer.valueOf(split[1]), Integer.valueOf(split[2])) + " "; ...> } else { acc = acc + x + " "; } ...> return acc; }).split(" ")[1] 0 4 5 0 9 6 $76 ==> "3"

迭代器 迭代器部分是通过使用流提供的序列来实现的。Java8 添加了forEach方法,该方法接收消费者作为参数,其行为与前面的循环实现类似,如下面的示例代码所示:
jshell> List.of(1, 4).forEach(System.out::println) jshell> for(Integer i: List.of(1, 4)) System.out.println(i);

如预期的那样,每个示例的输出是 1 和 4。
观察者 在 Java8 中,观察者模式被 Lambda 表达式取代。最明显的例子是ActionListener替换。使用匿名类监听器的旧代码被替换为一个简单的函数调用:
JButton button = new Jbutton("Click Here"); button.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { System.out.println("Handled by the old listener"); } });

新代码只有一行:
button.addActionListener(e -> System.out.println("Handled by lambda"));

策略 这个策略可以被一个函数代替。在下面的代码示例中,我们对所有价格应用 10% 的折扣策略:
jshell> Function tenPercentDiscount = x -> x * 0.9; tenPercentDiscount ==> $Lambda$217/1990160809@4c9f8c13 jshell> List.of(5.4, 6.27, 3.29).stream().map(tenPercentDiscount).collect(Collectors.toList()) $98 ==> [4.86, 5.643, 2.9610000000000003]

模板方法 当模板提供调用顺序时,可以实现模板方法以允许注入特定的方法调用。在下面的示例中,我们将添加特定的调用并从外部设置它们的内容。它们可能已经插入了特定的内容。通过使用接收所有可运行项的单个方法,可以简化代码:
jshell> class TemplateMethod { ...> private Runnable call1 = () -> {}; ...> private Runnable call2 = () -> System.out.println("Call2"); ...> private Runnable call3 = () -> {}; ...> public void setCall1(Runnable call1) { this.call1 = call1; } ...> public void setCall2(Runnable call2) { this.call2 = call2; } ...> public void setCall3(Runnable call3) { this.call3 = call3; } ...> public void run() { ...> call1.run(); ...> call2.run(); ...> call3.run(); ...> } ...> } | created class TemplateMethod jshell> TemplateMethod t = new TemplateMethod(); t ==> TemplateMethod@70e8f8e jshell> t.setCall1(() -> System.out.println("Call1")); jshell> t.setCall3(() -> System.out.println("Call3")); jshell> t.run(); Call1 Call2 Call3

函数式设计模式 在本节中,我们将学习以下函数式设计模式:
  • 映射和归约
  • 借贷模式
  • 尾部调用优化
  • 回忆录
  • 环绕执行方法
映射和归约 MapReduce 是 Google 开发的一种用于大规模并行编程的技术,由于易于表达,它以函数设计模式出现。在函数式编程中,它是单子的一种形式。
意图 其目的是将现有任务分解为多个较小的任务,并行运行它们,并聚合结果(reduce)。它有望提高大数据的性能。
示例 我们将通过基于给定的 Sleuth 跨度解析和聚合来自多个 Web 服务的日志并计算每个命中端点的总持续时间来演示 MapReduce 模式的用法。日志取自这个页面并拆分成相应的服务日志文件。下面的代码并行读取所有日志、映射、排序和过滤相关日志条目,收集并减少(聚合)结果。如果有结果,它将被打印到控制台。导入的日期/时间类用于排序比较。flatMap代码需要处理Exception,如下代码所示:
jshell> import java.time.* jshell> import java.time.format.* jshell> DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS") dtf ==> Value(YearOfEra,4,19,EXCEEDS_PAD)'-'Value(MonthOf ... Fraction(NanoOfSecond,3,3) jshell> try (Stream files = Files.find(Paths.get("d:/"), 1, (path, attr) -> String.valueOf(path).endsWith(".log"))) { ...> files.parallel(). ...> flatMap(x -> { try { return Files.lines(x); } catch (IOException e) {} return null; }). ...> filter(x -> x.contains("2485ec27856c56f4")). ...> map(x -> x.substring(0, 23) + " " + x.split(":")[3]). ...> sorted((x, y) -> LocalDateTime.parse(x.substring(0, 23), dtf).compareTo(LocalDateTime.parse(y.substring(0, 23), dtf))). ...> collect(Collectors.toList()).stream().sequential(). ...> reduce((acc, x) -> { ...> if (acc.length() > 0) { ...> Long duration = Long.valueOf(Duration.between(LocalDateTime.parse(acc.substring(0, 23), dtf), LocalDateTime.parse(x.substring(0, 23), dtf)).t oMillis()); ...> acc += "n After " + duration.toString() + "ms " + x.substring(24); ...> } else { ...> acc = x; ...> } ...> return acc; }).ifPresent(System.out::println); ...> } 2016-02-26 11:15:47.561 Hello from service1\. Calling service2 After 149ms Hello from service2\. Calling service3 and then service4 After 334ms Hello from service3 After 363ms Got response from service3 [Hello from service3] After 573ms Hello from service4 After 595ms Got response from service4 [Hello from service4] After 621ms Got response from service2 [Hello from service2, response from service3 [Hello from service3] and from service4 [Hello from service4]]

借贷模式 借贷模式确保资源一旦超出范围就被决定性地处置。资源可以是数据库连接、文件、套接字或任何处理本机资源的对象(内存、系统句柄、任何类型的连接)之一。这与 MSDN 上描述的 Dispose 模式的意图类似。
意图 这样做的目的是让用户在未使用的资源被使用后,从释放这些资源的负担中解脱出来。用户可能忘记调用资源的release方法,从而导致泄漏。
示例 在处理数据库事务时,最常用的模板之一是获取事务、进行适当的调用、确保在异常时提交或回滚并关闭事务。这可以实现为借贷模式,其中移动部分是事务中的调用。以下代码显示了如何实现这一点:
jshell> class Connection { ...> public void commit() {}; public void rollback() {}; public void close() {}; public void setAutoCommit(boolean autoCommit) {}; ...> public static void runWithinTransaction(Consumer c) { ...> Connection t = null; ...> try { t = new Connection(); t.setAutoCommit(false); ...> c.accept(t); ...> t.commit(); ...> } catch(Exception e) { t.rollback(); } finally { t.close(); } } } | created class Connection jshell> Connection.runWithinTransaction(x -> System.out.println("Execute statement...")); Execute statement...

尾部调用优化 尾部调用优化(TCO)是一些编译器在不使用栈空间的情况下调用函数的技术。Scala 通过用@tailrec注解递归代码来利用它。这基本上告诉编译器使用一个特殊的循环,称为 trampoline,它反复运行函数。函数调用可以处于一种或多种要调用的状态。在完成时,它返回结果(头部),在更多的情况下,它返回当前循环而不返回头部(尾部)。这个模式已经被 cyclops-react 提供给我们了。
意图 其目的是在不破坏栈的情况下启用递归调用。它只用于大量的递归调用,对于少数调用,它可能会降低性能。
示例 cyclops-react 的维护者 John McClean 演示了 TCO 在 Fibonacci 序列中计算数字的用法。代码简洁易懂,基本上是从初始状态 0 和 1 开始累加斐波那契数,f(0) = 0f(1) = 1,应用f(n) = f(n-1) + f(n-2)函数:
importstatic cyclops.control.Trampoline.done; importstatic cyclops.control.Trampoline.more; import cyclops.control.Trampoline; publicclass Main { publicvoid fib() { for(int i=0; i<100_000; i++) System.out.println(fibonacci(i, 0l, 1l).get()); } public Trampoline fibonacci(Integer count, Long a, Long b) { return count==0 ? done(a) : more(()->fibonacci (count - 1, b, a + b)); } publicstaticvoid main(String[] args) { new Main().fib(); } }

回忆录 多次调用前面的 Fibonacci 实现将导致 CPU 周期的浪费,因为有些步骤是相同的,并且我们可以保证,对于相同的输入,我们总是得到相同的输出(纯函数)。为了加速调用,我们可以缓存输出,对于给定的输入,只返回缓存结果,而不是实际计算结果。
意图 其目的是缓存给定输入的函数结果,并使用它加速对给定相同输入的相同函数的进一步调用。它应该只用于纯函数,因为它们提供了引用透明性。
示例 在下面的示例中,我们将重用 Fibonacci 代码并添加 Guava 缓存。缓存将保存 Fibonacci 的返回值,而键是输入数字。缓存配置为在大小和时间上限制内存占用:
importstatic cyclops.control.Trampoline.done; importstatic cyclops.control.Trampoline.more; import java.math.BigInteger; import java.util.Arrays; import java.util.List; import java.util.concurrent.TimeUnit; import com.google.common.cache.Cache; import com.google.common.cache.CacheBuilder; import cyclops.async.LazyReact; import cyclops.control.Trampoline; publicclass Main { public BigInteger fib(BigInteger n) { return fibonacci(n, BigInteger.ZERO, BigInteger.ONE).get(); } public Trampoline fibonacci(BigInteger count, BigInteger a, BigInteger b) { return count.equals(BigInteger.ZERO) ? done(a) : more(()->fibonacci (count.subtract(BigInteger.ONE), b, a.add(b))); } publicvoid memoization(List array) { Cache cache = CacheBuilder.newBuilder() .maximumSize(1_000_000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(); LazyReact react = new LazyReact().autoMemoizeOn((key,fn)-> cache.get((BigInteger)key,()-> (BigInteger)fn. apply((BigInteger)key))); Listresult = react.from(array) .map(i->fibonacci(BigInteger.valueOf(i), BigInteger.ZERO, BigInteger.ONE)) .toList(); } publicstaticvoid main(String[] args) { Main main = new Main(); List array = Arrays.asList(500_000, 499_999); long start = System.currentTimeMillis(); array.stream().map(BigInteger::valueOf).forEach(x -> main.fib(x)); System.out.println("Regular version took " + (System.currentTimeMillis() - start) + " ms"); start = System.currentTimeMillis(); main.memoization(array); System.out.println("Memoized version took " + (System.currentTimeMillis() - start) + " ms"); } }

输出如下:
Regular version took 19022 ms Memoized version took 394 ms

环绕执行方法 在度量每个版本的代码的性能时,前面的代码似乎都在重复。这可以通过环绕执行方法模式解决,方法是将执行的业务代码包装到 Lambda 表达式中。这种模式的一个很好的例子是单元测试前后的设置/拆卸函数。这类似于前面描述的模板方法和借贷模式。
意图 其目的是让用户可以在特定业务方法之前和之后执行某些特定的操作。
示例 上一个示例中提到的代码包含重复的代码(代码气味)。我们将应用环绕执行模式来简化代码并使其更易于阅读。可能的重构可以使用 Lambda,如我们所见:
publicstaticvoid measurePerformance(Runnable runnable) { long start = System.currentTimeMillis(); runnable.run(); System.out.println("It took " + (System.currentTimeMillis() - start) + " ms"); } publicstaticvoid main(String[] args) { Main main = new Main(); List array = Arrays.asList(500_000, 499_999); measurePerformance(() -> array.stream().map(BigInteger::valueOf) .forEach(x -> main.fib(x))); measurePerformance(() -> main.memoization(array)); }

总结 在本章中,我们了解了函数式编程的含义、最新 Java 版本提供的特性,以及它们是如何改变一些现有的 GOF 模式的。我们还使用了一些函数式编程设计模式。
【Java 设计模式最佳实践(五、函数式模式)】在下一章中,我们将深入到反应式世界,学习如何使用 RxJava 创建响应式应用。

    推荐阅读