Skip to content

JEP 269: Convenience Factory Methods for Collections | 集合的便利工厂方法

摘要

定义库 API,使得在 Java 编程语言中创建具有少量元素的集合和映射实例变得方便,以缓解没有集合字面量的痛苦。

目标

在集合接口上提供静态工厂方法,用于创建紧凑、不可修改的集合实例。该 API 被故意保持最小化。

非目标

目标不是提供一个完全通用的 "集合构建器" 功能,例如让用户控制集合的实现或各种特性,如可变性、预期大小、加载因子、并发级别等等。

目标不是支持具有任意数量元素的高性能、可扩展的集合。重点是小型集合。

目标不是提供不可修改的集合类型。也就是说,该提案在类型系统中不暴露不可修改性的特征,尽管所提议的实现实际上是不可修改的。

目标不是提供 "不可变持久" 或 "函数式" 集合。

动机

Java 经常因其冗长而受到批评。创建一个小的不可修改的集合(比如一个集合)涉及到构造它、将其存储在一个局部变量中,并多次调用 add() 方法,然后进行包装。例如,

java
Set<String> set = new HashSet<>();
set.add("a");
set.add("b");
set.add("c");
set = Collections.unmodifiableSet(set);

这非常冗长,而且由于不能用单个表达式表示,静态集合必须在静态初始化块中填充,而不是通过更方便的字段初始化器。或者,可以使用另一个集合的复制构造函数来填充集合:

java
Set<String> set = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("a", "b", "c")));

这仍然有点冗长,而且也不够明显,因为必须在创建 Set 之前创建 List。另一种选择是使用所谓的 "双括号" 技术:

java
Set<String> set = Collections.unmodifiableSet(new HashSet<String>() {{
    add("a"); add("b"); add("c");
}});

这使用了匿名内部类中的实例初始化器构造,看起来更漂亮一些。然而,它相当晦涩,而且每次使用都要多一个类。它还持有对封闭实例和任何捕获对象的隐藏引用。这可能会导致内存泄漏或与序列化问题有关。出于这些原因,最好避免使用这种技术。

Java 8 的 Stream API 可以用于构建小型集合,通过组合流工厂方法和收集器。例如,

java
Set<String> set = Collections.unmodifiableSet(Stream.of("a", "b", "c").collect(toSet()));

(流收集器不保证返回的集合的可变性。在 Java 8 中,返回的集合是普通的可变集合,如 ArrayListHashSetHashMap,但在未来的 JDK 版本中可能会发生变化。)

这有点绕弯子,虽然不晦涩,但也不是很明显。它还涉及一定量的不必要的对象创建和计算。通常情况下,Map 是个例外。除非可以从键计算值,或者流元素包含键和值,否则无法使用流来构造 Map

过去,有一些提案旨在改变 Java 编程语言以支持集合字面量。然而,正如通常情况下的语言特性一样,没有任何一个特性像人们最初想象的那样简单或干净,因此集合字面量不会出现在下一个 Java 版本中。

通过提供用于创建小型集合实例的库 API,可以以大大降低的成本和风险获得集合字面量的许多好处,与修改语言相比。例如,创建一个小的 Set 实例的代码可能如下所示:

java
Set<String> set = Set.of("a", "b", "c");

Collections 类中存在用于创建空 ListSetMap 的现有工厂。还有用于产生仅包含一个元素或键值对的单例 ListSetMap 的工厂。EnumSet 包含几个重载的 of(...) 方法,可以接受固定或可变数量的参数,方便地创建具有指定元素的 EnumSet。然而,没有很好的通用方法来创建包含任意类型对象的 ListSetMap

Collections 类中还有一些组合方法,用于创建不可修改的 ListSetMap。这些方法不会创建固有的不可修改集合。相反,它们接受另一个集合,并将其包装在一个拒绝修改请求的类中,创建原始集合的不可修改 视图。持有对底层集合的引用仍然允许修改。每个包装器都是一个额外的对象,需要另一层间接,并且消耗比原始集合更多的内存。最后,即使从不打算修改它,包装的集合仍然承担支持变异的开销。

描述

ListSetMap 接口上提供静态工厂方法,用于创建这些集合的不可修改实例。(注意,与类上的静态方法不同,接口上的静态方法不会被继承,因此无法通过实现类或接口类型的实例来调用它们。)

对于 ListSet,这些工厂方法将按以下方式工作:

java
List.of(a, b, c);
Set.of(d, e, f, g);

这些方法将包括可变参数的重载版本,因此集合的大小没有固定限制。然而,这样创建的集合实例可能针对较小的大小进行了优化。对于最多包含十个元素的特殊情况,将提供固定参数的重载版本。虽然这样做会导致 API 中出现一些冗余,但可以避免由可变参数调用引起的数组分配、初始化和垃圾回收开销。重要的是,无论调用的是固定参数还是可变参数的重载版本,调用点的源代码都是相同的。

对于 Map,将提供一组固定参数的方法:

java
Map.of()
Map.of(k1, v1)
Map.of(k1, v1, k2, v2)
Map.of(k1, v1, k2, v2, k3, v3)
...

我们预计支持最多十个键值对的小型 Map 将足以覆盖大多数使用情况。对于更多的条目,将提供一个 API,根据任意数量的键值对创建 Map 实例:

java
Map.ofEntries(Map.Entry<K,V>...)

虽然这种方法类似于 ListSet 的可变参数 API,但不幸的是,它要求每个键值对都进行装箱。为了方便起见,将提供一个适用于静态导入的键和值进行装箱的方法:

java
Map.Entry<K,V> entry(K k, V v)

使用这些方法,可以创建具有任意数量条目的 Map

java
Map.ofEntries(
    entry(k1, v1),
    entry(k2, v2),
    entry(k3, v3),
    // ...
    entry(kn, vn));

(在未来的 JDK 版本中,通过使用值类型,可能可以减少装箱的开销。entry() 便利方法实际上将返回一个新引入的实现 Map.Entry 的具体类型,以便促进潜在的将来迁移到值类型。)

提供用于创建小型不可修改集合的 API 可以满足大量使用情况,并且有助于保持规范和实现的简单性。不可修改的集合避免了进行防御性拷贝的需要,并且更易于并行处理。

小型集合占用的运行时空间也是一个重要考虑因素。使用包装器 API 直接创建具有两个元素的不可修改 HashSet 将包括六个对象:包装器本身、HashSet(其中包含一个 HashMap)、哈希桶的表(数组)以及每个元素的一个 Node 实例。与存储的数据量相比,这造成了巨大的开销,并且访问数据必然需要多次方法调用和指针解引用。专为小型固定大小集合设计的实现可以避免大部分这些开销,使用紧凑的基于字段或基于数组的布局。不需要支持变异(并且在创建时知道集合大小)还有助于节省空间。

这些工厂返回的具体类将不会作为公共 API 公开。不会对返回的集合的运行时类型或标识做任何保证。这将允许实现随时间的推移而更改而不会破坏兼容性。调用者唯一可以依赖的是返回的引用是其接口类型的实现。

生成的对象将是可序列化的。序列化代理对象将用作实现类的常见序列化形式。这将防止有关具体实现的信息泄漏到序列化形式中,从而保留了未来维护的灵活性,并允许具体实现在发布之间进行更改而不影响序列化兼容性。

将禁止使用空元素、键和值。(最近引入的集合都不支持空值。)此外,禁止使用空值可以提供更紧凑的内部表示、更快的访问速度和更少的特殊情况。

预计 List 实现将通过索引提供快速的元素访问,因此它们将实现 RandomAccess 标记接口。

存储在这些集合中的元素必须支持典型的集合契约,包括对 hashCode()equals() 的适当支持。如果 Set 的元素或 Map 的键以影响其 hashCode()equals() 方法的方式发生变化,则集合的行为可能变得不确定。

一旦构造并安全发布,这些集合实例将能够被多个线程安全地访问。

将搜索 JDK 以寻找可以使用这些新 API 的潜在位置。将根据时间和进度更新这些位置,以使用新的 API。

替代方案

已经多次考虑并拒绝了语言更改:

  1. Project Coin 提案,2009 年 3 月 29 日
  2. Project Coin 提案,2009 年 3 月 30 日
  3. JEP 186 在 lambda-dev 上的讨论,2014 年 1 月 -3 月

与语言提案相比,优先选择了基于库的提案,如 此消息 中总结的那样。

Google Guava 库提供了一套丰富的工具,用于创建不可修改的集合,包括构建器模式,以及创建各种可变集合的工具。Guava 库非常有用和通用,但可能过于庞大,不适合包含在 Java SE 平台中。这个提案类似于 Stephen Colebourne 的提案 lambda-dev, 2014 年 2 月 19 日,并包含了 Guava 不可修改集合工厂方法的一些想法。

使用固定数量条目初始化 MapMap.fromEntries() 方法并不理想,但它似乎是最不差的选择。它的优点是类型安全,键和值在语法中相邻,条目数量在编译时已知,并且适用于用作字段初始化器。然而,它涉及装箱,并且相当冗长。我们考虑了几种替代方案,但它们都引入了比当前提案更糟糕的权衡。

这个提案中移除了具体集合类(如 ArrayListHashSet )上的静态工厂方法。这些方法看起来很有用,但实际上它们往往会分散开发人员对不可变集合工厂方法的使用注意力。只有一小部分情况下需要使用预定义的值初始化可变集合实例。通常最好将这些预定义值放在不可变集合中,然后通过复制构造函数初始化可变集合。

还有一个问题是,类上的静态方法会被子类继承。假设添加了一个名为 HashMap.of() 的静态工厂方法。由于 LinkedHashMapHashMap 的子类,应用程序代码可能会调用 LinkedHashMap.of()。这将导致调用 HashMap.of(),这完全不符合预期!为了解决这个问题,一种方法是确保所有具体集合实现具有相同的工厂方法集,以避免继承发生。对于具体集合的用户定义子类,继承仍然是一个问题。

测试

在 JDK 回归测试套件中将进行一系列常规的单元测试,并对公共 API 进行 JCK 测试。序列化形式也可能由 JCK 进行覆盖。

将开发一组大小和性能测试。与通常的目标相反,即与基准测量进行比较,这些测试将比较新的集合实现与现有实现。预期新的集合将占用更少的堆空间,无论是固定开销还是每个元素的基础上。但是,由于与现有集合相比,新集合具有不同的内部表示,因此在某些情况下可能会导致性能较慢。任何这种减速都应该是合理的。虽然没有具体的性能目标,但是 10 倍的减速是不可接受的。此外,新的集合应该在元素数量增加时保持一致的性能。最后,这些测量将建立基准性能数据,以便将来的更改可以与之进行比较。