本文要点
- Eclipse Collections 是一个高性能的 Java 集合框架,针对 Java 8 及以上版本进行了重新设计,极大地丰富了这个 Java Collections 框架的功能。
- 在 2012 年开源之前,该框架在 Goldman Sachs 内部已经开发了 10 年,那时称为 GS Collections。2015 年,它被迁移到 Eclipse 基金会。
- 它使用原始的数据结构,性能优于传统的原始集合。
- 在 Eclipse Collections 8.0 版本之前,EC 兼容 Java 5 和 7 之间的版本。8.0 及以上版本需要使用 Java 8 及以上版本,并且使用 Optional 处理潜在的 null 值。
- 最新版本经过升级已经支持 Java 9 的模块。
30 秒简介——Eclipse Collections 是什么?
Eclipse Collections 是 Java Collections 框架的替代者。它提供 JDK 兼容的 List、Set 和 Map 实现,并且提供了丰富的 API 以及 JDK 中没有的其他类型,如 Bags、Multimaps 和 BiMaps。Eclipse Collections 还充分补充了原始容器。在 2012 年开源之前,该框架在 Goldman Sachs 内部已经开发了 10 年,那时称为 GS Collections。2015 年,它被迁移到 Eclipse 基金会。从那会开始,所有开发都是在 Eclipse Collections 名下完成的。如果你想阅读一些优质的介绍性文章,可以看下 Donald Raab 发表在 InfoQ 上的文章“GS Collections 实例教程”第一部分和第二部分。
领域
在讨论任何细节或代码示例之前,让我们了解下本文的代码片段来自什么领域,如下图所示:
我们有一个人的列表(类型为Person),每个人对应一个Pet 列表,每只宠物都是枚举类型PetType 中的一种。
面向Java 8 的Eclipse Collections 8
在Eclipse Collections 8 发布之前,EC 兼容的Java 版本为5 和7 之间的版本。开发人员也可以使用Java 8,既使用框架提供的丰富API,同时又充分利用Lambda 表达式和方法引用的优势,而实际效果还不错。
但你能做的也就只有那些。Eclipse Collections 与Java 8 兼容,但它没有使用或包含Java 8。现在,从Eclipse Collections 8 开始,我们已经决定兼容Java 8 及以上版本,从而可以开始在我们的代码库中利用部分绝妙的Java 8 新特性。
Optional
Optional 是 Java 8 中最受欢迎的新特性之一。据 Javadoc 介绍,“一个容器对象可能包含也可能不包含非空值。如果值存在,那么 isPresent() 会返回 true,而 get() 会返回那个值”。从根本上讲,Optional 强制开发人员处理潜在的 null 项,帮助他们避免 NullPointerExceptions。那么,我们可以在 Eclipse Collections 的那个地方使用这项特性呢? RichIterable.detectWith() 非常适合。detectWith 接收一个 Predicate 参数,返回集合中满足那个条件的第一个元素。如果它没有找到任何元素,则返回 null。因而,在 8.0 版本中,我们引入了 detectWithOptional()。该方法不会返回一个元素或 null,它返回一个 Optional 对象,然后由用户来处理,参见下面的代码(来自我们的 kata 教程资料):
Person person = this.people.detectWith(Person::named, "Mary Smith"); // 空指针异常 Assert.assertEquals("Mary", person.getFirstName()); Assert.assertEquals("Smith", person.getLastName());
在这段代码中,我们想查找 Mary Smith。当调用 detectWith 方法时,person 对象被置为 null,因为它没有找到任何满足条件的人。因此,这段代码会抛出 NullPointerException。
Person person = this.people.detectWith(Person::named, "Mary Smith"); if (person == null) { person = new Person("Mary", "Smith"); } Assert.assertEquals("Mary", person.getFirstName()); Assert.assertEquals("Smith", person.getLastName());
接下来,在 Java 8 之前,我们可以总是使用上面这样的 null 检查。但是,Java 8 提供了 Optional,那么我们就用它吧!
Optional<Person> optional = this.people.detectWithOptional(Person::named, "Mary Smith"); Person person = optional.orElseGet(() -> new Person("Mary", "Smith")); Assert.assertEquals("Mary", person.getFirstName()); Assert.assertEquals("Smith", person.getLastName());
在这段代码中,detectWithOptional 没有返回 null,而是返回了一个封装了 Person 的 Optional。现在,由开发人员决定如果处理这种潜在的 null 值。在我的代码中,如果它不是 null,我就调用 orElseGet() 新建一个 Person 实例。测试通过,我们避免了任何异常!
Collectors
如果你的代码中使用了 Streams,那么你之前很可能使用过 Collector。Collector 是一种实现可变归约操作的方法。例如,Collectors.toList() 让开发人员可以将 Stream 中的数据项累加到列表中。JDK 有多个“内置”的 Collector,可以从 Collectors 类里找到。下面是一些 Java 8(不是 Eclipse Collections)的例子:
List<String> names = this.people.stream() .map(Person::getFirstName) .collect(Collectors.toList()); // 输出: // [Bob, Ted, Jake] int total = this.people.stream().collect( Collectors.summingInt(Person::getNumberOfPets)); // 输出: // 4
既然现在我们可以在使用 Eclipse Collections 时利用 Streams,我们也应该内建自己的 Collectors——Collectors2。其中许多 Collector 都是针对特定的 Eclipse Collections 数据结构的,有些特性是 JDK 没有直接提供的,如 toBag()、toImmutableSet() 等。
(点击查看大图)
上图简要介绍了Collectors2 API。上面的方框是所有可以存储Collectors2 结果的不同数据结构,下面各项是部分可以达到这个目的API。可以看到,Collectors2 既支持JDK 和Eclipse Collections 的类型,也支持原始集合。开发人员甚至可以通过Collectors2 使用他们熟悉的collect()、select()、reject() 等API。
Collectors 和 Collectors2 之间也可以交互;二者并不相互排斥。看下下面这个例子,我们使用了 JDK 8 Collectors,但方便起见,我们接着使用了 EC 8.0 Collectors2:
Map<Integer, String> people = this.people .stream() .collect(Collectors.groupingBy( Person::getNumberOfPets, Collectors2.makeString())); Map<Integer, String> people2 = this.people .stream() .collect(Collectors.groupingBy( Person::getNumberOfPets, Collectors.mapping( Object::toString, Collectors.joining(",")))); // 输出: {1=Ted, Jake, 2=Bob}
上面两段代码的输出完全一种,但实现上有细微的差别:Eclipse Collections 提供了 makeString() 功能,它创建了一个逗号分隔的元素集合,并表示为一个字符串。使用 Java 8 做到这一点,就需要多做一点工作,调用 Collectors.mapping(),将每个对象转换成 toString 值,然后使用逗号连接在一起。
默认方法
对于像 Eclipse Collections 这样的框架,默认方法是对 JDK 的一个很好的补充。我们可以在部分最上层的接口之上实现新 API,而不必修改许多底层的实现。reduceInPlace() 是我们向 RichIterable 添加的其中一个新方法——他有什么功能?
/** * 该方法生成的结果和下面的代码完全相同 * {@link Stream#collect(Collector)}. * <p> * * MutableObjectLongMap<Integer> map2 = * Lists.mutable .with(1, 2, 3, 4, 5) .reduceInPlace( Collectors2.sumByInt( i -> Integer.valueOf(i % 2), Integer::intValue)); * * @since 8.0 */ default <R, A> R reduceInPlace(Collector<? super T, A, R> collector) { A mutableResult = collector.supplier().get(); BiConsumer<A, ? super T> accumulator = collector.accumulator(); this.each(each -> accumulator.accept(mutableResult, each)); return collector.finisher().apply(mutableResult); }
reduceInPlace 和在 Stream 上使用 Collector 的效果完全一样。但是我们为什么要在 Eclipse Collections 中加入这个方法呢?原因非常有趣;在涉及 Eclipse Collections 提供的 Immutable 或 Lazy API 时,我们就不必再使用 streaming API 了。在这一点上,我们无法使用 Collectors 获得同样的功能,因为我们已经无法使用 stream(),也无法调用后续的 API;这就该 reduceInPlace 发挥作用了。
如下图所示,一旦我们调用了集合的.toImmutable() 或.asLazy() 方法,我们就无法再调用.stream() 了。因此,如果我们想使用 Collectors,那么我们现在可以使用.reduceInPlace() 实现同样的效果。
“原始集合(Primitive Collections)”
从 GS Collections 3.0 开始,我们就受益于原始集合。Eclipse Collections 优化了所有原始类型集合的内存,提供了和 Object 类型类似的接口,并且和原始类型相对应。
(点击查看大图)
从上图可以看出,使用原始集合有若干好处。开发人员可以不用装箱非原始类型,节省大量的内存。从Java 8 开始,我们有三种原始类型(int、long 和double),使用专用的原始流和Lambda 表达式。在Eclipse Collections 中,如果你希望使用同样的惰性求值,那么我们在八种原始类型上都直接提供了API。让我们看一下代码示例。
Streams——类似 Iterator
IntStream stream = IntStream.of(1, 2, 3); Assert.assertEquals(1, stream.min().getAsInt()); Assert.assertEquals(3, stream.max().getAsInt()); java.lang.IllegalStateException: stream has already been operated upon or closed at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229) at java.base/java.util.stream.IntPipeline.reduce(IntPipeline.java:474) at java.base/java.util.stream.IntPipeline.max(IntPipeline.java:437) LazyIntIterable lazy = IntLists.mutable.with(1, 2, 3).asLazy(); Assert.assertEquals(1, lazy.min()); Assert.assertEquals(3, lazy.max()); // 重用!
在上述代码中,我们创建 IntStream 1、2、3,并试图调用它的 min() 和 max() 的方法。Java 8 的 Streams 和迭代器类似,不允许重用。Eclipse Collections LazyIterables 允许重用。让我们看一个更复杂的例子:
List<Map.Entry<Integer, Long>> counts = this.people.stream().flatMap( person -> person.getPets().stream()) .collect(Collectors.groupingBy(Pet::getAge, Collectors.counting())) .entrySet() .stream() .filter(e -> e.getValue().equals(Long.valueOf(1))) .collect(Collectors.toList()); // 输出:[3=1, 4=1] MutableIntBag counts2 = this.people.asLazy() .flatCollect(Person::getPets) .collectInt(Pet::getAge) .toBag() .selectByOccurrences(IntPredicates.equal(1)); // 输出:[3, 4]
这里,我们想筛选出年龄只出现过一次的宠物。由于 Java 8 没有 Bag 数据类型(将项映射到数量),所以我们必须对集合进行分组操作并把计数结果存储到 map 中。注意,一旦我们在宠物上调用了 collectInt() 方法,我们就转换到了原始集合和 API。当调用.toBag() 方法时,我们会得到一个专用的原始 IntBag。selectByOccurrences() 是 Bag 特有的 API,使开发人员可以根据出现的次数筛选 Bag 里的数据项。
Java 9——下一步呢?
众所周知,Java 9 为 Java 生态系统带来了许多有趣的变化,如新的模块化系统和内部 API 封装。为了保持兼容,Eclipse Collections 也必须做出相应的改变。
在 8.2 版本中,为了项目构建的顺利完成,我们不得不修改所有用了反射的方法。下面举个 ArrayListIterate 的例子:
public final class ArrayListIterate { private static final Field ELEMENT_DATA_FIELD; private static final Field SIZE_FIELD; private static final int MIN_DIRECT_ARRAY_ACCESS_SIZE = 100; static { Field data = null; Field size = null; try { data = ArrayList.class.getDeclaredField("elementData"); size = ArrayList.class.getDeclaredField("size"); data.setAccessible(true); size.setAccessible(true); } catch (Exception ignored) { data = null; size = null; } ELEMENT_DATA_FIELD = data; SIZE_FIELD = size; }
在这个例子里,我们一调用 data.setAccessible(true) 就会抛出异常。为了让代码可以继续执行,我们采用了一种变通方案,仅仅将 data 和 size 置为 null。遗憾的是,我们不能再使用这些字段优化我们的迭代模型了,但现在,EC 已经兼容 Java 9 了,这解决了我们的反射问题。
如果现在还没有迁移当前代码的打算,也有变通方案。可以通过增加一个命令行参数来避免抛出这些异常,但作为一个框架,我们不希望把这种负担加在用户身上。所有的反射问题都获得了积极的解决,用户可以开始使用 Java 9 编码了!
小结
Eclipse Collections 会继续随着不断发展变化的 Java 生态而不断地发展演化。如果你还没有这样做过的话,可以在使用 Java 8 的代码中试一下,实际地看一看上述新特性。如果你是 EC 的新用户,还不知道从哪里入手,那么可以参考下面这些资源:
感兴趣的读者可以查看完整的《 Eclipse Collections 随 Java 版本的演变》的视频。
快乐编码,享受编码!
关于作者
****Kristen O’Leary是 Goldman Sachs 平台组的合伙人。该小组负责公司的许多技术工具和框架。她已经向 Eclipse Collections 贡献了若干容器、API 和性能优化。她在公司内部和外部教授有关该框架的课程。
评论