JEP 511:模块导入声明
JEP 511:模块导入声明
原文:JEP 511- Module Import Declarations
作者:
日期:2025-10-26
| 作者 | 吉姆·拉斯基(Jim Laskey)、加文·比尔曼(Gavin Bierman) |
|---|---|
| 所有者 | 加文·比尔曼(Gavin Bierman) |
| 类型 | 特性 |
| 范围 | 标准版(SE) |
| 状态 | 已关闭 / 已交付 |
| 发布版本 | 25 |
| 组件 | 规范 / 语言 |
| 讨论组 | amber - dev@openjdk.org |
| 相关内容 | JEP 494:模块导入声明(第二次预览) JEP 512:紧凑源文件与实例主方法 |
| 审核人 | 亚历克斯·巴克利(Alex Buckley)、布莱恩·戈茨(Brian Goetz) |
| 批准人 | 布莱恩·戈茨(Brian Goetz) |
| 创建时间 | 2024 年 11 月 21 日 12:00 |
| 更新时间 | 2025 年 7 月 22 日 14:42 |
| 问题编号 | 8344700 |
摘要
为 Java 编程语言添加简洁导入模块导出的所有包的能力。这简化了模块化库的复用,且导入代码本身无需处于模块之中。
历史
模块导入声明在 JEP 476(JDK 23)中作为预览特性被提出,随后在 JEP 494(JDK 24)中得到完善。在此,我们提议在 JDK 25 中确定该特性,且不做更改。
目标
- 通过允许一次性导入整个模块,简化模块化库的复用。
- 在使用模块导出的 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 声明的 public 方法,其签名使用了 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;
它按需导入以下内容中的所有 public 顶级类和接口:
- 模块
M向当前模块导出的包中的内容,以及 - 当前模块因读取模块
M而可读的那些模块所导出的包中的内容。
第二条允许程序使用一个模块的 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模块的间接导出包 的按需包导入具有相同效果。
语法和语义
我们扩展导入声明的语法(《Java 语言规范》§7.5),以包含 import module 子句:
ImportDeclaration:
SingleTypeImportDeclaration
TypeImportOnDemandDeclaration
SingleStaticImportDeclaration
StaticImportOnDemandDeclaration
ModuleImportDeclaration
ModuleImportDeclaration:
import module ModuleName;
import module 接受一个模块名,因此无法从未命名模块(即从类路径)导入包。这与模块声明(即 module - info.java 文件)中的 requires 子句一致,requires 子句接受模块名,且不能表示对未命名模块的依赖。
import module 可用于任何源文件,无论该文件是否是显式模块定义的一部分。例如,java.base 和 java.sql 是标准 Java 运行时的一部分,并且可以被仅部署在类路径上的类导入。(有关技术背景,请参阅 JEP 261。)
在作为显式模块定义一部分的源文件中,import module 可用于方便地导入该模块无限制导出的所有包。在这样的源文件中,模块中未导出或有限制导出的包必须继续以传统方式导入。(换句话说,对于模块 M 内部的代码,import module M 并不比在 M 外部的代码更强大。)
一个源文件可以多次导入同一个模块。
解决模糊导入
导入一个模块会导入多个包,因此有可能从不同包中导入具有相同简单名称的类。简单名称存在歧义,因此使用它会导致编译时错误。
例如,在此源文件中,简单名称 Element 存在歧义:
import module java.desktop; // 导出 javax.swing.text,
// 其中有一个 public Element 接口,
// 还导出 javax.swing.text.html.parser,
// 其中有一个 public Element 类
...
Element e = ... // 错误 - 名称有歧义!
...
再例如,在此源文件中,简单名称 List 存在歧义:
import module java.base; // 导出 java.util,其中有一个 public List 接口
import module java.desktop; // 导出 java.awt,其中有一个 public List 类
...
List l = ... // 错误 - 名称有歧义!
...
最后一个例子,在此源文件中,简单名称 Date 存在歧义:
import module java.base; // 导出 java.util,其中有一个 public Date 类
import module java.sql; // 导出 java.sql,其中有一个 public Date 类
...
Date d = ... // 错误 - 名称有歧义!
...
解决歧义很简单:使用另一个导入声明。例如,添加一个单类型导入声明,通过“遮蔽”由 import module 声明导入的 Date 类,来解决前面示例中 Date 的歧义:
import module java.base; // 导出 java.util,其中有一个 public Date 类
import module java.sql; // 导出 java.sql,其中有一个 public 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将p2导出给与C.java相关联的模块M0;并且 - 从包
p10导入public顶级类和接口,因为M1传递依赖M4,而M4导出p10。
C.java 不会从包 p3 或 p11 导入任何内容。
在紧凑源文件中导入模块
本 JEP 与 《紧凑源文件与实例 main 方法》 一同开发,后者规定在紧凑源文件中,java.base 模块导出的每个包中的每个 public 顶级类和接口都会被自动按需导入。换句话说,就好像 import module java.base 出现在每个这样的文件开头。一个紧凑源文件可以导入其他模块,例如 java.desktop,并且可以显式导入 java.base 模块,尽管这样做是多余的。
JShell 工具会自动按需导入十个包。包的列表是特定的。因此,我们提议将 JShell 改为自动 import module java.base。
导入聚合模块
导入一个“聚合模块”有时很有用,即一个自身不导出任何包,但会导出其依赖模块所导出的包的模块。例如,java.se 模块不导出任何包,但它 传递性地 依赖其他 19 个模块,所以 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 模块。这样,无论有多少个模块参与导出标准 Java API,只需一个 import module java.se 就足以使用整个标准 Java 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 个按需包导入,要么必须弄清楚哪些导入是必要的。
风险与假设
使用一个或多个模块导入声明会带来名称歧义的风险,因为不同的包可能声明具有相同简单名称的成员。这种歧义直到在程序中使用了有歧义的简单名称时才会被检测到,此时会发生编译时错误。可以通过添加单类型导入声明来解决歧义,但管理和解决此类名称歧义可能很麻烦,并导致代码脆弱、难以阅读和维护。