Java 函数式编程三: Strings、 Comparators 和 Filters

JDK 包含许多有助于采用函数式风格的便捷方法。当使用库中常见的类和接口(例如 String)时,我们需要寻找机会用函数式风格替代命令式风格。此外,在任何使用只有一个方法的匿名内部类的地方,我们都可以使用 Lambda 表达式来减少代码的冗长和繁琐。

在本文中,我们将使用 Lambda 表达式和方法引用对字符串进行迭代、实现比较器、列出目录中的文件,以及观察文件和目录的变化。上一篇介绍的许多方法将再次出现,以协助完成当前的任务。你在学习过程中掌握的技术将有助于把冗长、繁琐的任务转化为简洁的代码片段,让你可以快速编写并轻松维护。

迭代字符串

String 类(实现了 CharSequence 接口)中的 chars 方法返回一个 IntStream,这对于流畅地迭代字符串中的字符很有用。我们可以使用这个便捷的内部迭代器对组成字符串的各个字符执行操作。让我们通过一个示例来处理字符串。在此过程中,我们将讨论更多使用方法引用的便捷方式。

final String str = "w00t";
str.chars()
     .forEach(ch -> System.out.println(ch));

chars 方法返回一个流,我们可以使用 forEach 内部迭代器对其进行迭代。在迭代器中,我们可以直接访问字符串中的字符。当我们迭代并打印每个字符时,结果如下:

119
48
48
116

结果并非我们所期望的。我们看到的不是字母,而是数字。这是由于 chars 方法返回的是一个表明字母的整数流,而不是字符流。在修复输出之前,让我们进一步探索一下这个 API。

在前面的代码中,我们在 forEach 方法的参数列表中创建了一个 Lambda 表达式。实则现只是一个简单的调用,我们将参数直接作为参数传递给 println 方法。由于这是一个简单的操作,我们可以在 Java 编译器的协助下消除这段繁琐的代码。我们可以依靠它来为我们完成参数传递,就像我们在“使用方法引用”中所做的那样使用方法引用。

我们已经看到了如何为实例方法创建方法引用。例如,对于 name.toUpperCase() 实例方法调用,方法引用是 String::toUpperCase。但在这个例子中,我们有一个对静态引用 System.out 的调用。在方法引用中,我们可以在双冒号左侧使用类名或表达式。利用这种灵活性,我们可以很容易地提供对 println 方法的引用,如下所示。

str.chars()
     .forEach(System.out::println);

在这个例子中,我们看到了 Java 编译器在参数传递方面的智能之处。请记住,Lambda 表达式和方法引用可以在需要函数式接口实现的地方使用,Java 编译器会在相应位置合成适当的方法(见“加点糖让代码更甜美”)。在我们之前使用的方法引用 String::toUpperCase 中,合成方法的参数变成了方法调用的目标,如下所示:parameter.toUpperCase();。这是由于方法引用基于类名(String)。在这个例子中,同样是对实例方法的方法引用,它基于一个表达式 —— 通过静态引用 System.out 访问的 PrintStream 实例。由于我们已经为方法提供了目标,Java 编译器决定将合成方法的参数作为被引用方法的参数,如下所示:System.out.println(parameter);,真不错。

使用方法引用的代码很简洁,但我们需要深入研究一下才能理解其工作原理。一旦我们习惯了方法引用,我们的大脑就会自动解析这些代码。

在这个例子中,虽然代码简洁,但输出并不令人满意。我们希望看到字母而不是数字。为了解决这个问题,让我们编写一个便捷方法,将整数作为字母打印出来。

private static void printChar(int aChar) {
    System.out.println((char)(aChar));
}

我们可以使用对这个便捷方法的引用修复输出。

str.chars()
     .forEach(IterateString::printChar);

我们可以继续将 chars 的结果作为整数使用,当需要打印时,再将结果转换为字符。这个版本的输出将显示字母。

w
0
0
t

如果我们从一开始就想处理字符而不是整数,可以在调用 chars 方法后立即将整数转换为字符,如下所示:

str.chars()
     .mapToObj(ch -> Character.valueOf((char)ch))
     .forEach(System.out::println);

chars 方法返回一个 IntStream 实例。如果我们对它调用 map 方法,结果也将是一个 IntStream。但我们想要一个字符流(Stream<Character>),为了实现这一点,我们使用 mapToObj 而不是 map。

我们对 chars 方法返回的流使用了内部迭代器,但我们并不局限于这个方法。一旦我们得到一个流,就可以使用它提供的任何方法,如 map、filter、reduce 等,来处理字符串中的字符。例如,我们可以从字符串中过滤出仅有的数字,如下所示:

str.chars()
     .filter(ch -> Character.isDigit(ch))
     .forEach(ch -> printChar(ch));

我们可以在下面的输出中看到过滤后的数字。

0
0

同样,我们可以使用对相应方法的引用,而不是传递给 filter 方法和 forEach 方法的 Lambda 表达式。

str.chars()
     .filter(Character::isDigit)
     .forEach(IterateString::printChar);

这里的方法引用有助于消除繁琐的参数传递。此外,在这个例子中,与我们之前使用方法引用的两个实例相比,我们又看到了方法引用的另一种变体。当我们第一次看到方法引用时,我们为实例方法创建了一个引用。后来,我们为对静态引用的调用创建了一个引用。目前,我们为静态方法创建了一个方法引用 —— 方法引用似乎不断带来新的用法。

实例方法的方法引用和静态方法的方法引用在结构上看起来是一样的,例如 String::toUpperCase 和 Character::isDigit。为了决定如何传递参数,Java 编译器会检查该方法是实例方法还是静态方法。如果是实例方法,那么合成方法的参数将成为调用的目标,如 parameter.toUpperCase();(如果目标已经指定,如 System.out::println,则是例外情况)。另一方面,如果是静态方法,那么合成方法的参数将作为参数传递给该方法,如 Character.isDigit(parameter);。有关方法引用变体及其语法的列表,请参阅附录 2“语法概述”。

虽然这种参数传递很方便,但有一个需要注意的地方 —— 方法冲突导致的歧义。如果既有匹配的实例方法又有静态方法,由于引用的歧义,我们会得到一个编译错误。例如,如果我们编写 Double::toString 来将 Double 实例转换为 String,编译器会困惑是使用 public String toString() 实例方法还是 public static String toString(double value) 静态方法,这两个方法都来自 Double 类。如果遇到这种情况,不用担心;我们只需切换回使用适当的 Lambda 表达式版本即可继续。

一旦我们习惯了函数式风格,就可以根据自己的舒服度在 Lambda 表达式和更简洁的方法引用之间切换。

我们使用 chars() 方法轻松地迭代了字符。接下来,我们将探索 Comparator 接口的增强功能。

实现 Comparator 接口

Comparator 接口在 JDK 库中被广泛使用,从搜索操作到排序、反转等。这个古老而实用的接口如今已成为函数式接口,好处是我们可以使用极为流畅的语法来实现比较器。

让我们创建几种不同的 Comparator 实现,以了解函数式风格的影响。不用再创建匿名内部类,我们的手指会感谢我们节省了大量的按键操作。

使用比较器进行排序

我们将构建一个示例,使用不同的比较点对人员列表进行排序。第一,让我们创建 Person JavaBean。

public class Person {
    private final String name;
    private final int age;

    public Person(final String theName, final int theAge) {
        name = theName;
        age = theAge;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public int ageDifference(final Person other) {
        return age - other.age;
    }

    public String toString() {
        return String.format("%s - %d", name, age);
    }
}

我们可以在 Person 类上实现 Comparable 接口,但这会将我们限制在一种特定的比较方式上。我们希望进行不同的比较,例如按姓名、年龄或字段组合进行比较。为了获得这种灵活性,我们将在需要时借助 Comparator 接口创建不同比较方式的代码。

让我们创建一个人员列表,其中包含不同姓名和年龄的人。

final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35));

我们可以按姓名或年龄对人员进行排序,并且可以按升序或降序排列。按照传统方式实现这一点,我们会使用匿名内部类来实现 Comparator 接口。但这里的核心是比较逻辑的代码,我们编写的其他任何内容都只是形式。我们可以使用 Lambda 表达式将其简化为核心部分。

第一,让我们按年龄升序对列表中的人员进行排序。 由于我们有一个 List,显然的选择是使用 List 的 sort 方法。但这个方法有一些缺点。它是一个 void 方法,这意味着调用它时列表会被修改。为了保留原始列表,我们必须复制一份,然后在副本上调用 sort 方法,这相当繁琐。相反,我们将借助 Stream。 我们可以从 List 中获取一个 Stream,并方便地对其调用 sorted 方法。它不会修改给定的集合,而是会返回一个排序后的集合。我们在调用此方法时可以很好地配置 Comparator 参数。

List<Person> ascendingAge =
        people.stream()
              .sorted((person1, person2) -> person1.ageDifference(person2))
              .collect(toList());
printPeople("Sorted in ascending order by age: ", ascendingAge);

我们第一使用 stream 方法将给定的人员 List 转换为 Stream。然后对其调用 sorted 方法。这个方法接受一个 Comparator 作为参数。由于 Comparator 是一个函数式接口,我们方便地传入了一个 Lambda 表达式。最后,我们调用 collect 方法,并要求它将结果放入一个 List 中。请记住,collect 方法是一个归约操作,它将协助把转换后的迭代元素收集到所需的类型或格式中。toList 是 Collectors 工具类的静态方法。

Comparator 的 compareTo 抽象方法接受两个参数,即要比较的对象,并返回一个 int 结果。为了符合这一点,我们的 Lambda 表达式接受两个 Person 实例作为参数,其类型由 Java 编译器推断。我们返回一个 int 表明对象是否相等。 由于我们想按年龄属性排序,我们比较给定的两个人的年龄并返回差值。如果他们年龄一样,我们的 Lambda 表达式将返回 0 表明他们相等。否则,它将通过返回负数表明第一个人更年轻,或者通过返回正数表明第一个人更年长。 sorted 方法将遍历目标集合(在这个例子中是 people)中的每个元素,并应用给定的 Comparator(在这种情况下是一个 Lambda 表达式)来确定元素的逻辑顺序。sorted 的执行机制与我们之前看到的 reduce 方法超级类似。reduce 方法将列表归约为一个值。而 sorted 方法则使用比较结果进行排序。 一旦我们对实例进行了排序,我们希望打印这些值,因此我们调用一个便捷方法 printPeople;接下来让我们编写这个方法。

public static void printPeople(
        final String message, final List<Person> people) {
    System.out.println(message);
    people.forEach(System.out::println);
}

在这个方法中,我们打印一条消息,并遍历给定的集合,打印每个实例。 让我们调用 sorted 方法,列表中的人员将按年龄升序打印。

Sorted in ascending order by age:
John - 20
Sara - 21
Jane - 21
Greg - 35

让我们重新审视对 sorted 方法的调用,并对其进行进一步改善。

.sorted((person1, person2) -> person1.ageDifference(person2))

在传递给 sorted 方法的 Lambda 表达式中,我们只是将两个参数进行传递:将第一个参数作为 ageDifference 方法的调用目标,将第二个参数作为其参数。我们可以不编写这段代码,而是使用一种便捷方式(即再次让 Java 编译器进行参数传递,使用方法引用)。 这里我们需要的参数传递与之前看到的有所不同。到目前为止,我们看到参数在一种情况下用作调用目标,在另一种情况下用作参数。不过,在当前情况下,我们有两个参数需要分开处理:第一个用作方法的调用目标,第二个用作参数。不用担心。Java 编译器会友善地表明:“我可以帮你处理这个。” 让我们用对 ageDifference 方法简洁的引用替换之前对 sorted 方法调用中的 Lambda 表达式。

people.stream()
      .sorted(Person::ageDifference)

由于 Java 编译器提供的方法引用便利,代码变得极其简洁。编译器获取参数,即正在比较的两个 Person 实例,将第一个作为 ageDifference 方法的调用目标,将第二个作为参数。我们没有显式地进行连接,而是让编译器为我们多做一些工作。在使用这种简洁性时,我们必须小心确保第一个参数是所引用方法的预期调用目标,其余参数是其参数。

复用比较器

我们轻松地按年龄升序对人员进行了排序,同样也可以轻松地按降序排序。让我们试试看。

printPeople("Sorted in descending order by age: ",
        people.stream()
              .sorted((person1, person2) -> person2.ageDifference(person1))
              .collect(toList()));

我们调用了 sorted 方法,并传递了一个符合 Comparator 接口的 Lambda 表达式,与之前类似。唯一的区别是 Lambda 表达式的实现 —— 我们在年龄比较中交换了两个人的顺序。结果应该是按年龄降序排序。让我们看看输出。

Sorted in descending order by age:
Greg - 35
Sara - 21
Jane - 21
John - 20

改变比较逻辑毫不费力。但我们无法将这个版本重构为使用方法引用,由于这里的参数顺序不符合方法引用的参数传递约定;第一个参数不是用作方法的调用目标,而是用作其参数。有一种方法可以解决这个问题,并在此过程中消除悄悄出现的重复工作。让我们看看怎么做。 之前我们创建了两个 Lambda 表达式:一个用于按升序排列两个人的年龄,另一个用于按降序排列。这样做时,我们重复了逻辑和工作,违反了 DRY 原则。如果我们只是想反转比较顺序,JDK 为我们提供了 Comparator 上的 reversed 方法,该方法带有一个名为 default 的特殊方法修饰符。我们将在“初探默认方法”中讨论默认方法,但这里我们将使用 reversed 方法来消除重复。

Comparator<Person> compareAscending =
        (person1, person2) -> person1.ageDifference(person2);
Comparator<Person> compareDescending = compareAscending.reversed();

我们第一使用 Lambda 表达式语法创建了一个 Comparator compareAscending,用于按升序比较人员的年龄。为了反转比较顺序,我们不用重复工作,只需在第一个 Comparator 上调用 reversed 方法,即可得到另一个比较顺序相反的 Comparator。实际上,reversed 方法创建了一个比较器,它会交换其参数的比较顺序。这使得 reversed 方法成为一个高阶方法 —— 这个函数创建并返回另一个没有副作用的函数式表达式。让我们在代码中使用这两个比较器。

printPeople("Sorted in ascending order by age: ",
        people.stream()
              .sorted(compareAscending)
              .collect(toList())
);
printPeople("Sorted in descending order by age: ",
        people.stream()
              .sorted(compareDescending)
              .collect(toList())
);

目前很明显,Java 的现代特性可以大大降低代码的复杂性和重复工作,但要获得所有好处,我们必须探索 JDK 提供的看似无穷无尽的可能性。 我们一直在按年龄排序,但我们也可以轻松地按姓名排序。让我们按姓名的字母升序排序;同样,只需更改 Lambda 表达式中的逻辑。

printPeople("Sorted in ascending order by name: ",
        people.stream()
              .sorted((person1, person2) ->
                      person1.getName().compareTo(person2.getName()))
              .collect(toList()));

在输出中,我们目前应该看到人员按姓名的字母升序列出。

Sorted in ascending order by name:
Greg - 35
Jane - 21
John - 20
Sara - 21

到目前为止,我们的比较要么基于年龄属性,要么基于姓名属性。我们可以让 Lambda 表达式中的逻辑更加智能。例如,我们可以同时基于姓名和年龄进行排序。 让我们找出列表中最年轻的人。我们可以在按年龄升序排序后找到第一个人。但我们不用这么麻烦;Stream 为我们提供了 min 方法。这个方法也接受一个 Comparator,但会根据给定的比较器返回集合中最小的对象。 让我们使用这个方法。

people.stream()
      .min(Person::ageDifference)
      .ifPresent(youngest -> System.out.println("Youngest: " + youngest));

我们在调用 min 方法时使用了 ageDifference 方法的引用。min 方法返回一个 Optional,由于列表可能为空,所以可能没有最年轻的人。然后我们使用 Optional 的 ifPresent 方法打印我们从 Optional 中获取到的最年轻的人的详细信息。 让我们看看输出。

Youngest: John - 20

我们也可以同样轻松地找出列表中最年长的人。只需将该方法引用传递给 max 方法。

people.stream()
      .max(Person::ageDifference)
      .ifPresent(eldest -> System.out.println("Eldest: " + eldest));

让我们看看列表中最年长的人的姓名和年龄的输出。

Eldest: Greg - 35

我们看到了 Lambda 表达式和方法引用如何使实现比较器变得简洁和容易。就 JDK 而言,Comparator 接口增加了一些便捷方法,使比较更加流畅,我们接下来会看到。

多重且流畅的比较

让我们来看看 Comparator 接口的便捷方法,并使用它们轻松地基于多个属性进行比较。

我们将继续使用上一节的示例。为了按姓名对人员进行排序,我们使用了以下代码:

people.stream()
      .sorted((person1, person2) ->
              person1.getName().compareTo(person2.getName()));

与过去使用内部类的语法相比,这种语法已经很简洁了。但由于 Comparator 接口中的便捷函数,我们可以做得更好。使用这些函数,我们可以更流畅地表达我们的目标。例如,要通过比较人员的姓名来对他们进行排序,我们可以这样写:

final Function<Person, String> byName = person -> person.getName();
people.stream()
      .sorted(comparing(byName));

在这段代码中,我们静态导入了 Comparator 接口中的 comparing 方法。comparing 方法使用提供的 Lambda 表达式中嵌入的逻辑来创建一个 Comparator。换句话说,它是一个高阶函数,接受一个函数(Function)并返回另一个函数(Comparator)。除了使语法更简洁之外,目前的代码读起来很流畅,能清晰地表达要解决的问题。

我们可以进一步利用这种流畅性进行多重比较。例如,以下是一段清晰的语法,用于按年龄和姓名升序对人员进行排序:

final Function<Person, Integer> byAge = person -> person.getAge();
final Function<Person, String> byTheirName = person -> person.getName();
printPeople("Sorted in ascending order by age and name: ",
        people.stream()
              .sorted(comparing(byAge).thenComparing(byTheirName))
              .collect(toList()));

我们第一创建了两个 Lambda 表达式:一个用于返回给定人员的年龄,另一个用于返回该人员的姓名。然后,我们在调用 sorted 方法时将这两个 Lambda 表达式组合起来,以基于这两个属性进行比较。comparing 方法创建并返回一个基于年龄进行比较的 Comparator。在返回的 Comparator 上,我们调用 thenComparing 方法来创建一个复合比较器,该比较器基于年龄和姓名进行比较。这段代码的输出显示了先按年龄排序,然后按姓名排序的最终结果。

Sorted in ascending order by age and name:
John - 20
Jane - 21
Sara - 21
Greg - 35

正如我们所见,借助 Lambda 表达式的便利性和 JDK 中的工具类,很容易组合 Comparator 实现。接下来,我们将研究 Collectors。

使用 collect 方法和 Collectors 类

在之前的示例中,我们多次使用 collect 方法将流中的元素收集到 ArrayList 中。该方法属于归约操作,可将集合转换为另一种形式,一般是可变集合。结合 Collectors 类的工具方法,collect 函数提供了诸多便利,本节将详细介绍。

以 Person 列表为例,来看看 collect 方法的强劲之处。假设我们要从原始列表中收集年龄大于 20 岁的人。以下是一个使用可变对象和 forEach 的版本:

List<Person> olderThan20 = new ArrayList<>();
people.stream()
      .filter(person -> person.getAge() > 20)
      .forEach(person -> olderThan20.add(person)); // 不推荐
System.out.println("People older than 20: " + olderThan20);

我们使用 filter 方法从 Person 列表中筛选出年龄大于 20 岁的人。然后,在 forEach 方法中,将这些元素添加到迭代开始前初始化的 ArrayList 中。在重构代码之前,先看看这段代码的输出:

People older than 20: [Sara - 21, Jane - 21, Greg - 35]

代码实现了预期结果,但存在一些问题。第一,将元素添加到目标集合的操作是超级底层的,是命令式而非声明式的。如果要使迭代并发执行,就必须立即处理线程安全问题,由于可变性使得并行化变得困难。幸运的是,使用 collect 方法可以轻松解决这些问题。下面看看具体做法。

collect 方法接收一个元素流,并将它们收集到一个结果容器中。为此,该方法需要知道三件事:

  1. 如何创建结果容器(例如,使用 ArrayList::new 方法)。
  2. 如何将单个元素添加到结果容器中(例如,使用 ArrayList::add 方法)。
  3. 如何将一个结果容器合并到另一个结果容器中(例如,使用 ArrayList::addAll 方法)。

对于纯顺序操作,最后一项可能不是必需的;代码设计为同时适用于顺序和并行执行。

下面为 collect 方法提供这些操作,以收集过滤操作后流的结果:

List<Person> olderThan20 =
        people.stream()
              .filter(person -> person.getAge() > 20)
              .collect(ArrayList::new, ArrayList::add, ArrayList::addAll); // 代码冗长
System.out.println("People older than 20: " + olderThan20);

这个版本的代码产生的结果与上一个版本一样,但有许多优点。 第一,我们的编程更具目的性和表现力,明确表明了将结果收集到 ArrayList 中的目标。collect 方法的第一个参数是一个工厂或供应商,后面跟着将元素累积到集合中的操作。 其次,由于代码中没有执行任何显式的可变操作,因此很容易并行化迭代的执行。由于我们让库来控制可变操作,它可以为我们处理协调和线程安全问题。尽管 ArrayList 本身不是线程安全的,但这样做依然可行,很巧妙。

collect 方法可以在适当的时候将元素并行添加到不同的子列表中,然后以线程安全的方式将它们合并成一个更大的列表(因此最后一个参数用于协助合并列表)。

我们看到了 collect 方法相对于手动将元素添加到 ArrayList 中的优势。接下来,看看这个方法的另一个重载版本,它更简单、更方便,使用 Collector 作为参数。Collector 将供应商、累加器和组合器的操作整合到一个接口中,即我们在前面示例中作为三个单独参数指定的操作,这样更方便且可重用。Collectors 工具类提供了一个 toList 便捷方法,用于创建 Collector 接口的实现,将元素累积到 ArrayList 中。让我们修改上一个版本的代码,使用这个版本的 collect 方法:

List<Person> olderThan20 =
        people.stream()
              .filter(person -> person.getAge() > 20)
              .collect(Collectors.toList());
System.out.println("People older than 20: " + olderThan20);

这个简洁版本的 collect 方法与 Collectors 工具类的便利之处还不止于此。Collectors 类中有几个方法可用于执行各种收集或累积操作。例如,除了 toList,还有 toSet 用于将元素累积到集合中,toMap 用于将元素收集到键值对集合中,joining 用于将元素连接成一个字符串。我们还可以使用 mapping、collectingAndThen、minBy、maxBy 和 groupingBy 等方法组合多个组合操作。

让我们使用 groupingBy 按年龄对人员进行分组:

Map<Integer, List<Person>> peopleByAge =
        people.stream()
              .collect(Collectors.groupingBy(Person::getAge));
System.out.println("Grouped by age: " + peopleByAge);

通过简单地调用 collect 方法,我们就能够执行分组操作。groupingBy 方法接受一个 Lambda 表达式或方法引用,称为分类器函数,它返回我们要进行分组的属性值。根据该函数的返回值,它将元素放入相应的桶或组中。从输出中可以看到分组结果:

Grouped by age: {35=[Greg - 35], 20=[John - 20], 21=[Sara - 21, Jane - 21]}

Person 实例按年龄进行了分组。

在前面的示例中,我们按年龄对人员进行了分组和收集。groupingBy 方法的一个变体可以组合多个条件。简单的 groupingBy 收集器使用分类器将元素流组织到桶中。而通用的 groupingBy 收集器可以对每个桶应用另一个收集器。换句话说,收集到桶中的值可以在下游进行更多的分类和收集,接下来我们会看到。

继续前面的示例,我们不创建按年龄分组的所有 Person 对象的映射,而是获取按年龄分组的人员姓名:

Map<Integer, List<String>> nameOfPeopleByAge =
        people.stream()
              .collect(
                      groupingBy(Person::getAge, mapping(Person::getName, toList())));
System.out.println("People grouped by age: " + nameOfPeopleByAge);

在这个版本中,groupingBy 接受两个参数:第一个是年龄,作为分组的标准;第二个是 Collector,是调用 mapping 函数的结果。这些方法来自 Collectors 工具类,在代码中通过静态导入使用。mapping 方法接受两个参数,即要映射的属性(在本例中是姓名)和要收集到的对象类型,如列表或集合。看看这段代码的输出:

People grouped by age: {35=[Greg], 20=[John], 21=[Sara, Jane]}

可以看到,姓名列表按年龄进行了分组。

再看一个组合示例。我们按姓名的首字母对姓名进行分组,然后找出每个组中年龄最大的人:

Comparator<Person> byAge = Comparator.comparing(Person::getAge);
Map<Character, Optional<Person>> oldestPersonOfEachLetter =
        people.stream()
              .collect(groupingBy(person -> person.getName().charAt(0),
                      reducing(BinaryOperator.maxBy(byAge))));
System.out.println("Oldest person of each letter:");
System.out.println(oldestPersonOfEachLetter);

第一,根据姓名的首字母对姓名进行分组。为此,我们将一个 Lambda 表达式作为第一个参数传递给 groupingBy 方法。在这个 Lambda 表达式中,我们返回姓名的首字母用于分组。在这个示例中,第二个参数不是 mapping,而是执行归约操作。在每个组中,它将元素归约为年龄最大的人,由 maxBy 方法决定。由于操作的组合,语法有点复杂,但可以这样理解:按姓名的首字母分组,并归约为年龄最大的人。看看输出,它列出了以给定字母开头的每个姓名分组中年龄最大的人:

Oldest person of each letter:
{S=Optional[Sara - 21], G=Optional[Greg - 35], J=Optional[Jane - 21]}

我们已经看到了 collect 方法和 Collectors 类的强劲功能。花几分钟时间在集成开发环境或文档中查看 Collectors 工具类,熟悉它提供的功能。接下来,我们将使用 Lambda 表达式来替代一些过滤器。

列出目录中的所有文件

使用 File 类的 list 方法来列出目录中的所有文件名超级简单。若要获取所有文件而不只是它们的名称,我们可以使用 listFiles 方法。这很容易,但挑战在于得到列表后该如何处理。

我们可以使用优雅的函数式风格工具来遍历列表,而不是冗长的传统外部迭代器。为了实现这一点,我们需要借助 JDK 的 CloseableStream 接口以及一些相关的高阶便捷函数。

以下是列出当前目录中所有文件名称的代码:

Files.list(Paths.get("."))
      .forEach(System.out::println);

若要列出不同目录中的文件,我们可以将 “.” 替换为所需目录的完整路径。

我们第一使用 Paths 便捷类的 get 方法从字符串创建一个 Path 实例。然后,使用 Files 工具类(在 java.nio.file 包中)的 list 方法,得到一个 CloseableStream 来遍历给定路径中的文件。接着,我们在该流上使用内部迭代器 forEach 来打印文件名。

让我们看看这段代码的部分输出:列出当前目录的文件和子目录。

./aSampleFiles.txt
122
./bin
./fpij
...

如果我们只想要当前目录中的子目录而不是列出所有文件,可以使用 filter 方法:

Files.list(Paths.get("."))
      .filter(Files::isDirectory)
      .forEach(System.out::println);

filter 方法从文件流中仅提取出目录。我们没有传入 Lambda 表达式,而是提供了一个对 Files 类的 isDirectory 方法的方法引用。

请记住,filter 方法期望一个返回布尔结果的 Predicate,因此这个方法符合要求。最后,我们使用内部迭代器来打印目录名称。

这段代码的输出将显示当前目录的子目录。

./bin
./fpij
./output
...

这很简单,而且比使用旧式 Java 代码所需的行数更少。接下来,让我们看看如何仅列出符合特定模式的文件。

列出目录中选定的文件

Java 长期以来一直提供 list 方法的一种变体来挑选文件名。这个版本的 list 方法接受一个 FilenameFilter 作为其参数。这个接口有一个 accept 方法,它接受两个参数:File dir(表明目录)和 String name(表明文件名)。我们在 accept 方法中返回 true 就会将给定的文件名包含在列表中,否则返回 false。让我们探讨实现这个方法的选项。

在 Java 中,习惯做法是将实现 FilenameFilter 接口的匿名内部类的实例传递给 list 方法。例如,让我们看看如何使用这种方法来选择 fpij 目录中仅有的 Java 文件。

final String[] files =
    new File("fpij").list(new java.io.FilenameFilter() {
        public boolean accept(final File dir, final String name) {
            return name.endsWith(".java");
        }
    });
if(files != null) {
    for(String file: files) {
        System.out.println(file);
    }
}

这需要一些精力和几行代码。代码中有许多冗余内容:对象创建、函数调用、匿名内部类定义以及该类中的嵌入式方法等等。我们不必再忍受这种痛苦了;我们可以简单地传递一个接受两个参数并返回布尔结果的 Lambda 表达式。Java 编译器会为我们处理其余的事情。

虽然我们可以在前面的示例中简单地用 Lambda 表达式替换匿名内部类,但我们可以做得更好。DirectoryStream 工具可以协助更高效地遍历大型目录结构,所以让我们探索这种方法。newDirectoryStream 方法有一个变体,它接受一个额外的过滤参数。

让我们使用 Lambda 表达式来获取 fpij 目录中所有 Java 文件的列表。

Files.newDirectoryStream(
    Paths.get("fpij"), path -> path.toString().endsWith(".java"))
      .forEach(System.out::println);

我们去掉了匿名内部类,将冗长的代码变成了简洁的代码。这两个版本的最终效果是一样的。让我们打印选定的文件。

代码将仅显示上述目录中的 .java 文件,部分输出如下:

fpij/Compare.java
fpij/IterateString.java
fpij/ListDirs.java
...

我们根据文件名挑选文件,但我们也可以轻松地根据文件属性(如文件是否可执行、可读或可写)来挑选文件。为此,我们需要 listFiles 方法的一个变体,它接受 FileFilter 作为其参数。

同样,我们可以使用 Lambda 表达式而不是创建匿名内部类。让我们看一个列出当前目录中所有隐藏文件的示例。

final File[] files = new File(".").listFiles(file -> file.isHidden());

如果我们处理的是一个大型目录,那么我们可以使用 DirectoryStream 而不是直接使用 File 类的方法。

我们传递给 listFiles 方法的 Lambda 表达式的签名符合 FileFilter 接口的 accept 方法的签名。在 Lambda 表达式中,我们接收一个 File 实例作为参数,在这个例子中名为 file。如果文件具有隐藏属性,我们返回布尔值 true,否则返回 false。

我们可以进一步简化这里的代码;我们可以使用方法引用而不是传递 Lambda 表达式,使代码更简洁:

new File(".").listFiles(File::isHidden);

我们先创建了 Lambda 表达式版本,然后将其重构为更简洁的方法引用版本。在编写新代码时,采用这种方法完全没问题。如果我们一眼就能看出简洁的代码,那么当然可以直接输入。本着“先让它工作,再让它更好”的精神,先让简单的代码工作起来是很好的,一旦我们理解了代码,就可以采取下一步行动,对其进行重构以提高简洁性、性能等等。

我们通过一个示例来从目录中过滤出选定的文件。接下来,我们将看看如何探索给定目录的子目录。

使用 flatMap 列出直接子目录

我们已经了解了如何列出给定目录中的成员。接下来,我们先通过基本操作,然后更方便地使用 flatMap 方法,来探讨如何探索给定目录的直接(一级深度)子目录。

第一,让我们使用传统的 for 循环来遍历给定目录中的文件。如果子目录包含任何文件,我们将这些文件添加到列表中;否则,我们将子目录本身添加到列表中。最后,我们将打印找到的文件总数。以下是实现该功能的代码——较为繁琐的方式。

public static void listTheHardWay() {
    List<File> files = new ArrayList<>();
    File[] filesInCurrentDir = new File(".").listFiles();
    for(File file : filesInCurrentDir) {
        File[] filesInSubDir = file.listFiles();
        if(filesInSubDir != null) {
            files.addAll(Arrays.asList(filesInSubDir));
        } else {
            files.add(file);
        }
    }
    System.out.println("Count: " + files.size());
}

我们获取当前目录中的文件列表,并遍历每个文件。对于每个文件,我们查询其子文件,如果存在则将它们添加到文件列表中。这种方法可行,但存在常见的问题:可变性、对基本类型的过度依赖、命令式编程以及代码冗余等。我们可以使用一个名为 flatMap 的好用的小方法来消除这些问题。

正如其名称所示,这个方法会在映射后进行扁平化处理。它和 map 方法类似,会对集合中的元素进行映射。但与 map 方法不同的是,在 map 方法中我们一般从 Lambda 表达式返回一个元素,而在这里我们返回一个 Stream。然后,该方法会将通过映射每个元素得到的多个流扁平化为一个单一的流。

我们可以将 flatMap 用于各种操作,但手头的问题很好地展示了这个方法的实用性。每个子目录都有一个文件列表或流,我们尝试获取当前目录所有子目录中文件的组合(或扁平化)列表。

有些目录(或文件)可能为空,没有子文件。在这种情况下,我们只需将一个不包含子文件的目录或文件元素包装成一个流。如果我们选择忽略某个文件,JDK 中的 flatMap 方法可以很好地处理空值;它会将对 Stream 的空引用合并为一个空集合。让我们看看 flatMap 方法的实际应用。

public static void betterWay() {
    List<File> files =
        Stream.of(new File(".").listFiles())
           .flatMap(file -> file.listFiles() == null ?
                   Stream.of(file) : Stream.of(file.listFiles()))
           .collect(toList());
    System.out.println("Count: " + files.size());
}

我们获取当前目录中的文件流,并对其调用 flatMap 方法。我们向该方法传递一个 Lambda 表达式,该表达式为给定的文件返回一个包含其子文件的流。flatMap 方法返回当前目录子目录中所有子文件集合的扁平化映射。我们使用 collect 方法和 Collectors 类的 toList 方法将这些元素收集回一个列表中。

我们作为参数传递给 flatMap 方法的 Lambda 表达式,对于给定的文件,如果有子文件,则返回一个包含其子文件的流;否则,返回一个仅包含该文件的流。flatMap 方法优雅地处理了这种情况,将这些流映射到一个结果流集合中,最后将其扁平化为一个最终的文件流。

flatMap 方法省去了许多麻烦——它很好地将两个操作序列(一般称为单子组合)合并为一个优雅的步骤。

我们已经了解了 flatMap 方法如何简化列出子目录直接(一级深度)内容的任务。接下来,我们将创建一个文件更改监听器。

监控文件更改

我们知道如何查找文件和目录,但如果我们想在文件被创建、修改或删除时坐享其成地接收警报,这也很容易。这样的功能对于监控配置文件和系统资源等特殊文件的更改超级有用。在这里,我们将探索自 Java 7 以来就可用的 WatchService 功能,用于监控文件更改。我们在这里看到的大多数功能都来自 JDK 7,主要的改善在于内部迭代器的便利性。

让我们创建一个示例来监控当前目录中的文件更改。JDK 中的 Path 类可以指向文件系统的一个实例,该实例可作为监控服务的工厂。我们可以向这个服务注册以接收任何通知,如下所示:

final Path path = Paths.get(".");
final WatchService watchService =
    path.getFileSystem()
       .newWatchService();
path.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);
System.out.println("Report any file changed within next 1 minute...");

我们注册了一个 WatchService 来观察当前目录的任何更改。我们可以轮询这个监控服务,以了解该目录中文件的任何更改,它将通过一个 WatchKey 通知我们。一旦我们获得了这个键,就可以遍历所有事件以获取文件更新的详细信息。由于多个文件可能同时更改,一次轮询可能会返回一个事件集合,而不是单个事件。

让我们看看轮询和迭代的代码。

final WatchKey watchKey = watchService.poll(1, TimeUnit.MINUTES);
if(watchKey != null) {
    watchKey.pollEvents()
           .stream()
           .forEach(event ->
                   System.out.println(event.context()));
}

我们在这里看到了旧版本 Java 和较新版本 Java 特性的相互作用。我们将 pollEvents 方法返回的集合转换为一个流,然后使用内部迭代器来显示更新文件的详细信息。

让我们运行代码,更改当前目录中的 sample.txt 文件,看看程序是否会报告这个更改。

Report any file changed within next 1 minute...
sample.txt

当我们修改文件时,程序会立即报告文件已更改。我们可以使用这个功能来监控各种文件的更改,并在我们的应用程序中执行相应的任务。或者,我们可以根据需要仅注册文件创建或删除事件。

总结

使用 Lambda 表达式和方法引用,处理字符串和文件的常规任务以及创建自定义比较器变得更加容易和简洁。匿名内部类演变成了一种优雅的风格,同时,可变性就像晨雾在阳光下一样消失了。采用这种风格的额外好处是,我们可以受益于强劲的 JDK 功能,高效地遍历大型目录。

目前你知道如何创建 Lambda 表达式并将其作为参数传递给方法了。在下一篇中,我们将进一步拓展本文中的一些概念,几乎毫不费力地转换数据。

© 版权声明
THE END
如果内容对您有所帮助,就支持一下吧!
点赞0 分享
评论 共1条

请登录后发表评论