JEP 494: Module Import Declarations (Second Preview) | 模块导入声明(第二次预览)
摘要
增强 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; // 或者 import java.util.*;
import java.util.function.Function; // 或者 import java.util.function.*;
import java.util.stream.Collectors; // 或者 import java.util.stream.*;
import java.util.stream.Stream; // (可以移除)
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 收集器 或 外部函数和内存 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
,在其方法签名中使用了来自 java.xml
模块的 javax.xml.transform
包中的接口。调用 java.sql.SQLXML
中这些方法的开发者通常需要同时导入 java.sql
包和 javax.xml.transform
包。为了便于这种额外的导入,java.sql
模块依赖于 java.xml
模块 传递性,这样依赖于 java.sql
模块的程序也会自动依赖于 java.xml
模块。在这种情况下,如果按需导入 java.sql
模块也能自动按需导入 java.xml
模块,会非常方便。当原型设计和探索时,自动从传递依赖项按需导入将是进一步的便利。
描述
一个 模块导入声明 的形式如下:
import module M;
它按需导入当前模块中由模块 M
导出的所有 public
顶级类和接口,以及由于读取模块 M
而被当前模块读取的模块所导出的包中的所有 public
顶级类和接口。
第二个条款允许程序使用一个模块的 API,该 API 可能引用了来自其他模块的类和接口,而无需导入所有这些其他模块。
例如:
import module java.base
的效果等同于 54 个按需包导入,对应于java.base
模块导出的每个 包。这就像是源文件包含了import java.io.*
和import java.util.*
等等。import module java.sql
的效果等同于import java.sql.*
和import javax.sql.*
加上对java.sql
模块的 间接导出 的按需包导入。
这是一个 JEP 12 预览语言特性,默认情况下禁用
要在 JDK 24 中尝试以下示例,您必须启用预览功能:
使用
javac --release 24 --enable-preview Main.java
编译程序,并使用java --enable-preview Main
运行;或者,当使用 源代码启动器 时,使用
java --enable-preview Main.java
运行程序;或者,当使用
jshell
时,使用jshell --enable-preview
启动它。
语法和语义
我们扩展了导入声明的语法(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
并不比模块外部的代码更强大。)
源文件可以多次导入相同的模块。
解决导入歧义
导入模块的效果是导入多个包,因此有可能从不同的包中导入具有相同简单名称的类。简单名称因此变得模糊,使用它将导致编译时错误。
例如,在此源文件中,简单名称 Element
是模棱两可的:
import module java.desktop; // 导出了 javax.swing.text,其中有一个公共的 Element 接口,
// 并且还导出了 javax.swing.text.html.parser,其中有一个公共的 Element 类
...
Element e = ... // 错误 - 名称不明确!
...
另一个例子,在此源文件中,简单名称 List
是模棱两可的:
import module java.base; // 导出了 java.util,其中有一个公共的 List 接口
import module java.desktop; // 导出了 java.awt,其中有一个公共的 List 类
...
List l = ... // 错误 - 名称不明确!
...
最后的例子,在此源文件中,简单名称 Date
是模棱两可的:
import module java.base; // 导出了 java.util,其中有一个公共的 Date 类
import module java.sql; // 导出了 java.sql,其中有一个公共的 Date 类
...
Date d = ... // 错误 - 名称不明确!
...
解决歧义很简单:使用另一个导入声明。例如,添加一个单类型导入声明通过 遮蔽 由 import module
声明导入的 Date
类来解决前面示例中模棱两可的 Date
问题:
import module java.base; // 导出了 java.util,其中有一个公共的 Date 类
import module java.sql; // 导出了 java.sql,其中有一个公共的 Date 类
import java.sql.Date; // 解决简单名称 Date 的歧义!
...
Date d = ... // 好!Date 解析为 java.sql.Date
...
在其他情况下,添加一个按需声明以通过遮蔽包中的所有类来解决歧义更为方便:
import module java.base;
import module java.desktop;
import java.util.*;
import javax.swing.text.*;
...
Element e = ... // Element 解析为 javax.swing.text.Element
List l = ... // List 解析为 java.util.List
Document d = ... // Document 解析为 javax.swing.text.Document,
// 不管有任何模块导入
...
导入声明的遮蔽行为与其特异性相匹配。最具体的,即单类型导入声明,可以遮蔽按需和模块导入声明,后者特异性较低。按需声明可以遮蔽特异性较低的模块导入声明,但不能遮蔽更具体的单类型导入声明。
合并导入声明
您可能可以将多个按需声明合并为一个模块导入声明;例如:
import javax.xml.*;
import javax.xml.parsers.*;
import javax.xml.stream.*;
可以替换为:
import module java.xml;
这更容易阅读。
分组导入声明
如果源文件包含不同类型的导入声明,则通过类型对它们进行分组可能会进一步提高可读性;例如:
// 模块导入
import module M1;
import module M2;
...
// 包导入
import P1.*;
import P2.*;
...
// 单类型导入
import P1.C1;
import P2.C2;
...
class Foo { ... }
组的顺序反映了它们的遮蔽行为:最不具体的模块导入声明排在最前面,最具体的单类型导入排在最后面,而按需导入位于中间。
实例解析
以下是一个 import module
如何工作的实例。假设源文件 C.java
是模块 M0
定义的一部分:
// C.java
package q;
import module M1; // 这个导入做了什么?
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
向与C.java
关联的模块M0
导出了p2
;以及导入来自包
p10
的所有public
顶级类和接口,因为M1
传递性地依赖于导出p10
的M4
。
C.java
不会导入来自包 p3
或 p11
的任何内容。
在简单的源文件中导入模块
此 JEP 与 JEP 简单的源文件和实例 main
方法 共同开发,后者规定在 java.base
模块中每个包导出的每个 public
顶级类和接口,在简单源文件中都是按需自动导入的。换句话说,这就像 import module java.base
出现在每个此类文件的开头一样。一个简单的源文件可以导入其他模块,例如 java.desktop
,并且可以显式地导入 java.base
模块,即使这样做是多余的。
JShell 工具自动按需导入十个包。这个包列表是特别指定的。因此,我们建议修改 JShell 以自动 import module java.base
。
导入聚合器模块
有时导入一个 聚合器模块 是有用的,即一个自身不导出任何包但确实导出其所要求模块的导出包的模块。例如,java.se
模块不导出任何包,但它需要其他十九个模块 传递性地,所以 import module java.se
的效果是导入那些模块导出的包,并且如此递归下去——具体来说,就是 java.se
模块列出的 123 个 间接导出 包。
在此功能的早期预览中,开发者惊讶地发现导入 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 个按需包导入,要么就得找出哪些导入是必要的。
风险和假设
使用一个或多个模块导入声明会导致由于不同包声明相同简单名称成员而导致名称模糊的风险。这种模糊直到程序中使用了模糊的简单名称时才会被检测到,此时会发生编译时错误。可以通过添加单一类型导入声明来解决模糊问题,但是管理和解决这样的名称模糊可能是负担沉重的,导致代码脆弱且难以阅读和维护。