Java函数式编程(七):MapReduce

译注:map(映射)和reduce(归约,化简)是数学上两个很基础的概念,它们很早就出现在各类的函数编程语言里了,直到2003年Google将其发扬光大,运用到分布式系统中进行并行计算后,这个组合的名字才开始在计算机界大放异彩(那些函数式粉可能并不这么认为)。本文我们会看到Java 8在摇身一变支持函数式编程后,map和reduce组合的首次亮相(这里只是初步介绍,后续还会有针对它们的专题)。

对集合进行归约

现在为止我们已经介绍了几个操作集合的新技巧了:查找匹配元素,查找单个元素,集合转化。这些操作有一个共同点,它们都是对集合中的单个元素进行操作。不需要对元素进行比较,或者对两个元素进行运算。本节中我们来看一下如何比较元素,以及在遍历集合过程中动态维护一个运算结果。

我们先从简单的例子开始,然后再循序渐进。在第一个例子中,我们先来遍历一下friends集合,计算出所有名字的总字符数。

代码如下:

System.out.println("Total number of characters in all names: " + friends.stream()
         .mapToInt(name -> name.length())
         .sum());

要算出所有字符的总数我们得知道每个名字的长度。通过mapToInt()方法可以轻松的完成这个。当我们已经把名字转化成了对应的长度之后,最后只需要把它们加到一块就行了。我们有一个内置的sum()方法来完成这个。下面是最后的输出:

代码如下:

Total number of characters in all names: 26

我们使用了map操作的一个变种,mapToInt()方法(这种的有mapToInt, mapToDouble等,会对应生成具体类型的流,比如IntStream,DoubleStream),然后根据返回的长度计算出总的字符数。

除了使用sum方法,还有很多类似的方法可以使用,比如用max()可以求出最大的长度,用min()是最小长度,sorted()对长度进行排序,average()求平均长度,等等。

上述这个例子还有一个吸引人的地方就是现在越来越流行的MapReduce模式,map()方法进行映射,而sum()方法是一个比较常用的reduce操作。事实上,JDK中sum()方法的实现用的就是reduce()方法。我们来看下reduce操作更常用的一些形式。

比方说,我们遍历所有的名字,然后打印出名字最长的那个。如果最长的名字有好几个,我们就打印出最开始找到的那个。一种方法是,我们计算出最大的长度,然后选出匹配这个长度的第一个元素。不过这样做需要遍历两次列表——效率太低了。这正是reduce操作上场的时候了。

我们可以用reduce操作来比较两个元素的长度,然后返回最长的那个,再和剩下的元素做进一步比较。跟我们之前看到的别的高阶函数一样,reduce()方法同样也是遍历了整个集合。除此之外,它还记录了lambda表达式返回的计算结果。有个例子的话可以帮助我们更好的理解这点,那我们先来看一段代码吧。

代码如下:

final Optional<String> aLongName = friends.stream()
         .reduce((name1, name2) ->
            name1.length() >= name2.length() ? name1 : name2);
aLongName.ifPresent(name ->
System.out.println(String.format("A longest name: %s", name)));

传给reduce()方法的lambda表达式接收两个参数,name1和name2,它会比较它们的长度,返回最长的那个。reduce()方法根本不知道我们要干什么。这个逻辑被剥离到我们传递进去的lambda表达式里面了——这是策略模式的一个轻量级的实现。

这个lambda表达式正好能适配成JDK中一个BinaryOperator的函数式接口的apply方法。这正是reduce方法要接受的参数类型。我们来运行下这个reduce方法,看看它能否正确地在两个最长的名字中选出第一个来。

代码如下:

A longest name: Brian

在reduce()方法遍历集合的过程中,它先对集合的前两个元素调用了lambda表达式,调用返回的结果继续用于下一次调用。在第二次调用中,name1的值被绑定成上次调用的结果,name2的值则是集合的第三个元素。剩余的元素也这样依次调用下去。最后一次lambda表达式调用的结果,就是整个reduce()方法返回的结果。

reduce()方法返回的是一个Optional值,因为传递给它的集合可能是空的。那样的话,也不存在什么最长的名字了。如果列表只有一个元素,reduce方法直接返回那个元素,不会对lambda表达式进行调用。

从这个例子中我们可以推断出,reduce的结果最多只可能是集合中的一个元素。如果我们希望能返回一个默认值或者基础值的话,我们可以使用reduce()方法的一个变种,它可以接收一个额外的参数。比如,如果最短的名字是Steve,我们可以把它传给reduce()方法,像这样:

代码如下:

final String steveOrLonger = friends.stream()
     .reduce("Steve", (name1, name2) ->
            name1.length() >= name2.length() ? name1 : name2);

如果有名字比它长的,那么这个名字会被选中;否则的话就返回这个基础值Steve。这个版本的reduce()方法不会返回Optional对象,因为如果集合是空的,会返回一个默认值;不用考虑没有返回值的情况。

在我们结束这章之前 ,我们再来看一下集合操作里面一个很基础的却又不是那么容易的操作:合并元素。

合并元素

我们已经学习了如何进行元素的查找,遍历,以及集合的转化。不过还有一个常见的操作——将集合元素进行拼接——如果没有这个新添加的join()函数的话,之前说的简洁和优雅的代码只能成为泡影了。这个简单的方法非常实用以至于它成为JDK里最常用的函数之一。我们来看下如何用它来打印列表中的元素,用逗号进行分隔。

我们还是用这个friends列表。如果用JDK库里的旧方法的话,想要打印出所有名字并用逗号隔开的话,要做哪些工作?

我们得遍历列表并且挨个打印元素。Java 5中的for循环比之前的有所改进,我们就用它吧。

代码如下:

for(String name : friends) {
      System.out.print(name + ", ");
}
System.out.println();

代码很简单,我们看下它的输出是什么。

代码如下:

Brian, Nate, Neal, Raju, Sara, Scott,

该死,最后多出了一个讨厌的逗号(我们难道要怪最后的那个Scott?)。怎么能让Java别放一个逗号在这呢?不幸的是,循环会按步就班的执行,想让它在最后特殊处理一下可不容易。为了解决这个问题,我们可以用回原来的那种循环方式。

代码如下:

for(int i = 0; i < friends.size() - 1; i++) {
      System.out.print(friends.get(i) + ", ");
}
if(friends.size() > 0)
      System.out.println(friends.get(friends.size() - 1));

我们来看下这个版本的输出是不是OK。

代码如下:

Brian, Nate, Neal, Raju, Sara, Scott

结果还是不错的,不过这个代码就不敢恭维了。救救我们吧,Java。

我们不用再忍受这种痛苦了。Java 8里的StringJoiner类帮我们搞定了这些难题,不止如此,String类还增加了一个join方法方便我们可以用一行代码来替代掉上面那坨东西。

代码如下:

System.out.println(String.join(", ", friends));

快来看下吧,结果跟代码一样令人满意。

代码如下:

Brian, Nate, Neal, Raju, Sara, Scott

结果还是不错的,不过这个代码就不敢恭维了。救救我们吧,Java。

我们不用再忍受这种痛苦了。Java 8里的StringJoiner类帮我们搞定了这些难题,不止如此,String类还增加了一个join方法方便我们可以用一行代码来替代掉上面那坨东西。

代码如下:

System.out.println(String.join(", ", friends));

快来看下吧,结果跟代码一样令人满意。

代码如下:

Brian, Nate, Neal, Raju, Sara, Scott

在底层实现中,String.join()方法调用了StringJoiner类来将第二个参数传进来的值(这是个变长参数)拼接成一个长的字符串,用第一个参数作为分隔符。这个方法当然不止是能拼接逗号这么简单了。比如说,我们可以传入一堆路径,然后很容易的拼出一个类路径(classpath),这可真是多亏了这些新增加的方法和类。

我们已经知道如何去连接列表元素了,在进行列表连接前,我们还可以先对元素进行转化,当然我们也知道如何使用map方法来进行列表转化了。接下来还可以用filter()方法过滤出我们想要的那些元素。最后一步的连接列表元素,用逗号还是什么分隔符,不过就是一个简单的reduce操作而已了。

我们可以用reduce()方法将元素拼接成一个字符串,不过这需要我们费点工夫。JDK有一个十分方便的collect()方法,它也是reduce()的一个变种,我们可以用它来把元素合并成一个想要的值。

collect()方法来执行归约操作,不过它把具体的操作委托给一个collector来执行。我们可以把转化后的元素合并成一个ArrayList。继续刚才那个例子,我们可以将转化后的元素,拼接成一个用逗号分隔的字符串。

代码如下:

System.out.println(
      friends.stream()
          .map(String::toUpperCase)
          .collect(joining(", ")));

我们在转化后的列表上调用了collect()方法,给它传入了一个joining()方法返回的collector,joining是Collectors工具类里的一个静态方法。collector就像是个接收器,它接收collect传进来的对象,并把它们存储成你想要的格式:ArrayList, String等。我们会在52页的collect方法及Collectors类中进一步探索这个方法。

这是输出的名字,现在它们是大写的,并用逗号隔开。

代码如下:

BRIAN, NATE, NEAL, RAJU, SARA, SCOTT

总结

集合在编程中十分常见,有了lambda表达式后,Java的集合操作变得更加简单容易了。那些拖沓的集合操作的老代码都可以换成这种优雅简洁的新方式。内部迭代器使得集合遍历,转化都变得更加方便,远离了可变性的烦恼,查找集合元素也变得异常轻松。使用这些新方法可以少写不少代码。这使得代码更容易维护,更聚焦于业务逻辑,编程中的那些基本操作也变得更少了。

下一章中我们会看到lambda表达式如何简化程序开发中的另一个基本操作:字符串操作以及对象比较。

(0)

相关推荐

  • Java函数式编程(二):集合的使用

    第二章:集合的使用 我们经常会用到各种集合,数字的,字符串的还有对象的.它们无处不在,哪怕操作集合的代码要能稍微优化一点,都能让代码清晰很多.在这章中,我们探索下如何使用lambda表达式来操作集合.我们用它来遍历集合,把集合转化成新的集合,从集合中删除元素,把集合进行合并. 遍历列表 遍历列表是最基本的一个集合操作,这么多年来,它的操作也发生了一些变化.我们使用一个遍历名字的小例子,从最古老的版本介绍到现在最优雅的版本. 用下面的代码我们很容易创建一个不可变的名字的列表: 复制代码 代码如下:

  • Java函数式编程(四):在集合中查找元素

    查找元素 现在我们对这个设计优雅的转化集合的方法已经不陌生了,但它对查找元素却也是无能为力.不过filter方法却是为这个而生的. 我们现在要从一个名字列表中,取出那些以N开头的名字.当然可能一个也没有,结果可能是个空集合.我们先用老方法实现一把. 复制代码 代码如下: final List<String> startsWithN = new ArrayList<String>(); for(String name : friends) { if(name.startsWith(&

  • Java函数式编程(十一):遍历目录

    列出目录中的文件 用File类的list()方法可以很容易的列出目录中的所有文件的文件名.如果想要获取文件而不止是文件名的话,可以使用它的listFiles()方法.这很简单,难的是怎么去处理这个返回的列表.我们不再使用传统的冗长的外部迭代器,而是使用优雅的函数式来实遍历这个列表.这里我们还得用到JDK的新的CloseableStream接口以及一些相关的高阶函数. 下面这段代码可以列出当前目录下所有文件的名字. 复制代码 代码如下: Files.list(Paths.get(".")

  • Java函数式编程(三):列表的转化

    列表的转化 将集合转化成一个新的集合就和遍历它一样简单.假设我们要将列表中的名字转化成全大写的.我们看下都有哪些实现方式. Java中的字符串是不可变的,所以它没法改变.我们可以生成新的字符串,用来替换列表中原有的元素.然而这样做的话,原来列表就没了;还有一个问题,原来的列表可能也是不可变的,比如Arrays.asList()生成的,所以修改原来的列表这招不行.还有一个缺点就是这样做很难并行操作. 生成一个新的全大写的列表是个不错的选择. 乍听起来这个建议弱爆了;性能是我们都很关注的一个问题.令

  • Java函数式编程(八):字符串及方法引用

    第三章 字符串,比较器和过滤器 JDK引入的一些方法对写出函数式风格的代码很有帮助.JDK库里的一些的类和接口我们已经用得非常熟悉了,比如说String,为了摆脱以前习惯的那种老的风格,我们得主动寻找机会来使用这些新的方法.同样,当我们需要用到只有一个方法的匿名内部类时,我们现在可以用lambda表达式来替换它了,不用再像原来那样写的那么繁琐了. 本章我们会使用lambda表达式和方法引用来遍历字符串,实现Comparator接口,查看目录中的文件,监视文件及目录的变更.上一章中介绍的一些方法还

  • Java函数式编程(九):Comparator

    实现Comparator接口 Comparator接口的身影在JDK库中随处可见,从查找到排序,再到反转操作,等等.Java 8里它变成了一个函数式接口,这样的好处就是我们可以使用流式语法来实现比较器了. 我们用几种不同的方式来实现一下Comparator,看看新式语法的价值所在.你的手指头会感谢你的,不用实现匿名内部类少敲了多少键盘啊. 使用Comparator进行排序 下面这个例子将使用不同的比较方法,来将一组人进行排序.我们先来创建一个Person的JavaBean. 复制代码 代码如下:

  • Java函数式编程(十二):监控文件修改

    使用flatMap列出子目录 前面已经看到如何列出指定目录下的文件了.我们再来看下如何遍历指定目录的直接子目录(深度为1),先实现一个简单的版本,然后再用更方便的flatMap()方法来实现. 我们先用传统的for循环来遍历一个指定的目录.如果子目录中有文件,就添加到列表里:否则就把子目录添加到列表里.最后,打印出所有文件的总数.代码在下面--这个是困难模式的. 复制代码 代码如下: public static void listTheHardWay() {      List<File> f

  • Java函数式编程(一):你好,Lambda表达式

    第一章 你好,lambda表达式! 第一节 Java的编码风格正面临着翻天覆地的变化. 我们每天的工作将会变成更简单方便,更富表现力.Java这种新的编程方式早在数十年前就已经出现在别的编程语言里面了.这些新特性引入Java后,我们可以写出更简洁,优雅,表达性更强,错误更少的代码.我们可以用更少的代码来实现各种策略和设计模式. 在本书中我们将通过日常编程中的一些例子来探索函数式风格的编程.在使用这种全新的优雅的方式进行设计编码之前,我们先来看下它到底好在哪里. 改变了你的思考方式 命令式风格--

  • Java函数式编程(六):Optional

    选取单个元素 直觉来说选取单个元素肯定会比选取多个要简单得多,不过这里也存在一些问题.我们先看下一般的做法的问题是什么,然后再看下如何用lambda表达式来解决它. 我们先新建一个方法来查找一个以特定字母开头的元素,然后打印出来. 复制代码 代码如下: public static void pickName( final List<String> names, final String startingLetter) { String foundName = null; for(String

  • Java函数式编程(十):收集器

    前面我们已经用过几次collect()方法来将Stream返回的元素拼成ArrayList了.这是一个reduce操作,它对于将一个集合转化成另一种类型(通常是一个可变的集合)非常有用.collect()函数,如果和Collectors工具类里的一些方法结合起来使用的话,能提供极大的便利性,本节我们将会介绍到. 我们还是继续使用前面的Person列表作为例子,来看一下collect()方法到底有哪些能耐.假设我们要从原始列表中找出所有大于20岁的人.下面是使用了可变性和forEach()方法实现

  • Java函数式编程(五):闭包

    使用词法作用域和闭包 很多开发人员都存在这种误解,认为使用lambda表达式会导致代码冗余,降低代码质量.恰恰相反,就算代码变得再复杂,我们也不会为了代码的简洁性而在代码质量上做任何妥协,下面我们就会看到. 在前面一个例子中我们已经可以重用lambda表达式了;然而,如果再匹配另外一个字母,代码冗余的问题很快又卷土重来了.我们先来进一步分析下这个问题,然后再用词法作用域和闭包来把它解决掉. lambda表达式带来的冗余 我们来从friends中过滤出那些以N或者B开头的字母.继续延用上面的那个例

随机推荐