Java 8 vs Scala — Part II Streams API
这是本文的第 2 部分。第 1 部分在这里。
Stream 与 Collection 的比较
这是我按自己的意思给的一个十分简要的说明:collection 是一个有限的数据集,而 stream 是数据的一个序列,可以是有限的也可以是无限的。区别就这么简单。
Streams API 是 Java 8 的一个新的 API,用于操作 collection 和 stream 数据。Collections API 会改变数据集的状态,而 Streams API 不会。例如,调用 Collections.sort(list) 会把传入的参数排好序,而 list.stream().sorted() 则不同,它会把数据复制一份,保持原数据不变。Streams API 可以参考这里。
下面是我从 Java 8 的文档中摘出来的关于 collections 和 streams 的比较。强烈建议你看一下完整的版本。
Streams 和 collections 几个不同之处:
1. 无存储。一个 steam 不是一个存储数据元素的数结构。而是通过计算操作管道从源头传输数据元素。
2. 本质是函数。在 steam 上的一个操作就产生一个新的结果,而不对数据源做任何的改动。
3. 懒执行的。许多 steam 的操作,如 filtering,mapping 或者 duplicate removal 都是懒执行的,使其能进行更好的优化。
4. 可能不受限制的。Collection 的大小是有限制的,streams 则没有。
5. 消耗的。Steam 中的元素在 steam 的生存时间内只能被访问一次。
Java 和 Scala 都有一个十分简单的方式去同时计算 collection 中的值。在 Java 中,你只需要使用parallelStream()* 或者 stream().parallel(),而不是简单的使用 stream()。在 Scala 中,在使用其他方法之前必须先调用 par()。而且可以通过添加并行度来提高程序的性能。不幸的是,大多数时间它的执行速度都是非常慢的。事实上,parallelism 是一个很容易被错误的使用组件。查看这个文章Java Parallel Streams Are Bad for Your Health!
* 在 JavaDoc 中,关于parallelStream() 方法是这样说明的,这个方法可能会返回一个并行stream,那么意味着它也可能返回的是一个串行 stream。看到这里你一定会觉得很奇怪,有人已经就这个问题进行了研究,详细情况请阅读下面链接中的文章。 (someone did some research on why this API exists)
Java 的 Stream API 是延后执行的。这就意味着,如果你在调用 stream API 的时候,没有指定一个终结操作(比如 collect() 方法调用),那么所有的中间调用(比如 filter 调用)是不会被执行的。这样做的主要目的是为了优化 stream API 的执行,并提高 stream API 的执行效率。比如,我们要对一个数据流进行过滤,映射,求和运算,通过使用延后执行机制,那么对所有这些操作只要遍历一遍数据流就可以了。同时延后执行能力,也实现了每个操作只处理感兴趣的的数据(数据经过前一个操作之后才传入下一个操作)。 而对于 Scala,默认的集合是非延后处理的,这意味着每个操作都会完全遍历 Collection 中的每个元素。这样是否意味着,在我们的测试中,Java Stream API 应该优于 Scala 的呢?如果我们只是在 Java Stream API 和 Scala Collection API 之间做比较,那么答案是正确的,Java Stream API 要优于 Scala Collection API。但是在 Scala 中,你可以通过一个简单的 toStream() 调用,将一个 Collection 转换成一个 Stream。就算你不把 Collection 转成Stream,在 Scala 中你还可以使用 view (一种提供延后处理能力的 Collection)来处理你的数据集合。
让我们快速的看一下 Scala 的 Stream 和 View 特性。
Scala 的 Stream
Scala 的 Stream 和 Java 的 Stream 有点不同。在 Scala 的 Stream 中,你无须去调用终端操作去取的 Stream 的结果,因为它本身就是一个结果。Stream 是继承 AbstractSeq, LinearSeq, 和 GenericTraversableTemplate 的一个抽象类。所以你可以把 Stream 看做一个Seq 。如果你不怎么熟悉 Scala,可以把 Seq 看做 Java 中的 List。(Scala 中的 List 不是一个接口,当然这个另作讨论:)).
我们必须要知道 Streams 中的元素都是懒计算的,也正因如此 Stream 可以计算无限的数据。如果要计算几个集合里的所有元素,Stream 和 List 有着相同的计算效率。一旦被使用,它的值就被 cache了。Stream 有一个叫 force 的方法,它强制评估整个 stream 并返回结果。在计算无限数据的时候千万不要使用这个方法。还有 size(),toList(),foreach() 这些强制计算这个 Stream 的方法。这些操作在 Scala 的 Stream 中都是隐式的。
在 Scala 的 Stream 中实现斐波那契数列。
def fibFrom(a: Int, b: Int): Stream[Int] = a #:: fibFrom(b, a + b) val fib1 = fibFrom(0, 1) //0 1 1 2 3 5 8 … val fib5 = fibFrom(0, 5) //0 5 5 10 15 … //fib1.force //不要这么使用,因为它会无限的执行下去,然后报内存溢出错误。 //fib1.size //不要这么使用和上面同样的原因。 fib1.take(10) //将返回前10个值 fib1.take(20).foreach(println(_)) //打印前20个值
:: 是集合中常用的连接数据的方法。而 #:: 方法则是连接数据但是是懒执行的(Scala中的方法名是比较随意的)。
Scala 的 View
再次重申,Scala 中的 collection 是一个样的 collection 而 View 则是一个非严格的 collection。View 是基于一个基础 collection 的 collection,其中所有的转换都是懒执行的。通过调用 view 方法可以把一个严格的 collection 转换成 view,也可以通过调用 force 方法把它转换回来。View 并不 cache 结果,每次你调用它的时候它都会执行一次。就像数据库的 View,但它是虚拟的集合。
创建一个要使用的数据集。
public class Pet { public static enum Type { CAT, DOG } public static enum Color { BLACK, WHITE, BROWN, GREEN } private String name; private Type type; private LocalDate birthdate; private Color color; private int weight; ... }
假设我们有一个宠物的集合,接着要使用这个集合。
过滤器
需求:我们希望从集合中过滤唯一的胖乎乎的宠物。重量超过 50 磅的宠物就认为它是胖的。我们还想要取得出生在 2013 年 1 月 1 日之前的宠物。下面的代码片段显示你如何通过两种方式实现这个过滤的工作。
Java 实现 1: 传统方式
//Before Java 8 List<Pet> tmpList = new ArrayList<>(); for(Pet pet: pets){ if(pet.getBirthdate().isBefore(LocalDate.of(2013, Month.JANUARY, 1)) && pet.getWeight() > 50){ tmpList.add(pet); } }
这种方式是我们在命令式语言中常见的。你必须创建一个临时的集合,之后遍历每个元素并存储每一个满足谓词的元素放入这个临时的集合。有点啰嗦,但其所做的工作和其性能一样,是惊人的。在这里,我会破坏这种更快的传统流式 API 方式。不要担心性能,因为那会让代码更优雅,这超过了轻微的性能增益。
Java Approach 2: Streams API
//Java 8 - Stream pets.stream() .filter(pet -> pet.getBirthdate().isBefore(LocalDate.of(2013, Month.JANUARY, 1))) .filter(pet -> pet.getWeight() > 50) .collect(toList())
在上面的代码中,我们用 Streams 的 API 去过滤集合中的元素。我故意调用两次 filter 是想展示Streams 的 API 设计的就像是一个 Builder pattern。在 Builder pattern 中,在构造结果集前你可以把一系列方法串联起来使用。在 Streams API 中,构造方法被叫做装卸操作。中间操作不是一个装卸操作。装卸操作可能和构造方法有些不同,因为它在 Streams API 中只能被调用一次。有很多你可以使用的装卸操作 --collect,count,min,max,iterator,toArray。这些操作产生的结果和一些装卸操作一样会消耗其中的值,例如,foreach。你认为传统和 Streams API 哪一个可读性更强?
Java Approach 3: Collections API
//Java 8 - Collection pets.removeIf(pet -> !(pet.getBirthdate().isBefore(LocalDate.of(2013, Month.JANUARY, 1)) && pet.getWeight() > 50)); //Applying De-Morgan's law. pets.removeIf(pet -> pets.get(0).getBirthdate().toEpochDay() >= LocalDate.of(2013, Month.JANUARY, 1).toEpochDay() || pet.getWeight() <= 50);
这是一个最简单的方法。然后,后者修改了原始的集合而前一个则没有。
removeIf 方法把 Predicate<T> (一个方法接口) 看做一个参数。Predicate 是一个行参并且只有一个接受一个类返回一个布尔类型叫做 test 的抽象方法。 我们可以在表达式前面加上"!"去取相反的结果,或者你可以使用 de morgan’s law,那样的话代码看起来就像是第二个声明。
Scala 入门:集合,视图,与流
//Scala - strict collection pets.filter { pet => pet.getBirthdate.isBefore(LocalDate.of(2013, Month.JANUARY, 1))} .filter { pet => pet.getWeight > 50 } //List[Pet] //Scala - non-strict collection pets.views.filter { pet => pet.getBirthdate.isBefore(LocalDate.of(2013, Month.JANUARY, 1))} .filter { pet => pet.getWeight > 50 } //SeqView[Pet] //Scala - stream pets.toStream.filter { pet => pet.getBirthdate.isBefore(LocalDate.of(2013, Month.JANUARY, 1))} .filter { pet => pet.getWeight > 50 } //Stream[Pet]
在 Scala 中解决方案非常相似于 Java 中流 API。看看那每一个,你不得不调用视图函数把严格的集合转向非严格的集合,并且调用 tostream 函数,把严格的集合转向一个流。
我认为,我已经有了这个想法,因此,我将向你显示该代码,并且保持沉默。
分组
元素属性中的一个元素中的组元素。该结果将是地图<T,列表<T>>,和一个泛型类型。
要求:通过其类型中组宠物,诸如狗,猫等等。
//Java approach Map<Pet.Type, List<Pet>> result = pets.stream().collect(groupingBy(Pet::getType));
//Scala approach val result = pets.groupBy(_.getType)
排序
集合中的任何属性元素中的各种元素。结果将是任何类型的集合,依靠配置,来维持元素的秩序。
要求:我们要按类型、名称和色序来给宠物分类。
//Java approach pets.stream().sorted(comparing(Pet::getType) .thenComparing(Pet::getName) .thenComparing(Pet::getColor)) .collect(toList());
//Scala approach pets.sortBy{ p => (p.getType, p.getName, p.getColor) }
Mapping
在集合中每个元素上应用给定的方法。根据你给定义的方法不同返回的结果类型也不同。
需求: 我们想把宠物类转换成“%s — name: %s, color: %s”格式。
//Java 方法 pets.stream().map( p-> String.format(“%s — name: %s, color: %s”, p.getType(), p.getName(), p.getColor()) ).collect(toList());
//Scala 方法 pets.map{ p => s"${p.getType} - name: ${p.getName}, color: ${p.getColor}"}
Finding First
返回第一个和给定值匹配的值.
需求:我们想找一个名叫 “Handsome”的宠物。 不管有多少个“Handsome",只取第一个。
//Java 方法 pets.stream() .filter( p-> p.getName().equals(“Handsome”)) .findFirst();
//Scala 方法 pets.find{ p=> p.getName == “Handsome” }
这个有点狡猾。你有注意到在 Scala 中我使用的是 find 而不是 filter 方法吗?如果用 filter 代替 find,它就会读取所有的元素,因为 scala 的集合严格的。然而,在 Java 的 Streams API 中你可以放心使用 filter,因为它会计算你只想要第一个值,所以不会读取集合中所有的元素。这就是懒执行的好处!
我们来看看在 scala 中更多的集合中的懒执行代码。我们假定 filter 总是返回 true,然后再取第二个值。我们将看到怎样的结果?
pets.filter { x => println(x.getName); true }.get(1) --- (1)
pets.toStream.filter { x => println(x.getName); true }.get(1) -- (2)
从上面的代码中,(1)式将会打印出集合中所有宠物的名字,而(2)式则只输出前2个宠物的名字。这就是集合懒执行的好处,连计算都是懒的。
pets.view.filter { x => println(x.getName); true }.get(1) --- (3)
(3)式和(2)式会有一样的结果吗?答案是不是,它的结果和(1)是一样的,你知道为什么吗?
通过比较 Java 和 Scala 中的一些共同的操作方法 --filter,group,map 和 find;很明显 Scala 的方法比 Java 的简洁。你更喜欢哪一个呢?哪一个是更可读的?
在文章的下一个部分,我们将比较哪一个比较快。它是可以准确比较的。请保持关注。