JEP 269:集合的便捷工厂方法
概述
定义库 API,以便于创建包含少量元素的集合和映射实例,从而缓解 Java 编程语言中没有集合字面量的不便。
目标
在集合接口上提供静态工厂方法,用于创建紧凑的、不可修改的集合实例。该 API 被有意设计得非常简洁。
非目标
我们的目标并非是提供一个完全通用的“集合构建器”工具,例如,让用户控制集合的实现方式或各种特性,比如可变性、预期大小、加载因子、并发级别等等。
支持拥有任意数量元素的高性能、可扩展集合并非目标。重点是小型集合。
提供不可修改的集合类型并不是目标。也就是说,此提案并不会在类型系统中暴露不可修改的特性,即使所提议的实现实际上是不可修改的。
提供“不可变持久化”或“函数式”集合并非目标。
动机
Java 因其冗长性而经常受到批评。创建一个小型的、不可修改的集合(比如,一个集)需要构建它,将其存储在局部变量中,并多次调用 add()
方法,然后将其包装起来。例如,
Set<String> set = new HashSet<>();
set.add("a");
set.add("b");
set.add("c");
set = Collections.unmodifiableSet(set);
这相当冗长,而且由于它无法用一个表达式来表示,静态集合必须在静态初始化块中填充,而不是通过更方便的字段初始化器。另一种方法是使用另一个集合的拷贝构造函数来填充集合:
Set<String> set = Collections.unmodifiableSet(new HashSet<>(Arrays.asList("a", "b", "c")));
这仍然有些冗长,而且不太直观,因为必须先创建一个 List
,然后再创建 Set
。另一种方法是使用所谓的“双大括号”技巧:
Set<String> set = Collections.unmodifiableSet(new HashSet<String>() {{
add("a"); add("b"); add("c");
}});
这种方式使用了匿名内部类中的实例初始化器构造方法,看起来更加简洁。然而,这种方法相当晦涩,并且在每次使用时都会额外创建一个类。它还对封闭的实例和任何捕获到的对象持有隐藏引用,这可能会导致内存泄漏或序列化问题。基于这些原因,最好避免使用这种技术。
Java 8 的 Stream API 可以通过结合流工厂方法和收集器来构建小型集合。例如,
Set<String> set = Collections.unmodifiableSet(Stream.of("a", "b", "c").collect(toSet()));
(流收集器对其返回的集合的可变性不做任何保证。在 Java 8 中,返回的集合是普通的可变集合,例如 ArrayList
、HashSet
和 HashMap
,但在未来的 JDK 版本中,这种情况可能会发生变化。)
这种方法有些绕弯子,虽然不是晦涩难懂,但也不十分明显。它还涉及一定量的不必要的对象创建和计算。通常情况下,Map
是个例外。除非值可以由键计算得出,或者流元素中同时包含键和值,否则无法以这种方式使用流来构造 Map
。
过去,有过一些提议来更改 Java 编程语言以支持集合字面量。然而,像往常一样,语言特性并非如人们最初想象的那么简单或清晰,因此集合字面量不会出现在 Java 的下一个版本中。
通过提供用于创建小型集合实例的库 API,可以以显著降低的成本和风险获得大部分集合字面量的好处,而不必更改语言。例如,创建小型 Set
实例的代码可能如下所示:
Set<String> set = Set.of("a", "b", "c");
在 Collections
类中已存在一些工厂方法,用于支持创建空的 List
、Set
和 Map
。还有一些工厂方法可以生成仅包含一个元素或键值对的单例 List
、Set
和 Map
。EnumSet
包含多个重载的 of(...)
方法,这些方法接受固定或可变数量的参数,以便方便地使用指定的元素创建 EnumSet
。然而,目前还没有一种通用的方法能够创建包含任意类型对象的 List
、Set
和 Map
。
在 Collections
类中有用于创建不可修改的 List
、Set
和 Map
的组合方法。这些方法并不会直接创建本质上不可修改的集合,而是接收另一个集合,并将其包装在一个拒绝修改请求的类中,从而创建原始集合的一个不可修改的视图。持有对底层集合的引用仍然可以进行修改。每个包装器都是一个额外的对象,需要另一层间接操作,并且比原始集合消耗更多的内存。最后,即使被包装的集合从未打算被修改,它仍然承担着支持变更操作的开销。
描述
在 List
、Set
和 Map
接口上提供静态工厂方法,用于创建这些集合的不可修改实例。(请注意,与类上的静态方法不同,接口上的静态方法不会被继承,因此无法通过实现类或接口类型的实例来调用它们。)
对于 List
和 Set
,这些工厂方法的工作方式如下:
List.of(a, b, c);
Set.of(d, e, f, g);
这些将包括可变参数重载,因此集合大小没有固定限制。然而,如此创建的集合实例可能会针对较小的尺寸进行优化。对于最多十个元素的特殊情况,将提供专门的 API(固定参数重载)。虽然这在 API 中引入了一些混乱,但它避免了可变参数调用所带来的数组分配、初始化和垃圾回收开销。重要的是,无论调用的是固定参数重载还是可变参数重载,调用点的源代码都是相同的。
对于 Map
,将提供一组固定参数的方法:
Map.of()
Map.of(k1, v1)
Map.of(k1, v1, k2, v2)
Map.of(k1, v1, k2, v2, k3, v3)
...
我们预计支持最多包含十个键值对的小型映射足以覆盖大多数使用场景。对于更多条目的情况,将提供一个 API,该 API 将根据任意数量的键值对创建一个 Map
实例:
Map.ofEntries(Map.Entry<K,V>...)
虽然这种方法类似于 List
和 Set
的等效可变参数 API,但不幸的是,它要求每个键值对都必须装箱。一种适用于静态导入的键值装箱方法将使这一过程更加便捷:
Map.Entry<K,V> entry(K k, V v)
使用这些方法,就可以创建具有任意多个条目的映射:
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。
替代方案
语言更改已经被考虑了好几次,但都被拒绝了:
- Project Coin 提案,2009 年 3 月 29 日
- Project Coin 提案,2009 年 3 月 30 日
- JEP 186 在 lambda-dev 上的讨论,2014 年 1 月 - 3 月
语言提案被搁置,优先考虑基于库的提案,如 此消息 中总结的那样。
Google Guava 库提供了一组丰富的实用工具,用于创建不可变集合,包括构建器模式,以及用于创建多种多样的可变集合。Guava 库非常有用且通用,但将其纳入 Java SE 平台可能有些大材小用。该提案与 Stephen Colebourne 在 lambda-dev, 2014 年 2 月 19 日 提出的提案类似,并包含了一些来自 Guava 不可变集合工厂方法的想法。
使用 Map.fromEntries()
方法来初始化具有任意数量条目的 Map
并不理想,但似乎是现有替代方案中问题最少的。其优点在于它是类型安全的,在语法中键和值是相邻的,条目数量在编译时是已知的,并且适合作为字段初始化器。然而,它涉及装箱操作,并且相当冗长。我们考虑了若干替代方案,它们都引入了一些权衡,这些权衡似乎使它们比当前提案更糟糕。
具体集合类(例如,ArrayList
、HashSet
)上的静态工厂方法已从本提案中移除。虽然它们看似有用,但在实践中,它们往往会分散开发者对不可变集合工厂方法的注意力。确实存在一小部分使用场景,需要使用预定义的值集初始化一个可变集合实例。但通常更推荐的做法是,将这些预定义的值存储在不可变集合中,然后通过拷贝构造函数来初始化可变集合。
这里还有一个问题,类中的静态方法会被子类继承。假设添加一个静态工厂方法 HashMap.of()
。由于 LinkedHashMap
是 HashMap
的子类,应用程序代码可以调用 LinkedHashMap.of()
。这将最终调用 HashMap.of()
,完全不是我们所期望的!缓解这个问题的一种方法是确保所有具体的集合实现都具有相同的工厂方法集合,这样就不会发生继承问题。不过,对于具体集合的用户定义子类,继承仍然是一个问题。
测试
在 JDK 回归测试套件中会有一组常规的单元测试,并且公共 API 也会有 JCK 测试。序列化形式也可能被 JCK 涵盖。
将开发一组规模和性能测试。与通常的对比基线测量的目标不同,这些测试将把新的集合实现与现有的实现进行比较。预期是新集合在固定开销和每个元素的基础上都将消耗更少的堆空间。然而,在某些情况下,由于内部表示与现有集合不同,新集合可能会较慢。任何这样的减速都应该是合理的。尽管没有具体的性能目标,但慢 10 倍是不可接受的。此外,随着元素数量的增加,新集合应保持一致的性能。最后,这些测量将建立基线性能数据,供将来更改时进行比较。