Java-函数式编程-全-

Java 函数式编程(全)

原文:Functional Programming in Java

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:什么是函数式编程?

本章涵盖的内容

  • 函数式编程的好处

  • 副作用的弊端

  • 引用透明性如何使程序更安全

  • 使用替换模型对程序进行推理

  • 充分利用抽象

并非每个人都对函数式编程(FP)的定义达成一致。一般来说,函数式编程是一种编程范式,它涉及使用函数进行编程。但这并没有解释最重要的方面:函数式编程与其他范式的不同之处,以及它为什么是一种(潜在的)更好的编程方式。在 1990 年发表的文章《为什么函数式编程很重要》中,约翰·休斯写道以下内容:

函数式程序不包含赋值语句,因此变量一旦赋予值就不再改变。更普遍地说,函数式程序根本不包含任何副作用。函数调用除了计算其结果外,不会产生任何影响。这消除了错误的主要来源,并且使得执行顺序无关紧要——因为没有任何副作用可以改变表达式的值,所以它可以在任何时间进行评估。这减轻了程序员指定控制流程的负担。由于表达式可以在任何时间进行评估,因此可以自由地用其值替换变量,反之亦然——也就是说,程序是“引用透明的”。这种自由使得函数式程序在数学上比传统的对应程序更容易处理。^([1])

¹

约翰·休斯,《为什么函数式编程很重要》,收录于 D. 特纳编,《函数式编程研究主题》(Addison-Wesley,1990),17–42,www.cs.kent.ac.uk/people/staff/dat/miranda/whyfp90.pdf

在本章的其余部分,我将简要介绍诸如引用透明性和替换模型等概念,以及其他共同构成函数式编程本质的概念。你将在接下来的章节中反复应用这些概念。

1.1. 什么是函数式编程?

了解某物不是什么,与同意它是什么一样重要。如果函数式编程是一种编程范式,那么显然必须存在其他与 FP 不同的编程范式。与一些人的想法相反,函数式编程并不是面向对象编程(OOP)的对立面。一些函数式编程语言是面向对象的;一些则不是。

函数式编程有时被认为是一组补充或替代其他编程范式(如)中找到的技术的方法

  • 首类函数

  • 匿名函数

  • 闭包

  • 柯里化

  • 惰性求值

  • 参数多态性

  • 代数数据类型

虽然大多数函数式语言确实使用了一些这些技术,但你可能会发现,对于每一种技术,都有函数式编程语言不使用它的例子,以及非函数式语言使用它的例子。正如你在本书研究这些技术的每一部分时将会看到的,使编程成为函数式的不是语言本身,而是你编写代码的方式。但有些语言比其他语言更函数式友好

函数式编程可能反对的是命令式编程范式。在命令式编程风格中,程序由“做”某事的元素组成。“做”某事通常意味着一个初始状态、一个转换和一个最终状态。这有时被称为状态突变。传统的命令式风格程序通常被描述为一系列突变,通过条件测试分隔。例如,一个用于添加两个正数ab的加法程序可能被以下伪代码表示:

  • if b == 0, return a

  • else increment a and decrement b

  • start again with the new a and b

在这个伪代码中,你可以识别出大多数命令式语言的传统指令:测试条件、突变变量、分支和返回值。此代码可以通过流程图来图形化表示,如图 1.1。

图 1.1. 表示命令式程序作为在时间中发生的流程的流程图。各种事物被转换,状态被突变,直到获得结果。

另一方面,函数式程序由“是”某物的元素组成——它们不“做”某事。ab的加法并不“制造”一个结果。例如,2 和 3 的加法并不制造5。它就是5。

这种区别可能看起来并不重要,但它很重要。主要后果是,每次你遇到 2 + 3 时,你都可以用 5 来替换它。你能在命令式程序中做同样的事情吗?嗯,有时你可以。但有时你无法在不改变程序结果的情况下做到这一点。如果你想要替换的表达式除了返回结果没有其他效果,你可以安全地用它的结果来替换。但你如何确保它没有其他效果?在加法示例中,你可以清楚地看到两个变量ab被程序破坏了。这是程序的一个效果,除了返回结果之外,所以它被称为副作用。(如果计算是在 Java 方法内部进行的,这将是不同的,因为变量ab将通过值传递,变化将是局部的,并且从方法外部不可见。)

命令式编程和 FP 之间的一大区别是,在 FP 中没有副作用。这意味着,在其他方面,

  • 没有变量突变

  • 不打印到控制台或任何设备

  • 不写入文件、数据库、网络或任何东西

  • 没有抛出异常

当我说“无副作用”时,我的意思是没有可观察的副作用。函数式程序是通过组合 函数 构建的,这些函数接受一个参数并返回一个值,仅此而已。你不需要关心函数内部发生的事情,因为,从理论上讲,永远没有发生任何事情。但在实践中,程序是为那些根本不是函数式的计算机编写的。所有计算机都基于相同的命令式范式;因此,函数是黑盒,它们

  • 接受一个参数(一个 单个 参数,你稍后会看到)

  • 在内部执行神秘的操作,例如更改变量和大量的命令式风格的操作,但没有从外部可观察到的效果

  • 返回一个(单个)值

这是一种理论。在实践中,一个函数完全没有副作用是不可能的。函数会在某个时间返回一个值,这个时间可能会变化。这就是副作用。它可能会创建一个内存不足错误,或者栈溢出错误,导致应用程序崩溃,这是一个可以观察到的副作用。它还会导致写入内存、注册变更、线程启动、上下文切换以及其他一些确实可以从外部观察到的效果。

因此,函数式编程是编写没有 有意副作用 的程序,我的意思是这些副作用是程序预期结果的一部分。还应该尽可能少地有非有意副作用。

1.2. 编写无副作用的实用程序

你可能会想知道如果没有副作用,你如何可能编写有用的程序。显然,你不能。函数式编程不是关于编写没有可观察结果程序的。它是关于编写除了返回值之外没有其他可观察结果的程序的。但如果程序只做这些,它将不会很有用。最终,函数式程序必须有一个可观察的效果,比如在屏幕上显示结果,写入文件或数据库,或者通过网络发送。这种与外部世界的交互不会在计算过程中发生,而只在你完成计算时发生。换句话说,副作用将被延迟并单独应用。

以图 1.1 中的加法为例。#ch01fig01。尽管它是以命令式风格描述的,但它可能仍然是函数式的,这取决于它的实现方式。想象这个程序是用以下方式用 Java 实现的:

public static int add(int a, int b) {
  while (b > 0) {
    a++;
    b--;
  }
  return a;
}

这个程序功能齐全。它接受一个参数,即整数对 ab,它返回一个值,并且绝对没有其他可观察的效果。它改变变量并不违反要求,因为在 Java 中参数是通过值传递的,所以参数的变更对外部是不可见的。然后你可以选择应用一个效果,比如显示结果或使用结果进行其他计算。

注意,尽管结果可能不正确(在算术溢出的情况下),但这并不与没有副作用相矛盾。如果值ab太大,程序将静默溢出并返回错误的结果,但这仍然是功能性的。另一方面,以下程序不是函数式的:

public static int div(int a, int b) {
  return a / b;
}

虽然这个程序没有修改任何变量,但如果b等于0,它会抛出异常。抛出异常是一个副作用。相比之下,以下实现,尽管有点愚蠢,但却是函数式的:

public static int div(int a, int b) {
  return (int) (a / (float) b);
}

如果b等于0,这个实现不会抛出异常,但它会返回一个特殊的结果。是否允许你的函数返回这个特定的结果来表示除数是0取决于你。(这很可能不是!)

抛出异常可能是有意为之或无意为之的副作用,但无论如何,它始终是一个副作用。然而,在命令式编程中,副作用通常是期望的。最简单的形式可能如下所示:

public static void add(int a, int b) {
  while (b > 0) {
    a++;
    b--;
  }
  System.out.println(a);
}

这个程序不返回值,但它将结果打印到控制台。这是一个期望的副作用。

注意,程序可以同时返回一个值并产生一些有意为之的副作用,如下面的例子所示:

public static int add(int a, int b) {
  log(String.format("Adding %s and %s", a, b));
  while (b > 0) {
    a++;
    b--;
  }
  log(String.format("Returning %s", a));
  return a;
}

这个程序不是函数式的,因为它使用了副作用进行日志记录。

1.3. 引用透明性如何使程序更安全

没有副作用(因此不会对外部世界中的任何东西进行修改)对于程序来说并不足够。函数式程序还必须不受外部世界的影响。换句话说,函数式程序的输出必须只依赖于其参数。这意味着函数式代码可能无法从控制台、文件、远程 URL、数据库或甚至从系统中读取数据。不修改或依赖于外部世界的代码被称为引用透明。

引用透明代码有几个属性,可能对程序员有些兴趣:

  • 它是自包含的。它不依赖于任何外部设备来工作。你可以在任何上下文中使用它——你只需要提供一个有效的参数。

  • 它是确定的,这意味着对于相同的参数,它将始终返回相同的值。在引用透明代码中,你不会感到惊讶。它可能会返回错误的结果,但至少,对于相同的参数,这个结果永远不会改变。

  • 它永远不会抛出任何类型的Exception。它可能会抛出错误,例如 OOME(内存不足错误)或 SOE(栈溢出错误),但这些错误意味着代码中存在错误,这不是你作为程序员或你的 API 用户应该处理的情况(除了崩溃应用程序并最终修复错误)。

  • 它不会创建导致其他代码意外失败的条件。例如,它不会修改参数或某些其他外部数据,导致调用者发现自己拥有过时的数据或并发访问异常。

  • 它不会挂起,因为某些外部设备(无论是数据库、文件系统还是网络)不可用、太慢或简单地损坏。

图 1.2 展示了引用透明程序和非引用透明程序之间的区别。

图 1.2. 比较引用透明程序和非引用透明程序

1.4. 函数式编程的好处

从我刚才说的,你可能会猜到函数式编程的许多好处:

  • 函数式程序更容易推理,因为它们是确定性的。特定的输入总是会给出相同的输出。在许多情况下,你可能会证明你的程序是正确的,而不是广泛测试它,并且仍然不确定它是否会在意外条件下崩溃。

  • 函数式程序更容易测试。因为没有副作用,你不需要模拟,这通常是隔离测试程序所必需的。

  • 函数式程序更模块化,因为它们是由只有输入和输出的函数构建的;没有副作用需要处理,没有异常需要捕获,没有上下文突变需要处理,没有共享可变状态,也没有并发修改。

  • 函数式编程使得组合和重组变得容易得多。要编写一个函数式程序,你必须首先编写所需的各个基础函数,然后将这些基础函数组合成更高级的函数,重复此过程,直到你有一个对应于你想要构建的程序的单个函数。由于所有这些函数都是引用透明的,因此它们可以被重用来构建其他程序,而无需任何修改。

函数式程序天生是线程安全的,因为它们避免了共享状态的突变。再次强调,这并不意味着所有数据都必须是不可变的。只有共享数据必须如此。但函数式程序员很快就会意识到,不可变数据总是更安全,即使突变在外部不可见。

1.5. 使用替换模型推理程序

记住,一个函数并不任何事情。它只有一个值,这个值只依赖于它的参数。因此,总是可以替换函数调用或任何引用透明表达式,用它的值来替换,如图 1.3 所示。

图 1.3. 用它们的值替换引用透明表达式不会改变整体意义。

当应用于函数时,替换模型允许你用任何函数的返回值替换任何函数调用。考虑以下代码:

public static void main(String[] args) {
  int x = add(mult(2, 3), mult(4, 5));
}
public static int add(int a, int b) {
  log(String.format("Returning %s as the result of %s + %s", a + b, a, b));
  return a + b;
}
public static int mult(int a, int b) {
  return a * b;
}
public static void log(String m) {
  System.out.println(m);
}

mult(2, 3)mult(4, 5) 的相应返回值替换它们不会改变程序的意义:

int x = add(6, 20);

相比之下,用其返回值替换对add函数的调用会改变程序的意义,因为log方法将不再被调用,并且不会发生日志记录。这可能是重要的,也可能不重要;无论如何,它改变了程序的结果。

1.6. 将函数式原则应用于简单示例

作为将命令式程序转换为函数式程序的一个例子,我们将考虑一个非常简单的程序,该程序代表使用信用卡购买甜甜圈。

列表 1.1. 带有副作用的 Java 程序

![Images/009fig01_alt.jpg]

在这段代码中,对信用卡的扣费是一个副作用 ![Images/num-01.jpg]。扣费信用卡可能包括调用银行,验证信用卡是否有效和已授权,并注册交易。函数返回甜甜圈 ![Images/num-02.jpg]。

这种代码的问题在于它很难测试。进行程序测试将涉及联系银行并使用某种模拟账户注册交易。或者你需要创建一个模拟信用卡来注册调用charge方法的效果,并在测试后验证模拟的状态。

如果你想要能够在不联系银行或使用模拟的情况下测试你的程序,你应该移除副作用。因为你还想对信用卡进行扣费,唯一的解决方案是向返回值中添加这个操作的表示。你的buyDonut方法将必须返回甜甜圈以及这个支付表示。

为了表示支付,你可以使用一个Payment类。

列表 1.2. Payment
public class Payment {

  public final CreditCard creditCard;
  public final int amount;
  public Payment(CreditCard creditCard, int amount) {
    this.creditCard = creditCard;
    this.amount = amount;
  }
}

这个类包含了表示支付所需的所有数据,支付由信用卡和要扣费的金额组成。因为buyDonut方法必须返回一个Donut和一个Payment,你可以创建一个特定的类来处理这种情况,例如Purchase

public class Purchase {

  public Donut donut;
  public Payment payment;

  public Purchase(Donut donut, Payment payment) {
    this.donut = donut;
    this.payment = payment;
  }
}

你经常会需要这样的类来持有两个(或更多)值,因为函数式编程用返回这些效果的表示来替换副作用。

你不会创建一个特定的Purchase类,而是使用一个通用的类,你可以称之为Tuple。这个类将由它将包含的两个类型(DonutPayment)进行参数化。以下列表显示了它的实现,以及它在DonutShop类中的使用方式。

列表 1.3. Tuple
public class Tuple<T, U> {

  public final T _1;
  public final U _2;

  public Tuple(T t, U u) {
    this._1 = t;
    this._2 = u;
  }
}
public class DonutShop {

  public static Tuple<Donut, Payment> buyDonut(CreditCard creditCard) {
    Donut donut = new Donut();
    Payment payment = new Payment(creditCard, Donut.price);
    return new Tuple<>(donut, payment);
  }
}

注意,你在这个阶段不再关心信用卡实际上是如何被扣费的。这为构建你的应用程序提供了一些自由度。你仍然可以立即处理支付,或者你可以将其存储起来稍后处理。你甚至可以将同一张卡的存储支付组合起来,一次性处理。这将允许你通过最小化信用卡服务的银行费用来节省金钱。

下面的列表中的combine方法允许您合并支付。请注意,如果信用卡不匹配,则会抛出异常。这并不与我说过的功能性程序不会抛出异常相矛盾。在这里,尝试使用两张不同的信用卡合并两个支付被认为是错误,因此应该使应用程序崩溃。(这并不太现实。您将不得不等到第七章来学习如何在不抛出异常的情况下处理这种情况。)

列表 1.4. 将多个支付组合成一个
package com.fpinjava.introduction.listing01_04;

public class Payment {

  public final CreditCard creditCard;
  public final int amount;

  public Payment(CreditCard creditCard, int amount) {
    this.creditCard = creditCard;
    this.amount = amount;
  }

  public Payment combine(Payment payment) {
    if (creditCard.equals(payment.creditCard)) {
      return new Payment(creditCard, amount + payment.amount);
    } else {
      throw new IllegalStateException("Cards don't match.");
    }
  }
}

当然,combine方法对于一次性购买多个甜甜圈可能不太高效。对于这种情况,您可以简单地用buy-Donuts(int n, CreditCard creditCard)方法替换buyDonut方法,如下所示。此方法返回一个Tuple<List<Donut>, Payment>

列表 1.5. 一次性购买多个甜甜圈
package com.fpinjava.introduction.listing01_05;

import static com.fpinjava.common.List.fill;
import com.fpinjava.common.List;
import com.fpinjava.common.Tuple;

public class DonutShop {

  public static Tuple<Donut, Payment> buyDonut(final CreditCard cCard) {
    return new Tuple<>(new Donut(), new Payment(cCard, Donut.price));
  }

  public static Tuple<List<Donut>, Payment> buyDonuts(final int quantity,
                                            final CreditCard cCard) {
    return new Tuple<>(fill(quantity, () -> new Donut()),
                                     new Payment(cCard, Donut.price * quantity));
  }
}

注意,这个方法没有使用标准的java.util.List类,因为这个类不提供您需要的某些功能性方法。在第三章中,您将看到如何通过编写一个小型的功能性库来以功能性方式使用java.util.List类。然后,在第五章中,您将开发一个全新的功能性List。这里使用的就是这个列表。这个combine方法在某种程度上等同于以下使用标准 Java 列表的代码:

public static Tuple<List<Donut>, Payment> buyDonuts(final int quantity,
                                                  final CreditCard cCard) {
    return new Tuple<>(Collections.nCopies(quantity, new Donut()),
                       new Payment(cCard, Donut.price * quantity));
  }

由于您很快就需要额外的功能性方法,您将不会使用 Java 列表。目前,您只需要知道static List<A> fill(int n, Supplier<A> s)方法通过使用一个特殊对象Supplier<A>来创建nA实例的列表。正如其名称所示,Supplier<A>是一个当其get()方法被调用时提供A的对象。使用Supplier<A>而不是A允许进行延迟评估,您将在下一章中学习到这一点。目前,您可以将其视为一种在不实际创建它直到需要时操作A的方法。

现在,您可以在不使用模拟的情况下测试您的程序。例如,以下是对buyDonuts方法的测试:

@Test
public void testBuyDonuts() {
  CreditCard creditCard = new CreditCard();
  Tuple<List<Donut>, Payment> purchase = DonutShop.buyDonuts(5, creditCard);
  assertEquals(Donut.price * 5, purchase._2.amount);
  assertEquals(creditCard, purchase._2.creditCard);
}

将您的程序设计为功能性的另一个好处是它更容易组合。如果同一个人使用您的初始程序进行了多次购买,您每次都必须联系银行(并支付相应的费用)。使用新的功能版本,您可以选择为每次购买立即扣款,或者将使用同一张卡进行的所有支付分组,并只对总额进行一次扣款。

要分组支付,您需要使用您功能List类中的额外方法(目前您不需要理解这些方法是如何工作的;您将在第五章和第八章中详细学习它们):

public <B> Map<B, List<A>> groupBy(Function<A, B> f)

List类的这个实例方法接受一个从AB的函数,并返回一个键值对映射,其中键的类型为B,值的类型为List<A>。换句话说,它按信用卡对付款进行分组:

List<A> values()

这是一个Map的实例方法,它返回映射中所有值的列表:

<B> List<B> map(Function<A, B> f)

这是一个List的实例方法,它接受一个从AB的函数,并将其应用于A类型列表的所有元素,从而得到一个B类型的列表:

Tuple<List<A1>, List<A2>> unzip(Function<A, Tuple<A1, A2>> f)

这是一个List类的方法,它接受一个从A到值元组的函数作为其参数。例如,它可能是一个接受电子邮件地址并返回名称和域名作为元组的函数。在这种情况下,unzip方法将返回一个包含名称列表和域名列表的元组。

A reduce(Function<A, Function<A, A>> f)

List的这种方法使用一个操作将列表缩减为一个单一值。这个操作由Function<A, Function<A, A>> f表示。这种表示法可能看起来有点奇怪,但你将在第二章中了解到它的含义。例如,它可能是一个加法操作。在这种情况下,它仅仅意味着一个函数,如f(a, b) = a + b

使用这些方法,你现在可以创建一个新的方法,按信用卡对付款进行分组。

列表 1.6. 按信用卡分组付款

图片

图片

注意,你可以在groupByCard方法的最后一行使用方法引用,但我选择使用 lambda 表达式,因为这可能(更)容易阅读。如果你更喜欢方法引用,你可以将这一行替换为以下一行:

.map(x -> x.reduce(c1 -> c1::combine));

在列表 1.6 中,c1 ->后面的部分是一个接受单个参数并将该参数传递给c1.combine()的函数。这正是c1::combine所做的事情——它是一个接受单个参数的函数。方法引用通常比 lambda 表达式更容易阅读,但并不总是如此!

1.7. 将抽象推向极限

正如你所看到的,函数式编程在于通过组合纯函数来编写程序,这意味着没有副作用。这些函数可以表示为方法,或者它们可以是一等函数,如前例中groupBymapreduce方法的参数。一等函数只是以这种方式表示的函数,与方法不同,它们可以被程序操作。在大多数情况下,它们被用作其他函数或方法的参数。你将在第二章中了解到如何做到这一点。

但这里最重要的概念是抽象。看看reduce方法。它接受一个操作作为参数,并使用它将列表缩减为一个单一值。在这里,操作有两个相同类型的操作数。除了这一点,它可以是任何操作。考虑一个整数列表。你可以编写一个sum方法来计算元素的总和;你可以编写一个product方法来计算元素乘积;或者你可以编写一个minmax方法来计算列表的最小值或最大值。但你也可以使用reduce方法来完成所有这些计算。这是抽象。你抽象了reduce方法中所有操作共有的部分,并将变量部分(操作)作为参数传递。

但你可以更进一步。reduce方法是一个更一般方法的特例,它可能产生与列表元素不同类型的结果。例如,它可以应用于字符列表以产生一个String。你需要从一个给定的值开始(可能是一个空字符串)。在第三章和 5 章中,你将学习如何开发这种方法(称为fold)。还要注意,reduce方法在空列表上不会工作。想象一下整数列表——如果你想计算总和,你需要有一个元素来开始。如果列表为空,你应该返回什么?当然,你知道结果应该是 0,但这只适用于总和。它不适用于乘积。

还要考虑groupByCard方法。它看起来像是一个只能用于按信用卡分组付款的业务方法。但并非如此!你可以使用这个方法通过任何属性来对任何列表的元素进行分组,因此这个方法应该被抽象,并放在List类中,以便可以轻松重用。

函数式编程的一个重要部分在于将抽象推向极限。在这本书的其余部分,你将学习如何抽象许多事物,这样你就不必再定义它们。例如,你将学习如何抽象循环,这样你就不必再编写循环。你还将学习如何以允许你通过在List类中选择方法来从串行处理切换到并行处理的方式来抽象并行化。

1.8. 摘要

  • 函数式编程是以函数、返回值和没有副作用的方式进行编程。

  • 函数式程序易于推理和测试。

  • 函数式编程提供了高级的抽象和可重用性。

  • 函数式程序比它们的命令式对应物更健壮。

  • 函数式程序在多线程环境中更安全,因为它们避免了共享可变状态。

第二章. 在 Java 中使用函数

本章涵盖

  • 在现实世界中理解函数

  • 在 Java 中表示函数

  • 使用 lambda 表达式

  • 使用高阶函数

  • 使用柯里化函数

  • 使用函数式接口进行编程

要理解函数式编程是如何工作的,我们可以使用某些函数式库提供的函数组件,或者甚至那些在 Java 8 库中已经可用的一些组件。但相反,我们将探讨如何构建事物,而不是如何使用这些提供的组件。一旦你掌握了这些概念,选择使用你自己的函数还是标准的 Java 8 函数,或者依赖现有的外部库,就取决于你了。在本章中,你将创建一个与 Java 8 的Function非常相似的Function。在处理类型参数(避免使用通配符)方面,它将略有简化,以便代码更容易阅读,但它将具有 Java 8 版本中缺少的一些强大功能。除了这些差异之外,它们是可以互换的。

你可能难以理解本章中展示的一些代码部分。这是可以预料的,因为在不使用其他函数式结构(如ListOption等)的情况下介绍函数是非常困难的。请耐心等待。所有未解释的组件将在接下来的章节中讨论。

我现在将更详细地解释什么是函数,无论是在现实世界还是在编程语言中。函数不仅仅是数学或编程实体。函数是日常生活的一部分。我们不断地在构建我们生活的世界的模型,这不仅适用于编程。我们构建我们周围世界的表示,而这些表示通常基于随时间改变其状态的对象。以这种方式看待事物是人的天性。从状态 A 到状态 B 的转变需要时间,并且在时间、努力或金钱方面都有成本。

以加法为例。我们大多数人将其视为需要时间(有时甚至需要智力努力)的计算!它有一个起始状态,一个过渡(计算),以及一个结果状态(加法的结果)。

要将 345、765 和 34,524 相加,我们当然需要进行计算。有些人可以在很短的时间内完成,而有些人则需要更长的时间。有些人可能永远无法成功,或者会得到错误的结果。有些人会在脑海中进行计算;其他人则需要将它们写在纸上。所有这些都会在某种程度上改变状态以实现这一点,无论是纸张还是他们大脑的一部分。但要将 2 和 3 相加,我们不需要所有这些。我们大多数人已经记住了答案,可以立即给出结果,而无需进行任何计算。

这个例子表明,计算并不是这里的关键要素。它只是计算函数结果的一种手段。但这个结果在我们进行计算之前就已经存在了。我们只是通常不知道这个结果是什么。

函数式编程就是使用函数进行编程。为了能够做到这一点,我们首先需要知道什么是函数,无论是在现实世界还是在我们所选择的编程语言中。

2.1. 什么是函数?

函数 通常被称为数学对象,尽管这个概念在日常生活中的应用也非常普遍。不幸的是,在日常生活中,我们经常混淆函数和效果。更不幸的是,我们在使用许多编程语言时也会犯这个错误。

2.1.1. 现实世界中的函数

在现实世界中,函数主要是一个数学概念。它是一个称为函数 定义域 的源集合与称为函数 值域 的目标集合之间的关系。定义域和值域不必是不同的。例如,一个函数可以具有与其定义域和值域相同的整数集合。

什么使得两个集合之间的关系成为函数

要成为一个函数,一个关系必须满足一个条件:定义域中的所有元素必须在值域中有一个且仅有一个对应的元素,如图 2.1 所示。

图 2.1. 函数定义域中的所有元素必须在值域中有一个且仅有一个对应的元素。

这有一些有趣的含义:

  • 在域中不能存在没有在值域中对应值的元素。

  • 在值域中不能存在两个与域中的相同元素对应的元素。

  • 值域中可能有元素在源集中没有对应元素。

  • 值域中可能有元素在源集中对应多个元素。

  • 值域中具有域中对应元素的元素集合被称为函数的

图 2.1 展示了一个函数。

你可以定义函数,例如

f(x) = x + 1

其中 x 是一个正整数。这个函数代表了每个正整数与其后继之间的关系。你可以给这个函数起任何名字。特别是,你可以给它起一个能帮助你记住它是什么的名字,比如

successor(x) = x + 1

这可能看起来是个好主意,但你不应该盲目地相信函数名。你还可以将函数定义为以下内容:

predecessor(x) = x + 1

这里没有发生错误,因为没有强制的关系存在于函数名和函数定义之间。但,显然,使用这样的名字是不明智的。

注意,我们在这里讨论的是函数的定义(它的定义),而不是它所做的事情。函数什么也不做。successor 函数不会将其参数加 1。可以给一个整数加 1 来计算它的后继,但不是一个函数。函数

successor(x)

并不将1加到x上。它仅与x + 1等价,这意味着每次遇到表达式successor(x)时,都可以将其替换为(x + 1)

注意用来隔离表达式的括号。当表达式单独使用时,不需要括号,但在某些情况下可能需要。

逆函数

一个函数可能或可能没有逆函数。如果f(x)是从AB的函数(A是定义域,B是陪域),则逆函数记作f^(-1)(x),其定义域是B,陪域是A。如果你将函数的类型表示为A –> B,则逆函数(如果存在)的类型是B –> A

函数的逆如果满足任何函数相同的条件:对于每个源值只有一个目标值,则是一个函数。因此,关系successor(x)(你可以称之为predecessor(x),尽管你也可以称之为xyz)的逆,在N(包括 0 的正整数集)上不是一个函数,因为 0 在N中没有前驱。相反,如果将successor(x)考虑为整数集(正数和负数,记为Z),则successor的逆是一个函数。

一些其他简单的函数没有逆函数。例如,函数

f(x) = (2 * x)

如果定义为从NN,则没有逆函数。如果你将其定义为从N到偶数整数的函数,则它有逆函数。

部分函数

定义域中并非所有元素都有定义但满足其他要求(定义域中的任何元素都不能与陪域中的多个元素有关系)的关系通常被称为部分函数。关系前驱(x)N(正整数集加上 0)上是部分函数,但在N*(不带 0 的正整数集)上是全函数,其陪域是N

部分函数在编程中很重要,因为许多错误是由于将部分函数当作全函数使用而产生的。例如,关系f(x) = 1/x是从NQ(有理数)的部分函数,因为它在 0 处没有定义。它是从N*Q的全函数,但也是从N到(Q加上error)的全函数。通过向陪域(错误条件)添加一个元素,可以将部分函数转换为全函数。但要做到这一点,函数需要一种返回错误的方式。你能看到与计算机程序的类比吗?你会发现将部分函数转换为全函数是函数式编程的一个重要部分。

函数组合

函数是构建其他函数的基石,可以组合起来构建其他函数。函数fg的组合记作f ˚ g,读作f round g。如果f(x) = x + 2g(x) = x * 2,那么

f ˚ g (x) = f(g(x)) = f(x * 2) = (x * 2) + 2

注意,两种表示法 f ˚ g (x)f(g(x)) 是等价的。但将复合函数写作 f(g(x)) 意味着使用 x 作为参数的占位符。使用 f ˚ g 表示法,你可以不使用这个占位符来表达函数复合。

如果你将这个函数应用到 5,你会得到以下结果:

f ˚ g (5) = f(g(5)) = f(5 * 2) = 10 + 2 = 12

有趣的是要注意 f ˚ g 通常与 g ˚ f 不同,尽管它们有时可能是等价的。例如:

g ˚ f (5) = g(f(5)) = g(5 + 2) = 7 * 2 = 14

注意,函数是按照书写顺序的逆序应用的。如果你写 f ˚ g,你首先应用 g,然后是 f。标准的 Java 8 函数定义了 compose() 方法和 andThen() 方法来表示这两种情况(顺便说一下,这并不是必要的,因为 f.andThen(g)g.compose(f) 相同,或者 g ˚ f)。

多参数函数

到目前为止,我们只讨论了单参数函数。那么多参数函数呢?简单地说,没有多参数函数这回事。还记得定义吗?函数是源集合和目标集合之间的关系。它不是两个或更多源集合与目标集合之间的关系。函数不能有多个参数。

但两个集合的乘积本身也是一个集合,因此从这样一个集合乘积到集合的函数可能看起来是多个参数的函数。让我们考虑以下函数:

f(x, y) = x + y

这可能是在 N x NN 之间的一个关系,在这种情况下,它是一个函数。但它只有一个参数,即 N x N 的一个元素。

N x N 是所有可能的整数对的集合。这个集合的元素是一对整数,而一对是更一般的概念 元组 的一个特例,用于表示几个元素的组合。一对是两个元素的元组。

元组用括号表示,所以 (3, 5) 是一个元组,也是 N x N 的一个元素。函数 f 可以应用于这个元组:

f((3, 5)) = 3 + 5 = 8

在这种情况下,按照惯例,你可以通过移除一组括号来简化写作:

f(3, 5) = 3 + 5 = 8

尽管如此,它仍然是一个元组的函数,而不是两个参数的函数。

函数柯里化

元组的函数可以有不同的理解方式。函数 f(3, 5) 可能被视为从 NN 的函数集合的函数。因此,前面的例子可以重写为

f(x)(y) = g(y)

其中

g(y) = x + y

在这种情况下,你可以写成

f(x) = g

这意味着将函数 f 应用到参数 x 的结果是一个新的函数 g。将这个 g 函数应用到 y 上,得到以下结果:

g(y) = x + y

当应用 g 时,x 已不再是变量。它不依赖于参数或任何其他东西。它是一个常数。如果你将这个应用到 (3, 5),你会得到以下结果:

f(3)(5) = g(5) = 3 + 5 = 8

这里唯一的新东西是 f 的陪域是一个函数集合而不是一个数字集合。将 f 应用到整数的结果是一个函数。将这个函数应用到整数的结果是一个整数。

f(x)(y) 是函数 f(x, y)柯里化 形式。将这种转换应用于元组函数(如果你更喜欢,可以将其称为多个参数的函数)称为 柯里化,以数学家 Haskell Curry 的名字命名(尽管他并不是这种转换的发明者)。

部分应用函数

加法函数的柯里化形式可能看起来不太自然,你可能会想知道它是否对应于现实世界中的某个东西。毕竟,在柯里化版本中,你是分别考虑这两个参数的。其中一个参数首先被考虑,将函数应用到它上会给你一个新的函数。这个新函数本身有用,还是它只是全局计算的一个步骤?

在加法的情况下,这似乎没有用。顺便说一句,你可以从两个参数中的任何一个开始,这不会有什么区别。中间函数会不同,但最终结果不会变。

现在考虑一对值的新函数:

f(rate, price) = price / 100 * (100 + rate)

那个函数看起来似乎等同于这个:

g(price, rate) = price / 100 * (100 + rate)

让我们现在考虑这两个函数的柯里化版本:

f(rate)(price)
g(price)(rate)

你知道 fg 是函数。但 f(rate)g(price) 是什么意思呢?当然,它们是将 f 应用到 rateg 应用到 price 的结果。但这些结果的类型是什么呢?

f(rate) 是一个将价格映射到价格的函数。如果 rate = 9,这个函数将对价格应用 9%的税,得到一个新的价格。你可以把这个结果函数称为 apply9-percentTax(price),这可能会是一个有用的工具,因为税率不经常变化。

另一方面,g(price) 是一个将比率映射到价格的函数。如果价格是 100 美元,它将给出一个新的函数,将 100 美元的价格应用到可变税率上。你能给这个函数起个名字吗?如果你想不出一个有意义的名字,那通常意味着它是无用的,尽管这取决于你要解决的问题。

类似于 f(rate)g(price) 的函数有时被称为 部分应用函数,这指的是 f(rate, price)g(price, rate) 的形式。部分应用函数在参数评估方面可能产生巨大的影响。我们将在后面的章节中回到这个话题。

如果你理解柯里化的概念有困难,想象你正在外国旅行,使用手持计算器(或你的智能手机)将一种货币转换成另一种货币。你更喜欢每次计算价格时都要输入汇率,还是更愿意将汇率存储在内存中?哪种解决方案的错误概率会更低?

函数没有效果

记住,纯函数只返回一个值,不做其他任何事情。它们不会修改外部世界的任何元素(外部相对于函数本身而言),它们不会修改它们的参数,并且在发生错误时不会爆炸(或抛出异常,或任何其他事情)。它们可以返回异常或任何其他内容,例如错误消息。但它们必须返回它,而不是抛出它,记录它,或打印它。

2.2. Java 中的函数

在 第一章 中,你使用了我认为的 函数,但实际上是方法。方法是在传统 Java 中表示(在某种程度上)函数的一种方式。

2.2.1. 有功能的函数

一个方法可以是有功能的,如果它遵守纯函数的要求:

  • 它不得修改函数外部的内容。没有任何内部修改可以从外部看到。

  • 它不得修改其参数。

  • 它不得抛出错误或异常。

  • 它必须始终返回一个值。

  • 当使用相同的参数调用时,它必须始终返回相同的结果。

让我们看看一个例子。

列表 2.1. 有功能的函数
public class FunctionalMethods {

  public int percent1 = 5;
  private int percent2 = 9;
  public final int percent3 = 13;

  public int add(int a, int b) {
    return a + b;
  }

public setPercent2(int value) {
  percent2 = value;
}

  public int mult(int a, Integer b) {
    a = 5;
    b = 2;
    return a * b;
  }

  public int div(int a, int b) {
    return a / b;
  }

  public int applyTax1(int a) {
    return a / 100 * (100 + percent1);
  }

  public int applyTax2(int a) {
    return a / 100 * (100 + percent2);
  }

  public int applyTax3(int a) {
    return a / 100 * (100 + percent3);
  }

  public List<Integer> append(int i, List<Integer> list) {
    list.add(i);
    return list;
  }
}

你能说出哪些方法代表了纯函数吗?在阅读答案之前,先思考几分钟。考虑所有条件以及方法内部进行的所有处理。记住,重要的是从外部可以看到的内容。不要忘记考虑异常情况。

考虑第一个方法:

public int add(int a, int b) {
  return a + b;
}

add 是一个函数,因为它总是返回一个只依赖于其参数的值。它不修改其参数,并且不与外部世界以任何方式交互。如果 a + b 的和超过最大 int 值,此方法可能会引发错误。但这不会抛出异常。结果将是错误的(一个负值),但这又是另一个问题。每次函数使用相同的参数调用时,结果必须相同。这并不意味着结果必须是精确的!

精确性

术语 精确 本身没有意义。它通常意味着它符合预期,因此要判断函数实现的输出是否精确,你必须知道实现者的意图。通常你只有函数名来确定意图,这可能是误解的来源。

考虑第二个方法:

public int mult(int a, Integer b) {
  a = 5;
  b = 2;
  return a * b;
}

mult 方法与 add 方法一样,是一个纯函数。这可能让你感到惊讶,因为它似乎在修改其参数。但 Java 方法中的参数是通过值传递的,这意味着重新分配给它们的值在方法外部是不可见的。此方法始终返回 10,这并不实用,因为它不依赖于参数,但这并不违反要求。当方法多次使用相同的参数调用时,它将返回相同的值。

顺便说一下,此方法与无参数的方法等价。这是函数的一个特殊情况:f(x) = 10。它是一个常数。

现在考虑 div

public int div(int a, int b) {
  return a / b;
}

div方法不是一个纯函数,因为它会在除数为0时抛出异常。为了使其成为一个函数,你可以测试第二个参数,如果它是null则返回一个值。它必须是一个int,因此很难找到一个有意义的值,但这又是另一个问题。

考虑第四个方法:

public int percent1 = 5;

public int applyTax1(int a) {
  return a / 100 * (100 + percent1);
}

applyTax1方法似乎不是一个纯函数,因为它的结果依赖于percent1的值,而percent1是公开的,可以在两次函数调用之间被修改。因此,具有相同参数的两个函数调用可能会返回不同的值。percent1可能被视为一个隐含参数,但这个参数不是与方法参数同时评估的。如果你只在方法内部使用一次percent1的值,这不是问题,但如果读取两次,它可能在两次读取操作之间发生变化。如果你需要使用该值两次,你必须先读取一次并将其保存在局部变量中。这意味着applyTax1方法是一个纯函数,其参数为(a, percent1)元组,但它不是a的纯函数。

将其与applyTax2方法进行比较:

private int percent2 = 9;

public int applyTax2(int a) {
  return a / 100 * (100 + percent2);
}

applyTax2方法没有不同。你可能会将其视为一个函数,因为percent2属性是私有的。但它是可以变的,并且被setPercent2方法修改。因为percent2只被访问一次,所以applyTax2可以被视为一个纯函数,其参数为(a, percent2)元组。但如果将其视为a的函数,它就不是一个纯函数。

现在考虑第六个方法:

public final int percent3 = 13;

public int applyTax3(int a) {
  return a / 100 * (100 + percent3);
}

applyTax3方法有些特殊。给定相同的参数,该方法总是会返回相同的值,因为它只依赖于其参数和percent3最终属性,该属性不能被修改。你可能会认为applyTax3不是一个纯函数,因为结果不只依赖于方法参数(纯函数的结果必须只依赖于其参数)。但如果将percent3视为一个补充参数,这里就没有矛盾。实际上,类本身也可以被视为一个补充的隐含参数,因为所有属性都可以在方法内部访问。

这是一个重要的概念。所有实例方法都可以通过添加一个封装类类型的参数来替换为静态方法。因此,applyTax3方法可以被重写为

public static int applyTax3(FunctionalMethods x, int a) {
  return a / 100 * 100 + x.percent3;
}

此方法可以从类内部调用,传递对this的引用作为参数,例如applyTax3(this, a)。它也可以从外部调用,因为它是公开的,只要有一个FunctionalMethods实例的引用。在这里,applyTax3(this, a)元组的纯函数。

最后,我们的最后一个方法:

public List<Integer> append(int i, List<Integer> list) {
 list.add(i);
 return list;
}

append方法在返回之前会修改其参数,并且这种修改可以从方法外部看到,因此它不是一个纯函数。

对象表示法与函数表示法

你已经看到,访问类属性的实例方法可能被认为是具有封装类实例的隐式参数。不访问封装类实例的方法可以安全地定义为静态。如果它们的隐式参数(封装实例)被显式化,访问封装实例的方法也可以定义为静态。

考虑来自第一章 的 Payment 类:

public class Payment {

  public final CreditCard cc;
  public final int amount;

  public Payment(CreditCard cc, int amount) {
    this.cc = cc;
    this.amount = amount;
  }

  public Payment combine(Payment other) {
    if (cc.equals(other.cc)) {
      return new Payment(cc, amount + other.amount);
    } else {
      throw new IllegalStateException(
                          "Can't combine payments to different cards");
    }
  }
}

combine 方法访问封装类的 ccamount 字段。因此,它不能被定义为静态的。这个方法将封装类作为隐式参数。

你可以将这个参数显式化,这样就可以将方法定义为静态的:

public class Payment {

  public final CreditCard cc;
  public final int amount;

  public Payment(CreditCard cc, int amount) {
    this.cc = cc;
    this.amount = amount;
  }

  public static Payment combine(Payment payment1, Payment payment2) {
    if (payment1.cc.equals(payment2.cc)) {
      return new Payment(payment1.cc, payment1.amount + payment2.amount);
    } else {
      throw new IllegalStateException(
                               "Can't combine payments to different cards");
    }
  }
}

静态方法使你能够确保不存在对封装作用域的不希望有的访问。但它改变了方法的使用方式。

如果在类内部使用,静态方法可以被调用,并传递 this 引用:

Payment newPayment = combine(this, otherPayment);

如果从类外部调用该方法,必须使用类名:

Payment newPayment = Payment.combine(payment1, payment2);

这几乎没有什么区别,但当需要组合方法调用时,一切都会改变。如果你需要组合几个支付,可以这样编写的实例方法

public Payment combine(Payment payment) {
    if (this.cc.equals(payment.cc)) {
      return new Payment(this.cc, this.amount + payment.amount);
    } else {
      throw new IllegalStateException(
                               "Can't combine payments to different cards");
    }
  }

可以使用对象表示法:

Payment newPayment = p0.combine(p1).combine(p2).combine(p3);

这比下面更容易阅读:

Payment newPayment = combine(combine(combine(p0, p1), p2), p3);

在第一种情况下,再添加一个费用也更简单。

2.2.2. Java 函数式接口和匿名类

方法可以被定义为函数式的,但它们缺少一些使它们能够代表函数式编程中的函数的特性:除了应用于参数之外,它们不能被操作。你不能将一个方法作为参数传递给另一个方法。结果是,你不能在不应用它们的情况下组合方法。你可以组合方法应用,但不能组合方法本身。Java 方法属于定义它的类,并且它将停留在那里。

你可以通过在其他方法中调用它们来组合方法,但必须在编写程序时这样做。如果你想要根据特定条件有不同的组合,你必须在编写时安排这些组合。你不能编写一个在执行过程中自身会改变的程序。或者可以吗?

是的,你可以!有时你会在运行时注册处理程序来处理特定情况。你可以向处理程序集合中添加处理程序,或删除它们,或改变它们将被使用的顺序。你如何做到这一点?通过使用包含你想要操作的方法的类。

在 GUI 中,你经常使用监听器来处理特定事件,例如移动鼠标、调整窗口大小或输入文本。这些监听器通常作为实现特定接口的匿名类创建。你可以使用相同的原则来创建函数。

假设你想要创建一个将整数值乘以三的方法。首先,你必须定义一个只有一个方法的接口:

public interface Function {
  int apply(int arg);
}

然后实现此方法以创建你的函数:

Function triple = new Function() {

    @Override
    public int apply(int arg) {
        return arg * 3;
    }
};

然后可以将此函数应用于一个参数:

System.out.println(triple.apply(2));

6

我必须承认,这并不引人注目。一个老式的方法可能会更容易使用。如果你想创建另一个函数,你可以用完全相同的方式处理它:

Function square = new Function() {

    @Override
    public int apply(int arg) {
        return arg * arg;
    }
};

到目前为止,一切顺利,但这样做有什么好处呢?

2.2.3. 组合函数

如果你把函数看作是方法,那么组合它们看起来很简单:

System.out.println(square.apply(triple.apply(2)));

36

但这并不是函数组合。在这个例子中,你是在组合函数应用。函数组合是函数上的二元操作,就像加法是数字上的二元操作一样。所以你可以通过一个方法来程序化地组合函数:

Function compose(final Function f1, final Function f2) {
  return new Function() {
    @Override
    public int apply(int arg) {
      return f1.apply(f2.apply(arg));
    }
  };
}

System.out.println(compose(triple, square).apply(3));

27

现在,你可以开始看到这个概念有多强大了!但还有两个大问题。第一个是,我们的函数只能接受整数 (int) 参数并返回整数。让我们先解决这个问题。

2.2.4. 多态函数

为了使我们的函数更具可重用性,你可以通过使用参数化类型将其改为多态函数,这些类型在 Java 中通过泛型实现:

public interface Function<T, U> {
  U apply(T arg);
}

给定这个新接口,你可以将我们的函数重写如下:

Function<Integer, Integer> triple = new Function<Integer, Integer>() {
  @Override
  public Integer apply(Integer arg) {
    return arg * 3;
  }
};
Function<Integer, Integer> square = new Function<Integer, Integer>() {
  @Override
  public Integer apply(Integer arg) {
    return arg * arg;
  }
};

正如你所见,我们已从 int 切换到 Integer,因为 int 在 Java 中不能用作类型参数。希望自动装箱和自动拆箱会使转换变得透明。

练习 2.1

使用这两个新函数编写 compose 方法。

注意

每个练习的解答都紧随其后,但你应该先尝试自己解决问题,不要看解答。解答代码也出现在本书的网站上。这个练习很简单,但有些可能会相当困难,所以你可能很难抵制作弊的诱惑。记住,你搜索得越努力,你学到的就越多。

解答 2.1

static Function<Integer, Integer> compose(Function<Integer, Integer> f1,
                                              Function<Integer, Integer> f2) {
  return new Function<Integer, Integer>() {

    @Override
    public Integer apply(Integer arg) {
      return f1.apply(f2.apply(arg));
    }
  };
}

函数组合的问题

函数组合是一个强大的概念,但在 Java 中实现时,它带来了很大的风险。组合几个函数是无害的。但想想看,构建一个包含 10,000 个函数的列表并将它们组合成一个单一的函数。(这可以通过折叠操作完成,你将在第三章中了解到这个操作。chapter 3)

在命令式编程中,每个函数在将结果传递给下一个函数作为输入之前都会被评估。但在函数式编程中,组合函数意味着在没有任何评估的情况下构建结果函数。组合函数之所以强大,是因为函数可以在不进行评估的情况下组合。但作为后果,应用组合函数会导致嵌套的方法调用数量增加,最终可能会溢出栈。这可以通过一个简单的例子(使用将在下一节中介绍的 lambda 表达式)来演示:

int fnum = 10_000; Function<Integer, Integer> g = x -> x;
Function<Integer, Integer> f = x -> x + 1;
for (int i = 0; i < fnum; i++) {
g = Function.compose(f, g); 
};

System.out.println(g.apply(0));

fnum 大约是 7,500 时,这个程序将会溢出栈。希望你通常不会组合几千个函数,但你应该意识到这一点。

2.2.5. 使用 lambda 表达式简化代码

你遇到的第二个问题是使用匿名类定义的函数在编码中使用起来很麻烦。如果你使用的是 Java 5 到 7,那么你就没有其他选择了。幸运的是,Java 8 引入了 Lambda 表达式。

Lambda 表达式不会改变Function接口的定义方式,但它们使得实现它变得更加简单:

Function<Integer, Integer> triple = x -> x * 3;
Function<Integer, Integer> square = x -> x * x;

Lambda 表达式不仅仅是语法简化。Lambda 表达式在代码编译方面有一些影响。Lambda 表达式与传统匿名类编写方式的主要区别之一是等号右侧的类型可以被省略。这是因为 Java 8 带来了关于类型推断的新功能。

在 Java 7 之前,类型推断只有在链式标识符解引用时才可能,例如这样:

System.out.println();

在这里,你不需要指定out的类型,Java 能够找到它。如果你不使用链式写法,你将不得不指定类型:

PrintStream out = System.out;
out.println();

Java 7 通过菱形语法增加了一点点类型推断:

List<String> list = new ArrayList<>();

在这里,你不需要重复ArrayListString类型参数,因为 Java 能够通过查看声明来推断它。同样的事情也可以用 Lambda 表达式实现:

Function<Integer, Integer> triple = x -> x * 3;

在这个例子中,x的类型是由 Java 推断的。但这并不总是可能的。当 Java 抱怨它无法推断类型时,你必须显式地写出它。然后你必须使用括号:

Function<Integer, Integer> triple = (Integer x) -> x * 3;
指定函数类型

虽然 Java 8 引入了 Lambda 表达式来简化函数实现,但它缺少简化编写函数类型的工具。从IntegerInteger的函数类型是

Function<Integer, Integer>

函数实现是这样写的:

x -> expression

如果能够将相同的简化应用到类型上,这将允许你这样写整个表达式:

Integer -> Integer square = x -> x * x;

不幸的是,在 Java 8 中这是不可能的,而且这也不是你可以自己添加的。

练习 2.2

使用 Lambda 表达式编写compose方法的新版本。

解决方案 2.2

用 Lambda 表达式替换匿名类很简单。以下是compose方法的第一版代码:

static Function<Integer, Integer> compose(Function<Integer, Integer> f1,
                                              Function<Integer, Integer> f2) {
  return new Function<Integer, Integer>() {
    @Override
    public Integer apply(Integer arg) {
      return f1.apply(f2.apply(arg));
    }
  };
}

你只需要将compose方法的返回值替换为匿名类的apply方法的参数,然后是一个箭头(->)和apply方法的返回值:

static Function<Integer, Integer> compose(Function<Integer, Integer> f1,
                                              Function<Integer, Integer> f2) {
  return arg -> f1.apply(f2.apply(arg));
}

你可以为参数使用任何名称。图 2.2 显示了此过程。

图 2.2. 用 Lambda 表达式替换匿名类

图片描述

2.3. 高级函数特性

你已经看到了如何创建applycompose函数。你也已经了解到函数可以由方法或对象表示。但你还没有回答一个基本问题:为什么你需要函数对象?你难道不能简单地使用方法吗?在回答这个问题之前,你必须考虑多参数方法的函数表示问题。

2.3.1. 几个参数的函数怎么办?

在第 2.1.1 节中,我说过没有多个参数的函数。只有一组参数的函数。元组的基数可以是任何你需要的大小,并且有一些特定的名称用于具有少量参数的元组:pair(对)、triplet(三元组)、quartet(四元组)等等。其他可能的名称也存在,有些人喜欢称它们为 tuple2、tuple3、tuple4 等等。但我还说过,参数可以逐个应用,每个参数的应用都会返回一个新的函数,除了最后一个参数。

让我们尝试定义一个用于加两个整数的函数。你将对第一个参数应用一个函数,这将返回一个新的函数。其类型如下:

Function<Integer, Function<Integer, Integer>>

这可能看起来有点复杂,尤其是如果你认为它本可以写成这样:

Integer -> Integer -> Integer

注意,由于结合律,这等价于

Integer -> (Integer -> Integer)

其中左边的Integer是参数的类型,括号内的元素是返回类型,显然是函数类型。如果你从Function<Integer, Function<Integer, Integer>>中移除单词Function,你得到的是这个:

<Integer, <Integer, Integer>>

这完全一样。Java 编写函数类型的方式更加冗长,但并不更复杂。

练习 2.3

编写一个用于加两个整数的函数。

解决方案 2.3

这个函数将接受一个Integer作为其参数,并返回一个从IntegerInteger的函数,因此其类型将是Function<Integer, Function<Integer, Integer>>。让我们给它命名为add。它将通过 lambda 表达式实现。最终结果如下所示:

Function<Integer, Function<Integer, Integer>> add = x -> y -> x + y;

你可以看到,你很快就会遇到行长的限制!Java 没有类型别名,但你可以通过继承来实现相同的结果。如果你有很多具有相同类型的函数要定义,你可以通过一个更短的标识符来扩展它,如下所示:

public interface BinaryOperator extends
                     Function<Integer, Function<Integer, Integer>> {}
BinaryOperator add = x -> y -> x + y;
BinaryOperator mult = x -> y -> x * y;

参数的数量不受限制。你可以定义具有所需数量参数的函数。正如我在本章的第一部分所说,像add函数或你刚刚定义的mult函数这样的函数被称为元组等价函数的柯里化形式。

2.3.2. 应用柯里化函数

你已经看到了如何编写柯里化函数类型以及如何实现它们。但你是如何应用它们的呢?嗯,就像任何函数一样。你将对第一个参数应用函数,然后将结果应用于下一个参数,依此类推,直到最后一个参数。例如,你可以将add函数应用于35

System.out.println(add.apply(3).apply(5));

8

这里,你又遗漏了一些语法糖。如果能直接通过写出函数名后跟其参数来应用一个函数那就太好了。这将允许像 Scala 那样的编码:

add(3)(5)

或者甚至更好,像 Haskell 那样:

add 3 5

或许在 Java 的将来版本中?

2.3.3. 高阶函数

在 2.14 节中,你编写了一个组合函数的方法。这种方法是一个函数式方法,它接受两个函数的元组作为参数,并返回一个函数。但你可以使用一个函数而不是方法!这种特殊的函数,接受函数作为参数并返回函数,被称为 高阶函数(HOF)。

练习 2.4

编写一个函数来组合练习 2.2 中使用的两个函数 squaretriple

解答 2.4

如果你遵循正确的程序,这个练习就很容易。首先要做的事情是编写类型。这个函数将作用于两个参数,所以它将是一个柯里化函数。两个参数和返回类型将是 IntegerInteger 的函数:

Function<Integer, Integer>

你可以称这个为 T。你想要创建一个接受类型 T(第一个参数)的函数,并返回一个从 T(第二个参数)到 T(返回值)的函数。函数的类型如下所示:

Function<T, Function<T, T>>

如果你将 T 替换为其值,你将获得实际类型:

Function<Function<Integer, Integer>,
         Function<Function<Integer, Integer>,
                  Function<Integer, Integer>>>

这里的主要问题是行长度!现在让我们添加实现,这比类型要简单得多:

x -> y -> z -> x.apply(y.apply(z));

完整的代码如下所示:

Function<Function<Integer, Integer>,
         Function<Function<Integer, Integer>,
                  Function<Integer, Integer>>> compose =
                                      x -> y -> z -> x.apply(y.apply(z));

你可以将此代码写在一行上!让我们用 squaretriple 函数测试此代码:

Function<Integer, Integer> triple = x -> x * 3;
Function<Integer, Integer> square = x -> x * x;

Function<Integer, Integer> f = compose.apply(square).apply(triple);

在此代码中,你首先应用第一个参数,这将给你一个应用于第二个参数的新函数。结果是函数,它是两个函数参数的组合。将这个新函数应用于(例如)2,这将给出首先将 triple 应用于 2,然后将 square 应用于结果(这对应于函数组合的定义):

System.out.println(f.apply(2));

36

注意参数的顺序:triple 首先应用,然后 square 应用于 triple 返回的结果。

2.3.4. 多态高阶函数

我们的 compose 函数很好,但它只能组合 IntegerInteger 的函数。如果你能组合任何类型的函数,比如 StringDoubleBooleanLong,那就更有趣了。但这只是开始。一个完全多态的 compose 函数将允许你组合 Function<Integer, Function<Integer, Integer>>,比如你在练习 2.3 中编写的 addmult。它还应该允许你组合不同类型的函数,只要一个函数的返回类型与另一个函数的参数类型相同。

练习 2.5(难度较高)

编写 compose 函数的多态版本。

提示

在尝试解决这个练习时,你可能会遇到两个问题。第一个问题是 Java 中缺乏多态属性。在 Java 中,你可以创建多态类、接口和方法,但你不能定义多态属性。解决方案是将函数存储在方法、类或接口中,而不是在属性中。

第二个问题是 Java 不处理可变性,所以你可能会发现自己试图将 Function<Integer, Integer> 强制转换为 Function<Object, Object>,这将导致编译器错误。在这种情况下,你必须通过明确指定类型来帮助 Java。

解决方案 2.5

第一步似乎是“泛化”练习 2.4 的例子:

<T, U, V> Function<Function<T, U>,
                   Function<Function<V, T>,
                            Function<V, U>>> higherCompose =
                                      f -> g -> x -> f.apply(g.apply(x));

但这是不可能的,因为 Java 不允许独立的泛型属性。要成为泛型,属性必须在定义类型参数的作用域内创建。只有类、接口和方法可以定义类型参数,所以你必须在这些元素之一内部定义你的属性。最实用的方法是静态方法:

可变性

可变性描述了参数化类型相对于子类型的行为。协变意味着如果 RedColor 的子类型,则 Matcher<Red> 被认为是 Matcher<Color> 的子类型。在这种情况下,Matcher<T> 被说成在 T 上是协变的。相反,如果 Matcher<Color> 被认为是 Matcher<Red> 的子类型,那么 Matcher<T> 被说成在 T 上是逆变。在 Java 中,尽管 IntegerObject 的子类型,但 List<Integer> 不是一个 List<Object> 的子类型。你可能觉得这很奇怪,但 List<Integer> 是一个 Object,但它不是一个 List<Object>。同样,Function<Integer, Integer> 不是一个 Function<Object, Object>。(这并不那么令人惊讶!)

在 Java 中,所有参数化类型都被说成在其参数上是不可变的。

static <T, U, V> Function<Function<U, V>,
                          Function<Function<T, U>,
                                   Function<T, V>>> higherCompose() {
  return f -> g -> x -> f.apply(g.apply(x));
}

注意,名为 higherCompose() 的方法不接受任何参数,并且总是返回相同的值。它是一个常量。从这一角度看,它被定义为方法并不重要。它不是一个用于组合函数的方法。它只是一个返回用于组合函数的函数的方法。

注意类型参数的顺序以及它们如何对应于实现 lambda 参数,如图 2.3 所示。

图 2.3. 注意类型参数的顺序。

你可以为 lambda 参数提供更有意义的名称,例如 uvFunctiontuFunction,或者更简单一些,uvtu,但你应该避免这样做。名称并不可靠。它们显示了(程序员的)意图,但没有其他。你可能会轻易地更改名称而不会注意到任何变化:

static <T, U, V> Function<Function<U, V>,
                          Function<Function<T, U>,
                                   Function<T, V>>> higherCompose() {
  return tuFunc -> uvFunc -> t -> tuFunc.apply(uvFunc.apply(t));
}

在这个例子中,tuFunc 是从 UV 的函数,而 uvFunc 是从 TU 的函数。

如果你需要更多关于类型的信息,你可以在每个 lambda 参数前简单地写出它们,将类型和参数括在括号内:

static <T, U, V> Function<Function<U, V>,
                          Function<Function<T, U>,
                                   Function<T, V>>> higherCompose() {
  return (Function<U, V> f) -> (Function<T, U > g) -> (T x)
                                                   -> f.apply(g.apply(x));
}

现在,你可能想以下这种方式使用这个函数:

Integer x = Function.higherCompose().apply(square).apply(triple).apply(2);

但这不会编译,产生以下错误:

Error:(39, 48) java: incompatible types: ...Function<java.lang.
  Integer,java.lang.Integer> cannot be converted to ....Function<java.lang.
  Object,java.lang.Object>

编译器表示它无法推断 TUV 类型参数的实际类型,因此它为所有三个使用了 Object。但是,squaretriple 函数的类型是 Function<Integer, Integer>。如果你认为这足以推断 TUV 类型,那么你比 Java 更聪明!Java 试图反过来,将 Function<Integer, Integer> 强制转换为 Function<Object, Object>。尽管 IntegerObject 的子类,但 Function<Integer, Integer> 并不是 Function<Object, Object> 的子类。这两种类型不相关,因为在 Java 中类型是不变的。为了使转换工作,类型应该是协变的,但 Java 不知道关于协变的信息。

解决方案是回到原始问题,并帮助编译器通过告诉它 TUV 的实际类型。这可以通过在点和方法名之间插入类型信息来完成:

Integer x = Function.<Integer, Integer, Integer>higherCompose().apply(....

这有点不切实际,但这不是主要问题。更常见的是,你会在库类中将像 higherCompose 这样的函数分组,你可能希望使用静态导入来简化代码:

import static com.fpinjava. ... .Function.*;
...
Integer x = <Integer, Integer, Integer>higherCompose().apply(...;

不幸的是,这不会编译!

练习 2.6(现在容易多了!)

编写 higherAndThen 函数,以相反的方式组合函数,这意味着 higherCompose(f, g) 等价于 higherAndThen(g, f)

解决方案 2.6

public static <T, U, V> Function<Function<T, U>, Function<Function<U, V>,
                                          Function<T, V>>> higherAndThen() {
  return f -> g -> x -> g.apply(f.apply(x));
}

测试函数参数

如果你对参数的顺序有疑问,你应该使用不同类型的函数来测试这些高阶函数。使用从 IntegerInteger 的函数进行测试将是模糊的,因为你将能够以两种顺序组合函数,因此错误将难以检测。以下是一个使用不同类型函数的测试示例:

public void TestHigherCompose() {

  Function<Double, Integer> f = a -> (int) (a * 3);
  Function<Long, Double> g = a -> a + 2.0;

  assertEquals(Integer.valueOf(9), f.apply((g.apply(1L))));
  assertEquals(Integer.valueOf(9),
    Function.<Long, Double, Integer>higherCompose().apply(f).apply(g).ap
             ply(1L));
}

注意,Java 无法推断类型,因此在调用 higherCompose 函数时你必须提供它们。

2.3.5. 使用匿名函数

之前,你一直在使用命名函数。这些函数被实现为匿名类,但你创建的实例被命名并且具有显式类型。通常你不会为函数定义名称,而是将它们用作匿名实例。让我们来看一个例子。

而不是写

Function<Double, Double> f = x -> Math.PI / 2 - x;
Function<Double, Double> sin = Math::sin;
Double cos = Function.compose(f, sin).apply(2.0);

你可以使用匿名函数:

Double cos = Function.compose(x -> Math.PI / 2 - x, Math::sin).apply(2.0);

这里,你使用了在 Function 类中静态定义的 compose 方法。但这同样适用于高阶函数:

Double cos = Function.<Double, Double, Double>higherCompose()
                  .apply(z -> Math.PI / 2 - z).apply(Math::sin).apply(2.0);

方法引用

除了 lambda 表达式之外,Java 8 还引入了方法引用,这是一种可以用来替换 lambda 表达式的语法,当 lambda 实现仅包含一个参数的方法调用时。例如,

Function<Double, Double> sin = Math::sin;

等价于以下内容:

Function<Double, Double> sin = x -> Math.sin(x);

在这里,sinMath 类中的静态方法。如果它是当前类中的实例方法,你可以这样写:

Function<Double, Double> sin = this.sin(x);

这类代码将经常在这本书中用来将方法转换为函数。

何时使用匿名函数和命名函数

除了匿名函数无法使用的一些特殊情况外,选择匿名函数和命名函数取决于你。一般来说,只使用一次的函数被定义为匿名实例。但“只使用一次”意味着你只写一次函数。这并不意味着它只实例化一次。

在下面的示例中,你定义了一个计算Double值余弦的方法。该方法实现使用了两个匿名函数,因为你使用了 lambda 表达式和方法引用:

Double cos(Double arg) {
  return Function.compose(z -> Math.PI / 2 - z, Math::sin).apply(arg);
}

不要担心匿名实例的创建。Java 不总是在函数被调用时创建新对象。而且,实例化这样的对象成本很低。相反,你应该只考虑代码的清晰性和可维护性来决定是否使用匿名或命名函数。如果你关心性能和可重用性,你应该尽可能频繁地使用方法引用。

类型推断

类型推断也可能与匿名函数有关。在先前的示例中,编译器可以推断出两个匿名函数的类型,因为它知道compose方法接受两个函数作为参数:

static <T, U, V> Function<V, U> compose(Function<T, U> f, Function<V, T> g)

但这并不总是有效。如果你用 lambda 代替方法引用来替换第二个参数,

Double cos(Double arg) {
  return Function.compose(z -> Math.PI / 2 - z,
                          a -> Math.sin(a)).apply(arg);
}

编译器迷失方向,显示以下错误信息:

Error:(64, 63) java: incompatible types: java.lang.Object cannot be converted to double
Error:(64, 44) java: bad operand types for binary operator '-'
  first type: double
  second type: java.lang.Object
Error:(64, 72) java: incompatible types: java.lang.Object cannot be converted to java.lang.Double

编译器如此困惑,甚至在第 44 列找到了一个不存在的错误!但第 63 列的错误是真实的。尽管这看起来很奇怪,Java 无法猜测第二个参数的类型。为了使这段代码编译,你必须添加类型注解:

Double cos(Double arg) {
  return Function.compose(z -> Math.PI / 2 - z,
                 (Function<Double, Double>) (a) -> Math.sin(a)).apply(arg);
}

这是有理由更喜欢方法引用的。

2.3.6. 局部函数

你刚刚看到你可以在方法中局部定义函数,但你不能在方法内定义方法。

另一方面,函数可以通过 lambda 在函数内部定义,没有任何问题。你将遇到的最常见情况是嵌套 lambda,如下所示:

public <T> Result<T> ifElse(List<Boolean> conditions, List<T> ifTrue) {
  return conditions.zip(ifTrue)
      .flatMap(x -> x.first(y -> y._1))
      .map(x -> x._2);
}

如果你不理解这段代码的功能,不要担心。你将在后面的章节中学习这类代码。然而,请注意,flatMap方法接受一个函数作为其参数(以 lambda 的形式),而这个函数的实现(->之后的代码)定义了一个新的 lambda,这对应于一个局部嵌入的函数。

局部函数不总是匿名的。当它们用作辅助函数时,通常会有名字。在传统的 Java 中,使用辅助方法是常见的做法。这些方法允许你通过抽象代码的部分来简化代码。同样的技术也用于函数,尽管你可能没有注意到,因为在使用匿名 lambda 时,它是隐式的。但显式声明局部函数总是可能的,如下面的示例所示,它与前面的示例几乎等价:

public <T> Result<T> ifElse_(List<Boolean> conditions, List<T> ifTrue) {
  Function<Tuple<Boolean, T>, Boolean> f1 = y -> y._1;
  Function<List<Tuple<Boolean, T>>, Result<Tuple<Boolean, T>>> f2 =
                                                       x -> x.first(f1);
  Function<Tuple<Boolean, T>, T> f3 = x -> x._2;
  return conditions.zip(ifTrue)
      .flatMap(f2)
      .map(f3);
}

如前所述,这两种形式(有或没有局部命名函数)之间有一点细微的差别,有时可能会变得很重要。当涉及到类型推断时,使用命名函数意味着必须显式写出类型,这在编译器无法正确推断类型时可能是必要的。

这不仅对编译器有用,而且对于在类型问题上遇到麻烦的程序员来说也是一个巨大的帮助。明确写出期望的类型可以帮助定位期望未得到满足的确切位置。

2.3.7. 闭包

你已经看到,纯函数必须不依赖于其参数以外的任何东西来评估它们的返回值。Java 方法通常会访问类成员,无论是读取还是甚至写入它们。方法甚至可以访问其他类的静态成员。我曾经说过,函数式方法是指尊重引用透明性的方法,这意味着除了返回一个值之外,它们没有可观察的效果。对于函数来说,也是如此。如果函数没有可观察的副作用,它们就是纯函数。

但是,对于返回值不仅依赖于它们的参数,还依赖于封装作用域中元素的函数(和方法)怎么办?你已经看到了这种情况,这些封装作用域中的元素可以被视为使用它们的函数或方法的隐含参数。

Lambda 表达式有一个额外的要求:Lambda 表达式只能访问标记为 final 的局部变量。这个要求对 Lambda 表达式来说并不新鲜。在 Java 8 之前,匿名类就已经有这个要求了,Lambda 表达式必须遵守相同的条件,尽管这个条件已经变得稍微宽松一些。从 Java 8 开始,从匿名类或 Lambda 表达式中访问的元素可以隐式地被视为 final;如果它们没有被修改,就不需要显式声明为 final。让我们来看一个例子:

public void aMethod() {

  double taxRate = 0.09;
  Function<Double, Double> addTax  = price -> price + price * taxRate;
  ...
}

在这个例子中,addTax 函数“封闭”了 taxRate 局部变量。只要 taxRate 变量没有被修改,并且不需要显式声明该变量为 final,这个程序就可以成功编译。

以下示例无法编译,因为 taxRate 变量不再隐式地被视为 final:

public void aMethod() {

  double taxRate = 0.09;
  Function<Double, Double> addTax  = price -> price + price * taxRate;
  ...
  taxRate = 0.13;
  ...
}

注意,这个要求仅适用于局部变量。以下代码可以无问题编译:

double taxRate = 0.09;

public void aMethod() {

  Function<Double, Double> addTax  = price -> price + price * taxRate;
  taxRate = 0.13;
  ...
}

重要的是要注意,在这种情况下,addTax 不是 price 的函数,因为它不会总是对相同的参数给出相同的结果。然而,它可以被视为元组 (price, taxRate) 的函数。

如果你将它们视为额外的隐含参数,闭包与纯函数是兼容的。然而,在重构代码时,以及当函数作为参数传递给其他函数时,它们可能会引起问题。这可能导致难以阅读和维护的程序。

使程序更加模块化的一种方法是通过使用参数元组的函数:

double taxRate = 0.09;

Function<Tuple<Double, Double>, Double> addTax 
  = tuple -> tuple._2 + tuple._2 * tuple._1;

System.out.println(addTax.apply(new Tuple<>(taxRate, 12.0)));

但使用元组比较麻烦,因为 Java 没有提供简单的语法来支持这一点,除了函数参数,那里可以使用括号表示法。您必须为元组函数定义一个特殊的接口,例如:

interface Function2<T, U, V> {
  V apply(T t, U u);
}

这个接口可以在 lambda 表达式中使用:

Function2<Double, Double, Double> addTax = (taxRate, price) -> price + price * taxRate;
double priceIncludingTax = addTax.apply(0.09, 12.0);

注意,lambda 表达式是 Java 允许您使用(x, y)表示元组的唯一地方。不幸的是,它不能用于其他任何情况,例如从函数返回一个元组。

您还可以使用 Java 8 中定义的BiFunction类,它模拟了两个参数元组的函数,或者甚至是BinaryOperator,它对应于相同类型两个参数的元组函数,或者甚至是DoubleBinaryOperator,它是一个两个double原始值的元组函数。所有这些可能性都是可行的,但如果你需要三个或更多的参数呢?你可以定义Function3Function4等等。但柯里化是一个更好的解决方案。这就是为什么学习如何使用柯里化是绝对必要的,正如您已经看到的,柯里化非常简单:

double tax = 0.09;

Function<Double, Function<Double, Double>> addTax 
  = taxRate -> price -> price + price * taxRate;

System.out.println(addTax.apply(tax).apply(12.00));

2.3.8. 部分函数应用和自动柯里化

之前示例中的闭包和柯里化版本给出了相同的结果,并且可能被视为等价。实际上,它们在“语义上”是不同的。正如我之前所说的,两个参数扮演着完全不同的角色。税率不经常改变,而价格在每次调用时都应该是不同的。这在闭包版本中表现得非常明显。函数覆盖了一个不改变的参数(因为它被声明为 final)。在柯里化版本中,两个参数在每次调用时都可能改变,尽管税率不会比闭包版本改变得更频繁。

需要变化的税率是很常见的,例如,当你有不同产品类别或不同配送目的地的多个税率时。在传统的 Java 中,可以通过将类转换为参数化的“税率计算器”来适应这种情况:

public class TaxComputer {

  private final double rate;

  public TaxComputer(double rate) {
    this.rate = rate;
  }

  public double compute(double price) {
    return price * rate + price;
  }
}

这个类允许您为多个税率创建多个TaxComputer实例,并且这些实例可以根据需要重复使用:

TaxComputer tc9 = new TaxComputer(0.09);
double price = tc9.compute(12);

通过部分应用函数也可以实现相同的效果:

Function<Double, Double> tc9 = addTax.apply(0.09);
double price = tc9.apply(12.0);

在这里,addTax函数是来自第 2.3.7 节的结尾。

你可以看到,柯里化和部分应用密切相关。柯里化包括用一个可以部分应用的新函数替换一个元组函数,你可以逐个参数地部分应用。这是柯里化函数和元组函数之间的主要区别。在元组函数中,所有参数在函数应用之前都被评估。在柯里化版本中,所有参数必须在函数完全应用之前都已知,但在部分应用之前,单个参数可以被评估。你没有义务完全柯里化函数。一个有三个参数的函数可以被柯里化为一个产生单个参数的元组函数。

在函数式编程中,柯里化和部分应用函数非常常见,因此抽象这些操作以便能够自动执行是有用的。在前面的章节中,你只使用了柯里化函数而没有使用元组函数。这带来了巨大的优势:部分应用这类函数绝对简单明了。

练习 2.7(非常简单)

编写一个函数式方法,将两个参数的柯里化函数部分应用到它的第一个参数上。

解决方案 2.7

你什么也不用做!这个方法的结构如下:

<A, B, C> Function<B, C> partialA(A a, Function<A, Function<B, C>> f)

你可以立即看出,部分应用第一个参数就像将第二个参数(一个函数)应用到第一个参数上一样简单:

<A, B, C> Function<B, C> partialA(A a, Function<A, Function<B, C>> f) {
  return f.apply(a);
}

(如果你想看看partialA如何使用的一个例子,请查看这个练习的单元测试,在附带的代码中。)

你可能会注意到原始函数的类型是Function<A, Function<B, C>>,这意味着ABC。如果你想将这个函数部分应用到第二个参数上怎么办?

练习 2.8

编写一个方法,将两个参数的柯里化函数部分应用到它的第二个参数上。

解决方案 2.8

使用我们之前的函数,这个问题的答案将是一个具有以下签名的函数:

<A, B, C> Function<A, C> partialB(B b, Function<A, Function<B, C>> f)

这个练习稍微难一些,但如果仔细考虑类型,仍然很简单。记住,你应该始终相信类型!它们不会在所有情况下立即给你解决方案,但它们会引导你找到解决方案。这个函数只有一个可能的实现,所以如果你找到一个可以编译的实现,你可以确信它是正确的!

你知道你必须从一个A返回一个函数到C。所以你可以通过写下这个来开始实现:

<A, B, C> Function<A, C> partialB(B b, Function<A, Function<B, C>> f) {
  return a ->

在这里,a是一个类型为A的变量。在右箭头之后,你必须写一个由函数f和变量ab组成的表达式,并且它必须评估为一个从AC的函数。函数f是一个从AB -> C的函数,所以你可以从应用到已有的A开始:

<A, B, C> Function<A, C> partialB(B b, Function<A, Function<B, C>> f) {
  return a -> f.apply(a)

这将给你一个从BC的函数。你需要一个C,你已经有一个B,所以答案再次简单明了:

<A, B, C> Function<A, C> partialB(B b, Function<A, Function<B, C>> f) {
  return a -> f.apply(a).apply(b);
}

就这样!实际上,你几乎什么都没做,只是遵循了类型。

正如我说的,最重要的是你有一个函数的 curry 版本。你可能会很快学会如何直接编写 curry 函数。当开始编写功能 Java 程序时,一个经常出现的任务是转换具有多个参数的方法为 curry 函数。这非常简单。

练习 2.9(非常简单)

将以下方法转换为 curry 函数:

<A, B, C, D> String func(A a, B b, C c, D d) {
  return String.format("%s, %s, %s, %s", a, b, c, d);
}

(我同意这种方法完全无用,但这只是一个练习。)

解决方案 2.9

再次强调,除了将逗号替换为右箭头外,你不需要做太多。然而,请记住,你必须在一个接受类型参数的作用域中定义此函数,而对于属性来说并不是这样。因此,你必须在类、接口或具有所有所需类型参数的方法中定义它。

你将使用一个方法来完成它。首先,写下方法类型参数:

<A,B,C,D>

然后,添加返回类型。一开始可能觉得困难,但实际上只是阅读起来困难。只需写下单词Function<,然后是第一个参数类型和一个逗号:

<A,B,C,D> Function<A,

然后对第二个参数类型做同样的事情:

<A,B,C,D> Function<A, Function<B,

然后继续,直到没有参数为止:

<A,B,C,D> Function<A, Function<B, Function<C, Function<D,

添加返回类型并关闭所有打开的括号:

<A,B,C,D> Function<A, Function<B, Function<C, Function<D, String>>>>

添加函数名称和大括号:

<A,B,C,D> Function<A, Function<B, Function<C, Function<D, String>>>> f() {
}

对于实现,列出所需的所有参数,用右箭头分隔(以箭头结束):

<A,B,C,D> Function<A, Function<B, Function<C, Function<D, E>>>> f() {
  return a -> b -> c -> d ->
}

最后,添加实现,与原始方法相同:

<A,B,C,D> Function<A, Function<B, Function<C, Function<D, String>>>> f() {
  return a -> b -> c -> d -> String.format("%s, %s, %s, %s", a, b, c, d);
}

同样的原则可以应用于将元组的函数 curry。

练习 2.10

编写一个将Tuple<A, B>类型的函数 curry 到C类型的方法。

解决方案 2.10

再次强调,你只需要遵循类型。你知道该方法将接受类型为Function<Tuple<A, B>, C>的参数,并将返回Function<A, Function<B, C>>,因此签名如下:

<A, B, C> Function<A, Function<B, C>> curry(Function<Tuple<A, B>, C> f)

现在,对于实现,你将需要返回一个具有两个参数的 curry 函数,所以你可以从以下开始:

<A, B, C> Function<A, Function<B, C>> curry(Function<Tuple<A, B>, C> f) {
  return a -> b ->
}

最终,你需要评估返回类型。为此,你可以使用函数f并将其应用于使用参数ab构建的新Tuple

<A, B, C> Function<A, Function<B, C>> curry(Function<Tuple<A, B>, C> f) {
  return a -> b -> f.apply(new Tuple<>(a, b));
}

再次强调,如果它编译成功,那么它就不会出错。这种确定性是函数式编程的众多好处之一!(这并不总是正确的,但你将在下一章中学习如何更频繁地实现这一点。)

2.3.9. 将部分应用函数的参数进行切换

如果你有一个接受两个参数的函数,你可能只想应用第一个参数以得到一个部分应用函数。假设你有一个以下函数:

Function<Double, Function<Double, Double>> addTax = x -> y -> y + y / 100 * x;

你可能首先想应用税,得到一个只有一个参数的新函数,然后你可以将其应用于任何价格:

Function<Double, Double> add9percentTax = addTax.apply(9.0);

然后,当你想对价格加税时,你可以这样做:

Double priceIncludingTax = add9percentTax.apply(price);

这很好,但如果初始函数如下呢?

Function<Double, Function<Double, Double>> addTax = x -> y -> x + x / 100 * y;

在这种情况下,价格是第一个参数。仅应用价格可能没有用,但如何只应用税呢?(假设你没有访问实现。)

练习 2.11

编写一个方法来交换柯里化函数的参数。

解决方案 2.11

以下方法返回一个参数顺序相反的柯里化函数。它可以推广到任意数量的参数和它们的任意排列:

public static <T, U, V> Function<U, Function<T, V>> reverseArgs(Function<T,
 Function<U, V>> f) {
  return u -> t -> f.apply(t).apply(u);
}

给定这个方法,你可以部分应用任意一个参数。例如,如果你有一个从利率和金额计算贷款月供的函数:

Function<Double, Function<Double, Double>> payment = amount -> rate -> ...

你可以非常容易地创建一个只有一个参数的函数,用于计算固定金额和变动利率的付款,或者创建一个计算固定利率和变动金额的付款的函数。

2.3.10. 递归函数

递归函数是大多数函数式编程语言中普遍存在的特性,尽管递归和函数式编程并不相关。一些函数式程序员甚至说递归是函数式编程的 goto 特性,因此应尽可能避免。尽管如此,作为函数式程序员,你必须掌握递归,即使你最终决定避免它。

如你所知,Java 在递归方面有限制。方法可以递归调用自身,但这意味着计算状态在每次递归调用时都会推入栈中,直到达到终止条件,此时所有先前的计算状态都会依次从栈中弹出并评估。栈的大小可以配置,但所有线程都将使用相同的大小。默认大小根据 Java 的实现而变化,32 位版本的默认大小为 320 KB,64 位实现的默认大小为 1,064 KB,这两个大小与存储对象的数据堆的大小相比都非常小。结果是递归步骤的数量受到限制。

确定 Java 可以处理多少递归步骤是困难的,因为这取决于推入栈中的数据大小,以及递归过程开始时的栈状态。一般来说,Java 可以处理大约 5,000 到 6,000 步。

通过人工提高这个限制是可能的,因为 Java 内部使用了记忆化。这种技术包括将函数或方法的结果存储在内存中,以加快未来的访问速度。Java 不需要重新评估结果,如果它已经被存储在内存中,可以直接从内存中检索。除了加快访问速度外,这还可以通过更快地找到终止状态来部分避免递归。我们将在第四章中回到这个话题,你将学习如何在 Java 中创建基于堆的递归。在本节的其余部分,你将假装 Java 的标准递归没有损坏。

递归方法定义简单。方法factorial(int n)可以定义为当其参数为 0 时返回1,否则返回n * factorial(n – 1)

public int factorial(int n) {
  return n == 0 ? 1 : n * factorial(n - 1);
}

回想一下,当n在 5,000 到 6,000 之间时,这会导致栈溢出,所以不要在生产环境中使用这种代码。

所以编写递归方法很容易。递归函数呢?

练习 2.12

编写一个递归阶乘函数。

提示

你不应该尝试编写匿名递归函数,因为为了函数能够调用自己,它必须有一个名字,并且必须在调用自己之前以那个名字定义它。因为它在调用自己时应该已经定义,这意味着它应该在尝试定义它之前就已经定义了!

解决方案 2.12

暂时放下这个鸡生蛋的问题。将单参数方法转换为函数很简单。类型是Function<Integer, Integer>,实现应该与方法相同:

Function<Integer, Integer> factorial = n -> n <= 1 ? n : n * factorial.apply(n – 1);

现在是棘手的部分。这段代码无法编译,因为编译器会抱怨非法自引用。这是什么意思?简单来说,当编译器读取这段代码时,它正在定义factorial函数。在这个过程中,它遇到了对factorial函数的调用,而这个函数尚未定义。

因此,定义一个局部递归函数是不可能的。但是你能把这个函数声明为成员变量或静态变量吗?这并不能解决自引用问题,因为它相当于定义了一个数值变量,例如这样的:

int x = x + 1;

这个问题可以通过首先声明变量,然后更改其值来解决,这可以在构造函数或任何方法中完成,但在初始化器中更方便,如下所示:

int x;
{
  x = x + 1;
}

这之所以有效,是因为成员变量是在初始化器执行之前定义的,所以变量首先会被初始化为默认值(对于int0,对于函数是null)。变量在一段时间内为null不应该是一个真正的问题,因为初始化器是在构造函数之前执行的,所以除非其他初始化器使用了这个变量,否则你是安全的。这个技巧可以用来定义你的函数:

public Function<Integer, Integer> factorial;
{
  factorial = n -> n <= 1 ? n : n * factorial.apply(n - 1);
}

这也可以用来定义静态函数:

public static Function<Integer, Integer> factorial;

static {
  factorial = n -> n <= 1 ? n : n * factorial.apply(n - 1);
}

这个技巧的唯一问题是字段可能没有被声明为final,这很烦人,因为函数式程序员喜欢不可变性。幸运的是,还有另一个技巧可用:

public final Function<Integer, Integer> factorial =
                         n -> n <= 1 ? n : n * this.factorial.apply(n - 1);

在变量名前添加this.,可以在将其声明为final的同时进行自引用。对于static实现,只需将this替换为包含类的名称即可:

public static final Function<Integer, Integer> factorial =
            n -> n <= 1 ? n : n * FunctionExamples.factorial.apply(n - 1);

2.3.11. 同一函数

你已经看到在函数式编程中,函数被当作数据来处理。它们可以作为其他函数的参数传递,可以被函数返回,也可以在操作中使用,就像整数或双精度浮点数一样。在未来的程序中,你将对函数应用操作,你需要这些操作的中性元素,或者称为单位元素。中性元素将作为加法的 0,乘法的 1,或者字符串连接的空字符串。

可以将恒等函数添加到我们的 Function 类的定义中,形式为一个名为 identity 的方法,返回恒等函数:

static <T> Function<T, T> identity() {
  return t -> t;
}

通过这个额外的方法,我们的 Function 接口现在已经完整,如下所示。

列表 2.2. 完整的 Function 接口
public interface Function<T, U> {

  U apply(T arg);

  default <V> Function<V, U> compose(Function<V, T> f) {
    return x -> apply(f.apply(x));
  }

  default <V> Function<T, V> andThen(Function<U, V> f) {
    return x -> f.apply(apply(x));
  }

  static <T> Function<T, T> identity() {
    return t -> t;
  }

  static <T, U, V> Function<V, U> compose(Function<T, U> f,
                                          Function<V, T> g) {
    return x -> f.apply(g.apply(x));
  }

  static <T, U, V> Function<T, V> andThen(Function<T, U> f,
                                          Function<U, V> g) {
    return x -> g.apply(f.apply(x));
  }

  static <T, U, V> Function<Function<T, U>,
                            Function<Function<U, V>,
                                     Function<T, V>>> compose() {
    return x -> y -> y.compose(x);
  }

  static <T, U, V> Function<Function<T, U>,
                            Function<Function<V, T>,
                                     Function<V, U>>> andThen() {
    return x -> y -> y.andThen(x);
  }

  static <T, U, V> Function<Function<T, U>,
                            Function<Function<U, V>,
                                     Function<T, V>>> higherAndThen() {
    return x -> y -> z -> y.apply(x.apply(z));
  }

  static <T, U, V> Function<Function<U, V>,
                            Function<Function<T, U>,
                                     Function<T, V>>> higherCompose() {
    return (Function<U, V> x) ->
                    (Function<T, U> y) -> (T z) -> x.apply(y.apply(z));
  }
}

2.4. Java 8 功能接口

Lambda 适用于需要特定接口的地方。这就是 Java 如何确定调用哪个方法。Java 不对命名施加任何约束,这在其他语言中可能是常见的情况。唯一的约束是所使用的接口不得模糊,这通常意味着它应该只有一个抽象方法。(实际上,这要复杂一些,因为有些方法不计入在内。)这样的接口被称为 SAM 类型,即单抽象方法类型,并被称为 功能接口

注意,lambda 并不仅仅用于函数。在标准的 Java 8 中,有许多功能接口可用,尽管它们并不都与函数相关。最重要的接口在此列出:

  • java.util.function.Function 几乎与本章中开发的 Function 相同。它为方法参数类型添加了一个通配符,使它们更有用。

  • java.util.function.Supplier 等同于一个无参数的函数。在功能编程中,它是一个常量,所以一开始可能看起来没有用,但它有两个特定的用途:首先,如果它不是引用透明的(不是一个纯函数),它可以用来提供变量数据,例如时间或随机数。(我们不会使用这样的非功能事物!)第二个用途,更有趣,是允许延迟评估。我们将在下一章经常回到这个主题。

  • java.util.function.Consumer 完全不是用于函数,而是用于效果。(在这里,它不是一个 副作用,因为 Consumer 的效果是唯一的结果,因为它不返回任何内容。)

  • java.lang.Runnable 也可以用于不需要任何参数的效果。通常最好为此创建一个特殊的接口,因为 Runnable 应用于线程,如果它在其他上下文中使用,大多数语法检查工具都会抱怨。

Java 定义了许多其他功能接口(java.util.function 包中有 43 个),它们对于功能编程来说大多是无用的。其中许多处理原始数据类型,其他处理两个参数的函数,还有针对操作(相同类型两个参数的函数)的特殊版本。

在这本书中,我并没有过多地讨论标准的 Java 8 函数。这是故意的。这不是一本关于 Java 8 的书。这是一本关于函数式编程的书,恰好使用了 Java 作为示例。你正在学习如何构建事物,而不是使用提供的组件。在你掌握这些概念之后,选择使用自己的函数还是标准的 Java 8 函数将取决于你。我们的Function与 Java 8 的Function类似。它为了简化书中展示的代码,没有为其参数使用通配符。另一方面,Java 8 的Function没有将composeandThen定义为高阶函数,而只是作为方法。除了这些差异之外,这些Function实现是可以互换的。

2.5. 使用 lambda 进行调试

使用 lambda 促进了代码编写的新风格。曾经需要写多条短行的代码,现在通常被一行代码所替代,如下所示:

public <T> T ifElse(List<Boolean> conditions, List<T> ifTrue, T ifFalse) {
  return conditions.zip(ifTrue).flatMap(x -> x.first(y -> y._1))
                               .map(x -> x._2).getOrElse(ifFalse);
}

(在这里,由于书籍的页边距,ifElse方法的实现被拆分到两行,但在代码编辑器中它可以在一行中完成。)

在 Java 5 到 7 版本中,这段代码会不使用 lambda 来编写,如下所示。

列表 2.3. 将基于 lambda 的一行代码方法转换为之前的 Java 版本
public <T> T ifElse(List<Boolean> conditions, List<T> ifTrue, T ifFalse) {

  Function<Tuple<Boolean, T>, Boolean> f1 =
      new Function<Tuple<Boolean, T>, Boolean>() {
        public Boolean apply(Tuple<Boolean, T> y) {
          return y._1;
        }
      };

  Function<List<Tuple<Boolean, T>>, Result<Tuple<Boolean, T>>> f2 =
      new Function<List<Tuple<Boolean, T>>, Result<Tuple<Boolean, T>>>() {
        public Result<Tuple<Boolean, T>> apply(List<Tuple<Boolean, T>> x) {
          return x.first(f1);
        }
      };

  Function<Tuple<Boolean, T>, T> f3 =
      new Function<Tuple<Boolean, T>, T>() {
        public T apply(Tuple<Boolean, T> x) {
          return x._2;
        }
      };

  Result<List<Tuple<Boolean, T>>> temp1 = conditions.zip(ifTrue);
  Result<Tuple<Boolean, T>> temp2 = temp1.flatMap(f2);
  Result<T> temp3 = temp2.map(f3);
  T result = temp3.getOrElse(ifFalse);
  return result;
}

显然,阅读和编写 lambda 版本要容易得多。Java 8 之前的版本通常被认为过于复杂,难以接受。但是,当涉及到调试时,lambda 版本却是一个更大的问题。如果一行代码等同于 20 行传统代码,你如何在其上设置断点来查找潜在的错误呢?问题是,并非所有调试器都有足够强大的功能来轻松地与 lambda 一起使用。这最终会改变,但在此期间,你可能需要找到其他解决方案。一个简单的解决方案是将一行代码拆分成多行,如下所示:

public <T> T ifElse(List<Boolean> conditions, List<T> ifTrue, T ifFalse) {
  return conditions.zip(ifTrue)
                   .flatMap(x -> x.first(y -> y._1))
                   .map(x -> x._2)
                   .getOrElse(ifFalse);
}

这允许你在每一行物理代码上设置断点。这当然很有用,它使得代码更容易阅读(并且更容易在书中发布)。但这并没有解决我们的问题,因为每一行仍然包含许多元素,这些元素并不能总是通过传统的调试器来调查。

为了使这个问题不那么关键,对每个组件进行广泛的单元测试非常重要,这意味着每个方法和每个作为方法参数传递的函数。在这里,这很容易。使用的方法(按出现顺序)是 List.zipOption.flatMapList.firstOption.mapOption.getOrElse。无论这些方法做什么,都可以进行广泛的测试。你可能还不知道它们,但你在下一章中将会构建 OptionList 组件,并编写 mapflatMapfirstzipgetOrElse 方法的实现(以及许多其他方法)。正如你将看到的,这些方法是纯函数式的。它们不能抛出任何异常,并且总是返回预期的结果,而不做任何其他事情。因此,在它们完全测试之后,就不会发生任何坏事。

关于函数,前面的例子使用了其中三个:

  • xx.first

  • yy._1

  • xx._2

第一个函数不能抛出任何异常,因为 x 不能为 null(你将在第五章中看到原因),并且 first 方法也不能抛出异常。

第二个和第三个函数不能抛出 NullPointerException,因为你已经确保 Tuple 不能用 null 参数构造。(参见第一章中的 Tuple 类代码。)图 2.4 展示了这些函数的匿名形式。

图 2.4. 匿名形式的函数

图片

这是在函数式编程中表现出色的一个领域:如果没有任何组件可以崩溃,整个程序也无法崩溃。在命令式编程中,组件可能在测试中运行良好,但在生产中由于某些非确定性行为而崩溃。如果组件的行为依赖于外部条件,你将无法完全测试它。即使没有任何组件作为单元有问题,多个组件的组合也可能为程序的不当行为创造条件。这种情况在函数式编程中是不会发生的。如果组件具有确定性行为,整个组合也将是确定性的。

许多地方仍然存在错误的可能性。程序可能不会按预期执行,因为组件可能被错误地组合。但是实现错误不会导致意外的崩溃。如果这个程序崩溃,那可能是由于将 null 引用传递给了 Tuple 构造函数。你不需要调试器来捕获这类错误。

因此,是的,与调试命令式程序相比,广泛使用 Lambda 表达式的函数式程序调试要困难一些,但只要所有组件都经过验证,调试就变得不那么必要了。请记住,这只有在抛出的异常导致程序崩溃时才成立。我们将在第六章中回到这一点。但就目前而言,请记住,默认情况下,抛出的异常或错误只会崩溃发生异常的线程,而不会影响整个应用程序。即使是OutOfMemoryError也可能不会使应用程序崩溃,因此,作为程序员,你必须处理这种情况。

2.6. 概述

  • 函数是源集合和目标集合之间的关系。它建立了源集合(定义域)的元素与目标集合(值域)的元素之间的对应关系。

  • 纯函数除了返回值外没有其他可见的效果。

  • 函数只有一个参数,这可能是一个包含多个元素的元组。

  • 元组函数可以通过柯里化来逐个应用其元组的一个元素。

  • 当柯里化函数只应用其部分参数时,我们称其为部分应用。

  • 在 Java 中,函数可以通过方法、Lambda 表达式、方法引用或匿名类来表示。

  • 方法引用是函数的首选表示形式。

  • 函数可以组合起来创建新的函数。

  • 函数可以递归地调用自身,但递归深度受栈大小的限制。

  • Lambda 表达式和方法引用可以在预期使用函数式接口的地方使用。

第三章. 使 Java 更具函数式

本章涵盖

  • 使标准控制结构函数化

  • 抽象控制结构

  • 抽象迭代

  • 使用正确的类型

你现在拥有了所有需要的函数类型。正如你在上一章中看到的,这些函数不需要违反传统的 Java 编码规则。将方法作为纯函数(也称为函数式方法)使用与大多数所谓的 Java 最佳实践完全一致。你没有改变规则或添加任何异构结构。你只是增加了一些关于函数式方法可以做什么的限制:它们可以返回一个值,仅此而已。它们不能修改封装作用域中的任何对象或引用,也不能修改它们的参数。本章的第一部分,你将学习如何将这些相同的原理应用到 Java 控制结构中。

你还学会了如何创建表示函数的对象,以便这些函数可以作为参数传递给方法和其他函数。但是,为了使这些函数有用,你必须创建可以操作它们的函数或方法。本章的第二部分,你将学习如何抽象集合操作和控制结构,以利用函数的力量。

本章的最后部分介绍了处理业务问题时,如何充分利用类型系统的技术。

3.1. 使标准控制结构函数化

控制结构是命令式编程的主要构建块。没有命令式 Java 程序员会相信没有使用if ... elseswitch ... case以及forwhiledo循环就能编写程序。这些结构是命令式编程的精髓。但在接下来的章节中,你将学习如何编写完全没有控制结构的函数式程序。在本节中,我们将不那么冒险——我们只会探讨以更函数式的方式使用传统的控制结构。

你在第二章中学到的一点是,纯函数式方法除了返回一个值之外什么都不能做。它们不能修改封装作用域中的对象或引用。方法返回的值只能依赖于其参数,尽管方法可以读取封装作用域中的数据。在这种情况下,数据被认为是隐式参数。

在命令式编程中,控制结构定义了一个它们通常在其中执行某些操作的作用域,这意味着它们有影响。这种影响可能只在控制结构的作用域内可见,也可能在封装作用域内可见。控制结构还可以访问封装作用域以读取值。以下列表显示了一个基本的电子邮件验证示例。

列表 3.1. 简单的电子邮件验证

在这个例子中,if ... else 结构 图片 1 从封装作用域中访问 emailPattern 变量。从 Java 语法角度来看,这个变量不一定要是 final,但如果你想使 testMail 方法函数化,这是必要的。另一个解决方案是在方法内部声明模式,但这会导致每次方法调用都重新编译它。如果模式可以在调用之间改变,你应该将其作为方法的第二个参数。如果条件为 true,则对这个电子邮件变量应用一个效果 图片 2。这个效果包括发送一个验证电子邮件,可能是为了检查电子邮件地址,除了格式正确外,是否有效。在这个例子中,效果通过将消息打印到标准输出进行模拟 图片 4。如果条件为 false,则通过将其包含在错误消息中对该变量应用不同的效果 图片 3。这个消息被记录 图片 5,这同样是通过打印到 标准错误 来模拟的。

3.2. 抽象控制结构

列表 3.1 中的代码完全是命令式的。在函数式编程中你永远不会找到这样的代码。尽管 testMail 方法看起来是一个纯效果,因为它不返回任何内容,但它将数据处理与效果混合在一起。这是你想要避免的事情,因为它会导致无法测试的代码。让我们看看你如何可以清理这个问题。

你可能想要做的第一件事是分离计算和效果,这样你就可以测试计算结果。这可以通过命令式完成,但我更喜欢使用一个函数,如下面的列表所示。

列表 3.2. 使用函数验证电子邮件

图片 1

现在你可以测试程序的数据处理部分(验证 email 字符串),因为你已经清楚地将其与效果分离。但你仍然有很多问题。一个是只处理字符串不验证的情况。但如果接收到的字符串是 null,则会抛出 NullPointerException(NPE)。考虑以下示例:

testMail("john.doe@acme.com");
testMail(null);
testMail("paul.smith@acme.com");

即使电子邮件地址有效,第三行也不会执行,因为第二行抛出的 NPE 杀死了线程。更好的做法是得到一个记录的消息,表明发生了什么,并继续处理下一个地址。

如果你收到一个空字符串,会出现另一个问题:

testMail("");

这不会导致错误,但地址不会验证,以下消息将被记录:

email  is invalid.

之间(“email”和“is”之间)的双空格表示字符串为空。一个特定的消息会更好,如下所示:

email must not be empty.

为了处理这些问题,你首先定义一个特殊组件来处理计算的结果。

列表 3.3. 管理计算结果的一个组件

图片 1

现在你可以编写你程序的新版本了。

列表 3.4. 具有更好错误处理的程序
import java.util.regex.Pattern;

public class EmailValidation {

  static Pattern emailPattern =
      Pattern.compile("^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,4}$");

  static Function<String, Result> emailChecker = s -> {
    if (s == null) {
      return new Result.Failure("email must not be null");
    } else if (s.length() == 0) {
      return new Result.Failure("email must not be empty");
    } else if (emailPattern.matcher(s).matches()) {
      return new Result.Success();
    } else {
      return new Result.Failure("email " + s + " is invalid.");
    }
  };

  public static void main(String... args) {
    validate("this.is@my.email");
    validate(null);
    validate("");
    validate("john.doe@acme.com");
  }

  private static void logError(String s) {
    System.err.println("Error message logged: " + s);
  }

  private static void sendVerificationMail(String s) {
    System.out.println("Mail sent to " + s);
  }

  static void validate(String s) {
    Result result = emailChecker.apply(s);
    if (result instanceof Result.Success) {
      sendVerificationMail(s);
    } else {
      logError(((Result.Failure) result).getMessage());
    }
  }
}

运行这个程序会产生预期的输出:

Error message logged: email this.is@my.email is invalid.
Mail sent to john.doe@acme.com
Error message logged: email must not be null
Error message logged: email must not be empty

但这仍然不满意。使用instanceof来确定结果是否成功很丑陋。使用类型转换来访问失败消息更丑陋。但更糟糕的是,你在validate方法中有一些程序逻辑无法测试。这是因为该方法是一个效果,这意味着它不返回值而是修改外部世界。

有没有解决问题的方法?是的。与其发送电子邮件或记录消息,不如返回一个执行相同操作的小程序。与其执行

sendVerificationMail(s)

logError(((Result.Failure) result).getMessage());

你可以返回执行时会产生相同结果的指令。多亏了 lambda 表达式,你可以轻松做到这一点。

首先,你需要一个表示可执行程序的函数式接口:

public interface Executable {
  void exec();
}

你本可以使用标准的Runnable接口,但大多数代码验证器如果这个接口用于除了运行线程之外的其他目的,都会发出警告。所以你会使用你自己的接口。

你可以很容易地更改你的程序,如下面的列表所示。

列表 3.5. 返回可执行程序

图片

图片

validate方法图片现在返回Executable而不是void。它不再有任何副作用,并且是一个纯函数。当返回Executable图片时,可以通过调用它的exec方法来执行它。

注意,Executable也可以传递给其他方法或存储起来稍后执行。特别是,它可以在所有计算完成后放入数据结构中并按顺序执行。这允许你将程序的函数部分与修改环境的部分分开。

你还用三元运算符替换了if ... else控制结构。这是一个个人喜好问题。三元运算符是函数式的,因为它返回一个值并且没有副作用。相比之下,if ... else结构可以通过只修改局部变量来使其函数式,但它也可能有副作用。如果你看到有很多嵌套的if ... else结构的命令式程序,问问自己用三元运算符替换它们有多容易。这通常是一个很好的指标,表明设计离函数式有多近。然而,通过调用非函数式方法来获取结果值,三元运算符也可能变得非函数式。

3.2.1. 清理代码

你的validate方法现在是函数式的,但它很脏。使用instanceof运算符几乎总是坏代码的迹象。另一个问题是可重用性低。当validate方法返回一个值时,除了执行或不执行之外,你没有其他选择。如果你想重用验证部分但产生不同的效果怎么办?

validate方法不应该依赖于sendVerificationMaillogError。它应该只返回一个结果,表示电子邮件是否有效,并且你应该能够选择成功或失败所需的任何效果。或者你可能更喜欢不应用效果,而是将结果与其他处理组合。

练习 3.1(难度较高)

尝试将验证与施加的效果解耦。

提示

首先,你需要一个接口,它只有一个方法来表示一个效果。其次,因为emailChecker函数返回一个Result,所以validate方法可以返回这个Result。在这种情况下,你将不再需要validate方法。第三,你需要将一个效果“绑定”到Result上。但由于结果可能是成功或失败,绑定两个效果并让Result类选择应用哪一个会更好。

解答 3.1

首先要做的是创建一个表示效果的接口,如下所示:

public interface Effect<T> {
  void apply(T t);
}

你可能会更喜欢 Java 8 的Consumer接口。尽管名字选得不好,但它做的是同样的工作。

然后,你需要对Result接口做一些修改,如图 3.1 所示。

图 3.1. Result接口的更改

名字有什么意义?

许多伟大的作家都曾写过关于名字。莎士比亚在《罗密欧与朱丽叶》中写道:^([a])

^a

威廉·莎士比亚,《罗密欧与朱丽叶》(1599),第二幕,第二场,shakespeare.mit.edu/romeo_juliet/romeo_juliet.2.2.html

名字有什么意义?那我们称之为玫瑰的东西,换一个名字,也会一样芬芳;

这用两行美丽的文字说明了费迪南德·德·索绪尔和其他语言学家在数百页中解释的内容:名字与其指称之间的关系是任意的。结果是,程序员永远不应该相信名字。通常,名字被选择来反映对象是什么或能做什么。但即使对象只能做一件明确的事情,也可能存在不匹配。

以 Java 接口为例。它们应该根据对象是什么ComparableClonableSerializable)或它们能做什么ListenerSupplierConsumer)来命名。遵循这个规则,Function应该更名为Applicable,并应该有一个apply方法。Supplier应该定义一个supply方法,而Consumer应该消费某些内容并有一个名为consume的方法。但Consumer定义了一个accept方法,并且它并没有消费任何东西,因为接受了一个对象之后,这个对象仍然可用。

不要相信名字。相信类型。类型不会说谎。类型是你的朋友!

下面的列表显示了Result类的修改版本。

列表 3.6. 可以处理EffectResult

你可以为bind方法选择任何你想要的名称。你可以称之为ifSuccessforEach。只有类型才是重要的。

现在,你可以通过使用新的EffectResult接口来清理程序,如下面的列表所示。

列表 3.7. 程序的更简洁版本

图像

图像

emailChecker函数现在返回一个参数化的Result<String> 图像Result与错误消息的类型相同这一事实并不重要。它可以是任何类型,例如Result<Email>。如果你查看Result实现,你会看到Failure的值总是String,无论Success的值可能是什么。Success类持有类型为T的值,而Failure类持有类型为String的值。在这个例子中,T恰好是String,但它可以是任何其他类型。(你将在本章的最后部分回到这个主题。)validate方法已被移除,现在定义了两个Effect实例 图像:一个用于成功,一个用于失败。这两个效果现在绑定 图像emailChecker函数的结果。

3.2.2. if ... else 的替代方案

你可能会想知道是否可以完全移除条件结构或运算符。你能编写一个没有任何这些结构的程序吗?这看起来可能是不可能的,因为许多程序员已经了解到决策是编程的基本构建块。但决策是命令式编程的概念。它是检查一个值并根据这个观察结果决定下一步做什么的概念。在函数式编程中,没有“下一步做什么”的问题,只有返回值的函数。最基本的if结构可能被视为函数的实现:

if (x > 0) {
  return x;
} else {
  return -x;
}

这是一个关于x的函数。它返回x的绝对值。你可以这样编写这个函数:

Function<Integer, Integer> abs = x -> {
  if (x > 0) {
    return x;
  } else {
    return -x;
  }
}

与如下函数的区别

Function<Integer, Integer> square = x -> x * x;

是的,你可能有函数的两个实现,并且必须根据参数的值在这两个实现之间进行选择。这并不是一个大问题,但如果你有很多可能的实现呢?你最终会有与列表 3.7 中一样多的嵌套if ... else结构,或者与列表 3.5 中一样多的嵌套三元运算符。你能做得更好吗?

练习 3.2

编写一个表示条件和相应结果的Case类。条件将由一个Supplier<Boolean>表示,其中Supplier是一个如下的函数式接口:

interface Supplier<T> {
  T get();
}

你可以使用 Java 8 的Supplier实现或你自己的实现。对应于条件的结果将由一个Supplier<Result<T>>表示。为了同时持有这两个,你可以使用一个Tuple<Supplier<Boolean>, Supplier<Result<T>>>

Case 类应该定义三个方法:

public static <T> Case<T> mcase(Supplier<Boolean> condition,
                                Supplier<Result<T>> value)

public static <T> DefaultCase<T> mcase(Supplier<Result<T>> value)

public static <T> Result<T> match(DefaultCase<T> defaultCase,
                                  Case<T>... matchers)

我使用名称 mcase 是因为 case 在 Java 中是一个保留字;m 代表 match。当然,你可以选择任何其他名称。

第一个 mcase 方法定义了一个正常情况,包含一个条件和结果值。第二个 mcase 方法定义了一个默认情况,由一个子类表示。第三个方法,match,选择一个情况。因为这个方法使用可变参数,所以默认情况应该放在第一个,但将是最后一个被使用的!

此外,Case 类应该定义具有以下签名的私有 DefaultCase 子类:

private static class DefaultCase<T> extends Case<T>

解决方案 3.2

我说过,这个类必须代表一个 Supplier<Boolean> 用于条件和一个 Supplier<Result<T>>> 用于结果值。最简单的方法是如下定义它:

public class Case<T> extends Tuple<Supplier<Boolean>, Supplier<Result<T>>>{
  private Case(Supplier<Boolean> booleanSupplier,
               Supplier<Result<T>> resultSupplier) {
    super(booleanSupplier, resultSupplier);
  }
}

mcase 方法很简单。第一个接受两个参数并创建一个新的实例。第二个只接受第二个参数(值的 Supplier)并创建默认的 Supplier 用于条件,它总是返回 true

public static <T> Case<T> mcase(Supplier<Boolean> condition,
                                Supplier<Result<T>> value) {
  return new Case<>(condition, value);
}

public static <T> DefaultCase<T> mcase(Supplier<Result<T>> value) {
  return new DefaultCase<>(() -> true, value);
}

DefaultCase 类非常简单。它只是一个标记类,所以你只需要创建一个调用 super 的构造函数:

private static class DefaultCase<T> extends Case<T> {
  private DefaultCase(Supplier<Boolean> booleanSupplier,
                      Supplier<Result<T>> resultSupplier) {
    super(booleanSupplier, resultSupplier);
  }
}

match 方法更复杂,但这是一种夸张,因为它只有三行代码:

@SafeVarargs
public static <T> Result<T> match(DefaultCase<T> defaultCase,
                                  Case<T>... matchers) {
  for (Case<T> aCase : matchers) {
    if (aCase._1.get()) return aCase._2.get();
  }
  return defaultCase._2.get();
}

如我之前提到的,默认情况必须在参数列表中首先出现,因为第二个参数是一个可变参数,但这个情况是最后被使用的。你通过调用 get 方法逐个测试所有情况。如果结果是 true,你将在评估后返回相应的值。如果没有情况匹配,则使用默认情况。

注意,评估意味着对返回值的评估。目前没有任何效果被应用。以下列表显示了完整的类。

列表 3.8。使用 Case 类匹配条件

图片

图片

现在,你可以极大地简化你的电子邮件验证应用程序的代码。正如你在以下列表中可以看到的,它绝对不包含任何控制结构。(注意对 CaseResult 方法的静态导入的使用。)

列表 3.9。没有控制结构的电子邮件验证应用程序

图片

图片

但等等。有一个技巧!你没有看到任何控制结构,因为它们隐藏在包含 if 指令甚至 for 循环的 Case 类中。所以你在作弊吗?不是的。首先,你有一个干净的循环和一个干净的 if。不再有嵌套的 if 语句系列。其次,你已经抽象了这些结构。你现在可以编写任意多的条件应用,而无需编写单个 iffor。但最重要的是,你只是功能编程之旅的开始。在第五章中,你将学习如何完全移除这两个结构。

在本章中,你将了解如何泛化所有控制结构的抽象。你已经为条件控制结构,如嵌套的if..else语句(以及switch..case也没有什么不同)做过这样的事情。让我们看看如何用循环来做同样的事情。

3.3. 抽象迭代

循环是遍历列表的结构。在 Java 中,循环也可以遍历集合,或者甚至可能看起来在没有任何东西上迭代,比如索引循环,但它们总是遍历列表。看似遍历集合的循环,如果执行两次,结果不会不同,因为在迭代过程中会应用一个顺序到集合上。即使每次迭代的顺序不相同,它也不会在单次迭代过程中改变。所以从迭代的角度来看,遍历集合就变成了一个列表。

索引循环并没有什么不同——它遍历一个由评估过的索引组成的列表。循环可以在评估所有参数之前退出,因为索引循环对它们的索引是惰性的。循环总是对它们的主体是惰性的,这意味着如果循环退出,剩余的元素将不会被处理。if..else结构的行为也类似。条件总是会被评估,所以它对条件是严格的,但只有ifelse部分中的一个会被根据条件评估,所以if..else在它的主体上也是惰性的。也许你以为 Java 是一种严格的语言,但事实并非如此。Java 对方法参数是严格的,但幸运的是,它有时也是惰性的。

回到循环上来,它们的主要用途是遍历列表的所有元素,如下所示:

for(String email : emailList) {
  // Do something with email;
}

每次你想处理一个列表时,你都会使用这个结构,或者使用其他结构,如whiledo..while,它们并没有什么不同。它们只是迭代上的语法糖。甚至前面的for循环也只是以下内容的语法糖:

for (int i = 0; i < emailList.size(); i++) {
  // do something with emailList.get(i)
}

while循环是不同的,因为它用于在条件得到验证的情况下迭代。它允许你在第一次迭代之前根据一个条件退出循环。do..while循环做的是同样的事情,但只是在第一次迭代之后。

重要的是在循环内部所做的事情,所以为什么你还得一次次地写循环呢?为什么你不能只说出你想要完成的事情,然后让它完成,而不必去弄乱控制结构、条件和索引呢?

举一个简单的例子。假设你有一串名字列表,并且你想要返回以逗号分隔的字符串。你能否第一次就把程序写在纸上正确无误?如果你是一个优秀的程序员,我想你能够做到。但许多程序员不得不编写代码,运行它,修复一般情况下的错误,再次运行,修复边缘情况下的错误,然后再次运行程序直到它正确无误。问题并不困难,但它如此无聊,以至于你往往第一次就做不对。如果你总是第一次就把你的程序写对,恭喜你。你是一个优秀的程序员,本节剩余的内容可能对你来说不是必要的。但如果你是一个普通的程序员,请继续阅读。

在循环内部,你可能想要做几件事情:

  • 将每个元素转换成其他东西

  • 将元素聚合为单个结果

  • 根据元素的条件移除一些元素

  • 根据外部条件移除一些元素

  • 根据某些标准分组元素

需要循环的各种操作可以应用于集合,例如连接、压缩或解压缩。(压缩意味着从两个列表中取元素并创建一个元组的列表。解压缩是逆操作。)

所有这些操作都可以被抽象。在第五章中,你将创建实现所有这些抽象的功能数据结构。现在,你将开发一个可以应用于遗留 Java 集合的这些抽象的库。

3.3.1. 使用映射抽象列表上的操作

当映射应用于集合时,意味着对集合中的每个元素应用一个转换。这是在传统的命令式编程中通常是如何做的:

List<Double> newList = new ArrayList<>();
for (Integer value : integerList) {
  newList.add(value * 1.2);
}

在这个例子中,一个操作被应用于Integer列表的每个元素(integerList),将其增加 20%。操作的结果是一个双精度浮点数,所以它被放入在循环开始之前创建的新列表中。尽管程序很简单,但它引发了一些有趣的问题。

第一个要点是你可以将迭代与计算分离。以下示例使用方法来实现这一点:

Double addTwentyPercent(Integer value) {
  return value * 1.2;
}

List<Double> newList = new ArrayList<>();
for (Integer value : integerList) {
  newList.add(addTwentyPercent(value));
}

这允许你重用计算,但它不允许你重用循环。为了允许这样做,你可以把循环放在一个方法内部,并传递一个应用计算的函数:

Function<Integer, Double> addTwentyPercent = x -> x * 1.2;
List<Double> map(List<Integer> list, Function<Integer, Double> f) {
  List<Double> newList = new ArrayList<>();
  for (Integer value : list) {
    newList.add(f.apply(value));
  }
  return newList;
}

现在,你可以使用Integer列表和一个从IntegerDouble的函数作为参数调用map方法,你将得到一个新的Double列表作为返回。此外,你可以自由地重用该函数,并且可以用不同的函数调用map方法。

通过使用泛型,你可以极大地提高可重用性:

<T, U> List<U> map(List<T> list, Function<T, U> f) {
  List<U> newList = new ArrayList<>();
  for (T value : list) {
    newList.add(f.apply(value));
  }
  return newList;
}

你可以将这个方法包含在一个库中,你将在其中定义几个方法,允许你抽象许多与列表相关的操作。你将调用这个库为Collection-Utilities

3.3.2. 创建列表

除了迭代之外,当在列表上工作时,程序员需要反复执行其他基本操作。最基本操作是创建列表。Java 支持许多创建列表的方法,但它们并不一致。

练习 3.3

编写创建空列表、包含一个元素的列表以及从元素集合创建列表的方法,以及一个 vararg 方法,它从参数列表创建列表。所有这些列表都将不可变。

解决方案 3.3

这很简单,正如你在下面的代码中可以看到:

public class CollectionUtilities {

  public static <T> List<T > list() {
    return Collections.emptyList();
  }

  public static <T> List<T > list(T t) {
    return Collections.singletonList(t);
  }

  public static <T> List<T > list(List<T> ts) {
    return Collections.unmodifiableList(new ArrayList<>(ts));
  }

  @SafeVarargs
  public static <T> List<T > list(T... t) {
    return Collections.unmodifiableList(Arrays.asList(Arrays.copyOf(t, t.length)));
  }
}

注意,list(List<T> ts) 方法会复制参数列表。这个防御性复制是为了确保列表不会被调用 list 方法的调用者之后修改。此外,vararg 版本可以用数组作为其参数。在这种情况下,结果列表由原始数组支持。因此,修改数组的一个元素将改变结果列表中相应的元素。这就是为什么你需要复制数组参数。

此外,结果列表实际上并不是不可变的。它们是可变列表的不可变视图,但这已经足够了,因为没有人会访问这些可变列表。它们只会在 CollectionUtilities 类中是可变的。

3.3.3. 使用 head 和 tail 操作

列表上的函数式操作通常访问 head(或第一个元素)以及 tail(移除第一个元素后的列表)。

练习 3.4

创建两个方法,分别返回列表的 head 和 tail。传递给列表的参数不得修改。因为你需要复制列表,所以还定义了一个 copy 方法。tail 返回的列表应该是不可变的。

解决方案 3.4

head() 方法很简单。如果列表为空,你抛出异常。否则,你读取索引 0 处的元素并返回它。

copy 方法也是基本的。它与列表创建方法相同,以列表作为其参数。

tail 方法稍微复杂一些。它必须复制其参数,删除第一个元素,并返回结果:

public static <T> T head(List<T> list) {
  if (list.size() == 0) {
    throw new IllegalStateException("head of empty list");
  }
  return list.get(0);
}

private static <T> List<T > copy(List<T> ts) {
  return new ArrayList<>(ts);
}

public static <T> List<T> tail(List<T> list) {
  if (list.size() == 0) {
    throw new IllegalStateException("tail of empty list");
  }
  List<T> workList = copy(list);
  workList.remove(0);
  return Collections.unmodifiableList(workList);
}

注意,copy 是私有的。它返回一个可变列表。要从外部复制,你可以调用 list(List<T>),它返回一个不可变列表。此外,这个例子在调用 headtail 时对空列表抛出异常。这不是函数式的,因为你应该总是捕获异常,而不是抛出它们,以便具有引用透明性。然而,在这个阶段,这要简单一些。在 第五章 中,当你查看函数式列表时,你会看到 headtail 方法将被声明为受保护的。这样,它们就只能在内 List 类中使用,并且永远不会从这个类中泄漏异常。

3.3.4. 函数式地追加到列表

在命令式程序中将元素追加到 Java 列表是一个基本操作,它被反复使用:

list.add(element);

但这个操作在函数式程序中不可用,因为它会改变它的参数,并且不返回修改后的列表。如果你认为它是因为它不改变它的元素参数而函数式的,记住你在第二章中学到的东西:这是对象表示法。列表本身是方法 add 的隐含参数,所以它等同于以下内容:

add(list, element);

将这种方法转换成函数式方法是简单的。你会调用它为 append

public static <T> List<T> append(List<T> list, T t) {
  List<T> ts = copy(list);
  ts.add(t);
  return Collections.unmodifiableList(ts);
}

append 方法会对其第一个参数(通过调用之前定义的 copy 方法)进行防御性复制,然后将第二个参数添加到其中,最后返回一个不可变视图包裹的修改后的列表。你很快就会有机会在无法使用 add 的地方使用这个 append 方法。

3.3.5. 减法和折叠列表

列表 折叠 通过使用特定的操作将列表转换成一个单一值。结果值可以是任何类型——它不必与列表的元素类型相同。将结果折叠成与列表元素相同类型的特定情况称为 减少。计算整数列表的元素总和是减少的一个简单例子。

你可以从两个方向折叠一个列表,从左到右或从右到左,这取决于使用的操作:

  • 如果操作是交换的,两种折叠方式是等价的。

  • 如果操作不是交换的,两种折叠方式会得到不同的结果。

折叠需要一个起始值,这是操作的中性元素,或称为恒等元素。这个元素被用作累加器的起始值。当计算完成时,累加器包含结果。另一方面,如果没有起始元素也可以进行减少操作,条件是列表不为空,因为第一个(或最后一个)元素将被用作起始元素。

用加法减少数字列表

假设你有一个列表 (1, 2, 3, 4),你想计算元素的总和。第一种方法是把累加器放在操作数的左边:

(((0 + 1) + 2) + 3) + 4 = 10

你也可以从另一边开始:

1 + (2 + (3 + (4 + 0))) = 10

结果是相同的。你也可以用乘法做同样的事情,但你需要使用恒等元素 1 作为累加器的起始值。

将字符列表折叠成字符串

现在我们用不同的操作对一个字符列表 ('a', 'b', 'c') 做同样的事情。这里使用的操作如下:

"x" + 'y' = "xy"

首先,让我们从左边折叠:

(("" + 'a') + 'b') + 'c' = "abc"

现在我们尝试从右边做同样的事情:

'a' + ('b' + ('c' + "")) = "abc"

从右边折叠不工作,因为左操作数是一个字符,而右操作数是一个字符串。所以你必须将操作更改为以下内容:

'x' + "y" = "xy"

在这种情况下,字符被添加到字符串的开头而不是末尾。第一次折叠被称为左折叠,这意味着累加器位于操作的左侧。当累加器位于右侧时,它被称为右折叠

理解左右折叠之间的关系

你可能会说,右折叠可以用左折叠来定义。让我们通过使用不同的形式来重写右折叠操作,这种形式称为核心递归

((0 + 3) + 2) + 1 = 6

在递归以及核心递归中,一步的评估依赖于前一步。但递归定义从最后一步开始,并定义其与前一步的关系。为了能够得出结论,它还必须定义基本步骤。另一方面,核心递归从第一步开始,并定义其与下一步的关系。由于它也是第一步,因此不需要基本步骤。

从这个例子中,似乎右折叠列表等同于反转元素顺序后的左折叠列表。

但等等。加法是一个交换操作。如果你使用非交换操作,你必须更改操作。如果不这样做,你可能会根据类型得到两种不同的情况。如果操作有不同类型的操作数,它将无法编译。另一方面,如果操作有相同类型的操作数但不是交换的,你将得到一个错误的结果而没有错误。所以foldLeftfoldRight有以下关系,其中operation1operation2在相反的顺序下给出相同的结果:

foldLeft(list, acc, x -> y -> operation1)

等价于

foldRight(reverse(list), acc, y -> x -> operation2)

如果操作是交换的,operation1operation2是相同的。否则,如果operation1x -> y -> compute(x, y),则operation2x -> y -> compute(y, x)

考虑一下用于反转列表的reverse函数。你能看到它是如何用leftFold来表达的吗?这是函数式编程之美的一部分。抽象无处不在。现在让我们看看如何将此应用于遗留的 Java 列表。

练习 3.5

创建一个方法来折叠整数列表,例如,用于求列表元素的和。此方法将接受一个整数列表、一个整数起始值和一个函数作为其参数。

解决方案 3.5

起始值取决于所应用的运算。该值必须是运算的中性单位元素。运算表示为你在上一章中学到的柯里化函数:

public static Integer fold(List<Integer> is, Integer identity,
                           Function<Integer, Function<Integer, Integer>> f) {
  int result = identity;
  for (Integer i : is) {
    result = f.apply(result).apply(i);
  }
  return result;
}

在静态导入CollectionUtilities.*之后,此方法可以按如下方式调用:

List<Integer> list = list(1, 2, 3, 4, 5);
int result = fold(list, 0, x -> y -> x + y);

在这里,result等于 15,这是 1、2、3、4 和 5 的和。将+替换为*并将0替换为1(乘法的单位元素)得到结果 1 x 2 x 3 x 4 x 5 = 120。

左折叠示例

您刚才定义的操作被命名为fold,因为左折叠或右折叠整数加法或乘法给出相同的结果。但如果您想使用其他函数,或者如果您想使折叠方法通用,您必须区分左右折叠。

练习 3.6

fold方法推广到foldLeft,以便它可以应用于任意类型元素的列表的左折叠。为了测试该方法是否正确,将其应用于以下参数,

List<Integer> list = list(1, 2, 3, 4, 5);
String identity = "0";
Function<String, Function<Integer, String>> f = x -> y -> addSI(x, y);

其中方法addSI定义如下:

String addSI(String s, Integer i) {
  return "(" + s + " + " + i + ")";
}

验证您是否得到以下输出:

(((((0 + 1) + 2) + 3) + 4) + 5)

注意,addSI方法允许您验证参数是否按正确顺序排列。直接使用"(" + s + " + " + i + ")"表达式无法进行此验证,因为反转参数只会改变+符号的意义,而不会改变结果。

解决方案 3.6

命令式实现相当简单:

public static <T, U> U foldLeft(List<T> ts, U identity,
                                Function<U, Function<T, U>> f) {
  U result = identity;
  for (T t : ts) {
    result = f.apply(result).apply(t);
  }
  return result;
}

这个通用版本可以用于整数运算,因此特定的整数版本是无用的。

右折叠示例

如您之前所见,左折叠是一个核心递归操作,因此通过命令式循环实现它很容易。另一方面,右折叠是一个递归操作。为了测试您的尝试性实现,您可以使用用于左折叠的方法。您将对以下参数进行测试,

List<Integer> list = list(1, 2, 3, 4, 5);
String identity = "0";
Function<Integer, Function<String, String>> f = x -> y -> addIS(x, y);

其中方法addIS定义如下:

private static String addIS(Integer i, String s) {
  return "(" + i + " + " + s + ")";
}

验证输出如下:

(1 + (2 + (3 + (4 + (5 + 0)))))

练习 3.7

编写foldRight方法的命令式版本。

解决方案 3.7

右折叠是一个递归操作。要使用命令式循环实现它,您必须以逆序处理列表:

public static <T, U> U foldRight(List<T> ts, U identity,
                                   Function<T, Function<U, U>> f) {
    U result = identity;
    for (int i = ts.size(); i > 0; i--) {
      result = f.apply(ts.get(i - 1)).apply(result);
    }
    return result;
  }

练习 3.8

编写foldRight的递归版本。请注意,在 Java 中,一个简单的递归版本可能不会完全工作,因为它使用栈来累积中间计算。在第四章[kindle_split_011.xhtml#ch04]中,您将学习如何使栈安全递归可用。

提示

您应该将函数应用于列表的头部以及折叠尾部的结果。

解决方案 3.8

简单版本至少适用于 5,000 个元素,这对于练习来说已经足够了:

public static <T, U> U foldRight(List<T> ts, U identity,
                                 Function<T, Function<U, U>> f) {
  return ts.isEmpty()
      ? identity
      : f.apply(head(ts)).apply(foldRight(tail(ts), identity, f));
}
基于堆的递归

解决方案 3.8 不是尾递归,因此不能优化为使用堆而不是栈。我们将在第五章中查看基于堆的实现。第五章。

反转列表

反转列表有时很有用,尽管从性能的角度来看,这个操作通常不是最优的。寻找不需要反转列表的其他解决方案更可取,但并不总是可能的。

通过迭代列表的逆序来定义一个具有命令式实现的reverse方法很容易。但您必须小心,不要弄乱索引:

public static <T> List<T> reverse(List<T> list) {
  List<T> result = new ArrayList<T>();
  for(int i = list.size() - 1; i >= 0; i--) {
    result.add(list.get(i));
  }
  return Collections.unmodifiableList(result);
}

存在许多可能的排列方式。例如,您可以从list.size()开始迭代,并使用i > 0作为条件。然后您必须使用i – 1作为列表的索引。

练习 3.9(困难)

不使用循环定义反转方法。相反,使用你到目前为止开发的方法。

提示

使用的方法是 foldLeftappend。可能从定义一个在列表前添加元素的 prepend 方法开始会有所帮助,这个方法是用 append 定义的。

解决方案 3.9

你可以先定义一个允许你在列表前添加元素的 prepend 函数式方法。这可以通过使用包含要添加的元素的累加器而不是空列表来左折叠列表来完成:

public static <T> List<T> prepend(T t, List<T> list) {
  return foldLeft(list, list(t), a -> b -> append(a, b));
}

然后,你可以定义一个反转方法作为左折叠,从空列表开始,并使用 prepend 方法作为操作:

public static <T> List<T> reverse(List<T> list) {
  return foldLeft(list, list(), x -> y -> prepend(y, x));
}

在你完成这个之后,你最终可以将对 prepend 的调用替换为相应的实现:

public static <T> List<T> reverse(List<T> list) {
  return foldLeft(list, list(), x -> y ->
                            foldLeft(x, list(y), a -> b -> append(a, b)));
}
警告

不要在生产代码中使用解决方案 3.9 中 reverseprepend 的实现。它们都意味着要遍历整个列表几次,所以它们很慢。在第五章(chapter 5)中,你将学习如何创建在所有场合都能良好运行的函数式不可变列表。

练习 3.10(困难)

在 3.10 节中,你定义了一个通过将操作应用于每个元素来映射列表的方法。这个操作,正如它被实现的那样,包括一个折叠。用 foldLeftfoldRight 重新编写 map 方法。

提示

要解决这个问题,你应该使用你刚刚定义的 appendprepend 方法。

解决方案

要理解这个问题,你必须考虑 map 包含两个操作:对每个元素应用一个函数,然后将所有元素收集到一个新列表中。第二个操作是一个折叠,其中恒等元是空列表(在静态导入 CollectionUtilities.* 之后写作 list()),操作是将一个元素添加到列表中。

这里是一个使用 appendfoldLeft 方法的实现:

public static <T, U> List<U> mapViaFoldLeft(List<T> list, 
                                        Function<T, U> f) {
  return foldLeft(list, list(), x -> y -> append(x, f.apply(y)));
}

以下实现使用了 foldRightprepend

public static <T, U> List<U> mapViaFoldRight(List<T> list, 
                                             Function<T, U> f) {
  return foldRight(list, list(), x -> y -> prepend(f.apply(x), y));
}

函数式编程的美丽之处在于寻找每个可以抽象和重用的微小元素。在你习惯了这种方式思考之后,你会在各个地方看到模式,并且想要抽象它们。

你可以通过组合你刚刚编写的基列表函数来定义很多其他有用的函数。但我们将在第五章(chapter 5)中推迟它们的学习,那时你将学习如何用纯函数式不可变列表替换遗留的 Java 列表,这将提供许多优势,包括大多数函数操作的性能将大大提高。

3.3.6. 组合映射和映射组合

对列表元素应用多个转换并不罕见。想象一下,你有一个价格列表,你想要将 9%的税应用到所有价格上,然后为运费添加一个固定的 3.50 美元费用。你可以通过组合两个映射来完成这个操作:

Function<Double, Double> addTax = x -> x * 1.09;
Function<Double, Double> addShipping = x -> x + 3.50;
List<Double> prices = list(10.10, 23.45, 32.07, 9.23);
List<Double> pricesIncludingTax = map(prices, addTax);
List<Double> pricesIncludingShipping =
                              map(pricesIncludingTax, addShipping);
System.out.println(pricesIncludingShipping);

这段代码打印以下内容:

[14.509, 29.0605, 38.456300000000006, 13.5607]

它可以工作,但效率不高,因为映射被应用了两次。你可以用这个来获得相同的结果:

System.out.println(map(map(prices,addTax),addShipping));

但这仍然是映射两次。一个更好的解决方案是组合函数而不是组合映射,或者说,映射组合而不是组合映射:

System.out.println(map(prices, addShipping.compose(addTax)));

或者如果你更喜欢更“自然”的编写顺序:

System.out.println(map(prices, addTax.andThen(addShipping)));

3.3.7. 将效果应用于列表

在前面的例子中,你打印列表以验证结果。在实际情况下,你可能会对列表的每个元素应用更复杂的效应。例如,你可以打印每个价格,在显示时只保留两位小数。这可以通过迭代来完成:

for (Double price : pricesIncludingShipping) {
  System.out.printf("%.2f", price);
  System.out.println();
}

但再次强调,你正在混合可以抽象化的动作。迭代可以像你对映射所做的那样进行抽象,并且应用于每个元素的效果可以抽象成一个类似函数的东西,但它有副作用且没有返回值。这正是你在 3.1 练习的解决方案中使用的Effect接口的作用。因此,示例可以重写如下:

Effect<Double> printWith2decimals = x -> {
  System.out.printf("%.2f", x);
  System.out.println();
};

public static <T> void forEach(Collection<T> ts, Effect<T> e) {
  for (T t : ts) e.apply(t);
}

forEach(pricesIncludingShipping, printWith2decimals);

这看起来似乎有更多的代码,但Effect接口和forEach方法可以一次性编写并重用,因此你可以单独测试它们。你的业务代码简化为只有一行。

3.3.8. 接近函数式输出

使用forEach方法,你可以某种程度上抽象副作用。你抽象了效果应用,使其可以隔离,但你还可以走得更远。使用forEach方法,单个效果应用于列表的每个元素。如果能将这些效果组合成一个单一的效果,那就太好了。把它想象成一个折叠成单一效果的过程。如果你能这样做,你的程序就可以是一个完全函数式的程序,完全没有副作用。它将生成一个新的程序,没有控制结构,只有一个效果列表,这些效果将依次应用。让我们来做这件事!

为了表示程序的指令,你将使用在列表 3.5 中使用的Executable接口。然后你需要一种方法来组合Executable实例,这可以通过函数式方法或函数来完成。你正处于函数式思维中,所以让我们使用一个函数:

Function<Executable, Function<Executable, Executable>> compose = 
    x -> y -> () -> {
        x.exec();
        y.exec();
    };

接下来你需要一个中性元素,或者称为单位元素,用于Executable的组合。这比一个什么也不做的可执行程序还要简单。让我们称它为ez

Executable ez = () -> {};

名称ez代表可执行零,这意味着由组合可执行程序组成的操作的零(或单位)元素。

你现在可以按照以下方式编写你的纯函数式程序:

Executable program = foldLeft(pricesIncludingShipping, ez,
        e -> d -> compose.apply(e).apply(() -> printWith2decimals.apply(d)));

这可能看起来有点复杂,但实际上很简单。它是 prices-IncludingShipping 列表的 foldLeft,使用 ez 作为累加器的初始值。唯一稍微复杂一点的部分是函数。如果你忘记了柯里化形式,把它当作一个接受两个参数的函数来考虑,它接受一个 Executablee)作为第一个参数,一个 Doubled)作为第二个参数,并将第一个参数与一个新的 Executable 组合,这个 Executable 包含应用 printWith2decimals 方法到 Double 上。正如你所见,这只是一个组合抽象的问题!

注意,你没有应用任何副作用。你得到的是一个用新语言编写的新程序(或者更确切地说,是一个脚本)。你可以通过在它上面调用 exec() 来执行这个程序:

program.exec();

你会得到以下结果:

14.51
29.06
38.46
13.56

这让你尝到了函数式编程如何在不使用副作用的情况下产生输出的味道。决定你是否应该在生产中使用这种技术取决于你自己。真正的函数式语言不会给你选择,但 Java 绝对不是一种函数式语言,所以你有选择。如果你决定以函数式编程,你可能会错过一些在这个领域帮助你的一些功能,但重要的是要知道,一切仍然都是可能的。

3.3.9. 构建 corecursive 列表

程序员反复做的事情之一是构建 corecursive 列表,其中大多数是整数列表。如果你认为,作为一个 Java 程序员,你不太经常这样做,考虑以下例子:

for (int i = 0; i < limit; i++) {
  some processing...
}

这段代码是两个抽象的组合:一个 corecursive 列表和一些处理。corecursive 列表是从 0(包含)到 limit(不包含)的整数列表。正如我们之前已经提到的,函数式编程,在许多方面,是关于将抽象推向极限。所以让我们抽象这个 corecursive 列表的构建。

如我之前所述,corecursive 意味着每个元素都可以通过应用一个函数到前一个元素来构建,从第一个元素开始。这就是 corecursive 与递归结构区分开来的地方。(在递归结构中,每个元素都是前一个元素的函数,从最后一个元素开始。)我们将在第四章中再次回到这个区别,但就现在而言,这意味着 corecursive 列表很容易构建。只需从第一个元素(int i = 0)开始,并应用选定的函数(i > i++)。

你可以先构建列表,然后再将其映射到对应于 some processing ... 或函数组合或效果的函数。让我们用一个具体的限制来做这个例子:

for (int i = 0; i < 5; i++) {
  System.out.println(i);
}

这几乎等同于以下内容:

list(0, 1, 2, 3, 4).forEach(System.out::println);

你已经抽象了列表和效果。但你可以进一步抽象。

练习 3.11

编写一个方法,使用起始值、限制和函数 x > x + 1 来生成一个列表。你将调用这个方法 range,它将有以下签名:

List<Integer> range(int start, int end)

解决方案 3.11

你可以使用for循环实现来实施range方法。但你会使用while循环为下一项练习做准备:

public static List<Integer> range(int start, int end) {
  List<Integer> result = new ArrayList<>();
  int temp = start;
  while (temp < end) {
    result = CollectionUtilities.append(result, temp);
    temp = temp + 1;
  }
  return result;
}

我选择使用while循环,因为它更容易转换成适用于任何类型的通用方法,给定从该类型到自身的函数以及第二个函数(称为predicate)从该类型到布尔值。

练习 3.12

编写一个通用的range方法,使其适用于任何类型和任何条件。因为范围的概念主要适用于数字,所以让我们称这个方法为unfold,并给它以下签名:

List<T> unfold(T seed, Function<T, T> f, Function<T, Boolean> p)

解决方案 3.12

range方法实现开始,你只需要将特定部分替换为通用部分:

public static <T> List<T> unfold(T seed,
                                 Function<T, T> f,
                                 Function<T, Boolean> p) {
  List<T> result = new ArrayList<>();
  T temp = seed;
  while (p.apply(temp)) {
    result = append(result, temp);
    temp = f.apply(temp);
  }
  return result;
}

练习 3.13

unfold来实现range方法。

解决方案 3.13

这里没有什么困难的。你必须提供seed,即rangestart参数;函数f,即x > x + 1;以及谓词p,它解析为x > x < end

public static List<Integer> range(int start, int end) {
  return unfold(start, x -> x + 1, x -> x < end);
}

核递归和递归之间存在双重关系。一个是另一个的对立面,因此总是可以将一个递归过程转换为核递归过程,反之亦然。这是下一章的主要内容,你将学习如何将递归过程转换为核递归过程。现在,让我们做相反的过程。

练习 3.14

基于你在前几节中定义的函数式方法编写range的递归版本。

提示

你只需要prepend方法,尽管你可以选择使用不同方法的其他实现。

解决方案 3.14

定义递归实现相当简单。你只需将start参数prepend到相同的方法中,使用相同的end参数,并用应用f函数后的结果替换start参数。这比用言语表达要容易得多:

public static List<Integer> range(Integer start, Integer end) {
    return end <= start
        ? CollectionUtilities.list()
        : CollectionUtilities.prepend(start, range(start + 1, end));
  }

range方法应用于获得与之前作为示例使用的for循环相同的结果很简单:

for (int i = 0; i < 5; i++) {
  System.out.println(i);
}

你可以将其重写如下:

range(0, 5).forEach(System.out::println);

更有趣的是,如果for循环内部应用的过程是函数式的,那么好处会更加显著:

List<Integer> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
  list.add(i * i);
}

这可以替换为以下内容(假设静态导入Collection-Utilities.*):

mapViaFoldLeft(range(0, 5), x -> x * x);

当然,在这个例子中,也可以使用mapViaFoldRight

基于栈的递归的危险

在前述示例中开发的递归实现不应在生产环境中使用,因为它限制在 6,000 到 7,000 步之间。如果你尝试走得更远,栈将溢出。第四章提供了更多关于这个主题的信息。

严格性的危险

这些版本(递归和核心递归)都不等同于for循环。这是因为,尽管 Java 主要是一种严格的语言(它在方法参数方面是严格的),但for循环,像所有的 Java 控制结构和一些运算符一样,是惰性的。这意味着在作为示例使用的for循环中,评估的顺序将是索引、计算、索引、计算……,尽管使用range方法将首先计算完整的列表,然后再映射函数。

这个问题出现是因为你不应该使用列表来做这件事:列表是严格的数据结构。但你必须从某个地方开始。在第九章中,你将学习如何构建惰性集合,这将解决这个问题。

在本节中,你已经学会了如何抽象和封装在使用命令式数据结构(如列表)时不可避免的命令式操作。在第五章中,你将学习如何完全用纯函数式数据结构替换这些遗留数据结构,这将提供更多的自由和更好的性能。同时,你必须更仔细地考虑类型。

3.4. 使用正确的类型

在前面的例子中,你已经使用了标准类型,如整数、双精度浮点数和字符串来表示业务实体,如价格和电子邮件地址。尽管这在命令式编程中是常见的做法,但它会导致应该避免的问题。正如我所说的,你应该比名称更信任类型。

3.4.1. 标准类型的问题

让我们考察一个简化的问题,看看如何使用标准类型解决问题会导致问题。想象你有产品,有名称、价格和重量,你必须创建代表产品销售的发票。这些发票必须提到产品、数量、总价和总重量。

你可以用以下类来表示一个Product

public class Product {

  private final String name;
  private final double price;
  private final double weight;

  public Product(String name, double price, double weight) {
    this.name = name;
    this.price = price;
    this.weight = weight;
  }

  ... (getters)
}

因为属性是最终的,你需要一个构造函数来初始化它们,以及获取器来读取它们,但我们没有表示获取器。

接下来,你可以使用一个OrderLine类来表示订单的每一行。这个类在下面的列表中展示。

列表 3.10. 代表订单一行组件
public class OrderLine {

  private Product product;
  private int count;

  public OrderLine(Product product, int count) {
    super();
    this.product = product;
    this.count = count;
  }

  public Product getProduct() {
    return product;
  }

  public void setProduct(Product product) {
    this.product = product;
  }

  public int getCount() {
    return count;
  }

  public void setCount(int count) {
    this.count = count;
  }

  public double getWeight() {
    return this.product.getWeight() * this.count;
  }

  public double getAmount() {
    return this.product.getPrice() * this.count;
  }
}

这看起来像是一个古老的 Java 对象,用Product和一个int初始化,代表订单的一行。它还具有计算行总价和总重量的方法。

继续使用标准类型的决定,你将使用List<OrderLine>来表示一个订单。列表 3.11 展示了如何处理订单。(如果你还不习惯于函数式风格,你可以将此代码与命令式等效的StoreImperative进行比较,你可以在本书的网站上找到,网址为github.com/fpinjava/fpinjava。)

列表 3.11. 处理订单
import java.util.List;
import static com.fpinjava.common.CollectionUtilities.*;

public class Store {

  public static void main(String[] args) {
    Product toothPaste = new Product("Tooth paste", 1.5, 0.5);
    Product toothBrush = new Product("Tooth brush", 3.5, 0.3);
    List<OrderLine> order = list(
        new OrderLine(toothPaste, 2),
        new OrderLine(toothBrush, 3));
    double weight = foldLeft(order, 0.0, x -> y -> x + y.getAmount());
    double price = foldLeft(order, 0.0, x -> y -> x + y.getWeight());
    System.out.println(String.format("Total price: %s", price));
    System.out.println(String.format("Total weight: %s", weight));
  }
}

运行此程序将在控制台显示以下结果:

Total price: 1.9
Total weight: 13.5

这是可以的,但是错误的!问题是编译器没有告诉你任何关于错误的信息。唯一能够捕获这个错误的方法是测试程序,但测试不能证明程序是正确的。它们只能证明你没有通过编写另一个程序(顺便说一句,这个程序也可能是不正确的)来证明它是不正确的。

如果你没有注意到(这不太可能),问题出在以下几行:

double weight = foldLeft(order, 0.0, x -> y -> x + y.getAmount());
double price = foldLeft(order, 0.0, x -> y -> x + y.getWeight());

你错误地将价格和重量混合在一起,编译器因为它们都是double类型而没有注意到这一点。

顺便说一句,如果你已经学过建模,你可能还记得一条旧规则:类不应该有多个相同类型的属性。相反,它们应该有一个具有特定基数(cardinality)的属性。在这里,这意味着一个Product应该有一个类型为double的属性,基数(cardinality)为2。这显然不是解决问题的正确方法,但这是一个值得记住的好规则。如果你发现自己正在用多个相同类型的属性来建模对象,你很可能做错了。

你能做些什么来避免这样的问题?首先,你必须意识到价格和重量不是数字。它们是数量。数量可能是数字,但价格是货币单位的数量,重量是重量单位的数量。你不应该处于将磅和美元相加的情况。

3.4.2. 定义值类型

为了避免这个问题,你应该使用值类型。值类型是表示值的类型。你可以定义一个值类型来表示价格:

public class Price {

  public final double value;

  public Price(double value) {
    this.value = value;
  }
}

你也可以对重量做同样的处理:

public class Weight {

  public final double value;

  public Weight(double value) {
    this.value = value;
  }
}

但这并不能解决你的问题,因为你可能写出这样的代码:

weight += orderLine.getAmount().value;
price += orderLine.getWeight().value;

你需要为PriceWeight定义加法,你可以用一个方法来做这件事:

public class Price {

  ...

  public Price add(Price that) {
    return new Price(this.value + that.value);
  }
...

你还需要乘法,但乘法有点不同。加法是相同类型的对象相加,而乘法是将一个类型的对象乘以一个数字。所以当乘法不是仅应用于数字时,它不是交换律的。以下是Product的乘法示例:

public Price mult(int count) {
  return new Price(this.value * count);
}

在你的程序中,你从零开始添加价格和重量。你不能再这样做,所以你需要为PriceWeight提供一个零。这可以是一个单例,所以你会使用

public static final Price ZERO = new Price(0.0);

Price类中,以及对于Weight类也是同样的情况。

Product类需要按以下方式修改:

public class Product {

  public final String name;
  public final Price price;
  public final Weight weight;

  public Product(String name, Price price, Weight weight) {
    this.name = name;
    this.price = price;
    this.weight = weight;
  }
}

OrderLine也需要进行修改:

public Weight getWeight() {
  return this.product.getWeight().mult(this.count);
}

public Price getAmount() {
  return this.product.price.mult(this.count);
}

你现在可以使用这些类型和操作重写你的程序:

import static com.fpinjava.common.CollectionUtilities.*;
import java.util.List;

public class Store {

  public static void main(String[] args) {

    Product toothPaste = new Product("Tooth paste", new Price(1.5), new Weight(0.5));
    Product toothBrush = new Product("Tooth brush", new Price(3.5), new Weight(0.3));

    List<OrderLine> order = list(
        new OrderLine(toothPaste, 2),
        new OrderLine(toothBrush, 3));

    Price price = Price.ZERO;
    Weight weight = Weight.ZERO;
    for (OrderLine orderLine : order) {
      price = price.add(orderLine.getAmount());
      weight = weight.add(orderLine.getWeight());
    }
  }
}

你现在不能再随意操作类型而不让编译器警告你。但你可以做得更好。首先,你可以为PriceWeight添加验证。它们都不应该用零值构造,除非是在类内部,用于身份元素。你可以使用私有构造函数和工厂方法。以下是Price的示例:

private Price(double value) {
  this.value = value;
}

public static Price price(double value) {
  if (value <= 0) {
    throw new IllegalArgumentException("Price must be greater than 0");
  } else {
    return new Price(value);
  }
}

但您可以做出的主要改变是重用您在第 3.3 节中开发的折叠函数。这些函数将函数作为它们的第三个参数,因此您首先必须定义一个用于添加价格的功能(在Price类中):

public static Function<Price, Function<OrderLine, Price>> sum =
                                           x -> y -> x.add(y.getAmount());

您还需要在Weight类中具有相同的函数,以便添加重量:

public static Function<Weight, Function<OrderLine, Weight>> sum =
                                           x -> y -> x.add(y.getWeight());

最后,您将为PriceWeight添加一个toString方法以简化测试:

public String toString() {
  return Double.toString(this.value);
}

现在,您可以将您的Store类修改为使用折叠:

Product toothPaste = new Product("Tooth paste", price(1.5), weight(0.5));
Product toothBrush = new Product("Tooth brush", price(3.5), weight(0.3));
List<OrderLine> order =
     list(new OrderLine(toothPaste, 2), new OrderLine(toothBrush, 3));
Price price = foldLeft(order, Price.ZERO, Price.sum);
Weight weight = foldLeft(order, Weight.ZERO, Weight.sum);
System.out.println(String.format("Total price: %s", price));
System.out.println(String.format("Total weight: %s", weight));

3.4.3. Java 中值类型的未来

值类型可以用于所有业务类型,以将类型安全引入您的程序。但如我所描述的值类型并不是真正的值类型。真正的值类型被当作对象来操作,但表现起来像原始类型。其他语言有内置的值类型,但 Java 没有,尽管这可能会改变;已经有人提出了在 Java 未来的版本中包含值类型的建议。如果您对这个主题感兴趣,可以阅读该建议cr.openjdk.java.net/~jrose/values/values-0.html

3.5. 摘要

  • 通过确保从结构外部不可见任何状态突变,Java 的控制结构可以变得更加函数式。

  • 控制结构可以从它们所控制的效果中抽象出来。

  • Result接口可以用来表示可能失败的操作的结果。

  • 类似于if ... elseswitch ... case的控制结构可以用函数替换。

  • 迭代可以抽象成函数,这些函数可以用作循环的替代品。

  • 列表可以双向折叠(右向和左向)以将其简化为一个单一的对象(顺便说一句,这个对象可能是一个新的列表)。

  • 列表可以通过递归或核心递归进行处理。

  • 函数可以映射到列表中,以改变其元素的价值和/或类型。

  • 映射可以通过折叠实现。

  • 可以将效果绑定到列表上,以便应用于它们的每个元素。

  • 递归和核心递归也可以用来构建列表。

  • 递归的深度受 Java 堆栈大小的限制。

  • 值类型可以通过允许编译器检测类型问题来使程序更安全。

第四章:递归、尾递归和记忆化

本章涵盖

  • 理解递归和尾递归

  • 与递归函数一起工作

  • 组合大量函数

  • 使用记忆化加速函数

上一章介绍了强大的方法和函数,但其中一些不应该在生产中使用,因为它们可能会溢出栈并导致应用程序崩溃(或者至少是调用它们的线程)。这些“危险”的方法和函数主要是显式递归的,但并不总是如此。你已经看到,组合函数也可能溢出栈,即使是非递归函数也可能发生这种情况,尽管这种情况并不常见。

在本章中,你将学习如何将基于栈的函数转换为基于堆的函数。这是必要的,因为栈是一个有限的内存区域。为了使递归函数安全,你必须以这种方式实现它们,即它们使用堆(主内存区域)而不是有限的栈空间。为了完全理解这个问题,你必须首先理解递归和尾递归之间的区别。

4.1. 理解尾递归和递归

尾递归是通过使用一个步骤的输出作为下一个步骤的输入来组合计算步骤,从第一个步骤开始。递归是相同的操作,但以最后一个步骤开始。在递归中,你必须延迟评估直到遇到基本条件(对应于尾递归的第一个步骤)。

假设你的编程语言中只有两个指令:增量(给一个值加 1)和减量(从一个值减 1)。作为一个例子,你将通过组合这些指令来实现加法。

4.1.1. 探索尾递归和递归加法示例

要将两个数字 x 和 y 相加,你可以这样做:

  • 如果 y 等于 0,则返回 x

  • 否则,增加 x,减少 y,然后重新开始。

这可以写成以下 Java 代码:

static int add(int x, int y) {
  while(y > 0) {
    x = ++x;
    y = --y;
  }
  return x;
}

这里有一个更简单的方法:

static int add(int x, int y) {
  while(y-- > 0) {
    x = ++x;
  }
  return x;
}

直接使用参数 xy 没有问题,因为在 Java 中,所有参数都是按值传递的。此外,请注意,你使用了后减量来简化编码。你可以通过稍微改变条件来使用前减量,从而将迭代从 y1 转换为从 y - 10

static int add(int x, int y) {
  while(--y >= 0) {
    x = ++x;
  }
  return x;
}

递归版本更复杂,但仍然简单:

static int addRec(int x, int y) {
  return y == 0
      ? x
      : addRec(++x, --y);
}

这两种方法似乎都有效,但如果你尝试使用大数字的递归版本,可能会感到惊讶。尽管这个版本,

addRec(10000, 3);

产生预期的结果 10,003,切换参数,如下所示,

addRec(3, 10000);

产生 StackOverflowException

4.1.2. 在 Java 中实现递归

要理解正在发生的事情,你必须看看 Java 如何处理方法调用。当一个方法被调用时,Java 暂停当前正在做的事情,并将环境推送到栈上以为执行被调用方法腾出空间。当这个方法返回时,Java 弹出栈以恢复环境并继续程序执行。如果你一个接一个地调用方法,栈始终最多只保留一个这些方法调用环境。

但方法不仅仅是由一个接一个地调用它们组成的。方法可以调用其他方法。如果 method1 在其实现中调用 method2,Java 会再次挂起 method1 的执行,将当前环境推送到栈上,并开始执行 method2。当 method2 返回时,Java 从栈中弹出最后推送的环境并恢复程序执行(在这种情况下是 method1)。当 method1 完成时,Java 再次从栈中弹出最后的环境并恢复调用此方法之前的状态。

方法调用可能深度嵌套,这种嵌套深度确实有一个限制,那就是栈的大小。在当前情况下,这个限制大约在几千层左右,可以通过配置栈大小来增加这个限制。但是,因为所有线程都使用相同的栈大小,所以增加栈大小通常会造成空间的浪费。默认的栈大小从 320 KB 到 1024 KB 不等,这取决于 Java 的版本和所使用的系统。对于一个使用最小栈空间的 64 位 Java 8 程序,嵌套方法调用的最大数量大约是 7,000。通常情况下,你不会需要更多,除非在特定情况下。其中一种情况就是递归方法调用。

4.1.3. 使用尾调用消除

在被调用方法返回后恢复计算通常需要在栈上推送环境,但这并不总是必要的。当对方法的调用是调用方法做的最后一件事时,方法返回时就没有什么可以恢复的,所以可以直接用当前方法的调用者而不是当前方法本身来恢复。在最后位置发生的方法调用,意味着它是返回前的最后一件事,被称为 尾调用。避免在尾调用后推送环境到栈上以恢复方法处理是一种称为 尾调用消除 (TCE) 的优化技术。不幸的是,Java 不使用 TCE。

尾调用消除有时被称为 尾调用优化 (TCO)。TCE 通常是一种优化,没有它也可以生存。但是,当涉及到递归函数调用时,TCE 就不再是一种优化了。它是一个必要特性。这就是为什么在处理递归时,TCE 比 TCO 是一个更好的术语。

4.1.4. 使用尾递归方法和函数

大多数函数式语言都有 TCE。但 TCE 并不足以使每个递归调用都成为可能。要成为 TCE 的候选者,递归调用必须是方法必须做的最后一件事。

考虑以下方法,它是计算列表元素之和的:

static Integer sum(List<Integer> list) {
    return list.isEmpty()
        ? 0
        : head(list) + sum(tail(list));
  }

此方法使用了第三章(chapter 3)中的 headtail 方法。对 sum 方法的递归调用不是方法必须做的最后一件事。方法做的最后四件事如下:

  • 调用 head 方法

  • 调用 tail 方法

  • 调用 sum 方法

  • head 的结果和 sum 的结果相加

即使你有 TCE,你也不能用这个方法处理包含 10,000 个元素列表。但你可以重写这个方法,以便将 sum 的调用放在尾部位置:

static Integer sum(List<Integer> list) {
  return sumTail(list, 0);
}

static Integer sumTail(List<Integer> list, int acc) {
  return list.isEmpty()
      ? acc
      : sumTail(tail(list), acc + head(list));
}

在这里,sumTail 方法是尾递归的,可以通过 TCE 进行优化。

4.1.5. 抽象递归

到目前为止,一切顺利,但为什么要在 Java 没有 TCE 的情况下还费心去做这些呢?好吧,Java 没有它,但你也可以不用它。你只需要做以下事情:

  • 表示未评估的方法调用

  • 将它们存储在类似栈的结构中,直到遇到终止条件

  • 按照后进先出(LIFO)的顺序评估调用

大多数递归方法的例子都使用阶乘函数。其他例子使用斐波那契数列。除了是递归的之外,阶乘方法没有特别有趣的地方。斐波那契数列更有趣,我们稍后会回到它。首先,你将使用本章开头所示的这个非常简单的递归加法方法。

递归和核心递归函数都是函数,其中 f(n)f(n - 1)f(n - 2)f(n - 3) 等的复合,直到遇到终止条件(通常是 f(0)f(1))。记住,在传统的编程中,组合通常意味着组合评估的结果。这意味着组合函数 f(a)g(a) 包括评估 g(a),然后使用结果作为 f 的输入。但不必这样做。在第二章(chapter 2)中,你开发了一个 compose 方法来组合函数,以及一个 higherCompose 函数来做同样的事情。它们都没有评估组合函数。它们只产生了一个稍后可以应用的其他函数。

递归和核心递归相似,但存在差异。你创建的是一个函数调用的列表,而不是函数的列表。在核心递归中,每一步都是终止的,因此它可以按顺序评估以获得结果,并将其用作下一步的输入。在递归中,你从另一端开始,因此你必须将未评估的调用放入列表中,直到找到终止条件,然后你可以按相反的顺序处理列表。你将步骤堆叠,直到找到最后一个,然后按相反的顺序(后进先出)处理堆栈,再次评估每个步骤,并将结果用作下一个(实际上,上一个)步骤的输入。

问题在于 Java 既使用线程栈进行递归又进行尾递归,其容量是有限的。通常,在 6,000 到 7,000 步之后栈会溢出。你必须创建一个返回未评估步骤的函数或方法。为了表示计算中的步骤,你将使用一个名为TailCall的抽象类(因为你想要表示出现在尾位置的函数调用)。

这个TailCall抽象类有两个子类。一个代表中间调用,当某一步的处理被暂停以再次调用方法来评估下一步时。这由一个名为Suspend的子类表示。它使用Supplier<TailCall>实例化,代表下一个递归调用。这样,你不会将所有的TailCalls放入一个列表中,而是通过将每个尾调用链接到下一个来构建一个链表。这种方法的好处是这样一个链表是一个栈,提供常数时间的插入以及常数时间的对最后一个插入元素的访问,这对于后进先出(LIFO)结构是最优的。

第二个子类代表最后的调用,它应该返回结果,因此你将调用它为Return。它不会保留指向下一个TailCall的链接,因为没有后续的调用,但它会保留结果。以下是你会得到的内容:

public abstract class TailCall<T> {
  public static class Return<T> extends TailCall<T> {
    private final T t;
    public Return(T t) {
      this.t = t;
    }
  }

  public static class Suspend<T> extends TailCall<T> {
    private final Supplier<TailCall<T>> resume;
    private Suspend(Supplier<TailCall<T>> resume) {
      this.resume = resume;
    }
  }
}

要处理这些类,你需要一些方法:一个用于返回结果,一个用于返回下一个调用,以及一个辅助方法用于确定一个TailCallSuspend还是Return。你可以避免最后一个方法,但你需要使用instanceof来完成这项工作,这看起来很糟糕。这三个方法如下:

public abstract TailCall<T> resume();
public abstract T eval();
public abstract boolean isSuspend();

resume方法在Return中没有实现,并且会抛出一个运行时异常。你的 API 用户不应该处于调用此方法的情况,所以如果最终调用了它,那将是一个错误,你将停止应用程序。在Suspend类中,此方法将返回下一个TailCall

eval方法返回存储在Return类中的结果。在第一个版本中,如果对Suspend类调用它,它将抛出一个运行时异常。

isSuspend方法在Suspend中返回true,在Return中返回false。以下列表显示了第一个版本。

列表 4.1. TailCall 接口及其两个实现
public abstract class TailCall<T> {

  public abstract TailCall<T> resume();
  public abstract T eval();
  public abstract boolean isSuspend();

  public static class Return<T> extends TailCall<T> {

    private final T t;

    public Return(T t) {
      this.t = t;
    }

    @Override
    public T eval() {
      return t;
    }

    @Override
    public boolean isSuspend() {
      return false;
    }
    @Override
    public TailCall<T> resume() {
      throw new IllegalStateException("Return has no resume");
    }
  }

  public static class Suspend<T> extends TailCall<T> {

    private final Supplier<TailCall<T>> resume;

    public Suspend(Supplier<TailCall<T>> resume) {
      this.resume = resume;
    }

    @Override
    public T eval() {
      throw new IllegalStateException("Suspend has no value");
    }

    @Override
    public boolean isSuspend() {
      return true;
    }

    @Override
    public TailCall<T> resume() {
      return resume.get();
    }
  }
}

为了使递归方法add能够与任何数量的步骤(在可用内存的范围内)一起工作,你需要进行一些修改。从你的原始方法开始,

static int add(int x, int y) {
  return y == 0
      ? x
      : add(++x, --y) ;
}

你需要做出以下列表中显示的修改。

列表 4.2. 修改后的递归方法

图片

此方法返回一个 TailCall<Integer> 而不是 int。此返回值可能是一个 Return<Integer>,如果你已经达到终止条件!,或者是一个 Suspend<Integer>,如果你还没有!Return 使用计算结果(即 x,因为 y0)进行实例化,而 Suspend 使用一个 Supplier<TailCall<Integer>> 进行实例化,这是从执行序列的角度来看计算的下一步,或者从调用序列的角度来看的上一步。重要的是要理解 Return 在方法调用方面对应于最后一步,但在评估方面对应于第一步。此外,请注意,我们稍微改变了评估,将 ++x--y 替换为 x + 1y – 1。这是必要的,因为我们正在使用闭包,它只适用于封闭的变量实际上是最终变量。这是作弊,但不是太严重。我们可以创建并调用两个方法,decinc,使用原始运算符。

此方法返回一个 TailCall 实例的链,除了最后一个实例外,所有实例都是 Suspend 实例,最后一个实例是 Return

到目前为止,一切顺利,但这个方法并不是原始方法的直接替代品。这不是什么大问题!原始方法的使用方式如下:

System.out.println(add(x, y))

你可以这样使用新方法:

TailCall<Integer> tailCall = add(3, 100000000);
while(tailCall.isSuspend()) {
  tailCall = tailCall.resume();
}
System.out.println(tailCall.eval());

这看起来不错吗?如果你感到沮丧,我理解。你以为你只是用一种透明的方式用新方法替换旧方法。你似乎离这个目标还很远。但只要你稍加努力,就可以让事情变得更好。

4.1.6. 使用基于堆栈的递归方法的替代方案

在上一节的开头,我说过,你的递归 API 的用户没有机会通过在 Return 上调用 resume 或在 Suspend 上调用 eval 来干扰 TailCall 实例。通过将评估代码放在 Suspend 类的 eval 方法中,这很容易实现:

public static class Suspend<T> extends TailCall<T> {

  ...

  @Override
  public T eval() {
    TailCall<T> tailRec = this;
    while(tailRec.isSuspend()) {
      tailRec = tailRec.resume();
    }
    return tailRec.eval();
  }

现在你可以以更简单、更安全的方式获取递归调用的结果:

add(3, 100000000).eval()

但这不是你想要的。你想要移除对 eval 方法的调用。这可以通过一个辅助方法来完成:

public static int add(int x, int y) {
  return addRec(x, y).eval();
}

private static TailCall<Integer> addRec(int x, int y) {
  return y == 0
      ? ret(x)
      : sus(() -> addRec(x + 1, y - 1));
}

现在你可以像原始方法一样调用 add 方法。你可以通过提供静态工厂方法来实例化 ReturnSuspend,使你的递归 API 更易于使用,这也允许你将 ReturnSuspend 的内部子类设置为私有:

public static <T> Return<T> ret(T t) {
  return new Return<>(t);
}

public static <T> Suspend<T> sus(Supplier<TailCall<T>> s) {
  return new Suspend<>(s);
}

以下列表显示了完整的 TailCall 类。它添加了一个无参数的私有构造函数,以防止其他类扩展。

列表 4.3. 完整的 TailCall
public abstract class TailCall<T> {

  public abstract TailCall<T> resume();
  public abstract T eval();
  public abstract boolean isSuspend();

  private TailCall() {}

  private static class Return<T> extends TailCall<T> {

    private final T t;

    private Return(T t) {
      this.t = t;
    }

    @Override
    public T eval() {
      return t;
    }

    @Override
    public boolean isSuspend() {
      return false;
    }
    @Override
    public TailCall<T> resume() {
      throw new IllegalStateException("Return has no resume");
    }
  }

  private static class Suspend<T> extends TailCall<T> {

    private final Supplier<TailCall<T>> resume;

    private Suspend(Supplier<TailCall<T>> resume) {
      this.resume = resume;
    }

    @Override
    public T eval() {
      TailCall<T> tailRec = this;
      while(tailRec.isSuspend()) {
        tailRec = tailRec.resume();
      }
      return tailRec.eval();
    }

    @Override
    public boolean isSuspend() {
      return true;
    }

    @Override
    public TailCall<T> resume() {
      return resume.get();
    }
  }

  public static <T> Return<T> ret(T t) {
    return new Return<>(t);
  }

  public static <T> Suspend<T> sus(Supplier<TailCall<T>> s) {
    return new Suspend<>(s);
  }
}

现在你已经有一个堆栈安全的尾递归方法,你能用函数做到同样的事情吗?

4.2. 使用递归函数

理论上,如果函数作为匿名类中的方法实现,递归函数的创建不应该比方法更困难。但 lambda 并不是在匿名类中作为方法实现的。

第一个问题在于,从理论上讲,lambda 表达式不能是递归的。但这只是理论。实际上,你在第二章中学习了一个技巧来绕过这个问题。一个静态定义的递归 add 函数看起来是这样的:

static Function<Integer, Function<Integer, TailCall<Integer>>> add =
    a -> b -> b == 0
        ? ret(a)
        : sus(() -> ContainingClass.add.apply(a + 1).apply(b - 1));

在这里,ContainingClass 代表定义函数的类的名称。或者你可能更喜欢一个实例函数而不是静态函数:

Function<Integer, Function<Integer, TailCall<Integer>>> add =
    a -> b -> b == 0
        ? ret(a)
        : sus(() -> this.add.apply(a + 1).apply(b - 1));

但在这里,你遇到了与 add 方法相同的问题。你必须对结果调用 eval。你可以使用同样的技巧,与递归实现一起使用一个辅助方法。但你应该使整个事情自包含。在其他语言中,例如 Scala,你可以在主函数内部局部定义辅助函数。你能在 Java 中做到同样的事情吗?

4.2.1. 使用局部定义的函数

在 Java 中,在函数内部定义一个函数是不可能的。但一个写成 lambda 表达式的函数是一个类。你能在那个类中定义一个局部函数吗?实际上,你不能。你不能使用静态函数,因为局部类不能有静态成员,而且它们没有名字。你能使用实例函数吗?不,因为你需要一个对 this 的引用。而 lambda 表达式和匿名类之间的一个区别是 this 引用。与引用匿名类实例不同,lambda 表达式中使用的 this 引用指向封装实例。

解决方案是声明一个包含实例函数的局部类,如下所示。

列表 4.4. 一个独立的尾递归函数

这个函数可以用作一个普通函数:

add.apply(3).apply(100000000)

4.2.2. 使函数尾递归

之前,我说过,一个简单的递归函数,用于计算列表中元素的总和,不能安全地处理,因为它不是尾递归:

static Integer sum(List<Integer> list) {
  return list.isEmpty()
      ? 0
      : head(list) + sum(tail(list));
}

你看到你需要将方法转换如下:

static Integer sum(List<Integer> list) {
    return sumTail(list, 0);
}

static Integer sumTail(List<Integer> list, int acc) {
  return list.isEmpty()
      ? acc
      : sumTail(tail(list), acc + head(list));
}

原则相当简单,尽管有时应用起来可能有些棘手。它包括使用一个累加器来保存计算的结果。这个累加器被添加到方法参数中。然后,函数被转换为一个辅助方法,由原始方法调用,并使用累加器的初始值。这个过程几乎要成为本能,因为每次你想编写递归方法或函数时,你都需要使用它。

将一个方法变成两个方法可能是可以的。毕竟,方法不会移动,所以你只需要将主方法设为公共的,将辅助方法(执行工作的那个)设为私有的。对于函数来说也是如此,因为主函数对辅助函数的调用是一个闭包。相比于私有辅助方法,更倾向于使用局部定义的辅助函数的主要原因是为了避免名称冲突。

在允许局部定义函数的语言中,当前的做法是使用单个名称调用所有辅助函数,例如goprocess。对于非局部函数来说,这是不可能的(除非每个类中只有一个函数)。在先前的例子中,sum的辅助函数被命名为sumTail。另一种当前的做法是使用与主函数相同的名称调用辅助函数,并在后面添加下划线,例如sum_。无论你选择哪种系统,保持一致性都是有用的。在这本书的其余部分,我将使用下划线来表示尾递归辅助函数。

4.2.3. 双重递归函数:斐波那契数列示例

任何关于递归函数的书籍都无法避免斐波那契数列函数。虽然这对我们大多数人来说毫无用处,但它无处不在且很有趣。让我们从需求开始,以防你从未遇到过这个函数。

斐波那契数列是一组数字,每个数字都是前两个数字的和。这是一个递归定义。你需要一个终止条件,所以完整的需求如下:

  • f (0) = 0

  • f (1) = 1

  • f (n) = f (n – 1) + f (n – 2)

这不是原始的斐波那契数列,其中前两个数等于 1。每个数都应该是其在该数列中的位置的函数,而这个位置从 1 开始。在计算机科学中,你通常更喜欢从 0 开始。无论如何,这并不改变问题。

为什么这个函数如此有趣?我们不急于回答这个问题,而是尝试一个朴素实现:

public static int fibonacci(int number) {
  if (number == 0 || number == 1) {
    return number;
  }
  return fibonacci(number - 1) + fibonacci(number - 2);
}

现在我们来编写一个简单的程序来测试这个方法:

public static void main(String args[]) {
  int n = 10;
  for(int i = 0; i <= n; i++){
    System.out.print(fibonacci(i) +" ");
  }
}

如果你运行这个测试程序,你会得到前 10 个(或根据原始定义的 9 个)斐波那契数:

0 1 1 2 3 5 8 13 21 34 55

基于你对 Java 中朴素递归的了解,你可能认为这个方法在计算f(n)时,对于n的值达到 6,000 到 7,000 之前会因栈溢出而失败。好吧,让我们来验证一下。将int n = 10替换为int n = 6000,看看会发生什么。启动程序,喝杯咖啡休息一下。当你回来时,你会发现程序仍在运行。它将达到大约 1,836,311,903(你的结果可能会有所不同——你可能会得到一个负数!),但它永远不会结束。没有栈溢出,没有异常——只是在野外悬挂。发生了什么?

问题在于每次函数调用都会创建两个递归调用。所以为了计算f(n),你需要 2n次递归调用。假设你的方法需要 10 纳秒来执行。(只是猜测,但很快你就会看到这不会改变任何事情。)计算f(5000)将需要 2⁵⁰⁰⁰ × 10 纳秒。你有什么想法吗?这个程序永远不会终止,因为它需要比太阳系(如果不是宇宙!)预期的持续时间更长的时间。

要创建一个可用的斐波那契函数,你必须将其修改为使用单个尾递归调用。还有一个问题:结果数值太大,你很快就会得到算术溢出,导致出现负数。

练习 4.1

创建斐波那契函数方法的尾递归版本。

提示

累加器解决方案是可行的。但是有两个递归调用,所以你需要两个累加器。

解答 4.1

让我们先写出辅助方法的签名。它将接受两个 BigInteger 实例作为累加器,一个用于原始参数,并返回一个 BigInteger

private static BigInteger fib_(BigInteger acc1, BigInteger acc2,
                                                BigInteger x) {

你必须处理终端条件。如果参数是 0,你返回 0

private static BigInteger fib_(BigInteger acc1, BigInteger acc2,
                                                BigInteger x) {
  if (x.equals(BigInteger.ZERO)) {
    return BigInteger.ZERO;

如果参数是 1,你返回两个累加器的和:

private static BigInteger fib_(BigInteger acc1, BigInteger acc2,
                                                BigInteger x) {
  if (x.equals(BigInteger.ZERO)) {
    return BigInteger.ZERO;
  } else if (x.equals(BigInteger.ONE)) {
    return acc1.add(acc2);

最终,你必须处理递归。你必须做以下事情:

  • 将累加器 2 变为累加器 1。

  • 通过将前两个累加器相加创建一个新的累加器 2。

  • 从参数中减去 1。

  • 递归调用函数,其参数为三个计算出的值。

以下是代码的转录:

private static BigInteger fib_(BigInteger acc1, BigInteger acc2,
                                                BigInteger x) {
  if (x.equals(BigInteger.ZERO)) {
    return BigInteger.ZERO;
  } else if (x.equals(BigInteger.ONE)) {
    return acc1.add(acc2);
  } else {
    return fib_(acc2, acc1.add(acc2), x.subtract(BigInteger.ONE));
  }
}

最后要做的事情是创建主方法,该方法使用累加器的初始值调用此辅助方法:

public static BigInteger fib(int x) {
  return fib_(BigInteger.ONE, BigInteger.ZERO, BigInteger.valueOf(x));
}

这只是可能的一种实现。你可以以稍微不同的方式组织累加器、初始值和条件,只要它有效。现在你可以调用 fib(5000),它将在几纳秒内给出结果。好吧,它将花费几十毫秒,但这只是因为打印到控制台是一个慢操作。我们很快就会回到这个问题。

结果令人印象深刻,无论是计算结果(1,045 位数字!)还是由于将双重递归调用转换为单个调用而带来的速度提升。但你仍然不能使用大于 7,500 的值。

练习 4.2

将此方法转换为栈安全的递归方法。

解答 4.2

这应该很简单。以下代码显示了所需的变化:

BigInteger fib(int x) {
  return fib_(BigInteger.ONE, BigInteger.ZERO,
                              BigInteger.valueOf(x)).eval();
}

TailCall<BigInteger> fib_(BigInteger acc1, BigInteger acc2, BigInteger x) {
  if (x.equals(BigInteger.ZERO)) {
    return ret(BigInteger.ZERO);
  } else if (x.equals(BigInteger.ONE)) {
    return ret(acc1.add(acc2));
  } else {
    return sus(() -> fib_(acc2, acc1.add(acc2), x.subtract(BigInteger.ONE)));
  }
}

你现在可以计算 fib(10000) 并计算结果中的数字数量!

4.2.4. 使列表方法栈安全和递归

在上一章中,你开发了用于处理列表的功能方法。其中一些方法是原始递归的,因此不能在生产中使用。现在是时候修复这个问题了。

练习 4.3

创建一个栈安全的 foldLeft 方法递归版本。

解答 4.3

foldLeft 方法的原始递归版本是尾递归的:

public static <T, U> U foldLeft(List<T> ts, U identity,
                                Function<U, Function<T, U>> f) {
  return ts.isEmpty()
      ? identity
      : foldLeft(tail(ts), f.apply(identity).apply(head(ts)), f);
}

将其转换为完全递归方法很容易:

public static <T, U> U foldLeft(List<T> ts, U identity,
                                Function<U, Function<T, U>> f) {
  return foldLeft_(ts, identity, f).eval();
}

private static <T, U> TailCall<U> foldLeft_(List<T> ts, U identity,
                                    Function<U, Function<T, U>> f) {
  return ts.isEmpty()
      ? ret(identity)
      : sus(() -> foldLeft_(tail(ts),
                        f.apply(identity).apply(head(ts)), f));
}

练习 4.4

创建递归 range 方法的完全递归版本。

提示

注意列表构建的方向(appendprepend)。

解答 4.4

range 方法不是尾递归的:

public static List<Integer> range(Integer start, Integer end) {
  return end <= start
      ? list()
      : prepend(start, range(start + 1, end));
}

你必须首先创建一个使用累加器的尾递归版本。在这里,你需要返回一个列表,所以累加器将是一个列表,并且你将从一个空列表开始。但你必须以相反的顺序构建列表:

public static List<Integer> range(List<Integer> acc,
                                  Integer start, Integer end) {
  return end <= start
      ? acc
      : range(append(acc, start), start + 1, end);
}

然后你必须通过使用真正的递归来将这个方法转换为主方法和辅助方法:

public static List<Integer> range(Integer start, Integer end) {
  return range_(list(), start, end).eval();
}

private static TailCall<List<Integer>> range_(List<Integer> acc,
                                              Integer start, Integer end) {
  return end <= start
      ? ret(acc)
      : sus(() -> range_(append(acc, start), start + 1, end));
}

你必须反转操作的事实很重要。你能看出为什么吗?如果不能,尝试下一个练习。

练习 4.5(困难)

创建一个安全的递归版本的 foldRight 方法。

解决方案 4.5

foldRight 方法的基于栈的递归版本如下:

public static <T, U> U foldRight(List<T> ts, U identity,
                                 Function<T, Function<U, U>> f) {
  return ts.isEmpty()
      ? identity
      : f.apply(head(ts)).apply(foldRight(tail(ts), identity, f));
}

这个方法不是尾递归的,所以让我们首先创建一个尾递归版本。你可能会得到以下结果:

public static <T, U> U foldRight(U acc, List<T> ts, U identity,
                                 Function<T, Function<U, U>> f) {
  return ts.isEmpty()
      ? acc
      : foldRight(f.apply(head(ts)).apply(acc), tail(ts), identity, f);
}

很遗憾,这不起作用!你能看出为什么吗?如果不能,测试这个版本,并将结果与标准版本进行比较。你可以通过使用前一章设计的测试来比较这两个版本:

public static String addIS(Integer i, String s) {
  return "(" + i + " + " + s + ")";
}

List<Integer> list = list(1, 2, 3, 4, 5);
System.out.println(foldRight(list, "0", x -> y -> addIS(x, y)));
System.out.println(foldRightTail("0", list, "0", x -> y -> addIS(x, y)));

你会得到以下结果:

(1 + (2 + (3 + (4 + (5 + 0)))))
(5 + (4 + (3 + (2 + (1 + 0)))))

这表明列表是按逆序处理的。一个简单的解决方案是在调用辅助方法之前在主方法中反转列表。如果你在使方法安全递归的同时应用这个技巧,你会得到以下结果:

public static <T, U> U foldRight(List<T> ts, U identity,
                                 Function<T, Function<U, U>> f) {
  return foldRight_(identity, reverse(ts), f).eval();
}

private static <T, U> TailCall<U> foldRight_(U acc, List<T> ts,
                                          Function<T, Function<U, U>> f) {
  return ts.isEmpty()
      ? ret(acc)
      : sus(() -> foldRight_(f.apply(head(ts)).apply(acc), tail(ts), f));
}

在第五章 [kindle_split_012.xhtml#ch05] 中,你将通过实现 foldLeft 来实现反转列表的过程,以及 foldRight 来实现 foldLeft。但这表明 foldRight 的递归实现不会是最优的,因为 reverse 是一个 O(n) 操作:执行它所需的时间与列表中的元素数量成正比,因为你必须遍历列表。通过使用 reverse,你通过遍历列表两次而将这个时间加倍。结论是,在考虑使用 fold-Right 时,你应该做以下之一:

  • 不关心性能

  • (如果可能)更改函数并使用 foldLeft

  • 只使用小列表的 foldRight

  • 使用命令式实现

4.3. 组合大量函数

在第二章 [kindle_split_009.xhtml#ch02] 中,你看到如果你尝试组合大量函数,你会溢出栈。原因与递归相同:因为组合函数会导致方法调用方法。

需要组合超过 7,000 个函数可能不是你很快就会期望做的事情。另一方面,没有理由不使其成为可能。如果可能,最终有人会发现用它做些有用的事情。如果它没有用,有人肯定会找到一些有趣的事情来做。

练习 4.6

编写一个函数,composeAll,它接受一个从 TT 的函数列表作为其参数,并返回列表中所有函数的组合结果。

解决方案 4.6

要得到你想要的结果,你可以使用右折叠,将函数列表、通过静态导入的 Function.identity() 方法获得的 identity 函数和第二章 [kindle_split_009.xhtml#ch02] 中编写的 compose 方法作为其参数:

static <T> Function<T, T> composeAll(List<Function<T, T>> list) {
  return foldRight(list, identity(), x -> y -> x.compose(y));
}

要测试这个方法,你可以静态导入你的 Collection-Utilities 类(在第三章 [kindle_split_010.xhtml#ch03] 中开发)中的所有方法,并编写以下内容:

Function<Integer, Integer> add = y -> y + 1;
System.out.println(composeAll(map(range(0, 500), x -> add)).apply(0));

如果你对这个类型的代码感到不舒服,它等同于,但比这个更易读:

List<Function<Integer, Integer>> list = new ArrayList<>();
for (int i = 0; i < 500; i++) {
  list.add(x -> x + 1);
}

int result = composeAll(list).apply(0);
System.out.println(result);

运行这段代码显示 500,因为它是通过组合 500 个函数,每次递增它们的参数 1 得到的。如果你将 500 替换为 10,000 会发生什么?你会得到一个StackOverflowException。原因应该是显而易见的。

顺便说一下,在我用于这个测试的机器上,程序在 2,856 个函数的列表上崩溃了。

练习 4.7

修复这个问题,这样你就可以组合(几乎)无限数量的函数。

解决方案 4.7

这个问题的解决方案很简单。你不必通过嵌套函数来组合函数,而必须组合它们的结果,始终保持在更高的级别。这意味着在每次调用函数之间,你将返回到原始调用者。如果这还不清楚,想象一下强制性的方法来做这件事:

T y = identity;

for (Function<T, T> f : list) {
  y = f.apply(y);
}

在这里,identity表示给定函数的单位元素。这不是组合函数,而是组合函数应用。在循环结束时,你会得到一个T而不是Function<T, T>。但这很容易修复。你创建一个从TT的函数,其实现如下:

你不能直接使用x,因为它会创建一个闭包,所以它应该是一个有效的最终值。这就是为什么你需要复制它。这段代码运行良好,除了两件事。第一点是它看起来不像是函数式的。这可以通过使用折叠来轻松修复。它可以是左折叠或右折叠:

<T> Function<T, T> composeAllViaFoldLeft(List<Function<T, T>> list) {
  return x -> foldLeft(list, x, a -> b -> b.apply(a));
}

<T> Function<T, T> composeAllViaFoldRight(List<Function<T, T>> list) {
  return x -> foldRight(list, x, a -> a::apply);
}

你在composeAllViaFoldRight实现中使用了方法引用。这等同于以下内容:

<T> Function<T, T> composeAllViaFoldRight(List<Function<T, T>> list) {
  return x -> FoldRight.foldRight(list, x, a -> b -> a.apply(b));
}

如果你难以理解它是如何工作的,想想与sum的类比。当你定义sum时,列表是一个整数列表。初始值(这里的x)是0ab是两个要加的参数;加法被定义为a + b。在这里,列表是一个函数列表;初始值是单位函数;ab是函数;实现被定义为b.apply(a)a.apply(b)。在foldLeft版本中,b是从列表中来的函数,而a是当前结果。在foldRight版本中,a是从列表中来的函数,而b是当前结果。

要看到这个动作,请参考从书籍网站提供的代码中的单元测试(github.com/fpinjava/fpinjava)。

练习 4.8

这段代码有两个问题,而你只修复了一个。你能看到另一个问题并修复它吗?

提示

第二个问题在结果中不可见,因为你在组合的函数是特定的。实际上,它们是从整数到整数的单个函数。它们组合的顺序无关紧要。尝试使用以下函数列表的composeAll方法:

Function<String, String> f1 = x -> "(a" + x + ")";
Function<String, String> f2 = x -> "{b" + x + "}";
Function<String, String> f3 = x -> "[c" + x + "]";
System.out.println(composeAllViaFoldLeft(list(f1, f2, f3)).apply("x"));
System.out.println(composeAllViaFoldRight(list(f1, f2, f3)).apply("x"));

解决方案 4.8

我们实现了andThenAll而不是composeAll!为了得到正确的结果,你首先必须反转列表:

<T> Function<T, T> composeAllViaFoldLeft(List<Function<T, T>> list) {
  return x -> foldLeft(reverse(list), x, a -> b -> b.apply(a));
}

<T> Function<T, T> composeAllViaFoldRight(List<Function<T, T>> list) {
  return x -> foldRight(list, x, a -> a::apply);
}

<T> Function<T, T> andThenAllViaFoldLeft(List<Function<T, T>> list) {
  return x -> foldLeft(list, x, a -> b -> b.apply(a));
}

<T> Function<T, T> andThenAllViaFoldRight(List<Function<T, T>> list) {
  return x -> foldRight(reverse(list), x, a -> a::apply);
}

4.4. 使用记忆化

在 第 4.2.3 节 中,你实现了一个函数来显示斐波那契数列的一系列数字。斐波那契数列实现的一个问题是,你想要打印表示序列直到 f(n) 的字符串,这意味着你必须计算 f(1)f(2) 等等,直到 f(n)。但为了计算 f(n),你必须递归地计算所有先前值的函数。最终,为了创建到 n 的序列,你将计算 f(1) n 次,f(2) n – 1 次,依此类推。那么,总的计算次数将是整数 1 到 n 的和。你能做得更好吗?你能否可能将计算过的值保存在内存中,这样在需要多次使用时就不必再次计算它们?

4.4.1. 命令式编程中的记忆化

在命令式编程中,你甚至不会遇到这个问题,因为明显的处理方式如下:

图片

尽管这个程序集中了 FP 应该避免或解决的问题,但它运行良好,并且比你的函数式版本更高效。原因是记忆化。

记忆化 是一种技术,它将计算结果保存在内存中,以便在将来必须重新执行相同的计算时可以立即返回。应用于函数,记忆化使得函数记住之前调用的结果,因此如果它们再次以相同的参数调用,它们可以更快地返回结果。

这可能看起来与函数式原则不兼容,因为记忆化的函数维护了一个状态。但实际上并非如此,因为当它以相同的参数调用时,函数的结果是相同的。(你甚至可以争论说它更相同,因为它不再重新计算了!)存储结果的副作用必须从函数外部不可见。

在命令式编程中,这甚至可能不会被注意到。维护状态是计算结果的通用方式,因此记忆化甚至不会被注意到。

4.4.2. 递归函数中的记忆化

递归函数通常隐式地使用记忆化。在你的递归斐波那契函数示例中,你想要返回序列,所以你计算序列中的每个数字,导致不必要的重新计算。一个简单的解决方案是重写函数,以便直接返回表示序列的字符串。

练习 4.9

编写一个安全的尾递归函数,该函数接受一个整数 n 作为其参数,并返回一个表示从 0n 的斐波那契数列值的字符串,值之间用逗号和空格分隔。

提示

一种解决方案是使用 StringBuilder 作为累加器。StringBuilder 不是一个函数式结构,因为它可变,但这种变化对外部是不可见的。另一种解决方案是返回一个数字列表,然后将其转换为 String。这个解决方案更容易,因为你可以通过首先返回一个列表,然后编写一个函数将列表转换为以逗号分隔的字符串来抽象分隔符的问题。

解决方案 4.9

以下列表展示了使用 List 作为累加器的解决方案。

列表 4.5. 带隐式记忆化的递归斐波那契

递归或核心递归?

这个例子演示了隐式记忆化的使用。不要认为这是解决问题的最佳方式。当问题被扭曲时,许多问题都更容易解决。所以让我们来扭曲这个问题。

你可以将斐波那契数列看作是一系列对(元组),而不是一系列数字。而不是尝试生成这个,

0, 1, 1, 2, 3, 5, 8, 13, 21, ...

你可以尝试产生这个:

(0, 1), (1, 1), (1, 2), (2, 3), (3, 5), (5, 8), (8, 13), (13, 21), ...

在这个系列中,每个元组都可以从前一个元组构建。元组 n 的第二个元素成为元组 n + 1 的第一个元素。元组 n + 1 的第二个元素等于元组 n 的两个元素之和。在 Java 中,你可以为这个操作编写一个函数:

x -> new Tuple<>(x._2, x._1.add(x._2));

你现在可以用核心递归方法替换递归方法:

public static String fiboCorecursive(int number) {
  Tuple<BigInteger, BigInteger> seed =
                           new Tuple<>(BigInteger.ZERO, BigInteger.ONE);
  Function<Tuple<BigInteger, BigInteger>,Tuple<BigInteger, BigInteger>> f =
                                    x -> new Tuple<>(x._2, x._1.add(x._2));
  List<BigInteger> list = map(List.iterate(seed, f, number + 1), x -> x._1);
  return makeString(list, ", ");
}

iterate 方法接受一个种子、一个函数和一个数字 n,通过将函数应用于每个元素来计算下一个元素,创建一个长度为 n 的列表。这是它的签名:

public static <B> List<B> iterate(B seed, Function<B, B> f, int n)

这个方法在 fpinjava-common 模块中可用。

4.4.3. 自动记忆化

记忆化不仅仅用于递归函数。它可以用于加速任何函数。想想你是如何进行乘法的。如果你需要将 234 乘以 686,你可能需要笔和纸,或者计算器。但如果你被要求将 9 乘以 7,你可以立即回答,而不需要进行任何计算。这是因为你使用了记忆化的乘法。记忆化的函数以同样的方式工作,尽管它只需要进行一次计算来保留结果。

想象你有一个函数式方法 doubleValue,它将其参数乘以 2:

Integer doubleValue(Integer x) {
  return x * 2;
}

你可以通过将结果存储到映射中来实现对这个方法的记忆化:

在 Java 8 中,这可以缩短很多:

Map<Integer, Integer> cache = new ConcurrentHashMap<>();

Integer doubleValue(Integer x) {
  return cache.computeIfAbsent(x, y -> y * 2);
}

如果你更喜欢使用函数(鉴于本书的主题,这很可能是你想要的),你可以应用同样的原则:

Function<Integer, Integer> doubleValue = 
                      x -> cache.computeIfAbsent(x, y -> y * 2);

但出现了两个问题:

  • 你必须为所有你想记忆化的函数重复此修改。

  • 你使用的映射暴露在外部。

第二个问题很容易解决。你可以将方法或函数放在一个单独的类中,包括映射,并使用私有访问。以下是一个方法的例子:

public class Doubler {

  private static Map<Integer, Integer> cache = new ConcurrentHashMap<>();

  public static Integer doubleValue(Integer x) {
    return cache.computeIfAbsent(x, y -> y * 2);
  }
}

你可以实例化这个类,并在每次需要计算一个值时使用它:

Integer y = Doubler.doubleValue(x);

使用这种解决方案,映射不再可以从外部访问。你不能对函数做同样的事情,因为函数不能有静态成员。一种可能性是将映射作为额外的参数传递给函数。这可以通过闭包来完成:

class Doubler {
  private static Map<Integer, Integer> cache = new ConcurrentHashMap<>();

  public static Function<Integer, Integer> doubleValue =
                               x -> cache.computeIfAbsent(x, y -> y * 2);
}

你可以使用此函数如下:

Integer y = Doubler.doubleValue.apply(x);

这与方法解决方案相比没有优势。但你也可以在更符合习惯的例子中使用这个函数,例如这个:

map(range(1, 10), Doubler.doubleValue);

这与使用以下语法的函数版本等效:

map(range(1, 10), Doubler::doubleValue);
要求

你需要的是一种方法来做以下事情:

Function<Integer, Integer> f = x -> x * 2;
Function<Integer, Integer> g = Memoizer.memoize(f);

然后,你可以使用缓存函数作为原始函数的替代品。函数g返回的所有值第一次都会通过原始函数f计算,并在后续访问中从缓存中返回。相比之下,如果你创建第三个函数,

Function<Integer, Integer> f = x -> x * 2;
Function<Integer, Integer> g = Memoizer.memoize(f);
Function<Integer, Integer> h = Memoizer.memoize(f);

g缓存的值不会被h返回;gh将使用单独的缓存。

实现

Memoizer类很简单,如下所示。

列表 4.6. Memoizer

以下列表显示了如何使用这个类。程序模拟了长时间的计算,以显示缓存函数的结果。

列表 4.7. 展示缓存器

在我的电脑上运行automaticMemoizationExample方法会产生以下结果:

2
2
1000
0

注意,确切的结果将取决于你电脑的速度。

现在你可以通过调用一个单一的方法将普通函数转换为缓存函数,但要在生产环境中使用这项技术,你必须处理潜在的内存问题。如果可能的输入数量很少,这段代码是可以接受的,因此你可以将所有结果保存在内存中而不会导致内存溢出。否则,你可以使用软引用或弱引用来存储缓存值。

“多参数”函数的缓存

如我之前所说,这个世界上没有多参数的函数。函数是将一个集合(源集合)应用于另一个集合(目标集合)的应用。它们不能有多个参数。看似有多个参数的函数是以下之一:

  • 元组函数

  • 返回函数的函数……返回结果

在任何情况下,你只关心单参数的函数,因此你可以轻松地使用你的Memoizer类。

使用元组的函数可能是最简单的选择。你可以使用前面章节中编写的Tuple类,但为了在映射中存储元组,你必须实现equalshashcode。除此之外,你还必须定义两个元素的元组(对),三个元素的元组,等等。谁知道在哪里停止?

第二种选择要简单得多。你必须使用函数的柯里化版本,就像你在前面的章节中所做的那样。缓存柯里化函数很容易,尽管你不能使用之前的那种简单形式。你必须缓存每个函数:

Function<Integer, Function<Integer, Integer>> mhc =
                                  Memoizer.memoize(x ->
                                          Memoizer.memoize(y -> x + y));

你可以使用相同的技巧来记忆化一个有三个参数的函数:

Function<Integer, Function<Integer, Function<Integer, Integer>>> f3 =
                                                x -> y -> z -> x + y - z;
Function<Integer, Function<Integer, Function<Integer, Integer>>> f3m =
                  Memoizer.memoize(x ->
                          Memoizer.memoize(y ->
                                  Memoizer.memoize(z -> x + y - z));

以下列表展示了使用这个记忆化三个参数的函数的例子。

列表 4.8. 测试三个参数的记忆化函数的性能
Function<Integer, Function<Integer, Function<Integer, Integer>>> f3m =
      Memoizer.memoize(x ->
              Memoizer.memoize(y ->
                      Memoizer.memoize(z ->
        longCalculation(x) + longCalculation(y) - longCalculation(z))));

  public void automaticMemoizationExample2() {
    long startTime = System.currentTimeMillis();
    Integer result1 = f3m.apply(2).apply(3).apply(4);
    long time1 = System.currentTimeMillis() - startTime;
    startTime = System.currentTimeMillis();
    Integer result2 = f3m.apply(2).apply(3).apply(4);
    long time2 = System.currentTimeMillis() - startTime;
    System.out.println(result1);
    System.out.println(result2);
    System.out.println(time1);
    System.out.println(time2);
  }

这个程序产生了以下输出:

2
2
3002
0

这表明第一次访问longCalculation方法花费了 3,000 毫秒,而第二次则立即返回。

另一方面,在定义了Tuple类之后,使用元组函数可能看起来更容易。以下列表展示了Tuple3的例子。

列表 4.9. Tuple3的实现
public class Tuple3<T, U, V> {

  public final T _1;
  public final U _2;
  public final V _3;

  public Tuple3(T t, U u, V v) {
    _1 = Objects.requireNonNull(t);
    _2 = Objects.requireNonNull(u);
    _3 = Objects.requireNonNull(v);
  }
  @Override
  public boolean equals(Object o) {
    if (!(o instanceof Tuple3)) return false;
    else {
      Tuple3 that = (Tuple3) o;
      return _1.equals(that._1) && _2.equals(that._2)
                                          && _3.equals(that._3);
    }
  }

  @Override
  public int hashCode() {
    final int prime = 31;
    int result = 1;
    result = prime * result + _1.hashCode();
    result = prime * result + _2.hashCode();
    result = prime * result + _3.hashCode();
    return result;
  }
}

以下列表展示了使用Tuple3作为参数的记忆化函数的测试例子。

列表 4.10. 一个记忆化的Tuple3函数
Function<Tuple3<Integer, Integer, Integer>, Integer> ft =
                    x -> longCalculation(x._1)
                                    + longCalculation(x._2)
                                              - longCalculation(x._3);
Function<Tuple3<Integer, Integer, Integer>, Integer> ftm =
                                               Memoizer.memoize(ft);

public void automaticMemoizationExample3() {
  long startTime = System.currentTimeMillis();
  Integer result1 = ftm.apply(new Tuple3<>(2, 3, 4));
  long time1 = System.currentTimeMillis() - startTime;
  startTime = System.currentTimeMillis();
  Integer result2 = ftm.apply(new Tuple3<>(2, 3, 4));
  long time2 = System.currentTimeMillis() - startTime;
  System.out.println(result1);
  System.out.println(result2);
  System.out.println(time1);
  System.out.println(time2);
}
记忆化函数是纯函数吗?

记忆化是关于在函数调用之间保持状态。一个记忆化的函数是一个其行为依赖于当前状态的函数。但它在相同的参数下总是会返回相同的值。只有返回值所需的时间会不同。所以,如果原始函数是纯函数,那么记忆化的函数仍然是一个纯函数。

时间上的变化可能是一个问题。像原始的斐波那契函数这样的函数可能需要很多年才能完成,可能被称为非终止的,所以时间的增加可能会造成问题。另一方面,使函数更快不应该是一个问题。如果是,那么其他地方可能有一个更大的问题!

4.5. 总结

  • 递归函数是定义为引用自身的函数。

  • 在 Java 中,递归方法在递归调用自身之前将当前计算状态推入栈中。

  • Java 的默认栈大小是有限的。它可以配置为更大的大小,但通常这会浪费空间,因为所有线程都使用相同的栈大小。

  • 尾递归函数是递归调用位于最后(尾端)位置的函数。

  • 在某些语言中,递归函数通过尾调用消除(TCE)进行优化。

  • Java 不实现 TCE,但可以模拟它。

  • Lambda 可以被递归。

  • 记忆化允许函数记住它们的计算值,以便加快后续访问。

  • 记忆化可以自动进行。

第五章. 使用列表处理数据

本章涵盖的内容

  • 在函数式编程中分类数据结构

  • 使用无处不在的单链表

  • 理解不可变性的重要性

  • 使用递归和函数处理列表

数据结构是编程以及日常生活中最重要的概念之一。我们所看到的世界本身就是一个巨大的数据结构,由更简单的数据结构组成,而这些更简单的结构又由更简单的结构组成。每次我们尝试对某个事物进行建模,无论是对象还是事实,最终都会得到数据结构。

存在许多类型的数据结构。在计算机科学中,数据结构通常由术语集合整体表示。集合是一组数据项,它们彼此之间有一定的关系。在最简单的形式中,这种关系是它们属于同一个组。

5.1. 如何分类数据集合

数据集合可以从许多不同的角度进行分类。您可以将数据集合分类为线性、关联和图:

  • 线性集合是元素沿着单一维度相关联的集合。在这样的集合中,每个元素都与下一个元素有关联。线性集合中最常见的例子是列表。

  • 关联集合是可以被视为函数的集合。给定一个对象o,函数f(o)将根据该对象是否属于集合返回truefalse。与线性集合不同,集合中的元素之间没有关系。这些集合是无序的,尽管可以在元素上定义一个顺序。关联集合中最常见的例子是集合和关联数组(也称为映射或字典)。我们将在第十一章(chapter 11)中研究映射的函数式实现。

  • 图是每个元素都与多个其他元素相关联的集合。一个特定的例子是树,更具体地说,是二叉树,其中每个元素都与另外两个元素相关联。您将在第十章(chapter 10)中从函数式角度了解更多关于树的内容。

5.1.1. 列表的类型

在本章中,我们将重点关注最常见的线性集合类型,即列表。列表是函数式编程中最常用的数据结构,因此通常用于教授函数式编程概念。然而,请注意,本章所学的知识并不仅限于列表,而是与其他许多数据结构(可能不是集合)共享的。

列表可以根据几个不同的方面进一步分类,包括以下内容:

  • 访问— 一些列表只能从一端访问,而其他列表可以从两端访问。一些可以从一端写入并从另一端读取。最后,一些列表可能允许通过其在列表中的位置访问任何元素;元素的位置也称为其索引

  • 排序类型— 在某些列表中,元素将以它们被插入的相同顺序被读取。这种结构被称为 FIFO(先进先出)。在其他列表中,检索顺序将是插入顺序的逆序(LIFO,或后进先出)。最后,一些列表将允许你以完全不同的顺序检索元素。

  • 实现— 访问类型和排序是与你为列表选择的实现方式紧密相关的概念。如果你选择通过将每个元素链接到下一个元素来表示列表,那么从访问的角度来看,你将得到与基于索引数组实现完全不同的结果。或者,如果你选择将每个元素同时链接到下一个元素和前一个元素,你将得到可以从两端访问的列表。

图 5.1 展示了提供不同类型访问的不同类型的列表。请注意,这个图显示了每种类型列表背后的原理,但没有显示列表的实现方式。

图 5.1. 不同类型的列表为它们的元素提供不同类型的访问。

5.1.2. 相对预期列表性能

在选择列表类型时,一个非常重要的标准是各种操作预期的性能。性能通常用大 O 符号表示。这种符号主要用在数学中,但用在计算机科学中,它表示算法的复杂度随输入大小变化的方式。当用来描述列表操作的性能时,这种符号显示了性能如何随列表长度的变化而变化。例如,考虑以下性能:

  • O(1)—这意味着操作所需的时间将是常数。(你可以将其视为一个元素的时间乘以 1,对于n个元素。)

  • O(log(n))—这意味着n个元素上的操作时间将是一个元素的时间乘以 log(n)。

  • O(n)—n个元素的时间将是单个元素的时间乘以n

  • O(n²)—n个元素的时间将是单个元素的时间乘以n²。

创建一个对所有类型操作都具有 O(1)性能的数据结构将是理想的。不幸的是,这至今尚未实现。每种类型的列表为不同的操作提供不同的性能。索引列表为数据检索提供 O(1)性能,对于插入接近 O(1)。单链表在一端提供 O(1)的插入和检索性能,而在另一端提供 O(n)。

选择最佳结构是一种折衷。大多数情况下,你将寻求对最频繁操作 O(1) 的性能,而对于不常发生的某些操作,你必须接受 O(log(n)) 或甚至 O(n)。

请注意,这种衡量性能的方式对于可以无限扩展的结构具有实际意义。然而,对于我们所操作的数据结构来说并非如此,因为你的结构大小受可用内存的限制。一个具有 O(n) 访问时间的结构可能总是比另一个具有 O(1) 访问时间的结构更快,这是由于这种大小限制。如果第一个结构中一个元素的时间远小于第二个结构,其内存限制可能会阻止第二个结构显示出其优势。通常,拥有 O(n) 性能且对单个元素的访问时间为 1 纳秒,比 O(1) 性能且访问时间为 1 毫秒要好。(后者只有在元素数量超过 1,000,000 时才会比前者快。)

5.1.3. 交易时间与内存空间,以及时间与复杂度

你刚刚看到,为数据结构选择实现通常是一个权衡时间的问题。你将选择在特定操作上更快,但在其他操作上较慢的实现,这取决于哪些操作是最频繁的。但还有其他需要做出的权衡决策。

假设你想要一个结构,可以从其中按顺序检索元素,从小到大。你可能选择在插入时对元素进行排序,或者你可能更喜欢按到达顺序存储它们,并在检索时仅搜索最小的元素。做出决策的一个重要标准将是检索的元素是否系统性地从结构中移除。如果不是,它可能被多次访问而不被移除,因此可能最好在插入时对元素进行排序,以避免在检索时多次排序。这种情况对应于所谓的 优先队列,其中你正在等待一个特定的元素。你可能需要多次测试队列,直到返回预期的元素。这种用例要求在插入时对元素进行排序。

但是,如果你想要通过几种不同的排序顺序来访问元素怎么办?例如,你可能希望以它们被插入的相同顺序或相反顺序来访问元素。结果可能与图 5.1 的双向链表相对应。在这种情况下,元素应该在检索时进行排序。你可能倾向于选择一个顺序,从而从一端提供 O(1)的访问时间,而从另一端提供 O(n)的访问时间,或者你可能发明一个不同的结构,可能从两端提供 O(log(n))的访问时间。另一个解决方案是存储两个列表,一个按插入顺序,一个按相反顺序。这样,你会有更慢的插入时间,但两端都有 O(1)的检索时间。一个缺点是,这种方法可能会使用更多的内存。因此,你可以看到,选择正确的结构也可能是一个权衡时间与内存空间的问题。

但是,你也可能发明一些结构,最小化从两端插入和检索的时间。这些类型的结构已经被发明出来,你只需要实现它们,但这样的结构比最简单的结构要复杂得多,所以你会牺牲时间来换取复杂性。

5.1.4. 原地突变

大多数数据结构会随着时间的推移而改变,因为元素会被插入和删除。基本上,处理这类操作有两种方式。第一种是原地更新

原地更新包括通过修改数据结构本身来更改数据结构的元素。当所有程序都是单线程的时候,这曾经被认为是一个好主意,尽管实际上并不是。现在所有程序都是多线程的,这更糟糕。这不仅关系到替换元素,对于添加或删除、排序以及所有修改结构的操作也是如此。如果程序被允许修改数据结构,那么这些结构在缺乏复杂且很少第一次就做对的保护措施的情况下是无法共享的,从而导致死锁、活锁、线程饥饿、过时数据以及所有这些麻烦。

那么,解决方案是什么?简单来说,就是使用不可变数据结构。许多命令式程序员在第一次读到这一点时都会感到震惊。如果你不能修改数据结构,你如何用它们做有用的事情呢?毕竟,你通常从一个空的结构开始,并希望向其中添加数据。如果它们是不可变的,你如何可能做到这一点呢?

原地更新

在 1981 年一篇题为“事务概念:优点和局限性”的文章中,Jim Gray 写道:^([a])

^a

Jim Gray,“事务概念:优点和局限性”(Tandem Computers,技术报告 81.3,1981 年 6 月),www.hpl.hp.com/techreports/tandem/TR-81.3.pdf.

原地更新:一个毒苹果?

当使用粘土板或纸张和墨水进行账目管理时,会计师们制定了一些关于良好会计实践的明确规则。其中一条基本规则是复式记账,这样计算可以自我校验,从而实现快速失败。第二条规则是永远不更改账簿;如果出错,则进行标注并在账簿中做出新的补偿性记录。因此,账簿是商业交易完整的历史记录...

在地更新(Update-in-place)在很多系统设计师看来是一种严重的罪行:它违反了数百年来一直观察到的传统会计实践。

答案很简单。与复式记账一样,而不是改变之前存在的内容,你创建新的数据来表示新的状态。而不是向现有列表中添加一个元素,你创建一个新的列表,包含这个添加的元素。主要好处是,如果另一个线程在插入时正在操作列表,它不会受到变化的影响,因为它看不到这个变化。

通常,这种观念会立即引起两个抗议:

  • 如果其他线程没有看到这个变化,它正在操作过时的数据。

  • 使用添加的元素创建新的列表副本是一个耗时且消耗内存的过程,因此不可变数据结构会导致非常差的性能。

这两个论点都是错误的。操作“过时数据”的线程实际上是在操作它开始读取时的数据。如果插入元素发生在操作完成后,就不会出现并发问题。但如果插入操作在操作进行时发生,可变数据结构会发生什么?要么它不会保护并发访问,数据可能会被破坏或结果错误(或两者兼而有之),或者某种保护机制会锁定数据,直到第一个线程的操作完成后才进行插入。在后一种情况下,最终结果将与不可变结构完全相同。

5.1.5. 持久数据结构

正如您在上一节中看到的,在插入元素之前对数据结构进行复制(有时称为防御性复制)通常被认为是一种耗时且性能较差的操作。如果你使用数据共享,情况并非如此,因为不可变数据结构是持久的。图 5.2 展示了如何移除和添加元素以创建一个新的、不可变的、单链表,并具有最佳性能。

图 5.2. 不进行变异或复制地移除和添加元素

如您所见,根本不会发生任何复制。结果是,这种列表在移除和插入元素方面可能比可变列表更高效。因此,函数式数据结构(不可变和持久)并不总是比可变结构慢。它们通常甚至更快(尽管在某些操作上可能较慢)。无论如何,它们的安全性要高得多。

5.2. 一个不可变、持久的单链表实现

图 5.1 和 5.2 中所示的单链表结构是理论上的。列表不能以这种方式实现,因为元素不能相互链接。它们必须是特殊的元素以允许链接,而您希望您的列表能够存储任何元素。解决方案是设计一个由以下组成的递归列表结构:

  • 将成为列表第一个元素的元素,也称为

  • 列表的其余部分,它本身也是一个列表,称为

注意,您已经遇到了一个由两种不同类型的元素组成的泛型元素:Tuple。类型为 A 的元素的单链表实际上是一个 Tuple<A, List<A>>。然后您可以定义列表为

class List<A> extends Tuple<A, List<A>>

但正如我在 第四章 中解释的,您需要一个终止情况,就像在每一个递归定义中一样。按照惯例,这个终止情况被称为 Nil,对应于空列表。由于 Nil 没有头和尾,它不是一个 Tuple。您的新列表定义可以是

  • 空列表 (Nil)

  • 一个元素和列表的元组

而不是使用具有 _1_2 属性的 Tuple,您将创建一个具有两个属性(headtail)的特定 List 类。这将简化对 Nil 情况的处理。图 5.3 展示了您的列表实现的结构。

图 5.3. 单链表实现的表示

列表 5.1 展示了该列表的基本实现。

列表 5.1. 单链表

列表类被实现为一个抽象类。List 类包含两个私有静态子类来表示 List 可能采取的两种形式:Nil 用于空列表,Cons 用于非空列表。

List 类定义了三个抽象方法:head(),它将返回列表的第一个元素;tail(),它将返回列表的其余部分(不包括第一个元素);以及 isEmpty(),如果列表为空则返回 true,否则返回 falseList 类使用类型参数 A 进行参数化,它表示列表元素的类型。

子类已被设置为私有,因此您通过调用静态工厂方法来构建列表。这些方法可以静态导入:

import static fpinjava.datastructures.List.*;

然后,它们可以不引用封装的类而使用,如下所示:

List<Integer> ex1 = list();
List<Integer> ex2 = list(1);
List<Integer> ex3 = list(1, 2);

注意,空列表没有类型参数。换句话说,它是一个原始类型,可以用来表示任何类型的空元素列表。因此,创建或使用空列表将生成编译器警告。优点是你可以使用一个单例来表示空列表。另一个解决方案是使用参数化的空列表,但这会带来很多麻烦。你将不得不为每个类型参数创建一个不同的空列表。为了解决这个问题,你使用一个没有参数类型的单例空列表。这会生成编译器警告。为了将这个警告限制在 List 类中,而不是让它泄露到 List 用户那里,你不直接提供对单例的访问。这就是为什么有一个(参数化的)静态方法来访问单例,以及 @SuppressWarnings("rawtypes")NIL 属性上,以及 @SuppressWarnings("unchecked")list() 方法上。

注意,list(A ... a) 方法被注解为 @SafeVarargs,以表明该方法不会执行可能导致堆污染的操作。此方法使用基于 for 循环的命令式实现。这并不非常“函数式”,但这是简单性和性能之间的权衡。如果你坚持要以函数式方式实现它,你也可以做到。你所需要的是一个接受数组作为参数并返回其最后一个元素的函数,以及另一个返回没有最后一个元素的数组的函数。这里有一个可能的解决方案:

@SafeVarargs
public static <A> List<A> list(A... as) {
  return list_(list(), as).eval();
}

public static <A> TailCall<List<A>> list_(List<A> acc, A[] as) {
  return as.length == 0
      ? ret(acc)
      : sus(() -> list_(new Cons<>(as[as.length -1], acc),
          Arrays.copyOfRange(as, 0, as.length - 1)));
}

然而,务必不要使用这个实现,因为它比命令式实现慢 10,000 倍。这是一个很好的例子,说明何时不要盲目地追求函数式。命令式版本有一个函数式接口,这正是你所需要的。注意,递归并不是问题。使用 TailCall 的递归几乎和迭代一样快。这里的问题是 copyOfRange 方法,它非常慢。

5.3. 列表操作中的数据共享

单链表等不可变持久数据结构的一个巨大好处是数据共享带来的性能提升。你已经在列表的第一个元素访问上是即时的。这只是一个调用 head() 方法的简单操作,它是对 head 属性的简单访问器。

移除第一个元素同样快速。只需调用 tail() 方法,它将返回 tail 属性。现在让我们看看如何获取一个包含额外元素的新列表。

练习 5.1

实现实例函数式方法 cons,在列表的开始处添加一个元素。(记住 cons 代表 construct。)

解决方案 5.1

这个实例方法对 NilCons 子类有相同的实现:

public List<A> cons(A a) {
  return new Cons<>(a, this);
}

练习 5.2

实现 setHead,这是一个实例方法,用于用新值替换 List 的第一个元素。

解决方案 5.2

你可能会考虑实现一个静态方法来处理这个问题,但你将不得不测试空列表:

public static <A> List<A> setHead(List<A> list, A h) {
  if (list.isEmpty()) {
    throw new IllegalStateException("setHead called on an empty list");
  } else {
    return new Cons<>(h, list.tail());
  }
}

这几乎没有什么意义。一般来说,如果你发现自己被迫使用 if ... else 结构,你可能走错了路。想想你将如何实现调用此静态方法的实例方法。

一个更好的解决方案是在 List 类中添加一个抽象方法:

public abstract List<A> setHead(A h);

Nil 子类中的实现很简单。只需抛出一个异常,因为尝试访问空列表的头部被认为是错误:

public List<A> setHead(A h) {
  throw new IllegalStateException("setHead called on empty list");
}

Cons 实现对应于静态方法的 else 子句:

public List<A> setHead(A h) {
  return new Cons<>(h, tail());
}

如果你需要一个静态方法,它可以直接调用实例实现:

public static <A> List<A> setHead(List<A> list, A h) {
  return list.setHead(h);
}

练习 5.3

编写一个 toString 方法来显示列表的内容。空列表将显示为 "[NIL]",包含从 1 到 3 的整数的列表将显示为 "[1, 2, 3, NIL]"。对于任意对象的列表,将调用 toString 方法来显示每个对象。

解决方案 5.3

Nil 实现非常简单:

public String toString() {
  return "[NIL]";
}

cons 方法是递归的,并使用 StringBuilder 作为累加器。请注意,尽管 StringBuilder 是一个可变对象,但它有一个功能友好的 append 方法,因为它返回被修改的 StringBuilder 实例。

public String toString() {
  return String.format("[%sNIL]",
                       toString(new StringBuilder(), this).eval());
}
private TailCall<StringBuilder> toString(StringBuilder acc, List<A> list) {
  return list.isEmpty()
      ? ret(acc)
      : sus(() -> toString(acc.append(list.head()).append(", "),
                          list.tail()));
}

如果你记不住如何使用 TailCall 类从堆而不是从栈中使递归工作,请参阅第四章。

5.3.1. 更多列表操作

你可以依赖数据共享以非常高效的方式实现各种其他操作——通常比使用可变列表更高效。在本节的其余部分,你将基于数据共享向链表添加功能。

练习 5.4

tail 方法虽然不会以任何方式修改列表,但其效果与删除第一个元素相同。编写一个更通用的方法 drop,从列表中删除前 n 个元素。当然,这个方法不会删除元素,但会返回一个对应于预期结果的新列表。这个“新”列表实际上不会是新创建的,因为将使用数据共享,所以不会创建任何内容。图 5.4 展示了你应该如何进行。

图 5.4. 在不修改或创建任何内容的情况下删除列表的前 n 个元素。

方法的签名将是

public List<A> drop(int n);
提示

你应该使用递归来实现 drop 方法。并且不要忘记考虑每个特殊情况,例如空列表,或者 n 大于列表长度。

解决方案 5.4

这里,你有选择实现静态方法或实例方法。如果你想要使用对象表示法,实例方法就是必需的,因为这种表示法更容易阅读。例如,如果你想删除整数列表中的两个元素,然后将结果列表的第一个元素替换为 0,你可以使用静态方法:

List<Integer> newList = setHead(drop(list, 2), 0);

每次你向过程添加一个方法时,方法名被添加到左侧,而除了列表本身之外的其他参数被添加到右侧,如图图 5.5 所示。

图 5.5。没有对象表示法,组合函数可能难以阅读。使用对象表示法可以使代码更易于阅读。

使用对象表示法使代码更容易阅读:

List<Integer> newList = drop(list, 2).setHead(0);

Nil类中drop方法的实现简单地返回this

public List<A> drop(int n) {
  return this;
}

Cons类中,你使用一个私有辅助方法以与你在第四章中学到的方式相同来实现递归。此代码假定方法TailCall.retTailCall.sus已静态导入:

public List<A> drop(int n) {
  return n <= 0
      ? this
      : drop_(this, n).eval();
}

private TailCall<List<A>> drop_(List<A> list, int n) {
  return n <= 0 || list.isEmpty()
      ? ret(list)
      : sus(() -> drop_(list.tail(), n - 1));
}

注意,你必须测试空列表参数。如果drop方法是递归的,这就不必要了。但只有drop_辅助方法是递归的,而这个方法没有为Nil定义。忘记测试空列表会导致在调用list.tail()时抛出异常。当然,你需要一种更好的方法来处理这种情况。毕竟,从包含三个元素的列表中删除四个元素几乎没有意义。你可以抛出异常,但使用你将在下一章中学习的更函数式的技术会更好。

练习 5.5

实现一个dropWhile方法,从List的头部删除元素,直到条件为真。以下是添加到List抽象类中的签名:

public abstract List<A> dropWhile(Function<A, Boolean> f);

解决方案 5.5

我们不会查看Nil的实现,因为它只会返回thisCons类的实现是递归的:

@Override
public List<A> dropWhile(Function<A, Boolean> f) {
  return dropWhile_(this, f).eval();
}

private TailCall<List<A>> dropWhile_(List<A> list,
                                     Function<A, Boolean> f) {
  return !list.isEmpty() && f.apply(list.head())
      ? sus(() -> dropWhile_(list.tail(), f))
      : ret(list);
}

注意,当在空列表上调用dropWhile时,你可能会遇到问题。例如,以下代码将无法编译:

list().dropWhile(f)

原因是 Java 无法从传递给dropWhile方法的函数中推断出列表的类型。假设你正在处理一个整数列表。然后你可以使用以下解决方案:

List<Integer> list = list();
list.dropWhile(f);

或者这个:

List.<Integer>list().dropWhile(f);
列表连接

列表上非常常见的操作是将一个列表“添加”到另一个列表中,以形成一个包含两个原始列表所有元素的新列表。能够简单地链接两个列表将很方便,但这是不可能的。解决方案是将一个列表的所有元素添加到另一个列表中。但元素只能添加到列表的前端(头部),因此如果你想将list1连接到list2,你必须首先将list1的最后一个元素添加到list2的前端,如图图 5.6 所示。

图 5.6。通过连接共享数据。你可以看到两个列表都被保留,并且list2被结果列表共享。但你也看到,你不能像图中所示那样直接进行,因为你必须首先访问list1的最后一个元素,这是由于列表的结构而不可能的。

一种进行的方法是首先反转list1,生成一个新的列表,然后从反转列表的头部开始将每个元素添加到list2中。但你还没有定义一个反转方法。你还能定义concat吗?是的,你可以。只需考虑你如何定义这个方法:

  • 如果list1为空,则返回list2

  • 否则,返回list1的第一个元素(list1.head)与list1的其余部分(list1.tail)连接到list2的结果。

这种递归定义可以转换为以下代码:

public static List<A> concat(List<A> list1, List<A> list2) {
  return list1.isEmpty()
      ? list2
      : new Cons<>(list1.head(), concat(list1.tail(), list2));
}

这个解决方案(对于一些读者来说)的美丽之处在于,你不需要一个图来展示它是如何工作的,因为它并不是“工作”。它只是一个数学定义转换成代码。

这个定义(对于其他读者来说)的主要缺点是,由于同样的原因,你无法轻易地用图表示它。这听起来可能像幽默,但不是。这两种解决方案代表的是完全相同的“操作”,但一个表示过程(从中你可以看到结果),而另一个直接表达结果。哪种更好取决于选择。但函数式编程通常涉及思考预期的结果,而不是如何获得它。函数式代码是定义直接转换成代码。

显然,如果list1太长,这段代码将导致栈溢出,尽管你永远不会遇到list2长度的栈问题。结果是,如果你小心只将小列表添加到任何长度的列表的前端,你不必担心。

需要注意的一个重要点是,你实际上所做的操作是将第一个列表的元素以相反的顺序添加到第二个列表的前面。这显然与我们对连接的常识理解不同:将第二个列表添加到第一个列表的尾部。这绝对不是单链表的工作方式。

如果你需要连接任意长度的列表,你只需应用你在第四章中学到的知识,使concat方法栈安全。

如果你思考你所做的事情,你可能会猜测这里还有很大的抽象空间。如果concat方法只是更通用操作的一个特定应用呢?也许你可以抽象这个操作,使其栈安全,然后重用它来实现许多其他操作?等等,看看会发生什么!

你可能已经注意到,这个操作的复杂度(以及 Java 执行它所需的时间)与第一个列表的长度成正比。换句话说,如果你连接长度为n1list1和长度为n2list2,其复杂度为 O(n1),这意味着它与n2无关。换句话说,根据n2的不同,这个操作可能比在命令式 Java 中连接两个可变列表更高效。

从列表末尾删除

有时需要从列表的末尾移除元素。尽管单链表不是这种操作的理想数据结构,但你必须仍然能够实现它。

练习 5.6

编写一个方法来从列表中移除最后一个元素。此方法应返回结果列表。将其实现为以下签名的实例方法:

List<A> init()
提示

可能存在一种方法可以用另一个我们已经讨论过的函数来表示这个函数。也许现在是创建这个辅助函数的正确时机。

解答 5.6

要移除最后一个元素,你必须遍历列表(从前往后)并构建新的列表(从后往前,因为列表中的“最后一个”元素必须是 Nil)。这是使用 Cons 对象创建列表的方式的结果。这导致了一个元素顺序相反的列表,所以结果列表必须反转。这意味着你只需要实现一个 reverse 方法:

public List<A> reverse() {
  return reverse_(list(), this).eval();
}

private TailCall<List<A>> reverse_(List<A> acc, List<A> list) {
  return list.isEmpty()
      ? ret(acc)
      : sus(() -> reverse_(new Cons<>(list.head(), acc), list.tail()));
}

使用反转方法,你可以非常容易地实现 init

public List<A> init() {
  return reverse().tail().reverse();
}

当然,这些都是 Cons 类的实现。在 Nil 类中,reverse 方法返回 this,而 init 方法抛出异常。

5.4. 使用递归和高级函数折叠列表

在 第三章 中,你学习了如何折叠列表,折叠也适用于不可变列表。但是,对于可变列表,你可以选择通过迭代或递归来实现这些操作。在 第三章 中,你通过迭代实现了折叠,因为你使用了可变列表,其中通过非函数式方法在原地添加和移除元素。add 方法不返回任何内容,而 remove 方法只返回被移除的元素,同时修改列表参数。因为不可变列表是递归数据结构,你可以非常容易地使用递归来实现折叠操作。

让我们考虑数字列表的常见折叠操作。

练习 5.7

编写一个函数式方法来计算包含整数的列表中所有元素之和,使用简单的基于栈的递归。

解答 5.7

列表中所有元素之和的递归定义是

  • 对于空列表:0

  • 对于非空列表:头部加上尾部之和

这几乎可以逐字逐句翻译成 Java 代码:

public static Integer sum(List<Integer> ints) {
  return ints.isEmpty()
      ? 0
      : ints.head() + sum(ints.tail());
}

不要忘记,对于长列表,这种实现将导致栈溢出,所以不要在生产环境中使用这种代码。

练习 5.8

编写一个函数式方法来计算包含双精度浮点数的列表中所有元素之积,使用简单的基于栈的递归。

解答 5.8

非空列表中所有元素之积的递归定义是

head * product of tail

但对于空列表,它应该返回什么?当然,如果你还记得你的数学课程,你会知道答案。如果你不记得,你可以在解答 5.7 中显示的非空列表的要求中找到答案。

考虑当你将递归公式应用于所有元素时会发生什么。你最终会得到一个结果,这个结果必须乘以空列表所有元素的总乘积。因为你最终想要得到这个结果,你别无选择,只能认为空列表所有元素的总乘积是 1。这与使用 0 作为空列表所有元素的总和的情况相同。求和操作的恒等元素或中性元素是 0,而乘法的恒等或中性元素是 1。所以你的 product 方法可以写成以下形式:

public static Double product(List<Double> ds) {
  return ds.isEmpty()
      ? 1.0
      : ds.head() * product(ds.tail());
}

注意,乘法操作在一点上与求和操作不同。它有一个 吸收元素,它满足以下条件:

a × 吸收元素 = 吸收元素 × a = 吸收元素

乘法的吸收元素是 0。类似地,任何操作(如果存在)的吸收元素也被称为 零元素。零元素的存在允许你避免计算,也称为 短路

public static Double product(List<Double> ds) {
  return ds.isEmpty()
      ? 1.0
      : ds.head() == 0.0
          ? 0.0
          : ds.head() * product(ds.tail());
}

但先忘记这个优化版本,看看 sumproduct 的定义。你能发现可以抽象的模式吗?让我们将它们并排放置(在更改参数名称之后):

public static Integer sum(List<Integer> list) {
  return list.isEmpty()
      ? 0
      : list.head() + sum(list.tail());
}

public static Double product(List<Double> list) {
  return list.isEmpty()
      ? 1
      : list.head() * product(list .tail());
}

现在,让我们消除差异,用共同的符号替换它们:

public static Type operation(List<Type> list) {
  return list.isEmpty()
      ? identity
      : list.head() operator operation(list .tail());
}

public static Type operation(List<Type> list) {
  return list.isEmpty()
      ? identity
      : list.head() operator operation(list .tail());
}

这两个操作几乎相同。如果你能找到一种方法来抽象共同的部分,你只需提供变量信息(Typeoperationidentityoperator)就可以实现这两个操作,而无需重复。这种共同的操作就是我们所说的 折叠,你在第三章 中学习过。在第三章中,你了解到有两种折叠——右折叠和左折叠,以及这两种操作之间的关系。

列表 5.2 展示了求和和乘法操作中抽象出来的一个名为 foldRight 的方法,它接受折叠的列表、一个恒等元素以及一个表示折叠列表所使用的操作的更高阶函数作为参数。恒等元素显然是给定操作的恒等元素,而函数是柯里化形式。(如果你不记得这是什么意思,请参阅第二章。)这个函数代表了代码的运算符部分。

列表 5.2. 实现 foldRight 并使用它进行 sumproduct

注意,Type 变量部分在这里被替换成了两种类型,AB。这是因为折叠的结果并不总是与列表的元素具有相同的类型。在这里,它比求和和乘法操作所需的抽象程度更高,但很快就会派上用场。

operation 变量部分当然是两个方法的名字。

折叠操作并不仅限于算术计算。你可以使用折叠将字符列表转换为字符串。在这种情况下,AB是两种不同的类型:CharString。但你也可以使用折叠将字符串列表转换为单个字符串。你现在能看出如何实现concat吗?

顺便说一句,foldRight与单链表本身非常相似。如果你把列表 1, 2, 3 想象成

Cons(1, Cons(2, Cons(3, Nil)

你可以立即看出它与右折叠非常相似:

f(1, f(2, f(3, identity)

但也许你已经意识到Nil是向列表添加元素的恒等元素。这很有道理:如果你想将字符列表转换为字符串,你必须从一个空列表开始。(顺便说一句,Nil也是列表连接的恒等元素,尽管在没有空列表的情况下你也可以做到这一点。在这种情况下,它被称为reduce而不是fold。但这只因为结果是元素类型的相同。)

这可以通过将Nilcons传递给foldRight作为折叠的恒等元素和函数来实现:

List.foldRight(list(1, 2, 3), list(), x -> y -> y.cons(x))

这只是简单地产生一个新的列表,其元素顺序与原列表相同,正如你可以通过运行以下代码看到的那样:

System.out.println(List.foldRight(list(1, 2, 3), list(),
                                               x -> y -> y.cons(x)));

这段代码会产生以下输出:

[1, 2, 3, NIL]

下面是每一步发生情况的跟踪:

foldRight(list(1, 2, 3), list(), x -> y -> y.cons(x));
foldRight(list(1, 2), list(3), x -> y -> y.cons(x));
foldRight(list(1), list(2, 3), x -> y -> y.cons(x));
foldRight(list(), list(1, 2, 3), x -> y -> y.cons(x));

练习 5.9

编写一个计算列表长度的方法。这个方法将使用foldRight方法。

解决方案 5.9

Nil的实现很明显,返回 0。Cons的实现可以写成

public int length() {
  return foldRight(this, 0, x -> y -> y + 1);
}

注意,这个实现,除了基于栈的递归之外,性能非常差。即使转换为基于堆的,它仍然是 O(n),这意味着返回长度所需的时间与列表的长度成正比。在接下来的章节中,你将看到如何以常数时间获取链表的长度。

练习 5.10

foldRight方法使用递归,但它不是尾递归,所以它会迅速溢出栈。溢出的速度取决于几个因素,其中最重要的是栈的大小。在 Java 中,栈的大小可以通过-Xss命令行参数进行配置,但主要的缺点是所有线程使用相同的大小。对于大多数线程来说,使用更大的栈将是内存的浪费。

代替使用foldRight,创建一个尾递归的foldLeft方法,并且可以使其栈安全。这是它的签名:

public abstract <B> B foldLeft(B identity, Function<B, Function<A, B>> f);
提示

如果你记不起foldLeftfoldRight之间的区别,请参阅第 3.3.5 节。

解决方案 5.10

Nil的实现显然会返回identity。对于Cons的实现,首先定义一个前端方法foldLeft,它调用基于栈的尾递归辅助方法foldLeft_,累加器acc初始化为identity,并引用this

public <B> B foldLeft(B identity, Function<B, Function<A, B>> f) {
  return foldLeft_(identity, this, f);
}

private <B> B foldLeft_(B acc, List<A> list,
                                    Function<B, Function<A, B>> f) {
  return list.isEmpty()
      ? acc
      : foldLeft_(f.apply(acc).apply(list.head()), list.tail(), f);
}

然后进行以下更改,以便你可以使用你在第四章中定义的TailCall接口(retsus方法被静态导入):

public <B> B foldLeft(B identity, Function<B, Function<A, B>> f) {
  return foldLeft_(identity, this, f).eval();
}

private <B> TailCall<B> foldLeft_(B acc, List<A> list,
                                  Function<B, Function<A, B>> f) {
  return list.isEmpty()
      ? ret(acc)
      : sus(() -> foldLeft_(f.apply(acc).apply(list.head()),
                                               list.tail(), f));
}

练习 5.11

使用你的新foldLeft方法创建新的栈安全版本的sumproductlength

解决方案 5.11

这是sumViaFoldLeft方法:

public static Integer sumViaFoldLeft(List<Integer> list) {
  return list.foldLeft(0, x -> y -> x + y);
}

productViaFoldLeft方法如下:

public static Double productViaFoldLeft(List<Double> list) {
  return list.foldLeft(1.0, x -> y -> x * y);
}

以下是lengthViaFoldLeft方法:

public static <A> Integer lengthViaFoldLeft(List<A> list) {
  return list.foldLeft(0, x -> ignore -> x + 1);
}

注意,再次提醒,方法length的第二个参数(代表方法每次递归调用中的列表元素)被忽略。此方法与上一个方法一样低效,不应该在生产代码中使用。

练习 5.12

使用foldLeft编写一个静态函数方法来反转列表。

解决方案 5.12

通过左折叠反转列表非常简单,从空列表作为累加器开始,并将第一个列表的每个元素cons到这个累加器中:

public static <A> List<A> reverseViaFoldLeft(List<A> list) {
  return list.foldLeft(list(), x -> x::cons);
}

本例使用方法引用而不是 lambda,如第二章所述。如果你更喜欢使用 lambda,它等同于以下内容:

public static <A> List<A> reverseViaFoldLeft(List<A> list) {
  return list.foldLeft(list(), x -> a -> x.cons(a));
}

练习 5.13(困难)

foldLeft来表示foldRight

解决方案 5.13

此实现对于获取foldRight的栈安全版本可能很有用:

public static <A, B> B foldRightViaFoldLeft(List<A> list,
                              B identity, Function<A, Function<B, B>> f) {
  return list.reverse().foldLeft(identity, x -> y -> f.apply(y).apply(x));
}

注意,你还可以用foldRight来定义foldLeft,尽管这不太有用:

public static <A, B> B foldLeftViaFoldRight(List<A> list,
                              B identity, Function<B, Function<A, B>> f) {
  return List.foldRight(list.reverse(),identity, x -> y ->
                                                    f.apply(y).apply(x));
}

再次注意,你使用的foldLeft方法是List的实例方法。相比之下,foldRight是静态方法。(我们很快将定义一个实例foldRight方法。)

5.4.1. foldRight的基于堆的递归版本

正如我所说的,递归的foldRight实现只是为了演示这些概念,因为它基于栈,因此不应该在生产代码中使用。此外,请注意这是一个静态实现。实例实现会更容易使用,允许你使用对象表示法链式调用方法调用。

练习 5.14

使用你在第四章中学习的知识,编写一个基于堆的递归实例版本的foldRight方法。

提示

该方法可以在父List类中定义。编写一个基于栈的尾递归版本的foldRight方法(使用辅助方法)。然后,将辅助方法更改为使用你在第四章中开发的TailCall接口的基于堆的递归实现。

解决方案 5.14

首先,让我们编写基于栈的尾递归辅助方法。你只需要编写一个辅助方法,该方法接受一个累加器作为额外的参数。累加器的类型与函数返回类型相同,其初始值等于identity元素(顺便说一下,这个元素被使用了两次)。

public <B> B foldRight_(B acc, List<A> ts, B identity,
                        Function<A, Function<B, B>> f) {
  return ts.isEmpty()
      ? acc
      : foldRight_(f.apply(ts.head()).apply(acc), ts.tail(), identity, f);
}

然后编写调用此辅助方法的主方法:

public <B> B foldRight(B identity, Function<A, Function<B, B>> f) {
  return foldRight_(identity, this.reverse(), identity, f);
}

现在将这两个方法都改为使用TailCall堆递归:

public <B> B foldRight(B identity, Function<A, Function<B, B>> f) {
  return foldRight_(identity, this.reverse(), identity, f).eval();
}

private <B> TailCall<B> foldRight_(B acc, List<A> ts, B identity,
                                   Function<A, Function<B, B>> f) {
  return ts.isEmpty()
      ? ret(acc)
      : sus(() -> foldRight_(f.apply(ts.head()).apply(acc),
                                         ts.tail(), identity, f));
}

当然,你也应该编写Nil实现,这实际上非常简单。

你可以通过重用你的foldRightVia-FoldLeft实现来使这个方法更短:

public <B> B foldRight(B identity, Function<A, Function<B, B>> f) {
  return reverse().foldLeft(identity, x -> y -> f.apply(y).apply(x));
}

练习 5.15

使用foldLeftfoldRight来实现concat

解决方案 5.15

可以通过右折叠轻松实现concat方法:

public static <A> List<A> concat(List<A> list1, List<A> list2) {
  return foldRight(list1, list2, x -> y -> new Cons<>(x, y));
}

另一种解决方案是使用左折叠。在这种情况下,实现方式将与reverseViaFoldLeft应用于反转后的第一个列表相同,使用第二个列表作为累加器:

public static <A> List<A> concat(List<A> list1, List<A> list2) {
  return list1.reverse().foldLeft(list2, x -> x::cons);
}

这个实现(基于foldLeft)可能看起来效率较低,因为它必须首先反转第一个列表。实际上,它并不低效,因为你的foldRight实现是基于反转列表的左折叠。(如果这还不清楚,请参考reverse [练习 5.6]、foldLeft [练习 5.10]和foldRight [清单 5.2]的实现。)

练习 5.16

编写一个将列表中的列表展平为包含每个包含列表所有元素的列表的方法。

提示

这个操作由一系列连接组成。换句话说,它与将整数列表的所有元素相加类似,尽管整数被列表所替代,加法被连接所替代。除此之外,它与sum方法完全相同。

解决方案 5.16

在这个解决方案中,你可以使用方法引用而不是 lambda 来表示函数的第二部分:x -> x::concat 等价于 x -> y -> x.concat(y)

public static <A> List<A> flatten(List<List<A>> list) {
  return foldRight(list, List.<A>list(), x -> y -> concat(x,y));
}

5.4.2. 列表的映射和过滤

你可以为处理列表定义许多有用的抽象。一个抽象是通过应用一个共同函数来更改列表的所有元素。

练习 5.17

编写一个函数式方法,它接受一个整数列表并乘以每个元素的 3 倍。

提示

尝试使用你到目前为止定义的方法。不要显式使用递归。目标是最终抽象堆安全递归,这样你就可以使用它而无需每次都重新实现它。

解决方案 5.17

public static List<Integer> triple(List<Integer> list) {
  return List.foldRight(list, List.<Integer>list(), h -> t ->
                                                    t.cons(h * 3));
}

练习 5.18

编写一个函数,将List<Double>中的每个值转换为String

解决方案 5.18

这个操作可以看作是将一个空列表(List<String>类型)与原始列表连接起来,每个元素在cons到累加器之前都会被转换。因此,实现方式与你之前在concat方法中做的是非常相似的:

图片

练习 5.19

编写一个通用的函数式方法map,允许你通过将指定的函数应用于它来修改列表中的每个元素。这次,将其作为List的实例方法。在List类中添加以下声明:

public abstract <B> List<B> map(Function<A, B> f);
提示

使用foldRight方法的堆安全实例版本。

解决方案 5.19

map方法可以在父List类中实现:

public <B> List<B> map(Function<A, B> f) {
  return foldRight(list(), h -> t -> new Cons<>(f.apply(h),t));
}

练习 5.20

编写一个filter方法,从列表中移除不满足给定谓词的元素。再次,将其实现为一个具有以下签名的实例方法:

public List<A> filter(Function<A, Boolean> f)

解决方案 5.20

这里是在父List类中使用foldRight的一个实现,不要忘记使用这个方法的栈安全版本。

public List<A> filter(Function<A, Boolean> f) {
  return foldRight(list(), h -> t -> f.apply(h) ? new Cons<>(h,t) : t);
}

练习 5.21

编写一个flatMap方法,它将一个从AList<B>的函数应用于List<A>的每个元素,并返回一个List<B>。它的签名将是

public <B> List<B> flatMap(Function<A, List<B>> f);

例如,List.list(1,2,3).flatMap(i -> List.list(i, -i))应该返回list(1,-1,2,-2,3,-3)

解决方案 5.21

再次强调,这可以在父List类中实现,使用foldRight

public <B> List<B> flatMap(Function<A, List<B>> f) {
  return foldRight(list(), h -> t -> concat(f.apply(h), t));
}

练习 5.22

基于flatMap创建一个新的filter版本。

解决方案 5.22

这里是一个静态实现:

public static <A> List<A> filterViaFlatMap(List<A> list,
                                           Function<A, Boolean> p) {
  return list.flatMap(a -> p.apply(a) ? List.list(a) : List.list());
}

注意到mapflattenflatMap之间存在紧密的联系。如果你将返回列表的函数映射到列表上,你会得到一个列表的列表。然后你可以应用flatten来得到一个包含所有嵌套列表元素的单一列表。直接应用flatMap也会得到相同的结果。

这种关系的一个后果是,你可以用flatMap来重新定义flatten

public static <A> List<A> flatten(List<List<A>> list) {
  return list.flatMap(x -> x);
}

这并不令人惊讶,因为对concat的调用已经被抽象为flatMap

5.5. 总结

  • 数据结构是编程中最重要概念之一。

  • 单链表是函数式编程中最常用的数据结构。

  • 使用不可变和持久列表可以带来线程安全性。

  • 使用数据共享可以使大多数操作具有非常高的性能,尽管不是所有操作。

  • 你可以创建其他数据结构以获得特定用例的良好性能。

  • 你可以通过递归应用函数来折叠列表。

  • 你可以使用基于堆的递归折叠列表,而不会出现栈溢出的风险。

  • 一旦你定义了foldRightfoldLeft,你就不再需要使用递归来处理列表。foldRightfoldLeft为你抽象了递归。

第六章. 处理可选数据

本章涵盖的内容

  • null引用,或称为“十亿美元的错误”

  • null引用的替代方案

  • 为可选数据开发Option数据类型

  • 将函数应用于可选值

  • 组合可选值

  • Option的使用案例

可选数据的表示在计算机程序中一直是一个问题。在日常生活中,可选数据的概念非常简单。当某物包含在容器中时,表示这种事物的缺失是容易的——无论是什么,都可以用一个空容器来表示。苹果的缺失可以用一个空苹果篮子来表示。汽车中汽油的缺失可以想象成一个空油箱。

在计算机程序中表示数据的缺失更困难。大多数数据都表示为指向它的引用,所以表示数据缺失的最明显方式是使用一个指向空值的指针。这就是空指针的含义。

在 Java 中,变量是一个指向值的指针。变量可以被创建为null(静态和实例变量默认创建为null),然后它们可以被更改以指向值。如果数据被移除,它们甚至可以被更改回指向null

为了处理可选数据,Java 8 引入了Optional类型。然而,在本章中,你将开发自己的类型,你将称之为Option。目标是了解这种结构是如何工作的。完成本章后,你应该可以自由地使用标准的 Java 8 库版本Optional,但在接下来的章节中,你会发现它比你在本章中创建的类型要弱得多。

6.1. null指针的问题

在命令式程序中最常见的错误之一是NullPointerException。当标识符被解引用并发现它指向空值时,会引发此错误。换句话说,预期某些数据,但发现数据缺失。这样的标识符被称为指向null

null引用是在 1965 年由托尼·霍尔在为设计 ALGOL 面向对象语言时发明的。以下是他在 44 年后所说的话:^([1])

¹

托尼·霍尔,“Null References: The Billion Dollar Mistake”(QCon,2009 年 8 月 25 日),mng.bz/l2MC

我称之为我的十亿美元的错误……我的目标是确保所有引用的使用都绝对安全,由编译器自动执行检查。但我无法抗拒加入一个空引用的诱惑,仅仅因为它很容易实现。这导致了无数的错误、漏洞和系统崩溃,在过去四十年中可能造成了十亿美元的痛苦和损失。

虽然现在应该众所周知应该避免null引用,但这远非事实。Java 标准库包含接受可选参数的方法和构造函数,如果未使用,则必须设置为null。以java.net.Socket类为例。这个类定义了以下构造函数:

public Socket(String address,
              int port,
              InetAddress localAddr,
              int localPort throws IOException

根据文档,

如果指定的本地地址是null,它等同于指定地址为 AnyLocal 地址。

在这里,null引用是一个有效的参数。这有时被称为业务null。请注意,这种处理数据缺失的方式并不仅限于对象。端口也可能不存在,但它不能是null,因为它是一个原始类型:

本地端口号为零将允许系统在绑定操作中选取一个空闲端口。

这种类型的值有时被称为哨兵值。它不是用于值本身(它不意味着端口 0),而是用于指定端口值的缺失。

Java 库中处理数据缺失的例子还有很多。这真的很危险,因为本地地址为null可能是无意的,并且是由于之前的错误。但这不会引发异常。程序将继续工作,尽管不是按照预期的方式。

商业null还有其他情况。如果你尝试使用不在映射中的键从HashMap中检索值,你会得到一个null。这是错误吗?你不知道。可能这个键是有效的但尚未在映射中注册;或者可能这个键被认为是有效的,应该存在于映射中,但在计算键时发生了之前的错误。例如,键可能是null,无论是故意还是由于错误,这不会引发异常。它甚至可以返回非null值,因为HashMap允许null键。这种情况是一团糟。

当然,你知道如何处理这个问题。你知道在使用引用之前,你应该检查它是否为null。 (你不为每个方法接收到的对象参数这样做,对吗?) 你知道在从映射中获取值之前,你应该先测试映射是否包含相应的键。你也知道,如果你通过索引访问元素,你应该首先验证列表不为空且具有足够的元素。你一直这样做,所以你永远不会得到NullPointerExceptionIndexOutOfBoundsException

如果你是一位完美的程序员,你可以忍受null引用。但对于我们其他人来说,处理值缺失(无论是故意的还是由于错误的结果)的一种更简单、更安全的方法是必要的。在本章中,你将学习如何处理非错误结果的缺失值。这种数据被称为可选数据

处理可选数据的技巧一直存在。最知名且最常使用的一种是列表。当一个方法应该返回一个值或无结果时,一些程序员使用列表作为返回值。列表可以包含零个或一个元素。尽管这可以完美工作,但它有几个重要的缺点:

  • 没有办法确保列表最多只包含一个元素。如果你收到一个包含多个元素的列表,你应该怎么办?

  • 你如何区分一个应该最多只包含一个元素的列表和一个普通列表?

  • List 类定义了许多方法和函数来处理列表可能包含多个元素的事实。这些方法对我们的用例来说是无用的。

  • 函数式列表是递归结构,你不需要这个。一个更简单的实现就足够了。

6.2. 空引用的替代方案

看起来我们的目标是避免 NullPointerException,但这并不完全是这样。NullPointerException 应该始终指示一个错误。因此,你应该应用“快速失败”原则:如果存在错误,程序应该尽可能快地失败。完全移除业务 null 不会让你摆脱 NullPointerException。它只会确保 null 引用仅由程序中的错误引起,而不是由可选数据引起。

以下代码是一个返回可选数据的方法的示例:

static Function<List<Integer>, Double> mean = xs -> {
  if (xs.isEmpty()) {
    ???;
  } else {
    return xs.foldLeft(0.0, x -> y -> x + y) / xs.length();
  }
};

mean 函数是一个部分函数的例子,正如你在第二章中看到的:它对所有列表(除了空列表)都进行了定义。你应该如何处理空列表的情况?

一种可能性是返回一个哨兵值。你应该选择什么值?因为类型是 Double,你可以使用在 Double 类中定义的值:

static Function<List<Integer>, Double> mean = xs -> {
  if (xs.isEmpty()) {
    return Double.NaN;
  } else {
    return xs.foldLeft(0.0, x -> y -> x + y) / xs.length();
  }
};

这之所以有效,是因为 Double.NaN(不是一个数字)实际上是一个 double 值(注意小写的 d)。Double.NaN 是一个原始值!

到目前为止一切顺利,但你有三个问题:

  • 如果你想要将相同的原理应用到返回 Integer 的函数上,整数类中没有 NaN 值的等价物。

  • 你如何向你的函数的用户发出信号,表明它可能返回一个哨兵值?

  • 你如何处理一个参数化函数,例如

    static <A, B> Function<List<A>, B> f = xs -> {
      if (xs.isEmpty()) {
        ???;
      } else {
        return ...;
      };
    

另一个解决方案是抛出异常:

static Function<List<Integer>, Double> mean = xs -> {
  if (xs.isEmpty()) {
    throw new MeanOfEmptyListException();
  } else {
    return xs.foldLeft(0.0, x -> y -> x + y) / xs.length();
  }
};

但这个解决方案很丑陋,并且比解决的问题更多:

  • 异常通常用于错误结果,但在这里并没有错误。实际上没有结果,这是因为没有输入数据!或者你应该认为调用空列表的函数是一个错误?

  • 你应该抛出哪种异常?一个自定义的(如示例中所示)?还是标准的一个?

  • 你应该使用检查型异常还是非检查型异常?此外,你的函数不再是一个纯函数。它不再具有引用透明性,这导致了我第二章中提到的许多问题。此外,你的函数不再可组合。

你也可以返回null并让调用者处理它:

static Function<List<Integer>, Double> mean = xs -> {
  if (xs.isEmpty()) {
    return null;
  } else {
    return xs.foldLeft(0.0, x -> y -> x + y) / xs.length();
  }
};

返回null是最糟糕的解决方案:

  • 它强制(理想情况下)调用者测试结果是否为null并相应地处理。

  • 如果使用装箱,它将崩溃。

  • 与异常解决方案一样,函数不再可组合。

  • 它允许潜在的问题从其源头传播得很远。如果调用者忘记测试null结果,NullPointerException可能会从代码的任何地方抛出。

一个更好的解决方案是要求用户提供一个特殊值,如果数据不可用,则返回该值。例如,这个函数计算列表的最大值:

static <A, B> Function<B, Function<List<A>, B>> max = x0 -> xs -> {
  return xs.isEmpty()
    ? x0
    : ...;

这是你定义max函数的方法:

static <A extends Comparable<A>> Function<A, Function<List<A>, A>> max() {
  return x0 -> xs -> xs.isEmpty()
    ? x0
    : xs.tail().foldLeft(xs.head(), x -> y -> x.compareTo(y) < 0 ? x : y);
}

记住,你必须使用返回函数的方法,因为没有方法可以参数化属性。

如果你觉得这太复杂,这里有一个函数式方法版本:

static <A extends Comparable<A>> A max(A x0, List<A> xs) {
  return xs.isEmpty()
    ? x0
    : xs.tail().foldLeft(xs.head(), x -> y -> x.compareTo(y) < 0 ? x : y);
}

这可行,但过于复杂。最简单的解决方案是返回一个列表:

public static <A extends Comparable<A>> Function<List<A>, List<A>> max() {
  return xs -> xs.isEmpty()
    ? List.list()
    : List.list(xs.foldLeft(xs.head(), x -> y -> x.compareTo(y) < 0
                                                                ? x : y));
}

虽然这个解决方案工作得很好,但它有点丑陋,因为函数的参数类型和返回类型相同,尽管它们并不代表相同的东西。为了解决这个问题,你可以简单地创建一个新的类型,类似于List但具有不同的名称,以表明它的含义。同时,你可以选择一个更合适的实现,确保这个“列表”最多只有一个元素。

6.3. Option数据类型

本章中你将创建的Option数据类型将与List数据类型非常相似。使用Option类型处理可选数据允许你在数据不存在的情况下组合函数(参见图 6.1)。它将作为一个抽象类Option实现,包含两个表示数据存在和不存在私有子类。表示数据不存在的子类将被称为None,表示数据存在的子类将被称为SomeSome将包含相应的数据值。

图 6.1. 没有使用Option类型,组合函数不会产生函数,因为生成的程序可能会抛出NullPointerException

图片

以下列表显示了这三个类的代码。

列表 6.1. Option数据类型

图片

图片

在这个列表中,你可以看到OptionList有多接近。它们都是具有两个私有实现的抽象类。None子类对应于Nil,而Some子类对应于ConsgetOrThrow方法类似于List中的head方法。

你可以使用Option来定义max函数,如下所示:

static <A extends Comparable<A>> Function<List<A>, Option<A>> max() {
  return xs -> xs.isEmpty()
      ? Option.none()
      : Option.some(xs.foldLeft(xs.head(),
                    x -> y -> x.compareTo(y) > 0 ? x : y));
}

现在你的函数是一个全函数,这意味着它对所有列表都有值,包括空列表。注意这段代码与返回列表的版本是多么相似。尽管Option的实现与List的实现不同,但它们的用法几乎相同。正如你很快就会看到的,这种相似性延伸得更远。

但就目前而言,Option类并不很有用。使用Option的唯一方法就是测试实际的类以确定它是否是SomeNone,并在前者的情况下调用getOrThrow方法来获取值。如果没有数据,这个方法将抛出异常,这并不很实用。为了使其成为一个强大的工具,你需要添加一些方法,就像你为List所做的那样。

6.3.1. 从Option获取值

你为List创建的许多方法也将对Option有用。实际上,只有与多个值相关的、如折叠等方法可能在这里无用。但在你创建这些方法之前,让我们先从一些Option特定的用法开始。

为了避免测试Option的子类,你需要定义方法,这些方法与getOrThrow不同,可能在两个子类中都很有用,因此你可以从Option父类中调用它们。你需要做的第一件事是找到一种方法来检索Option中的值。当数据缺失时,使用默认值是一个常见的用例。

练习 6.1

实现一个getOrElse方法,该方法将返回包含的值(如果存在),否则返回提供的默认值。以下是方法签名:

A getOrElse(A defaultValue)

解决方案 6.1

这个方法将被实现为一个在抽象Option类中声明的实例方法,如下所示:

public abstract A getOrElse(A defaultValue);

Some实现很明显,它将简单地返回它包含的值:

public A getOrElse(A defaultValue) {
  return this.value;
}

None实现将返回默认值:

public A getOrElse(A defaultValue) {
  return defaultValue;
}

到目前为止一切顺利。你现在可以定义返回选项的方法,并像下面这样透明地使用返回值:

int max1 = max().apply(List.<Integer>list(3, 5, 7, 2, 1)).getOrElse(0);
int max2 = max().apply(List.list()).getOrElse(0);

在这里,max1将等于7(列表中的最大值),而max2将被设置为0(默认值)。

但你可能遇到了问题。看看下面的例子:

int max1 = max().apply(List.list(3, 5, 7, 2, 1)).getOrElse(getDefault());
System.out.println(max1);
int max2 = max().apply(List.<Integer>list()).getOrElse(getDefault());
System.out.println(max2);

int getDefault() {
  throw new RuntimeException();
}

当然,这个例子有点牵强。getDefault方法根本不是函数式的。这只是为了向你展示发生了什么。这个例子会打印什么?如果你认为它会打印 7 然后抛出异常,那么再想想。

这个示例将不会打印任何内容,并直接抛出异常,因为 Java 是一种严格的语言。方法参数在方法实际执行之前进行评估,无论它们是否需要。因此,getOrElse方法参数在任何情况下都会被评估,无论是调用Some还是None。对于Some来说,方法参数不是必需的这一点无关紧要。当参数是字面量时,这没有区别,但当它是一个方法调用时,这就有很大区别。getDefault方法在任何情况下都会被调用,所以第一行将抛出异常,并且不会显示任何内容。这通常不是你想要的。

练习 6.2

通过对getOrElse方法参数使用延迟评估来解决前面的问题。

提示

使用你在第三章(练习 3.2)中定义的Supplier类。

解决方案 6.2

方法签名将更改为

public abstract A getOrElse(Supplier<A> defaultValue);

Some的实现没有变化,除了方法签名,因为参数没有被使用:

@Override
public A getOrElse(Supplier<A> defaultValue) {
  return this.value;
}

最重要的是None类的变化:

@Override
public A getOrElse(Supplier<A> defaultValue) {
  return defaultValue.get();
}

在没有值的情况下,参数通过调用Supplier.get()方法进行评估。现在可以将max示例重写如下:

int max1 = max().apply(List.list(3, 5, 7, 2, 1))
                .getOrElse(() -> getDefault());

System.out.println(max1);
int max2 = max().apply(List.<Integer>list()).getOrElse(() -> getDefault());
System.out.println(max2);
int getDefault() {
  throw new RuntimeException();
}

这个程序在抛出异常之前将 7 打印到控制台。

现在你有了getOrElse方法,你不再需要getOrThrow方法了。但在为Option类开发其他方法时,它可能很有用,所以我们将保留它并将其设置为受保护的。

6.3.2. 将函数应用于可选值

List中有一个非常重要的方法,即map方法,它允许你将一个从AB的函数应用于A类型列表的每个元素,从而生成一个B类型的列表。考虑到Option就像一个最多包含一个元素的列表,你可以应用同样的原则。

练习 6.3

创建一个map方法,通过应用从AB的函数将Option<A>转换为Option<B>

提示

Option类中定义一个抽象方法,并在每个子类中实现一个。Option中的方法签名将是

public abstract <B> Option<B> map(Function<A, B> f)

解决方案 6.3

None的实现很简单。你只需要返回一个None实例。正如我之前所说的,Option类包含一个None单例,可以用于此目的:

public <B> Option<B> map(Function<A, B> f) {
  return none();
}

注意,尽管thisnone指向同一个对象,但你不能返回this,因为它用A进行了参数化。none引用指向同一个对象,但具有原始类型(没有参数)。这就是为什么你用@SuppressWarnings ("rawtypes")注解none,以防止编译器警告泄露给调用者。同样地,你使用对none()工厂方法的调用,而不是直接访问none实例,以避免“未检查的赋值警告”,你已经在none()方法中通过使用@SuppressWarnings ("unchecked")注解避免了这种警告。

Some 实现并不复杂。你只需要获取值,将其应用于函数,并将结果包装在一个新的 Some 中:

public <B> Option<B> map(Function<A, B> f) {
  return new Some<>(f.apply(this.value));
}

6.3.3. 处理 Option 组合

你很快就会意识到,从 AB 的函数在函数式编程中并不是最常见的。一开始你可能难以熟悉返回可选值的函数。毕竟,似乎需要在 Some 实例中包装值并在之后检索这些值,这需要额外的工作。但随着进一步的练习,你会发现这些操作很少发生。当链式调用函数构建复杂计算时,你通常会从一个由先前计算返回的值开始,将结果传递给新函数,而不会看到中间结果。换句话说,你将更频繁地使用从 AOption<B> 的函数,而不是从 AB 的函数。

考虑一下 List 类。这让你想起什么吗?是的,它导致了 flatMap 方法。

练习 6.4

创建一个接受从 AOption<B> 的函数作为参数的 flatMap 实例方法,并返回一个 Option<B>

提示

你可以在两个子类中定义不同的实现;但你应该尝试设计一个适用于两个子类的独特实现,并将其放入 Option 类。它的签名将是

<B> Option<B> flatMap(Function<A, Option<B>> f)

尝试使用你已有的方法(mapgetOrElse)。

解决方案 6.4

简单的解决方案是在 Option 类中定义一个抽象方法,在 None 类中返回 none(),在 Some 类中返回 f.apply(this.value)。这可能是最有效的实现。但一个更优雅的解决方案是映射 f 函数,得到一个 Option<Option<B>>,然后使用 getOrElse 方法提取值(Option<B>),提供 None 作为默认值:

public <B> Option<B> flatMap(Function<A, Option<B>> f) {
  return map(f).getOrElse(Option::none);
}

练习 6.5

正如你需要一种映射返回 Option 的函数(导致 flatMap),你还需要一个 getOrElse 的版本用于 Option 默认值。创建一个具有以下签名的 orElse 方法:

Option<A> orElse(Supplier<Option<A>> defaultValue)
提示

如你所猜,为了实现这个方法,不需要“获取”值。这就是 Option 主要的使用方式:通过 Option 组合而不是包装和获取值。一个结果是,相同的实现将适用于两个子类。

解决方案 6.5

解决方案在于映射函数 x -> this,这会导致一个 Option<Option<A>>,然后使用提供的默认值对这一结果调用 getOrElse

public Option<A> orElse(Supplier<Option<A>> defaultValue) {
  return map(x -> this).getOrElse(defaultValue);
}

练习 6.6

在 第五章 中,你创建了一个 filter 方法来从列表中移除所有不满足以谓词形式表达的条件(换句话说,它是一个返回 Boolean 的函数)的所有元素。为 Option 创建相同的方法。以下是它的签名:

Option<A> filter(Function<A, Boolean> f)
提示

因为 Option 类似于最多只有一个元素的 List,所以实现看起来很简单。在 None 子类中,你只需返回 none()。在 Some 类中,如果条件成立,则返回原始的 Option,否则返回 none()。但尝试设计一个更智能的实现,使其适合 Option 父类。

解决方案 6.6

解决方案是将 Some 情况下使用的函数 flatMap

public Option<A> filter(Function<A, Boolean> f) {
  return flatMap(x -> f.apply(x)
      ? this
      : none());
}

6.3.4. 选项使用案例

如果你已经了解 Java 8 的 Optional 类,你可能已经注意到 Optional 包含一个 isPresent() 方法,允许你测试 Optional 是否包含值。(Optional 有一个不同的实现,它不基于两个不同的子类。)你可以轻松实现这样一个方法,尽管你会称它为 isSome(),因为它将测试对象是否是 SomeNone。你也可以称它为 isNone(),这可能更合逻辑,因为它将是 List.isEmpty() 方法的等效。

虽然 isSome() 方法有时很有用,但并不是使用 Option 类的最佳方式。如果你在调用 getOrThrow() 获取值之前,通过 isSome() 方法测试 Option,那么这和在取消引用之前测试 null 引用的引用不会有太大区别。唯一的区别在于,如果你忘记先进行测试,你会看到 IllegalStateException 而不是 NullPointerException

使用 Option 的最佳方式是通过组合。为此,你必须为所有用例创建所有必要的函数。这些用例对应于你在测试它不是 null 之后会做什么。你可以做以下之一:

  • 将值作为另一个函数的输入

  • 对值应用效果

  • 如果值不是 null,则使用该值,或者使用默认值应用函数或效果

第一个和第三个用例已经通过你已创建的方法实现。应用效果可以通过不同的方式完成,你将在第十三章(kindle_split_020.xhtml#ch13)中了解到。

例如,看看 Option 类如何改变你使用映射的方式。列表 6.2 展示了函数式 Map 的实现。这不是一个函数式实现,而是一个围绕旧版 ConcurrentHashMap 的包装,以提供函数式接口。

列表 6.2. 在函数式 Map 中使用 Option

图片

图片

如您所见,Option 允许你在调用 get 之前,将查询映射的模式封装到映射实现中。以下列表展示了如何使用它。

列表 6.3. 将 Option 应用起来

图片

图片

在这个(非常简化的)程序中,你可以看到如何组合返回 Option 的各种函数。你不需要进行任何测试,也不必担心 NullPointer-Exception,尽管你可能会请求一个 Toon 的电子邮件,而这个 Toon 没有电子邮件,或者甚至请求一个在映射中不存在的 Toon

但这里有个小问题。这个程序打印

mickey@disney.com
No data
No data

第一行是米奇的电子邮件。第二行说“无数据”,因为米妮没有电子邮件。第三行说“无数据”,因为高飞不在映射中。显然,你需要一种方法来区分这两种情况。Option 类不允许你区分这两种情况。你将在下一章中看到如何解决这个问题。

练习 6.7

使用 flatMap 实现一个 variance 函数。一系列值的方差表示这些值围绕平均值分布的情况。如果所有值都非常接近平均值,则方差较低。当所有值都等于平均值时,方差为 0。一系列的方差是系列中每个元素 xMath.pow(x - m, 2) 的平均值,其中 m 是系列的均值。以下是函数的签名:

Function<List<Double>, Option<Double>> variance = ...
提示

要实现这个函数,你必须首先实现一个计算 List<Double> 的总和的函数。然后你应该创建一个 mean 函数,就像你在本章前面创建的那样,但针对双精度浮点数。如果你在定义这些函数时遇到困难,请参阅第四章和第五章或使用以下函数:

static Function<List<Double>, Double> sum =
                           ds -> ds.foldLeft(0.0, a -> b -> a + b);

static Function<List<Double>, Option<Double>> mean =
      ds -> ds.isEmpty()
          ? Option.none()
          : Option.some(sum.apply(ds) / ds.length());

解决方案 6.7

一旦你定义了 summean 函数,variance 函数就相当简单:

static Function<List<Double>, Option<Double>> variance =
      ds -> mean.apply(ds)
                .flatMap(m -> mean.apply(ds.map(x -> Math.pow(x - m, 2))));

注意,使用函数不是强制性的。如果你需要将它们作为参数传递给高阶函数,则必须使用函数,但当你只需要应用它们时,函数式方法可能更简单易用。

如果你更喜欢在可能的情况下使用方法,你可能会得到以下解决方案:

public static Double sum(List<Double> ds) {
  return sum_(0.0, ds).eval();
}

public static TailCall<Double> sum_(Double acc, List<Double> ds) {
  return ds.isEmpty()
      ? ret(acc)
      : sus(() -> sum_(acc + ds.head(), ds.tail()));
}

public static Option<Double> mean(List<Double> ds) {
  return ds.isEmpty()
      ? Option.none()
      : Option.some(sum(ds) / ds.length());
}

public static Option<Double> variance(List<Double> ds) {
  return mean(ds).flatMap(m -> mean(ds.map(x -> Math.pow(x - m, 2))));
}

如你所见,函数式方法由于两个原因更容易使用。首先,你不需要在函数名称和参数之间写 .apply。其次,类型更短,因为你不需要写 Function 这个词。因此,你将尽可能多地使用函数式方法而不是函数。

但记住,从一种切换到另一种非常容易。给定这个方法,

B aToBmethod(A a) {
  return ...
}

你可以通过编写以下内容创建一个等效函数:

Function<A, B> aToBfunction = a -> aToBmethod(a);

或者你可以使用方法引用:

Function<A, B> aToBfunction = this::aToBmethod;

相反,你可以从前面的函数创建一个方法:

B aToBmethod2(A a) {
  return aToBfunction.apply(a)
}

variance 的实现所示,使用 flatMap 可以构建具有多个阶段的计算,其中任何一个都可能失败,并且一旦遇到第一个失败,计算就会终止,因为 None.flatMap(f) 会立即返回 None 而不应用 f

6.3.5. 其他组合选项的方法

决定使用 Option 可能会带来巨大的影响。特别是,一些开发者可能认为他们的旧代码将变得过时。现在你需要一个从 Option<A>Option<B> 的函数,而你只有将 A 转换为 B 的方法的 API,你该怎么办?你需要重写所有库吗?一点也不用。你可以轻松地适应它们。

练习 6.8

定义一个 lift 方法,它接受一个从 AB 的函数作为其参数,并返回一个从 Option<A>Option<B> 的函数。像往常一样,使用你已定义的方法。图 6.2 显示了 lift 方法的工作原理。

图 6.2. 提升函数

图 6.2

提示

使用 map 方法在 Option 类中创建一个静态方法。

解答 6.8

解决方案相当简单:

static <A, B> Function<Option<A>, Option<B>> lift(Function<A, B> f) {
  return x -> x.map(f);
}

当然,你现有的大多数库不会包含函数,而是包含方法。将接受 A 作为参数并返回 B 的方法转换为从 Option<A>Option<B> 的函数很容易。例如,提升 String.toUpperCase 方法可以这样进行:

Function<Option<String>, Option<String>> upperOption =
                                             lift(x -> x.toUpperCase());

或者你可以使用方法引用:

Function<Option<String>, Option<String>> upperOption =
                                             lift(String::toUpperCase);

练习 6.9

这样的解决方案对于会抛出异常的方法是无用的。编写一个与抛出异常的方法一起工作的 lift 方法。

解答 6.9

你所需要做的就是将 lift 返回的函数的实现包装在一个 try ... catch 块中,如果抛出异常则返回 None

static <A, B> Function<Option<A>, Option<B>> lift(Function<A, B> f) {
  return x -> {
    try {
      return x.map(f);
    } catch (Exception e) {
      return Option.none();
    }
  };
}

你可能还需要将一个从 AB 的函数转换为从 AOption<B> 的函数。你可以应用相同的技巧:

static <A, B> Function<A, Option<B>> hlift(Function<A, B> f) {
  return x -> {
    try {
      return Option.some(x).map(f);
    } catch (Exception e) {
      return Option.none();
    }
  };
}

注意,然而,这并不很有用,因为异常被丢失了。在下一章,你将学习如何解决这个问题。

如果你想使用接受两个参数的旧方法呢?比如说,你想使用 Integer.parseInt(String s, int radix)Option<String>Option<Integer> 一起。你该如何做?

第一步是创建从这个方法来的函数。这很简单:

Function<Integer, Function<String, Integer>> parseWithRadix =
                       radix -> string -> Integer.parseInt(string, radix);

注意,我已经在这里反转了参数以创建一个柯里化函数。这很有意义,因为仅应用基数将给我们一个有用的函数,可以解析具有给定基数的所有字符串:

Function<String, Integer> parseHex = parseWithRadix.apply(16);

反向(先应用 String)将没有太多意义。

练习 6.10

编写一个名为 map2 的方法,它接受一个 Option<A>、一个 Option<B> 和一个从 (A, B)C 的函数(以柯里化形式),并返回一个 Option<C>

提示

使用 flatMap 和可能还有 map 方法。

解答 6.10

这是使用 flatMapmap 的解决方案。这个模式非常重要,你经常会遇到它。我们将在第八章(kindle_split_015.xhtml#ch08)中再次回到这个话题。

<A, B, C> Option<C> map2(Option<A> a,
                         Option<B> b,
                         Function<A, Function<B, C>> f) {
  return a.flatMap(ax -> b.map(bx -> f.apply(ax).apply(bx)));
}

现在,使用 map2,你可以使用任何两个参数的方法,就像它是为操作 Option 而创建的一样。

更多参数的方法怎么办?这里是一个 map3 方法的例子:

<A, B, C, D> Option<D> map3(Option<A> a,
                            Option<B> b,
                            Option<C> c,
                            Function<A, Function<B, Function<C, D>>> f) {
  return a.flatMap(ax -> b.flatMap(bx -> c.map(cx ->
                                   f.apply(ax).apply(bx).apply(cx))));
}

你看到模式了吗?

6.3.6. 使用 Option 组合列表

组合 Option 实例并不需要所有功能。你定义的每个新类型都必须在某个时刻与其他任何类型组合。在前一章中,你定义了 List 类型。为了编写有用的程序,你需要能够组合 ListOption

最常见的操作是将 List<Option<A>> 转换为 Option<List<A>>。当你使用从 BOption<A> 的函数将 List<B> 映射时,你会得到一个 List<Option<A>>。通常,如果你需要的结果是 Some<List<A>>,则所有元素都是 Some<A>,如果至少有一个元素是 None<A>,则结果是 None<List<A>>

练习 6.11

编写一个函数 sequence,将 List<Option<T>> 合并成一个 Option<List<T>>。如果原始列表中的所有值都是 Some 实例,则它将是一个 Some<List<T>>,否则是一个 None<List<T>>。以下是它的签名:

Option<List<A>> sequence(List<Option<A>> list)
提示

为了找到你的方向,你可以测试列表是否为空,如果不是,则对 sequence 进行递归调用。然后,记住 foldRightfoldLeft 抽象了递归,你可以使用这些方法之一来实现 sequence

解决方案 6.11

这是一个显式递归版本,如果 list.head()list.tail() 被公开,则可以用来:

<A> Option<List<A>> sequence(List<Option<A>> list) {
  return list.isEmpty()
      ? some(List.list())
      : list.head()
            .flatMap(hh -> sequence(list.tail()).map(x -> x.cons(hh)));
}

list.head()list.tail() 应该只能在 List 类内部使用,因为这些方法可能会抛出异常。幸运的是,sequence 方法也可以使用 foldRightmap2 来实现。这甚至更好,因为 foldRight 使用基于堆的递归。

<A> Option<List<A>> sequence(List<Option<A>> list) {
  return list.foldRight(some(List.list()),
                             x -> y -> map2(x, y, a -> b -> b.cons(a)));
}

考虑以下示例:

Function<Integer, Function<String, Integer>> parseWithRadix =
                       radix -> string -> Integer.parseInt(string, radix);
Function<String, Option<Integer>> parse16 =
                                  Option.hlift(parseWithRadix.apply(16));
List<String> list = List.list("4", "5", "6", "7", "8", "9");
Option<List<Integer> result = Option.sequence(list.map(parse16));

这会产生预期的结果,但效率不高,因为 map 方法和 sequence 方法都会调用 foldRight

练习 6.12

定义一个 traverse 方法,它产生相同的结果,但只调用一次 foldRight。以下是它的签名:

Option<List<B>> traverse(List<A> list, Function<A, Option<B>> f)
提示

你需要根据 traverse 实现 sequence。不要使用递归。优先使用抽象递归的 foldRight 方法。

解决方案 6.12

首先定义 traverse 方法:

<A, B> Option<List<B>> traverse(List<A> list,
                                Function<A, Option<B>> f) {
  return list.foldRight(some(List.list()),
                    x -> y -> map2(f.apply(x), y, a -> b -> b.cons(a)));
}

然后,你可以根据 traverse 重新定义 sequence 方法:

<A> Option<List<A>> sequence(List<Option<A>> list) {
  return traverse(list, x -> x);
}

6.4. 其他 Option 实用工具

为了使 Option 尽可能有用,你需要添加一些实用方法。其中一些方法是必须的,而其他方法则值得怀疑,因为它们的使用并不符合函数式编程的精神。尽管如此,你仍然需要考虑添加它们。你可能需要一个方法来测试 Option 是否为 NoneSome。你可能还需要一个 equals 方法来比较选项,在这种情况下,你一定不要忘记定义一个兼容的 hashCode 方法。

6.4.1. 测试 Some 或 None

到目前为止,你不需要测试选项来知道它是 Some 还是 None。理想情况下,你永远不应该需要这样做。在实践中,尽管如此,有时使用这个技巧比求助于真正的函数式技术更简单。

例如,你将 map2 方法定义为

<A, B, C> Option<C> map2(Option<A> a,
                         Option<B> b,
                         Function<A, Function<B, C>> f) {
  return a.flatMap(ax -> b.map(bx -> f.apply(ax).apply(bx)));
}

这非常聪明,因为你想要显得聪明,你可能更喜欢这个解决方案。但有些人可能觉得以下版本更容易理解:

<A, B, C> Option<C> map2(Option<A> a,
                         Option<B> b,
                         Function<A, Function<B, C>> f) {
  return a.isSome() && b.isSome()
      ? some(f.apply(a.get()).apply(b.getOrThrow()))
      : none();
}
测试代码

如果你想要测试这段代码,你首先需要定义isSome方法,但这并不是鼓励你使用这种非函数式技术。你应该始终首选第一种形式,但也应该完全理解两种形式之间的关系。此外,你可能会发现自己某天需要使用isSome方法。

6.4.2. 等于和 hashCode

更重要的是equalshashCode方法的定义。正如你所知,这些方法密切相关,必须保持一致的定义。如果两个Option实例的equalstrue,它们的hashCode方法应该返回相同的值。(反之则不成立。具有相同hashCode的对象可能并不总是相等的。)

这里是SomeequalshashCode方法的实现:

@Override
public boolean equals(Object o) {
  return (this == o || o instanceof Some)
                             && this.value.equals(((Some<?>) o).value);
}

@Override
public int hashCode() {
  return Objects.hashCode(value);
}

下面是None的相应实现:

@Override
public boolean equals(Object o) {
  return this == o || o instanceof None;
}

@Override
public int hashCode() {
  return 0;
}

6.5. 如何以及何时使用 Option

如你所知,Java 8 引入了Optional类,有些人可能认为它与你的Option相同,尽管它的实现方式完全不同,并且缺少了你在Option中放入的大多数功能方法。关于 Java 8 的新特性是否是向函数式编程迈进的一步,存在很多争议。它们确实如此,尽管这并非官方立场。官方立场是Optional不是一个函数式特性。

这里是 Oracle 的 Java 语言架构师 Brian Goetz 在 Stack Overflow 上关于这个问题的回答。问题是“Java 8 的 getters 应该返回 optional 类型吗?”以下是 Brian Goetz 的回答:^([2])

²

完整的讨论可以在mng.bz/Rkk1阅读。

当然,人们会做他们想做的事情。但我们添加这个功能时确实有一个明确的目的,那就是它不是一个通用的MaybeSome类型,尽管很多人希望我们这样做。我们的目的是提供一个有限的机制,用于库方法的返回类型,当需要明确表示“无结果”时,使用null几乎肯定会引起错误。

例如,你可能永远不应该用它来返回结果数组或结果列表;相反,应该返回一个空数组或列表。你几乎从不应该将它用作某个对象的字段或方法参数。

我认为将其作为 getters 的返回值使用肯定是一种过度使用。

Optional没有错误到需要避免,它只是不是许多人希望它成为的那样,因此我们相当关注过度使用的风险。

(公共服务公告:除非你能证明它永远不会是null,否则永远不要调用Optional.get;相反,使用像orElseifPresent这样的安全方法。回顾过去,我们本应该将get命名为类似于getOrElse-ThrowNoSuch-Element-Exception的东西,这样就可以更清楚地表明这是一个非常危险的方法,它从根本上破坏了Optional的整个目的。教训已经吸取。)

这是一个非常重要的答案,值得深思。首先,也许这是最重要的部分,“人们会做他们想做的事情。”这里没有更多要说的。只做你做的事情。这并不意味着你应该不考虑后果地做任何你想做的事情。但你可以自由地尝试所有想到的解决方案。你不应该仅仅因为Optional不是用来那样使用的,就避免以特定方式使用它。想象一下第一个想到用石头砸东西的人。他有两个选择(这不是巧合!):因为显然石头不是用来当锤子的,所以避免这样做,或者只是尝试一下。

第二,Goetz 说,除非你能证明它永远不会是null,否则不应该调用get。这样做会完全破坏使用Option的任何好处。但你不需要给get一个很长的名字。getOrThrow就可以完成这项工作。请注意,为了表示没有结果而返回一个空列表本身并不能解决问题。忘记测试列表是否为空会导致IndexOutOfBoundException而不是NullPointerException。这并没有好到哪里去!

何时使用 getOrThrow

正确的建议是尽可能避免使用getOrThrow。作为一个经验法则,每次你发现自己在外部Option类中使用这个方法时,你应该考虑是否有其他可行的方式。使用getOrThrow意味着你正在退出Option类的函数安全性。

对于List类的headtail方法来说,情况也是一样的。如果可能的话,这些方法不应该在List类外部使用。直接访问ListOption等类中包含的值总是存在风险,如果在NoneNil子类上这样做,可能会引发NullPointerException。在库类中可能无法避免这种情况,但在业务类中应该避免。这就是为什么最好的解决方案是将此方法设为受保护的,这样它就只能从Option类内部调用。

但最重要的点是原始问题:getter 应该返回 Option(或 Optional)吗?通常情况下,它们不应该,因为属性应该是最终的,并且在声明或构造函数中初始化,所以完全没有必要让 getter 返回 Option。(然而,我必须承认,在构造函数中初始化字段并不能保证在它们初始化之前无法访问属性。这是一个可以通过将类设置为最终类来轻松解决的问题,如果可能的话。)

但有些属性可能是可选的。例如,一个人总是会有一个名字和一个姓氏,但他们可能没有电子邮件。你该如何表示这一点?通过将属性存储为 Option。在这种情况下,getter 将必须返回一个 Option。说“常规地将其用作 getter 的返回值肯定是一种过度使用”就像说没有值的属性应该设置为 null,相应的 getter 应该返回 null。这完全破坏了拥有 Option 的好处。

那么关于接受 Option 作为参数的方法呢?一般来说,这种情况不应该发生。为了组合返回 Option 的方法,你不应该使用接受 Option 作为参数的方法。例如,为了组合以下三个方法,你不需要更改方法以使它们接受 Option 作为参数:

Option<String> getName () {
  ...
}

Option<String> validate(String name) {
  ...
}

Option<Toon> getToon(String name) {
  ...
}

假设 validate 方法是 Validate 类的静态方法,而 toonMap 是一个具有 get 实例方法的 Map 实例,那么组合这些方法的函数式方法是以下这样:

Option<Toon> toon = getName()
                      .flatMap(Validate::validate)
                      .flatMap(toonMap::get)

因此,在业务代码中,接受 Option 作为参数的方法用途很小。

还有另一个原因,为什么 Option(或 Optional)可能很少(如果有的话)被使用。通常,数据的缺失是错误的结果,你通常应该通过在命令式 Java 中抛出异常来处理这些错误。正如我之前所说的,返回 Option.None 而不是抛出异常就像捕获异常然后默默地吞下它一样。这通常不是一笔价值十亿美元的错误,但它仍然是一个很大的错误。你将在下一章中学习如何处理这种情况。在那之后,你几乎再也不需要 Option 数据类型了。但别担心。你在这个章节中学到的所有内容仍然会非常有用。

Option 类型是你将反复使用的一种数据类型的简单形式。它是一个参数化类型,它有一个方法可以将 A 转换为 Option<A>,并且它有一个 flatMap 方法,可以用来组合 Option 实例。尽管它本身并不非常有用,但它让你熟悉了函数式编程的非常基本的概念。

6.6. 摘要

  • 你需要一个方法来表示可选数据,这意味着数据可能存在也可能不存在。

  • null 指针是表示数据缺失最不实用且危险的方式。

  • 监视值和空列表是表示数据缺失的另一种可能方式,但它们组合得并不好。

  • Option 数据类型是表示可选数据的一种更好的方式。Some 子类型表示数据,而 None 子类型表示数据的缺失。

  • 函数可以通过 mapflatMap 方法应用于 Option,从而实现轻松的 Option 组合。

  • 操作值的函数可以提升为操作 Option 实例。

  • List 可以与 Option 组合。使用 sequence 方法,List<Option> 可以转换为 Option<List>

  • 可以比较 Option 实例的相等性。如果包装的值相等,则 Some 子类型的实例是相等的。因为只有一个 None 实例,所以所有 None 实例都是相等的。

  • 虽然 Option 可能代表产生异常的计算结果,但所有关于发生的异常的信息都丢失了。在下一章中,你将学习如何处理这个问题。

第七章. 处理错误和异常

本章涵盖的内容

  • 使用Either类型保存错误信息

  • 使用有偏Result类型简化错误处理

  • 访问Result内部的数据

  • Result数据应用效果

  • 将函数提升到在Result上操作

在第六章中,你学习了如何使用Option数据类型处理可选数据,而无需通过操作null引用来处理。正如你所看到的,这种数据类型在处理数据缺失(且非错误结果)时非常完美。但它不是处理错误的有效方式,因为尽管它允许你干净地报告数据缺失,但它吞没了这种缺失的原因。因此,所有缺失的数据都被同等对待,而调用者必须尝试弄清楚发生了什么,这通常是不可能的。

7.1. 需要解决的问题

大多数情况下,数据缺失是输入数据或计算错误的结果。这两种情况非常不同,但最终结果相同:数据缺失,而它本应存在。

在经典的命令式编程中,当一个函数或方法接受一个对象参数时,大多数程序员都知道他们应该测试这个参数是否为null。如果参数是null,他们应该做什么通常是未定义的。记住第六章中的列表 6.3 的例子:

Option<String> goofy = toons.get("Goofy").flatMap(Toon::getEmail);

System.out.println(goofy.getOrElse(() -> "No data"));

在这个例子中,由于"Goofy"键不在映射中,所以得到了“无数据”的输出。这可以被视为一个正常情况。但看看这个例子:

Option<String> toon = getName()
                         .flatMap(toons::get)
                         .flatMap(Toon::getEmail);

System.out.println(toon.getOrElse(() -> "No data"));

Option<String> getName() {
  String name = // retrieve the name from the user interface
  return name;
}

如果用户输入一个空字符串,你应该怎么办?一个明显的解决方案是验证输入并返回Option<String>。在没有有效字符串的情况下,你可以返回None。但尽管你还没有学习如何函数式地让用户输入字符串,你可以确信这样的操作可能会抛出异常。程序看起来像这样:

Option<String> toon = getName()
                         .flatMap(Example::validate)
                         .flatMap(toons::get)
                         .flatMap(Toon::getEmail);

System.out.println(toon.getOrElse(() -> "No data"));

Option<String> getName() {
  try {
    String name = // retrieve the name from the user interface
    return Option.some(name);
  } catch (Exception e) {
    return Option.none();
  }
}

Option<String> validate(String name) {
  return name.length() > 0 ? Option.some(name) : Option.none();
}

现在考虑可能会发生什么:

  • 一切顺利,你会在控制台看到一个电子邮件。

  • 抛出IOException,并在控制台打印出“无数据”。

  • 用户输入的名称无效,你得到“无数据”。

  • 名称验证通过,但在映射中找不到。你得到“无数据”。

  • 名称在映射中找到,但对应的卡通没有电子邮件。你得到“无数据”。

你需要的是在控制台打印不同的消息来指示每种情况发生的情况。

如果你想要使用你已知的类型,你可以使用 Tuple<Option<T>, Option<String>> 作为每个方法的返回类型,但这有点复杂。Tuple 是一个乘积类型,这意味着 Tuple<T, U> 可以表示的元素数量是 T 的可能数量乘以 U 的可能数量。你不需要这个,因为每次你有 T 的值时,U 将会是 None。同样,每次 USome 时,T 将会是 None。你需要的是一个和类型,这意味着一个 E<T, U> 类型,它将持有 TU 中的一个,但不是 TU 两个。

7.2. Either 类型

设计一个可以持有 TU 的类型很容易。你只需要稍微修改 Option 类型,将 None 类型改为可以持有值。你也会更改名称。Either 类的两个私有子类将被称为 LeftRight

列表 7.1. Either 类型
public abstract class Either<T, U> {

  private static class Left<T, U> extends Either<T, U> {

    private final T value;

    private Left(T value) {
      this.value = value;
    }

    @Override
    public String toString() {
      return String.format("Left(%s)", value);
    }
  }

  private static class Right<T, U> extends Either<T, U> {

    private final U value;

    private Right(U value) {
      this.value = value;
    }
    @Override
    public String toString() {
      return String.format("Right(%s)", value);
    }
  }

  public static <T, U> Either<T, U> left(T value) {
    return new Left<>(value);
  }

  public static <T, U> Either<T, U> right(U value) {
    return new Right<>(value);
  }
}

现在,你可以轻松地使用 Either 而不是 Option 来表示可能由于错误而缺失的值。你必须用你的数据和错误类型参数化 Either。按照惯例,你将使用 Right 子类来表示成功(即“正确”),而使用 Left 来表示错误。但你不会调用子类为 Wrong,因为 Either 类型可以用来表示可以由一种类型或另一种类型表示的数据,两者都是有效的。

当然,你必须选择哪种类型将代表错误。你可以选择 String 来携带错误信息,或者你可以选择 Exception。例如,你可以在 第六章 中定义的 max 函数如下修改:

<A extends Comparable<A>> Function<List<A>, Either<String, A>> max() {
  return xs -> xs.isEmpty()
      ? Either.left("max called on an empty list")
      : Either.right(xs.foldLeft(xs.head(), x -> y -> x.compareTo(y) < 0 ?
                                                                  x : y));
}

7.2.1. 组合 Either

要组合返回 Either 的方法和函数,你需要定义与 Option 类上定义的相同的方法。

练习 7.1

定义一个 map 方法,将 Either<E, A> 转换为 Either<E, B>,给定一个从 AB 的函数。map 方法的签名如下:

public abstract <B> Either<E, B> map(Function<A, B> f);
提示

我使用了类型参数 EA 来明确指出你应该映射哪一侧,E 代表 错误。但也可以定义两个 map 方法(可以称它们为 mapLeftmapRight),以映射 Either 实例的一侧或另一侧。换句话说,你正在开发一个“有偏”的 Either 版本,它只能在一侧进行映射。

解答 7.1

Left 的实现比 OptionNone 实现要复杂一些,因为你必须构造一个新的 Either,它持有与原始相同的(错误)值:

public <B> Either<E, B> map(Function<A, B> f) {
  return new Left<>(value);
}

Right 的实现与 Some 中的实现完全相同:

public <B> Either<E, B> map(Function<A, B> f) {
  return new Right<>(f.apply(value));
}

练习 7.2

定义一个 flatMap 方法,将 Either<E, A> 转换为 Either<E, B>,给定一个从 AEither<E, B> 的函数。flatMap 方法的签名如下:

public abstract <B> Either<E, B> flatMap(Function<A, Either<E, B>> f);

解答 7.2

Left 的实现与 map 方法完全相同:

public <B> Either<E, B> flatMap(Function<A, Either<E, B>> f) {
  return new Left<>(value);
}

Right 的实现与 Option.flatMap 方法相同:

public <B> Either<E, B> flatMap(Function<A, Either<E, B>> f) {
  return f.apply(value);
}

练习 7.3

定义具有以下签名的getOrElseorElse方法:

A getOrElse(Supplier<A> defaultValue)

Either<E, A> orElse(Supplier<Either<E, A>> defaultValue)
提示

并非所有练习都有令人满意的解决方案!

解答 7.3

orElse方法可以在Either类中定义,因为相同的实现适用于两个子类:

public Either<E, A> orElse(Supplier<Either<E, A>> defaultValue) {
  return map(x -> this).getOrElse(defaultValue);
}

getOrElse方法的解决方案很简单。在Right子类中,你只需返回包含的值:

public A getOrElse(Supplier<A> defaultValue) {
  return value;
}

Left子类中,只需返回默认值:

public A getOrElse(Supplier<A> defaultValue) {
  return defaultValue.get();
}

这种方法可行,但远非理想。问题是,如果没有值可用,你不知道发生了什么。你只是得到默认值,甚至不知道它是计算的结果还是错误的结果。为了正确处理错误情况,你需要一个已知左类型的Either的偏置版本。而不是使用Either(顺便说一下,Either有很多其他有趣的用途),你可以创建一个使用已知固定类型的Left类的专用版本。

你可能会问的第一个问题是,“我应该使用什么类型?”显然,会想到两种不同的类型:StringRuntimeException。字符串可以保存错误消息,就像异常一样,但许多错误情况会产生异常。使用String作为Left值携带的类型将迫使你忽略异常中的相关信息,而只使用包含的消息。因此,最好使用RuntimeException作为Left值。这样,如果你只有一条消息,你可以将其包装成一个异常。

7.3. 结果类型

因为新的类型通常表示可能失败的计算结果,所以你可以将其称为Result。它与Option类型非常相似,不同之处在于子类被命名为SuccessFailure,如下所示。

列表 7.2. Result

这个类与Option类非常相似,除了存储了异常。

7.3.1. 向 Result 类添加方法

你需要在Result类中定义与在OptionEither类中定义相同的方法,但有细微差别。

练习 7.4

Result类定义mapflatMapgetOrElseorElse。对于getOrElse,你可以定义两个方法:一个接受一个值作为其参数,另一个接受一个Supplier。以下是签名的示例:

public abstract V getOrElse(final V defaultValue);
public abstract V getOrElse(final Supplier<V> defaultValue);
public abstract <U> Result<U> map(Function<V, U> f);
public abstract <U> Result<U> flatMap(Function<V, Result<U>> f);
public Result<V> orElse(Supplier<Result<V>> defaultValue)

getOrElse的第一个版本在默认值是字面量时很有用,因为它们已经评估过了。在这种情况下,你不需要使用懒加载。

解答 7.4

这次,你不会在getOrElse上遇到问题,因为你只需要抛出Failure中包含的异常。所有其他方法都与Either类中的方法非常相似。以下是Success类的实现:

public V getOrElse(V defaultValue) {
  return value;
}

public V getOrElse(Supplier<V> defaultValue) {
  return value;
}

public <U> Result<U> map(Function<V, U> f) {
  try {
    return success(f.apply(successValue()));
  } catch (Exception e) {
    return failure(e.getMessage(), e);
  }
}

public <U> Result<U> flatMap(Function<V, Result<U>> f) {
  try {
    return f.apply(successValue());
  } catch (Exception e) {
    return failure(e.getMessage());
  }
}

下面是Failure类的实现:

public V getOrElse(V defaultValue) {
  return defaultValue;
}

public V getOrElse(Supplier<V> defaultValue) {
  return defaultValue.get();
}

public <U> Result<U> map(Function<V, U> f) {
  return failure(exception);
}

public <U> Result<U> flatMap(Function<V, Result<U>> f) {
  return failure(exception);
}

Option中,mapflatMap不能在Failure类中返回this,因为类型将无效。

最后,你可以在父类中定义orElse方法,因为实现对于两个子类都是有效的:

public Result<V> orElse(Supplier<Result<V>> defaultValue) {
  return map(x -> this).getOrElse(defaultValue);
}

7.4. 结果模式

Result类现在可以以函数式方式使用,这意味着通过组合表示可能成功或失败的计算的方法。这很重要,因为Result和类似类型通常被描述为可能包含或不包含值的容器。这种描述部分是错误的。Result是一个可能存在或不存在的值的计算上下文。使用它的方式不是通过检索值,而是通过使用其特定方法组合Result的实例。

例如,你可以修改之前的ToonMail示例以使用这个类。首先,你必须像列表 7.3 和 7.4 中所示修改MapToon类。

列表 7.3. 返回Result的修改后的Map

列表 7.4. 修改后的Toon类及其修改后的mail属性

现在你可以像下面这样修改ToonMail程序。

列表 7.5. 使用Result修改后的程序

列表 7.5 中的程序使用getName方法模拟一个可能抛出异常的输入操作。要表示抛出异常,只需返回一个包含异常的Failure即可。

注意,各种返回Result的操作是如何组合的。你不需要访问Result中包含的值(这可能是一个异常)。flatMap方法用于这种组合。

尝试使用各种getName方法的实现运行这个程序,例如这些:

return Result.success("Mickey");
return Result.failure(new IOException("Input error"));
return Result.success("Minnie");
return Result.success("Goofy");

下面是程序在每种情况下打印的内容:

Success(mickey@disney.com)
Failure(Input error)
Failure(Minnie Mouse has no mail)
Failure(Key Goofy not found in map)

这个结果看起来可能不错,但实际上并不好。问题是,由于 Minnie 没有电子邮件,而 Goofy 不在地图中,它们都被报告为失败。它们可能是失败,但也可能是正常情况。毕竟,如果你认为没有电子邮件是失败,你就不会允许创建一个没有电子邮件的Toon实例。显然,这并不是失败,而只是可选数据。对于地图来说也是如此。如果一个键不在地图中(假设它应该在那里),这可能是一个错误,但从地图的角度来看,它只是可选数据。

你可能认为这不是问题,因为你已经有了这种类型的类型:你在第六章中开发的Option类型。但看看你如何组合你的函数:

getName().flatMap(toons::get).flatMap(Toon::getEmail);

这之所以可能,仅仅是因为getNameMap.getToon.getEmail都返回一个Result。如果Map.getToon.getMail返回Option,它们就不再与getName组合。

仍然可以将Result转换为Option,然后再转换回来。例如,你可以在Result中添加一个toOption方法:

public abstract Option<V> toOption()

Success的实现将是

public Option<V> toOption() {
  return Option.some(value);
}

Failure的实现将是

public Option<V> toOption() {
  return Option.none();
}

你可以使用它如下所示:

Option<String> result =
     getName().toOption().flatMap(toons::get).flatMap(Toon::getEmail);

当然,这需要您使用第六章中定义的Map版本以及Toon类的特定版本:

public class Toon {
  private final String firstName;
  private final String lastName;
  private final Option<String> email;

  Toon(String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.email = Option.none();
  }

  Toon(String firstName, String lastName, String email) {
    this.firstName = firstName;
    this.lastName = lastName;
    this.email = Option.some(email);
  }

  public Option<String> getEmail() {
    return email;
  }
}

但您会失去使用Result的所有好处!现在如果在getName方法内部抛出异常,它仍然被包裹在Failure中,但异常在toOption方法中丢失,程序简单地打印

none

您可能会认为应该反过来将Option转换为Result。这会起作用(尽管,在您的例子中,您应该在Map.getToon.getMail返回的Option实例上调用新的toResult方法),但这会很繁琐,并且因为您通常需要将Option转换为Result,所以一个更好的方法是将这种转换铸造成Result类。您只需创建一个新的子类,对应于None情况,因为Some情况不需要转换,除了将其名称更改为Success。列表 7.6 显示了带有新子类Empty的新Result类。

列表 7.6. 新的Result类处理错误和可选数据

现在,您可以再次修改您的ToonMail应用程序,如列表 7.7 至 7.9 所示。

列表 7.7. 使用新的Result.Empty类处理可选数据的Map

列表 7.8. 使用Result.Empty处理可选数据的Toon

列表 7.9. 正确处理可选数据的ToonMail应用程序

现在您的程序为getName方法的每个实现打印以下结果(在列表 7.9 中已注释):

Success(mickey@disney.com)
Failure(Input error)
Empty()
Empty()

您可能会认为缺少了某些内容,因为您无法区分两种不同的空情况,但这并不是事实。对于可选数据不需要错误消息,所以如果您认为需要消息,数据就不是可选的。成功结果是可选的,但在那种情况下,消息是强制性的,所以您应该使用Failure。这将创建一个异常,但没有任何东西强迫您抛出它!

7.5. 高级Result处理

到目前为止,你看到的 Result 的使用非常有限。Result 永远不应该用于直接访问包装值(如果存在)。你在上一个例子中使用 Result 的方式对应于更简单的特定组合用例:获取一个计算的结果并将其用作下一个计算的输入。还存在更具体的用例。你可以选择仅在结果匹配某些谓词(这意味着某些条件)时使用结果。你也可以使用失败情况,你需要将失败映射到其他东西,或者将失败转换为成功或异常(Success<Exception>)。你可能还需要将多个 Result 作为单个计算的输入。你可能将受益于一些辅助方法,这些方法可以从计算中创建 Result,以便处理遗留代码。最后,有时你需要将效果应用于 Result

7.5.1. 应用谓词

将谓词应用于 Result 是你经常会做的事情。这是一件可以很容易地抽象的事情,这样你就可以只写一次。

练习 7.5

编写一个名为 filter 的方法,该方法接受一个从 TBoolean 的函数作为条件,并返回一个 Result<T>,这将根据包装值是否满足条件而返回 SuccessFailure。其签名将是

filter(Function<T, Boolean> f);

创建一个接受条件作为第一个参数和 String 作为第二个参数的第二个方法,并使用字符串参数作为潜在的 Failure 情况。

提示

虽然在 Result 类中定义抽象方法并在子类中实现它们是可能的,但请尽量不要这样做。相反,使用你之前定义的一个或多个方法在 Result 类中创建单个实现。

7.5 解答

你必须创建一个函数,该函数接受包装值作为参数,将其应用于函数,并在条件成立时返回相同的 Result,否则返回 Empty(或 Failure)。然后你只需要 flatMap 这个函数:

public Result<T> filter(Function<T, Boolean> p) {
  return flatMap(x -> p.apply(x)
      ? this
      : failure("Condition not matched"));
}
public Result<T> filter(Function<T, Boolean> p, String message) {
  return flatMap(x -> p.apply(x)
      ? this
      : failure(message));
}

练习 7.6

定义一个 exists 方法,该方法接受一个从 TBoolean 的函数,如果包装值匹配条件,则返回 true,否则返回 false。这是方法的签名:

boolean exists(Function<T, Boolean> p);
提示

再次提醒,请尽量不要在每个子类中定义实现。相反,使用你拥有的方法在父类中创建单个实现。

7.6 解答

解决方案很简单,将函数 mapResult<T>,得到一个 Result<Boolean>,然后使用 getOrElse 并将 false 作为默认值。你不需要使用 Supplier,因为默认值是一个字面量:

public boolean exists(Function<T, Boolean> p) {
  return map(p).getOrElse(false);
}

使用exists作为此方法的名称可能看起来有些可疑。但这是可以应用于列表的相同方法,如果至少有一个元素满足条件,则返回true,因此使用相同的名称是有意义的。有些人可能会争论,这种实现也可以用于返回true的所有列表元素都满足条件的forAll方法。这取决于您是选择另一个名称还是定义一个具有相同实现的forAll方法在Result类中。重要的是理解ListResult的相似之处和不同之处。

7.5.2. 映射失败

有时将Failure转换为另一个Failure是有用的,如下例所示。

列表 7.10. 内存监控

图片

图片

在多线程 Java 程序中,OutOfMemoryError(OOME)通常会崩溃一个线程,但不会崩溃应用程序,使其处于不确定状态。为了解决这个问题,您必须捕获错误并干净地停止应用程序。

通常使用UncaughtException-Handler来捕获 OOME。这种方法允许您将处理程序放在低级库中,并继续要求业务开发者不要捕获 OOMEs。但是,当捕获到 OOME 时,有时剩余的内存不足以运行处理程序,导致应用程序出现异常行为。解决此问题的一种方法是用MemoryPoolMXBean监控内存。此解决方案允许您注册一个通知处理程序,如果垃圾回收后释放的内存不足,它将被自动调用。

在示例中,如果您使用0.8作为参数值调用monitorMemory方法,如果垃圾回收后堆占用率仍然超过 80%,则将调用通知监听器。此时,您希望有足够的内存来干净地记录问题并停止应用程序。

此程序运行良好(尽管代码很糟糕,这主要是由于 Java 库的编写方式,方法接受null作为参数,迫使您将MemoryPoolMXBean强制转换为NotificationEmitter,但这又是另一个故事)。

注意,此程序使用了List上的first方法,而您尚未定义该方法。此方法与 filter 方法非常相似,尽管它返回一个Result,可能包含满足条件的第一个元素。

虽然程序可以工作,但您有一个问题:如果由于任何原因find-PSOldGenPool方法返回一个Failure,无论是由于您拼写错误"PS Old Gen"还是因为您正在使用 Java 的新版本,其中名称已更改,您将在Failure中收到以下错误消息:

No element satisfying function com.fpinjava.handlingerrors
                                 .listing07_10.MemoryMonitor$
$Lambda$3/1096979270@7b23ec81 in list
[sun.management.MemoryPoolImpl@3feba861,
sun.management.MemoryPoolImpl@5b480cf9,
sun.management.MemoryPoolImpl@6f496d9f,
sun.management.MemoryPoolImpl@723279cf,
sun.management.MemoryPoolImpl@10f87f48,
sun.management.MemoryPoolImpl@b4c966a, NIL]

练习 7.7

定义一个mapFailure方法,该方法接受一个String作为参数,并使用该字符串作为错误消息将一个Failure转换成另一个Failure。如果ResultEmptySuccess,则此方法应不执行任何操作。

提示

在父类中定义一个抽象方法。

解决方案 7.7

这里是父类中的抽象方法:

public abstract Result<T> mapFailure(String s);

EmptySuccess实现只是返回this

public Result<T> mapFailure(String s) {
  return this;
}

Failure实现将现有的异常包装到一个使用给定消息创建的新异常中。然后通过调用相应的静态工厂方法创建一个新的Failure

public Result<T> mapFailure(String s) {
  return failure(new IllegalStateException(s, exception));
}

你可以选择RuntimeException作为异常类型,或者一个更具体的自定义子类型RuntimeException。请注意,可能还有其他类似的方法是有用的,例如这些:

public abstract Result<T> mapFailure(String s, Exception e);
public abstract Result<T> mapFailure(Exception e);

另一个有用的方法是将Empty映射到Failure,给定一个String消息。

7.5.3. 添加工厂方法

你已经看到了如何从值创建SuccessFailure。一些其他的使用案例非常频繁,值得将其抽象为补充静态工厂方法。为了适应遗留库,你可能经常需要从可能为null的值创建Result。为此,你可以使用以下签名的静态工厂方法:

public static <T> Result<T> of(T value)
public static <T> Result<T> of(T value, String message)

一个从TBoolean的函数和一个T实例创建Result的方法也可能有用:

public static <T> Result<T> of(Function<T, Boolean> predicate, T value)
public static <T> Result<T> of(Function<T, Boolean> predicate,
                                           T value, String message)

练习 7.8

定义这些静态工厂方法。

提示

你必须在每个情况下做出选择。

解决方案 7.8

这个练习没有困难。以下是基于选择在未使用错误消息时返回Empty,否则返回Failure的可能实现:

public static <T> Result<T> of(T value) {
  return value != null
      ? success(value)
      : Result.failure("Null value");
}

public static <T> Result<T> of(T value, String message) {
  return value != null
      ? success(value)
      : failure(message);
}

public static <T> Result<T> of(Function<T, Boolean> predicate, T value) {
  try {
    return predicate.apply(value)
        ? success(value)
        : empty();
  } catch (Exception e) {
    String errMessage =
        String.format("Exception while evaluating predicate: %s", value);
    return Result.failure(new IllegalStateException(errMessage, e));
  }
}

public static <T> Result<T> of(Function<T, Boolean> predicate,
                               T value, String message) {
  try {
    return predicate.apply(value)
        ? Result.success(value)
        : Result.failure(String.format(message, value));
  } catch (Exception e) {
    String errMessage =
            String.format("Exception while evaluating predicate: %s",
                                         String.format(message, value));
    return Result.failure(new IllegalStateException(errMessage, e));
  }
}

注意,你应该处理消息参数可能为null的可能性。如果不这样做,将会抛出 NPE,因此null消息将被视为一个错误。相反,你可以检查参数,并在null的情况下使用默认值。这取决于你。在任何情况下,一致地检查null参数应该被抽象化,正如你将在第十五章中看到的 chapter 15。

7.5.4. 应用效果

到目前为止,你还没有对Result中包装的值应用任何效果,除了通过getOrElse获取这些值。这并不令人满意,因为它破坏了使用Result的优势。另一方面,你还没有学习到应用函数式技术所需的必要技术。效果包括任何修改外部世界中的事物,例如写入控制台、文件、数据库或可变组件的字段,或者发送本地或网络消息。

我现在要展示的技术不是函数式的,但它是一个有趣的高级抽象,允许你在不知道涉及的函数式技术的情况下使用Result。你可以使用这里展示的技术,直到我们查看函数式版本,或者你可能甚至会发现这足够强大,可以经常使用。

注意

本节讨论的技术是 Java 8 函数式构造所采取的方法,这并不奇怪,因为 Java 不是函数式编程语言。

要应用效果,请使用你在 第三章 中开发的 Effect 接口。这是一个非常简单的函数式接口:

public interface Effect<T> {
  void apply(T t);
}

你可以将这个接口命名为 Consumer 并定义一个 accept 方法,就像 Java 8 中的那样。我已经说过这个名字选得很糟糕,因为 Consumer 应该有一个 consume 方法。但实际上,Consumer 并不消费任何东西——在将效果应用于一个值之后,该值保持不变,仍然可用于进一步的计算或效果。

练习 7.9

定义一个 forEach 方法,它接受一个 Effect 作为参数并将其应用于包装值。

提示

Result 类中定义一个抽象方法,并在每个子类中实现它。

解决方案 7.9

这里是 Result 中的抽象方法声明:

public abstract void forEach(Effect<T> ef)

EmptyFailure 实现什么都不做。因此,你只需要在 Empty 中实现该方法,因为 Failure 扩展了这个类:

public void forEach(Effect<T> ef) {
  // Empty. Do nothing.
}

Success 实现很简单。你只需将效果应用于值:

public void forEach(Effect<T> ef) {
  ef.apply(value);
}

这个 forEach 方法对于你创建的 Option 类来说非常完美,在 第六章 中。但对于 Result 来说并非如此。通常,你希望在失败时采取特殊操作。处理失败的一个简单方法就是抛出异常。

练习 7.10

定义 forEachOrThrow 方法以处理此用例。以下是 Result 类中的签名:

public abstract void forEachOrThrow(Effect<T> ef)
提示

对于 Empty 的情况,你有选择权。

解决方案 7.10

Success 实现与 forEach 方法的实现相同。Failure 实现只是抛出包装的异常:

public void forEachOrThrow(Effect<T> ef) {
  throw exception;
}

Empty 实现可能是个问题。你可以选择什么都不做,因为 Empty 不是一个错误。或者你可以决定调用 forEachOrThrow 意味着你希望将数据的缺失转换为错误。这是一个艰难的决定。Empty 本身不是错误。如果你需要将其变为错误,可以使用 mapFailure 方法之一,所以可能最好在 Empty 中实现 forEachOrThrow 作为一种不执行任何操作的方法。

练习 7.11

当将效果应用于 Result 的更通用用例时,如果它是 Success,则应用效果;如果它是 Failure,则以某种方式处理异常。forEachOrThrow 方法对于抛出来说很好,但有时你只想记录错误并继续。与其定义一个用于记录的方法,不如定义一个 forEachOrException 方法,该方法将在值存在时应用效果并返回一个 Result。如果原始 ResultSuccess,则该 Result 将是 Empty;如果它是 Failure,则将是 EmptySuccess <RuntimeException>

解决方案 7.11

该方法在 Result 父类中声明为 abstract

public abstract Result<RuntimeException> forEachOrException(Effect<T> ef)

Empty 实现返回 Empty

public Result<RuntimeException> forEachOrException(Effect<T> ef) {
  return empty();
}

Success 实现将效果应用于包装值并返回 Empty

public Result<RuntimeException> forEachOrException(Effect<T> ef) {
  ef.apply(value);
  return empty();
}

Failure 实现返回一个包含原始异常的 Success<RuntimeException>,这样你就可以对其采取行动:

public Result<RuntimeException> forEachOrException(Effect<T> ef) {
  return success(exception);
}

这种方法的典型用例如下(使用一个假设的 Logger 类型及其 log 方法):

Result<Integer> result = getComputation();

result.forEachOrException(System.out::println).forEach(Logger::log);

记住,这些方法不是函数式的,但它们是使用 Result 的好方法。如果你更喜欢以函数式的方式应用效果,你将不得不等到 第十三章。

7.5.5. 高级结果组合

Result 的用例与 Option 大致相同。在前一章中,你定义了一个 lift 方法,通过将一个从 AB 的函数转换为一个从 Option<A>Option<B> 的函数来组合 Options。你也可以为 Result 做同样的事情。

练习 7.12

Result 编写一个 lift 方法。这将是 Result 类中的一个静态方法,其签名如下:

static <A, B> Function<Result<A>, Result<B>> lift(final Function<A, B> f)

解决方案 7.12

这里有一个非常简单的解决方案:

public static <A, B> Function<Result<A>, Result<B>> lift(final Function<A,
                                                                    B> f) {
  return x -> {
    try {
      return x.map(f);
    } catch (Exception e) {
      return failure(e);
    }
  };
}

练习 7.13

定义 lift2 用于将函数从 A 升级到 BC,以及 lift3 用于从 ABCD 的函数,以下是其签名:

public static <A, B, C> Function<Result<A>, Function<Result<B>,
                        Result<C>>> lift2(Function<A, Function<B, C>> f)
public static <A, B, C, D> Function<Result<A>,
            Function<Result<B>, Function<Result<C>,
            Result<D>>>> lift3(Function<A, Function<B, Function<C, D>>> f)

解决方案 7.13

这里是解决方案:

public static <A, B, C> Function<Result<A>, Function<Result<B>,
                        Result<C>>> lift2(Function<A, Function<B, C>> f) {
  return a -> b -> a.map(f).flatMap(b::map);
}

public static <A, B, C, D> Function<Result<A>,
          Function<Result<B>, Function<Result<C>,
          Result<D>>>> lift3(Function<A, Function<B, Function<C, D>>> f) {
  return a -> b -> c -> a.map(f).flatMap(b::map).flatMap(c::map);
}

我猜你可以看到模式。你可以用这种方法为任何数量的参数定义 lift

练习 7.14

在 第六章 中,你定义了一个 map2 方法,它接受一个 Option<A>、一个 Option<B> 和一个从 ABC 的函数作为参数,并返回一个 Option<C>。为 Result 定义一个 map2 方法。

提示

不要使用你为 Option 定义的函数。相反,使用 lift2 方法。

解决方案 7.14

Option 定义的解决方案是

<A, B, C> Option<C> map2(Option<A> a,
                         Option<B> b,
                         Function<A, Function<B, C>> f) {
  return a.flatMap(ax -> b.map(bx -> f.apply(ax).apply(bx)));
}

这与 lift2 中使用的相同模式。所以 map2 方法将看起来像这样:

public static <A, B, C> Result<C> map2(Result<A> a,
                                       Result<B> b,
                                       Function<A, Function<B, C>> f) {
  return lift2(f).apply(a).apply(b);
}

这种函数的常见用例是调用由其他函数或方法返回的 Result 类型的参数的方法或构造函数。以之前的 ToonMail 示例为例。要填充 Toon 映射,你可以通过要求用户在控制台输入名字、姓氏和电子邮件来构建 toons,使用以下方法:

static Result<String> getFirstName() {
  return success("Mickey");
}

static Result<String> getLastName() {
  return success("Mickey");
}

static Result<String> getMail() {
  return success("mickey@disney.com");
}

真实的实现可能会有所不同,但你仍然需要学习如何从控制台功能性地获取输入。现在,你将使用这些模拟实现。

使用这些实现,你可以创建一个 Toon 如下:

Function<String, Function<String, Function<String, Toon>>> createPerson =
                                          x -> y -> z -> new Toon(x, y, z);
Result<Toon> toon2 = lift3(createPerson)
    .apply(getFirstName())
    .apply(getLastName())
    .apply(getMail());

但你正在达到抽象的极限。你可能需要调用带有超过三个参数的方法或构造函数。在这种情况下,你可以使用以下模式:

Result<Toon> toon = getFirstName()
          .flatMap(firstName -> getLastName()
              .flatMap(lastName -> getMail()
                  .map(mail -> new Toon(firstName, lastName, mail))));

这种模式有两个优点:

  • 你可以使用任意数量的参数。

  • 你不需要定义一个函数。

注意,你可以不单独定义函数而使用 lift3,但因为你需要指定类型,所以 Java 的类型推断能力较差:

Result<Toon> toon2 =
        lift3((String x) -> (String y) -> (String z) -> new Toon(x, y, z))
            .apply(getFirstName())
            .apply(getLastName())
            .apply(getMail());

你的新模式有时被称为 理解。一些语言为这样的结构提供了语法糖,大致相当于以下内容:

for {
  firstName in getFirstName(),
  lastName in getLastName(),
  mail in getMain()
} return new Toon(firstName, lastName, mail)

Java 没有这种语法糖,但即使没有它也很容易做到。只需注意对 flatMapmap 的调用是嵌套的。从一个方法的调用开始(或从一个 Result 实例开始),对每个新的调用使用 flatMap,然后通过映射到你要使用的构造函数或方法来结束。例如,当你只有五个 Result 实例时,需要调用一个接受五个参数的方法,可以使用以下方法:

Result<Integer> result1 = success(1);
  Result<Integer> result2 = success(2);
  Result<Integer> result3 = success(3);
  Result<Integer> result4 = success(4);
  Result<Integer> result5 = success(5);

  Result<Integer> result = result1
      .flatMap(p1 -> result2
          .flatMap(p2 -> result3
              .flatMap(p3 -> result4
                  .flatMap(p4 -> result5
                      .map(p5 -> compute(p1, p2, p3, p4, p5))))));

  private int compute(int p1, int p2, int p3, int p4, int p5) {
    return p1 + p2 + p3 + p4 + p5;
  }

这个例子有点牵强,但它展示了如何扩展这个模式。然而,最后一个调用(最深层嵌套的调用)是 map 而不是 flatMap,这并不是模式固有的。那只是因为最后一个方法(compute)返回一个原始值。如果它返回一个 Result,你就必须使用 flatMap 而不是 map。但是,因为最后一个方法通常是构造函数,而构造函数总是返回原始值,所以你经常会发现自己使用 map 作为最后一个方法调用。

7.6. 摘要

  • 表示由于错误而缺失数据是必要的。Option 类型不允许这样做。

  • Either 类型允许你表示一种类型(Right)或另一种类型(Left)的数据。

  • Either 可以像 Option 一样进行映射或扁平映射,但它可以在两边(右或左)进行。

  • Either 可以通过使一边(Left)始终代表相同的类型(RuntimeException)来产生偏差。你将这种偏差的 Either 类型称为 Result。成功由 Success 子类型表示,失败由 Failure 子类型表示。

  • 使用 Result 类型的一种方法是在存在包装值时获取它,或者在没有时使用提供的默认类型。

  • 默认类型,如果不是字面量,必须进行惰性评估。

  • Option(表示可选数据)与 Result(表示数据或错误)组合是繁琐的。通过向 Result 添加一个 Empty 子类型,使 Option 类型变得无用,这个用例变得更加简单。

  • 如果需要,可以映射失败,例如使错误消息更加明确。

  • 几个静态工厂方法简化了从各种情况创建 Result 的过程,例如使用可空数据或条件数据,这些数据由数据和必须满足的条件表示。

  • 可以通过 forEach 方法将效果应用于 Result(尽管不是以函数式的方式)。

  • forEachOrThrow 方法处理必须应用效果(如果存在数据)或抛出异常(否则)的特定情况。

  • forEachforEachOrThrow 方法是更通用的 forEachOrException 方法的特例。该方法应用一个效果(如果存在值),并返回 Empty(如果效果可以应用)或 Success<RuntimeException>(如果数据缺失)。

  • 你可以使用 lift 方法将函数从 A 升级到 B(从 Result<A> 操作到 Result<B>)。你可以通过 lift2 方法将函数从 A 升级到 BC(通过 Result<A>Result<B>Result<C>)。

  • 你可以使用理解模式组合任意数量的 Result

第八章. 高级列表处理

本章涵盖的内容

  • 使用记忆化加速列表处理

  • 组合 ListResult

  • 在列表上实现索引访问

  • 展开列表

  • 自动并行列表处理

在 第五章 中,你创建了你的第一个数据结构,单链表。在那个时刻,你没有掌握所有使它成为数据处理完整工具所需的技巧。你特别缺少的一个有用工具是表示产生可选数据或可能产生错误的操作的方式。在 第六章 和 第七章 中,你学习了如何表示可选数据和错误。在本章中,你将学习如何将产生可选数据或错误的操作与列表组合。

你还开发了一些远非最优的函数,例如 length,我说你最终会学到这些操作的更有效的方法。在本章中,你将学习如何实现这些技术。你还将学习如何自动并行化一些列表操作,以利用当今计算机的多核架构。

8.1. 长度问题

折叠列表涉及从一个值开始,并依次将其与列表的每个元素组合。这显然需要与列表长度成比例的时间。有没有办法使这个操作更快?或者,至少有没有办法让它看起来更快?

作为折叠应用的一个例子,你在练习 5.9 中在 List 中创建了一个 length 方法,其实现如下:

public int length() {
  return foldRight(this, 0, x -> y -> y + 1);
}

在这个实现中,列表是通过一个操作折叠的,该操作由将 1 加到结果中组成。起始值是 0,列表中每个元素的值被简单地忽略。这就是为什么你可以为所有列表使用相同的定义。因为列表元素被忽略,所以列表元素的类型无关紧要。

你可以将前面的操作与计算整数列表和的操作进行比较:

public static Integer sum(List<Integer> list) {
  return list.foldRight(0, x -> y -> x + y);
}

这里的主要区别在于 sum 方法只能与整数一起工作,而 length 方法适用于任何类型。请注意,foldRight 只是一种抽象递归的方式。列表的长度可以定义为空列表的 0,非空列表的长度加 1。同样,整数列表的和可以递归地定义为空列表的 0,非空列表的头元素加上尾部的和。

有其他操作可以以这种方式应用于列表,其中一些操作与列表元素的类型无关:

  • 列表的哈希码可以通过简单地将其元素的哈希码相加来计算。因为哈希码是一个整数(至少对于 Java 对象来说是这样),这个操作不依赖于对象的类型。

  • 列表的字符串表示,由toString方法返回,可以通过组合列表元素的toString表示来计算。再次强调,元素的实际类型是不相关的。

一些操作可能依赖于元素类型的某些特性,而不是具体的类型本身。例如,一个返回列表最大元素的max方法只需要类型是ComparableComparator

8.1.1. 性能问题

所有这些方法都可以使用折叠来实现,但这样的实现有一个主要的缺点:计算结果所需的时间与列表的长度成比例。想象一下,你有一个大约一百万个元素的列表,你想要检查它的长度。计数元素似乎是唯一的方法(这就是基于折叠的length方法所做的事情)。但如果你在向列表中添加元素直到它达到一百万个元素,你当然不会在添加每个元素后计数。

在这种情况下,你会在某个地方保留元素的计数,并且每次向列表中添加元素时,都会将这个计数加一。如果你从一个非空列表开始,可能只需要计数一次,但这就足够了。这种技术就是你在第四章中学到的:记忆化。问题是,你可以在哪里存储记忆化的值?答案是显而易见的:在列表本身。

8.1.2. 记忆化的好处

维护列表中元素数量的计数将花费一些时间,因此向列表中添加元素会比不保持计数时稍微慢一些。这看起来像是你在时间与时间之间进行交易。如果你构建一个包含 1,000,000 个元素的列表,你将失去 1,000,000 倍于添加一个元素到计数所需的时间。然而,作为补偿,获取列表长度所需的时间将接近 0(并且显然是恒定的)。也许在增加计数时损失的总时间将与调用length时的收益相等。但一旦你多次调用length,这种收益就绝对明显了。

8.1.3. 记忆化的缺点

记忆化可以将一个在 O(n)时间(与元素数量成比例的时间)内工作的函数转换为 O(1)时间(恒定时间)。这是一个巨大的好处,尽管它有一个时间成本,因为它使得元素的插入稍微慢一些。但减慢插入通常不是一个大问题。

一个更加重要的问题是内存空间的增加。实现原地修改的数据结构没有这个问题。在可变列表中,没有什么能阻止你将列表长度记忆化为一个可变整数,它只需要 32 位。但是,对于不可变列表,你必须在每个元素中记忆化长度。很难知道确切的尺寸增加,但如果单链表的每个节点大小约为 40 字节(对于节点本身),加上头和尾的两个 32 位引用(在 32 位 JVM 上),这将导致每个元素约为 100 字节。在这种情况下,添加长度会导致增加略超过 30%。如果记忆化的值是引用,比如记忆化Comparable对象列表的maxmin,结果也会相同。在 64 位 JVM 上,由于一些优化,计算甚至更加困难,但你可以理解这个概念。

对象引用的大小

关于 Java 7 和 Java 8 中对象引用大小的更多信息,请参阅 Oracle 关于压缩 Oops 的文档(mng.bz/TjY9)和 JVM 性能增强(mng.bz/8X0o)。

是否在数据结构中使用记忆化(memoization)取决于你。对于经常被调用且不为其结果创建新对象的函数来说,这可能是一个有效的选项。例如,lengthhashCode函数返回整数,而maxmin函数返回对已存在对象的引用,因此它们可能是很好的候选者。另一方面,toString函数创建新的字符串,这些字符串需要被记忆化,这可能会造成巨大的内存空间浪费。另一个需要考虑的因素是函数的使用频率。length函数可能比hashCode函数使用得更频繁,因为使用列表作为映射键不是一种常见的做法。

练习 8.1

创建length方法的记忆化版本。在List类中的签名将是

public abstract int lengthMemoized();

解决方案 8.1

Nil类中的实现与未记忆化的length方法完全相同:

public int lengthMemoized() {
  return 0;
}

要实现Cons版本,你必须首先将记忆化字段添加到类中,并在构造函数中初始化它:

private final int length;
private Cons(A head, List<A> tail) {
  this.head = head;
  this.tail = tail;
  this.length = tail.length() + 1;
}

然后你可以实现lengthMemoized方法,简单地返回长度:

public int lengthMemoized() {
  return length;
}

这个版本将比原始版本快得多。一个有趣的现象是lengthisEmpty方法之间的关系。你可能倾向于认为isEmpty等价于length == 0,但从逻辑角度来看,尽管这是真的,但在实现和性能上可能会有很大的差异。

注意,以相同的方式(尽管是静态方法)缓存Comparable列表中的最大值或最小值是可能的,但在你想从列表中移除最大或最小值的情况下,这并没有帮助。最小或最大元素通常通过优先级来检索元素。在这种情况下,元素的compareTo方法会比较它们的优先级。缓存优先级会让你立即知道哪个元素具有最高优先级,但这帮助不大,因为你通常需要移除相应的元素。对于这样的用例,你需要一个不同的数据结构,你将在第十一章(chapter 11)中学习如何创建它。

8.1.4. 实际性能

正如我所说,决定是否应该缓存List类的一些函数取决于你。一些实验应该能帮助你做出决定。在创建一个包含 1,000,000 个整数的列表前后测量可用内存大小,使用缓存时内存增加非常小。尽管这种测量方法不是很精确,但在两种情况下(有或没有缓存),可用内存的平均减少量约为 22 MB,介于 20 MB 和 25 MB 之间。这表明理论上的 4 MB(1,000,000 x 4 字节)的增加并不像你预期的那么显著。另一方面,性能的提升是巨大的。在没有缓存的情况下,请求长度十次可能需要超过 200 毫秒。有了缓存,时间几乎为 0(短到无法用毫秒来测量)。

注意,尽管添加一个元素会增加成本(将长度加一并存储结果),但移除一个元素没有成本,因为尾部长度已经被缓存。

如果不希望使用缓存,另一种方法是优化length方法。而不是使用折叠,你可以求助于命令式风格,使用循环和局部可变变量。以下是从 Scala List类借用的length实现:

public int length() {
  List<A> these = this;
  int len = 0;
  while (!these.isEmpty()) {
    len += 1;
    these = these.tail();
  }
  return len;
}

虽然它在风格上看起来不太像函数式编程,但这种实现与函数式编程的定义完全兼容。它是一个纯函数,没有任何外部世界可观察的效果。主要问题是它只比基于折叠的实现快五倍,而对于非常大的列表,缓存实现可以快数百万倍。

8.2. 列表与结果的组合

在上一章中,你看到了ResultList是非常相似的数据结构,主要区别在于它们的基数,但它们共享一些最重要的方法,例如mapflatMap,甚至foldLeftfoldRight

你看到了如何用列表来组合列表,以及结果与结果的组合。现在,你将看到结果如何与列表组合。

8.2.1. 列表方法返回结果

到目前为止,你已经注意到我试图避免直接访问结果和列表的元素。如果列表是Nil,访问列表的头部或尾部将抛出异常,而在函数式编程中抛出异常是可能发生的最糟糕的事情之一。但是你看到,通过提供一个用于失败或空结果的情况的默认值,你可以安全地访问Result中的值。你能否在访问列表的头部时做同样的事情?不是完全一样,但你可以返回一个Result

练习 8.2

List<A>中实现一个headOption方法,该方法将返回一个Result<A>

提示

List中使用以下抽象方法声明,并在每个子类中实现它:

public abstract Result<A> headOption();

注意,方法被命名为headOption是为了表明一个值是可选的,尽管你将使用Result作为类型。

解决方案 8.2

Nil类的实现返回Empty

public Result<A> headOption() {
  return Result.empty();
}

Cons实现返回一个包含头值的Success

public Result<A> headOption() {
  return Result.success(head);
}

练习 8.3

创建一个返回列表中最后一个元素的ResultlastOption方法。

提示

不要使用显式递归,而是尝试构建你在第五章中开发的方法。你应该能够在List类中定义一个单一的方法。

解决方案 8.3

一个简单的解决方案是使用显式递归:

public Result<A> lastOption() {
  return isEmpty()
      ? Result.empty()
      : tail().isEmpty()
          ? Result.success(head())
          : tail().lastOption();
}

这种解决方案有几个问题。它是基于栈的递归,所以你应该将其转换为基于堆的,另外你还需要处理空列表的情况,其中tail().lastOption()会抛出 NPE。

但你可以简单地使用折叠,它为你抽象了递归!你需要做的只是创建正确的折叠函数。你需要始终保留存在的最后一个值。这可能就是你要使用的函数:

Function<Result<A>, Function<A, Result<A>>> f =
                                   x -> y -> Result.success(y);

或者使用方法引用:

Function<Result<A>, Function<A, Result<A>>> f =
                                   x -> Result::success;

然后你只需要使用Result.Empty作为恒等元foldLeft列表:

public Result<A> lastOption() {
  return foldLeft(Result.empty(), x -> Result::success);
}

练习 8.4

你能否在List类中用单个实现替换headOption方法?这种实现的好处和缺点是什么?

解决方案 8.4

有可能创建这样的实现:

public Result<A> headOption() {
  return foldRight(Result.empty(), x -> y -> Result.success(x));
}

唯一的好处是如果你喜欢这种方式,它会更有趣。在设计last-Option实现时,你知道你必须遍历列表以找到最后一个元素。要找到第一个元素,你不需要遍历列表。在这里使用fold-Right与反转列表然后遍历结果以找到最后一个元素(这是原始列表的第一个元素)完全相同。这并不高效!顺便说一句,这正是lastOption方法找到最后一个元素的方式:反转列表并取结果的第一个元素。所以除了有趣之外,实际上没有理由使用这种实现。

8.2.2. 将 List转换为 Result

当一个列表包含一些计算的结果时,它通常是一个 List<Result>。例如,将函数从 T 映射到 Result<U> 应用到一个 T 的列表上,将产生一个 Result<U> 的列表。这些值通常需要与接受 List<T> 作为参数的函数组合。这意味着你需要一种方法将结果 List<Result<U>> 转换成一个 List<U>,这与 flatMap 方法中的扁平化类似,但有一个巨大的区别:涉及两种不同的数据类型:ListResult。你可以应用几种策略来完成这个转换:

  • 丢弃所有失败或空结果,并从剩余的成功列表中生成一个 U 的列表。如果列表中没有成功,结果可以简单地包含一个空的 List

  • 丢弃所有失败或空结果,并从剩余的成功列表中生成一个 U 的列表。如果列表中没有成功,结果将是一个 Failure

  • 决定所有元素都必须是成功,整个操作才能成功。如果所有元素都是成功,则使用值构造一个 U 的列表,并作为 Success<List<U>> 返回,否则返回 Failure<List<U>>

第一个解决方案对应于所有结果都是可选的结果列表。第二个解决方案意味着列表中至少有一个成功,结果才是一个成功。第三个解决方案对应于所有结果都是必需的情况。

练习 8.5

编写一个名为 flattenResult 的方法,它接受一个 List<Result<A>> 作为其参数,并返回一个包含原始列表中所有成功值的 List<A>,忽略失败和空值。这将是 List 中的一个静态方法,其签名如下:

public static <A> List<A> flattenResult(List<Result<A>> list)

尽量不要使用显式递归,而是组合 ListResult 类的方法。

提示

为该方法选择的名字是你要做的指示。

解决方案 8.5

要解决这个练习,你可以使用 foldRight 方法将列表折叠成一个由函数生成的列表列表。每个 Success 将被转换成一个包含单个元素的列表,其中包含值,而每个 FailureEmpty 将被转换成一个空列表。以下是该函数:

Function<Result<A>, Function<List<List<A>>, List<List<A>>>> f =
                    x -> y -> y.cons(x.map(List::list).getOrElse(list()));

一旦你有了这个函数,你可以用它来将列表向右折叠,生成一个包含值的列表列表,其中一些元素是空列表:

list.foldRight(list(), f)

剩下的工作就是将结果 flatten。完整的方法如下:

public static <A> List<A> flattenResult(List<Result<A>> list) {
  return flatten(list.foldRight(list(), x -> y ->
                y.cons(x.map(List::list).getOrElse(list()))));
  }

请注意,这不是最有效的方法。这主要是一个练习。

练习 8.6

编写一个 sequence 函数,它将 List<Result<T>> 合并成一个 Result<List<T>>。如果原始列表中的所有值都是 Success 实例,则它将是一个 Success<List<T>>,否则是一个 Failure<List<T>>。以下是它的签名:

public static <A> Result<List<A>> sequence(List<Result<A>> list)
提示

再次使用 foldRight 方法而不是显式递归。你还需要在 Result 类中定义的 map2 方法。

解决方案 8.6

这里是使用 foldRightmap2 的实现方法:

public static <A> Result<List<A>> sequence(List<Result<A>> list) {
  return list.foldRight(Result.success(List.list()),
                  x -> y -> Result.map2(x, y, a -> b -> b.cons(a)));
}

注意,这个实现将空的 Result 处理成 Failure,并返回它遇到的第一个失败案例,这可能是一个 Failure 或一个 Empty。这可能是你需要的,也可能不是。为了坚持 Empty 表示可选数据的想法,你需要首先过滤列表以移除 Empty 元素:

public static <A> Result<List<A>> sequence2(List<Result<A>> list) {
  return list.filter(a -> a.isSuccess() || a.isFailure())
      .foldRight(Result.success(List.list()),
                 x -> y -> Result.map2(x, y, a -> b -> b.cons(a)));
}

最终,你应该将移除空元素的操作抽象成一个单独的方法,在 List 类中。但在本书的其余部分,我们仍将 Empty 视为 sequence 方法上下文中的 Failure

练习 8.7

定义一个更通用的 traverse 方法,它遍历一个 A 的列表,同时应用一个从 AResult<B> 的函数,并生成一个 Result<List<B>>。以下是它的签名:

public static <A, B> Result<List<B>> traverse(List<A> list,
                                           Function<A, Result<B>> f)

然后用 traverse 方法定义 sequence 的新版本。

提示

不要使用递归。优先使用 foldRight 方法,它为你抽象了递归。

解决方案 8.7

首先定义 traverse 方法:

public static <A, B> Result<List<B>> traverse(List<A> list,
                                              Function<A, Result<B>> f) {
  return list.foldRight(Result.success(List.list()),
      x -> y -> Result.map2(f.apply(x), y, a -> b -> b.cons(a)));
}

然后你可以用 traverse 方法来重新定义 sequence 方法:

public static <A> Result<List<A>> sequence(List<Result<A>> list) {
  return traverse(list, x -> x);
}

8.3. 抽象化常见的列表使用场景

许多 List 数据类型的常见使用场景值得抽象化,这样你就不必一次又一次地重复相同的代码。你经常会发现自己发现新的使用场景,这些场景可以通过组合基本函数来实现。你永远不应该犹豫将这些使用场景作为 List 类中的新函数来包含。以下练习展示了几个最常见的使用场景。

8.3.1. 压缩和解压缩列表

压缩(Zipping)是将两个列表组合成一个列表的过程,通过合并相同索引的元素。解压缩(Unzipping)是相反的过程,通过“解构”元素来从单个列表中生成两个列表,例如从一个点列表中生成 xy 坐标的两个列表。

练习 8.8

编写一个 zipWith 方法,它结合两个不同类型的列表的元素,并使用一个函数参数生成一个新的列表。以下是它的签名:

public static <A, B, C> List<C> zipWith(List<A> list1, List<B> list2,
                                        Function<A, Function<B, C>> f)

此方法接受一个 List<A> 和一个 List<B>,通过一个从 ABC 的函数,生成一个 List<C>

提示

压缩应该限制在最短列表的长度内。

解决方案 8.8

对于这个练习,你必须使用显式递归,因为递归必须在两个列表上同时进行。你没有任何抽象可以使用。以下是解决方案:

public static <A, B, C> List<C> zipWith(List<A> list1, List<B> list2,
                                        Function<A, Function<B, C>> f) {
  return zipWith_(list(), list1, list2, f).eval().reverse();
}
private static <A, B, C> TailCall<List<C>> zipWith_(List<C> acc,
          List<A> list1, List<B> list2, Function<A, Function<B, C>> f) {
  return list1.isEmpty() || list2.isEmpty()
      ? ret(acc)
      : sus(() -> zipWith_(
          new Cons<>(f.apply(list1.head()).apply(list2.head()), acc),
          list1.tail(), list2.tail(), f));
}

zipWith_ 辅助方法使用空列表作为起始累加器被调用。如果两个参数列表中的任意一个为空,递归停止,并返回当前的累加器。否则,通过将函数应用于两个列表的头部值来计算一个新的值,并递归地使用两个参数列表的尾部调用辅助函数。

练习 8.9

前一个练习是通过对两个列表的元素按索引匹配来创建一个列表。编写一个product方法,该方法将生成从两个列表中取出的所有可能元素组合的列表。换句话说,给定两个列表list("a", "b", "c")list("d", "e", "f")以及字符串连接,两个列表的乘积应该是List("ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf")

提示

对于这个练习,你不需要使用显式递归。

解决方案 8.9

解决方案与你在第七章中用来组合Result的推导模式类似。唯一的区别是它产生的组合数量与列表中元素数量的乘积一样多,而对于组合Result,组合的数量总是限制为 1。

public static <A, B, C> List<C> product(List<A> list1, List<B> list2,
                                        Function<A, Function<B, C>> f) {
  return list1.flatMap(a -> list2.map(b -> f.apply(a).apply(b)));
}

注意,这样可以通过组合超过两个列表。唯一的问题是组合的数量将以指数级增长。

productzipWith的常见用例之一是使用组合函数的构造函数。以下是一个使用Tuple构造函数的示例:

List.product(List.list(1, 2, 3), List.list(4, 5, 6),
                                    x -> y -> new Tuple<>(x, y));
List.zipWith(List.list(1, 2, 3), List.list(4, 5, 6),
                                    x -> y -> new Tuple<>(x, y));

第一行将产生由两个列表的元素构建的所有可能元组的列表:

[(1,4), (1,5), (1,6), (2,4), (2,5), (2,6), (3,4), (3,5), (3,6), NIL]

第二行将只产生由具有相同索引的元素构建的元组列表:

[(1,4), (2,5), (3,6), NIL]

当然,你可以使用任何类的任何构造函数。(Java 对象实际上是具有特殊名称的元组。)

练习 8.10

编写一个unzip静态方法,将元组列表转换为元组列表。以下是它的签名:

<A, B> Tuple<List<A>, List<B>> unzip(List<Tuple<A, B>> list)
提示

不要使用显式递归。一个简单的foldRight调用就可以完成工作。

解决方案 8.10

你需要使用一个包含两个空列表的元组作为恒等元素来foldRight列表:

public static <A,B> Tuple<List<A>, List<B>> unzip(List<Tuple<A, B>> list) {
  return list.foldRight(new Tuple<>(list(), list()),
               t -> tl -> new Tuple<>(tl._1.cons(t._1), tl._2.cons(t._2)));
}

练习 8.11

unzip函数泛化,使其可以将任何类型的列表转换为元组列表,给定一个接受列表类型对象作为其参数并产生元组的函数。例如,给定一个Payment实例的列表,你应该能够生成一个包含用于支付的信用的列表和一个包含支付金额的列表的元组。将此方法实现为List的实例方法,其签名如下:

<A1, A2> Tuple<List<A1>, List<A2>> unzip(Function<A, Tuple<A1, A2>> f)
提示

解决方案与练习 8.10 的解决方案几乎相同。

解决方案 8.11

重要的是,函数的结果将被使用两次。为了不将函数应用两次,你必须使用多行 lambda:

public <A1, A2> Tuple<List<A1>, List<A2>> unzip(Function<A,
                                                Tuple<A1, A2>> f) {
  return this.foldRight(new Tuple<>(list(), list()), a -> tl -> {
    Tuple<A1, A2> t = f.apply(a);
    return new Tuple<>(tl._1.cons(t._1), tl._2.cons(t._2));
  });
}

8.3.2. 通过索引访问元素

单链表不是访问其元素的索引的最佳结构,但有时有必要使用索引访问。像往常一样,你应该将此类过程抽象为List方法。

练习 8.12

编写一个getAt方法,该方法接受一个索引作为其参数,并返回相应的元素。该方法在索引超出范围的情况下不应抛出异常。

提示

这次,从一个显式的递归版本开始。然后尝试回答以下问题:

  • 是否可以使用折叠操作完成?是左折叠还是右折叠?

  • 为什么显式的递归版本更好?

  • 你能找到解决问题的方法吗?

解答 8.12

显式的递归解决方案很简单:

public Result<A> getAt(int index) {
  return index < 0 || index >= length()
      ? Result.failure("Index out of bound")
      : getAt_(this, index).eval();
}

private static <A> TailCall<Result<A>> getAt_(List<A> list, int index) {
    return index == 0
              ? TailCall.ret(Result.success(list.head()))
              : TailCall.sus(() -> getAt_(list.tail(), index - 1));
}

首先,你可以检查索引是否为正且小于列表长度。如果不是,就返回一个Failure。否则,调用辅助方法递归地处理列表。此方法检查索引是否为 0。如果是,则返回列表的头部。否则,它将递归地调用自身在列表的尾部,并使用递减的索引。

这看起来是最好的可能递归解决方案。是否可以使用折叠操作?是的,可以,而且应该是一个左折叠。但解决方案很棘手:

public Result<A> getAt(int index) {
  Tuple<Result<A>, Integer> identity =
               new Tuple<>(Result.failure("Index out of bound"), index);

  Tuple<Result<A>, Integer> rt = index < 0 || index >= length()
      ? identity
      : foldLeft(identity, ta -> a -> ta._2 < 0
            ? ta
            : new Tuple<>(Result.success(a), ta._2 - 1));
  return rt._1;
}

首先,你必须定义恒等值。因为这个值必须同时持有结果和索引,所以它将是一个包含Failure情况的Tuple。然后你可以检查索引的有效性。如果发现无效,将临时结果(rt)设置为identity。否则,使用返回已计算结果(ta)的函数向左折叠,如果索引小于 0,或者如果否则返回一个新的Success

这个解决方案可能看起来更聪明,但实际上并非如此,原因有三:

  • 它的可读性远低。这可能具有主观性,所以这取决于你决定。

  • 你必须使用一个中间结果(rt),因为 Java 无法推断正确的类型。如果你不相信我,尝试在最后一行将rt替换为其值。

  • 它效率较低,因为它会在找到搜索到的值之后继续折叠整个列表。

练习 8.13(困难且可选)

找到一个解决方案,使得基于折叠的版本在找到结果后立即终止。

提示

你需要为这个特殊版本提供foldLeft,以及一个特殊的Tuple版本。

解答 8.13

首先,你需要一个特殊的foldLeft版本,其中可以在找到折叠操作的吸收元素(或“零”元素)时退出折叠。想象一下你想要通过乘法折叠的整数列表。乘法的吸收元素是 0。以下是List类中短路(或退出)版本的foldLeft声明:

public abstract <B> B foldLeft(B identity, B zero,
                                           Function<B, Function<A, B>> f);
零元素

通过类比,任何操作的吸收元素有时被称为“零”,但请记住,它并不总是等于 0。0 值只是乘法的吸收元素。对于正整数的加法,它将是无穷大。

这里是Cons的实现:

@Override
public <B> B foldLeft(B identity, B zero, Function<B, Function<A, B>> f) {
  return foldLeft(identity, zero, this, f).eval();
}

private <B> TailCall<B> foldLeft(B acc, B zero, List<A> list,
                                 Function<B, Function<A, B>> f) {
  return list.isEmpty() || acc.equals(zero)
      ? ret(acc)
      : sus(() -> foldLeft(f.apply(acc).apply(list.head()),
                                                zero, list.tail(), f));
}

如你所见,唯一的区别是,如果累加器的值被发现在是“零”,递归就会停止,并返回累加器。

现在你需要一个零值用于 fold。零值是一个 Tuple<Result<A, Integer>,其中 Integer 的值为 -1(第一个小于 0 的值)。你能使用标准的 Tuple 吗?不,你不能,因为它必须有一个特殊的等于方法,当整数值相等时返回 true,无论 Result<A> 是什么。完整的方法如下:

public Result<A> getAt(int index) {

  class Tuple<T, U> {

    public final T _1;
    public final U _2;

    public Tuple(T t, U u) {
      this._1 = Objects.requireNonNull(t);
      this._2 = Objects.requireNonNull(u);
    }

    @Override
    public boolean equals(Object o) {
      if (!(o.getClass() == this.getClass()))
        return false;
      else {
        @SuppressWarnings("rawtypes")
        Tuple that = (Tuple) o;
        return _2.equals(that._2);
      }
    }
  }

  Tuple<Result<A>, Integer> zero =
              new Tuple<>(Result.failure("Index out of bound"), -1);
  Tuple<Result<A>, Integer> identity =
              new Tuple<>(Result.failure("Index out of bound"), index);
  Tuple<Result<A>, Integer> rt = index < 0 || index >= length()
      ? identity
      : foldLeft(identity, zero, ta -> a -> ta._2 < 0
                    ? ta
                    : new Tuple<>(Result.success(a), ta._2 - 1));
  return rt._1;
}

注意,我已经省略了 hashCodetoString 方法,以使代码更短。

现在 fold 将在找到搜索到的元素后自动停止。当然,你可以使用新的 foldLeft 方法来逃逸任何带有零元素的计算。(记住:零,不是 0。)

8.3.3. 分割列表

有时候你需要将列表在特定位置分割成两部分。尽管单链表对于这种操作来说远非理想,但实现起来相对简单。分割列表有几个有用的应用,其中之一是使用多个线程并行处理其部分。

练习 8.14

编写一个 splitAt 方法,该方法接受一个 int 作为参数,并在给定位置分割列表,返回两个列表。不应该有任何 IndexOutOfBound-Exception。相反,一个小于 0 的索引应该被视为 0,一个大于最大值的索引应该被视为索引的最大值。

提示

使方法显式递归。

解答

设计一个显式递归的解法很简单:

public Tuple<List<A>, List<A>> splitAt(int index) {
  return index < 0
      ? splitAt(0)
      : index > length()
          ? splitAt(length())
          : splitAt(list(), this.reverse(), this.length() - index).eval();
}

private TailCall<Tuple<List<A>, List<A>>> splitAt(List<A> acc,
                                                  List<A> list, int i) {
  return i == 0 || list.isEmpty()
      ? ret(new Tuple<>(list.reverse(), acc))
      : sus(() -> splitAt(acc.cons(list.head()), list.tail(), i - 1));
}

注意,第一个方法使用递归来调整索引的值。然而,不需要使用 TailCall,因为这个方法最多只会递归一次。第二个方法与 getAt 方法非常相似,不同之处在于列表首先被反转。该方法累积元素直到达到索引位置,因此累积的列表是正确的顺序,但剩余的列表需要反转回来。

练习 8.15(如果你已经完成了练习 8.13,那么这个练习就不会那么难)

你能想到一个使用 fold 而不是显式递归的实现吗?

提示

一个遍历整个列表的实现很简单。一个只遍历到找到索引为止的列表实现则要复杂得多,并且需要一个带有逃逸的新特殊版本的 foldLeft,返回逃逸值和列表的其余部分。

解答 8.15

一个遍历整个列表的解决方案可能如下:

public Tuple<List<A>, List<A>> splitAt(int index) {
  int ii = index < 0 ? 0 : index >= length() ? length() : index;
  Tuple3<List<A>, List<A>, Integer> identity =
                         new Tuple3<>(List.list(), List.list(), ii);
  Tuple3<List<A>, List<A>, Integer> rt =
         foldLeft(identity, ta -> a -> ta._3 == 0
               ? new Tuple3<>(ta._1, ta._2.cons(a), ta._3)
               : new Tuple3<>(ta._1.cons(a), ta._2, ta._3 - 1));
  return new Tuple<>(rt._1.reverse(), rt._2.reverse());
}

fold 的结果累积在第一个列表累加器中,直到达到索引(在调整索引值以避免索引越界之后)。一旦找到索引,列表遍历继续,但剩余的值累积在第二个列表累加器中。

这个实现的一个问题是,通过在第二个列表累加器中累积剩余值,你反转了列表的这一部分。不仅不应该需要遍历列表的剩余部分,而且在这里它被做了两次:一次是为了以相反的顺序累积,一次是为了最终反转结果。为了避免这种情况,你应该修改特殊的“逃逸”版本的 foldLeft,使其不仅返回逃逸结果(吸收元素或零元素),还返回未受影响的列表的其余部分。为了实现这一点,你必须更改签名以返回一个 Tuple

public abstract <B> Tuple<B, List<A>> foldLeft(B identity, B zero,
                                          Function<B, Function<A, B>> f);

然后你需要更改 Nil 类的实现:

@Override
public <B> Tuple<B, List<A>> foldLeft(B identity, B zero,
                                      Function<B, Function<A, B>> f) {
  return new Tuple<>(identity, list());
}

最后,你必须将 Cons 实现改为返回列表的剩余部分:

@Override
public <B> Tuple<B, List<A>> foldLeft(B identity, B zero,
                                      Function<B, Function<A, B>> f) {
  return foldLeft(identity, zero, this, f).eval();
}

private <B> TailCall<Tuple<B, List<A>>> foldLeft(B acc, B zero,
                         List<A> list, Function<B, Function<A, B>> f) {
  return list.isEmpty() || acc.equals(zero)
      ? ret(new Tuple<>(acc, list))
      : sus(() -> foldLeft(f.apply(acc).apply(list.head()),
                                               zero, list.tail(), f));
}

现在,你可以使用这个特殊的 foldLeft 方法重写 splitAt 方法:

public Tuple<List<A>, List<A>> splitAt(int index) {

  class Tuple3<T, U, V> {
    public final T _1;
    public final U _2;
    public final V _3;

    public Tuple3(T t, U u, V v) {
      this._1 = Objects.requireNonNull(t);
      this._2 = Objects.requireNonNull(u);
      this._3 = Objects.requireNonNull(v);
    }

    @Override
    public boolean equals(Object o) {
      if (!(o.getClass() == this.getClass()))
        return false;
      else {
        @SuppressWarnings("rawtypes")
        Tuple3 that = (Tuple3) o;
        return _3.equals(that._3);
      }
    }
  }

  Tuple3<List<A>, List<A>, Integer> zero =
                                      new Tuple3<>(list(), list(), 0);
  Tuple3<List<A>, List<A>, Integer> identity =
                                      new Tuple3<>(list(), list(), index);
  Tuple<Tuple3<List<A>, List<A>, Integer>, List<A>> rt = index <= 0
        ? new Tuple<>(identity, this)
        : foldLeft(identity, zero, ta -> a -> ta._3 < 0
                ? ta
                : new Tuple3<>(ta._1.cons(a), ta._2, ta._3 - 1));
  return new Tuple<>(rt._1._1.reverse(), rt._2);
}

这里,你同样需要一个特定的 Tuple3 类,它有一个特殊的 equals 方法,当第三个元素相等时返回 true,而不考虑前两个元素。请注意,第二个结果列表不需要反转。

何时不使用 fold

只因为可以使用 fold 并不意味着你应该这样做。前面的练习只是练习而已。作为一个函数式库的设计者,你需要选择最有效的实现方式。

一个函数式库必须有一个函数式接口,并且必须遵守函数式编程的要求,这意味着所有函数都必须是真正的函数,没有副作用,并且都必须遵守引用透明性。库内部发生的事情无关紧要。在像 Java 这样的命令式语言中,函数式库可以与函数式语言的编译器相提并论。编译后的代码总是命令式的,因为这是计算机能理解的。函数式库提供了更多的选择。一些函数可能以函数式风格实现,而另一些则以命令式风格实现;这无关紧要。当以命令式方式实现时,分割单链表或通过索引查找元素比以函数式方式实现要容易得多,也快得多,因为单链表并不适合这种操作。

最函数式的方法可能不是基于 fold 实现这些函数,而是根本不实现它们。如果你需要具有这些函数的函数式实现的结构,最好的做法是创建特定的结构,正如你将在第十章中看到的。第十章。

8.3.4. 搜索子列表

列表的一个常见用途是搜索以确定一个列表是否包含在另一个(更长的)列表中。换句话说,你想要知道一个列表是否是另一个列表的子列表。

练习 8.16

实现一个 hasSubList 方法来检查一个列表是否是另一个列表的子列表。例如,列表 (3, 4, 5) 是列表 (1, 2, 3, 4, 5) 的子列表,但不是列表 (1, 2, 4, 5, 6) 的子列表。将其实现为一个具有以下签名的静态方法:

public static <A> boolean hasSubsequence(List<A> list, List<A> sub)
提示

你首先必须实现一个startsWith方法来确定列表是否以子列表开头。一旦完成,你将递归地测试这个方法,从列表的每个元素开始。

解决方案 8.16

可以显式地实现一个startsWith方法,如下所示:

public static <A> Boolean startsWith(List<A> list, List<A> sub) {
  return sub.isEmpty()
      ? true
      : list.isEmpty()
          ? false
          : list.head().equals(sub.head())
              ? startsWith(list.tail(), sub.tail())
              : false;
}

这是一个基于栈的版本,可以使用TailCall转换为基于堆的版本:

public static <A> Boolean startsWith(List<A> list, List<A> sub) {
  return startsWith_(list, sub).eval();
}

public static <A> TailCall<Boolean> startsWith_(List<A> list,
                                                List<A> sub) {
  return sub.isEmpty()
      ? ret(Boolean.TRUE)
      : list.isEmpty()
          ? ret(Boolean.FALSE)
          : list.head().equals(sub.head())
              ? sus(() -> startsWith_(list.tail(), sub.tail()))
              : ret(Boolean.FALSE);
}

从那里,实现hasSubList是直接的:

public static <A> boolean hasSubList(List<A> list, List<A> sub) {
  return hasSubList_(list, sub).eval();
}

public static <A> TailCall<Boolean> hasSubList_(List<A> list, List<A> sub){
  return list.isEmpty()
      ? ret(sub.isEmpty())
      : startsWith(list, sub)
          ? ret(true)
          : sus(() -> hasSubList_(list.tail(), sub));
}

8.3.5. 用于处理列表的杂项函数

可以开发出许多其他有用的函数来处理列表。以下练习将为你提供在这个领域的一些实践。请注意,提出的解决方案当然不是唯一的。你可以自由地发明自己的。

练习 8.17

创建一个groupBy方法,该方法接受一个从AB的函数作为参数,并返回一个Map,其中键是函数应用于列表中每个元素的输出,值是与每个键对应的元素列表。换句话说,给定如下Payment列表,

public class Payment {

  public final String name;
  public final int amount;

  public Payment(String name, int amount) {
    this.name = name;
    this.amount = amount;
  }
}

以下代码应该创建一个包含(键/值)对的Map,其中每个键是一个名称,相应的值是相应人员做出的Payment列表:

Map<String, List<Payment>> map = list.groupBy(x -> x.name);
提示

使用前几章中的函数式Map包装器。这次,先尝试创建一个命令式版本,然后基于折叠创建一个函数式版本。你更喜欢哪一个?

解决方案 8.17

这里是一个命令式版本的示例。对此没有太多可说的,因为它只是传统的命令式代码,带有局部可变状态:

public <B> Map<B, List<A>> groupByImperative(Function<A, B> f) {
  List<A> workList = this;
  Map<B, List<A>> m = Map.empty();
  while (!workList.isEmpty()) {
    final B k = f.apply(workList.head());
    List<A> rt = m.get(k).getOrElse(list()).cons(workList.head());
    m = m.put(k, rt);
    workList = workList.tail();
  }
  return m;
}

注意,这个实现是完全功能性的,因为从方法外部看不到任何状态变化。但风格相当命令式,使用了一个while循环和局部变量。

这里是一个更函数式风格的版本,使用折叠操作:

public <B> Map<B, List<A>> groupBy(Function<A, B> f) {
  return foldRight(Map.empty(), t -> mt -> {
    final B k = f.apply(t);
    return mt.put(k, mt.get(k).getOrElse(list()).cons(t));
  });
}

选择你喜欢的风格由你决定。显然,第二个版本更紧凑。但主要优势是它更好地表达了意图。groupBy是一个折叠操作。选择命令式风格是重新实现折叠,而选择函数式风格是重用抽象。

练习 8.18

编写一个unfold方法,该方法接受一个起始元素S和一个从SResult<Tuple<A, S>>的函数f,并通过连续应用fS值(只要结果是Success)来生成一个List<A>。换句话说,以下代码应该生成从 0 到 9 的整数列表:

List.unfold(0, i -> i < 10
    ? Result.success(new Tuple<>(i, i + 1))
    : Result.empty());

解决方案 8.18

一个简单的非栈安全递归版本很容易实现:

public static <A, S> List<A> unfold_(S z,
                                     Function<S, Result<Tuple<A, S>>> f) {
    return f.apply(z).map(x ->
                   unfold_(x._2, f).cons(x._1)).getOrElse(list());
}

不幸的是,尽管这个解决方案很聪明,但它会在超过 1,000 次递归步骤后耗尽栈空间。为了解决这个问题,你可以创建一个尾递归版本,并使用TailCall类在堆上执行递归:

public static <A, S> List<A> unfold(S z,
                                    Function<S, Result<Tuple<A, S>>> f) {
  return unfold(list(), z, f).eval().reverse();
}

private static <A, S> TailCall<List<A>> unfold(List<A> acc, S z,
                                      Function<S, Result<Tuple<A, S>>> f) {
    Result<Tuple<A, S>> r = f.apply(z);
    Result<TailCall<List<A>>> result =
               r.map(rt -> sus(() -> unfold(acc.cons(rt._1), rt._2, f)));
    return result.getOrElse(ret(acc));
}

注意,然而,这将反转列表。这可能在小型列表中不是大问题,但对于大型列表可能是。在这种情况下,回到命令式风格可能是一个选择。

练习 8.19

编写一个 range 方法,它接受两个整数作为参数,并生成一个所有大于或等于第一个整数且小于第二个整数的整数列表。

提示

当然,你应该使用你已经定义的方法。

解答 8.19

如果你重用练习 8.18 中的方法,这会非常简单:

public static List<Integer> range(int start, int end) {
  return List.unfold(start, i -> i < end
      ? Result.success(new Tuple<>(i, i + 1))
      : Result.empty());
}

练习 8.20

创建一个 exists 方法,它接受一个从 ABoolean 的函数,表示一个条件,如果列表中至少有一个元素满足这个条件,则返回 true。不要使用显式递归,但尝试基于你已定义的方法构建。

提示

没有必要评估列表中所有元素的条件。该方法应在找到满足条件的第一个元素时立即返回。

解答 8.20

递归解决方案可以定义为以下:

public boolean exists(Function<A, Boolean> p) {
  return p.apply(head()) || tail().exists(p);
}

因为 || 运算符会惰性评估其第二个参数,所以一旦找到满足谓词 p 表达的条件的一个元素,递归过程就会停止。但这是一个非尾递归的基于栈的方法,如果列表很长且在前 1,000 或 2,000 个元素中没有找到满足条件的元素,它将会耗尽栈空间。顺便提一下,如果列表为空,它也会抛出异常,因此你必须在 List 类中定义一个抽象方法,并为 Nil 子类提供一个特定的实现。

一个更好的解决方案是重用 foldLeft 方法,并使用零参数:

public boolean exists(Function<A, Boolean> p) {
  return foldLeft(false, true, x -> y -> x || p.apply(y))._1;
}

练习 8.21

创建一个 forAll 方法,它接受一个从 ABoolean 的函数,表示一个条件,如果列表中的所有元素都满足这个条件,则返回 true

提示

不要使用显式递归。而且,你并不总是需要评估列表中所有元素的条件。forAll 方法将与 exists 方法非常相似。

解答 8.21

这个解决方案与 exists 方法非常相似,有两个不同之处:恒等值和零值被反转,布尔运算符是 && 而不是 ||

public boolean forAll(Function<A, Boolean> p) {
  return foldLeft(true, false, x -> y -> x && p.apply(y))._1;
}

注意,另一种可能性是重用 exists 方法:

public boolean forAll(Function<A, Boolean> p) {
  return !exists(x -> !p.apply(x));
}

这个方法检查是否存在不满足条件的元素。

8.4. 列表的自动并行处理

应用于列表的大多数计算都会依赖于折叠。折叠涉及将操作应用于列表中的每个元素。对于非常长的列表和持续时间长的操作,折叠可能需要相当长的时间。因为现在大多数计算机都配备了多核处理器(如果不是多个处理器),你可能想找到一种方法让计算机并行处理列表。

为了并行化一个折叠操作,你只需要一件事情(当然,除了多核处理器):一个额外的操作,允许你重新组合每个并行计算的结果。

8.4.1. 并非所有计算都可以并行化

以整数列表为例。计算所有整数的平均值并不是可以直接并行化的任务。你可以将列表分成四部分(如果你有一台有四个处理器的计算机),然后计算每个子列表的平均值。但你无法从子列表的平均值中计算出整个列表的平均值。

另一方面,计算列表的平均值意味着计算所有元素的总和,然后除以元素的数量。而计算总和是可以通过计算子列表的总和,然后计算子列表总和的总和来轻松并行化的。

这是一个非常特殊的例子,其中用于折叠的操作(加法)与用于组装子列表结果的操作相同。这并不总是情况。以一个通过添加字符到 String 来折叠的字符列表为例。为了组装中间结果,你需要不同的操作:字符串连接。

8.4.2. 将列表拆分为子列表

首先,你必须将列表拆分为子列表,并且必须自动完成这项操作。一个重要的问题是应该获得多少个子列表。乍一看,你可能认为每个可用的处理器一个子列表是理想的,但这并不完全正确。处理器的数量(或者更精确地说,逻辑核心的数量)并不是最重要的因素。还有一个更关键的问题:所有子列表的计算是否需要相同的时间?可能不是,这取决于计算的类型。如果你决定将列表分成四个子列表,因为你要将四个线程 dedicate 给并行处理,一些线程可能会非常快地完成,而其他线程可能需要进行更长时间的计算。这会破坏并行化的好处,因为它可能会导致大部分计算任务由单个线程处理。

一个更好的解决方案是将列表分成大量的子列表,然后将每个子列表提交给线程池。这样,一旦一个线程完成处理一个子列表,它就会得到一个新的子列表来处理。因此,首要任务是创建一个将列表拆分为子列表的方法。

练习 8.22

编写一个 divide(int depth) 方法,该方法将列表分成一定数量的子列表。列表将被分成两部分,每个子列表递归地分成两部分,depth 参数表示递归步骤的数量。此方法将在 List 父类中实现,以下是其签名:

List<List<A>> divide(int depth)
提示

你首先定义一个新的 splitAt 方法版本,它返回一个列表的列表,而不是一个 Tuple<List, List>。让我们称这个方法为 splitListAt,并给它以下签名:

List<List<A>> splitListAt(int i)

解决方案 8.22

splitListAt 方法是一个显式递归方法,通过使用 TailCall 类来确保栈安全:

public List<List<A>> splitListAt(int i) {
  return splitListAt(list(), this.reverse(), i).eval();
}

private TailCall<List<List<A>>> splitListAt(List<A> acc,
                                            List<A> list, int i) {
  return i == 0 || list.isEmpty()
      ? ret(List.list(list.reverse(), acc))
      : sus(() -> splitListAt(acc.cons(list.head()), list.tail(), i - 1));
}

当然,此方法将始终返回一个包含两个列表的列表。然后你可以定义 divide 方法如下:

public List<List<A>> divide(int depth) {
  return this.isEmpty()
      ? list(this)
      : divide(list(this), depth);
}

private List<List<A>> divide(List<List<A>> list, int depth) {
  return list.head().length() < depth || depth < 2 
      ? list
      : divide(list.flatMap(x -> x.splitListAt(x.length() / 2)), depth / 2);
}

注意,您不需要使此方法堆栈安全,因为递归步骤的数量将只有 log(length)。换句话说,您永远不会拥有足够的堆内存来持有足够长的列表以导致堆栈溢出。

8.4.3. 并行处理子列表

要并行处理子列表,您需要一个特殊版本的方法来执行,它将接受一个额外的参数,即配置了您想要并行使用的线程数的ExecutorService

练习 8.23

List<A>中创建一个parFoldLeft方法,它将接受与fold-Left相同的参数,加上一个ExecutorService和一个从BBB的函数,并返回一个Result<List<B>>。额外的函数将用于组装子列表的结果。以下是方法的签名:

public<B> Result<B> parFoldLeft(ExecutorService es, B identity,
             Function<B, Function<A, B>> f, Function<B, Function<B, B>> m)

解决方案 8.23

首先,您必须定义您想要使用的子列表数量,并相应地分割列表:

final int chunks = 1024;
final List<List<A>> dList = divide(chunks);

然后,您将使用一个将任务提交给Executor-Service的函数映射子列表。该任务包括折叠每个子列表并返回一个Future实例。将Future实例的列表映射到调用每个Future上的get的函数,以生成一个结果列表(每个子列表一个)。请注意,您必须捕获潜在的异常。

最终,结果列表将与第二个函数折叠,并将结果以Result.Success的形式返回。在发生异常的情况下,返回Failure

try {
  List<B> result = dList.map(x -> es.submit(() -> x.foldLeft(identity,
                                                         f))).map(x -> {
    try {
      return x.get();
    } catch (InterruptedException | ExecutionException e) {
      throw new RuntimeException(e);
    }
  });
  return Result.success(result.foldLeft(identity, m));
} catch (Exception e) {
  return Result.failure(e);
}

您将在附带的代码中找到一个此方法的示例基准测试(github.com/fpinjava/fpinjava)。基准测试包括使用非常慢的算法计算 1 到 30 之间 35,000 个随机数的 10 次斐波那契值。在四核 Macintosh 上,并行版本执行时间为 22 秒,而串行版本需要 83 秒。

练习 8.24

虽然映射可以通过折叠实现(因此可以受益于自动并行化),但它也可以在不使用折叠的情况下并行实现。这可能是可以在列表上实现的 simplest automatic parallelization。创建一个parMap方法,它将自动将给定的函数并行应用于列表的所有元素。以下是方法的签名:

public <B> Result<List<B>> parMap(ExecutorService es, Function<A, B> g)
提示

实际上,在这个练习中几乎没有什么要做。只需将每个函数应用提交给ExecutorService,并从每个相应的Callable获取结果。

解决方案 8.24

这是解决方案:

public <B> Result<List<B>> parMap(ExecutorService es, Function<A, B> g) {
  try {
    return Result.success(this.map(x -> es.submit(() -> g.apply(x)))
                                                             .map(x -> {
      try {
        return x.get();
      } catch (InterruptedException | ExecutionException e) {
        throw new RuntimeException(e);
      }
    }));
  } catch (Exception e) {
    return Result.failure(e);
  }
}

伴随此书的代码中可用的基准测试将允许您测量性能的提升。当然,这种提升可能因运行程序的机器而异。

8.5. 总结

  • 通过使用缓存,可以提高列表处理的效率。

  • 您可以将Result实例的List转换为ListResult

  • 您可以通过zip操作组装两个列表。您还可以将元组的列表解包以生成列表的Tuple

  • 您可以使用显式递归来实现列表元素的索引访问。

  • 您可以实现一个特殊的foldLeft版本,以在获得“零”结果时跳出折叠。

  • 您可以通过一个函数和终止条件来展开创建列表。

  • 列表可以自动拆分,这允许并行处理子列表。

第九章:与惰性一起工作

本章涵盖的内容

  • 理解惰性的重要性

  • 在 Java 中实现惰性

  • 创建惰性列表数据结构:Stream

  • 通过缓存已评估的值来优化惰性列表

  • 处理无限流

有些语言被称为 惰性的,而有些则不是。这难道意味着有些语言比其他语言更努力工作吗?根本不是。惰性与严格性相对立。这与语言可以工作多努力没有关系,尽管有时你可以将惰性语言视为不需要像严格语言那样努力工作的语言。

正如你所看到的,对于某些特定问题,如组合无限数据结构和评估错误条件,惰性有许多优点。

9.1. 理解严格性和惰性

当应用于方法参数时,严格性意味着参数在方法接收到它们时立即被评估。惰性意味着只有在需要时才会评估参数。

当然,严格性和惰性不仅适用于方法参数,还适用于一切。例如,考虑以下声明:

int x = 2 + 3;

在这里,x 立即被评估为 5,因为 Java 是一种严格的语言;它立即执行加法。让我们看看另一个例子:

int x = getValue();

在 Java 中,一旦声明了 x 变量,就会调用 getValue 方法来提供相应的值。另一方面,在惰性语言中,只有在 x 变量被使用时才会调用 getValue 方法。这可能会产生巨大的差异。

例如,看看以下 Java 程序:

public static void main(String... args) {
  int x = getValue();
}

public static int getValue() {
  System.out.println("Returning 5");
  return 5;
}

这个程序将在控制台上打印 Returning 5,因为会调用 getValue 方法,尽管返回的值永远不会被使用。在惰性语言中,不会进行任何评估,因此控制台上不会打印任何内容。

9.1.1. Java 是一种严格的语言

Java 在原则上没有关于惰性的选择。Java 是严格的。一切都会立即被评估。方法参数被称为是按 传递的,这意味着首先进行评估,然后传递评估后的值。另一方面,在惰性语言中,参数被称为是按 传递的,这意味着 未评估的。不要被 Java 中方法参数通常是引用的事实所迷惑。引用是地址,这些地址是按值传递的。

一些语言是严格的(如 Java);其他是惰性的;有些默认是严格的,但可以选择惰性;还有一些默认是惰性的,但可以选择严格。

然而,Java 并非总是严格的。以下是一些 Java 中的惰性结构:

  • 布尔运算符 ||&&

  • 三元运算符 ?:

  • if ... else

  • for 循环

  • while 循环

  • Java 8 流

如果你仔细思考,很快就会意识到如果 Java 不时地不是那么“懒惰”,那么能做的事情就很少了。你能想象一个两个分支都会被系统性地评估的 if ... else 结构吗?或者你能想象一个无法逃离的循环吗?所有语言都必须在某种程度上“懒惰”。话虽如此,标准的 Java 对于函数式编程来说通常不够“懒惰”。

9.1.2. 严格性的问题

在像 Java 这样的语言中,严格性是如此基本,以至于许多程序员认为它是评估表达式的唯一可能性,即使在实际中,使用完全严格的语言什么也不可能实现。此外,Java 的文档在描述懒惰结构时没有使用“非严格”或“懒惰”这样的词。例如,布尔运算符 ||&& 并不被称为“懒惰”,而是称为“短路”。但简单的事实是,这些运算符在它们的参数方面是非严格的。我们可以很容易地展示这与方法参数的“严格”评估有何不同。

想象一下,你想要用一个函数来模拟布尔运算符。下面的列表显示了你可以做什么。

列表 9.1. andor 逻辑方法
public class BooleanMethods {

  public static void main(String[] args) {
    System.out.println(or(true, true));
    System.out.println(or(true, false));
    System.out.println(or(false, true));
    System.out.println(or(false, false));

    System.out.println(and(true, true));
    System.out.println(and(true, false));
    System.out.println(and(false, true));
    System.out.println(and(false, false));
  }

  public static boolean or(boolean a, boolean b) {
    return a ? true : b ? true : false;
  }

  public static boolean and(boolean a, boolean b) {
    return a ? b ? true : false : false;
  }
}

当然,使用布尔运算符有更简单的方法来做这件事,但你的目标是要避免这些运算符。你完成了吗?运行这个程序将在控制台上显示以下结果:

true
true
true
false
true
false
false
false

到目前为止,一切顺利。但现在尝试运行以下程序。

列表 9.2. 严格性的问题
public class BooleanMethods {

  public static void main(String[] args) {
    System.out.println(getFirst() || getSecond());
    System.out.println(or(getFirst(), getSecond()));
  }

  public static boolean getFirst() {
    return true;
  }

  public static boolean getSecond() {
    throw new IllegalStateException();
  }

  public static boolean or(boolean a, boolean b) {
    return a ? true : b ? true : false;
  }

  public static boolean and(boolean a, boolean b) {
    return a ? b ? true : false : false;
  }
}

这个程序打印以下内容:

true
Exception in thread "main" java.lang.IllegalStateException

显然,or 方法并不等同于 || 操作符。区别在于 || 会懒惰地评估其操作数,这意味着如果第一个操作数是 true,则不需要评估第二个操作数,因为它对计算结果没有影响。但是,or 方法严格地评估其参数,这意味着即使第二个参数的值不需要,也会评估它,因此总是抛出 IllegalStateException

在 第六章 和 第七章 中,你遇到了 getOrElse 方法的问题,因为其参数总是被评估,即使计算成功也是如此。

9.2. 实现懒惰

在许多场合,懒惰是必要的。实际上,Java 确实使用了懒惰来处理诸如 if ... else、循环和 try ... catch 块等结构。如果没有懒惰,例如,即使在没有异常的情况下,catch 块也会被评估。在提供错误行为以及需要操作无限数据结构时,实现懒惰是必须的。

在 Java 中实现懒惰并不完全可能,但你可以使用之前章节中使用的 Supplier 类来产生一个很好的近似:

public interface Supplier<T> {
  T get();
}

注意,你创建了自己的类,但 Java 8 也提供了一个 Supplier 类。你使用哪一个取决于你。它们是完全等价的。

使用 Supplier 类,你可以将 BooleanMethods 示例重写如下。

列表 9.3. 使用惰性模拟布尔运算符
public class BooleanMethods {

  public static void main(String[] args) {
    System.out.println(getFirst() || getSecond());
    System.out.println(or(() -> getFirst(), () -> getSecond()));
  }

  public static boolean getFirst() {
    return true;
  }

  public static boolean getSecond() {
    throw new IllegalStateException();
  }

  public static boolean or(Supplier<Boolean> a, Supplier<Boolean> b) {
    return a.get() ? true : b.get() ? true : false;
  }

  public static boolean and(Supplier<Boolean> a, Supplier<Boolean> b) {
    return a.get() ? b.get() ? true : false : false;
  }
}

这个程序打印出以下内容:

true
true

惰性问题几乎已经解决,尽管你被迫改变了方法的签名。这是使用惰性所付出的低代价。当然,如果参数很快就能评估,或者它们已经被评估,比如使用字面值时,这可能有点过度。但如果有长计算需要评估,它可能会节省大量时间。而且如果评估不是无副作用的,它可能会完全改变程序的结果。

9.3. 没有惰性无法完成的事情

到目前为止,可能看起来 Java 在评估表达式时缺乏惰性并不是什么大问题。毕竟,为什么要在可以使用布尔运算符时重写布尔方法呢?然而,还有其他情况下惰性会有用。甚至有几个算法没有惰性是无法实现的。我已经讨论了严格的if ... else版本是多么的无用。想想以下算法:

  1. 取正整数列表。

  2. 过滤素数。

  3. 返回前十个结果列表。

这是一个寻找前十个素数的算法,但这个算法没有惰性是无法实现的。如果你不相信我,就试试看。从第一行开始。如果你很严格,你首先会评估正整数列表。你永远不会有机会到达第二行,因为整数列表是无限的,你会在达到(不存在的)终点之前耗尽可用内存。

显然,这个算法没有惰性是无法实现的,但你知道如何用不同的算法来替换它。前面的算法是函数式的。如果你想不依赖惰性找到结果,你必须用命令式算法来替换它,如下所示:

  1. 取第一个整数。

  2. 检查它是否是素数。

  3. 如果是,将其存储在列表中。

  4. 检查这个结果列表是否有十个元素。

  5. 如果它有十个元素,则将其作为结果返回。

  6. 如果没有,将整数加 1。

  7. 转到第二行。

当然,它工作。但多么混乱!首先,这是一个糟糕的配方。你不应该将测试的整数增加 2 而不是 1,以避免测试偶数吗?为什么还要测试 3、5 等的倍数?但更重要的是,它没有表达问题的本质。它只是计算结果的一个配方。

这并不是说实现细节(例如不测试偶数)对于获得良好的性能不重要。但这些实现细节应该与问题定义明确分开。命令式描述不是对问题的描述——它是对另一个给出相同结果的问题的描述。

在函数式编程中,你通常用一种特殊结构来解决这类问题:惰性列表,称为Stream

9.4. 为什么不使用 Java 8 Stream?

Java 8 引入了一种新的结构称为Stream。你能用它来进行这种类型的计算吗?好吧,你可以,但有几个原因不这样做:

  • 定义自己的结构要更有回报。这样做,你会学到和理解许多你甚至都没有想过的事情,如果你使用的是 Java 8 流的话。

  • Java 流是一个非常强大的工具,但不是你需要的那把工具。Java 8 流的设计考虑到了自动并行化的想法。为了允许自动并行化,做出了许多妥协。许多功能方法缺失,因为它们会使自动并行化变得更加困难。

  • Java 8 流是有状态的。一旦它们被用于某些操作,它们的状态就会改变,就不再可用了。

  • Java 8 流的折叠是一个严格的操作,它会导致所有元素的评估。

由于所有这些原因,你将在本章中定义自己的流。完成本章后,你可能更喜欢使用 Java 8 流,但你会完全理解 Java 8 实现中缺少什么。

9.5. 创建一个懒列表数据结构

现在你已经知道了如何将未评估的数据表示为Supplier的实例,你可以轻松地定义一个懒列表数据结构。它将被命名为Stream,并且将与你在第五章中开发的单链表非常相似,但有一些微妙但非常重要的区别。以下列表显示了你的Stream数据类型的起点。

列表 9.4. Stream数据类型

下面是如何使用这种Stream类型的一个示例:

Stream<Integer> stream = Stream.from(1);
System.out.println(stream.head());
System.out.println(stream.tail().head());
System.out.println(stream.tail().tail().head());

这个程序会打印以下内容:

1
2
3

这可能看起来并不很有用。为了使Stream成为一个有价值的工具,你需要向其中添加一些方法。但首先你必须稍微优化它。

9.5.1. 缓存已评估的值

懒性的理念是,你可以在需要时评估数据来节省时间。这暗示了你在第一次访问时必须评估数据。但在后续访问中重新评估它是浪费时间。因为你在编写函数式程序,多次评估不会伤害任何东西,但它会减慢程序。一个解决方案是缓存已评估的值。

要做到这一点,你必须在Cons类中添加用于已评估值的字段:

private final Supplier<A> head;
private A h;
private final Supplier<Stream<A>> tail;
private Stream<A> t;

然后按照以下方式更改获取器:

public A head() {
  if (h == null) {
    h = head.get();
  }
  return h;
}

public Stream<A> tail() {
  if (t == null) {
    t = tail.get();
  }
  return t;
}

这种众所周知的技术并不仅限于函数式编程。有时被称为按需评估,或按需评估,或懒评估。当第一次请求值时,评估字段是null,因此会进行评估。在后续访问中,值不会再次评估,并且会返回之前评估的值。

一些语言提供懒加载作为标准功能,无论是默认提供还是可选提供。在这样的语言中,你不需要求助于 null 引用和可变字段。不幸的是,Java 不是这些语言之一。在 Java 中,当值稍后需要初始化时,最常见的方法是首先将其分配给 null 引用(如果它是对象类型),或者如果它是原始类型,则分配给哨兵值。这是有风险的,因为没有保证在需要时值确实会被初始化为一个有意义的值。null 引用可能会抛出 NullPointerException,这至少会在异常处理被正确实现时被发现,但零值可能是一个可接受的业务值,导致程序静默地使用这个可接受但错误的价值。

或者,你可以使用 Result<A> 来表示值。这将避免使用 null 引用,但你仍然需要使用可变字段。因为所有这些内容都是私有的,所以使用 null 是可以接受的。但如果你愿意,可以使用 Result(或 Option)来表示 ht 字段。

注意,尽管 ht 字段必须是可变的,但它们不需要同步。最糟糕的情况是,一个线程将测试该字段并发现它是 null,然后第二个线程可能在第一个线程初始化它之前测试该字段。最终结果是该字段被初始化了两次,可能具有不同的(尽管相等)值。单从本身来看,这不是一个大问题;写入引用是原子的,所以数据不会被破坏。然而,这可能导致内存中存在对应对象的两个实例。如果你只测试对象是否相等,这不会是问题,但如果你测试它们是否具有相同的身份(当然,你永远不会这样做),则可能会出现问题。

还要注意,可以通过在其他地方进行轻微修改来完全避免 null 引用和可变字段。尝试找出如何做到这一点。如果你不知道如何做到,请记住这个想法。我们将在本章末尾回到它。

下面的列表显示了具有懒加载 headtail 的完整 Stream 类。

列表 9.5. 完整的 Stream

图片

图片

练习 9.1

编写一个 headOption 方法,该方法返回流评估后的 head。此方法将在 Stream 父类中声明,其签名如下:

public abstract Result<A> headOption();

解决方案 9.2

Empty 实现返回一个空的 Result

@Override
public Result<A> headOption() {
  return Result.empty();
}

Cons 实现返回评估后的 headSuccess

@Override
public Result<A> headOption() {
  return Result.success(head());
}

9.5.2. 操作流

在本章的剩余部分,你将学习如何在数据未评估的情况下组合流,并充分利用这一点。但为了查看流,你需要一个方法来评估它们。可以通过将其转换为 List 来评估流的所有元素。或者,你可以通过评估前 n 个元素,或者通过评估满足条件直到条件不再满足的元素来处理流。

练习 9.2

创建一个 toList 方法,将 Stream 转换为 List

提示

你可以在 Stream 类中实现一个显式的递归方法。

解答 9.2

递归版本将简单地 cons 流的 head 到对 tail 应用 toList 方法的结果。当然,你需要使此过程尾递归,以便使用 TailCall 来获得栈安全的实现:

public List<A> toList() {
  return toList(this, List.list()).eval().reverse();
}

private TailCall<List<A>> toList(Stream<A> s, List<A> acc) {
  return s.isEmpty()
      ? ret(acc)
      : sus(() -> toList(s.tail(), List.cons(s.head(), acc)));
}

注意,这里没有显示 TailCall.ret()TailCall.sus() 的静态导入。

注意,在无限流(如 Stream.from(1) 创建的流)上调用 toList 将创建一个无限列表。与流不同,列表是急切评估的,因此理论上会导致程序永远无法结束。(在现实中,它将以 OutOfMemoryError 结束。)确保在运行程序之前创建一个条件来截断列表,正如你将在下一个练习中看到的那样。

练习 9.3

编写一个 take(n) 方法,它返回流的前 n 个元素,并编写一个 drop(n) 方法,它返回移除前 n 个元素后的剩余流。请注意,在调用这些方法时必须确保不发生任何评估。以下是 Stream 父类中的签名:

public abstract Stream<A> take(int n);
public abstract Stream<A> drop(int n);

解答 9.3

Empty 类中的两种实现都返回 this。对于 Cons 类中的 take 方法,你需要通过调用 cons 方法并使用流的非评估 head(这意味着对 head 字段的引用,而不是对 head() 方法的调用)来创建一个新的 Stream<A>,并对流的 tail 进行递归调用 take(n - 1),直到 n == 1drop 方法甚至更简单。你只需要在 n > 0 时对尾部递归调用 drop(n - 1)。请注意,take 方法不需要确保栈安全,因为对 take 的递归调用已经是懒加载的。

public Stream<A> take(int n) {
  return n <= 0
      ? empty()
      : cons(head, () -> tail().take(n - 1));
}

take 方法允许你通过截断无限流来在有限范围内工作。但是请注意,在将其转换为列表之前,必须在该流上调用此方法:

List<Integer> list = Stream.from(1).take(10).toList();

在结果列表上调用等效方法将导致程序挂起,直到内存耗尽,从而引发 OutOfMemoryError

List<Integer> list = Stream.from(1).toList().takeAtMost(10);

与之相反,drop 方法必须确保栈安全:

public Stream<A> drop(int n) {
  return drop(this, n).eval();
}

public TailCall<Stream<A>> drop(Stream<A> acc, int n) {
  return n <= 0
      ? ret(acc)
      : sus(() -> drop(acc.tail(), n - 1));
}

练习 9.4

编写一个 takeWhile 方法,该方法将返回一个 Stream,只要满足条件,就包含所有起始元素。这是 Stream 父类中的方法签名:

public abstract Stream<A> takeWhile(Function<A, Boolean> p)
提示

注意,与takedrop不同,这个方法将评估一个元素,因为它必须测试第一个元素以验证它是否满足由谓词表达的条件。您应该验证只有流中的第一个元素被评估。

解决方案 9.4

这个方法与take方法非常相似。主要区别在于终止条件不再是n <= 0,而是提供的返回false的函数:

public Stream<A> takeWhile(Function<A, Boolean> f) {
  return f.apply(head())
      ? cons(head, () -> tail().takeWhile(f))
      : empty();
}

再次强调,您不需要使该方法堆栈安全,因为递归调用未评估。Empty实现返回this

练习 9.5

编写一个dropWhile方法,该方法返回一个流,只要前导元素满足条件就移除它们。这是在Stream父类中的签名:

public Stream<A> dropWhile(Function<A, Boolean> p);
提示

您需要编写这个方法的尾递归版本,以便使其堆栈安全。

解决方案 9.5

与之前的递归方法一样,解决方案将包括一个主方法调用堆栈安全的递归辅助方法并评估其结果:

public Stream<A> dropWhile(Function<A, Boolean> p) {
  return dropWhile(this, p).eval();
}

private TailCall<Stream<A>> dropWhile(Stream<A> acc,
                                      Function<A, Boolean> p) {
  return acc.isEmpty()
      ? ret(acc)
      : p.apply(acc.head())
          ? sus(() -> dropWhile(acc.tail(), p))
          : ret(acc);
}

因为这个方法使用辅助方法,所以它可以在Stream父类中实现。

9.6. 懒惰的真正本质

懒惰通常被理解为仅在需要时(如果需要)评估表达式。实际上,这仅仅是懒惰的一个应用。

懒惰的真正含义

严格性和懒惰之间的真正区别在于,严格性是关于做事情,而懒惰是关于标记要做的事情。懒惰评估数据表示数据必须在未来的某个时刻被评估。但懒惰并不局限于评估数据。

在 Java 中向控制台打印是严格的,因为它是一个效果,所以它与函数式编程不兼容。但是,标记您应该在未来的某个时刻打印到控制台(这可以称为“懒惰打印”)是不同的。这种懒惰效果只是产生可以作为程序结果返回的数据。关于这个主题的更多内容请参阅第十三章学习如何做到这一点。

以一个非常简单的命令式程序为例:

List<String> names = ...
for(String name : names) {
  System.out.println(String.format("Hello, %s!", name));
}

这个程序应用了严格性,因为对于列表中的每个名称,它执行必须执行的操作。这个程序的懒惰版本可能看起来像这样:

List<String> names = ...
names.map(name -> (Runnable) () -> System.out.println(name));

而不是打印每个名称,这个程序生成打印名称的指令列表。换句话说,这个程序编写了一个可以在以后执行的程序。重要的是要理解,这两个程序并不等价,因为如果您运行它们,它们不会产生相同的结果。但第二个程序的结果与第一个程序本身等价,因为如果您运行第二个程序的结果,您将得到与运行第一个程序完全相同的结果。

当然,要运行第二个程序的结果,你需要某种类型的解释器。您将在第十三章学习如何做到这一点(尽管您可能已经对涉及的内容有了很好的了解)。

这种方法的巨大优势在于,你可以生成一个描述产生错误的程序的描述,然后基于某些条件决定不执行它。或者,你可以生成一个无限表达式,然后应用一些方法将其简化为有限的表达式。

当你编写一个模拟布尔运算符惰性的方法时,你已经看到了第一个情况的例子。对于第二个情况的例子,想象你有一个所有正整数的列表。在命令式编程中,可以这样写:

for (int i = 0;; i++) {}

这样的程序永远不会终止,尽管它没有做任何事情。但如果你想找到第一个斐波那契值大于 500 的整数,你可以这样写:

for (int i = 0;; i++) {
  if (fibo(i) > 500) return i;
}

现在程序终止,因为找到答案后整数列表将停止评估。这是因为for循环是一个惰性结构。尽管for (int i = 0;; i++)代表一个无限整数序列,但它只会按需评估。

在第八章中,你在List类中创建了以下exists方法:

public Boolean exists(Function<T, Boolean> p) {
  return p.apply(head()) || tail().exists(p);
}

此方法遍历列表,直到找到一个满足谓词p的元素。由于||操作符是惰性的,如果第一个参数评估为true,则不会评估其第二个参数,因此不会检查列表的其余部分。

练习 9.6

Stream创建一个exists方法。该方法应该只在满足条件时评估元素。如果条件从未满足,则所有元素都将被评估。

解决方案 9.6

一个简单的解决方案可以与List中的exists方法非常相似:

public boolean exists(Function<A, Boolean> p) {
  return p.apply(head()) || tail().exists(p);
}

当然,你应该使其堆栈安全。为了编写堆栈安全的实现,你必须首先使其尾递归,然后使用TailCall类:

public boolean exists(Function<A, Boolean> p) {
  return exists(this, p).eval();
}

private TailCall<Boolean> exists(Stream<A> s, Function<A, Boolean> p) {
  return s.isEmpty()
      ? ret(false)
      : p.apply(s.head())
          ? ret(true)
          : sus(() -> exists(s.tail(), p));
}

这个版本适用于两个子类,因此它可以放在Stream父类中。

9.6.1. 折叠流

在第五章中,你看到了如何将递归抽象为折叠方法,并学习了如何折叠列表的左右。折叠流略有不同。尽管原理相同,但主要区别在于流是未评估的。递归操作可能会导致堆栈溢出并抛出StackOverflowException,但递归操作的描述不会。结果是,在List中无法使其堆栈安全的foldRight在许多情况下不会溢出堆栈。如果它意味着评估每个操作,例如添加Stream<Integer>的元素,则它将溢出,但如果它不是评估操作,而是构建一个未评估操作的描述,则不会溢出。

另一方面,基于foldLeftListfoldRight实现(可以是栈安全的)不能与流一起使用,因为它需要反转流,这将导致评估所有元素;在无限流的情况下甚至可能不可能。同样,栈安全的foldLeft版本也不能使用,因为它反转了计算的方向。

练习 9.7

为流创建一个foldRight方法。此方法将与List.fold-Right方法类似,但你应该注意延迟计算。

提示

延迟计算通过元素是Supplier<T>而不是T来表示。Stream父类中方法的签名将是

public abstract <B> B foldRight(Supplier<B> z,
                                Function<A, Function<Supplier<B>, B>> f);

解决方案 9.7

Empty类中的实现是显而易见的:

public <B> B foldRight(Supplier<B> z,
                       Function<A, Function<Supplier<B>, B>> f) {
  return z.get();
}

以下是Cons实现的示例:

public <B> B foldRight(Supplier<B> z,
                       Function<A, Function<Supplier<B>, B>> f) {
  return f.apply(head()).apply(() -> tail().foldRight(z, f));
}

注意,此方法不是栈安全的,因此不应用于计算超过大约一千个整数的列表之和。然而,你会发现它有许多有趣的用例。

练习 9.8

使用foldRight实现takeWhile方法。验证它在长列表上的行为。

解决方案 9.8

初始值是一个空的流Supplier。这可以写成() -> empty(),但你也可以使用方法引用版本,Stream::empty。该函数测试当前元素(f.apply(a))。如果结果是true(意味着该元素满足由谓词p表达的条件),则通过将aSupplier添加到当前流中,cons返回一个流。

public Stream<A> takeWhile(Function<A, Boolean> p) {
  return foldRight(Stream::empty, a -> b -> p.apply(a)
      ? cons(() -> a, b)
      : empty());
}

如您通过运行本书附带代码中的测试(github.com/fpinjava/fpinjava)所验证的,即使对于超过一百万个元素的流,此方法也不会导致栈溢出。这是因为foldRight不会自己评估结果。评估取决于用于折叠的函数。如果此函数构造了一个新的流(如takeWhile的情况),则此流不会被评估。

练习 9.9

使用foldRight实现headOption

解决方案 9.9

起始元素将是一个非评估的空流(Result::empty() -> Result.empty())。这将是在流为空时返回的值。用于折叠流的函数将简单地忽略第二个参数,因此第一次应用(到head元素)时,它返回Result.success(a),并且此结果将永远不会改变。

public Result<A> headOptionViaFoldRight() {
  return foldRight(Result::empty, a -> ignore -> Result.success(a));
}

练习 9.10

使用foldRight实现map。验证此方法不会评估流中的任何元素。

解决方案 9.10

从一个空的流Supplier开始。用于折叠的函数将cons当前元素的非评估应用与当前结果。

public <B> Stream<B> map(Function<A, B> f) {
  return foldRight(Stream::empty, a -> b -> cons(() -> f.apply(a), b));
}

练习 9.11

使用foldRight实现filter。验证此方法不会评估比所需更多的流元素。

解决方案 9.11

再次,从一个非评估的空流开始。用于折叠的函数将过滤器应用于当前参数。如果结果是true,则使用该元素通过cons将其与当前流结果组合来创建一个新的流。否则,当前流结果保持不变。(在b上调用get不会评估任何元素。)

public Stream<A> filter(Function<A, Boolean> p) {
  return foldRight(Stream::empty, a -> b -> p.apply(a)
      ? cons(() -> a, b)
      : b.get());
}

注意,这种方法评估流元素直到找到第一个匹配项。有关详细信息,请参阅附带代码中的相应测试。

练习 9.12

使用foldRight来实现appendappend方法在参数上应该是非严格的。

解答 9.12

起始元素是你想要附加的(非评估的)流。折叠函数简单地通过cons将当前元素附加到当前结果上创建一个新的流。

public Stream<A> append(Supplier<Stream<A>> s) {
  return foldRight(s, a -> b -> cons(() -> a, b));
}

练习 9.13

使用foldRight来实现flatMap

解答 9.13

再次,你从一个未评估的空流开始。函数应用于当前元素,生成一个流,当前结果被附加到该流上。这相当于将结果扁平化(将Stream<Stream<B>>转换为Stream<B>)。

public <B> Stream<B> flatMap(Function<A, Stream<B>> f) {
  return foldRight(Stream::empty, a -> b -> f.apply(a).append(b));
}
跟踪评估和函数应用

重要的是要注意惰性的后果。对于像列表这样的严格集合,连续应用mapfilter和新的map将意味着对列表进行三次迭代:

private static Function<Integer, Integer> f = x -> {
  System.out.println("Mapping " + x);
  return x * 3;
};

private static Function<Integer, Boolean> p = x -> {
  System.out.println("Filtering " + x);
  return x % 2 == 0;
};

public static void main(String... args) {
  List<Integer> list = List.list(1, 2, 3, 4, 5).map(f).filter(p);
  System.out.println(list);
}

如你所见,函数fp不是真正的函数,因为它们将日志记录到控制台。这并不非常函数式,但它将帮助你理解正在发生的事情。你可以很容易地实现这个测试的函数式版本,通过返回一个包含结果和日志字符串列表的元组。(如果你喜欢,你可以作为额外练习这样做。)这个程序打印以下内容:

Mapping 5
Mapping 4
Mapping 3
Mapping 2
Mapping 1
Filtering 15
Filtering 12
Filtering 9
Filtering 6
Filtering 3
[6, 12, NIL]

这表明所有元素都通过函数f处理,意味着对列表的完整遍历。然后所有元素都通过函数p处理,意味着对由第一次map产生的列表的第二次完整遍历。

相比之下,看看以下程序,它使用Stream而不是List

private static Stream<Integer> stream =
    Stream.cons(() -> 1,
        Stream.cons(() -> 2,
            Stream.cons(() -> 3,
                Stream.cons(() -> 4,
                    Stream.cons(() -> 5, Stream.<Integer>empty())))));

private static Function<Integer, Integer> f = x -> {
  System.out.println("Mapping " + x);
  return x * 3;
};

private static Function<Integer, Boolean> p = x -> {
  System.out.println("Filtering " + x);
  return x % 2 == 0;
};
public static void main(String... args) {
  Stream<Integer> result = stream.map(f).filter(p);
  System.out.println(result.toList());
}

这是输出:

Mapping 1
Filtering 3
Mapping 2
Filtering 6
Mapping 3
Filtering 9
Mapping 4
Filtering 12
Mapping 5
Filtering 15
[6, 12, NIL]

你可以看到流遍历只发生一次。首先元素1通过f映射,得到3。然后3被过滤(由于它不是偶数而被丢弃)。然后2通过f映射,得到6,被过滤并保留为结果。

如你所见,流的惰性允许你组合计算的描述而不是结果。注意,元素的评估被减少到最小。

如果你使用未评估的值来构建流,并使用带有日志记录的评估方法,同时删除结果的打印,则会得到以下结果:

Evaluating 1
Mapping 1
Filtering 3
Evaluating 2
Mapping 2
Filtering 6

你可以看到只有前两个元素被评估。其余的评估是最终打印的结果。

练习 9.14

编写一个 find 方法,它接受一个谓词(一个从 ABoolean 的函数)作为参数,并返回一个 Result<A>。如果找到与谓词匹配的元素,则为 Success,否则为 Empty

提示

你几乎不需要写什么。只需结合前几节中编写的两种方法即可。

解答 9.14

只需将 filter 方法与 headOption 组合:

public Result<A> find(Function<A, Boolean> p) {
  return filter(p).headOption();
}

9.7. 处理无限流

因为流是未评估的,所以它可以在计算中组合的同时变得无限。一个简单的例子是您已经看到的 from 方法:

public static Stream<Integer> from(int i) {
  return cons(() -> i, () -> from(i + 1));
}

此方法返回一个从 i 开始并以每个新元素加一为特征的整数无限流。这是一种创建有限递增整数流的非常方便的方法:

Stream<Integer> stream = from(0).take(10000);

此代码将创建一个包含 10,000 个整数的流,从 0 到 9,999,而不进行任何评估。

练习 9.15

编写一个 repeat 方法,它接受一个对象作为其参数,并返回一个无限流,该流包含相同的对象。

解答 9.15

此方法与 from 方法非常相似:

public static <A> Stream<A> repeat(A a) {
  return cons(() -> a, () -> repeat(a));
}

练习 9.16

通过编写一个接受两个参数的 iterate 方法泛化 fromrepeat 方法:一个种子,它将被用于第一个值,以及一个计算下一个值的函数。以下是它的签名:

public static <A> Stream<A> iterate(A seed, Function<A, A> f)

然后基于 iterate 重新编写 fromrepeat 方法。

解答 9.16

iterate 方法的结构与 fromrepeat 完全相同,区别在于起始值和函数已被参数化:

public static <A> Stream<A> iterate(A seed, Function<A, A> f) {
  return cons(() -> seed, () -> iterate(f.apply(seed), f));
}

public static <A> Stream<A> repeat(A a) {
  return iterate(a, x -> x);
}
public static Stream<Integer> from(int i) {
  return iterate(i, x -> x + 1);
}

注意,因为种子作为方法参数传递,所以在用于创建一个“未评估”值(一个 Supplier)之前,它会被评估。当然,创建一个接受未评估种子的 iterate 版本非常简单:

public static <A> Stream<A> iterate(Supplier<A> seed, Function<A, A> f) {
  return cons(seed, () -> iterate(f.apply(seed.get()), f));
}

练习 9.17

编写一个 fibs 函数,生成斐波那契数的无限流:0, 1, 1, 2, 3, 5, 8,以此类推。

提示

考虑使用 iterate 方法生成一个包含整数元组的中间流。

解答 9.17

解答在于创建一个包含两个连续斐波那契数 (x, y) 的元组流。一旦生成了这个流,只需使用一个从元组到其第一个元素的函数对其进行 map 即可:

public static Stream<Integer> fibs() {
  return iterate(new Tuple<>(0, 1),
                 x -> new Tuple<>(x._2, x._1 + x._2)).map(x -> x._1);
}

练习 9.18

iterate 方法可以进一步泛化。编写一个 unfold 方法,它接受一个类型为 S 的起始状态和一个从 SResult<Tuple<A, S>> 的函数作为参数,并返回一个 A 类型的流。返回 Result 使得可以指示流是否应该停止或继续。

使用状态 S 意味着数据生成的来源不必与生成的数据类型相同。要应用这种方法,请用 unfold 方法重新编写 fibsfrom 的版本。以下是 unfold 方法的签名:

public static <A, S> Stream<A> unfold(S z,
                                      Function<S, Result<Tuple<A, S>>> f)

解答 9.18

首先,将f函数应用于初始状态z。这会产生一个Result<Tuple<A, S>>。然后使用一个从Tuple<A, S>到函数的映射,通过cons操作将元组的左侧成员(A值)与一个(非评估的)递归调用unfold相结合,并使用元组的右侧成员作为初始状态,从而生成一个流。这个映射的结果是Success(stream)Empty。然后使用getOrElse来返回包含的流或默认的空流:

public static <A, S> Stream<A> unfold(S z,
                                      Function<S, Result<Tuple<A, S>>> f) {
  return f.apply(z).map(x -> cons(() -> x._1,
                              () -> unfold(x._2, f))).getOrElse(empty());
}

from的新版本使用整数种子作为初始状态,以及一个从IntegerTuple<Integer, Integer>的函数。在这里,状态与值具有相同的类型:

public static Stream<Integer> from(int n) {
  return unfold(n, x -> Result.success(new Tuple<>(x, x + 1)));
}

fibs方法更完整地使用了unfold方法。状态是一个Tuple<Integer, Integer>,函数产生一个Tuple<Integer, Tuple<Integer, Integer>>

public static Stream<Integer> fibs() {
  return unfold(new Tuple<>(1, 1),
      x -> Result.success(new Tuple<>(x._1, new Tuple<>(x._2, x._1 + x._2))));
}

你可以看到这些方法实现是多么紧凑和优雅!

9.8. 避免使用null引用和可变字段

在第 9.5.1 节中,我说修改你的Stream类以缓存头和尾而不使用null引用和可变字段是很简单的。你找到解决方案了吗?实际上,对尾引用的缓存并不是真正必要的,因为尾本身是一个惰性结构(一个Stream),所以评估引用不会花费太多时间。你只需要缓存头。

避免使用null引用很简单:只要值没有被评估,你可以使用Result.Empty代替null,并使用Result.Success来保存评估后的值。为了避免使用可变字段,当值被评估时,你需要生成一个新的Stream。为此,你将使用两个构造函数:一个用于非评估的头,另一个用于评估的头:

private final Supplier<A> head;
private final Result<A> h;
private final Supplier<Stream<A>> tail;

private Cons(Supplier<A> h, Supplier<Stream<A>> t) {
  head = h;
  tail = t;
  this.h = Result.empty();
}

private Cons(A h, Supplier<Stream<A>> t) {
  head = () -> h;
  tail = t;
  this.h = Result.success(h);
}

由于评估发生在head方法中,你需要一个新的实现。但你还需要返回带有head值的新的Stream。你可以让head方法返回一个Tuple<A, Stream<A>>

public Tuple<A, Stream<A>> head() {
  A a = h.getOrElse(head.get());
  return h.isEmpty()
      ? new Tuple<>(a, new Cons<>(a, tail))
      : new Tuple<>(a, this);
}

当然,现在所有使用head()的方法都必须使用head()._1。如果持有对流的引用,它必须被新的流(head()._2)替换。注意,到目前为止,这从未在Stream类内部发生!

headOption方法也必须修改为返回一个元组。你可以在本书附带的代码中的listing09_06包中找到完整的Stream类(github.com/fpinjava/fpinjava)。

练习 9.19

使用foldRight来实现各种方法是智能的技术。不幸的是,它对filter并不真正适用。如果你用一个不匹配超过 1,000 或 2,000 个连续元素的谓词测试这个方法,它将溢出栈。在不使用null或可变字段的新Stream类中,编写一个栈安全的filter方法。

提示

问题来自于那些返回false的长序列元素。试着想想如何去除这些元素。

解决方案 9.19

解决方案是使用dropWhile方法删除返回false的长序列元素。为此,你必须反转条件(!p.apply(x)),然后测试结果流是否为空。如果流为空,则返回它。(任何空流都可以,因为空流是一个单例。它只需要是正确的类型。)如果流不为空,则通过cons将头与过滤后的尾部组合来创建一个新的流。

注意,head方法返回一个元组,因此你必须使用这个元组的左(第一个)元素作为流的head元素。在理论上,你应该使用元组的右(第二个)元素进行任何进一步的访问。如果不这样做,就会导致对头的再次评估。但由于你不会第二次访问头,而只是访问尾部,你可以使用stream.getTail()代替。这允许你避免使用局部变量来引用stream.head()的结果。

public Stream<A> filter(Function<A, Boolean> p) {
  Stream<A> stream = this.dropWhile(x -> !p.apply(x));
  return stream.isEmpty()
      ? stream
      : cons(() -> stream.head()._1,
             () -> stream.tail().filter(p));
}

另一种可能性是使用headOption方法。此方法返回一个包含Result<A>Tuple,该Result<A>可以通过递归调用映射以产生新的流。最终,这将产生一个Result<Stream<A>>,如果没有元素满足谓词,它将是空的。剩下要做的就是调用Result上的getOrElse,传递一个空流作为默认值。

public Stream<A> filter(Function<A, Boolean> p) {
  Stream<A> stream = this.dropWhile(x -> !p.apply(x));
  return stream.headOption()._1.map(a -> cons(() -> a,
                      () -> stream.tail().filter(p))).getOrElse(empty());
}

9.9. 摘要

  • 严格评估意味着在引用值时立即评估值。

  • 懒加载意味着仅在需要时评估值。

  • 一些语言是严格的,而另一些是懒加载的。有些默认是懒加载的,并且可以选择严格;而有些默认是严格的,并且可以选择懒加载。

  • Java 是一种严格的编程语言。它在方法参数方面非常严格。

  • 虽然 Java 不是懒加载的,但你仍然可以使用Supplier接口来实现懒加载。

  • 懒加载允许你操作和组合无限数据结构。

  • Stream是一个非评估的、可能无限的列表。

  • 你可以使用记忆化来避免多次评估相同的值。

  • 右折叠不会导致流评估。只有一些用于折叠的函数会这样做。

  • 使用折叠,你可以组合多个迭代操作,而不会导致多次迭代。

  • 你可以轻松定义和组合无限流。

第十章. 使用树进行更多数据处理

本章涵盖

  • 理解树结构中大小、高度和深度的关系

  • 理解插入顺序与二叉搜索树结构之间的关系

  • 以不同顺序遍历树

  • 实现二叉搜索树

  • 合并、折叠和平衡树

在第五章中,你学习了单链表,这可能是函数式编程中最广泛使用的数据结构。尽管列表对于许多操作来说是一个非常高效的数据结构,但它有一些局限性,主要的一个是访问元素的复杂度与元素数量成比例增长。例如,如果搜索的元素恰好是列表中的最后一个,那么搜索特定元素可能需要检查所有元素。其他效率较低的操作包括排序、通过索引访问元素以及找到最大或最小元素。显然,要找到列表中的最大(或最小)元素,必须遍历整个列表。在本章中,你将了解一种解决这些问题的数据结构:二叉树。

10.1. 二叉树

数据树是结构,与列表不同,每个元素都链接到多个元素。在某些树中,一个元素(有时称为节点)可能链接到可变数量的其他元素。然而,通常元素链接到固定数量的元素。在二叉树中,正如其名所示,每个元素链接到两个元素。这些链接称为分支。在二叉树中,我们谈论左分支和右分支。图 10.1 显示了二叉树的一个示例。

图 10.1. 二叉树是一个由根和两个分支组成的递归结构。左分支是左子树的链接,右分支是右子树的链接。终端元素具有空分支(图中未表示)并称为叶子。

图片

在图 10.1 中表示的树并不常见,因为它的元素类型不同。换句话说,它是一个对象树。你通常会处理更具体类型的树,例如整数树。在图中,你可以看到树是一个递归结构。每个分支都指向一个新的树(有时称为子树)。你也可以看到一些分支指向单个元素。这并不是问题,因为单个元素实际上是一个带有空分支的树。还要注意T元素:它有一个左分支,但没有右分支。

从这个定义中,你可以推断出二叉树的定义。树是以下之一:

  • 单个元素

  • 具有一个分支(右或左)的元素

  • 具有两个分支(右和左)的元素

每个分支都持有(子)树。所有元素要么有两个分支要么没有分支的树称为 树。图 10.1 中的树不是满的,但左子树是。

10.1.1. 平衡和不平衡树

二叉树可能更或更平衡。完全平衡的树是所有子树的两个分支包含相同数量元素的树。图 10.2 展示了具有相同元素的三棵树的例子。第一棵树是完全平衡的,最后一棵树是完全不平衡的。完全平衡的二叉树有时被称为 完美 树。

图 10.2. 树可以更或更不平衡。

图片

在 图 10.2 中,右边的树实际上是一个单链表。单链表可以看作是完全不平衡树的特例。

10.1.2. 大小、高度和深度

树可以通过它包含的元素数量以及这些元素所在的层数来描述。元素的数量称为 大小,不包括根的层数称为 高度。在 图 10.2 中,所有三棵树的大小都是 7。第一棵(完全平衡)树的高度为 2,第二棵高度为 3,第三棵高度为 6。

“高度”这个词也用来描述单个元素,它指的是从元素到叶子的最长路径的长度。根的高度是树的高度,元素的高度是以该元素为根的子树的高度。

元素的 深度 是从根到元素路径的长度。第一个元素,也称为 ,深度为 0。在 图 10.2 中的完全平衡树中,5 和 4 的深度为 1;而 2、8、7 和 3 的深度为 2。

按照惯例,空树的高度和深度等于 -1。你会看到这对于某些操作是必要的,例如平衡。

10.1.3. 叶子树

二叉树有时以不同的方式表示,如 图 10.3 所示。在这种表示中,树由不持有值的分支表示。只有终端节点持有值。终端节点被称为 叶子;因此,得名 叶子树

图 10.3. 叶子树只持有叶子中的值。

图片

叶子树表示法有时更受欢迎,因为它使得实现某些函数更容易。在这本书中,我们将只考虑“经典”树,而不是叶子树。

10.1.4. 有序二叉树或二叉搜索树(BST)

有序二叉树,也称为二叉搜索树(BST),是一种包含可以排序的元素的树,其中某一分支的所有元素值都低于根元素,而另一分支的所有元素值都高于根元素。这个条件对所有子树都成立。按照惯例,值低于根的元素位于左分支,而值高于根的元素位于右分支。图 10.4 展示了有序树的一个示例。

图 10.4. 有序树或二叉搜索树(BST)的示例

图片

有序二叉树的定义的一个重要后果是它们永远不会包含重复项。

有序树特别有趣,因为它们允许快速检索元素。要找出一个元素是否包含在树中,你应遵循以下步骤:

  1. 将要搜索的元素与根节点进行比较。如果它们相等,则完成。

  2. 如果要搜索的元素低于根节点,则递归地使用左分支进行操作。

  3. 如果要搜索的元素高于根节点,则递归地使用右分支进行操作。

与单链表的搜索相比,你可以看到在完美平衡的有序二叉树中进行搜索所需的时间与树的高度成正比,这意味着它将花费与 log2(n)成正比的时间,其中n是树的大小(元素数量)。相比之下,单链表中的搜索时间与元素数量成正比。

这的直接后果是,在完美平衡的二叉树中进行递归搜索永远不会溢出栈。正如你在第四章中看到的,标准栈大小允许 1,000 到 3,000 次递归步骤。因为高度为 1,000 的完美平衡二叉树包含 2^(1,000)个元素,你永远不会有足够的主内存来容纳这样的树。

这是一个好消息。但坏消息是并非所有二叉树都是完美平衡的。因为完全不平衡的二叉树实际上是一个单链表,它将具有与列表相同的性能和递归问题。这意味着要充分利用树,你将不得不找到一种方法来平衡它们。

10.1.5. 插入顺序

树的结构(即其平衡程度)取决于其元素的插入顺序。插入的方式与搜索相同:

  1. 将要插入的元素与根节点进行比较。如果它们相等,则完成。因为你可以只插入比根节点低或高的元素,所以没有东西可以插入。然而,请注意,实际情况有时会不同。如果插入树中的对象在树排序的角度上可能相等,但根据其他标准不同,你可能希望用你要插入的元素替换根节点。这将是最常见的案例,正如你将看到的。

  2. 如果要插入的元素低于根节点,则递归地将其插入到左分支。

  3. 如果要插入的元素高于根节点,则递归地将其插入到右分支。

这个过程导致一个非常有趣的观察:树的平衡取决于元素插入的顺序。显然,插入有序元素将产生一个完全不平衡的树。另一方面,许多插入顺序会产生相同的树。图 10.5 显示了可能导致相同树的可能的插入顺序。

图 10.5. 许多不同的插入顺序可以产生相同的树。

10 个元素可以以 3,628,800 种不同的顺序插入到树中,但这只会产生 16,796 种不同的树。这些树将从完全平衡到完全不平衡。从更实际的角度来看,有序树在存储和检索随机数据时非常高效,但在存储和检索预序数据时非常糟糕。你很快就会学到如何解决这个问题。

10.1.6. 树遍历顺序

给定一个如图图 10.5 所示的特定树,一个常见的用例是遍历它,依次访问所有元素。这通常是映射或折叠树的情况,以及在某种程度上搜索树以查找特定值的情况。当我们研究列表时,你了解到有两种方式可以遍历它们:从左到右或从右到左。树提供了许多更多的方法,其中我们将区分递归和非递归方法。

递归遍历顺序

考虑图 10.5 中的树的左分支。这个分支本身是一个由根节点 1、左分支 0 和右分支 2 组成的树。你可以用六种顺序遍历这个树:

  • 1, 0, 2

  • 1, 2, 0

  • 0, 1, 2

  • 2, 1, 0

  • 0, 2, 1

  • 2, 0, 1

你可以看到,这三个顺序与另外三个顺序是对称的。1, 0, 2 和 1, 2, 0 是对称的。你从根节点开始,然后访问两个分支,从左到右或从右到左。同样,0, 1, 2 和 2, 1, 0 也一样,它们只是分支的顺序不同,以及 0, 2, 1 和 2, 0, 1 也一样。你只会考虑从左到右的方向(因为另一个方向正好相同,就像在镜子中看到的一样),所以你只剩下三个顺序,这些顺序是以根节点的位置命名的:

  • 预序(1 0 2 或 1 2 0)

  • 有序(0 1 2 或 2 1 0)

  • 后序(0 2 1 或 2 0 1)

这些术语是根据操作中的操作符位置提出的。为了更好地看到类比,想象将根(1)替换为加号(+),产生如下:

  • 前序(+ 0 2 或 + 2 0)

  • 中序(0 + 2 或 2 + 0)

  • 后缀(0 2 + 或 2 0 +)

将这些顺序递归应用于整个树,会导致在优先考虑高度的同时遍历树,导致 图 10.6 中显示的遍历路径。请注意,这种遍历通常被称为 深度优先 而不是更合理的 高度优先。当谈论整个树时,高度和深度指的是根的高度和最深叶子的深度。这两个值是相等的。

图 10.6. 深度优先遍历是指在遍历树时优先考虑高度。这可以应用在三种主要顺序中。

非递归遍历顺序

遍历树的另一种方式是首先访问一个完整的层,然后转到下一层。同样,这可以从左到右或从右到左进行。这种遍历称为 层序遍历,或 广度优先搜索;一个例子在 图 10.7 中展示。

图 10.7. 层序遍历是指在访问给定层的所有元素之后,再访问下一层。

10.2. 实现二叉搜索树

在这本书中,我们将考虑传统的二叉树而不是叶树。二叉树以与单链表相同的方式实现,有一个头(称为 value)和两个尾(分支,称为 leftright)。你将定义一个抽象的 Tree 类,有两个子类分别命名为 TEmptyT 代表非空树,而 Empty(不出所料)代表空树。以下列表表示 Tree 的最小实现。

列表 10.1. Tree 的实现

这个类相当简单,但如果你没有构建真实树的方法,它就毫无用处。

练习 10.1

定义一个 insert 方法将值插入到树中。这个方法的名字 insert 并不是很好选择,因为没有真正需要插入的东西。像往常一样,Tree 结构是不可变的和持久的,所以必须构建一个新的包含插入值的树,而原始树保持不变。但通常称之为 insert 方法,因为它与传统编程中的插入功能相同。

如果值等于根,你必须返回一个新的树,其中插入的值作为根,两个原始分支保持不变。否则,小于根的值插入到左分支,大于根的值插入到右分支。在父 Tree 类中声明此方法,并在两个子类中实现它。这是方法签名:

public abstract Tree<A> insert(A a);

解决方案 10.1

Empty 的实现构建一个新的 T,其中插入的值作为根,两个空树作为分支:

public Tree<A> insert(A insertedValue) {
  return new T<>(empty(), insertedValue, empty());
}

T的实现稍微复杂一些。首先,它比较插入值与根节点。如果它更低,它将使用当前根节点和当前右分支构建一个新的T。左分支是递归将值插入原始左分支的结果。

如果值高于根节点,它将使用当前根节点和当前左分支构建一个新的T。右分支是递归将值插入原始右分支的结果。

最后,如果值等于根节点,你将返回一个由插入值作为根节点和两个未更改的原始分支组成的新树:

public Tree<A> insert(A insertedValue) {
  return insertedValue.compareTo(this.value) < 0
      ? new T<>(left.insert(insertedValue), this.value, right)
      : insertedValue.compareTo(this.value) > 0
          ? new T<>(left, this.value, right.insert(insertedValue))
          : new T<>(this.left, insertedValue, this.right);
}

注意,这与 Java TreeSet中的情况不同,如果你尝试插入一个等于集合中已有元素的元素,TreeSet不会改变。尽管这种行为对于可变元素可能是可接受的,但当元素不可变时,这是不可接受的。你可能认为,使用具有相同左分支、相同右分支和等于当前根节点的根节点构建一个新的T实例是浪费时间和内存空间,因为你可以直接返回this。返回this将等同于返回

new T<>(this.left, this.value, this.right)

如果这是你的意图,返回this将是一个好的优化。这会起作用,但与通过修改树元素来获得相同结果相比,会显得繁琐。你必须在插入具有一些更改属性的相等元素之前删除元素。当你实现第十一章中的映射时,你会遇到这种情况。

你可能想知道是否应该实现栈安全递归,因为insert方法本身就是递归的。正如我之前所说的,在平衡树中这样做是没有必要的,因为树的高度(决定最大递归步骤数)通常远低于树的大小。但你已经看到,这并不总是成立,尤其是当要插入的元素是有序的时候。这最终可能导致只有一个分支的树,其高度等于其大小(减 1),从而溢出栈。

然而,目前你不必担心这个问题。与其实现栈安全递归操作,你将找到一种自动平衡树的方法。你正在工作的简单树只是为了学习,它永远不会在生产中使用。但平衡树的实现更复杂,所以你将从简单的未平衡树开始。

练习 10.2

树上常用的一个操作是检查特定元素是否存在于树中。实现一个member方法来执行这个检查。以下是它的签名:

boolean member(A a)
提示

Tree父类中实现这个作为抽象方法,并在每个子类中具体实现。

解决方案 10.2

让我们从 T 子类实现开始。你必须将参数与树 value(这意味着树的根值)进行比较。如果参数较低,递归地将比较应用于左分支。如果它较高,递归地将比较应用于右分支。如果 value 和参数相等,则简单地返回 true

public boolean member(A value) {
  return value.compareTo(this.value) < 0
      ? left.member(value)
      : value.compareTo(this.value) > 0
          ? right.member(value)
          : true;
}

注意,这段代码可以被简化成以下形式:

public boolean member(A value) {
  return value.compareTo(this.value) < 0
      ? left.member(value)
      : value.compareTo(this.value) == 0 || right.member(value);
}

但你可能觉得第一个版本更清晰。当然,Empty 实现返回 false

10.3 练习

为了简化树创建,编写一个静态方法,它接受可变参数并插入所有元素到一个空树中。这是它的签名:

public static <A extends Comparable<A>> Tree<A> tree(A... as)
提示

首先实现一个接受列表作为参数的方法。然后根据 list 方法定义可变参数方法。

10.3 解决方案

这更像是一个关于列表的练习,而不是关于树的!下面是解决方案:

public static <A extends Comparable<A>> Tree<A> tree(List<A> list) {
  return list.foldLeft(empty(), t -> t::insert);
}

@SafeVarargs
public static <A extends Comparable<A>> Tree<A> tree(A... as) {
  return tree(List.list(as));
}

10.4 练习

编写计算树的大小和高度的方法。以下是 Tree 类中它们的签名:

public abstract int size();
public abstract int height();

10.4 解决方案

当然,Empty 实现的 size 方法返回 0。正如我之前所说的,Empty 实现的 height 方法返回 -1T 类中 size 方法的实现返回每个分支的大小加 1。height 方法的实现返回两个分支的最大 height 加 1:

public int size() {
  return 1 + left.size() + right.size();
}

public int height() {
  return 1 + Math.max(left.height(), right.height());
}

基于此,你可以看到为什么空树的高度需要等于 -1。如果它是 0,则高度将等于路径中的元素数量,而不是段的数量。

注意,这些方法只是为了说明。在现实中,你会像在 List 中的 length 一样缓存高度和大小。看看这本书的代码,以提醒你如何做到这一点。

10.5 练习

编写 maxmin 方法来计算树中包含的最大和最小值。

提示

考虑在 Empty 类中这些方法应该返回什么。

10.5 解决方案

当然,空树中没有最小或最大值。解决方案是返回一个 Result<A>Empty 实现将返回 Result.empty()T 类的实现稍微有些棘手。对于 max 方法,解决方案是返回右分支的最大值。如果右分支不为空,这将是一个递归调用。如果右分支为空,你会得到 Result.Empty。然后你知道最大值是当前树中的值,所以你可以简单地调用 right.max() 方法的返回值上的 orElse 方法:

public Result<A> max() {
  return right.max().orElse(() -> Result.success(value));
}

回想一下,orElse 方法是惰性评估其参数的,这意味着它接受一个 Supplier<Result<A>>。当然,min 方法是完全对称的:

public Result<A> min() {
  return left.min().orElse(() -> Result.success(value));
}

10.3. 从树中删除元素

与单链表不同,树允许你检索特定元素,正如你在练习 10.2 中开发 member 方法时所看到的。这也应该使得从树中删除特定元素成为可能。

练习 10.6

编写一个 remove 方法,从树中移除一个元素。此方法将一个元素作为其参数。如果此元素存在于树中,它将被移除,并且该方法将返回一个不包含此元素的新树。当然,这个新树将遵守所有左分支上的元素都低于根,所有右分支上的元素都高于根的要求。如果元素不在树中,则方法将返回未更改的树。方法签名将是

Tree<A> remove(A a)
提示

你需要定义一个方法来合并两个树,其特殊性在于一个树的所有元素要么大于另一个树的所有元素,要么小于另一个树的所有元素。你还需要一个 isEmpty 方法,在 Empty 类中返回 true,在 T 类中返回 false

解决方案 10.6

当然,Empty 实现不能移除任何内容,将简单地返回 this。对于 T 子类实现,以下是你需要实现的算法:

  • 如果 a < this,则从左侧移除。

  • 如果 a > this,则从右侧移除。

  • 否则,需要移除根节点。合并左右分支,丢弃根节点,并返回结果。

合并是一个简化的合并,因为你知道左分支的所有元素都低于右分支的所有元素。

首先,你必须定义 merge 方法。在 Tree 类中定义一个抽象方法:

protected abstract Tree<A> removeMerge(Tree<A> ta)

Empty 类的实现简单地返回未更改的参数,因为将 ta 与空树合并的结果是 ta

protected Tree<A> removeMerge(Tree<A> ta) {
  return ta;
}

T 实现使用以下算法:

  • 如果 ta 为空,则返回 thisthis 不能为空)。

  • 如果 ta < this,则在左分支中合并 ta

  • 如果 ta > this,则在右分支中合并 ta

下面是其实施:

protected Tree<A> removeMerge(Tree<A> ta) {
  if (ta.isEmpty()) {
    return this;
  }
  if (ta.value().compareTo(value) < 0) {
    return new T<>(left.removeMerge(ta), value, right);
  } else if (ta.value().compareTo(value) > 0) {
    return new T<>(left, value, right.removeMerge(ta));
  }
  throw new IllegalStateException("We shouldn't be here");
}

注意,如果两个树的根相等,该方法会抛出异常,这不应该发生,因为要合并的两个树应该是同一原始树的左右分支。

现在,你可以编写 remove 方法:

public Tree<A> remove(A a) {
  if (a.compareTo(this.value) < 0) {
    return new T<>(left.remove(a), value, right);
  } else if (a.compareTo(this.value) > 0) {
    return new T<>(left, value, right.remove(a));
  } else {
    return left.removeMerge (right);
  }
}

10.4. 合并任意树

在上一节中,你使用了一种限制合并方法,该方法只能合并所有值在一个树中低于另一个树所有值的树。对于树的合并相当于列表的连接。你需要一个更通用的方法来处理任意树的合并。

练习 10.7(困难)

到目前为止,你只合并了所有元素在一个树中大于另一个树所有元素的树。编写一个 merge 方法来合并任意树。它的签名将是

public abstract Tree<A> merge(Tree<A> a);

解决方案 10.7

Empty 实现将简单地返回其参数:

public Tree<A> merge(Tree<A> a) {
  return a;
}

T 子类实现将使用以下算法,其中 this 表示定义该方法时的树:

  • 如果参数树为空,则返回 this

  • 如果参数的根高于this根,则移除参数树的左侧分支,并将结果与this右侧分支合并。然后,将结果与参数的左侧分支合并。

  • 如果参数的根小于this根,则移除参数树右侧的分支,并将结果与this左侧分支合并。然后,将结果与参数的右侧分支合并。

  • 如果参数的根等于this根,则将参数的左侧分支与this左侧分支合并,并将参数的右侧分支与this右侧分支合并。

这里是这个算法的实现:

public Tree<A> merge(Tree<A> a) {
  if (a.isEmpty()) {
    return this;
  }
  if (a.value().compareTo(this.value) > 0) {
    return new T<>(left, value, right.merge(new T<>(empty(),
                              a.value(), a.right()))).merge(a.left());
  }
  if (a.value().compareTo(this.value) < 0) {
    return new T<>(left.merge(new T<>(a.left(), a.value(),
                            empty())), value, right).merge(a.right());
  }
  return new T<>(left.merge(a.left()), value, right.merge(a.right()));
}

这个算法通过图 10.8 到 10.17 的图例来说明。

图 10.8。要合并的两个树。左侧是this树,右侧是参数树。

图片

图 10.9。参数树的根高于this树的根。将this树的右侧分支与移除左侧分支的参数树合并。(合并操作用虚线框表示。)

图片

图 10.10。要合并的每个树的根相等,你使用this值作为合并的结果。左侧分支将是两个左侧分支合并的结果,右侧分支将是两个右侧分支合并的结果。

图片

对于左侧分支,与空树合并是平凡的,只需返回原始树(根 4 和两个空分支)。对于右侧分支,第一个树有两个空分支和根 6,第二个树的根是 7,因此你移除根为 7 的树的左侧分支,并将其结果与根为 6 的树的空右侧分支合并。移除的左侧分支将与前一次合并的结果合并。请注意,右侧的根为 6 的树来自根为 7 的树,其中它已被空树替换。

图片

图 10.12。要合并的两个树的根相等(6),因此合并分支(左侧与左侧合并,右侧与右侧合并)。因为要合并的树两个分支都为空,实际上没有需要做的事情。

图片

图 10.13。合并一个空树简单地得到要合并的树。你将剩下两个具有相同根的树需要合并。

图片

图 10.14。合并具有相同根的两个树很简单:只需将右侧与右侧合并,左侧与左侧合并,并使用结果作为新分支。

图片

图 10.15。左侧合并是平凡的,因为根相等,要合并的树的两个分支都为空。在右侧,要合并的树的根较低(4),因此你移除右侧分支(E),并将剩余部分与原始树的左侧分支合并。

图片

你可以从这些图中看到,合并两个树可以得到一个大小(元素数量)小于原始树大小之和的树,因为重复的元素会自动删除。

另一方面,结果的高度可能比你预期的要高。合并高度为 3 的两棵树可能导致高度为 5 的结果树。很容易看出,最佳高度不应该高于 log2(size)。换句话说,最佳高度是大于结果大小的最小 2 的幂。在这个例子中,两棵原始树的大小是 7,高度是 3。合并树的大小是 9,最佳高度应该是 4 而不是 5。在这样一个小的例子中,这可能不是问题。但当你合并大树时,你可能会得到不平衡的树,导致性能不佳,甚至可能在使用递归方法时出现栈溢出。

图 10.16. 合并两个相同的树不需要任何解释。

图片

图 10.17. 合并最后一个空树后的最终结果

图片

10.5. 折叠树

不,这不是关于折纸的章节。折叠一棵树类似于折叠一个列表;它包括将树转换成一个单一值。例如,在一个数值树中,计算所有元素的总和可以通过折叠来表示。但折叠树比折叠列表更复杂。

计算整数树中元素的总和是微不足道的,因为加法在两个方向上都是结合的,并且是交换的。换句话说,以下表达式具有相同的值:

* (((1 + 3) + 2) + ((5 + 7) + 6)) + 4
* 4 + ((2 + (1 + 3)) + (6 + (5 + 7)))
* (((7 + 5) + 6) + ((3 + 1) + 2)) + 4
* 4 + ((6 + (7 + 5)) + (2 + (3 + 1)))
* (1 +(2 + 3)) + (4 + (5 + (6 + (7))))
* (7 + (6 + 5)) + (4 + (3 + (2 + 1)))

检查这些表达式,你可以看到它们代表使用加法折叠以下树的某些可能结果:

                           4
                          / \
                         /   \
                        2     6
                       / \   / \
                      1   3 5   7

仅考虑处理元素的顺序,你可以识别以下顺序:

  • 后序左

  • 前序左

  • 后序右

  • 前序右

  • 按序左

  • 按序右

注意,意味着从左开始从右开始。你可以通过计算每个表达式的结果来验证这一点。例如,第一个表达式可以简化如下:

(((1 + 3) + 2) + ((5 + 7) + 6 )) + 4
((   4    + 2) + ((5 + 7) + 6)) + 4  used: 1, 3
(         6    + ((5 + 7) + 6)) + 4  used: 1, 3, 2
(         6    + (   12   + 6)) + 4  used: 1, 3, 2, 5, 7
(         6    +         18   ) + 4  used: 1, 3, 2, 5, 7, 6
               24               + 4  used: 1, 3, 2, 5, 7, 6
                                28   used: 1, 3, 2, 5, 7, 6, 4

还有其他可能性,但其中这六个是最有趣的。虽然它们对于加法是等价的,但对于其他操作,例如向字符串添加字符或向列表添加元素,可能不是等价的。

10.5.1. 使用两个函数进行折叠

当折叠一棵树时,问题在于递归方法实际上将是双向递归的。你可以用给定的操作折叠每个分支,但你需要一种方法将两个结果合并成一个。这让你想起了列表折叠并行化吗?是的,你需要一个额外的操作。如果折叠Tree<A>所需的操作是从BA再到B的函数,你需要一个额外的从BBB的函数来合并左右结果。

练习 10.8

编写一个 foldLeft 方法,用于折叠一棵树,给定上述两个函数。在 Tree 类中的签名如下:

public abstract <B> B foldLeft(B identity,
                               Function<B, Function<A, B>> f,
                               Function<B, Function<B, B>> g)

解决方案 10.8

Empty 子类的实现很简单,它将简单地返回 identity 元素。T 子类的实现稍微复杂一些。你需要递归地计算每个分支的折叠,然后将结果与根结合。问题是每个分支折叠返回一个 B,但根是一个 A,而你没有任何从 AB 的函数可用。解决方案可能如下:

  1. 递归折叠左分支和右分支,给出两个 B 值。

  2. 将这两个 B 值与 g 函数结合,然后将结果与根结合并返回结果。

这可能是一个解决方案:

public <B> B foldLeft(B identity,
                      Function<B, Function<A, B>> f,
                      Function<B, Function<B, B>> g) {
  return g.apply(right.foldLeft(identity, f, g))
      .apply(f.apply(left.foldLeft(identity, f, g)).apply(this.value));
}

简单吗?并不简单。问题是 g 函数是一个从 BBB 的函数,所以你可以很容易地交换参数:

public <B> B foldLeft(B identity,
                      Function<B, Function<A, B>> f,
                      Function<B, Function<B, B>> g) {
  return g.apply(*left*.foldLeft(identity, f, g))
      .apply(f.apply(*right*.foldLeft(identity, f, g)).apply(this.value));
}

这是不是一个问题?是的,是。如果你用一个交换律的操作(如加法)折叠一个列表,结果不会改变。但如果你使用一个非交换律的操作,你就有麻烦了。最终结果是,两种解决方案会给出不同的结果。例如,以下函数,

Tree.tree(4, 2, 6, 1, 3, 5, 7)
           .foldLeft(List.list(), list -> a -> list.cons(a),
                                   x -> y -> y.concat(x)).toString();

将产生以下结果(第一个解决方案),

[4, 2, 1, 3, 6, 5, 7, NIL]

以及第二个解决方案的以下结果:

[4, 6, 7, 5, 2, 3, 1, NIL]

正确的结果是什么?你可以通过交换第二个函数的参数来找到原始结果:

Tree.tree(4, 2, 6, 1, 3, 5, 7)
           .foldLeft(List.list(), list -> a -> list.cons(a),
                                   x -> y -> x.concat(y)).toString();

实际上,这两个列表,尽管顺序不同,但代表的是同一棵树。图 10.18 表示这两种情况。

图 10.18. 从左到右和从右到左读取树

图 10.18

在本书的配套代码中,你可以找到这两个例子。请注意,这与 List 类的 foldLeftfoldRight 的可比较差异不同。从右到左的折叠实际上是反转列表的左折叠。右折叠看起来像这样:

@Override
public <B> B foldRight(B identity,
                       Function<A, Function<B, B>> f,
                       Function<B, Function<B, B>> g) {
  return g.apply(f.apply(this.value).apply(left.foldRight(identity, f, g)))
      .apply(right.foldRight(identity, f, g));
}

因为有多个遍历顺序,所以有多个可能的实现,这些实现会在非交换律操作中给出不同的结果。你可以在本书配套代码的注释中找到示例。

10.5.2. 使用单个函数进行折叠

也可以使用一个带有额外参数的单个函数进行折叠,这意味着,例如,一个从 BABB 的函数。再次强调,将会有许多可能的实现,这取决于遍历顺序。

练习 10.9

编写三个方法来折叠一棵树:foldInOrderfoldPreOrderfoldPostOrder。应用于 图 10.18 中的树,元素应按以下方式处理:

  • 按顺序:1 2 3 4 5 6 7

  • 前序:4 2 1 3 6 5 7

  • 后序:1 3 2 5 7 6 4

这里是方法签名:

<B> B foldInOrder(B identity, Function<B, Function<A, Function<B, B>>> f);
<B> B foldPreOrder(B identity, Function<A, Function<B, Function<B, B>>> f);
<B> B foldPostOrder(B identity, Function<B, Function<B, Function<A, B>>> f);

解决方案 10.9

这里是解决方案。Empty 实现都返回 identityT 类的实现如下:

public <B> B foldInOrder(B identity,
                         Function<B, Function<A, Function<B, B>>> f) {
  return f.apply(left.foldInOrder(identity, f))
          .apply(value).apply(right.foldInOrder(identity, f));
}

public <B> B foldPreOrder(B identity,
                          Function<A, Function<B, Function<B, B>>> f) {
  return f.apply(value).apply(left.foldPreOrder(identity, f))
                       .apply(right.foldPreOrder(identity, f));
}

public <B> B foldPostOrder(B identity,
                           Function<B, Function<B, Function<A, B>>> f) {
  return f.apply(left.foldPostOrder(identity, f))
          .apply(right.foldPostOrder(identity, f)).apply(value);
}

10.5.3. 选择哪种折叠实现

现在,你已经编写了五种不同的折叠方法。你应该选择哪一个?为了回答这个问题,让我们考虑一个折叠方法应该具有哪些属性。

数据结构折叠的方式与其构建方式之间存在关系。你可以从空元素开始,逐个添加元素来构建数据结构。这与折叠相反。理想情况下,你应该能够使用特定的参数折叠结构,使其成为恒等函数。对于一个列表,这将是以下这样:

list.foldRight(List.list(), i -> l -> l.cons(i));

你也可以使用foldLeft,但函数会稍微复杂一些:

list1.foldLeft(List.list(), l -> i -> l.reverse().cons(i).reverse());

(这并不令人惊讶;如果你查看foldRight的实现,你会看到它内部使用foldLeftreverse。)

你能否用树折叠做到同样的事情?为了实现这一点,你需要一种新的构建树的方法,通过组装左树、根和右树。这样,你将能够使用仅需要一个函数参数的任何三种折叠方法。

练习 10.10(困难)

创建一个方法,将两个树和一个根组合成一个新的树。它的签名将是

Tree<A> tree(Tree<A> left, A a, Tree<A> right)

此方法应允许你使用以下三种折叠方法中的任何一种重建与原始树相同的树:foldPreOrderfoldInOrderfoldPostOrder

提示

你将不得不以不同的方式处理这两种情况。如果要合并的树是有序的,这意味着第一个树的最大值低于根,第二个树的最小值高于根,你可以简单地使用T构造函数组装这三个元素。否则,你应该回退到另一种构建结果的方法。

解决方案 10.10

实现此方法有几种方式。一种是在首先定义一个方法来测试两个树是否排序。为此,你可以首先定义方法来返回值比较的结果:

public static <A extends Comparable<A>> boolean lt(A first, A second) {
  return first.compareTo(second) < 0;
}

public static <A extends Comparable<A>> boolean lt(A first, A second,
                                                            A third) {
  return lt(first, second) && lt(second, third);
}

然后,你可以定义一个有序方法来实现树比较:

public static <A extends Comparable<A>> boolean ordered(Tree<A> left,
                                                    A a, Tree<A> right) {
  return left.max().flatMap(lMax -> right.min().map(rMin ->
        lt(lMax, a, rMin))).getOrElse(left.isEmpty() && right.isEmpty())
    || left.min().mapEmpty().flatMap(ignore -> right.min().map(rMin ->
        lt(a, rMin))).getOrElse(false)
    || right.min().mapEmpty().flatMap(ignore -> left.max().map(lMax ->
        lt(lMax, a))).getOrElse(false);
}

第一个测试(在第一个||运算符之前)如果两个树都不为空,并且左maxa和右min是有序的,则返回true。第二个和第三个测试处理左树或右树为空的情况(但不是两者都为空)。请注意,Result.mapEmpty方法如果ResultEmpty,则返回Success<Nothing>,否则返回失败。

使用此方法,编写tree方法非常简单:

public static <A extends Comparable<A>> Tree<A> tree(Tree<A> t1,
                                                      A a, Tree<A> t2) {
  return ordered(t1, a, t2)
      ? new T<>(t1, a, t2)
      : ordered(t2, a, t1)
          ? new T<>(t2, a, t1)
          : Tree.<A>empty().insert(a).merge(t1).merge(t2);
}

注意,如果树没有排序,你将先测试逆序,然后再回退到正常的插入/合并算法。

现在,你可以折叠一棵树并得到与原始树相同的树(前提是你使用了正确的函数)。你将在随本书附带的测试代码中找到以下示例:

tree.foldInOrder(Tree.<Integer>empty(),
                            t1 -> i -> t2 -> Tree.tree(t1, i, t2));
tree.foldPostOrder(Tree.<Integer>empty(),
                            t1 -> t2 -> i -> Tree.tree(t1, i, t2));
tree.foldPreOrder(Tree.<Integer>empty(),
                            i -> t1 -> t2 -> Tree.tree(t1, i, t2));

你也可以定义一个只接受一个具有两个参数的函数的折叠方法,就像你在List中做的那样。诀窍是首先将树转换成列表,如下面的foldLeft示例所示:

public <B> B foldLeft(B identity, Function<B, Function<A, B>> f) {
  return toListPreOrderLeft().foldLeft(identity, f);
}

protected List<A> toListPreOrderLeft() {
  return left().toListPreOrderLeft()
                   .concat(right().toListPreOrderLeft()).cons(value);
}

这可能不是最快的实现方式,但它可能仍然很有用。

10.6. 树的映射

与列表一样,树也可以进行映射,但映射树要复杂一些。将函数应用于树的每个元素可能看起来很 trivial,但实际上并非如此。问题是并非所有函数都会保持排序。将给定值添加到整数的树的所有元素中是可以的,但如果树可能包含负值,使用函数 f(x) = x * x 将会复杂得多,因为简单地“就地”应用该函数不会得到二叉搜索树。

练习 10.11

为树定义一个map方法。如果可能的话,尽量保留树结构。例如,通过平方值映射整数树可能会产生具有不同结构的树,但通过添加常数映射则不应。

解决方案 10.11

使用其中一种折叠方法会使它非常简单直接。有几种可能的实现方式,使用各种折叠方法。以下是一个示例:

public <B extends Comparable<B>> Tree<B> map(Function<A, B> f) {
  return foldInOrder(Tree.<B>empty(),
                         t1 -> i -> t2 -> Tree.tree(t1, f.apply(i), t2));
}

当然,Empty实现返回empty()(而不是this,因为类型将无效)。

10.7. 平衡树

如我之前所说,如果树是平衡的,它们将工作得很好,这意味着从根到叶元素的路径长度几乎相同。在完全平衡的树中,长度的差异不会超过 1,这发生在较深的层级不是满的情况下。(只有大小为 2n + 1 的完全平衡树的所有根到叶元素的路径长度才相同。)

使用不平衡的树可能会导致性能不佳,因为操作可能需要的时间与树的大小成比例,而不是与 log2(size) 成比例。更严重的是,不平衡的树在使用递归操作时可能会导致栈溢出。有两种方法可以避免这个问题:

  • 平衡不平衡的树。

  • 使用自平衡树。

一旦你有了一种平衡树的方法,就很容易通过在每次可能改变树结构的操作后自动启动平衡过程来使树自平衡。

10.7.1. 旋转树

在你能够平衡树之前,你需要知道如何增量地改变树的结构。所使用的技术称为旋转树,并在图 10.19 和 10.20 中进行了说明。

图 10.19. 向右旋转树。在旋转过程中,2 和 3 之间的线被替换为 2 和 4 之间的线,因此元素 3 被移动到成为 4 的左元素。

图片

图 10.20. 向左旋转树。6 的左元素变为 4(之前是 6 的父元素),因此 5 被移动到成为 4 的右元素。

图片

练习 10.12

编写 rotateRightrotateLeft 方法以在两个方向上旋转树。注意保留分支顺序。左元素必须始终低于根节点,右元素必须始终高于根节点。在父类中声明抽象方法。使它们为受保护的,因为它们只会在 Tree 类内部使用。以下是父类中的签名:

protected abstract Tree<A> rotateLeft();
protected abstract Tree<A> rotateRight();

解决方案 10.12

Empty 实现简单地返回 this。在 T 类中,这些是右旋转的步骤:

  1. 测试左分支是否为空。

  2. 如果左分支为空,则直接返回 this,因为右旋转包括将左元素提升为根节点。(你不能提升一个空树。)

  3. 如果左元素不为空,它成为根节点,因此创建一个新的 T,其根值为 left.value。左元素的左分支成为新树的左分支。对于右分支,你使用原始根作为根,原始左分支的右分支作为左分支,原始右分支作为右分支来构建一个新的树。

左旋转是对称的:

protected Tree<A> rotateLeft() {
  return right.isEmpty()
      ? this
      : new T<>(new T<>(left, value, right.left()),
                                     right.value(), right.right());
}

protected Tree<A> rotateRight() {
  return left.isEmpty()
      ? this
      : new T<>(left.left(), left.value(),
                             new T<>(left.right(), value, right));
}

解释似乎很复杂,但实际上非常简单。只需将代码与图进行比较,看看发生了什么。

如果你尝试多次旋转树,你会到达一个点,其中一个分支为空,树不能在相同方向上继续旋转。

练习 10.13

为了平衡树,你还需要将树转换为有序列表的方法。编写一个将树转换为从右到左的有序列表(即降序)的方法。如果你想尝试更多练习,不要犹豫,定义从左到右的有序列表方法,以及前序和后序的方法。

这是 toListInOrderRight 方法的签名:

public List<A> toListInOrderRight()

解决方案 10.13

这非常简单,并且与列表比与树更相关。Empty 实现简单地返回一个空列表。你可能认为以下实现:

public List<A> toListInOrderRight() {
  return right.toListInOrderRight().concat(List.list(value))
                                   .concat((left.toListInOrderRight()));
}

不幸的是,如果树非常不平衡,这种方法会导致栈溢出。你需要这种方法来平衡树,所以如果它不能与不平衡的树一起工作,那就太遗憾了!

这是安全的递归版本:

unBalanceRight 方法简单地旋转树直到左分支为空 。然后它递归地调用自身,对所有的右子树执行相同操作,在将树值添加到累加列表 之后。最终找到空的树参数,方法返回累加列表。

10.7.2. 使用 Day-Stout-Warren 算法平衡树

Day-Stout-Warren 算法很简单。首先,将树转换成完全不平衡的树。然后应用旋转直到树完全平衡。将树转换成完全不平衡的树是一个简单的过程,即创建一个有序列表,然后从它创建一个新的树。因为你想要按升序创建树,所以你必须创建一个降序列表,然后开始旋转结果向左。当然,你也可以选择对称的情况。

这是获得完全平衡树的算法:

  1. 将树向左旋转,直到结果分支尽可能相等。这意味着如果总大小是奇数,则分支大小将相等;如果总大小是偶数,则分支大小将相差 1。结果将是一个具有两个几乎相等大小的完全不平衡分支的树。

  2. 将相同的进程递归地应用于右分支。对左分支应用对称的过程(向右旋转)。

  3. 当结果的高度等于 log2(size)时停止。为此,你需要以下辅助方法:

    public static int log2nlz(int n) {
      return n == 0
          ? 0
          : 31 - Integer.numberOfLeadingZeros(n);
    }
    

练习 10.14

实现一个balance方法来完全平衡任何树。这将是一个接受要平衡的树作为参数的静态方法。

提示

此实现将基于几个辅助方法:一个前方法将通过调用toListInOrderRight方法创建完全不平衡的树。结果列表将折叠成(完全不平衡的)树,然后更容易平衡。

你还需要一个方法来测试树是否完全平衡,以及一个递归旋转树的方法。以下是旋转树的方法:

public static <A> A unfold(A a, Function<A, Result<A>> f) {
  Result<A> ra = Result.success(a);
  return unfold(new Tuple<>(ra, ra), f).eval()._2.getOrElse(a);
}

private static <A> TailCall<Tuple<Result<A>, Result<A>>> unfold(Tuple<Result<A>,
                                            Result<A>> a, Function<A, Result<A>> f) {
  Result<A> x = a._2.flatMap(f::apply);
  return x.isSuccess()
      ? TailCall.sus(() -> unfold(new Tuple<>(a._2, x), f))
      : TailCall.ret(a);
}

这个方法被称为unfold,是类比于List.unfoldStream.unfold。它执行相同的任务(除了函数的结果类型与其输入类型相同),但它忘记了结果,只保留最后两个,因此它更快且占用更少的内存。

解决方案 10.14

首先,你定义一个实用方法来测试树是否不平衡。为了使其平衡,如果分支的总大小是偶数,则两个分支的高度差必须为 0;如果大小是奇数,则高度差为 1:

static <A extends Comparable<A>> boolean isUnBalanced(Tree<A> tree) {
  return Math.abs(tree.left().height() - tree.right().height())
                                            > (tree.size() - 1) % 2;
}

然后,你可以编写主要平衡方法:

public static <A extends Comparable<A>> Tree<A> balance(Tree<A> tree) {
  return balance_(tree.toListInOrderRight().foldLeft(Tree.<A>empty(),
                                     t -> a -> new T<>(empty(), a, t)));
}

public static <A extends Comparable<A>> Tree<A> balance_(Tree<A> tree) {
  return !tree.isEmpty() && tree.height() > log2nlz(tree.size())
      ? Math.abs(tree.left().height() - tree.right().height()) > 1
          ? balance_(balanceFirstLevel(tree))
          : new T<>(balance_(tree.left()), tree.value(),
                                              balance_(tree.right()))
      : tree;
}

private static <A extends Comparable<A>> Tree<A>
                                        balanceFirstLevel(Tree<A> tree) {
  return unfold(tree, t -> isUnBalanced(t)
      ? tree.right().height() > tree.left().height()
          ? Result.success(t.rotateLeft())
          : Result.success(t.rotateRight())
      : Result.empty());
}

10.7.3. 自动平衡树

尽管balance方法旨在处理大而不平衡的树时避免栈溢出,但你不能在这样的大树上使用它,因为在平衡过程中它本身可能会溢出栈。这一点可以在测试中看到。用超过 15,000 个元素的完全不平衡的树测试balance方法是不可行的。

解决方案是只在小型完全不平衡树和任何大小的部分平衡树上使用balance。这意味着你必须在大树变得太大之前对其进行平衡。问题是是否可以在每次修改后自动进行平衡。

练习 10.15

将你开发的树转换,使其在插入、合并和删除时自动平衡。

解决方案 10.15

显然的方法是在每次修改树的操作后调用balance,如下面的代码所示:

@Override
public Tree<A> insert(A a) {
  return balance(ins(a));
}

protected Tree<A> ins(A a) {
  return a.compareTo(this.value) < 0
      ? new T<>(left.ins(a), this.value, right)
      : a.compareTo(this.value) > 0
          ? new T<>(left, this.value, right.ins(a))
          : new T<>(this.left, value, this.right);
}

这对于小树(实际上不需要平衡)是有效的,但对于大树则不适用,因为它会非常慢。一个解决方案是仅部分平衡树。例如,你可以在高度是完全平衡树理想高度的 20 倍时运行平衡方法:

public Tree<A> insert(A a) {
  Tree<A> t = ins(a);
  return t.height() > log2nlz(t.size()) * 20 ? balance(t) : t;
}

10.7.4. 解决正确的问题

平衡解决方案的性能可能看起来远非最佳,但这是一种折衷方案。从有序列表中创建包含 100,000 个元素的树将需要 7.5 秒,并且生成的树的高度为 59,与理想高度 16 相比。在insert方法中将值 20 替换为 10 将使时间加倍,而没有任何好处,因为生成的树的高度将为 159。请注意,生成的高度并不与您使用的值成比例。如果树在最后插入时接近平衡,那就更好了,因此最好使用一个较高的值,仅为了避免栈溢出,并在使用之前显式地平衡树。

但真正的问题是,你试图解决什么问题?实际上,至少有两个非常不同的要求:

  • 你必须能够从任意顺序的大量元素中创建一个树,而不会出现栈溢出的风险。

  • 你必须尽可能使树平衡,因为这最小化了高度,而搜索所需的时间与高度成正比。

对于第一个要求,你不需要使树完全平衡。高度为 2,000 是可以接受的,因为这样不会溢出栈。你可以在插入 2,000 个元素后简单地平衡树。然后,在构建完成后再次平衡树。

第二个要求是另一个故事,用例可能不同。有些树几乎从不更新,而有些则持续变化。在第一种情况下,在每次更改后平衡树可能是可以的。在第二种情况下,可能更好的是在一定数量的更改后更新。无论如何,一个优化是在每个批次后批量修改树并仅在批次后平衡。你将在第十一章中了解更多关于这方面的内容。

10.8. 概述

  • 树是递归数据结构,其中一个元素与一个或多个子树链接。

  • 二叉搜索树允许更快地检索可比元素。

  • 树可能更平衡或不平衡。完全平衡的树提供最佳性能,而完全不平衡的树与列表具有相同性能。

  • 树的大小是它包含的元素数量;它的高度是树中最长的路径。

  • 树结构取决于树元素的插入顺序。

  • 树可以以许多不同的顺序(前序、中序或后序)遍历,并且可以双向(从左到右或从右到左)遍历。

  • 树可以轻松合并,而无需遍历它们。

  • 树还可以以多种方式映射、旋转以及折叠。

  • 树可以通过平衡来提高性能并避免递归操作中的栈溢出。

第十一章. 使用高级树解决实际问题

本章涵盖

  • 使用自平衡树避免栈溢出

  • 实现红黑树

  • 创建函数式映射

  • 设计函数式优先队列

在上一章中,你学习了二叉树结构和基本树操作。但你看到,为了充分利用树,你必须有非常具体的使用案例,例如处理随机排序的数据,或者有限的数据集,以避免任何栈溢出的风险。使树栈安全比列表要困难得多,因为每个计算步骤都涉及两个递归调用,这使得无法创建尾递归版本。

在本章中,我们将研究两种特定的树:

  • 红黑树是一种高性能的自平衡通用树。它适用于通用用途和任何大小的数据集。

  • 左式堆是一个非常适合实现优先队列的特定树。

11.1. 更好的性能和栈安全性通过自平衡树

你在上一章中使用的 Day-Stout-Warren 平衡算法并不适合平衡函数树,因为它是为就地修改而设计的。在函数式编程中,通常避免就地修改,而是为每次更改创建一个新的结构。一个更好的解决方案是定义一个平衡过程,该过程不涉及在重建一个完全不平衡的树并最终平衡它之前将树转换为列表。有两种方法可以优化这个过程:

  • 直接旋转原始树(消除列表/不平衡树的过程)。

  • 接受一定程度的失衡。

你可以尝试发明这样的解决方案,但其他人早已做到了。最有效的自平衡树设计之一是红黑树。这种结构是在 1978 年由古巴斯和赛德维克发明的.^([1]) 在 1999 年,Chris Okasaki 在他的书《纯函数数据结构》(Cambridge University Press,1999)中发布了红黑树算法的函数式版本。描述通过在 Standard ML 中的实现来展示,后来又添加了 Haskell 的实现。这就是你将在 Java 中实现的算法。

¹

李奥·J·古巴斯和罗伯特·赛德维克,“平衡树的二色框架”,计算机科学基础 (1978), mng.bz/Ly5Jl.

如果你对手动数据结构感兴趣,我强烈建议你购买并阅读 Okasaki 的书。你也可以阅读他 1996 年的同名论文。它不如他的书完整,但可以作为免费下载(www.cs.cmu.edu/~rwh/theses/okasaki.pdf)。

11.1.1. 基本树结构

红黑树是一种二叉搜索树(BST),在其结构和插入算法上做了一些补充,并且也平衡了结果。不幸的是,Okasaki 没有描述删除操作,而这实际上是一个更为复杂的过程。但 Kimball Germane 和 Matthew Might 在 2014 年描述了这种“缺失的方法”[2]。

²

Kimball Germane 和 Matthew Might,“功能珍珠,删除:红黑树的诅咒”,JFP 24,4(2014):423–433;matt.might.net/papers/germane2014deletion.pdf

在红黑树中,每个树(包括子树)都有一个额外的属性来表示其颜色。除此之外,结构与 BST 结构完全相同,如下所示。

列表 11.1. 红黑树基本结构

图片描述

图片描述

图片描述

图片描述

图片描述

member方法没有表示,也没有其他方法,如foldmap等,因为它们与标准树版本没有区别。正如你将看到的,只有insertremove方法不同。

11.1.2. 将元素插入到红黑树中

红黑树的主要特征是必须始终验证的不变量。在修改树的过程中,它将测试这些不变量是否被破坏,并在必要时通过旋转和颜色变化来恢复它们。这些不变量如下:

  • 空树是黑色的。(这不会改变,因此不需要验证。)

  • 红树的左右子树都是黑色。换句话说,在向下遍历树的过程中,不可能找到两个连续的红色节点。

  • 从根到每个空子树的所有路径都有相同数量的黑色节点。

在红黑树中插入一个元素是一个相对复杂的过程,包括在插入后检查不变量(如果需要,还会进行重新平衡)。以下是相应的算法:

  • 空树始终是黑色的。

  • 正确的插入操作与普通树完全相同,但随后会进行平衡。

  • 将一个元素插入到空树中会产生一个红色树。

  • 平衡后,根节点变为黑色。

图 11.1 至 11.7 展示了将整数 1 到 7 插入到初始为空的树中的过程。图 11.1 展示了元素1被插入到空树中的情况。由于你是在向空树中插入,初始颜色是红色。一旦元素被插入,根节点变为黑色。

图 11.1. 将整数 1 到 7 插入到初始为空的树中,步骤 1

图片描述

图 11.2 展示了元素2的插入。插入的元素是红色,根节点已经是黑色,而且仍然不需要平衡。

图 11.2. 将整数 1 到 7 插入到初始为空的树中,步骤 2

图片描述

图 11.3 说明了元素 3 的插入。插入的元素是红色,树正在平衡,因为它有两个连续的红色元素。因为红色元素现在有两个孩子,它们被设置为黑色。(红色元素的子节点必须是黑色。)最终,根被变黑。

图 11.3. 将整数 1 至 7 插入初始为空的树中,第 3 步

图 11.4 展示了元素 4 的插入。不需要进一步操作。

图 11.4. 将整数 1 至 7 插入初始为空的树中,第 4 步

图 11.5 说明了元素 5 的插入。现在你有两个连续的红色元素,所以树必须通过将 3 作为 4 的左孩子来平衡。4 成为 2 的右孩子。

图 11.5. 将整数 1 至 7 插入初始为空的树中,第 5 步

图 11.6 展示了元素 6 的插入。不需要进一步操作。

图 11.6. 将整数 1 至 7 插入初始为空的树中,第 6 步

在 图 11.7 中,元素 7 被添加到树中。因为元素 67 是两个连续的红色元素,所以树必须平衡。第一步是将 5 作为 6 的左孩子,将 6 作为 4 的右孩子,这又留下了两个连续的红色元素:46。然后树再次平衡,使 4 成为根,2 成为 4 的左孩子,3 成为 2 的右孩子。最后的操作是使根变黑。

图 11.7. 将整数 1 至 7 插入初始为空的树中,第 7 步

balance 方法接受与树构造函数相同的参数:colorleftvalueright。这四个参数被测试以适应各种模式,并据此构建结果。换句话说,balance 方法替换了树构造函数。任何使用构造函数的过程都应该修改为使用此方法。

以下列表显示了每种参数模式如何通过此方法进行转换:

  • (T B (T R (T R a x b) y c) z d) → (T R (T B a x b) y (T B c z d))

  • (T B (T R a x (T R b y c)) z d) → (T R (T B a x b) y (T B c z d))

  • (T B a x (T R (T R b y c) z d)) → (T R (T B a x b) y (T B c z d))

  • (T B a x (T R b y (T R c z d))) → (T R (T B a x b) y (T B c z d))

  • (T color a x b) → (T color a x b)

括号中的每一对对应一棵树。字母 T 表示非空树。B 和 R 表示颜色。小写字母是占位符,代表任何可能在该位置有效的值。每个左模式(箭头左侧的模式)按降序应用,这意味着如果找到匹配项,则将相应的右模式应用于结果树。这种呈现方式与 switch ... case 指令非常相似,最后一行是默认情况。

练习 11.1

编写 insertbalanceblacken 方法以实现红黑树中的插入。不幸的是,Java 不实现模式匹配,所以你必须使用条件指令。

提示

编写一个 ins 方法,执行常规插入,然后使用 balance 方法的调用替换构造函数调用。接下来,编写 blacken 方法,最后在父类中编写 insert 方法,在 ins 的结果上调用 blacken。所有这些方法都应该是受保护的,除了 insert 方法,它将是公共的。

解决方案 11.1

一次,我不建议使用条件运算符。使用一系列 if 部分来表示模式要容易得多,每个部分都包含一个 return。以下是 balance 方法:

Tree<A> balance(Color color, Tree<A> left, A value, Tree<A> right) {
  if (color.isB() && left.isTR() && left.left().isTR()) {
    return new T<>(R, new T<>(B, left.left().left(), left.left().value(),
      left.left().right()), left.value(), new T<>(B, left.right(), value,
      right));
  }
  if (color.isB() && left.isTR() && left.right().isTR()) {
    return new T<>(R, new T<>(B, left.left(), left.value(),
        left.right().left()), left.right().value(), new T<>(B,
        left.right().right(), value, right));
  }
  if (color.isB() && right.isTR() && right.left().isTR()) {
    return new T<>(R, new T<>(B, left, value, right.left().left()),
        right.left().value(), new T<>(B, right.left().right(),
        right.value(), right.right()));
  }
  if (color.isB() && right.isTR() && right.right().isTR()) {
    return new T<>(R, new T<>(B, left, value, right.left()), right.value(),
        new T<>(B, right.right().left(), right.right().value(),
        right.right().right()));
  }
  return new T<>(color, left, value, right);
}

每个 if 部分实现了在此练习之前列出的一个模式。如果您想比较它们,在文本编辑器中可能比在打印页面上更容易做到。

ins 方法与你在标准二叉搜索树(BST)中做的非常相似,唯一的区别是 balance 方法替换了 T 构造函数(此外还有一个额外的 color 参数)。以下是 T 类中的实现:

protected Tree<A> ins(A value) {
  return value.compareTo(this.value) < 0
      ? balance(this.color, this.left.ins(value), this.value, this.right)
      : value.compareTo(this.value) > 0
          ? balance(this.color, this.left, this.value,
                                           this.right.ins(value))
          : this;
}

以下是 E 类中的实现:

protected Tree<A> ins(A value) {
  return new T<>(R, empty(), value, empty());
}

blacken 方法在 Tree 类中实现:

protected static <A extends Comparable<A>> Tree<A> blacken(Tree<A> t) {
    return t.isEmpty()
        ? empty()
        : new T<>(B, t.left(), t.value(), t.right());
}

最后,insert 方法在 Tree 类中定义,并返回 ins 的黑化结果:

public Tree<A> insert(A value) {
  return blacken(ins(value));
}
从红黑树中删除元素

从红黑树中删除元素由 Kimball Germane 和 Matthew Might 在一篇题为“缺失的方法:从 Okasaki 的红黑树中删除”的文章中讨论(matt.might.net/articles/red-black-delete/)。Java 中的实现太长,无法包含在这本书中,但它包含在配套代码中(github.com/fpinjava/fpinjava)。它将在下一个练习中使用。

11.2. 红黑树的一个应用案例:映射

整数树通常不很有用(尽管有时它们是有用的)。二叉搜索树的一个重要用途是映射,也称为字典或关联数组。映射是一组键/值对,允许插入、删除和快速检索每一对。映射对 Java 程序员来说很熟悉,Java 提供了几种实现,其中最常见的是HashMapTreeMap。然而,这些映射在没有使用一些难以正确设计和使用的保护机制的情况下不能在多线程环境中使用(尽管有可用于此类用途的并发版本)。

11.2.1. 实现映射

函数树,如你开发的红黑树,具有不可变性的优势,这允许你在多线程环境中使用它们,而不必担心锁和同步。下一个列表显示了可以使用红黑树实现的Map接口。

列表 11.2. 一个函数式映射
public class Map<K extends Comparable<K>, V> {

  public Map<K, V> add(K key, V value) {
    . . .
  }

  public boolean contains(K key) {
    . . .
  }

  public Map<K, V> remove(K key) {
    . . .
  }

  public Result<MapEntry<K, V>> get(K key) {
    . . .
  }

  public boolean isEmpty() {
    . . .
  }

  public static <K extends Comparable<K>, V> Map<K, V> empty() {
    return new Map<>();
  }
}

练习 11.2

通过实现所有方法来完善Map类。

提示

你应该使用一个委托。从这个委托中,所有方法都可以用一行代码实现。唯一(非常简单)的问题是选择你如何在映射中存储数据。

解决方案 11.2

解决方案是创建一个表示键/值对的组件,并将该组件的实例存储在树中。这个组件与Tuple非常相似,但有一个重要的区别:它必须是可比较的,比较必须基于keyequalshashCode方法也将基于键的相等性和哈希码。以下是一个可能的实现:

public class MapEntry<K extends Comparable<K>, V>
                                  implements Comparable<MapEntry<K, V>> {
  public final K key;
  public final Result<V> value;

  private MapEntry(K key, Result<V> value) {
    this.key = key;
    this.value = value;
  }

  @Override
  public String toString() {
    return String.format("MapEntry(%s, %s)", key, value);
  }

  @Override
  public int compareTo(MapEntry<K, V> me) {
    return this.key.compareTo(me.key);
  }

  @Override
  public boolean equals(Object o) {
    return o instanceof MapEntry && this.key.equals(((MapEntry) o).key);
  }

  @Override
  public int hashCode() {
    return key.hashCode();
  }

  public static <K extends Comparable<K>, V> MapEntry<K, V>
                                           mapEntry(K key, V value) {
    return new MapEntry<>(key, Result.success(value));
  }

  public static <K extends Comparable<K>, V> MapEntry<K, V>
                                                      mapEntry(K key) {
    return new MapEntry<>(key, Result.empty());
  }
}

实现Map组件现在只是将所有操作委托给Tree<MapEntry<Key, Value>>的问题。以下是一个可能的实现:

import static com.fpinjava.advancedtrees.exercise11_02.MapEntry.*;

public class Map<K extends Comparable<K>, V> {

  protected final Tree<MapEntry<K, V>> delegate;

  private Map() {
    this.delegate = Tree.empty();
  }
  private Map(Tree<MapEntry<K, V>> delegate) {
    this.delegate = delegate;
  }

  public Map<K, V> add(K key, V value) {
    return new Map<>(delegate.insert(mapEntry(key, value)));
  }

  public boolean contains(K key) {
    return delegate.member(mapEntry(key));
  }

  public Map<K, V> remove(K key) {
    return new Map<>(delegate.delete(mapEntry(key)));
  }

  public MapEntry<K, V> max() {
    return delegate.max();
  }

  public MapEntry<K, V> min() {
    return delegate.min();
  }

  public Result<MapEntry<K, V>> get(K key) {
    return delegate.get(mapEntry(key));
  }

  public boolean isEmpty() {
    return delegate.isEmpty();
  }

  public static <K extends Comparable<K>, V> Map<K, V> empty() {
    return new Map<>();
  }
}

11.2.2. 扩展映射

并非所有树操作都进行了委托,因为有些操作在当前条件下没有太多意义。但某些特殊用例可能需要额外的操作。实现这些操作很简单:扩展Map类并添加委托方法。例如,你可能需要找到具有最大或最小键的对象。另一个可能的需求是将映射折叠起来,例如获取包含值的列表。以下是一个委托foldLeft方法的示例:

public <B> B foldLeft(B identity, Function<B,
        Function<MapEntry<K, V>, B>> f, Function<B, Function<B, B>> g) {
  return delegate.foldLeft(identity, b -> me -> f.apply(b).apply(me), g);
}

通常,折叠映射发生在非常具体的用例中,这些用例值得在Map类内部进行抽象。

练习 11.3

Map类中编写一个values方法,该方法按升序键顺序返回映射中包含的值的列表。

提示

你可能需要在Tree类中创建一个新的折叠方法,并从Map类中委托给它。

解决方案 11.3

values 方法的实现有几种可能。可以委托给 foldInOrder 方法,但这个方法按升序遍历树值。使用此方法构造列表将导致列表按降序排列。你可以反转结果,但这不会很高效。

一个更好的解决方案是在 Tree 类中添加一个 foldInReverseOrder 方法。回想一下 foldInOrder 方法:

public <B> B foldInOrder(B identity,
                         Function<B, Function<A, Function<B, B>>> f) {
  return f.apply(left.foldInOrder(identity, f))
          .apply(value)
          .apply(right.foldInOrder(identity, f));
}

所要做的就是反转顺序:

public <B> B foldInReverseOrder(B identity,
                          Function<B, Function<A, Function<B, B>>> f) {
  return f.apply(right.foldInReverseOrder(identity, f))
          .apply(value).apply(left
          .foldInReverseOrder(identity, f));
}

如同往常,Empty 实现返回 identity。现在你可以从 Map 类内部委托给这个方法:

public List<V> values() {
  return List.sequence(delegate.foldInReverseOrder(List.<Result<V>>list(),
    lst1 -> me -> lst2 -> List.concat(lst2,
                             lst1.cons(me.value)))).getOrElse(List.list());
}

如果你有类型问题,你可以用显式类型编写函数:

Function<List<Result<V>>, Function<MapEntry<K, V>,
                  Function<List<Result<V>>, List<Result<V>>>>> f =
             lst1 -> me -> lst2 -> List.concat(lst2, lst1.cons(me.value));

11.2.3. 使用不可比较键的 Map

Map 类很有用且相对高效,但与您可能习惯的映射相比有一个很大的缺点:键必须是可比较的。用于键的类型通常是可比较的,例如整数或字符串,但如果你需要使用不可比较的类型作为键呢?

练习 11.4

实现一个使用不可比较键的 Map 版本。

提示

有两件事需要修改。首先,MapEntry 类应该是可比较的,尽管键不是。其次,可能发生非相等值恰好被存储在相等的映射条目中,因此应该通过保留两个冲突条目来解决冲突。

解决方案 11.4

首先要做的是修改 MapEntry 类,移除键需要可比较的要求:

public class MapEntry<K, V> implements Comparable<MapEntry<K, V>> {

注意,尽管 K 类型不是,MapEntry 类仍然是可比较的。

其次,你必须为 compareTo 方法使用不同的实现。一种可能性是基于键哈希码比较来比较映射条目:

public int compareTo(MapEntry<K, V> that) {

  int thisHashCode = this.hashCode();
  int thatHashCode = that.hashCode();

  return thisHashCode < thatHashCode
      ? -1
      : thisHashCode > thatHashCode
          ? 1
          : 0;
}

然后,你必须处理当两个映射条目具有相同哈希码的不同键时发生的冲突。在这种情况下,应该保留它们两个。最简单的解决方案是将映射条目存储在列表中,为此,你必须修改 Map 类。

首先,树代理将有一个修改后的类型:

protected final Tree<MapEntry<Integer, List<Tuple<K, V>>>> delegate;

然后,你必须更改接受代理作为参数的构造函数:

public Map(Tree<MapEntry<Integer, List<Tuple<K, V>>>> delegate) {
  this.delegate = delegate;
}

接下来,你需要一个方法来检索与相同键哈希码对应的关键字/值元组的列表:

private Result<List<Tuple<K, V>>> getAll(K key) {
  return delegate.get(mapEntry(key.hashCode()))
                       .flatMap(x -> x.value.map(lt -> lt.map(t -> t)));
}

你可以定义 addcontainsremoveget 方法,这些方法基于 getAll 方法。以下是 add 方法:

public Map<K, V> add(K key, V value) {
  Tuple<K, V> tuple = new Tuple<>(key, value);
  List<Tuple<K, V>> ltkv = getAll(key).map(lt ->
              lt.foldLeft(List.list(tuple), l -> t -> t._1.equals(key)
                  ? l
                  : l.cons(t))).getOrElse(() -> List.list(tuple));
  return new Map<>(delegate.insert(mapEntry(key.hashCode(), ltkv)));
}

这是 contains 方法:

public boolean contains(K key) {
  return getAll(key).map(lt -> lt.exists(t ->
                                   t._1.equals(key))).getOrElse(false);
}

这里是 remove 方法:

public Map<K, V> remove(K key) {
  List<Tuple<K, V>> ltkv = getAll(key).map(lt ->
     lt.foldLeft(List.<Tuple<K, V>>list(), l -> t -> t._1.equals(key)
         ? l
         : l.cons(t))).getOrElse(List::list);
  return ltkv.isEmpty()
        ? new Map<>(delegate.delete(MapEntry.mapEntry(key.hashCode())))
        : new Map<>(delegate.insert(mapEntry(key.hashCode(), ltkv)));
}

public Result<Tuple<K, V>> get(K key) {
  return getAll(key).flatMap(lt -> lt.first(t -> t._1.equals(key)));
}

最后,需要删除 minmax 方法。

经过这些修改,Map 类可以用于不可比较的键。使用列表存储键/值元组可能不是最有效的实现,因为列表中的搜索需要与元素数量成比例的时间。但在大多数情况下,列表中只包含一个元素,因此搜索将立即返回。

关于这种实现的一个需要注意的事项是,remove 方法会检查生成的元组列表是否为空。如果是,它会在代理上调用 remove 方法。否则,它会调用 insert 方法重新插入从其中删除相应条目的新列表。回想一下第十章的练习 10.1。第十章。这之所以可能,仅仅是因为你决定以这种方式实现插入,即如果找到与映射中存在的元素相等的元素,则将其插入原位置。如果你没有这样做,你将不得不首先删除元素,然后使用修改后的列表插入新元素。

11.3. 实现功能优先队列

如你所知,队列是一种具有特定访问协议的列表。队列可以是单端队列,就像你在前几章中经常使用的单链表一样。在这种情况下,访问协议是后进先出(LIFO)。队列也可以是双端队列,允许先进先出(FIFO)的访问协议。但也有一些具有更特殊协议的数据结构。其中之一是优先队列

11.3.1. 优先队列的访问协议

值可以以任何顺序插入到优先队列中,但它们只能以非常特定的顺序检索。所有值都有一个优先级级别,并且只有具有最高优先级的元素是可用的。优先级通过元素的排序来表示,这意味着元素必须在某种程度上是可比较的。

优先级对应于理论等待队列中元素的位置。最高优先级属于位置最低的元素(即第一个元素)。因此,按照惯例,最高优先级用最低的值来表示。

由于优先队列将包含可比较的元素,这使得它非常适合树状结构。但从用户的角度来看,优先队列被视为一个列表,有一个头部(具有最高优先级的元素,即最低的值)和一个尾部(队列的其余部分)。

11.3.2. 优先队列的使用场景

优先队列有许多不同的使用场景。一个很快就能想到的是排序。你可以在随机顺序中将元素插入到优先队列中,然后按顺序检索它们。这不是这种结构的主要使用场景,但它可能对排序小型数据集很有用。

另一个非常常见的用例是在异步并行处理后重新排序元素。假设你有大量数据页面需要处理。为了加快处理速度,你可以将数据分配给多个线程并行工作。但是,没有保证线程将以接收它们的相同顺序返回工作。为了重新同步页面,你可以将它们放入优先队列。然后,应该消费页面的进程将轮询队列以检查是否有可用的元素(队列的头部)是预期的。例如,如果将 1、2、3、4、5、6、7 和 8 页分配给八个线程并行处理,消费者将轮询队列以查看第 1 页是否可用。如果是,它将消费它。如果不是,它将只是等待。

在这种情况下,队列既充当缓冲区,也充当重新排序元素的方式。这通常意味着大小变化有限,因为元素将从队列中以大致相同的速度被移除。当然,这是在消费者以与生产者相同的速度消费元素的情况下成立的。如果不是这样,可能可以使用多个消费者。

如我之前所说,选择实现通常是一个权衡空间和时间或时间与时间的问题。在这里,你必须做出的选择是在插入和检索时间之间。在一般用例中,检索时间必须比插入时间优化,因为插入和检索操作的数量比通常将大大有利于检索。(通常头部会被读取但不会被移除。)

11.3.3. 实现要求

你可以实现一个基于红黑树的优先队列,因为查找最小值非常快。但检索并不意味着删除。如果你搜索最小值,却发现它不是你想要的,你将不得不稍后回来再次搜索。解决这个问题的一个方案可能是在插入时缓存最小值。你可能还想做的另一个改变是关于删除。删除一个元素相对较快,但由于你总是删除最小元素,你可能能够优化数据结构以适应这种操作。

另一个重要的问题将涉及到重复项。尽管红黑树不允许重复项,但优先队列必须允许,因为完全可能存在具有相同优先级的多个元素。解决方案可以与映射相同——存储具有相同优先级的元素列表(而不是单个元素),但这可能不会对性能最优。

11.3.4. 左式堆数据结构

为了满足您对优先队列的要求,您将使用 Okasaki 在其书籍《纯函数式数据结构》中描述的“左侧堆”。^([3]) 这种数据结构满足优先队列的要求。Okasaki 将左侧堆定义为“具有额外左侧属性的堆有序树”:

³

左侧堆最初由 Clark Allan Crane 在“线性列表和优先队列作为平衡二叉树”(1972 年)中描述,但 Okasaki 是最早发布纯函数式实现的人之一。

  • 堆有序树是一种树,其中每个元素的分支都大于或等于该元素本身。这保证了树中的最低元素总是根元素,使得访问最低值是瞬时的。

  • “左侧”属性意味着,对于每个元素,左侧分支 rank 大于或等于右侧分支 rank。

  • 一个元素的 rank 是到达一个空元素右侧路径(也称为右侧 )的长度。左侧属性保证了从任何元素到空元素的最短路径是右侧路径。这一结果的后果是,元素总是按照升序沿任何下降路径找到。

图 11.8 展示了一个左侧树的示例。

图 11.8. 堆有序的左侧树,显示每个元素的分支都高于或等于该元素本身,并且每个左侧分支的 rank 都大于或等于相应的右侧分支 rank

如您所见,检索最高优先级元素是可能的,因为这将始终是树的根。这个元素将被称为结构的“头”。通过类比列表,移除一个元素将包括在移除根之后返回树的其余部分。这个返回值将被称为结构的“尾”。

11.3.5. 实现左侧堆

左侧堆的主类将被命名为 Heap,并且将是一个树形实现。其基本结构在列表 11.3 中展示。与您至今为止所开发的树相比,主要区别在于 rightlefthead(在之前的例子中您称之为 value)等方法将返回一个 Result 而不是原始值。请注意,元素的个数被称为 length(类比于队列),而缓存的 lengthrank 将由构造函数的调用者计算,而不是由构造函数本身计算。这是一个没有明确动机的设计选择,只是为了展示另一种做事的方式。构造函数是私有的,所以这种差异不会泄露到 Heap 类外部。

列表 11.3. 左侧堆结构

练习 11.5

你想要添加到你的Heap实现中的第一个功能是添加一个元素的能力。为此定义一个add方法。将其作为Heap类中的一个实例方法,其签名如下:

public Heap<T> add(T element)

要求是在值小于堆中任何元素的情况下,它应成为新堆的根。否则,堆的根不应改变。还应遵守关于右路径等级和长度的其他要求。

提示

定义一个静态方法来从一个元素创建一个Heap,另一个方法是通过合并两个堆来创建一个堆,其签名如下:

public static <A extends Comparable<A>> Heap<A> heap(A element)
public static <A extends Comparable<A>> Heap<A> merge(Heap<A> first,
                                                            Heap<A> second)

然后根据这两个定义add方法。

解决方案 11.5

从单个元素创建堆的方法很简单。只需创建一个长度为 1,等级为 1 的新树;参数元素作为头;以及两个空堆作为左右分支:

public static <A extends Comparable<A>> Heap<A> heap(A element) {
  return new H<>(1, 1, empty(), element, empty());
}

通过合并两个堆来创建堆要复杂一些。为此,你需要一个额外的辅助方法,该方法可以从一个元素和两个堆创建一个堆:

protected static <A extends Comparable<A>> Heap<A> heap(A head,
                                        Heap<A> first, Heap<A> second) {
  return first.rank() >= second.rank()
      ? new H<>(first.length() + second.length() + 1,
                                 second.rank() + 1, first, head, second)
      : new H<>(first.length() + second.length() + 1,
                                 first.rank() + 1, second, head, first);
}

此代码首先检查第一个堆的等级是否大于或等于第二个堆。如果第一个堆的等级大于或等于,则新等级设置为第二个堆的等级 + 1,并且两个堆按第一、第二顺序使用。否则,新等级设置为第一个堆的等级 + 1,并且两个堆按相反顺序(第二、第一)使用。

现在可以编写合并两个堆的方法如下:

public static <A extends Comparable<A>> Heap<A> merge(Heap<A> first,
                                                       Heap<A> second) {
  return first.head().flatMap(
    fh -> second.head().flatMap(
      sh -> fh.compareTo(sh) <= 0
      ? first.left().flatMap(
          fl -> first.right().map(
            fr -> heap(fh, fl, merge(fr, second))))
      : second.left().flatMap(
          sl -> second.right().map(
            sr -> heap(sh, sl, merge(first, sr))))))
                     .getOrElse(first.isEmpty() ? second : first);
}

当然,如果要合并的堆之一为空,则返回另一个堆。否则,计算合并的结果。

如果你发现这段代码难以理解(到现在我希望你没有),它只是以下不太有效的实现的全功能等价物:

public static <A extends Comparable<A>> Heap<A> merge(Heap<A> first, Heap<A> second) {
  return first.isEmpty()
      ? second
      : second.isEmpty()
          ? first
          : first.head().successValue()
                       .compareTo(second.head().successValue()) <= 0
              ? heap(first.head().successValue(), first.left()
                       .successValue(), merge(first.right()
                                         .successValue(), second))
              : heap(second.head().successValue(), second.left()
                       .successValue(), merge(second.right()
                                         .successValue(), first));
}

public static <A extends Comparable<A>> Heap<A> merge(Heap<A> first,
                                                      Heap<A> second) {
  try {
    return first.head().successValue()
                .compareTo(second.head().successValue()) <= 0
       ? heap(first.head().successValue(), first.left().successValue(),
                           merge(first.right().successValue(), second))
       : heap(second.head().successValue(), second.left().successValue(),
                           merge(second.right().successValue(), first));
  } catch(IllegalStateException e) {
    return first.isEmpty() ? second : first;
  }
}

作为一般规则,你应该始终记住,调用successValue,就像getOrThrow一样,如果ResultEmpty,可能会抛出异常。你可以先测试空值(如上面的第一个示例),或者将代码包含在try ... catch块中(如第二个示例),但上述任何解决方案都不是真正有效的。

顺便说一句,你应该尽量避免调用successValuegetOrThrowsuccessValue方法应该只在使用Result类内部时使用。强制执行此操作的最佳解决方案是将其设置为受保护的,但在学习时使用它是很有用的,以便了解发生了什么。

定义了这些方法后,创建add方法就很容易了:

public Heap<A> add(A element) {
  return merge(this, heap(element));
}

11.3.6. 实现类似队列的接口

尽管它被实现为一个树,但从用户的角度来看,堆就像是一个优先队列,这意味着一种链表,其中头始终是最小的元素。通过类比,树的根元素被称为head,而“移除”头之后剩下的部分被称为tail

练习 11.6

定义一个 tail 方法,它返回移除 head 后剩余的内容。这个方法,就像 head 方法一样,返回一个 Result 以确保在空队列上调用时的安全性。这是它在 Heap 父类中的签名:

Result<Heap<A>> tail()

解决方案 11.6

Empty 的实现很明显,并返回一个 Failure

public Result<Heap<A>> tail() {
  return Result.failure(new NoSuchElementException("tail() called
                                                      on empty heap"));
}

在前一个练习中定义的方法的基础上,H 的实现并不复杂,它只是简单地返回合并左右分支的结果:

public Result<Heap<A>> tail() {
  return Result.success(Heap.merge(left, right));
}

练习 11.7

实现一个 get 方法,它接受一个 int 参数,并按优先级顺序返回第 n 个元素。这个方法将返回一个 Result 以处理找不到元素的情况。这是它在 Heap 父类中的签名:

public abstract Result<A> get(int index)

解决方案 11.7

Empty 的实现很明显,将返回一个失败:

public Result<A> get(int index) {
  return Result.failure(new NoSuchElementException("Index out of range"));
}

H 的实现同样简单。它首先测试索引。如果是 0,它返回 head 值的 Success。否则,它递归地在尾部搜索索引 n - 1 的元素。因为尾部实际上并不存在,它只是 getTail 方法返回的值(这是一个 Result),所以这个结果通过递归调用 get 进行扁平映射:

public Result<A> get(int index) {
  return index == 0
      ? head()
      : tail().flatMap(x -> x.get(index - 1));
}

11.4. 非可比较元素的优先队列

要将元素插入到优先队列中,你必须能够比较它们的优先级。但优先级并不总是元素的一个属性;并非所有元素都实现了 Comparable 接口。没有实现此接口的元素仍然可以使用 Comparator 进行比较,那么你能否为你的优先队列实现这一点?

练习 11.8

修改 Heap 类,使其可以使用 Comparable 元素或单独的 Comparator

解决方案 11.8

首先,你可以在 Heap 类中添加一个方法,该方法将返回 Comparator。因为比较器是可选的,所以这个方法将返回一个可能为空的 Result<Comparator>

protected abstract Result<Comparator<A>> comparator();

然后,你可以在两个子类中实现它。Empty 的实现将返回一个在构造函数中初始化的已添加属性的值:

private final Result<Comparator<A>> comparator;

private Empty(Result<Comparator<A>> comparator) {
  this.comparator = comparator;
}
protected Result<Comparator<A>> comparator() {
  return this.comparator;
}

当然,你也会在 H 类中做同样的事情,不同之处在于你将修改现有的构造函数而不是创建一个新的:

private final Result<Comparator<A>> comparator;

private H(int length, int rank, Heap<A> left, A head, Heap<A> right,
                                       Result<Comparator<A>> comparator) {
  this.length = length;
  this.rank = rank;
  this.head = head;
  this.left = left;
  this.right = right;
  this.comparator = comparator;
}

protected Result<Comparator<A>> comparator() {
  return this.comparator;
}

然后,你必须更新工厂方法。但在你这样做之前,你必须更改类的类型参数,替换为这个

public abstract class Heap<A extends Comparable<A>>

用这个:

public abstract class Heap<A>>

应将相同的修改应用于子类构造函数。

创建空 Heap 的静态工厂方法将接受一个额外的 Result<Comparator> 参数,并且你需要添加一个使用默认 Result.Empty 的新方法:

public static <A> Heap<A> empty(Comparator<A> comparator) {
  return empty(Result.success(comparator));
}

public static <A> Heap<A> empty(Result<Comparator<A>> comparator) {
  return new Empty<>(comparator);
}

注意,我还添加了一个接受 Comparator<A> 而不是 Result<Comparable> 的方法,以便更容易地使用 Heap 类。这个方法主要将从 Heap 类外部使用。

然而,你将保留一个不带参数的 empty 方法。这个方法仍然需要使用一个 Comparable 类型进行参数化。否则,你可能会在以后的风险中遇到 ClassCastException

public static <A extends Comparable<A>> Heap<A> empty() {
  return empty(Result.empty());
}

通过使用 Comparable 类型,你可以确保你得到的是编译器错误而不是运行时异常。

现在,你也可以对创建单个元素 Heap 的方法做同样的处理:

public static <A extends Comparable<A>> Heap<A> heap(A element) {
  return heap(element, Result.empty());
}

public static <A> Heap<A> heap(A element, Result<Comparator<A>> comparator) {
  Heap<A> empty = empty(comparator);
  return new H<>(1, 1, empty, element, empty, comparator);
}

public static <A> Heap<A> heap(A element, Comparator<A> comparator) {
  Heap<A> empty = empty(comparator);
  return new H<>(1, 1, empty, element, empty, Result.success(comparator));
}

需要修改接受一个元素和两个 Heap 的方法,但这次,你将从堆参数中提取比较器:

protected static <A> Heap<A> heap(A head, Heap<A> first, Heap<A> second) {
  Result<Comparator<A>> comparator = first.comparator()
                                          .orElse(second::comparator);
  return first.rank() >= second.rank()
      ? new H<>(first.length() + second.length() + 1,
                   second.rank() + 1, first, head, second, comparator)
      : new H<>(first.length() + second.length() + 1,
                   first.rank() + 1, second, head, first, comparator);
}

对于 merge 方法,你可以使用要合并的两个树中的任何一个的 Comparator。如果没有 Comparator,你可以使用 Result.Empty。为了不在每次递归调用中从参数中提取比较器,你可以将方法分成两部分:

public static <A> Heap<A> merge(Heap<A> first, Heap<A> second) {
    Result<Comparator<A>> comparator =
                      first.comparator().orElse(second::comparator);
    return merge(first, second, comparator);
  }

  public static <A> Heap<A> merge(Heap<A> first, Heap<A> second,
                                       Result<Comparator<A>> comparator) {
    return first.head().flatMap(fh -> second.head()
                          .flatMap(sh -> compare(fh, sh, comparator) <= 0
        ? first.left().flatMap(fl -> first.right().map(fr ->
                           heap(fh, fl, merge(fr, second, comparator))))
        : second.left().flatMap(sl -> second.right().map(sr ->
                           heap(sh, sl, merge(first, sr, comparator))))))
                  .getOrElse(first.isEmpty()
                      ? second
                      : first);
  }

第二种方法使用了一个名为 comparehelper 方法:

@SuppressWarnings("unchecked")
public static <A> int compare(A first, A second,
                              Result<Comparator<A>> comparator) {
  return comparator.map(comp -> comp.compare(first, second))
         .getOrElse(() -> ((Comparable<A>) first).compareTo(second));
}

此方法对其参数之一进行强制类型转换,但你知道你不会冒 ClassCastException 被抛出的风险,因为如果你确保没有比较器就不能创建堆,如果类型参数没有扩展 Comparable

现在,静态最终 EMPTY 单例可以被移除。add 方法也必须按以下方式修改:

public Heap<A> add(A element) {
  return merge(this, heap(element, this.comparator()));
}

最后,Empty 类中的 leftright 方法必须按以下方式更改:

public Result<Heap<A>> left() {
  return Result.success(empty(this.comparator));
}

protected Result<Heap<A>> right() {
  return Result.success(empty(this.comparator));
}

练习 11.9

到目前为止,你向 Heap 添加元素的唯一方法是通过 merge 方法。实现一个 insert 方法,在不使用 merge 的情况下添加元素。在 Heap 父类中定义一个抽象方法,其签名如下:

public abstract Heap<A> insert(A a)
提示

你应该重用之前练习中的 compare 方法。

解决方案 11.9

Empty 的实现只是调用 heap 工厂方法,传递要插入的值和两个对 this 的引用:

public Heap<A> insert(A a) {
  return heap(a, this, this);
}

H 类中,你需要实现算法很简单。让我们称 a 为要插入的元素。你必须构建一个新的 H,包含一个 head、一个 left 和一个 right

  • 如果这个 heada 低,保持它作为当前 head。否则使用 a

  • 保持左分支不变。

  • 如果 heada 高,递归地将 head 插入到右分支。

  • 否则,递归地将 a 插入到右分支。

下面是代码:

public Heap<A> insert(A a) {
  return heap(compare(head, a, comparator) < 0
      ? head
      : a, left, right.insert(compare(head, a, comparator) > 0
          ? head
          : a));
}

这段代码没有优化,因为你用相同的参数调用了 compare 两次。你可以只调用一次并缓存结果,这也会使代码更容易阅读:

public Heap<A> insert(A a) {
  int comp = compare(head, a, comparator);
  return heap(comp < 0
               ? head
               : a, left, right.insert(comp > 0 ? head : a));
}

看起来不错?其实不然。

练习 11.10

Heap<Integer> 上运行练习 11.9 的解决方案将工作,但它有一个错误。找出它并修复它。当然,如果你做了练习 11.9 并直接找到了正确的解决方案,你可以休息一下。

提示

考虑一下如果插入的值与头部的优先级相同会发生什么。

解决方案 11.10

如果head的优先级等于插入元素a的优先级,则使用a作为新的head,并将其插入到新的右分支中。对于整数堆来说,这并不是什么大问题,但对于大多数其他类型来说,这可能会是一个大错误。考虑以下类型:

class Point implements Comparable<Point> {

  public final int x;
  public final int y;

  private Point(int x, int y) {
    this.x = x;
    this.y = y;
  }

  public String toString() {
    return "(" + x + "," + y + ")";
  }

  @Override
  public int compareTo(Point that) {
    return this.x < that.x ? -1 : this.x > that.x ? 1 : 0;
  }
}

此类型表示可以使用其x坐标进行比较的点。现在,考虑以下模拟将点插入堆的程序:

List<Tuple<Integer, Integer>> points =
               List.list(1, 2, 2, 2, 6, 7, 5, 0, 5, 1).zipWithPosition();
Heap<Point> heap = points.foldLeft(Heap.empty(), h -> t ->
                                        h.insert(new Point(t._1, t._2)));
List<Point> lp = List.unfold(heap, hp -> hp.head()
                   .flatMap(h -> hp.tail().map(t -> new Tuple<>(h, t))));
System.out.println(points);
System.out.println(lp);

在将点插入后,它们将按优先级顺序再次从列表中提取。以下是结果(第一行显示原始点):

[(1,0), (2,1), (2,2), (2,3), (6,4), (7,5), (5,6), (0,7), (5,8), (1,9), NIL]
[(0,7), (1,9), (1,9), (2,3), (2,1), (2,3), (5,8), (5,6), (6,4), (7,5), NIL]

在第二行,你可以看到你得到了两个 x = 1 的点,但不是(1,0)(1,9),而是(1,9)两次。对于 x = 2 的点,你也会有同样的问题。如果你只向堆中插入整数,这个问题就不会明显。

这里是正确的实现:

现在你将得到以下(正确)的结果:

[(1,0), (2,1), (2,2), (2,3), (6,4), (7,5), (5,6), (0,7), (5,8), (1,9), NIL]
[(0,7), (1,9), (1,0), (2,3), (2,1), (2,2), (5,8), (5,6), (6,4), (7,5), NIL]

11.5. 概述

  • 为了提高性能并避免递归操作中的栈溢出,可以平衡树。

  • 红黑树是一种自我平衡的树结构,可以让你不必关心树的平衡。

  • 可以通过委托到一个存储键/值对的树来实现映射。

  • 使用不可比较键的映射必须处理冲突,以便存储具有相同键表示的元素。

  • 优先队列是允许按优先级顺序检索元素的集合。

  • 可以使用左倾堆实现优先队列,它是一种堆有序的二元树。

  • 可以使用额外的比较器构造不可比较元素的优先队列。

第十二章. 以函数式方式处理状态变化

本章涵盖

  • 创建一个函数式随机数生成器

  • 设计一个通用的 API 来处理状态变化

  • 处理和组合状态操作

  • 使用递归状态操作

  • 通用状态处理

  • 构建状态机

在本章中,你将学习如何以纯函数式的方式处理状态。在前面的章节中,我们尽可能地避免了状态变化,你可能会认为状态变化与函数式编程不相容。这并不正确。在函数式编程中,处理状态变化是完全可能的。与你可能习惯的不同之处在于,你必须以函数式的方式处理状态变化,这意味着不依赖于副作用。

对于程序员来说,处理状态变化的理由有很多。一个最简单的例子是随机数生成器。随机数生成器是一个具有返回随机数字的方法的组件。如果随机数生成器没有状态(在现实中意味着没有变化的状态),它将始终返回相同的数字。这不是你期望的。

另一方面,因为我已经在前面的章节中多次说过,一个函数,给定相同的参数,应该返回相同的值,所以你可能很难想象这样的生成器是如何工作的。

12.1. 函数式随机数生成器

随机数生成器有许多用途,但可以将它们分为两大类:

  • 生成在给定范围内均匀分布的数字

  • 生成真正的“随机”数字,这意味着你无法预测的数字

在第一种情况下,你不需要数字真的随机。你需要的是它们是随机分布的。因此,在这种情况下,随机性不适用于单个数字,而是一系列数字。此外,你希望能够在需要时重现这一系列数字。这将允许你测试你的程序。如果生成的数字真的是随机的(在不可预测的意义上),你就无法测试生成器或使用它的程序,因为你不知道期望哪些值。

在第二种情况下,你真的希望数字是不可预测的。例如,如果你想要生成随机测试数据来测试其他程序,每次测试运行时生成相同的数据将毫无用处。

Java 有一个随机数生成器。你可以通过调用nextInt方法(以及其他方法)来使用它:

Random rng = new Random();
System.out.println(rng.nextInt());
System.out.println(rng.nextInt());
System.out.println(rng.nextInt());

这个程序打印...好吧,你不知道。每次运行,它都会打印不同的结果,如下所示:

773282358
-496891854
-47242220

虽然有时你可能想要这样,但这不是函数式的。随机数生成器的nextInt方法不是一个函数,因为它在用相同的参数调用时并不总是返回相同的值。

无参数的函数

nextInt 不接受参数的事实并不重要。为了成为一个函数,它必须始终返回相同的值。不接受参数实际上意味着它可以接受任何参数,而这个参数对返回值没有任何影响。这并不违反函数的定义。这种函数只是一个常数。

让我们思考一下正在发生的事情。如果方法不接受参数并返回一个值,那么这个值必须来自某个地方。当然,你会猜测这个“某个地方”就在随机数生成器内部。值在每次调用时都会变化的事实意味着生成器在每次调用之间都会变化;它有一个可变的状态。所以问题是 nextInt 方法返回的值是否只依赖于生成器的状态,或者它是否依赖于其他东西。

如果返回的值只依赖于生成器的状态,那么让它成为功能性的将很容易。你只需将生成器的状态作为参数传递给方法。当然,由于状态会在方法返回结果时变化(以便生成器不会总是返回相同的值),方法必须返回生成器的状态以及生成的值。你知道如何通过简单地返回一个元组来完成这个操作,所以 nextInt 方法的签名将如下所示:

public Tuple<Integer, Random> nextInt(Random)

这里的问题在于 Java 的 Random 生成器并不这样工作。nextInt 方法返回的值不仅依赖于生成器的状态,还依赖于系统时钟:系统时钟用于初始化生成器。实际上,Java 的 Random 生成器使用一个 long 类型的值来初始化自己。从这一点开始,生成的数字序列将不会变化,但这个称为 种子long 值默认基于系统时钟返回的纳秒数。(更多细节请查看 Random.java 源代码。)重要的是,Java 采取的方法是返回不可预测的数字,除非提供特定的种子来初始化生成器。因此,你仍然可以用它以功能方式生成随机数。

12.1.1. 随机数生成器接口

你现在将实现一个功能随机数生成器。这不会是最好的数字生成器示例,但因为你只是在学习如何以功能方式处理状态变化,它将作为一个功能状态处理的示例。

首先,你需要定义生成器的接口。生成随机数可以通过许多不同的方式完成,因此你可以使用不同的实现。从业务角度来看,生成器的质量基于仅通过查看前一个数字就无法预测下一个数字的不可能性。因此,你可能定义一个简单的生成器,以低廉的成本生成某种可预测的数据,或者你可能定义一个复杂的实现,用于需要不可预测性作为安全措施的用例。

这是你的生成器接口:

import com.fpinjava.common.Tuple;

public interface RNG {
  Tuple<Integer, RNG> nextInt();
}

12.1.2. 实现随机数生成器

在本节中,你将通过使用 Java Random 类尽可能简单地实现随机数生成器。你必须使用种子初始化它,以便随机数序列可以重复。以下是一个可能的实现:

import com.fpinjava.common.Tuple;
import java.util.Random;

public class JavaRNG implements RNG {

  private final Random random;

  private JavaRNG(long seed) {
    this.random = new Random(seed);
  }

  @Override
  public Tuple<Integer, RNG> nextInt() {
    return new Tuple<>(random.nextInt(), this);
  }

  public static RNG rng(long seed) {
    return new JavaRNG(seed);
  }
}

剩下的工作就是创建一个前端组件,使随机数生成器更具功能性:

import com.fpinjava.common.Tuple;

public class Generator {
  public static Tuple<Integer, RNG> integer(RNG rng) {
    return rng.nextInt();
  }
}

要了解这个类如何使用,让我们看看一个单元测试:

public void testInteger() throws Exception {
  RNG rng = JavaRNG.rng(0);
  Tuple<Integer, RNG> t1 = Generator.integer(rng);
  assertEquals(Integer.valueOf(-1155484576), t1._1);
  Tuple<Integer, RNG> t2 = Generator.integer(t1._2);
  assertEquals(Integer.valueOf(-723955400), t2._1);
  Tuple<Integer, RNG> t3 = Generator.integer(t2._2);
  assertEquals(Integer.valueOf(1033096058), t3._1);
}

如您所见,Generator 类的整数方法是函数式的。你可以运行这个测试任意多次;它总是会生成相同的值。所以尽管生成器返回的值依赖于生成器的可变状态,但该方法仍然是引用透明的。

如果你需要生成真正不可预测的数字,你可以使用带有“随机”长值的 JavaRNG.rng 方法;例如,System.nanoTime() 返回的值。但是,请注意,返回的值没有 1 纳秒的分辨率,所以连续多次调用可能会返回相同的值。这可以通过缓存 nanoTime 返回的值并在值未改变时再次调用它来避免,直到获得不同的值。Random 类提供了这项服务,所以最简单的解决方案是创建一个初始化随机字段的无参数 Random() 的第二个方法。但再次强调,本章不是关于生成器,而是关于函数式处理状态。

练习 12.1

Generator 类中编写一个方法,该方法返回一个小于作为参数传递的值但大于或等于 0 的随机正整数。以下是签名:

public static Tuple<Integer, RNG> integer(RNG rng, int limit)

解决方案 12.1

简单地从生成器中获取下一个随机值。对于第一个元组成员,使用除以参数的其余部分的绝对值创建一个新的元组。第二个成员保持不变。

public static Tuple<Integer, RNG> integer(RNG rng, int limit) {
  Tuple<Integer, RNG> random = rng.nextInt();
  return new Tuple<>(Math.abs(random._1 % limit), random._2);
}

练习 12.2

编写一个返回一个包含 n 个随机整数的列表的方法。它还必须返回当前状态,这相当于最后一个 RNG,以便它可以生成下一个整数。以下是签名:

Tuple<List<Integer>, RNG> integers(RNG rng, int length)
提示

尽量不要使用显式递归。使用 List 类的方法,首先创建一个所需大小的列表,然后折叠它。注意,如果你生成一个随机数列表,你也可以将其以反向顺序返回(如果这样更简单)。但你必须确保返回的生成器是最新的,这意味着它必须是 nextInt 方法返回的最后一个。

解决方案 12.2

想法是创建一个所需长度的列表,然后使用正确的函数折叠它。你会用整数列表来做这件事:

List.range(0, length).foldLeft(identity, f);

这是一种常见的模式,用于替换命令式编程中的索引循环。在这里,f 函数忽略了列表中的整数。这个函数将生成器产生的值添加到一个列表中,从空列表开始。所以它看起来应该是一个以下类型的函数:

Function<List<Tuple<Integer, RNG>>, Function<Integer,
                                             List<Tuple<Integer, RNG>>

但如果你这样做,你会遇到问题。你可能会轻易地将生成的 List<Tuple<Integer, RNG>> 转换为 List<Integer>,但要重建 Tuple<List<Integer>, RNG>,你必须获取列表中的最后一个 RNG。这是因为将列表折叠到另一个列表中会反转元素的顺序。随机值顺序相反的事实并不重要,但你需要访问最后一个返回的 RNG,由于折叠,它将位于最后一个位置。要访问它,你必须反转列表,这既不高效也不明智。

一个更好的解决方案是在折叠整数列表时携带当前的 RNG。结果将是一个 Tuple<List<Tuple<Integer, RNG>>, RNG>,用于折叠的函数将是以下内容:

Function<Tuple<List<Tuple<Integer, RNG>>, RNG>, Function<Integer,
                Tuple<List<Tuple<Integer, RNG>>, RNG>>> f = tuple -> i -> {
  Tuple<Integer, RNG> t = integer(tuple._2);
  return new Tuple<>(tuple._1.cons(t), t._2);
};

类型可能看起来令人畏惧,但尽管如此,你不应该使其显式。编译器将能够推断出这个类型,所以你不必写它。以下是完整的折叠:

Tuple<List<Tuple<Integer, RNG>>, RNG> result = List.range(0, length)
                .foldLeft(new Tuple<>(List.list(), rng), tuple -> i -> {
  Tuple<Integer, RNG> t = integer(tuple._2);
  return new Tuple<>(tuple._1.cons(t), t._2);
});

现在你得到了一个 Tuple<List<Tuple<Integer, RNG>>, RNG>,构建预期的结果就很容易了:

public static Tuple<List<Integer>, RNG> integers(RNG rng, int length) {
  Tuple<List<Tuple<Integer, RNG>>, RNG> result = List.range(0, length)
                  .foldLeft(new Tuple<>(List.list(), rng), tuple -> i -> {
    Tuple<Integer, RNG> t = integer(tuple._2);
    return new Tuple<>(tuple._1.cons(t), t._2);
  });
  List<Integer> list = result._1.map(x -> x._1);
  return new Tuple<>(list, result._2);
}

如你所见,由于单链表构建的方式,生成的随机数列表仍然是反向的,但你不需要反转列表。你不在乎第一个生成的数字最后出现。唯一重要的是返回的 RNG 将产生正确的数字。

如果你愿意,你可以这样实现该方法:

图片

或者,你可以使用显式递归:

public static Tuple<List<Integer>, RNG> integers3(RNG rng, int length) {
  return integers3_(rng, length, List.list()).eval();
}

private static TailCall<Tuple<List<Integer>, RNG>> integers3_(RNG rng,
                                         int length, List<Integer> xs) {
  if (length <= 0)
    return TailCall.ret(new Tuple<>(xs, rng));
  else {
    Tuple<Integer, RNG> t1 = rng.nextInt();
    return TailCall.sus(() ->
                 integers3_(t1._2, length - 1, xs.cons(t1._1)));
  }
}

然而,请注意,函数式程序员通常认为使用显式递归是一种不良做法。他们更倾向于通过使用折叠来抽象递归。

12.2. 处理状态的泛型 API

正如我说的,你实现 RNG 的方式并不是实现生成器的最佳方式。这只是一个例子,用来向你展示状态如何在函数式方式中处理。你可以从那个例子中学到的是,你的 RNG 代表了生成器的当前状态。

但如果你想要生成整数,你可能对 RNG 并不真正感兴趣。你可能更希望使其透明。换句话说,你到目前为止使用的是一个接受 RNG 并返回生成的值(无论是 IntegerList 还是其他什么)以及新的 RNG 的函数:

Function<RNG, Tuple<A, RNG>>

如果你能去掉 RNG 会更好吗?是否有可能以这种方式抽象处理 RNG,以至于你不再需要担心它?

为了抽象处理 RNG,你需要创建一个新的类型来封装 RNG 参数:

public interface Random<A> extends Function<RNG, Tuple<A, RNG>>

现在,你可以用这个新类型来重新定义生成操作。例如,你可以替换以下方法

public static Tuple<Integer, RNG> integer(RNG rng) {
  return rng.nextInt();
}

使用一个函数:

public static Random<Integer> integer = RNG::nextInt;

12.2.1. 处理状态操作

通过抽象 RNG,你剩下的是与你在前面章节中学习过的参数化类型非常相似的东西。你在这里得到的是一个简单类型的计算上下文。还记得 ListResult 吗?这些类型就像其他类型的计算上下文一样在起作用。

整数列表是 Integer 类型的计算上下文。例如,它允许你将一个从 Integer 到另一个类型的函数应用于整数列表,而无需关心列表中的元素数量。

Result 没有不同。它为值创建一个计算上下文,允许你对该值应用函数,而无需关心该值是否真的存在。同样地,Random 允许你对该值应用计算,而无需处理该值是随机的这一事实。

你能为 Random 定义与 ListResult 相同的抽象吗?让我们试试。

首先,你需要一种方法从单个值创建一个 Random。尽管这在现实生活中可能看起来用处不大,但它对于创建其他抽象是必需的。你可以将这个方法命名为 unit

public static <A> Random<A> unit(A a) {
  return rng -> new Tuple<>(a, rng);
}

按照惯例,unit 这个名称被使用。你也可以用这个名称来命名 ResultStreamListHeap 等等,但你选择了更与商业相关的名称,例如 listsuccess。这是将相同的概念应用于不同类型。

让我们更进一步。你能使用从 AB 的函数将 Random<A> 转换为 Random<B> 吗?当然可以。对于其他类型,这被称为 map。让我们为 Random 定义一个 map 方法:

static <A, B> Random<B> map(Random<A> s, Function<A, B> f) {
  return rng -> {
    Tuple<A, RNG> t = s.apply(rng);
    return new Tuple<>(f.apply(t._1), t._2);
  };
}

这个方法可以在任何地方定义,例如在 Random 接口中。

练习 12.3

使用 map 方法生成一个随机的 Boolean。通过在 Random 接口中创建一个函数来完成此操作。

提示

使用以下你刚刚创建的函数:

Random<Integer> intRnd = RNG::nextInt;

解决方案 12.3

解决方案包括将 intRnd 函数返回的结果映射到一个将 int 转换为 boolean 的函数。当然,如果你想使结果有 50% 的概率为 true,你必须相应地选择函数。常用的算法是测试除以 2 的余数是否为 0:

Random<Boolean> booleanRnd = Random.map(intRnd, x -> x % 2 == 0);

练习 12.4

实现一个返回随机生成Double的函数。

解决方案 12.4

这与booleanRnd函数的工作方式完全相同。唯一的区别是映射的函数:

Random<Double> doubleRnd =
             map(intRnd, x -> x / (((double) Integer.MAX_VALUE) + 1.0));

12.2.2. 组合状态操作

在上一节中,你使用普通函数组合了状态操作。如果你需要组合两个或多个状态操作呢?这就是你在练习 12.2 中做的,以生成一个随机生成的整数List。你能否在Random类型中抽象化这一点?作为一个起点,你可能需要一个方法来组合两个Random实例,例如生成一对随机数。

练习 12.5

实现一个函数,该函数接受一个RNG并返回一对整数。

提示

首先在Random接口中定义一个map2方法,该方法组合两个对随机生成器的调用,以生成泛型类型AB的值对,然后使用它们作为返回第三个类型C的函数的参数。以下是它的签名:

static <A, B, C> Random<C> map2(Random<A> ra, Random<B> rb,
                                Function<A, Function<B, C>> f) {

解决方案 12.5

这并不比实现map更困难。你首先必须将rng参数传递给第一个函数。然后从结果中提取返回的RNG,并将其传递给第二个函数。最后,使用这两个值作为f函数的输入,并返回结果以及生成的RNG

static <A, B, C> Random<C> map2(Random<A> ra, Random<B> rb,
                                Function<A, Function<B, C>> f) {
  return rng -> {
    Tuple<A, RNG> t1 = ra.apply(rng);
    Tuple<B, RNG> t2 = rb.apply(t1._2);
    return new Tuple<>(f.apply(t1._1).apply(t2._1), t2._2);
  };
}

使用这种方法,你可以定义一个返回一对随机整数的函数,如下例所示:

Random<Tuple<Integer, Integer>> intPairRnd =
                    map2(intRnd, intRnd, x -> y -> new Tuple<>(x, y));

不要使用相同的RNG来生成两个值。这样做会产生一对相同的整数!

练习 12.6

实现一个函数,该函数接受一个RNG并返回一个随机生成的整数列表。

提示

整个过程描述起来相当简单。首先,你必须生成一个List<Random<Integers>>。然后,你必须将这个列表转换成Random<List<Integer>>。这让你想起了什么吗?这是你为Result实现的相同抽象,将List<Result>转换为Result<List>,你称之为sequence

你可以从在Random类中实现一个sequence方法开始。以下是它的签名:

static <A> Random<List<A>> sequence(List<Random<A>> rs)

要生成列表,你可以使用在List类中定义的List.fill()方法,其签名如下:

public static <A> List<A> fill(int n, Supplier<A> s)

解决方案 12.6

你可以猜测你将需要遍历列表。你不需要为此使用显式递归,也不应该这样做!你应该使用折叠。起始值将是一个使用空列表构造的Random。这就是unit方法开始变得有用的地方。使用foldLeftfoldRight,并使用一个将map2应用于当前累加器值和要处理的列表元素的函数。

这比描述代码要困难得多。以下是一个使用foldLeft的示例:

static <A> Random<List<A>> sequence(List<Random<A>> rs) {
  return rs.foldLeft(unit(List.list()), acc -> r ->
                              map2(r, acc, x -> y -> y.cons(x)));
}

然后定义一个函数,返回一个随机整数的列表。这次,类型不再是Random<Integer>,因为你必须处理表示列表所需长度的额外int参数:

Function<Integer, Random<List<Integer>>> integersRnd =
                    length -> sequence(List.fill(length, () -> intRnd));

将这个实现与练习 12.2 的解决方案进行比较很有趣:

public static Tuple<List<Integer>, RNG> integers(RNG rng, int length) {
  Tuple<List<Tuple<Integer, RNG>>, RNG> result = List.range(0, length)
                  .foldLeft(new Tuple<>(List.list(), rng), tuple -> i -> {
    Tuple<Integer, RNG> t = integer(tuple._2);
    return new Tuple<>(tuple._1.cons(t), t._2);
  });
  List<Integer> list = result._1.map(x -> x._1);
  return new Tuple<>(list, result._2);
}

你可以看到,折叠已经被抽象成sequence方法,中间结果处理已经被抽象成map2方法。生成的代码非常简洁且易于理解(前提是你理解了这两个抽象)。在integersRnd函数中,你不需要操作RNG生成器。对于sequencemap2方法也是如此。正如你所见,你非常接近实现一个通用的状态处理工具。

12.2.3. 递归状态操作

到目前为止,你已经看到了如何多次调用生成器以返回多个值。但你可能需要处理不同的用例。想象一下,你想要生成不是 5 的倍数的整数。

如果你正在编写一个命令式程序,你可以简单地生成一个数字并测试它。如果不是 5 的倍数,你就返回它。否则,你生成下一个数字。在这个实现中,你平均每五个案例中就要生成第二个数字。你可能考虑以下方案:

Random<Integer> notMultipleOfFiveRnd = Random.map(intRnd, x -> {
  return x % 5 != 0
      ? x
      : Random.notMultipleOfFiveRnd.apply(???);
});

但你如何访问必须传递给not-MultipleOfFiveRnd函数递归调用的RNG?这是函数第一次调用产生的RNG

可以通过显式处理第一次函数调用的结果来解决这个问题:

Random<Integer> notMultipleOfFiveRnd = rng -> {
    Tuple<Integer, RNG> t = intRnd.apply(rng);
    return t._1 % 5 != 0
        ? t
        : Random.notMultipleOfFiveRnd.apply(t._2);
};

但看起来你回到了起点。你真正需要的是flatMap方法。

练习 12.7

编写一个flatMap方法,并使用它来实现notMultipleOfFiveRnd函数。这是flatMap方法的签名:

static <A, B> Random<B> flatMap(Random<A> s, Function<A, Random<B>> f)

解决方案 12.7

flatMap方法与map方法非常相似:

static <A, B> Random<B> flatMap(Random<A> s, Function<A, Random<B>> f) {
  return rng -> {
    Tuple<A, RNG> t = s.apply(rng);
    return f.apply(t._1).apply(t._2);
  };
}

不同之处在于,你不需要构造一个元组并返回它,而是简单地将生成的值传递给f函数,这个函数给你一个Random<B>。记住,实际上这是一个Function<RNG, Tuple<A, RNG>>,所以你将应用s到该函数的结果RNG传递给它,这给你一个可以返回的Tuple<A, RNG>

现在,你可以用flatMap来实现notMultipleOfFiveRnd函数:

Random<Integer> notMultipleOfFiveRnd = Random.flatMap(intRnd, x -> {
    int mod = x % 5;
    return mod != 0
        ? unit(x)
        : Random.notMultipleOfFiveRnd;
});

练习 12.8

flatMap来实现mapmap2

提示

之间存在着mapflatMapunit的关系:flatMapmapunit的组合。

解决方案 12.8

这里是两种新的实现:

static <A, B> Random<B> map(Random<A> s, Function<A, B> f) {
  return flatMap(s, a -> unit(f.apply(a)));
}
static <A, B, C> Random<C> map2(Random<A> ra, Random<B> rb,
                                Function<A, Function<B, C>> f) {
  return flatMap(ra, a -> map(rb, b -> f.apply(a).apply(b)));
}

正如你所见,flatMap为你提供了一个额外的抽象层次,这允许你编写更清晰的方法实现。

12.3. 通用状态处理

到目前为止,你在这个章节中开发的全部方法和函数都用于生成随机数。但你从生成随机数的特定代码开始,并以与随机数生成完全无关的工具结束。Random接口的方法仅通过这个接口扩展Function <RNG, Tuple<A, RNG>>与随机数生成相关联。实际上,你可以重新定义这个接口来处理任何类型的状态:

interface State<S, A> extends Function<S, Tuple<A, S>> {}

你当然知道组合优于继承,所以你可能更喜欢使用代理来定义State类:

public class State<S, A> {

  public final Function<S, Tuple<A, S>> run;

  public State(Function<S, Tuple<A, S>> run) {
    super();
    this.run = run;
  }
}

现在,你可以将Random重新定义为State的一个特定情况:

public class Random<A> extends State<RNG, A> {

  public Random(Function<RNG, Tuple<A, RNG>> run) {
    super(run);
  }
}

练习 12.9

通过以通用方式重新实现Random接口的方法,完成State类的实现。

提示

将方法定义为实例方法,当然,unit方法需要是静态的。每个方法都必须创建一个新的State

解决方案 12.9

这里是您的新方法:

public static <S, A> State<S, A> unit(A a) {
  return new State<>(state -> new Tuple<>(a, state));
}

public <B> State<S, B> map(Function<A, B> f) {
  return flatMap(a -> State.unit(f.apply(a)));
}

public <B, C> State<S, C> map2(State<S, B> sb, Function<A,
                                               Function<B, C>> f) {
  return flatMap(a -> sb.map(b -> f.apply(a).apply(b)));
}

public <B> State<S, B> flatMap(Function<A, State<S, B>> f) {
  return new State<>(s -> {
    Tuple<A, S> temp = run.apply(s);
    return f.apply(temp._1).run.apply(temp._2);
  });
}

public static <S, A> State<S, List<A>> sequence(List<State<S, A>> fs) {
  return fs.foldRight(State.unit(List.<A>list()),
                       f -> acc -> f.map2(acc, a -> b -> b.cons(a)));
}

你现在可以用State<RNG, A>的别名替换你的Random接口:

public class Random<A> extends State<RNG, A> {
  public Random(Function<RNG, Tuple<A, RNG>> run) {
    super(run);
  }
  public static State<RNG, Integer> intRnd = new Random<>(RNG::nextInt);
}

12.3.1. 状态模式

假设你需要生成三个随机整数来初始化一个三维(3D)点:

public class Point {

  public final int x;
  public final int y;
  public final int z;
  public Point(int x, int y, int z) {
    this.x = x;
    this.y = y;
    this.z = z;
  }

  @Override
  public String toString() {
    return String.format("Point(%s, %s, %s)", x, y, z);
  }
}

你可以创建一个随机的Point如下所示:

State<RNG, Point> ns =
    intRnd.flatMap(x ->
        intRnd.flatMap(y ->
            intRnd.map(z -> new Point(x, y, z))));

这段代码只是修改了一个状态。但如果有一个get方法来读取状态和一个set方法来写入它,这种修改可以简化。然后你可以使用函数f将它们组合起来修改状态,如下所示:

public static <S> State<S, Nothing> modify(Function<S, S> f) {
  return State.<S>get().flatMap(s -> set(f.apply(s)));
}

此方法返回State<S, Nothing>,因为它不返回任何值。你只对修改后的状态感兴趣。"Nothing"是一个你必须如下定义的类型:

public final class Nothing {

  private Nothing() {}

  public static final Nothing instance = new Nothing();
}

你可以使用Void类型而不是Nothing类型返回,但实例化Void有点棘手,使用一个肮脏的技巧,所以更清洁的解决方案更可取。

get方法创建一个函数,该函数简单地返回参数的状态,既是状态也是值:

public static <S> State<S, S> get() {
  return new State<>(s -> new Tuple<>(s, s));
}

set方法创建一个函数,该函数返回参数的状态作为新状态,以及Nothing单例作为值:

public static <S> State<S, Nothing> set(S s) {
  return new State<>(x -> new Tuple<>(Nothing.instance, s));
}

12.3.2. 构建状态机

组合状态变异的最常见工具之一是状态机。状态机是一段代码,它通过有条件地从一种状态切换到另一种状态来处理输入。许多业务问题可以通过此类条件状态变异来表示。

通过创建一个参数化状态机,你可以抽象出所有关于状态处理的所有细节。这样,你只需列出条件/转换对,然后输入输入列表以获取结果状态,就能简单地处理任何此类问题。机器将透明地处理各种转换的组合。

首先,你需要定义两个接口来表示条件和相应的转换。这些接口并不是绝对必要的,因为它们是简单的函数,但它们将简化编码:

interface Condition<I, S> extends Function<StateTuple<I, S>, Boolean> {}

interface Transition<A, S> extends Function<StateTuple<A, S>, S> {}

StateTuple 类也是一个辅助类,用于简化编码。它只是一个字段名为 valuestate 的元组。这比 _1_2leftright 更容易阅读,因为很容易忘记哪个是哪个。

public class StateTuple<A, S> {

  public final A value;
  public final S state;

  public StateTuple(A a, S s) {
    value = a;
    state = s;
  }
}

StateMachine 类简单地持有类型为 Function<A, State<S, Nothing>> 的函数。将最终值作为状态的一部分是选择问题。在这里,最终值包含在状态中,因此你不需要单独携带该值。

状态机是由一个包含 <Tuple<Condition<A, S>, Transition<A, S>> 的列表构建的。在构造函数中,函数构建如下:

public class StateMachine<A, S> {

  Function<A, State<S, Nothing>> function;

  public StateMachine(List<Tuple<Condition<A, S>,
                                Transition<A, S>>> transitions) {
    function = a -> State.sequence(m ->
      Result.success(new StateTuple<>(a, m)).flatMap((StateTuple<A, S> t) ->
          transitions.filter((Tuple<Condition<A, S>, Transition<A, S>> x) ->
             x._1.apply(t)).headOption().map((Tuple<Condition<A, S>,
                 Transition<A, S>> y) -> y._2.apply(t))).getOrElse(m));
  }

State.sequence 方法定义如下:

public static <S> State<S, Nothing> sequence(Function<S, S> f) {
  return new State<>(s -> new StateTuple<>(Nothing.instance, f.apply(s)));
}

这段代码可能看起来很复杂,但它只是构建一个函数,该函数将组合所有作为构造函数参数接收的条件转换。

StateMachine 类还定义了一个 process 方法,该方法接收一个输入列表以产生结果状态:

public State<S, S> process(List<A> inputs) {
    List<State<S, Nothing>> a = inputs.map(function);
    State<S, List<Nothing>> b = State.compose(a);
    return b.flatMap(x -> State.get());
  }
}

State.compose() 方法定义如下:

public static <S, A> State<S, List<A>> compose(List<State<S, A>> fs) {
  return fs.foldRight(State.unit(List.<A>list()),
                        f -> acc -> f.map2(acc, a -> b -> b.cons(a)));
}

练习 12.10

编写一个 Atm 类来模拟自动取款机。输入将由以下接口表示:

public interface Input {

  Type type();

  boolean isDeposit();

  boolean isWithdraw();

  int getAmount();

  enum Type {DEPOSIT,WITHDRAW}
}

Input 接口将有两个实现,DepositWithdraw

public class Deposit implements Input {

  private final int amount;

  public Deposit(int amount) {
    super();
    this.amount = amount;
  }

  @Override
  public Type type() {
    return Type.DEPOSIT;
  }

  @Override
  public boolean isDeposit() {
    return true;
  }

  @Override
  public boolean isWithdraw() {
    return false;
  }
  @Override
  public int getAmount() {
    return this.amount;
  }
}

public class Withdraw implements Input {

  private final int amount;

  public Withdraw(int amount) {
    super();
    this.amount = amount;
  }

  @Override
  public Type type() {
    return Type.WITHDRAW;
  }

  @Override
  public boolean isDeposit() {
    return false;
  }

  @Override
  public boolean isWithdraw() {
    return true;
  }

  @Override
  public int getAmount() {
    return this.amount;
  }
}

为了简化代码,使用一个额外的 Outcome 类来表示结果元组:

public class Outcome {

  public final Integer account;
  public final List<Integer> operations;

  public Outcome(Integer account, List<Integer> operations) {
    super();
    this.account = account;
    this.operations = operations;
  }

  public String toString() {
    return "(" + account.toString() + "," + operations.toString() + ")";
  }
}

如你在该类中看到的,Atm 生成一个表示账户最终余额的整数值,以及一个表示操作金额的整数列表(正数表示存款,负数表示取款)。

练习是要实现 Atm 类,它基本上包含一个构建 StateMachine 的方法:

public class Atm {
  public static StateMachine<Input, Outcome> createMachine() {
    ...
  }
}
提示

createMachine 的实现必须首先构建一个包含条件和相应转换的元组列表。这些元组必须按顺序排列,更具体的元组排在前面。最后一个元组需要一个通配符条件。这就像 switch 结构中的默认情况(以及练习 3.2 中的默认情况)。这个通配符条件并不总是需要的,但总是有更安全的选择。这个元组列表将作为 StateMachine 构造函数的参数。

你必须运行生成的状态机以获得可观察的结果。这可以通过将 run 函数应用于起始状态来完成,这将产生一个结果状态,你可以从中提取值:

Outcome out = Atm.createMachine().process(inputs)
                 .run.apply(new Outcome(0, List.list())).value;

此代码的运行部分(第二行)可以通过添加以下方法抽象到 State 类中:

public A eval(S s) {
  return run.apply(s).value;
}

通过添加此方法,运行状态机变得更加整洁:

Outcome out = Atm.createMachine().process(inputs)
                           .eval(new Outcome(0, List.list()));

解决方案 12.10

解决方案就像一种命令式语言中的程序。它可以像这样用伪代码描述:

process operation
  if the operation is a deposit
    add the amount to the account and add the operation
                                              to the operation list
    process next operation
  if the operation is a withdraw and the amount is less
                                              than the account balance
    remove the amount from the account and add the operation
                                              to the operation list
    process next operation
  else
    do not change account nor operation list

实现这一点很容易:

public static StateMachine<Input, Outcome> createMachine() {

  Condition<Input, Outcome> predicate1 = t -> t.value.isDeposit();
  Transition<Input, Outcome> transition1 =
            t -> new Outcome(t.state.account + t.value.getAmount(),
                             t.state.operations.cons(t.value.getAmount()));

  Condition<Input, Outcome> predicate2 = t -> t.value.isWithdraw()
                              && t.state.account >= t.value.getAmount();
  Transition<Input, Outcome> transition2 =
         t -> new Outcome(t.state.account - t.value.getAmount(),
                        t.state.operations.cons(- t.value.getAmount()));

  Condition<Input, Outcome> predicate3 = t -> true;
  Transition<Input, Outcome> transition3 = t -> t.state;

  List<Tuple<Condition<Input, Outcome>,
                 Transition<Input, Outcome>>> transitions = List.list(
        new Tuple<>(predicate1, transition1),
        new Tuple<>(predicate2, transition2),
        new Tuple<>(predicate3, transition3));

    return new StateMachine<>(transitions);
}

如果你想看到机器的实际运行情况,只需运行本书附带代码中的单元测试。

这段代码的工作方式与命令式程序完全一样,顺便说一下,它确实是。这是一种函数式命令式编程。当然,使用这种代码处理如此简单的问题可能是过度设计。这种方法的缺点主要不是代码的复杂性(这段代码非常简单),而是其冗长性。另一方面,好处是它几乎可以零成本扩展。你只需要在正确的位置插入正确的条件/转换即可。

练习 12.11

修改之前的程序,以便报告诸如尝试提取超过账户余额的错误等错误。

解决方案 12.11

我没有为这个练习提供书面解决方案,但在本书的代码中提供了一种可能的解决方案,以及相应的 JUnit 测试。

12.3.3. 何时使用状态和状态机

可能看起来在函数式编程中处理状态是一个过于复杂的命令式编程版本。对于可以在书中描述的非常简单和小的例子来说,这是真的。但如果你考虑具有大量规则的复杂程序,函数式状态处理的高度抽象显然是有益的。但这不是唯一的优点——主要优点是可扩展性。你可以通过更改规则或添加更多规则来简单地演进应用程序,而无需冒破坏实现的风险。

你可以使这更加简单。在 Java 中描述规则(条件/转换)非常冗长,但可以以更简洁的形式编写。然后你只需阅读它们并将它们转换为 Java。

这可能演变成创建一个领域特定语言(DSL)。当然,你需要一个解析器来处理使用此 DSL 编写的程序,但这样的解析器可以很容易地使用函数式状态机创建。(状态机不是解析所有类型语法的最佳解决方案,但这又是另一个故事。)

12.4. 摘要

  • 生成随机数涉及到管理生成器的状态。

  • 你可以通过使用状态操作表示来以函数式方式管理状态。

  • 你可以使用像 mapflatMap 这样的方法来组合状态操作。

  • 你可以递归地组合状态操作。

  • State 类型是对状态操作的泛型表示,可以用作实现状态机的基础。

第十三章. 函数式输入/输出

本章涵盖

  • 从上下文中安全地应用效果

  • 将效果应用添加到ResultList

  • 成功和失败效果的组合

  • 使用Reader抽象从控制台、文件或内存中安全地读取数据

  • 使用IO类型处理输入/输出

到目前为止,你已经学习了如何编写没有真正产生任何可用结果的函数式程序。你学习了如何组合真正的函数来构建更强大的函数。更有趣的是,你学习了如何以安全、函数式的方式使用非函数操作。非函数操作是产生副作用的操作,如抛出异常、改变外部世界或简单地依赖于外部世界来产生结果。例如,你学习了如何进行整数除法,这是一个可能不安全的操作,通过在计算上下文中使用它,你可以将其转换为安全的操作。

你已经遇到了几个这样的计算上下文:

  • 你在第七章中开发的Result类型就是这样一种计算上下文,它允许你以安全、无错误的方式使用可能产生错误的函数。

  • 第六章中的Option类型也是一个计算上下文,用于安全地应用有时(对于某些参数)可能不会产生数据的函数。

  • 你在第五章和第八章学习的List类是一个计算上下文,但它不是处理错误,而是允许在元素集合的上下文中使用对单个元素工作的函数。它还处理由空列表表示的数据缺失问题。

在学习这些类型以及StreamMapHeapState等其他类型时,你并不关心产生有用的结果。然而,在本章中,你将学习从你的函数式程序中产生有用结果的技术。这包括为人类用户显示结果或将结果传递给另一个程序。

13.1. 在上下文中应用效果

回想一下你是如何将函数应用于整数操作的结果的。假设你想编写一个inverse函数,该函数计算整数的倒数:

Function<Integer, Result<Double>> inverse = x -> x != 0
    ? Result.success((double) 1 / x)
    : Result.failure("Division by 0");

此函数可以应用于整数值,但当与其他函数组合时,值将是另一个函数的输出,因此它通常已经处于上下文中,并且通常是同一类型的上下文。以下是一个例子:

Result<Integer> ri = ...
Result<Double> rd = ri.flatMap(inverse);

需要注意的是,你不会将ri中的值从其上下文中取出以应用函数。相反:你将函数传递给上下文(Result类型),以便它可以在其中应用,产生一个新的上下文,可能包含产生的结果。在这里,你将函数传递给ri上下文,产生新的rd结果。

这非常整洁且安全。不会发生任何坏事;不会抛出异常。这是函数式编程的美丽之处:无论你使用什么数据作为输入,你的程序都将始终有效。但问题是,你如何使用这个结果?假设你想要在控制台上显示结果——你该如何做?

13.1.1. 什么是效果?

我将纯函数定义为没有任何可观察副作用的功能。效果是任何可以从程序外部观察到的内容。函数的作用是返回一个值,而副作用是除了返回值之外,可以从函数外部观察到的任何内容。它被称为“副作用”,因为它是在返回值之外附加的。一个没有“副”的效果就像副作用,但它是一个程序的主要(并且通常是唯一的)作用。函数式编程是关于以函数式方式编写具有纯函数(没有副作用)和纯效果的程序。

问题是,以函数式方式处理效果意味着什么?在这个阶段,我能给出的最接近的定义是“以不干扰函数式编程原则的方式处理效果,最重要的原则是引用透明性。”有几种方法可以接近或达到这个目标,完全达到这个目标可能很复杂。通常,接近它就足够了。取决于你决定使用哪种技术。将效果应用于上下文是使其他功能程序产生可观察效果的最简单(尽管不是完全函数式)的方法。

13.1.2. 实现效果

正如我刚才说的,效果是任何可以从程序外部观察到的内容。当然,为了有价值,这种效果通常必须反映程序的结果,所以你通常需要将程序的结果与它进行某种可观察的操作。请注意,“可观察”并不总是指由人类操作员观察。通常,结果可以被另一个程序观察,然后这个程序可能将这种效果转换成人类操作员可以观察的形式,无论是同步还是异步形式。打印到计算机屏幕可以被操作员看到。另一方面,写入数据库可能并不总是直接对人类用户可见。有时结果将由人类查找,但通常它将在稍后由另一个程序读取。在第十四章(kindle_split_021.xhtml#ch14)中,你将了解到这样的效果如何被程序用来与其他程序通信。

因为效果通常应用于一个值,所以纯效果可以被建模为一种特殊类型的函数,不返回任何值。我在书中通过以下接口表示:

public interface Effect<T> {
  void apply(T t);
}

注意,这相当于 Java 的 Consumer 接口。只有类的名称和方法名称不同。实际上,正如我在本书开头提到的几次,名称无关紧要,但有意义的名称更好。

Effect 接口是 Java 所称的功能式接口,这大致意味着一个只有一个抽象方法(SAM)的接口。为了定义一个将 Double 值打印到屏幕上的效果,你可以编写如下代码:

Effect<Double> print = x -> System.out.println(x);

或者更好,你可以使用方法引用:

Effect<Double> print = System.out::println;

注意,这创建了一个类型为 Effect<Double> 的对象,所以通常这不是处理效果最高效的方式。命名效果类似于命名函数:匿名 lambda(不要与匿名类混淆)通常编译为添加到底层代码的几个额外指令,而命名 lambda 编译为对象。因此,通常更好的做法是使用匿名 lambda 或匿名方法引用作为效果。此外,使用匿名 lambda 可以使我们不必显式声明类型。

你需要的是类似这样的东西,其中 rd 是 第 13.1 节 中的示例的 Result

rd.map(x -> System.out.println(x));

不幸的是,这无法编译,因为表达式 System.out.println(x) 返回 void,而它必须返回一个值才能使代码编译。

你可以使用一个返回值并打印副作用的函数。你只需忽略返回的值即可。但你可以做得更好,正如你在第七章中看到的。在第七章中,你编写了一个 forEach 方法,该方法接受一个效果并将其应用于底层值。这个方法在 Empty 类中如下实现:

public void forEach(Effect<T> ef) {
  // Do nothing
}

Success 类中,它是这样实现的:

public void forEach(Effect<T> ef) {
  ef.apply(value);
}

当然,你不能为这个方法编写单元测试。为了验证它是否工作,你可以运行以下列表中的程序,并查看屏幕上的结果。

列表 13.1. 输出数据

图片

此程序产生以下结果:

Inverse of 4: 0.25
Inverse of 0:

练习 13.1

List 类中编写一个 forEach 方法,该方法接受一个效果并将其应用于列表的所有元素。

解决方案 13.1

Nil 类的实现与 Result.Empty 相同:

public void forEach(Effect<A> ef) {
  // Do nothing
}

Cons 类的最简单递归实现如下:

public void forEach(Effect<A> ef) {
  ef.apply(head);
  tail.forEach(ef);
}

不幸的是,如果你有超过几千个元素,这个实现将会耗尽堆栈。

对于这个问题,有许多不同的解决方案。你不能直接使用 TailCall 类来使递归堆栈安全,但你可以使用一个带有副作用的帮助函数并忽略结果:

public void forEach(Effect<A> ef) {
  forEach(this, ef).eval();
}

private static <A> TailCall<List<A>> forEach(List<A> list, Effect<A> ef) {
  return list.isEmpty()
      ? TailCall.ret(list)
      : TailCall.sus(() -> {
        ef.apply(list.head());
        return forEach(list.tail(), ef);
      });
}

此实现使用了 forEach 辅助函数的副作用,但由于你正在实现效果的运用,这实际上并不重要。另一个(更高效)的解决方案是简单地使用 while 循环。选择哪种实现取决于你。

13.1.3. 更强大的失败效果

虽然当列表为空时(对于 Option.NoneResult.Empty 也是如此)什么都不做是有意义的,但在处理可能出现的错误结果时,这显然是不够的。在这种情况下,你可能需要将效果应用到错误上。

你的 Result 类在出现错误的情况下将包含一个 Exception。你可能认为对于这种情况有两种不同的效果。第一种效果是抛出异常,第二种是处理异常的另一种方式,避免抛出异常。

在 第七章 中,你在 Result 类中编写了 forEachOrThrow 方法,它接受一个 Effect 作为参数,如果底层值存在,则应用它,如果它是 Failure,则抛出异常。

forEachOrThrowEmpty 实现不执行任何操作,类似于 forEach 的实现。Failure 实现简单地抛出包含的异常:

public void forEachOrThrow(Effect<T> c) {
  throw this.exception;
}

Success 的实现再次类似于 forEach,并将效果应用到包含的值上:

public void forEachOrThrow(Effect<T> e) {
  e.apply(this.value);
}

在失败的情况下抛出异常并不是你通常想要做的,至少在 Result 类中是这样。通常,决定做什么是由客户端来决定的,你可能想要做一些比抛出异常更温和的事情。例如,你可能在继续之前记录这个异常。

记录日志并不非常实用,因为日志通常是一个副作用。没有程序是以记录日志作为其主要目标的。使用类似 forEach 这样的方法来应用效果是违反函数式契约的。这本身并不是一个问题,但当你记录日志时,你突然就不再是函数式的——这在某些方面意味着函数式程序的终结。效果应用之后,你就可以开始另一个新的函数式程序了。

如果你的应用程序在每一个方法中都记录日志,那么命令式编程和函数式编程之间的边界将不会非常清晰。但是,因为日志通常是一个要求,至少在 Java 世界中是这样,你可能想要一个干净的方式来处理它。在失败的情况下,你没有简单的方法来记录一个异常。你需要的是将失败转换为异常的成功。为此,你需要直接访问异常,而这不能从 Result 上下文之外完成。

为什么记录日志是危险的

在函数式编程中,你不会看到很多日志记录。这是因为函数式编程使得日志记录变得几乎无用。函数式程序是通过组合纯函数构建的,这意味着对于相同的参数,函数总是返回相同的值,因此不可能有任何意外。另一方面,在命令式编程中,日志记录无处不在,因为在命令式程序中,你无法预测给定输入的输出。日志记录就像是在说“我不知道程序在这个点可能会产生什么,所以我将其写入日志文件。如果一切顺利,我就不需要这个日志文件,但如果出了问题,我就能查看日志来了解程序在这个点的状态。”这是无意义的。

在函数式编程中,不需要这样的日志。如果所有函数都是正确的,这通常是可以证明的,你不需要知道中间状态。此外,在命令式程序中的日志记录通常是条件性的,这意味着某些日志代码只有在非常罕见和未知的状态下才会执行。这段代码通常未经测试。如果你曾经看到过一个在 INFO 模式下运行良好的命令式 Java 程序,在 TRACE 模式下运行时突然崩溃,你就知道我的意思了。

13.2 练习

在第七章中,你在Result类型中编写了一个forEachOrException方法,它在EmptySuccess中的工作方式类似于forEach,增加的是它会返回一个Result.Empty,并在Failure类中返回一个Result.Success<Exception>

编写一个forEachOrFail方法,该方法将返回一个包含异常信息的Result<String>,而不是异常本身。

注意,这两个方法都不是函数式的。尽管它们返回一个值,但它们可能具有副作用。

13.2 解决方案

Empty中的实现不执行任何操作并返回Empty

public Result<String> forEachOrFail(Effect<T> c) {
  return empty();
}

Success中的实现应用了效果并返回Empty

public Result<String> forEachOrFail(Effect<T> e) {
  e.apply(this.value);
  return empty();
}

Failure实现只是返回包含的异常或其消息的Success

public Result<String> forEachOrFail(Effect<T> c) {
  return success(exception.getMessage());
}

public Result<RuntimeException> forEachOrException(Effect<T> c) {
  return success(exception);
}

这些方法,尽管不是函数式的,但极大地简化了Result值的用法:

public class ResultTest {

  public static void main(String... args) {

    Result<Integer> ra = Result.success(4);
    Result<Integer> rb = Result.success(0);

    Function<Integer, Result<Double>> inverse = x -> x != 0
        ? Result.success((double) 1 / x)
        : Result.failure("Division by 0");

    Result<Double> rt1 = ra.flatMap(inverse);
    Result<Double> rt2 = rb.flatMap(inverse);

    System.out.print("Inverse of 4: ");
    rt1.forEachOrFail(System.out::println).forEach(ResultTest::log);

    System.out.print("Inverse of 0: ");
    rt2.forEachOrFail(System.out::println).forEach(ResultTest::log);
  }

  private static void log(String s) {
    System.out.println(s);
  }
}

这个程序将打印以下内容:

Inverse of 4: 0.25
Inverse of 0: Division by 0

13.2. 读取数据

到目前为止,你只处理了输出。正如你所看到的,数据输出发生在程序的最后,一旦计算出了结果。这允许大多数程序以函数式的方式编写,并享有该范式的所有好处。只有输出部分不是函数式的。我也说过,输出可以通过将数据发送到其他程序来完成,但你还没有看到如何将数据输入到你的程序中。现在让我们来做这件事。

接下来,我们将探讨一种函数式的方法来输入数据。但首先,就像我们讨论输出那样,我们将讨论如何以干净(尽管非函数式和命令式的)的方式输入数据,这样就可以很好地与函数式部分相匹配。

13.2.1. 从控制台读取数据

作为示例,你将以一种虽然命令式但允许通过使程序确定性来测试的方式从控制台读取数据。你将使用的方法与你对第十二章中的随机生成器所做的方法类似。

你将首先开发一个示例,该示例读取整数和字符串。以下列表显示了你需要实现的接口。

列表 13.2. 输入数据的接口

图片

你可以为这个接口编写一个具体实现,但首先你会写一个抽象实现(因为你可能想从其他来源读取数据,例如文件)。你将把通用代码放在一个抽象类中,并为每种输入类型扩展它。以下列表显示了这种实现。

列表 13.3. AbstractReader 的实现

图片

图片

现在你只需要实现一个具体类来从控制台读取。这个类将负责提供 reader。此外,你将重新实现接口中的两个默认方法,向用户显示提示。

列表 13.4. ConsoleReader 的实现

图片

图片

现在,你可以使用你学到的 ConsoleReader 类来编写一个完整的程序,从输入到输出。

列表 13.5. 从输入到输出的完整程序

图片

这并不非常令人印象深刻。这相当于大多数编程课程中普遍存在的“hello”程序,通常是第二个示例(在“hello world”之后)。当然,这只是一个示例。有趣的是,它如何容易地演变成为一个更有用的东西。

练习 13.3

编写一个程序,该程序会反复提示用户输入一个整数 ID、一个名字和一个姓氏,并在稍后显示控制台上的人员列表。当用户输入一个空 ID 时,数据输入停止,然后显示输入的数据列表。

提示

你需要一个类来保存每行数据。使用以下列表中显示的 Person 类。

列表 13.6. Person
public class Person {

  private static final String FORMAT =
                 "ID: %s, First name: %s, Last name: %s";
  public final int id;
  public final String firstName;
  public final String lastName;

  private Person(int id, String firstName, String lastName) {
    this.id = id;
    this.firstName = firstName;
    this.lastName = lastName;
  }

  public static Person apply(int id, String firstName, String lastName) {
    return new Person(id, firstName, lastName);
  }

  @Override
  public String toString() {
    return String.format(FORMAT, id, firstName, lastName);
  }
}

ReadConsole 类的主方法中实现解决方案。使用 Stream.unfold 方法生成人员流。你可能发现为输入单个人员对应的数据创建一个单独的方法更容易,并使用方法引用作为 unfold 的参数。此方法可以具有以下签名:

public static Result<Tuple<Person, Input>> person(Input input)

解决方案 13.3

解决方案非常简单。考虑到你有一个用于输入单个人员数据的函数,你可以创建一个人员流,并按如下方式打印结果(忽略任何错误):

Input input = ConsoleReader.consoleReader();
Stream<Person> stream = Stream.unfold(input, ReadConsole::person);
stream.toList().forEach(System.out::println);

你现在只需要 person 方法。此方法将简单地询问 ID、名字和姓氏,生成三个 Result 实例,这些实例可以使用你在前几章中学到的理解模式进行组合:

public static Result<Tuple<Person, Input>> person(Input input) {
  return input.readInt("Enter ID:")
     .flatMap(id -> id._2.readString("Enter first name:")
         .flatMap(firstName -> firstName._2.readString("Enter last name:")
             .map(lastName -> new Tuple<>(Person.apply(id._1, firstName._1,
                                             lastName._1), lastName._2))));
}

注意,理解模式可能是函数式编程中最重要的一种模式,所以你真的需要掌握它。其他语言,如 Scala 或 Haskell,为此提供了语法糖,但 Java 没有提供。在伪代码中,这对应于以下内容:

for {
  id in input.readInt("Enter ID:")
  firstName in id._2.readString("Enter first name:")
  lastName in firstName._2.readString("Enter last name:")
} return new Tuple<>(Person.apply(id._1, firstName._1,
                                             lastName._1), lastName._2))

但你实际上并不需要这种语法糖。flatMap 习语可能一开始比较难掌握,但它确实展示了正在发生的事情。

顺便说一句,许多程序员都知道这个模式如下:

a.flatMap(b -> flatMap(c -> map(d -> getSomething(a, b, c, d))))

他们常常认为它总是以一系列的 flatMap 结尾,最后是 map。这绝对不是事实。它是否以 mapflatMap 结尾完全取决于返回类型。通常情况下,最后一个方法(这里指的是 getSomething)返回一个裸值,这就是为什么模式以 map 结尾。但如果 getSomething 返回一个上下文(例如 Result),模式如下:

a.flatMap(b -> flatMap(c -> flatMap(d -> getSomething(a, b, c, d))))

13.2.2. 从文件中读取

你设计的程序使得将其适应读取文件变得非常简单。FileReader 类与 ConsoleReader 非常相似。唯一的区别是静态工厂方法必须处理 IOException,因此它返回 Result<Input> 而不是裸值。

列表 13.7. FileReader 的实现
import com.fpinjava.common.Result;
import java.io.*;

public class FileReader extends AbstractReader {

  private FileReader(BufferedReader reader) {
    super(reader);
  }

  public static Result<Input> fileReader(String path) {
    try {
      return Result.success(new FileReader(new BufferedReader(
        new InputStreamReader(new FileInputStream(new File(path))))));
    } catch (Exception e) {
      return Result.failure(e);
    }
  }
}

练习 13.4

编写一个 ReadFile 程序,类似于 ReadConsole,但它从包含条目的文件中读取,每行一个条目。本书附带代码中提供了一个示例文件(github.com/fpinjava/fpinjava)。

提示

虽然它与 ReadConsole 程序类似,但你必须处理工厂方法返回 Result 的事实。尽量重用相同的 person 方法。

解决方案 13.4

解决方案在 列表 13.8 中给出。注意在调用 person 方法之前如何处理工厂方法返回的 Result,这允许你使用与 ConsoleReader 相同的方法。(你也可以使用不带任何参数的 read 方法。)

列表 13.8. ReadFile 的实现

图片

13.2.3. 使用输入进行测试

在前一个解决方案中采取的方法的一个好处是程序很容易测试。当然,你可以通过在控制台提供文件而不是用户输入来测试你的程序,但与将程序与生成输入命令脚本的另一个程序接口一样容易。以下列表显示了一个可以用于测试的示例 ScriptReader

列表 13.9. 允许使用输入命令列表的 ScriptReader
public class ScriptReader implements Input {

  private final List<String> commands;

  public ScriptReader(List<String> commands) {
    super();
    this.commands = commands;
  }

  public ScriptReader(String... commands) {
    super();
    this.commands = List.list(commands);
  }

  public Result<Tuple<String, Input>> readString() {
    return commands.isEmpty()
        ? Result.failure("Not enough entries in script")
        : Result.success(new Tuple<>(commands.headOption().getOrElse(""),
                                      new ScriptReader(commands.drop(1))));
  }

  @Override
  public Result<Tuple<Integer, Input>> readInt() {
    try {
      return commands.isEmpty()
          ? Result.failure("Not enough entries in script")
          : Integer.parseInt(commands.headOption().getOrElse("")) >= 0
              ? Result.success(new Tuple<>(Integer.parseInt(
                                   commands.headOption().getOrElse("")),
                                      new ScriptReader(commands.drop(1))))
              : Result.empty();
    } catch(Exception e) {
      return Result.failure(e);
    }
  }
}

下一个列表显示了一个使用 ScriptReader 类的示例。在本书的代码中,你可以找到单元测试的示例。

列表 13.10. 使用 ScriptReader 输入数据
public class ReadScriptReader {

  public static void main(String... args) {
    Input input = new ScriptReader(
        "0", "Mickey", "Mouse",
        "1", "Minnie", "Mouse",
        "2", "Donald", "Duck",
        "3", "Homer", "Simpson"
    );

    Stream<Person> stream =
                Stream.unfold(input, ReadScriptReader::person);
    stream.toList().forEach(System.out::println);
  }

  public static Result<Tuple<Person, Input>> person(Input input) {
    return input.readInt("Enter ID:")
      .flatMap(id -> id._2.readString("Enter first name:")
         .flatMap(firstName -> firstName._2.readString("Enter last name:")
            .map(lastName -> new Tuple<>(Person.apply(id._1, firstName._1,
                                             lastName._1), lastName._2))));
  }
}

13.3. 真正的功能性输入/输出

你到目前为止学到的知识对于大多数 Java 程序员来说已经足够了。将程序的函数部分与非函数部分分开是必要的,也是足够的。但是,了解 Java 程序如何变得更加函数化是非常有趣的。

你是否在 Java 程序的生产环境中使用以下技术取决于你。这可能不值得额外的复杂性。然而,学习这些技术是有用且有趣的,这样你可以做出明智的选择。

13.3.1. 如何使输入/输出完全函数化?

对于这个问题有几个答案。最简短的答案是:它不能。根据我们对函数式程序的定义,即“一个除了返回值外没有其他可观察效果的程序”,无法进行任何输入或输出。

但是,许多程序不需要进行任何输入或输出。例如,许多库都属于这一类。库是设计为供其他程序使用的程序。它们接收参数值,并根据它们的参数返回计算结果。在本章的前两节中,你将你的程序分为三个部分:一个进行输入,一个进行输出,第三个部分作为库,并且完全函数化。

处理问题的另一种方法是编写这个库部分,并生成一个最终返回值,即另一个(非功能性的)程序,该程序处理所有输入和输出。这在概念上与惰性非常相似。你可以将输入和输出视为将来在单独的程序中发生的事情,该程序将是你的纯函数式程序的返回值。

13.3.2. 实现纯函数式输入/输出

在本节中,你将了解如何实现纯函数式输入/输出。让我们从输出开始。想象一下,你只想在控制台显示一条欢迎信息。目前,你将假设你已经知道要使用的信息名称。而不是编写这个

static void sayHello(String name) {
   System.out.println("Hello, " + name + "!");
}

我们可以使得sayHello方法返回一个程序,一旦运行,就会产生相同的效果。为此,你可能使用 lambda 和Runnable接口,如下所示:

static Runnable sayHello(String name) {
    return () -> System.out.println("Hello, " + name + "!");
}

你可以使用以下方法使用此方法:

public static void main(String... args) {
  Runnable program = sayHello("Georges");
}

这段代码是纯函数式的。你可以争论说它没有做任何可见的事情,这是真的。它生成一个可以运行以产生所需效果的程序。这个程序可以通过调用它生成的Runnable对象的run方法来运行。返回的程序不是函数式的,但你并不关心。你的程序是函数式的。

这是在作弊吗?不是。想想任何“函数式”语言编写的程序。最终,它被编译成一个可执行的程序,这个程序绝对不是函数式的,并且可以在你的计算机上运行。你正在做的是完全相同的事情,只是你生成的程序可能看起来像是用 Java 编写的。实际上,它不是。它是用某种类型的 DSL(领域特定语言)编写的,你的程序正在构建这种语言。

要执行这个程序,你可以简单地写下:

program.run();

注意,大多数代码检查程序不会喜欢在Runnable上调用run的事实。这就是为什么在之前的章节中,你创建了Executable接口来做同样的事情。

在这里,你需要更强大的功能,所以你会创建一个新的接口名为IO。你将从单个run方法开始。在这个阶段,它与Runnable没有区别:

public interface IO {
  void run();
}

假设你有以下三个方法:

static IO println(String message) {
  return () -> System.out.print(message);
}

static <A> String toString(Result<A> rd) {
  return rd.map(Object::toString).getOrElse(rd::toString);
}

static Result<Double> inverse(int i) {
  return i == 0
      ? Result.failure("Div by 0")
      : Result.success(1.0 / i);
}

你可能会编写以下纯函数式程序:

IO computation = println(toString(inverse(3)));

这个程序生成另一个可以稍后执行的程序:

computation.run();

13.3.3. 结合 IO

使用你的IO接口,你可以构建任何程序,但作为一个单一单元。能够组合这样的程序将很有趣。你可以使用的最简单的组合是将两个程序组合成一个。这就是你将在以下练习中做的。

练习 13.5

IO接口中创建一个方法,允许你将两个IO实例组合成一个。这个方法将被称为add,并且它将有一个默认实现。以下是签名:

default IO add(IO io)

解决方案 13.5

解决方案很简单,就是返回一个新的IO,它有一个run实现,首先执行当前的IO,然后执行参数IO

default IO add(IO io) {
  return () -> {
    IO.this.run();
    io.run();
  };
}

你稍后需要一个“什么也不做”的IO来作为某些IO组合的中性元素。这可以在 IO 接口中轻松创建,如下所示:

IO<Nothing> empty = () -> Nothing.instance;

使用这些新方法,你可以通过组合IO实例来创建更复杂的程序:

当然,你可以简化这个过程:

println("Hello, ").add(println(name)).add(println("!\n")).run();

你也可以从一系列指令创建一个程序:

List<IO> instructions = List.list(
    println("Hello, "),
    println(name),
    println("!\n")
);

这看起来像是一个命令式程序吗?实际上,它是。为了“编译”它,你可能使用一个右折叠:

IO program = instructions.foldRight(IO.empty(), io -> io::add);

或者一个左折叠:

IO program = instructions.foldLeft(IO.empty(), acc -> acc::add);

你可以看到为什么需要一个“什么也不做”的实现。最后,你可以像通常一样运行程序:

program.run();

13.3.4. 使用 IO 处理输入

到目前为止,你的IO类型只能处理输出。为了使其能够处理输入,一个必要的更改是使用输入值的类型对其进行参数化,以便它可以用来处理这个值。以下是新的参数化IO类型:

如你所见,IO接口以与OptionResultListStreamState等类似的方式创建计算上下文。它同样有一个返回空实例的方法,以及一个将裸值放入上下文的方法。

为了在IO值上执行计算,你现在需要像mapflatMap这样的方法来将函数绑定到IO上下文。

练习 13.6

IO<A>中定义一个map方法,它接受一个从AB的函数作为其参数,并返回一个IO<B>。在IO接口中将其作为默认实现。

解决方案 13.6

这是实现,它将函数应用于this的值,并在新的IO上下文中返回结果:

default <B> IO<B> map(Function<A, B> f) {
  return () -> f.apply(this.run());
}

练习 13.7

编写一个flatMap方法,它接受一个从AIO<B>的函数作为其参数,并返回一个IO<B>

提示

不要担心潜在的栈问题。您将在以后处理这个问题。

解决方案 13.7

将函数应用于运行 thisIO 获得的值将给出 IO<IO<B>>。您需要展开这个结果,这可以通过简单地运行它来完成,如下所示:

default <B> IO<B> flatMap(Function<A, IO<B>> f) {
  return () -> f.apply(this.run()).run();
}

如您所见,这有点递归。一开始这不会是问题,因为只有一个递归步骤,但如果您要链式调用大量的 flatMap 调用,它可能会成为问题。

要查看您的新方法如何工作,请使用以下 Console 类。

列表 13.11. Console

重要的是要注意,这两个方法完全是函数式的。它们不会抛出任何异常,也不会从控制台读取或打印。它们只返回执行这些操作的程序。

要看到这个程序的工作情况,您可以运行以下示例程序。

列表 13.12. 以纯函数方式从控制台读取和打印

13.3.5. 扩展 IO 类型

通过使用 IO 类型,您可以在纯函数方式中创建不纯的程序(具有效果的程序)。但在这个阶段,这些程序只能让我们从类似 Console 类的元素中读取和打印。您可以通过添加创建控制结构(如循环和条件)的指令来扩展您的 DSL。

首先,您将实现一个类似于 for 索引循环的循环。这将采取 repeat 方法的形式,该方法接受迭代次数和要重复的 IO 作为参数。

练习 13.8

IO 接口中将 repeat 实现为一个静态方法,其签名如下:

static <A> IO<List<A>> repeat(int n, IO<A> io)
提示

您应该创建一个表示每个迭代的 IO 实例的集合,然后通过组合 IO 实例来折叠这个集合。为此,您需要比 add 方法更强大的功能。首先,实现一个具有以下签名的 map2 方法:

static <A, B, C> IO<C> map2(IO<A> ioa, IO<B> iob,
                                        Function<A, Function<B, C>> f)

解决方案 13.8

map2 方法可以按以下方式实现:

static <A, B, C> IO<C> map2(IO<A> ioa, IO<B> iob,
                                        Function<A, Function<B, C>> f) {
  return ioa.flatMap(a -> iob.map(b -> f.apply(a).apply(b)));
}

这是一个普遍存在的理解模式的简单应用。有了这个方法,您可以轻松地实现 repeat,如下所示:

static <A> IO<List<A>> repeat(int n, IO<A> io) {
  return Stream.fill(n, () -> io)
    .foldRight(() -> unit(List.list()), ioa -> sioLa -> map2(ioa,
                               sioLa.get(), a -> la -> List.cons(a, la)));
}

注意您使用 Stream.fill() 方法创建流,该方法具有以下签名:

public static <T> Stream<T> fill(int n, Supplier<T> elem)

它返回一个包含 T 类型 n 个(懒加载)实例的 Stream

这可能看起来有点复杂,但部分原因是由于打印时换行,部分原因是它被写成一行以进行优化。它与以下内容等效:

static <A> IO<List<A>> repeat(int n, IO<A> io) {
  Stream<IO<A>> stream = Stream.fill(n, () -> io);
  Function<A, Function<List<A>, List<A>>> f = a -> la -> List.cons(a, la);
  Function<IO<A>, Function<Supplier<IO<List<A>>>, IO<List<A>>>> g =
                                 ioa -> sioLa -> map2(ioa, sioLa.get(), f);
  Supplier<IO<List<A>>> z = () -> unit(List.list());
  return stream.foldRight(z, g);
}

如果您使用的是 IDE,找到类型相对容易。例如,在 IntelliJ 中,您只需在按住 Ctrl 键的同时将鼠标指针放在引用上,就可以显示类型。

使用这些方法,您现在可以编写以下代码:

IO program = IO.repeat(3, sayHello());

这将给出一个与调用以下方法作为 sayHello(3) 相对应的程序:

private static void sayHello(int n) throws IOException {

  BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

  for (int i = 0; i < n; i++) {
    System.out.println("Enter your name: ");
    String name = br.readLine();
    System.out.println(buildMessage(name));
  }
}

然而,非常重要的区别是,调用sayHello(3)会三次积极地执行效果,而IO.repeat(3, sayHello())将简单地返回一个(未评估的)程序,它只有在调用其run方法时才会执行相同的效果。

可以定义许多其他控制结构。你可以在附带的代码中找到示例,这些代码可以从github.com/fpinjava/fpinjava下载。以下列表显示了使用whendoWhile方法的示例,这些方法与命令式 Java 中的ifwhile做完全相同的事情。

列表 13.13. 使用IO封装命令式编程
public class Main {

  public static void main(String... args) throws IOException {

    IO program = program(buildMessage,
                         "Enter the names of the persons to welcome:");
    program.run();
  }

  public static IO<Nothing> program(Function<String, IO<Boolean>> f,
                                                         String title) {
    return IO.sequence(
        Console.printLine(title),
        IO.doWhile(Console.readLine(), f),
        Console.printLine("bye!")
    );
  }

  private static Function<String, IO<Boolean>> buildMessage =
            name -> IO.when(name.length() != 0,
               () -> IO.unit(String.format("Hello, %s!", name))
      .flatMap(Console::printLine));
}

这个例子并不是建议你应该这样编程。当然,最好只使用IO类型进行输入和输出,在函数式编程中完成所有计算。毕竟,如果你选择学习函数式编程,可能不是为了在函数式代码中实现命令式语言。但作为一个练习,了解它是如何工作的,这是很有趣的。

13.3.6. 使IO类型堆栈安全

在之前的练习中,你可能没有注意到一些IO方法使用了与递归方法相同的方式使用栈。例如,repeat方法如果重复次数太高,就会导致栈溢出。"太高"的具体数值取决于栈的大小以及当方法返回的程序运行时栈的满载程度。(到现在为止,我期望你已经理解调用repeat方法不会导致栈溢出。只有运行它返回的程序才可能这样做。)

练习 13.9

为了实验栈溢出,创建一个接受IO作为参数并返回一个在无限循环中执行该参数的新IOforever方法。以下是相应的签名:

static <A, B> IO<B> forever(IO<A> ioa)

解决方案 13.9

这就像它的无用性一样简单实现!你所要做的就是使构造的程序无限递归。请注意,forever方法本身不应该递归。只有返回的程序应该递归。解决方案是使用Supplier,并将IO参数与执行get操作的SupplierIO进行flatMap

static <A, B> IO<B> forever(IO<A> ioa) {
  Supplier<IO<B>> t = () -> forever(ioa);
  return ioa.flatMap(x -> t.get());
}

这个方法可以这样使用:

public static void main(String... args) {
  IO program = IO.forever(IO.unit("Hi again!")
                            .flatMap(Console::printLine));
  program.run();
}

它在几千次迭代后会溢出栈。请注意,这与以下代码等效:

IO.forever(Console.printLine("Hi again!")).run();

如果你不知道为什么会导致栈溢出,可以考虑以下伪代码(这个代码无法编译!)其中t变量被相应的表达式替换:

static <A, B> IO<B> forever(IO<A> ioa) {
  return ioa.flatMap(x -> (() -> forever(ioa)).get());
}

现在,让我们用相应的代码替换递归调用:

static <A, B> IO<B> forever(IO<A> ioa) {
  return ioa.flatMap(x -> (() -> ioa.flatMap(x -> (() -> forever(ioa)).get())).get());
}

你可以无限递归地继续。 (记住,你不应该尝试编译这段代码!)你可能注意到flatMap的调用将是嵌套的,每次调用都会将当前状态推入栈中,这确实会在几千步后导致栈溢出。与命令式代码不同,在命令式代码中你会依次执行一条指令,你调用flatMap方法递归。

要使IO堆栈安全,你可以使用与你在第四章中创建堆栈安全递归方法和函数相同的技巧。首先,你需要表示你程序的三种状态:

  • Return将表示一个完成的计算,这意味着你只需返回结果。

  • Suspend将表示一个挂起的计算,当在恢复当前计算之前必须应用某些效果时。

  • Continue将表示一个程序必须首先应用子计算然后再继续下一个的状态。

这些状态将由以下三个类表示列表 13.14。

注意

列表 13.14 至列表 13.16 是整体的一部分。它们不应该与迄今为止构建的代码一起使用,而应该一起使用。

列表 13.14. 使IO堆栈安全所需的三个类

必须对封装的IO接口进行一些修改,如列表 13.15 和 13.16 所示。

列表 13.15. IO堆栈安全版本的更改

列表 13.16. 堆栈安全的run方法

新的堆栈安全版本可以使用如下方式。

列表 13.17. 使用堆栈安全版本的Console
public class Console {

  private static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

  public static IO<String> readLine(Nothing nothing) {
    return new IO.Suspend<>(() -> {
      try {
        return br.readLine();
      } catch (IOException e) {
        throw new IllegalStateException((e));
      }
    });
  }

  /**
   * A possible implementation of readLine as a function
   */
  public static Function<Nothing, IO<String>> readLine_ = x -> new IO.Suspend<>(() -> {
    try {
      return br.readLine();
    } catch (IOException e) {
      throw new IllegalStateException((e));
    }
  });

  /**
   * A simpler implementation of readLine as a function using a method reference
   */
  public static Function<Nothing, IO<String>> readLine = Console::readLine;

  /**
   * A convenience helper method allowing calling the readLine method without
   * providing a Nothing.
   */
  public static IO<String> readLine() {
    return readLine(Nothing.instance);
  }

  public static IO<Nothing> printLine(Object s) {
    return new IO.Suspend<>(() -> println(s));
  }

  private static Nothing println(Object s) {
    System.out.println(s);
    return Nothing.instance;
  }

  public static IO<Nothing> printLine_(Object s) {
    return new IO.Suspend<>(() -> {
      System.out.println(s);
      return Nothing.instance;
    });
  }
  public static Function<String, IO<Nothing>> printLine_ =
            s -> new IO.Suspend<>(() -> {
              System.out.println(s);
              return Nothing.instance;
            });

  public static Function<String, IO<Nothing>> printLine = Console::printLine;
}

现在你可以使用foreverdoWhile而不用担心堆栈溢出。你也可以重写repeat使其堆栈安全。这里我不会展示新的实现,但你可以从配套代码(github.com/fpinjava/fpinjava)中找到它。

请记住,这并不是编写函数式程序的建议方式。将其视为一个最终可以完成的例子,而不是作为良好实践。此外,请注意,“最终”在这里适用于 Java 编程。使用更函数式友好的语言,你可以构建更强大的程序。

13.4. 摘要

  • 可以将效果传递到ListResult和其他上下文中,安全地应用于值,而不是从这些上下文中提取值并在外部应用效果,如果没有值,这可能会导致错误。

  • 成功和失败两种不同效果的处理可以抽象在Result类型内部。

  • 读取数据可以像在第十二章中生成随机数一样进行。

  • 从文件中读取的方式与从控制台或通过Reader抽象从内存中读取的方式完全相同。

  • 通过IO类型可以获得更多功能的输入/输出。

  • IO类型可以扩展为一个更通用的类型,这使得通过构建稍后将要执行的程序以函数式方式执行任何命令式任务成为可能。

  • 可以通过使用与堆栈安全递归方法和函数相同的技巧来使IO类型堆栈安全。

第十四章。使用演员共享可变状态

本章涵盖的内容

  • 理解演员模型

  • 使用异步消息

  • 构建演员框架

  • 将演员投入工作

  • 优化演员表现

在处理这本书的过程中,你首先了解到函数式编程通常处理不可变数据,这导致程序更安全、更可靠,并且更容易设计和扩展。然后你了解到可以通过将状态作为函数的参数传递来以函数式方式处理可变状态。你看到了几个这种技术的例子:

  • 在生成随机数时传递生成器允许提高测试性。

  • 将控制台作为参数传递允许你将功能输出发送到屏幕,并从键盘接收输入。

这种技术可以广泛应用于许多领域。在命令式编程中,解析文件通常通过持续修改表示解析结果的组件的状态来处理。为了使此过程与函数式编程兼容,你只需将状态作为所有解析函数的附加参数传递即可。日志记录也可以以相同的方式进行,以及监控性能:而不是在每个函数中写入日志文件,你可以让函数接收日志文件作为参数,并将增强的文件作为结果的一部分返回。

这种方法的好处是,它让你在访问资源时不必关心同步和锁定。但这种安全性是通过防止数据共享获得的。这是好事,因为它迫使你找到其他更安全的方法来做事情。使用不可变列表不会自动为涉及共享这些列表的操作增加安全性。它只是阻止你共享可变状态。它允许你以某种方式模拟列表的修改,这大致对应于制作防御性副本,但不会带来性能损失。这很有用,但有时这并不是你需要的。

假设你想要计算一个函数被调用的次数。在一个单线程应用程序中,你可能会通过将计数器添加到函数参数中,并将增加的计数器作为结果的一部分返回来做这件事。但大多数命令式程序员更愿意将计数器作为副作用来增加。这将无缝进行,因为只有一个线程,所以不需要锁定来防止潜在的并发访问。这就像生活在一个荒岛上。如果你是唯一的居民,那么在门上上锁实际上真的没有必要。

但在多线程程序中,你如何以安全的方式增加计数器,避免并发访问?答案通常是使用锁或使操作原子化,或者两者兼而有之。

在函数式编程中,共享资源必须作为一个效果来完成,这意味着,或多或少,每次你访问共享资源时,你都必须离开函数式安全,并将这种访问视为你在第十三章中处理输入/输出时所做的。这意味着你必须管理锁和同步吗?绝对不是。正如你在前面的章节中学到的,函数式编程也是关于将抽象推向极限。共享可变状态可以被抽象化,这样你就可以使用它而不用担心那些令人毛骨悚然的细节。实现这一点的一种方法就是使用演员框架。

与前面的章节不同,在这里你不会开发一个真实、完整的演员框架。创建一个完整的演员框架是一项如此巨大的工作,你可能会选择使用现有的一个。在这里,你将开发一个最小的演员框架,这将给你带来演员框架为函数式编程带来的感觉。

14.1. 演员模型

在演员模型中,一个多线程应用程序被划分为基本单线程的组件,称为演员。如果每个演员都是单线程的,那么它不需要使用锁或同步来共享数据。演员通过效果与其他演员进行通信,就像这种通信是输入/输出一样。这意味着演员依赖于一种序列化他们接收到的消息的机制。(在这里,“序列化”意味着依次处理一个消息。这不要与 Java 序列化混淆。)由于这种机制,它们可以一次处理一个消息,而无需担心它们资源的并发访问。因此,演员系统可以看作是一系列通过效果相互通信的功能程序。每个演员都可以是单线程的,因此内部没有资源的并发访问。并发性在框架内部被抽象化。

14.1.1. 异步消息传递

作为消息处理的一部分,演员可以向其他演员发送消息。消息是以异步方式发送的,这意味着不需要等待答案。一旦消息被发送,发送者就可以继续其工作,这主要是由处理它接收到的消息队列中的消息组成。当然,处理消息队列意味着需要对队列进行一些并发访问的管理。但这种管理在演员框架中被抽象化,所以你,程序员,不需要担心这一点。

当然,可能需要消息的答案。假设一个演员负责一项长期计算。客户端可以利用异步性,在计算被处理的同时继续自己的工作。但是一旦计算完成,客户端必须有一种方式来接收结果。这很简单,就是让负责计算的演员回调其客户端,并以异步方式发送结果。请注意,客户端可能是原始发送者,尽管这并不总是必须的。

14.1.2. 处理并行化

演员模型允许通过使用一个负责将任务分解为子任务并将它们分配给多个工作演员的管理演员来并行化任务。每次一个工作演员将结果返回给管理演员时,它都会得到一个新的子任务。这种模型相较于其他并行化模型的优势在于,没有任何工作演员会在子任务列表为空之前处于空闲状态。缺点是管理演员不会参与计算。但在实际应用中,这通常不会造成明显的差异。

对于某些任务,当收到子任务的结果时可能需要重新排序。在这种情况下,管理演员可能会将结果发送给负责这项工作的特定演员。你可以在第 14.2.3 节中看到一个例子。在小程序中,管理器本身可以处理这个任务。在图 14.1 中,这个演员被称为Receiver

图 14.1. Main 演员产生主要任务并将其发送给Manager 演员,

图片

14.1.3. 处理演员状态变更

演员(actors)可以是无状态的(不可变的)或有状态的,这意味着它们应该根据接收到的消息来改变它们的状态。例如,一个同步器演员可能会接收到需要在使用前重新排序的计算结果。

想象一下,例如,您有一个必须经过大量计算才能提供结果列表的数据列表。简而言之,这是一个映射。可以通过将列表拆分为几个子列表并将这些子列表分配给工作演员进行处理来并行化。但是,不能保证工作演员将以与分配给他们的任务相同的顺序完成他们的工作。为了重新同步结果的一个解决方案是对任务进行编号。当工作演员发送回结果时,它会添加相应的任务编号,这样接收者就可以将结果放入优先队列。这不仅允许自动排序,而且还使得将结果作为异步流处理成为可能。每次接收者收到一个结果时,它会将任务编号与预期编号进行比较。如果匹配,它将结果传递给客户端,然后查看优先队列以确定第一个可用的结果是否对应于新的预期任务编号。如果再次匹配,则出队过程将继续,直到不再匹配。如果接收到的结果与预期结果编号不匹配,它将简单地添加到优先队列中。

在这样的设计中,接收的演员必须处理两块可变数据:优先队列和预期结果编号。这意味着演员必须使用可变属性吗?这并不是什么大问题,但鉴于演员是单线程的,这甚至不是必要的。正如您将看到的,属性变更的处理可以包含并抽象为一个通用的状态变更过程,允许程序员仅使用不可变数据。

14.2. 构建演员框架

在本节中,您将学习如何构建一个最小但功能齐全的演员框架。在构建此框架的过程中,您将了解演员框架如何允许安全地共享可变状态、易于且安全的并行化和反序列化,以及应用程序的模块化架构。在本章结束时,您将看到一些您可以使用演员框架执行的一般性操作。

您的演员框架将由四个组件组成:

  • Actor 接口将确定演员的行为。

  • AbstractActor 类将包含所有演员共有的内容。这个类必须由业务演员扩展。

  • ActorContext 将作为访问演员的一种方式。在您的实现中,这个组件将非常简约,主要用于访问演员行为。在这个小型实现中,这个组件实际上并不是必需的,但大多数严肃的实现都会使用这样的组件。这个上下文允许,例如,搜索可用的演员。

  • MessageProcessor 接口将是您为任何必须处理接收到的消息的组件实现的接口。

14.2.1. 此演员框架的限制

正如我所说,你在这里创建的实现是极简的;将其视为理解和使用 actor 模型的一种方式。你将缺少许多(大多数?)真实 actor 系统的功能,尤其是那些与 actor 上下文相关的功能。另一个简化是,每个 actor 将被映射到一个单独的线程。在真实的 actor 系统中,actor 会被映射到线程池中,允许数千甚至数百万个 actor 在几十个线程上运行。

你实现的另一个限制是,大多数 actor 框架允许以透明的方式处理分布式 actor,这意味着你可以使用运行在不同机器上的 actor,而不必关心通信。这当然使 actor 框架成为构建可扩展应用的理想方式。我们不会处理这个方面。

14.2.2. 设计 actor 框架接口

首先,你需要定义将构成你的 actor 框架的接口。当然,最重要的是定义Actor接口,它将定义几个方法。这个接口的主要方法是

void tell(T message, Result<Actor<T>> sender)

这个方法用于向这个 actor(即持有这个方法的 actor)发送消息。当然,这意味着要向 actor 发送消息,你必须有它的引用。(这与真实的 actor 框架不同,在真实的 actor 框架中,消息不是发送给 actor,而是发送给 actor 引用、代理或其他替代品。没有这个增强,就无法向远程 actor 发送消息。)这个方法将Result<Actor>作为第二个参数。它应该表示发送者,但有时会被设置为无人(空结果)或不同的 actor。

其他方法用于管理 actor 的生命周期,以简化 actor 的使用,如列表 14.1 所示。请注意,这段代码并不是为了使用之前章节的练习结果,而是使用本书附带代码中的fpinjava-common模块(github.com/fpinjava/fpinjava)。这基本上与练习的解决方案相同,但增加了一些方法。

列表 14.1. Actor接口

图片

图片

下面的列表显示了另外两个必要的接口:ActorContextMessageProcessor

列表 14.2. ActorContextMessageProcessor接口

图片

这里最重要的元素是ActorContext接口。become方法允许 actor 改变其行为,即处理消息的方式。如你所见,actor 的行为看起来像是一个效果,它的参数是一个由要处理的消息和发送者组成的对。

在应用程序的生命周期中,每个演员的行为将被允许改变。通常,这种行为的变化将由演员状态的修改引起,用新的行为替换原始行为。一旦您看到实现,这将更加清晰。

14.2.3. 抽象演员实现

AbstractActor 实现代表了所有演员实现中共同的部分。所有的消息管理操作都是共同的,并由演员框架提供,这样您就只需要实现业务部分。AbstractActor 实现如下所示。

列表 14.3. AbstractActor 实现列表

图片

图片

注意,如果演员是单线程的,Executor 将使用单个线程执行器初始化,这是最一般的情况,或者如果它是多线程的,则使用缓存线程池。线程池使用守护线程工厂创建,以便在主线程终止时自动关闭。

您的演员框架现在已经完成,尽管如我之前提到的,这并不是生产代码。这是一个最小示例,用来展示一个演员框架可能的工作方式。

14.3. 使用演员

现在您已经拥有了一个可用的演员框架,是时候将其应用于一些具体问题上了。当多个线程需要共享一些可变状态时,演员非常有用,例如当一个线程产生计算结果,而这个结果必须传递给另一个线程进行进一步处理时。通常,这种可变状态共享是通过在共享的可变属性中存储值来完成的,这暗示了锁定和同步。我们首先将查看一个最小的演员示例,这可以被认为是演员的“Hello, World!”。然后我们将研究一个更完整的应用,其中使用演员将任务分配给其他并行工作的演员。

第一个示例是一个最小、传统的示例,用于测试演员。它由两个乒乓球运动员和一个裁判员组成。游戏开始时,代表整数的球被交给一个玩家。然后每个玩家将球发送给另一个玩家,直到发生十次,此时球被交还给裁判员。

14.3.1. 实现乒乓球示例

首先,您将实现裁判员。您需要做的只是创建一个演员,实现其 onReceive 方法。在这个方法中,您将显示一条消息:

Actor<Integer> referee =
         new AbstractActor<Integer>("Referee", Actor.Type.SERIAL) {
  @Override
  public void onReceive(Integer message, Result<Actor<Integer>> sender) {
    System.out.println("Game ended after " + message + " shots");
  }
};

接下来,您必须创建两个玩家。因为有两个实例,您不会将它们创建为匿名类。您将创建一个 Player 类。

列表 14.4. Player 演员列表

图片

图片

创建了Player类后,您就可以完成您的程序了。但是,您需要一种方法来保持应用程序运行,直到游戏结束。如果没有这个,主应用程序线程将在游戏开始时终止,玩家将没有机会玩游戏。这可以通过使用信号量来实现,如下所示。

列表 14.5. Ping Pong 示例

程序显示以下输出:

Ping - 1
Pong - 2
Ping - 3
Pong - 4
Ping - 5
Pong - 6
Ping - 7
Pong - 8
Ping - 9
Pong - 10
Game ended after 10 shots

14.3.2. 一个更严重的例子:并行运行计算

现在是时候看看一个更严肃的演员框架实例:并行运行计算了。为了模拟长时间运行的计算,您将选择一个介于 0 到 30 之间的随机数字列表,并使用慢速算法计算相应的斐波那契值。应用程序将由三种类型的演员组成:一个Manager,负责创建指定数量的工作演员并将任务分配给他们;几个工作实例;以及一个客户端,它将在主程序类中以匿名演员的形式实现。以下列表显示了这些类中最简单的一个,即Worker演员。

列表 14.6. 负责运行计算部分的Worker演员

如您所见,这个演员是无状态的。它计算结果并将其发送回它接收引用的发送者。请注意,这可能不同于调用者。因为数字是在 0 到 30 之间随机选择的,所以计算结果所需的时间将高度可变。这模拟了执行时间可变的任务。与第八章中自动并行化的例子不同,除了没有更多任务要处理外,所有线程/演员都将保持忙碌,直到整个计算完成。

Manager类稍微复杂一些。以下列表显示了类的构造函数和初始化的属性。

列表 14.7. Manager类的构造函数和属性

如您所见,如果计算完成,结果将被添加到结果列表中并发送给客户端。否则,结果将被添加到当前结果列表中。在传统程序中,这将通过修改Manager将保留的结果列表来完成。这正是这里发生的事情,除了两个区别:

  • 结果列表存储在行为中。

  • 行为和列表都没有被修改。相反,创建了一个新的行为,并将上下文修改为持有这个新行为,以替换旧的行为。然而,您不必处理这种修改。就您而言,一切都是不可变的,因为修改被演员框架抽象化了。

以下列表显示了作为内部类实现的Behavior类。

列表 14.8. Behavior内部类允许你抽象演员的突变

这涵盖了Manager的主要部分。其余部分由主要用于启动工作的实用方法组成。

列表 14.9. Manager的实用方法,用于启动处理

重要的是要理解onReceive方法代表了演员在接收到它的第一条消息时将执行的操作。当工人将结果发送给经理时,此方法不会被调用。

程序的最后部分显示在列表 14.10 中。WorkersExample类代表应用程序的客户端代码。但与ManagerWorker不同,它不是一个演员。相反,它拥有一个演员。这是一个实现选择。没有具体的原因选择一个解决方案或另一个。但是,为了接收结果,客户端演员是必要的。

列表 14.10. 客户端应用程序

你可以用各种长度的任务列表和各种工作演员的数量运行这个程序。在我的八核 Linux 机器上,以 200,000 个任务长度运行的结果如下:

  • 一个工作演员:3.5 秒

  • 两个工作演员:1.5 秒

  • 三个工作演员:1.1 秒

  • 四个工作演员:0.8 秒

  • 六个工作演员:0.8 秒

  • 八个工作演员:0.8 秒

  • 十六个工作演员:0.8 秒

当然,这些数字并不非常精确,但它们表明使用与可用核心数相对应的线程数是无用的。程序显示的结果如下(仅显示前 40 个结果):

Input: [0, 11, 28, 13, 20, 5, 15, 8, 24, 19, 12, 7, 11, 4, 18, 20, 26,
    21, 15, 21, 29, 16, 15, 8, 22, 11, 26, 1, 22, 13, 25, 3, 13, 24, 29,
    10, 7, 26, 24, 1, NIL]
Time: 797
Result: [1, 8, 28657, 34, 196418, 34, 987, 987, 1597, 832040, 28657,
    17711, 987, 377, 1, 17711, 196418, 377, 10946, 4181, 5, 6765, 144,
    21, 75025, 233, 832040, 89, 144, 75025, 514229, 21, 377, 1, 10946,
    3, 17711, 196418, 144, 1597, NIL]

如你所见,你遇到了问题!

14.3.3. 重新排序结果

正如你可能已经注意到的,结果是不正确的。当查看第三个和第五个随机值(28 和 29)以及相应的结果(28,657 和 196,418)时,这一点很明显。你也可以比较 4 和 6 的值和结果。当参数值为 13 和 5 时,结果都是 34。请注意,如果你在自己的计算机上运行程序,你会得到不同的结果。

这里发生的情况是,不是所有任务执行所需的时间都相同。我选择以这种方式执行计算,以便一些任务(低参数值的计算)可以快速返回,而其他任务(高值的计算)则需要更长的时间。因此,返回的值没有按正确的顺序排列。

为了解决这个问题,你需要按与相应参数相同的顺序对结果进行排序。一种解决方案是使用你在第十一章中开发的Heap数据类型。你可以对每个任务进行编号,并使用这个编号作为优先队列中的优先级。

你必须改变工作演员的类型。他们不再处理整数,而必须处理整数的元组:一个整数表示参数或计算,另一个表示任务号。下面的列表显示了 Worker 类中相应的更改。

列表 14.11. Worker 演员跟踪任务号

注意,任务号是元组的第二个元素。考虑到任务号和计算参数的类型相同(Integer),这并不容易阅读和记忆。在现实生活中,这种情况不应该发生,因为你应该为任务使用特定的类型。但如果你愿意,你也可以使用特定的类型来包装任务和任务号,例如具有数字属性的 Task 类型。

Manager 类的更改更多。首先,你必须更改类类型以及 workList 和结果属性的 类型:

public class Manager extends AbstractActor<Tuple<Integer, Integer>> {

  ...

  private final List<Tuple<Integer, Integer>> workList;
  private final Heap<Tuple<Integer, Integer>> resultHeap;

这些属性在构造函数中如下初始化:

Tuple<List<Tuple<Integer, Integer>>, List<Tuple<Integer, Integer>>>
           splitLists = list.zipWithPosition().splitAt(this.workers);
this.initial = splitLists._1;
this.workList = splitLists._2;
this.resultHeap = Heap.empty((t1, t2) -> t1._2.compareTo(t2._2));

workList 现在包含元组(正如前一个例子中 initial 列表的情况),结果是元组的优先队列(Heap)。请注意,这个 Heap 是基于元组的第二个元素的比较来初始化的 Comparator。使用一个同时包装任务和任务号的 Task 类型可以使这个类型 Comparable,这样 Comparator 就没有用了。(我将这个优化留给你作为练习。)

当然,managerFunction 也有所不同:

private final Function<Manager, Function<Behavior, Effect<Tuple<Integer,
                                              Integer>>>> managerFunction;

它在构造函数中这样初始化:

Behavior 内部类必须更改以反映演员类型的变化:

Manager 类的其余部分还有一些小的更改需要应用。start 方法必须进行修改:

Worker 的初始化过程也略有不同:

private Result<Executable> initWorker(Tuple<Integer, Integer> t) {
  return Result.success(() -> new Worker("Worker " + t._2,
                Type.SERIAL).tell(new Tuple<>(t._1, t._2), self()));
}

最后,onReceive 方法也进行了修改:

@Override
public void onReceive(Tuple<Integer, Integer> message,
                     Result<Actor<Tuple<Integer, Integer>>> sender) {
  getContext().become(new Behavior(workList, resultHeap));
}

现在结果以正确的顺序显示。但你有一个新的问题:现在一个工作演员需要 15 秒,四个工作演员需要 13 秒来计算所需的时间。这是怎么回事?

答案很简单:瓶颈是 HeapHeap 数据结构并不适合排序。只要元素数量保持低,它就有很好的性能,但在这里你将所有 200,000 个结果插入到堆中,并在每次插入时对整个数据集进行排序。这并不高效。

14.3.4. 解决性能问题

显然,这种低效并不是实现问题,而是关于使用正确工具的问题。当计算完成后,通过存储所有结果并在一次排序中排序,你会得到更好的性能,尽管你需要使用正确的工具来进行排序。

另一个选项是修复你的实现。你当前设计中遇到的一个问题是,不仅插入到Heap需要很长时间,而且是由Manager线程完成的,因此,而不是在计算完成后立即将任务分配给工作演员,Manager让他们等待直到它完成堆的插入。一个可能的解决方案是使用一个单独的演员来插入到Heap中。

但有时使用正确的工具来做正确的工作会更好。你同步消费结果可能不是必需的。如果不是,你只是在添加一个隐含的要求,这使得问题更难解决。一个可能性是将结果单独传递给客户端。这样,只有在结果无序时才会使用Heap,防止它变得太大。实际上,这种使用方式正是优先队列预期的方式。为了考虑这一点,你可以在程序中添加一个Receiver演员。

列表 14.12。负责异步接收结果的Receiver演员

图片

图片

主要类(WorkersExample)与上一个示例没有太大不同。唯一的区别是添加了Receiver

public static void main(String... args) throws InterruptedException {
  semaphore.acquire();
  final AbstractActor<List<Integer>> client =
              new AbstractActor<List<Integer>>("Client", Actor.Type.SERIAL) {
    @Override
    public void onReceive(List message, Result<Actor<List<Integer>>> sender) {
      System.out.println("Result: " + message.takeAtMost(40));
      semaphore.release();
    }
  };

  final Receiver receiver = new Receiver("Receiver", Actor.Type.SERIAL, client);
  final Manager manager = new Manager("Manager", testList, receiver, workers);
  manager.start();
  semaphore.acquire();
}

Worker演员与上一个示例中的完全相同。这导致Manager类保留了最重要的更改。第一个更改是Manager将拥有一个类型为Actor<Integer>的客户端,并跟踪任务列表的长度:

private final Actor<Integer> client;
...
private final int limit;
...
public Manager(String id, List<Integer> list, Actor<Integer> client,
                                                       int workers) {
  super(id, Type.SERIAL);
  this.client = client;
  this.workers = workers;
  this.limit = list.length() - 1;

还要注意,client现在是Receiver,因此它是Actor<Integer>类型,异步接收结果,一个接一个。

当然,managerFunction是不同的:

图片

如您所见,大部分工作都是在streamResult方法中完成的:

private Tuple3<Heap<Tuple<Integer, Integer>>, Integer,
  List<Integer>> streamResult(Heap<Tuple<Integer, Integer>> result,
                                  int expected, List<Integer> list) {
  Tuple3<Heap<Tuple<Integer, Integer>>, Integer, List<Integer>> tuple3 =
                                     new Tuple3<>(result, expected, list);
  Result<Tuple3<Heap<Tuple<Integer, Integer>>, Integer,
         List<Integer>>> temp = result.head().flatMap(head ->
                  result.tail().map(tail -> head._2 == expected
                    ? streamResult(tail, expected + 1, list.cons(head._1))
                    : tuple3));
  return temp.getOrElse(tuple3);
}

这种方法可能看起来难以理解,但这只是因为 Java 中的类型表示法非常冗长。streamResult方法将其参数作为结果Heap、下一个预期的任务编号和一个最初为空的整数列表:

  • 如果结果堆的头部与预期的任务结果编号不同,则不需要做任何事情,并将三个参数作为Tuple3返回。

  • 如果结果堆的头部与预期的任务结果编号匹配,则将其从堆中移除并添加到列表中。然后递归调用该方法,直到头部不再匹配,从而按预期顺序构建结果列表,其余的留在堆中。

通过这种方式处理,堆始终保持较小。例如,在计算 20 万个任务时,发现堆的最大大小为 121。它在 12 次超过了 100,并且超过 95%的时间小于 2。

图 14.2 14.2 从Manager的角度展示了接收结果的整体过程。

图 14.2. Manager 接收到一个结果,如果它不对应预期的数字,则将其存储在 Heap 中,或者将其发送给客户端。在后一种情况下,它随后查看 Heap 以查看下一个预期的结果是否已经被接收。

图片

tellClientEmptyResult 方法根据客户端类型进行了修改:

private void tellClientEmptyResult(String ignore) {
  client.tell(-1);
}

onReceive 方法不同,因为,在启动时,你期望结果是 0:

getContext().become(new Behavior(workList, resultHeap, 0));

最后的更改是对 Behavior 类的,现在它持有预期的任务编号:

class Behavior implements MessageProcessor<Tuple<Integer, Integer>> {

  private final List<Tuple<Integer, Integer>> workList;
  private final Heap<Tuple<Integer, Integer>> resultHeap;
  private final int expected; // Change

  private Behavior(List<Tuple<Integer, Integer>> workList,
            Heap<Tuple<Integer, Integer>> resultHeap, int expected) {
    this.workList = workList;
    this.resultHeap = resultHeap;
    this.expected = expected;
  }

  ...

通过这些修改,应用程序的速度大大提高。例如,在先前的例子相同的条件下,一个工作 actor 处理 20 万个数字所需的时间是 7.5 秒,而使用四个工作 actor 时,时间降至 5.3 秒。

显然,这个过程的速度不如将所有值无序存储,然后再排序,这使一个 actor 的时间降至 3.5 秒,四个 actor 降至 1.19 秒。但仍有很大的优化空间。例如,你不必将每个结果放入 Heap,而是可以将其传递给 streamResult 方法,如果它匹配预期的任务编号,它将直接放入结果列表。

总之,这只是个例子,用来展示 actor 是如何被使用的。解决这类问题最好通过其他方式,例如自动并行化列表(如第八章所示),或者甚至一个简单的 map。actor 的主要用途不是用于并行化,而是用于抽象共享可变状态。在这些例子中,你使用了在任务之间共享的列表。如果没有 actor,你将不得不同步访问 workListresultHeap 以处理并发。actor 允许你在框架中抽象同步和修改。如果你查看你编写的业务代码(除了 actor 框架本身),你会发现没有可变数据,因此不需要关心同步,也没有线程饥饿或死锁的风险。尽管它们不是函数式的,但 actor 提供了一种很好的方式来使代码的函数部分协同工作,以抽象的方式共享可变状态。

你的 actor 框架非常简单,并不打算用于任何严肃的代码。对于此类用途,你可以使用可用的 Java actor 框架之一,尤其是 Akka。尽管 Akka 是用 Scala 编写的,这是一种比 Java 更功能友好的语言,但它也可以用于 Java 程序。当使用 Akka 时,除非你想要,否则你永远不会看到一行 Scala 代码。要了解更多关于 actor 的信息,特别是关于 Akka 的信息,请参阅 Raymond Roestenburg、Rob Bakker 和 Rob Williams 的《Akka in Action》(Manning,2016)。

14.4. 摘要

  • Actor 是异步接收消息并依次处理的组件。

  • 共享可变状态可以抽象为 actor。

  • 抽象可变状态共享可以减轻你关于同步和并发问题的负担。

  • 演员模型基于异步消息传递,并且是函数式编程的一个很好的补充。

  • 演员模型提供了简单且安全的并行化。

  • 框架将演员突变(actor mutations)从程序员那里抽象出来。

  • 对于 Java 程序员来说,有多个演员框架可供选择。

  • Akka 是 Java 编程中最常用的演员框架之一。

第十五章. 函数式解决常见问题

本章涵盖

  • 使用断言

  • 读取属性文件

  • 适配命令式库

现在,你拥有许多功能工具,可以使你的编程生活更容易。但了解工具是不够的。要成为函数式编程的高效程序员,你必须让它成为第二本能。你需要以函数式的方式思考。最初,你将保持命令式的反射,你可能会考虑如何将命令式解决方案转换为函数式编码。当你第一次处理编程问题时,首先考虑函数式解决方案(也许会有一些困难将其转换为命令式!)时,你将成为一个熟练的函数式程序员。

要达到这个阶段,没有其他方法,只能通过练习。而且,至少在 Java 世界中,大多数已知解决常见问题的方案都是命令式的,因此,查看一些常见问题并看看它们如何以函数式方式解决可能是一个很好的练习。

互联网上有许多关于以函数式方式解决数学问题的示例。这些示例非常有趣,但有时它们在某种程度上是适得其反的,因为它们让程序员相信函数式编程只适用于解决数学问题。更糟糕的是,它导致一些人认为数学技能是实践函数式编程所必需的。这不是事实。数学技能对于解决数学问题是必要的,但你需要解决的绝大多数编程问题与数学无关。而且,它们通常以函数式方式解决要简单得多。

在本章中,我们将探讨程序员在日常生活中必须解决的常见问题,并看看它们如何可以通过函数式范式以不同的方式来处理。

15.1. 使用断言验证数据

Java 从 1.4 版本开始就有断言。断言用于检查不变量,如前置条件、后置条件、控制流条件和类条件。在函数式编程中,通常没有控制流,类通常是不可变的,所以唯一需要检查的条件是前置条件和后置条件,由于同样的原因(不可变性和没有控制流),这些条件包括测试方法接收到的参数,并在返回之前测试它们的结果。

在部分函数(如这个例子)中测试参数值是必要的:

double inverse(int x) {
  return 1.0 / x;
}

此方法对任何输入都返回一个可用的值,除了 0,对于 0 它返回“无穷大”。因为你可能无法使用这个值,你可能更喜欢以特定的方式处理它。在命令式编程中,你可以这样写:

double inverse(int x) {
  assert x == 0;
  return 1.0 / x;
}

但在 Java 中,你可以在运行时禁用断言,所以常见的技巧是使用静态初始化器来防止程序在禁用断言的情况下运行:

static {
  boolean assertsEnabled = false;
  assert assertsEnabled = true;
  if (!assertsEnabled) {
     throw new RuntimeException("Asserts must be enabled!!!");
  }
}

这是 Oracle 建议的。当然,写成这样更简单:

double inverse(int x) {
  if (x != 0) throw new IllegalArgumentException("div. By 0");
  return 1.0 / x;
}

在函数式编程中,函数应该被转换成一个全函数,如下所示:

Result<Double> inverse(int x) {
  return x == 0
      ? Result.failure("div. By 0")
      : Result.success(1.0 / x);
}

然后,就没有必要检查参数了,因为这种测试是函数实现的一部分。当然,也没有必要检查返回的值。

必须经常检查的一个条件是参数不是 null。Java 有 Objects.requireNonNull 用于此。这个方法有变体,可以接受一个额外的错误消息,或者一个错误消息的 Supplier。这些方法有时可能很有用:

public static <T, U> Tuple<T, U> t(T t, U u) {
  return new Tuple<>(Objects.requireNonNull(t), Objects.requireNonNull(u));
}

但在函数式程序中,最通用的断言形式是对一个参数进行特定条件的测试,如果不匹配条件则返回 Result.Failure,否则返回 Result.Success。以 Person 类型的工厂方法为例:

public static Person apply(int id, String firstName, String lastName) {
  return new Person(id, firstName, lastName);
}

这个方法可以与从数据库中提取的数据一起使用:

Person person = Person.apply(rs.getInt("personId"),
                rs.getString("firstName"), rs.getString("lastName"));

在这种情况下,你可能在调用 apply 方法之前想要验证数据。例如,你可能想要检查 ID 是否为正数,以及名字和姓氏是否不是 null 或空,并且它们以大写字母开头。在命令式 Java 中,这可以通过使用断言方法来完成:

Person person = Person.apply(
    assertPositive(rs.getInt("personId"), "Negative id"),
    assertValidName(rs.getString("firstName"), "Invalid first name:"),
    assertValidName(rs.getString("lastName"), "Invalid last name:"));
private static int assertPositive(int i, String message) {
  if (i < 0) {
    throw new IllegalStateException(message);
  } else {
    return i;
  }
}

private static String assertValidName(String name, String message) {
  if (name == null || name.length() == 0
          || name.charAt(0) < 65 || name.charAt(0) > 91) {
    throw new IllegalStateException(message);
  }
  return name;
}

在函数式编程中,你不抛出异常;你使用特殊的上下文,如 Result 进行错误处理。这种验证被抽象成 Result 类型。你所要做的就是编写验证函数,这意味着你只需要编写方法和使用方法引用。通用的验证函数可以组合到一个特殊类中:

public class Assertion {
  public static boolean isPositive(int i) {
    return i >= 0;
  }

  public static boolean isValidName(String name) {
    return name != null && name.length() != 0
                   && name.charAt(0) >= 65 && name.charAt(0) <= 91;
  }
}

你可以验证数据:

Result<Person> person =
   Result.of(Assertion::isPositive, getInt("personId"), "Negative id")
      .flatMap(id -> Result.of(Assertion::isValidName,
                        getString("firstName"), "Invalid first name")
          .flatMap(firstName -> Result.of(Assertion::isValidName,
                        getString("lastName"), "Invalid last name")
              .map(lastName -> Person.apply(id, firstName, lastName))));

但你也可以通过在 Assertion 类中抽象更多过程来简化事情:

public static Result<Integer> assertPositive(int i, String message) {
  return Result.of(Assertion::isPositive, i, message);
}

public static Result<String> assertValidName(String name, String message) {
  return Result.of(Assertion::isValidName, name, message);
}

你可以创建一个 Person 如下:

Result<Integer> rId = Assertion.assertPositive(getInt("personId"), "Negative id");
Result<String> rFirstName =
         Assertion.assertValidName(getString("firstName"), "Invalid first name");
Result<String> rLastName =
         Assertion.assertValidName(getString("lastName"), "Invalid first name");
Result<Person> person =
    rId.flatMap(id -> rFirstName
           .flatMap(firstName -> rLastName
               .map(lastName -> Person.apply(id, firstName, lastName))));

下面的列表展示了 Assertion 类和一些示例方法。

列表 15.1. 功能性断言的示例
public final class Assertion {

  private Assertion() {
  }

  public static <T> Result<T> assertCondition(T value,
                                         Function<T, Boolean> f) {
    return assertCondition(value, f,
              "Assertion error: condition should evaluate to true");
  }

  public static <T> Result<T> assertCondition(T value,
                         Function<T, Boolean> f, String message) {
    return f.apply(value)
        ? Result.success(value)
        : Result.failure(message, new IllegalStateException(message));
  }

  public static Result<Boolean> assertTrue(boolean condition) {
    return assertTrue(condition,
                       "Assertion error: condition should be true");
  }

  public static Result<Boolean> assertTrue(boolean condition,
                                               String message) {
    return assertCondition(condition, x -> x, message);
  }

  public static Result<Boolean> assertFalse(boolean condition) {
    return assertFalse(condition,
                       "Assertion error: condition should be false");
  }

  public static Result<Boolean> assertFalse(boolean condition,
                                                     String message) {
    return assertCondition(condition, x -> !x, message);
  }

  public static <T> Result<T> assertNotNull(T t) {
    return assertNotNull(t, "Assertion error: object should not be null");
  }
  public static <T> Result<T> assertNotNull(T t, String message) {
    return assertCondition(t, x -> x != null, message);
  }

  public static Result<Integer> assertPositive(int value) {
    return assertPositive(value,
       String.format("Assertion error: value %s must be positive", value));
  }
  public static Result<Integer> assertPositive(int value, String message) {
    return assertCondition(value, x -> x > 0, message);
  }

  public static Result<Integer> assertInRange(int value, int min,
                                                             int max) {
    return assertCondition(value, x -> x >= min && x < max,
          String.format("Assertion error: value %s should be between %s and
                          %s (exclusive)", value, min, max));
  }

  public static Result<Integer> assertPositiveOrZero(int value) {
    return assertPositiveOrZero(value,
      String.format("Assertion error: value %s must not be negative", 0));
  }

  public static Result<Integer> assertPositiveOrZero(int value,
                                                     String message) {
    return assertCondition(value, x -> x >= 0, message);
  }

  public static <A> void assertType(A element, Class<?> clazz) {
    assertType(element, clazz,
        String.format("Wrong type: %s, expected: %s",
                 element.getClass().getName(), clazz.getName()));
  }

  public static <A> Result<A> assertType(A element, Class<?> clazz,
                                                            String message) {
    return assertCondition(element, e -> e.getClass().equals(clazz)
                                                                 ,message);
  }
}

15.2. 从文件中读取属性

大多数软件应用程序都是通过在启动时读取属性文件进行配置的。属性是键/值对,键和值都作为字符串写入。无论选择的属性格式是 key=value、XML、JSON、YAML 等等,程序员总是需要读取字符串并将它们转换成 Java 对象或原始数据。这个过程既繁琐又容易出错。你可以使用专门的库来做这件事,但如果出了问题,你会发现自己在抛出异常。为了获得更多功能性的行为,你可能需要编写自己的库。

15.2.1. 加载属性文件

无论你使用什么格式,过程都是一样的:读取文件并处理在这个过程中可能出现的任何 IOException。在下面的示例中,你将读取一个 Java 属性文件。

首先要做的事情是读取文件并返回一个 Result<Properties>

列表 15.2. 读取 Java 属性文件

在这个例子中,你从类路径中加载属性文件。当然,它也可以从磁盘上的任何位置加载,或者从远程 URL 读取,或者从任何其他来源读取。

15.2.2. 将属性作为字符串读取

简单的使用案例就是将属性作为字符串读取。这非常直接。你只需要向PropertyReader类添加一个readProperty方法,该方法以属性名称作为参数,并返回一个Result<String>。但请注意,以下情况不会工作:

public Result<String> getProperty(String name) {
  return properties.map(props -> props.getProperty(name));
}

如果属性不存在,getProperty方法返回null。(在 Java 8 中,它应该返回Optional,但它没有。)请注意,Properties类可以用默认属性列表构造,并且getProperty方法本身可以带有默认值调用。但并非所有属性都有默认值。

为了处理这个问题,你可以创建一个辅助方法:

public Result<String> getProperty(String name) {
  return properties.flatMap(props ->getProperty(props, name));
}

private Result<String> getProperty(Properties properties, String name) {
  return Result.of(properties.getProperty(name));
}

现在,假设你在类路径中有一个属性文件,包含以下属性:

host=acme.org
port=6666
name=
temp=71.3
price=$45
list=34,56,67,89
person=3,Jeanne,Doe

你可以安全地访问属性:

PropertyReader propertyReader = new PropertyReader("com/fpinjava/properties/config.properties");

propertyReader.getProperty("host")
              .forEachOrFail(System.out::println)
              .forEach(System.out::println);

propertyReader.getProperty("name")
              .forEachOrFail(System.out::println)
              .forEach(System.out::println);

propertyReader.getProperty("year")
              .forEachOrFail(System.out::println)
              .forEach(System.out::println);

给定你的属性文件,你会得到以下结果:

acme.org

Null value

第一行对应于host属性,这是正确的。第二行对应于name属性,它是一个空字符串,这可能正确也可能不正确;你不知道。这取决于从业务角度来看名称是否是可选的。第三行对应于缺失的year属性,但“空值”信息并不很有帮助。当然,它包含在一个Result <String>中,可以分配给year变量,因此你可以知道哪个属性缺失。但最好将属性名称作为消息的一部分。此外,如果文件找不到,你会得到一个非常不详细的错误信息:

java.lang.NullPointerException

15.2.3. 生成更好的错误信息

你在这里遇到的问题是一个非常好的例子,说明了永远不应该发生的事情。使用 Java 标准库,你确信事情会按预期进行。特别是,你期望如果找不到文件,或者无法读取,你会得到一个IOException。你甚至希望被告知文件的完整路径,因为“缺失”的文件通常只是不在正确位置(或者是一个 Java 没有在正确位置寻找的文件)。在这种情况下,一个好的错误信息会是“我在位置‘xyz’寻找文件‘abc’,但找不到它。”

现在,看看ClassLoader.getResourceAsStream方法的代码:

public InputStream getResourceAsStream(String name) {
  URL url = getResource(name);
  try {
    return url != null ? url.openStream() : null;
  } catch (IOException e) {
    return null;
  }
}

不,你并没有做梦。这就是 Java 8 的编写方式。结论是,作为程序员,你应该在查看相应代码之前,永远不要使用 Java 标准库中的任何方法。

注意,Javadoc 表示该方法返回“用于读取资源的输入流,或者如果资源找不到,则返回 null。”这意味着许多事情可能会出错。如果找不到文件或在读取文件时出现问题,可能会发生 IOException。或者文件名可能是 null。或者 getResource 方法可能会抛出异常或返回 null。(查看该方法的代码以了解我的意思。)

您至少应该为每种情况提供不同的消息。尽管 IOException 很不可能被抛出,但您仍然必须处理这种情况,以及意外的异常的通用情况:

private Result<Properties> readProperties(String configFileName) {
  try (InputStream inputStream =
       getClass().getClassLoader().getResourceAsStream(configFileName)) {
    Properties properties = new Properties();
    properties.load(inputStream);
    return Result.of(properties);
  } catch (NullPointerException e) {
    return Result.failure(String.format("File %s not found in classpath",
                                                        configFileName));
  } catch (IOException e) {
    return Result.failure(String.format("IOException reading classpath
                                          resource %s", configFileName));
  } catch (Exception e) {
    return Result.failure(String.format("Exception reading classpath
                                       resource %s", configFileName), e);
  }
}

现在,如果找不到文件,消息将是

File com/fpinjava/properties/config.properties not found in classpath

您还必须处理与属性相关的错误消息。当使用如下代码时

Result<String> year = propertyReader.getProperty("year");

如果您收到“空值”错误消息,那么这意味着 year 属性未找到。但在以下示例中,“空值”消息没有提供有关缺少哪个属性的信息:

PropertyReader propertyReader =
            new PropertyReader("com/fpinjava/properties/config.properties");
Result<Person> person =
  propertyReader.getProperty("id").map(Integer::parseInt)
    .flatMap(id -> propertyReader.getProperty("firstName")
      .flatMap(firstName -> propertyReader.getProperty("lastName")
        .map(lastName -> Person.apply(id, firstName, lastName))));
person.forEachOrFail(System.out::println).forEach(System.out::println);

为了解决这个问题,您有几种选择可供选择。最简单的是在 PropertyReader 类的 getProperty 辅助方法中映射失败:

private Result<String> getProperty(Properties properties, String name) {
  return Result.of(properties.getProperty(name))
        .mapFailure(String.format("Property \"%s\" no found", name));
}

上述示例产生了以下错误消息,清楚地表明 id 属性在属性文件中不存在:

Property "id" not found

另一个潜在的错误来源是在将字符串 id 属性转换为整数时发生的解析错误。例如,如果该属性是

id=three

错误消息将是

For input string: "three"

这并不提供有意义的错误信息,这是因为它是标准 Java 8 解析错误的错误消息。大多数标准 Java 错误消息都是这样的。它就像一个 NullPointerException。它说找到了一个 null 引用,但没有说哪个。在这里,甚至没有说明遇到了哪个错误。错误的性质由异常携带。打印堆栈跟踪将给出以下信息:

Exception in thread "main" java.lang.NumberFormatException: For input string: "three"
at java.lang.NumberFormatException.forInputString(NumberFormatException.java:48) ...

您真正需要的是导致异常的属性名称。类似于以下内容:

propertyReader.getProperty("id")
    .map(Integer::parseInt)
    .mapFailure(String.format("Invalid format for property \"id\": ", ???))

但是你必须将属性名称写两次,并且希望用找到的值替换“???”(这是不可能的,因为该值已经丢失)。因为您将不得不解析所有非字符串属性值,所以您应该在 PropertyReader 类中抽象这一过程。

要做到这一点,您首先需要重命名 getProperty 方法:

public Result<String> getAsString(String name) {
  return properties.flatMap(props -> getProperty(props, name));
}

然后,您将添加一个 getAsInteger 方法:

public Result<Integer> getAsInteger(String name) {
  Result<String> rString =
       properties.flatMap(props ->getProperty(props, name));
  return rString.flatMap(x -> {
    try {
      return Result.success(Integer.parseInt(x));
    } catch (NumberFormatException e) {
      return Result.failure(String.format("Invalid value while parsing
                                               property %s: %s", name, x));
    }
  });
}

现在,您不需要担心在转换为整数时出错:

Result<Person> person =
  propertyReader.getAsInteger("id")
    .flatMap(id -> propertyReader.getAsString("firstName")
      .flatMap(firstName -> propertyReader.getAsString("lastName")
        .map(lastName -> Person.apply(id, firstName, lastName))));
person.forEachOrFail(System.out::println).forEach(System.out::println);

15.2.4. 将属性作为列表读取

您可以为其他数值类型,如 longdouble,做与整数相同的事情。但您能做的远不止这些。您可以读取属性作为列表:

list=34,56,67,89

您只需添加一个专门的方法来处理这种情况。您可以使用以下方法将属性作为整数列表获取:

public Result<List<Integer>> getAsIntegerList(String name) {
  Result<String> rString =
           properties.flatMap(props ->getProperty(props, name));
  return rString.flatMap(s -> {
    try {
      return Result.success(List.fromSeparatedString(s,',')
                                             .map(Integer::parseInt));
    } catch (NumberFormatException e) {
      return Result.failure(String.format("Invalid value while parsing
                                             property %s: %s", name, s));
    }
  });
}

当然,你需要将fromSeparatedString方法添加到List类中。正如我在上一章所说,这段代码不是用来使用上一章练习的结果,而是使用本书附带代码中的fpinjava-common模块(github.com/fpinjava/fpinjava)。这基本上与练习的解决方案中的代码相同,但增加了一些方法,例如以下示例中的List.fromCollection(...)

public static List<String> fromSeparatedString(String string,
                                                     char separator) {
  return List.fromCollection(Arrays.asList(string.split("\\s*"
                                              + separator + "\\s*")));
}

但你可以做更多。你可以通过提供转换函数来读取任何数值列表的属性:

public <T> Result<List<T>> getAsList(String name, Function<String, T> f) {
  Result<String> rString
              = properties.flatMap(props ->getProperty(props, name));
  return rString.flatMap(s -> {
    try {
      return Result.success(List.fromSeparatedString(s, ',').map(f));
    } catch (NumberFormatException e) {
      return Result.failure(String.format("Invalid value while parsing
                                             property %s: %s", name, s));
    }
  });
}

现在你可以定义各种数字格式的函数,以getAsList为依据:

public Result<List<Integer>> getAsIntegerList(String name) {
  return getAsList(name, Integer::parseInt);
}

public Result<List<Double>> getAsDoubleList(String name) {
  return getAsList(name, Double::parseDouble);
}

public Result<List<Boolean>> getAsBooleanList(String name) {
  return getAsList(name, Boolean::parseBoolean);
}

15.2.5. 读取枚举值

一个常见的用例是将属性作为enum值读取,这是读取属性为任何类型的特例。你可以首先创建一个方法将属性转换为任何类型T,接受一个从StringResult<T>的函数:

public <T> Result<T> getAsType(final Function<String, Result<T>> function,
                                                       final String name) {
  Result<String> rString =
              properties.flatMap(props -> getProperty(props, name));
  return rString.flatMap(s -> {
    try {
      return function.apply(s);
    } catch (Exception e) {
      return Result.failure(String.format("Invalid value while parsing
                                              property %s: %s", name, s));
    }
  });
}

你现在可以在getAsType的基础上创建一个getAsEnum方法:

图片

给定以下属性

type=SERIAL

以及以下enum

public enum Type {
  SERIAL,
  PARALLEL
}

你现在可以使用以下代码读取属性:

Result<Type> type = propertyReader.getAsEnum("type", Type.class);

15.2.6. 读取任意类型的属性

到目前为止,你一直是以字符串、原始数据类型(intdoubleboolean等)或enum的形式读取属性。读取属性为任意对象可能也很有趣。为此,你必须在属性文件中以某种序列化的形式编写对象属性,然后加载这些属性并反序列化它们。

你可以使用getAsType方法以任何类型读取属性。例如,你可以读取以下属性以获取一个Person

person=id:3,firstName:Jane,lastName:Doe

你只需要提供一个从StringResult<Person>的函数。这个函数应该能够从字符串id:3,firstName:Jane,lastName:Doe中创建一个Person对象。

为了简化其使用,你可以创建一个getAsPerson方法。但由于它是类型特定的,你不应该将其放在PropertyReader内部。可以添加一个静态工厂方法到Person类中,该方法接受一个PropertyReader和属性名作为参数。

实现它的方法有多种。一种方法是将属性作为列表获取,然后分割每个元素,将键/值对放入映射中。然后就可以很容易地从映射中创建一个Person对象。另一种方法是创建一个第二PropertyReader,在将逗号替换为换行符后从字符串中读取。以下列表显示了具有从属性字符串构建实例的两个特定方法的Person类。

列表 15.3. 允许你以对象或对象列表读取属性的方法
public class Person {
  ...
  public static Result<Person> getAsPerson(String propertyName,
                                 PropertyReader propertyReader) {
    Result<String> rString =
               propertyReader.getAsPropertyString(propertyName);
    Result<PropertyReader> rPropReader =
               rString.map(PropertyReader::stringPropertyReader);
    return rPropReader.flatMap(Person::readPerson);
  }

  public static Result<List<Person>> getAsPersonList(String propertyName,
                                         PropertyReader propertyReader) {
    Result<List<String>> rList =
                          propertyReader.getAsStringList(propertyName);
    return rList.flatMap(list -> List.sequence(list.map(s ->
        readPerson(PropertyReader.stringPropertyReader(PropertyReader
                                               .toPropertyString(s))))));
  }

  private static Result<Person> readPerson(PropertyReader propReader) {
    return propReader.getAsInteger("id")
        .flatMap(id -> propReader.getAsString("firstName")
            .flatMap(firstName -> propReader.getAsString("lastName")
                .map(lastName -> Person.apply(id, firstName, lastName))));
  }
}

getAsPersonList方法允许你以如下方式读取向量属性:

employees:\
  id:3;firstName:Jane;lastName:Doe,\
  id:5;firstName:Paul;lastName:Smith,\
  id:8;firstName:Mary;lastName:Winston

这些方法需要在PropertyReader类中进行一些修改。

列表 15.4. 添加到PropertyReader类的静态工厂方法

当然,同样的操作也可以应用于 XML 属性文件(Java 默认处理此类文件)或其他格式,例如 JSON 或 YAML。

15.3. 将命令式程序转换为:XML 读取器

为任何任务编写新的函数式程序都是令人兴奋的,但通常你没有时间这样做。通常,你会在自己的代码中使用现有的命令式程序。每次你想使用 Java 库时都是这种情况。当然,你可能更喜欢从头开始构建一个完全新的、100% 函数式解决方案。但你必须现实。你通常没有时间或预算这样做,你将不得不使用现有的非函数式库。

如你很快会发现,一旦你熟悉了函数式技术,回到旧的命令式编码风格就真的很痛苦。通常的解决方案是在这些命令式库周围构建一个薄薄的函数式包装器。作为一个例子,我们将检查一个非常常见的用于读取 XML 文件的库,JDOM 2.0.6。这是此任务最常用的 Java 库。

你将从 列表 15.5 中的示例程序开始。此程序来自众多提供有关如何使用 JDOM 的教程的网站之一(mng.bz/4p3x)。我选择这个示例是因为它最小化且易于融入本书中。

列表 15.5. 使用 JDOM 读取 XML 数据:命令式版本
import org.jdom2.Document;
import org.jdom2.Element;
import org.jdom2.JDOMException;
import org.jdom2.input.SAXBuilder;
import java.io.File;
import java.io.IOException;
import java.util.List;

public class ReadXmlFile {

  public static void main(String[] args) {
    SAXBuilder builder = new SAXBuilder();
    File xmlFile = new File("path_to_file");
    try {
      Document document = (Document) builder.build(xmlFile);
      Element rootNode = document.getRootElement();
      List list = rootNode.getChildren("staff");
      for (int i = 0; i < list.size(); i++) {
        Element node = (Element) list.get(i);
        System.out.println("First Name : " +
                                    node.getChildText("firstname"));
        System.out.println("\tLast Name : " +
                                    node.getChildText("lastname"));
        System.out.println("\tNick Name : " +
                                    node.getChildText("email"));
        System.out.println("\tSalary : " + node.getChildText("salary"));
      }
    } catch (IOException io) {
      System.out.println(io.getMessage());
    } catch (JDOMException jdomex) {
      System.out.println(jdomex.getMessage());
    }
  }
}

与此示例一起使用的数据文件如下所示。

列表 15.6. 需要读取的 XML 文件
<?xml version="1.0"?>
<company>
  <staff>
    <firstname>Paul</firstname>
    <lastname>Smith</lastname>
    <email>paul.smith@acme.com</email>
    <salary>100000</salary>
  </staff>
  <staff>
    <firstname>Mary</firstname>
    <lastname>Colson</lastname>
    <email>mary.colson@acme.com</email>
    <salary>200000</salary>
  </staff>
</company>

首先,你将看看通过以函数式方式重写此示例可以获得哪些好处。你可能遇到的第一问题是程序没有任何部分可以重用。当然,这只是一个示例,但即使是作为一个示例,它也应该以可重用的方式编写,至少应该可测试。在这里,测试程序的唯一方法是查看控制台,它将显示预期的结果或错误消息。正如你将看到的,它甚至可能显示错误的结果。

15.3.1. 列出必要的函数

要使此程序更具函数性,你应该首先列出你需要的基本函数,将它们编写为自主的、可重用的和可测试的单位,然后通过组合这些函数来编写示例。以下是程序的主要功能:

  1. 读取文件并将内容作为 XML 字符串返回。

  2. 将 XML 字符串转换为元素列表。

  3. 将元素列表转换为这些元素的字符串表示列表。

你还需要一个效果来将字符串列表显示到计算机屏幕上。

注意

此程序主要功能的描述仅适用于可以完全加载到内存中的小文件。

你需要的第一个函数可以按以下方法实现:

public static Result<String> readFile2String(String path)

此方法不会抛出任何异常,但返回一个 Result<String>

第二个方法将 XML 字符串转换为元素列表,因此它需要知道根 XML 元素的名字。它将具有以下签名:

private static Result<List<Element>> readDocument(String rootElementName,
                                                         String stringDoc)

你需要的第三个函数将接收一个元素列表作为其参数,并返回这些元素的字符串表示形式列表。这将通过以下签名的方法实现:

private static List<String> toStringList(List<Element> list, String format)

最终,你需要对数据进行应用效果,因此你必须将其定义为具有以下签名的方法:

private static <T> void processList(List<T> list)

这种在函数中的分解看起来与你在命令式编程中能做的事情没有太大区别。毕竟,将命令式程序分解为具有单一职责的方法也是一种良好的实践。然而,实际上它比看起来要不同。请注意,readDocument方法将其第一个参数作为一个字符串,这个字符串是由可能(在命令式世界中)抛出异常的方法返回的。因此,你必须处理额外的函数:

private static Result<String> getRootElementName()

同样,文件路径可以通过相同类型的函数返回:

private static Result<String> getXmlFilePath()

需要注意的重要事项是,这些函数的参数类型和返回类型不匹配!这是这些函数的命令式版本可能不完整的一个明确表述,这意味着它们可能会抛出异常。抛出异常的方法不容易组合。相比之下,你的函数可以完美地组合。

15.3.2. 组合函数并应用效果

虽然参数和返回类型不匹配,但你的函数可以很容易地使用理解模式进行组合:

final static String format = "First Name : %s\n" +
      "\tLast Name : %s\n" +
      "\tEmail : %s\n" +
      "\tSalary : %s";
...
final Result<String> path = getXmlFilePath();
final Result<String> rDoc = path.flatMap(ReadXmlFile::readFile2String);
final Result<String> rRoot = getRootElementName();
final Result<List<String>> result = rDoc.flatMap(doc -> rRoot
    .flatMap(rootElementName -> readDocument(rootElementName, doc))
    .map(list -> toStringList(list, format)));

要显示结果,你只需应用相应的效果:

result.forEachOrException(ReadXmlFile::processList)
      .forEach(Throwable::printStackTrace);

你的程序的功能版本更加简洁,并且可以完全测试——或者在你实现了所有必要的函数之后,它将可以完全测试。

15.3.3. 实现函数

你的程序相对优雅,但你仍然需要实现你正在使用的函数和效果,以便使其工作。好消息是每个函数都非常简单,并且可以轻松测试。

首先,你需要实现getXmlFilePathgetRootElementName函数。在我们的例子中,这些是将在实际应用中替换的常量:

private static Result<String> getXmlFilePath() {
  return Result.of("<path_to_file>");
}

private static Result<String> getRootElementName() {
  return Result.of("staff");
}

然后,你必须实现readFile2String方法。以下是一种可能的实现方式:

public static Result<String> readFile2String(String path) {
  try {
    return Result.success(new String(Files.readAllBytes(Paths.get(path))));
  } catch (IOException e) {
    return Result.failure(String.format("IO error while reading file %s",
                                                                 path), e);
  } catch (Exception e) {
    return Result.failure(String.format("Unexpected error while reading
                                                     file %s", path), e);
  }
}

注意,你分别捕获IOExceptionException。这不是强制性的,但它允许你提供更好的错误消息。无论如何,你必须始终捕获Exception。(例如,你可能会在这里遇到SecurityException。)

接下来,你需要实现readDocument方法。该方法接受一个包含 XML 数据的 XML 字符串和根元素的名字作为参数:

你首先捕获 IOException(由于你正在读取字符串,所以不太可能抛出)和 JDOMException,这两个都是检查异常,并返回带有相应错误信息的失败。但通过查看 JDOM 代码(没有人应该在查看实现之前调用库方法),你会发现代码可能会抛出 IllegalStateExceptionNullPointerException。又一次,你必须捕获 Exception

toStringList 方法简单地将列表映射到负责转换的函数:

private static List<String> toStringList(List<Element> list,
                                               String format) {
  return list.map(e -> processElement(e, format));
}
private static String processElement(Element element, String format) {
  return String.format(format, element.getChildText("firstname"),
      element.getChildText("lastname"),
      element.getChildText("email"),
      element.getChildText("salary"));
}

最后,你需要实现将应用于结果的效果:

private static <T> void processList(List<T> list) {
  list.forEach(System.out::println);
}

15.3.4. 使程序更加功能化

你的程序现在更加模块化和可测试,其部分是可重用的。但你还可以做得更好。你仍然在使用四个非功能性元素:文件路径、根元素名称、将元素转换为字符串的格式以及应用于结果的效果。为了使你的程序完全功能化,你应该将这些元素作为程序参数。

processElement 方法也使用了以元素名形式的具体数据,这些元素名对应于用于显示它们的格式字符串的参数。你可以用格式字符串和参数列表的 Tuple 替换格式参数。这样,processElement 方法将变成以下形式:

private static List<String> toStringList(List<Element> list,
                                     Tuple<String, List<String>> format) {
  return list.map(e -> processElement(e, format));
}

private static String processElement(Element element, Tuple<String,
                                                   List<String>> format) {
  String formatString = format._1;
  List<String> parameters = format._2.map(element::getChildText);
  return String.format(formatString, parameters.toJavaList().toArray());
}

现在程序可以是一个纯函数,接受四个参数,并返回一个新的(非功能性的)可执行程序作为其结果。这个版本的程序在下面的列表中展示。

列表 15.7. 完全功能化的 XML 读取器程序

在这一点上,这个程序可以用下面的列表中所示的客户端代码进行测试。

列表 15.8. 测试 XML 读取器的客户端程序
public class Test {

  private final static Tuple<String, List<String>> format =
      new Tuple<>("First Name : %s\n" +
          "\tLast Name : %s\n" +
          "\tEmail : %s\n" +
          "\tSalary : %s", List.list("firstname", "lastname", "email", "salary"));

  public static void main(String... args) {
    Executable program = ReadXmlFile.readXmlFile(Test::getXmlFilePath,
                           Test::getRootElementName, format, Test::processList);
    program.exec();
  }

  private static Result<String> getXmlFilePath() {
    return Result.of("file.xml"); // <- adjust path
  }

  private static Result<String> getRootElementName() {
    return Result.of("staff");
  }

  private static <T> void processList(List<T> list) {
    list.forEach(System.out::println);
  }
}

这个程序并不理想,因为你没有处理可能由无效元素名引起的潜在错误。例如,如果你使用了一个错误的元素名,你可能会得到以下结果:

First Name : null
  Last Name : Smith
  email : paul.smith@acme.com
  Salary : 100000
First Name : null
  Last Name : Colson
  email : mary.colson@acme.com
  Salary : 200000

你可以通过看到所有名字都是 null 来猜测错误是什么,但最好是将null这个词替换成一个包含错误元素名的显式消息。一个更重要的问题是,如果你在列表中忘记了一个元素名,你将因为以下代码而从 String.format 方法得到一个异常:

List<String> parameters = format._2.map(element::getChildText);
return String.format(formatString, parameters.toJavaList().toArray());

在这段代码中,参数数组将只有三个元素,而不是预期的四个。但要从异常跟踪中找到错误的来源将非常困难。

实际上,问题的真正原因是,你已经从ReadXmlFile类中提取了所有特定数据,例如根元素名称、文件路径和要应用的效果,但processElement方法仍然是针对客户端业务用例特定的。ReadXmlFile类只允许你读取根元素的直接子元素,收集它们的一些直接子元素值(那些名称与格式一起传递的)。

第三个问题是readXmlFile方法接受两个相同类型的参数。如果参数被交换,这将成为一个错误源,编译器不会检测到。

15.3.5. 解决参数类型问题

第三个问题通过使用第三章中描述的值类型技术非常容易解决。你不需要使用Result<String>参数,你可以使用Result<FilePath>Result<ElementName>FilePathElementName只是字符串值的值类:

public class FilePath {

  public final Result<String> value;

  private FilePath(Result<String> value) {
    this.value = value;
  }
  public static FilePath apply(String value) {
    return new FilePath(Result.of(FilePath::isValidPath, value,
                                        "Invalid file path: " + value));
  }

  private static boolean isValidPath(String path) {
    // Replace with validation code
    return true;
  }
}

ElementName类也是类似的。当然,如果你想进行一些验证,你必须添加验证代码。最简单的方法是检查值是否与正则表达式匹配。要使用这些新类,readXmlFile方法可以修改如下:

public static Executable readXmlFile(Supplier<FilePath> sPath,
                                     Supplier<ElementName> sRootName,
                                     Tuple<String, List<String>> format,
                                     Effect<List<String>> e) {
  final Result<String> path = sPath.get().value;
  final Result<String> rDoc = path.flatMap(ReadXmlFile::readFile2String);
  final Result<String> rRoot =sRootName.get().value;

如你所见,更改是最小的。注意,如果你认为在值类型类中使用公共属性不合适,你可以使用 getter 而不是公共属性。

客户端类也必须进行修改:

private static FilePath getXmlFilePath() {
  return FilePath.apply("<path_to_file>");
}

private static ElementName getRootElementName() {
  return ElementName.apply("staff");
}

这些更改后,现在不可能在不被编译器警告的情况下交换参数的顺序。

15.3.6. 将元素处理函数作为参数

剩下的两个问题可以通过一个更改来解决:将元素处理函数作为参数传递给readXmlFile方法。这样,这个方法将只有一个任务:读取文件中的第一级元素列表,将它们应用于可配置的函数,并返回结果。主要区别是,该方法将不再生成字符串列表并应用字符串效果。

你需要使方法通用。这意味着只有以下更改:

图片

客户端程序现在可以相应地进行修改。这让你摆脱了使用Tuple技巧来传递格式字符串和参数名称列表的需求:

图片

注意,processList效果没有改变。现在,客户端需要提供一个函数来转换一个元素,以及应用于此元素的效果。

15.3.7. 处理元素名称上的错误

现在,您面临的问题是读取元素时发生错误。传递给readXmlFile方法的函数返回一个原始类型,这意味着它应该是一个全函数,但它不是。在我们的初始示例中,这是因为错误产生了“null”字符串。现在您正在使用从ElementT的函数,您可以使用Result<String>作为T的实现,但这不太实用,因为您最终会得到一个List<Result<T>>,您必须将其转换为Result<List<T>>。这不是什么大问题,但这一点绝对应该被抽象化。

解决方案是使用从ElementResult<T>的函数,并使用List.sequence方法将结果转换为Result<List<T>>。以下是新方法:

需要进行的唯一额外更改是处理在过程元素方法中可能发生的错误。最佳方法是检查 JDOM 中getChildText方法的代码。此方法实现如下:

/**
 * Returns the textual content of the named child element, or null if
 * there's no such child. This method is a convenience because calling
 * <code>getChild().getText()</code> can throw a NullPointerException.
 *
 * @param cname the name of the child
 * @return text   content for the named child, or null if no such child
 */
public String getChildText(final String cname) {
  final Element child = getChild(cname);
  if (child == null) {
    return null;
  }
  return child.getText();
}

如您所见(当您继续检查getChild方法的代码时),此方法不会抛出任何异常,但如果元素不存在,它将返回null。因此,您可以修改您的processElement方法:

现在,大多数潜在的错误都以功能方式处理。然而,请注意,并非所有错误都可以以功能方式处理。正如我之前所说,传递给readXmlFile方法的效应抛出的异常不能以这种方式处理。这些是由方法返回的程序抛出的异常。当方法返回程序时,它尚未执行。这些异常必须在执行结果程序时捕获:

public static void main(String... args) {
  Executable program = ReadXmlFile.readXmlFile(Test::getXmlFilePath,
                                               Test::getRootElementName,
                                               Test::processElement,
                                               Test::processList);
  try {
    program.exec();
  } catch (Exception e) {
    System.out.println(e.getMessage());
  }
}

您可以在本书附带的代码中找到完整的示例(github.com/fpinjava/fpinjava)。

15.4. 摘要

  • 将值放入Result上下文是断言的功能等价。

  • 可以使用Result上下文安全地读取属性文件。

  • 功能性属性读取可让您免于处理转换错误。

  • 属性可以以抽象方式读取为任何类型、enum或集合。

  • 可以在遗留命令式库周围构建功能包装器。

附录 A. 使用 Java 8 函数式特性

当 Java 8 发布时,Oracle 将其定位为向更函数式编程迈出的一步。在 Oracle 的“JDK 8 新特性”笔记中列出的函数式友好特性包括以下内容:

  • “Lambda 表达式,这是一种新的语言特性,已在本版本中引入。它们使您能够将功能作为方法参数处理,或将代码作为数据。Lambda 表达式允许您更紧凑地表达单方法接口(称为函数式接口)的实例。”这是函数式范式的一个重要方面。

  • “方法引用为已命名的现有方法提供了易于阅读的 lambda 表达式。”这句话的后半部分可能指的是“现有方法”,因为未命名的的方法是不存在的。

  • “类型注解提供了在任何使用类型的地方应用注解的能力,而不仅仅是声明上。”

  • “改进的类型推断。”

  • “新 java.util.stream 包中的类提供了 Stream API,以支持对元素流进行函数式操作。Stream API 集成到 Collections API 中,使得诸如顺序或并行 map-reduce 转换等操作成为可能。”

你可以在 Oracle 的原始“JDK 8 新特性”文档中阅读这些声明(以及许多与函数式编程无关的其他声明)(mng.bz/27na)。

在这次演示中,Oracle 没有列出几个元素,并省略了一个重要的事实:

  • Function

  • Optional

  • CompletableFuture

  • 事实是,大多数集合通过添加 stream() 方法进行了修改,这使得它们可以转换为 Stream 实例。

所有这些,包括 OptionalStreamCompletableFuture 都是单子结构(有关这意味着什么,请参阅附录 B appendix B)的事实,清楚地表明 Oracle 的意图是使使用 Java 进行函数式编程更容易。

在这本书中,我大量使用了这些功能友好的特性,例如 lambda 表达式和函数式接口,并间接受益于更好的类型推断和扩展的类型注解。然而,我没有使用其他函数式元素,如 OptionalStream。在本附录中,我将解释原因。

A.1. Optional

Optional 类类似于你在第六章 chapter 6 中开发的 Option 类。它旨在解决 null 引用的问题,但对函数式编程帮助不大。显然,某些地方出了问题。Optional 类有一个 get 方法,如果有“封装”的值,它将返回该值,否则返回 null。当然,调用此方法违背了原始目标。

如果你想要使用Optional类,你应该记住永远不要调用get。你可能会反对,因为Option类有getOrThrow方法,虽然它永远不会返回null,但如果没有数据可用,它将抛出异常。但这个方法是受保护的,并且不能从外部扩展这个类。这有很大的不同。这个方法与List中的headtail方法等价:它们不应该从外部调用。

此外,Optional类与Option有相同的局限性:Optional可以用于真正的可选数据,但通常数据的缺失是由于错误。Optional,就像Option一样,不允许你携带错误原因,所以它只适用于真正的可选数据,这意味着当数据缺失的原因很明显时,例如从一个映射中返回一个值,或者字符串中字符的位置。如果映射的get(key)方法不返回值,无论是null还是空的Optional,都应该很明显,键没有找到。如果indexOf(char)方法不返回值或空的Optional,应该意味着字符不在字符串中。

但即使是这样也不一定正确。映射的get(key)方法可能会返回null,因为null值被存储在那个键下。或者它可能不返回任何值,因为键是null(假设null不是一个有效的键)。indexOf(char)方法也可能因为许多原因不返回值,例如负数参数。在这些情况下返回Optional并不能表明错误的性质。此外,这个Optional与可能产生错误的其它方法返回的值组合起来会很困难。

由于所有这些原因,Optional,就像我们的Option版本一样,是无用的。这就是我们开发Return类型的原因,你可以用它来表示可选数据的缺失以及错误。

A.2. 流

流是 Java 8 的另一个新元素,它混合了三个不同的概念:

  • 惰性集合

  • 单子集合

  • 自动并行化

这三个概念是独立的,没有明显的理由说明为什么它们被混合在一起。不幸的是,就像许多其他旨在做几件不同事情的工具一样,它们在每个方面都不够理想。

单子数据结构对于函数式编程至关重要,而 Java 集合不是单子的。你可以通过简单地在新添加的stream()方法上调用这些集合来创建这样的结构。如果流有所有必要的函数处理方法,这可能是一个可接受的解决方案。但流被设计成能够自动从串行切换到并行处理。这个过程相当复杂,这可能是为什么一些重要的方法没有在流中实现。例如,Java 8 流没有takeWhiledropWhile方法。

这可能是一个为了获得自动并行化访问的合理代价,但即使这个特性实际上也不是真正可用的。(这个问题在 Java 9 中得到了解决。)所有并行流都使用一个包含与计算机上物理线程数量相同但减去一个(主线程)的线程数量的单个 fork/join 池。任务被分配到池中每个工作线程的等待队列中。一旦一个线程耗尽了其任务队列,它就会从其他线程“窃取”工作。主线程本身也通过从工作线程“窃取”工作来参与。

整体结果并不理想,因为当然,计算机可能还有许多其他任务要同时执行。想想一个 Java EE 应用程序正在接收来自客户端的请求。这些请求已经并行处理,所以进一步并行化每个请求几乎没有好处。通常,在这种情况下,将没有任何好处。

更糟糕的是,因为所有并行流共享同一个 fork/join 池,如果一个流阻塞,它可能会阻塞所有其他流!你可以为每个流使用一个特定的池,但这有点复杂,并且只有在使用较小大小的池(即较少的线程)时才应该这样做。如果你对这样的技术感兴趣,请查看我在 DZone 上发布的以下文章:

Java 8 流最糟糕的可能就是它们只能使用一次。一旦对它们调用了一个终端方法,它们就再也不能使用了。任何进一步的访问都会产生异常。这有两个后果。

第一个问题是无法进行记忆化。你无法像第二次访问流那样,只能创建一个新的流。结果是,如果值是延迟评估的,它们将不得不再次评估。

第二个后果甚至更糟:Java 8 的流不能用于理解模式。想象一下,你想编写一个函数来验证毕达哥拉斯关系 a² + b² = c²,使用类似于下面的Triple类实现:

public class Triple {
  public final int a;
  public final int b;
  public final int c;
  Triple(int a, int b, int c) {
    this.a = a;
    this.b = b;
    this.c = c;
  }

  @Override
  public String toString() {
    return String.format("(%s,%s,%s)", a, b, c);
  }
}

在命令式 Java 中,pyths方法可以如下实现:

static List<Triple> pyths(int n) {
  List<Triple> result = new ArrayList<>();
  for (int a = 1; a <= n; a++) {
    for (int b = 1; b <= n; b++) {
      for (int c = 1; c <= n; c++) {
        if (a * a + b * b == c * c) {
          result.add(new Triple (a, b, c));
        }
      }
    }
  }
  return result;
}

“功能”版本,使用流,应该看起来像这样:

static Stream<Triple> pyths(int n) {
  Stream<Integer> stream = IntStream.rangeClosed(1, n).boxed();
  return stream.flatMap(a -> stream
      .flatMap(b -> stream
          .flatMap(c -> a * a + b * b == c * c
              ? Stream.of(new Triple (a, b, c))
              : Stream.empty())));
}

不幸的是,在 Java 8 中,这将产生以下异常:

java.lang.IllegalStateException: stream has already been operated upon or closed

相比之下,你可以使用你在第五章中开发的List类来编写这个示例。

Java 8 流的另一个限制是折叠是一个终端操作,这意味着折叠(在 Java 8 流中称为 reduce)将导致评估所有流元素。为了理解差异,回想一下你在第九章中开发的 Stream.foldRight 方法。使用此方法,你可以编写如下 identity 函数的实现:

public Stream<A> identity() {
  return foldRight(Stream::empty, a -> b -> cons(() -> a, b));
}

此方法完全惰性,允许你用它来实现 mapflatMap 以及许多其他方法。这在 Java 8 流中是完全不可能的。

这是否意味着你永远不应该使用 Java 8 流?绝对不是。Java 8 流在性能是最重要的标准时是一个很好的选择,尤其是在你需要处理原始数据时。然而,在生产环境中,通常应避免使用并行流。对于大多数函数式使用,真正的函数式流是一个更好的选择。

如果你想要(或需要)在函数式上下文中使用 Java 8 Stream,请注意,尽管 Stream 类型有一个 reduce 方法(实际上有三个版本的这个方法)用于折叠,但这并不是折叠流的最佳方式。折叠应该使用 Collector 实现来完成。Collector 是一个接口,它定义了五个方法:

@Override
public Supplier<A> supplier();

@Override
public BiConsumer<A, T> accumulator();

@Override
public BinaryOperator<A> combiner();

@Override
public Function<List<List<T>>, List<List<T>>> finisher();

@Override
public Set<Characteristics> characteristics();

supplier 方法返回一个 Supplier<A> 用于身份元素。accumulator 方法返回一个 BiConsumer<A, T>,这是折叠函数的非功能性替代品。相应的折叠函数将是 BiFunction <A, T, A>,它将一个元素与当前结果组合。而不是返回结果,消费者应该将其存储在某个地方(在 Collector 中)。换句话说,它是一个基于状态变异的折叠版本。finisher 是一个可选函数,它将在最终结果上应用。最后,characteristics 返回 Collector 使用的特征集,用于优化其工作。有三个可能的特征——CONCURRENTIDENTITY_FINISHUNORDERED

  • CONCURRENT 表示 accumulator 函数支持并发,可以被多个线程使用。

  • IDENTITY_FINISH 表示 finisher 函数是身份函数,因此可以被忽略。

  • UNORDERED 表示流是无序的,这为并行化提供了更多自由。

这里是一个将 Stream<String> 折叠成 List<List<String>>Collector 示例,模拟了给定最大长度行上的单词分组。首先,你定义一个泛型的 GroupingCollector

import java.util.ArrayList;
import java.util.List;
import java.util.function.*;
import java.util.stream.Collector;

import static java.util.stream.Collector.Characteristics.IDENTITY_FINISH;

public class GroupingCollector<T> {

  private final BiPredicate<List<T>, T> p;

  public GroupingCollector(BiPredicate<List<T>, T> p) {
    this.p = p;
  }

  public void accumulator(List<List<T>> llt, T t) {
    if (! llt.isEmpty()) {
      List<T> last = llt.get(llt.size() - 1);
      if (p.test(last, t)) {
        llt.get(llt.size() - 1).add(t);
      } else {
        addNewList(llt, t);
      }
    } else {
      addNewList(llt, t);
    }
  }

  public List<List<T>> combiner(List<List<T>> list1, List<List<T>> list2) {
    List<List<T>> result = new ArrayList<>();
    result.addAll(list1);
    result.addAll(list2);
    return result;
  }

  public static <T> void addNewList(List<List<T>> llt, T t) {
    List<T> list = new ArrayList<>();
    list.add(t);
    llt.add(list);
  }

  public Collector<T, List<List<T>>, List<List<T>>> collector() {
    return Collector.of(ArrayList::new, this::accumulator, this::combiner, IDENTITY_FINISH);
  }
}

然后,你创建一个特定的字符串分组收集器:

import java.util.List;
import java.util.function.BiPredicate;
import java.util.stream.Collector;

public class StringGrouperCollector {

  private StringGrouperCollector() {
  }

  public static Collector<String, List<List<String>>, List<List<String>>> getInstance(int length) {
    BiPredicate<List<String>, String> p = (ls, s) -> length(ls) + s.length() <= length;
    return new GroupingCollector<>(p).collector();
  }

  public static int length(List<String> list) {
    int length = 0;
    for (String s : list) {
      length += s.length();
    }
    return length;
  }
}

最后,你可以创建客户端代码来测试收集器:

public class Client {

  public static void main(String...args) {

   List<String> words2 = Arrays.asList("Once", "upon", "a", "time", "there", "was", "a", "prince",
        "who", "lived", "in", "a", "magnificent", "castle");
     words2.stream().collect(StringGrouperCollector.getInstance(20))
         .forEach(System.out::println);
  }
}

该程序打印以下内容:

[Once, upon, a, time, there]
[was, a, prince, who, lived, in]
[a, magnificent, castle]

原则与折叠操作完全相同,抽象了流元素迭代的迭代过程。

附录 B. 单子

在阅读这本书之后,你可能会对我没有谈论单子的事实感到惊讶(并且可能感到沮丧)。单子是一个热门话题,你可以在网上找到许多所谓的“单子教程”。单子的主题似乎非常令人生畏,许多程序员一个接一个地阅读这些教程,希望他们最终能理解单子是什么。当然,许多其他程序员确实理解单子,但很少有人能够用简单的话解释单子。

有这么多单子教程的原因可能是因为没有确切的教程,所以人们一直在尝试自己编写。这个附录不是另一个单子教程。我不愿意写一个,有两个原因:

  • 如果你已经阅读了这本书,你不需要单子教程。尽管我从未使用过单子这个术语,但你已经知道什么是单子。你知道这个概念,并在整本书中大量使用了它。你只需要给它命名。

  • 关于单子(monads)有一种古老的谚语:一旦你理解了它们,你就失去了向他人解释它们的能力。

但让我们看看别人对单子的看法。在网上搜索,你可以找到许多定义:

  • “单子是端点函子范畴中的单群。”

  • “单子是某个值的计算上下文。”

  • “单子是一个具有unit方法和flatmap方法的类。”

你也可能找到一些更奇特的定义:

  • “单子是卷饼。”

  • “单子是象。”

在第一个列表中,定义在某些上下文中是有效的...。第一个定义可能是范畴论上下文中最严谨的定义,范畴论是大多数程序员不太关心的数学的一个分支。(他们应该关心,但这又是另一个故事。)

第三个定义可能是 Java 程序员最容易理解的。方法的名字并不重要。重要的是这些方法必须遵守的规则。

第二个定义可能是理解单子最有用的。单子是计算上下文,函数式编程是用函数编程。安全的函数式编程是用全函数编程。不是全函数的函数被称为部分函数,这意味着它们并不总是有一个值(见第二章)。当它们没有值时,它们就不高兴,开始做可怕的事情。它们就不再是纯函数了。

考虑以下函数:

f(x, y) = x + y

这是一个纯函数吗?没有人能说。这取决于所使用的编程语言。在某些语言中,它可能会抛出一个算术溢出异常,因此它不会是一个全函数,因为它不会为所有(x, y)对定义。它不会是一个纯函数,因为抛出异常是一个副作用。

然而,在 Java 中使用整数,这个函数是纯函数。这意味着无论你给函数提供哪一对整数,它都会返回一个值,并且对于相同的对总是返回相同的值。所以你可以信任这个函数。这并不意味着结果总是正确的。在溢出的情况下,结果可能不是你想要的,但这又是另一个问题。总会有一个结果(这意味着程序不会在野外挂起)并且这个结果总是相同的。

这个函数怎么样?

g(x, y) = x / y

在 Java 和整数的背景下,这意味着一个函数接受一对整数作为其参数并返回一个整数,这不是一个全函数。对于某些整数对,它可能没有结果。如果第二个参数是 0,就没有结果,函数会抛出异常。这是因为如果将g视为从(整数,整数)到整数的函数,它不是一个全函数。

要使g成为一个全函数,有两种方法:改变定义域,使其成为(整数,非空整数)的函数,或者改变值域,使其成为(整数,整数)到(整数 | 异常)的函数。

要实现第一种方法,你必须创建一个新的类型:NonNullInteger。这是完全可能的。

要实现第二种方法,你还得创建一个新的类型:IntegerOrException

函数式程序员更喜欢第二种方法。

但如果你将函数g改为返回IntegerOrException,你就不能再将其与f组合。更准确地说,f . g (x),或者如果你喜欢这种表示法,f(g(x))将无法编译,因为类型不再匹配。

解决方案是在一个计算环境中创建函数,以便可以安全地执行。如果你喜欢隐喻,你可以将这个环境想象成一个安全盒。

所以,你需要做的是

  • 一个安全盒

  • 一种方法是将参数值放入盒子里

  • 一种方法是将修改后的函数放入盒子里,以便它可以应用于参数值

就这样。结果将是一个包含函数结果的盒子。

以 Java 为例,简单来说,你需要稍微修改一下要求,因为 Java 没有提供这样的安全盒。它提供了三种安全盒类型,但没有一种适合这种情况,所以你必须通过说在出错的情况下,结果不会抛出异常,而是简单地返回空值来修改要求。(注意,Java 有这种类型:Void。但实例化这种类型有点棘手。)

你可以用返回结果或无结果的Option来作为安全盒的类型,这是你在第六章中开发的,或者(更好)是第七章中的Result类型。在标准的 Java 8 中,可以是Optional类型。

在函数式语言中,将值放入盒子的方法通常命名为unitreturn,但你将其命名为of用于OptionResult,就像 Java 8 设计者对Optional所做的那样。这并没有改变什么。

允许你将修改后的函数应用于盒子内值的那个方法被称为flatMap。让我们以一个更简单的函数为例,它接受一个String并返回第一个字符。一个“正常”的方法可能看起来像这样:

public static char firstChar(String a) {
  if (a == null || a.length() == 0) {
    throw new IllegalArgumentException();
  } else {
    return a.charAt(0);
  }
}

要使这个函数返回第一个字符或无,你必须将其改为以下内容:

public static Optional<Character> firstChar(String a) {
  return a == null || a.length() == 0
      ? Optional.empty()
      : Optional.of(a.charAt(0));
}

要使用这些工具,你需要将一个String放入上下文中:

Optional<String> data = Optional.of("Hello!");
Optional<Character> character = data.flatMap(ThisClass::firstChar);

unit(单位)和flatMap方法就足够将Optional转换为单子。

然而,还有一些用例足够频繁,以至于在大多数单子实现中都被添加了。例如,你可能需要使用返回原始值的函数,如下所示:

public static int toUpper(char c) {
  return c >= 'a' && c <= 'z'
      ? c - 32
      : c;
}

你可以用以下方式做到这一点

Optional<Integer> upperChar = character.flatMap(x -> Optional.of(toUpper(x)));

但这并不非常高效,因为函数将结果包装在一个Optional中,只是为了flatMap方法解包它。因此,有一个针对这种情况的映射方法:

Optional<Integer> upperChar = character.map(ThisClass::toUpper);

注意,如果你使用返回Optional的函数与map一起使用,例如以下示例,你会得到Optional<Optional<Character>>

Optional<String> data = Optional.of("Hello!");
Optional<???> character = data.map(ThisClass::firstChar);

这可以通过一个名为flatten(或join)的方法转换为Optional<Character>,但这个方法在Optional中缺失。正如你所见,unitof)、flatMapmapflatten之间存在强烈的关联。flatten方法可以如下实现:

public static <T> Optional<T> flatten(Optional<Optional<T>> oot) {
  return oot.flatMap(Function.identity());
}

许多其他用例可以在单子内部抽象,但它们对于将类型转换为单子并不是必要的。最常用的之一是fold。这种方法通常被视为特定于向量类型,如ListStream,但实际上并非如此。例如,对于Optionfold可以在None<T>中实现如下:

public <U> U fold(U z, Function<U, Function<T, U>> f) {
  return z;
}

Some<T>中也是如此

public <U> U fold(U z, Function<U, Function<T, U>> f) {
  return f.apply(z).apply(value);
}

(技术上,这是一个左折叠,但这个例子中的差异无关紧要。)

你不能将此方法添加到 Java 8 类Optional中,因为它是final的。但你可以编写一个外部实现:

public static <T, U> U fold(U z, Function<U, Function<T, U>> f, Optional<T> ot) {
  return ot.isPresent() ? f.apply(z).apply(ot.get()) : z;
}

这里,你使用了Optional.get(),这非常糟糕。(从上下文外部访问值是被禁止的,这意味着这个方法不应该公开。)没有聪明的解决方案来解决这个问题。你知道如果值不存在,get方法永远不会被调用,所以你可以写出以下内容:

public static <T, U> U fold(U z, Function<U, Function<T, U>> f, Optional<T> ot) {
  return ot.isPresent() ? f.apply(z).apply(ot.orElse(null)) : z;
}

但这很丑。不那么丑的实现可能看起来像这样:

public static <T, U> U fold(U z, Function<U, Function<T, U>> f, Optional<T> ot) {
  return ot.isPresent()
    ? f.apply(z).apply(ot.orElseThrow(() ->
         new IllegalStateException("This exception is (never) thrown by dead code!")))
    : z;
}

无论如何,折叠Optional是没有用的,除非是为了理解orElse方法实际上是一个fold,可以定义为以下内容:

public static <T> T orElse(Optional<T> ot, T defaultValue) {
  return fold(defaultValue, ignore -> t -> t, ot);
}

是的,这同样毫无用处,但它在研究其他单子,如StreamList(当然不是java.util.List)时很有帮助。

许多其他方法本可以添加到Optional中,但它们缺失了,而且你不能添加它们,因为Optional是最终的。这是开发一个全新的Option单子的好理由。但与此同时,Optional几乎毫无用处,因为它不能携带数据缺失的原因。这就是为什么还需要另一个单子的原因。在这本书中,我们称之为Result,它大致对应于 Scala 的Try类。

附录 C. 从这里开始去哪里

你现在已经有了一些在 Java 中编写函数式程序的经验。你将把学到的东西应用到日常 Java 编程中的程度取决于你自己。试图做到 100% 函数式可能对许多 Java 程序员来说太多了一些。例如,使用完全函数式的 I/O,可能不是每个读者都希望在他们的生产代码中做的事情。但如果你想在专业项目中采用函数式编程范式,你有选择。

C.1. 选择一门新语言

第一个选择是你要使用的语言。通常,选择不同的(更友好的函数式语言)并不是一个选项。但有时是。我们只是触及了这个主题的表面,有了合适的工具,你可以走得更远。选择一种函数式语言可能看起来很复杂,但实际上并不复杂。如果你选择了一个在该领域更强大的语言,切换到另一种语言才会变得有趣。如果你已经阅读了这本书并想更进一步,你不会对弱类型语言感兴趣。所以你有三种可能的选择:Haskell、Scala 和 Kotlin(或者可能是第四种,Frege)。

C.1.1. Haskell

Haskell 是函数式编程的事实标准语言。Haskell 是一种强类型、惰性函数式语言,拥有几乎所有有抱负的函数式程序员可能梦想拥有的特性,以及许多你一开始难以理解的更复杂特性。大多数关于函数式编程的现代文章和书籍都使用 Haskell 作为示例。此外,它们使用的是 Haskell 的特定版本:格拉斯哥 Haskell 编译器(GHC)。

无论你是否能选择你喜欢的语言,如果你在一个团队工作或者必须使用遗留代码,这通常是不可能的,学习 Haskell 将是有益的。当你使用 Java 编写函数式程序时,你经常不得不与语言本身作斗争。使用 Haskell,你将不得不与之作斗争来编写命令式程序。学习 Haskell 将训练你的思维进入一种只有其他语言无法做到的函数式思维。即使你继续使用 Java,用 Haskell 原型化函数也是非常有益的。

对于 Java 程序员来说,Haskell 的主要问题在于一切都是新的。你将无法使用你习惯的任何常规 Java 工具(除了代码编辑器)或你习惯的众多库。当然,有很多 Haskell 库,但你将不得不从头开始学习一切,包括如何查找、下载和管理它们,如何构建你的程序,如何处理文档,以及其他所有事情。

C.1.2. Scala

另一个解决方案是切换到 Scala。Scala 不是一个严格的功能性语言。使用 Scala,你可以以命令式和函数式风格编写程序。切换到 Scala 很容易,因为你可以用类似 Java 的设计编写 Scala 程序,就像 Java 首次出现时,可以像编写 C 程序一样编写 Java 程序。当然,这不是最好的方法,我们许多在 Java 中的问题都源于这种 C 遗产。随着越来越多的 Java 程序员转向 Scala,我们将看到越来越多的命令式程序用这种语言编写。

因此,在 Scala 中编写函数式程序是一种纪律,但几乎没有什么缺失(如果你使用一些高级函数库)。巨大的优势是,你将能够重用你大部分已知的知识。你可以在 Eclipse、NetBeans 或 IntelliJ 中编写 Scala 程序。尽管 Scala 有自己的构建工具(sbt),但你也可以使用 Gradle 构建 Scala 程序,甚至可以使用 Maven 或 Ant(尽管没有人会想这样做?)。此外,你可以在 Scala 程序中使用所有现有的 Java 库。(当然,Scala 库也可以从 Java 中使用。)这些特性使得 Scala 成为处理遗留 Java 代码和工具时的一个很好的首选。

C.1.3. Kotlin

Kotlin 是由 IntelliJ IDE 的出版商 JetBrains 设计的一种新语言,IntelliJ IDE 是 Java 以及许多其他语言的最佳 IDE。Kotlin 是 Java 应该成为的样子。它拥有许多功能友好的特性,例如函数类型(允许你编写 (A) -> (B) -> C 而不是 Function<A, Function<B, C>>),数据类(自动生成构造函数、访问器以及 equalshashCode 方法),以及隐式方法调用(允许你以 f(x) 的形式调用函数,而不是更冗长的 Java 语法 f.apply(x))。此外,Kotlin 与 Java 完全兼容,可以在同一个项目中混合使用 Java 和 Kotlin。由于(目前)没有什么完美的事物,Kotlin 没有功能集合(意味着不可变、持久和共享数据的集合),但它使用特殊的机制——扩展函数——来使用 Java 标准集合。实际上,它允许以实例方法的方式调用静态方法,并使用 this 引用来引用“扩展”的实例。Kotlin 与 Java 的集成如此紧密,以至于你可以从向 Java 项目添加 Kotlin 类开始。你只需修改你的构建系统以添加 Kotlin 编译即可。对于开发来说,甚至这也不是必需的,因为 IntelliJ 允许透明地编译和运行混合的 Java/Kotlin 项目。截至本文撰写时,Kotlin 的版本是 1.0.5,因此在未来会有很多变化。版本 1.1 正在测试中,你应该在阅读本文时就可以使用。如果你对 Java 生态系统中的函数式编程感兴趣,这真的是你应该关注的事情。

C.1.4. Frege

另一个有潜力的解决方案是弗雷格语言(以德国数学家和哲学家戈特洛布·弗雷格的名字命名,发音类似于“frey-guh”)。弗雷格是一种非常年轻的语言,可能还不够成熟,不适合用于生产代码,但它正在快速发展,并可能成为 JVM 上纯函数式编程的首选语言。弗雷格实际上是“JVM 上的 Haskell”。它与 Haskell 尽可能接近,同时保留了使用所有现有 Java 库的可能性。因为它可以与 Java 混合(就像 Scala 一样),所以它是一个很好的平滑过渡选择。而且,如果你决定学习 Haskell 或 Kotlin 作为原型设计语言,为什么不也使用弗雷格呢?你可以在github.com/Frege/fregeandhttp://fregepl.blogspot.fr/找到更多关于弗雷格的信息。

C.1.5. 动态类型函数式语言怎么样?

动态类型函数式语言与之前的语言不同,因为它们不是依赖于类型系统来帮助程序员编写正确的程序,而是让程序员摆脱类型的束缚,允许他们编写类型错误的程序,这些程序可以编译。

为了让这种语言听起来像是一种好处,这类语言通常被称为“动态类型语言”。众所周知,动态类型比静态类型更好,所以这应该是一个质量特性。不幸的是,与 Java、Haskell 或 Scala 等“强类型”语言相比,这些语言最好被称为“弱类型”语言。这并不是说弱类型语言不好。它们只是有一个非常重要的区别:如果你与类型搞混,编译器通常不会警告你。程序只会在运行时崩溃。这是一个选择。你自己看看吧。

C.2. Staying with Java

你可以选择坚持使用 Java。为了从学习函数式范式过渡到将其应用于 Java 生产代码,你需要一个 Java 函数式库。你可以使用你在阅读这本书时开发的库,但你需要意识到维护一个库是一项巨大的任务。如果你是唯一的用户,这可能是最好的选择,因为你可以根据你的需求定制库。每次你发现一个可以抽象到库中的新函数时,你都可以自由地这样做。但如果你在一个团队中工作,那就另当别论了。你必须照顾到每个人的需求,小心不要破坏任何东西,并且始终保持向后兼容。这是一项非常繁重的工作。

另一个选择是使用由许多人开发和测试的现有开源库。你不会拥有添加所需新功能的相同自由度,但你很快就能变得高效。而且,如果你真的需要新功能,你可以自己添加并提议给社区。

C.2.1. 函数式 Java

函数式 Java 是早期使用至今的开源 Java 函数式库之一。它早于 Java 8,最初是用匿名类来表示函数编写的。如果你旨在成为一名极端的函数式程序员,这无疑是一个好事。即使你不是,使用它并查看其编码方式也是一种非常有益的经历。然而,请注意,文档很少。你将不得不自己弄清楚如何使用它,尽管这本书中学到的知识将大大帮助你。

还要注意,这个库是由许多伟大的函数式程序员开发的,其中一些人现在将他们的兴趣转向了更友好的函数式语言。你可以在本网站上找到更多信息:www.functionaljava.org/.

C.2.2. Javaslang

Javaslang 是一个较新的、不那么极端的 Java 函数式库。它有更好的文档,包括基本示例,尽管文档只有一页(大页)。在这里,你在这本书中学到的知识在使用 Javaslang 时将非常有帮助。正如我所说,Javaslang 的方法不那么极端,这可能使其更容易过渡,尤其是对于对函数式范式兴趣各异的团队。有一点小问题,尽管它有流,但这些流也面临着 Java 8 流的一个相同问题:它们没有懒折叠。然而,问题声明有计划实现它们。另一方面,它提供了一个可用的模式匹配机制。你可以在javaslang.io/找到有关这个库的信息。

C.2.3. Cyclops

Cyclops 被描述为“强大的、轻量级的和模块化的 JDK 8 扩展”,但它不仅仅是这样。实际上,它是 Java 的完整函数式库,并提供了利用标准 Java 数据类型以使其真正可用的额外支持。例如,它向标准 Java 集合添加了函数式方法,并且还提供了类似于你在本书中开发的不可变持久集合。Cyclops 还提供了 Java 8 Stream接口缺失的方法,如takeWhiledropWhile。Cyclops 充满了有趣的东西,如可重放流、记忆化、跳跃、模式匹配、元组等。它可能拥有所有可用 Java 函数式库中最好的文档。最后,它被设计成与其他库(如 Functional Java 或 Javaslang 或 Guava)协同工作。Cyclops 可以在github.com/aol/cyclops找到。

C.2.4. 其他函数式库

曾经还有其他 Java 函数式库,例如 Fun4j、LambdaJ、op4j 和 Apache Commons Functor。所有这些库都在 Java 8 之前出现,并且自 Java 8 发布以来都没有进化,这使它们大多变得过时。

Guava 库之所以持续发展,是因为它不仅仅是一个函数式库,而是一个包含函数在内的库。但 Guava 的函数式特性并没有太多发展,现在已经过时了。

C.3. 进一步阅读

如果你想了解更多关于函数式编程的信息,你可以在互联网上找到很多资源。关于函数式编程的文章和书籍已经有很多,但关于 Java 函数式编程的却不多。然而,你可能找到一些关于通用函数式编程的文章,这些文章以“函数式语言”为例,因为许多概念都适用于 Java。

这里是一个可能感兴趣的文献列表,但不全面:

  • John Hughes, “为什么函数式编程很重要,” 来自“函数式编程研究主题”,D. Turner 编著(Addison-Wesley,1990 年), mng.bz/qp3B。这篇文章非常有趣,主要讨论了高阶函数和惰性,并解释了为什么这些特性对于编写更好、更安全的程序至关重要。

  • Philip Walder,“免费定理!” (格拉斯哥大学,1989 年), mng.bz/my25。这篇文章比较难读,但如果你想知道强大的类型系统作为程序员能为你提供什么,那么这份努力是值得的。

  • Chris Okasaki, “纯函数式数据结构” (论文,卡内基梅隆大学计算机科学学院,1996 年), mng.bz/8Gz4. 这篇更容易阅读的大学论文讲述了如何构建纯函数式数据结构。示例是用标准 ML 编写的,这是一种函数式语言。Okasaki 基于这篇论文写了一本书,这本书更容易阅读,并且有 Haskell 语言的示例。如果你对函数式(不可变和持久)数据结构感兴趣,这本书是你必须阅读的。

  • Kimball Germane 和 Matthew Might, “删除:红黑树的诅咒,” 《函数式编程杂志》 24, 4 (2014): 423–433, mng.bz/yl57。这篇论文补充了 Okasaki 关于函数式红黑树的介绍。在他的书中,Okasaki 没有给出从该结构中删除元素的实现,而是将其留作读者的练习。这篇文章就是关于那个实现的。

  • Graham Hutton, “关于折叠的普遍性和表达性的教程,” 《函数式编程杂志》 9, 4 (1999): 355–372, mng.bz/me7Z. 这是最有趣的关于函数式编程的文章之一,而且非常易于阅读。如果你想要全面理解折叠,这是一篇必读的文章。

  • Ralf Hinze 和 Ross Patterson, “指针树:一种简单通用的数据结构,” mng.bz/AYZS。这篇文章介绍了一种非常有趣的函数式数据结构,它允许以良好的性能进行所有类型的访问和操作,尽管它并不像标题所说的那么简单。在 Java 中实现它是一个有价值的挑战。(有几种已知的实现。)

posted @ 2025-11-15 13:05  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报