JEP 494: 模块导入声明(第二次预览)
概述
增强 Java 编程语言,使其能够简洁地导入模块导出的所有包。这简化了模块化库的重用,但不要求导入代码本身位于一个模块中。这是一个预览语言特性。
历史
模块导入声明最初由JEP 476(JDK 23)作为预览功能提出。我们在此提议第二次预览该功能,以获得更多经验和反馈,并增加了两项内容:
-
取消对任何模块都不能声明对
java.base
模块的传递依赖的限制,并修改java.se
模块的声明,使其能够传递地依赖java.base
模块。通过这些更改,导入java.se
模块将按需导入整个 Java SE API。 -
允许类型导入语句覆盖模块导入声明。
目标
-
通过允许一次性导入整个模块来简化模块库的复用。
-
在使用模块导出的 API 的不同部分时,避免多个类型导入声明(例如,
import com.foo.bar.*
)带来的噪音。 -
让初学者能够更轻松地使用第三方库和基础的 Java 类,而无需了解它们在包层次结构中的位置。
-
确保模块导入声明与现有的导入声明顺畅协作。
-
不要求使用模块导入功能的开发者将他们自己的代码模块化。
动机
java.lang
包中的类和接口,例如 Object
、String
和 Comparable
,对于每个 Java 程序都是必不可少的。因此,Java 编译器会自动按需导入 java.lang
包中的所有类和接口,就像
import java.lang.*;
出现在每个源文件的开头。
随着 Java 平台的发展,诸如 List
、Map
、Stream
和 Path
这样的类和接口也变得几乎必不可少。然而,这些都不在 java.lang
中,因此它们不会被自动导入;相反,开发人员必须通过在每个源文件的开头编写大量的 import
声明来使编译器满意。例如,下面的代码将字符串数组转换为从大写字母到字符串的映射,但导入语句几乎和代码本身一样多:
import java.util.Map; // or import java.util.*;
import java.util.function.Function; // or import java.util.function.*;
import java.util.stream.Collectors; // or import java.util.stream.*;
import java.util.stream.Stream; // (can be removed)
String[] fruits = new String[] { "apple", "berry", "citrus" };
Map<String, String> m =
Stream.of(fruits)
.collect(Collectors.toMap(s -> s.toUpperCase().substring(0,1),
Function.identity()));
开发人员对于是选择单类型导入还是按需类型导入声明持有不同的观点。许多人更喜欢在大型、成熟的代码库中使用单类型导入,因为在这种情况下清晰性最为重要。然而,在早期阶段,当便捷性优先于清晰性时,开发人员通常更倾向于按需导入;例如,
-
在原型设计代码并使用
java
启动器运行它时; -
在 JShell 中探索新 API 时,例如 Stream Gatherers 或 Foreign Function & Memory API;或者
-
在学习与新 API 协同工作的编程新特性时,例如 虚拟线程及其执行器。
自 Java 9 起,模块允许将一组包组合在一起,以便在单个名称下重用。模块的导出包旨在形成一个内聚且连贯的 API,因此如果开发人员能够从整个模块按需导入,即从模块导出的所有包中导入,那将会很方便。这就像是将所有导出的包一次性导入一样。
例如,按需导入 java.base
模块会立即提供对 List
、Map
、Stream
和 Path
的访问,而无需手动按需导入 java.util
、按需导入 java.util.stream
以及按需导入 java.nio.file
。
在模块级别进行导入的能力在某个模块中的 API 与另一个模块中的 API 关系密切时尤其有用。这在大型多模块库(如 JDK)中很常见。例如,java.sql
模块通过其 java.sql
和 javax.sql
包提供数据库访问,但其中一个接口 java.sql.SQLXML
声明了签名使用来自 javax.xml.transform
包(在 java.xml
模块中)的接口的 public
方法。调用 java.sql.SQLXML
中这些方法的开发者通常会同时导入 java.sql
包和 javax.xml.transform
包。为了便于这种额外的导入,java.sql
模块传递地依赖于 java.xml
模块,因此依赖于 java.sql
模块的程序会自动依赖于 java.xml
模块。在这种情况下,如果按需导入 java.sql
模块也能自动按需导入 java.xml
模块将会很方便。当原型设计和探索时,自动从传递依赖项中按需导入将是一个进一步的便利。
描述
模块导入声明的形式为
import module M;
它按需导入所有 public
顶级类和接口到
-
模块
M
导出到当前模块的包,以及 -
由于读取模块
M
而由当前模块读取的模块导出的包。
第二条子句允许程序使用模块的 API,该 API 可能引用来自其他模块的类和接口,而无需导入所有这些其他模块。
例如:
这是一个预览语言特性,默认情况下是禁用的
要尝试以下 JDK 24 中的示例,您必须启用预览功能:
语法和语义
我们扩展了导入声明的语法(JLS §7.5),以包括 import module
子句:
ImportDeclaration:
SingleTypeImportDeclaration
TypeImportOnDemandDeclaration
SingleStaticImportDeclaration
StaticImportOnDemandDeclaration
ModuleImportDeclaration
ModuleImportDeclaration:
import module ModuleName;
import module
需要一个模块名称,因此无法从无名模块(即类路径)导入包。这与模块声明中的 requires
子句(即 module-info.java
文件)一致,它们需要模块名称,并且不能表示对无名模块的依赖。
import module
可以在任何源文件中使用,无论该文件是否是某个显式模块定义的一部分。例如,java.base
和 java.sql
是标准 Java 运行时的一部分,可以被仅部署在类路径上的类导入。(有关技术背景,请参阅 JEP 261。)
在属于显式模块定义的源文件中,可以使用 import module
便捷地导入该模块导出的所有包,且无需限定。在这样的源文件中,模块中未导出或带限定导出的包必须继续以传统方式导入。(换句话说,对于模块 M
内部的代码来说,import module M
并不比对于 M
外部的代码更强大。)
源文件可以导入同一个模块多次。
解决模棱两可的导入
导入一个模块相当于导入了多个包,因此可以从不同的包中导入具有相同简单名称的类。简单名称是模糊的,所以使用它会导致编译时错误。
例如,在此源文件中,简单名称 Element
是有歧义的:
import module java.desktop; // exports javax.swing.text,
// which has a public Element interface,
// and also exports javax.swing.text.html.parser,
// which has a public Element class
...
Element e = ... // Error - Ambiguous name!
...
例如,在此源文件中,简单名称 List
是有歧义的:
import module java.base; // exports java.util, which has a public List interface
import module java.desktop; // exports java.awt, which a public List class
...
List l = ... // Error - Ambiguous name!
...
作为最后一个例子,在这个源文件中,简单名称 Date
是有歧义的:
import module java.base; // exports java.util, which has a public Date class
import module java.sql; // exports java.sql, which has a public Date class
...
Date d = ... // Error - Ambiguous name!
...
消除歧义很简单:使用另一个导入声明。例如,添加一个单类型导入声明通过遮蔽由 import module
声明导入的 Date
类来解决前面例子中模糊的 Date
:
import module java.base; // exports java.util, which has a public Date class
import module java.sql; // exports java.sql, which has a public Date class
import java.sql.Date; // resolve the ambiguity of the simple name Date!
...
Date d = ... // Ok! Date is resolved to java.sql.Date
...
在其他情况下,通过遮蔽包中的所有类来添加一个按需声明以解决歧义会更方便:
import module java.base;
import module java.desktop;
import java.util.*;
import javax.swing.text.*;
...
Element e = ... // Element is resolved to javax.swing.text.Element
List l = ... // List is resolved to java.util.List
Document d = ... // Document is resolved to javax.swing.text.Document,
// regardless of any module imports
...
导入声明的遮蔽行为与其特异性相匹配。最具体的,即单类型导入声明,可以遮蔽较不具体的按需导入声明和模块导入声明。按需声明可以遮蔽较不具体的模块导入声明,但不能遮蔽更具体的单类型导入声明。
合并导入声明
你可能可以将多个按需声明合并为一个模块导入声明;例如:
import javax.xml.*;
import javax.xml.parsers.*;
import javax.xml.stream.*;
可以替换为:
import module java.xml;
哪个更容易阅读。
导入声明的分组
如果一个源文件中混合了不同种类的导入声明,那么按种类对它们进行分组可能会进一步提高可读性;例如:
// Module imports
import module M1;
import module M2;
...
// Package imports
import P1.*;
import P2.*;
...
// Single-type imports
import P1.C1;
import P2.C2;
...
class Foo { ... }
各组的顺序反映了它们的遮蔽行为:最不具体的模块导入声明在前,最具体的单类型导入在后,而按需导入则位于中间。
一个实例
以下是 import module
的工作原理。假设源文件 C.java
是模块 M0
定义的一部分:
// C.java
package q;
import module M1; // What does this import?
class C { ... }
其中模块 M0
具有以下声明:
module M0 { requires M1; }
import module M1
的含义取决于 M1
的导出内容以及 M1
传递依赖的任何模块。
module M1 {
exports p1;
exports p2 to M0;
exports p3 to M3;
requires transitive M4;
requires M5;
}
module M3 { ... }
module M4 { exports p10; }
module M5 { exports p11; }
import module M1
的效果是
-
从包
p1
中导入public
顶层类和接口,因为M1
向所有人导出了p1
; -
从包
p2
中导入public
顶层类和接口,因为M1
向M0
导出了p2
,而C.java
与M0
模块关联;以及 -
从包
p10
中导入public
顶层类和接口,因为M1
传递性地依赖了M4
,而M4
导出了p10
。
C.java
没有导入来自 p3
或 p11
包的任何内容。
在简单的源文件中导入模块
此JEP与JEP 简单的源文件和实例 main
方法 共同开发,该JEP规定,在 java.base
模块导出的每个包中的每个 public
顶级类和接口都会在简单的源文件中自动按需导入。换句话说,就好像每个这样的文件开头都出现了 import module java.base
。简单的源文件可以导入其他模块,例如 java.desktop
,并且可以显式地导入 java.base
模块,尽管这样做是多余的。
JShell 工具会按需自动导入十个包。这个包的列表是临时确定的。因此,我们建议将 JShell 更改为自动 import module java.base
。
导入聚合器模块
在该功能的早期预览中,开发人员惊讶地发现导入 java.se
模块并没有导入 java.base
模块的效果。因此,他们不得不另外导入 java.base
模块,或者从 java.base
中导入特定的包,例如 import java.util.*
。
导入 java.se
模块并不会导入 java.base
模块,因为 Java 语言规范 明确禁止 任何模块声明对 java.base
模块的传递依赖。在模块特性的原始设计中,这种限制是合理的,因为每个模块都隐式依赖于 java.base
。然而,使用模块声明来导出一组要导入的包的模块导入特性,能够传递地要求 java.base
是有用的。
因此,我们建议解除这一语言限制。我们还将修订 java.se
模块的声明,使其递归地需要 java.base
模块。这样,只需要一个 import module java.se
就可以使用整个标准 Java API,无论有多少模块参与导出该 API。
只有 Java 平台中的聚合模块应该使用 requires transitive java.base
。这样的聚合器的客户端期望导入所有 java.*
模块,包括 java.base
。Java 平台中既有直接导出又有间接导出的模块严格来说不是聚合器。因此它们不应该使用 requires transitive java.base
,因为它可能会污染客户端的命名空间。例如,java.sql
模块既导出自己的包,也导出来自 java.xml
和其他模块的包,但说 import module java.sql
的客户端不一定对从 java.base
导入所有内容感兴趣。
指令 import module java.se
仅在属于已经 requires java.se
的显式模块定义的一部分的源文件中有效。在模块定义之外的源文件中,特别是在隐式声明类的简单源文件中,使用 import module java.se
会失败,因为 java.se
不在未命名模块的默认根模块集合中。在自动模块定义的一部分的源文件中,如果其他已解析的显式模块 requires java.se
,则 import module java.se
将起作用。
替代方案
-
除了
import module ...
之外,另一种方法是自动导入比java.lang
更多的包。这会将更多的类引入到作用域中,即可以通过它们的简单名称使用,并且可以推迟初学者学习任何类型的导入的时间。但是,我们应该自动导入哪些额外的包呢?每个读者都会对从无处不在的
java.base
模块中自动导入哪些包有自己的建议:java.io
和java.util
几乎是普遍的建议;java.util.stream
和java.util.function
也很常见;而java.math
、java.net
和java.time
也各自有支持者。对于 JShell 工具,我们设法找到了十个在尝试一次性 Java 代码时广泛有用的java.*
包,但很难看出java.*
包中的哪个子集应该永久并自动地导入到每个 Java 程序中。此外,随着 Java 平台的发展,这个列表也会发生变化;例如,java.util.stream
和java.util.function
只是在 Java 8 中才被引入。开发人员可能会依赖 IDE 来提醒他们哪些自动导入是有效的——这是一个不希望看到的结果。 -
此功能的一个重要用例是在隐式声明的类中自动按需从
java.base
模块导入。这也可以通过自动导入java.base
导出的 54 个包来实现。然而,当一个隐式类迁移到普通的显式类时,这是预期的生命周期,开发人员要么必须写 54 个按需包导入,要么必须弄清楚哪些导入是必要的。
风险和假设
使用一个或多个模块导入声明会导致名称歧义的风险,因为不同的包可能声明具有相同简单名称的成员。这种歧义在程序中使用了有歧义的简单名称时才会被发现,并且会导致编译时错误。可以通过添加单类型导入声明来解决这种歧义,但管理和解决此类名称歧义可能会很麻烦,并导致代码变得脆弱且难以阅读和维护。