跳转到内容

JEP 511:模块导入声明

原文:https://openjdk.org/jeps/511
翻译:张欢

通过简洁地导入模块导出的所有包,增强Java编程语言的功能。这简化了模块库的复用,但导入代码无需位于模块本身中。

模块导入声明是由JEP 476(JDK 23)引入的,随后由JEP 494(JDK 24)进一步改进。我们建议在JDK 25中完成该功能,不做改动。

  • 通过允许一次导入整个模块来简化模块库的重用。
  • 当使用模块导出的API的不同部分时,避免多个按需类型导入声明(例如import com.foo.bar.*)的干扰。
  • 允许初学者更轻松地使用第三方库和基本Java类,而无需了解它们在包层次结构中的位置。
  • 确保模块导入声明与现有导入声明顺利协同工作。
  • 不需要使用模块导入功能的开发者模块化自己的代码。

java.lang包中的类和接口(例如ObjectStringComparable)对于每个Java程序都至关重要。因此,Java编译器会根据需要自动导入java.lang包中的所有类和接口,就像

import java.lang.*;

出现在每个源文件的开头一样。

随着Java平台的发展,诸如ListMapStreamPath几乎同样重要。然而,它们都不在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 9以来,模块允许将一组包组合在一起,以便使用单一名称进行复用。模块导出的包,旨在形成一个内聚且一致的API。因此,如果开发者能够按需从整个模块(即模块导出的所有包)导入,将会非常方便。这就像一次性导入所有导出的包一样。

例如,按需导入java.base模块可以立即访问ListMapStreamPath,而不用按需手动导入java.utiljava.util.streamjava.nio.file包。

在模块级别导入的能力在以下情况下尤其有用:一个模块中的API与另一个模块中的API有密切的关系。这在大型多模块库(例如 JDK)中很常见。例如,java.sql模块通过其java.sqljavax.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模块间接导出的包

我们扩展了导入声明的语法(JLS §7.5)以包含import module子句:

ImportDeclaration:
SingleTypeImportDeclaration
TypeImportOnDemandDeclaration
SingleStaticImportDeclaration
StaticImportOnDemandDeclaration
ModuleImportDeclaration
ModuleImportDeclaration:
import module ModuleName;

import module需要指定模块名称,因此无法从未命名的模块(即从类路径)导入包。这与模块声明中的requires子句要求一致,即module-info.java文件,它使用模块名称,但不能表达对未命名模块的依赖。

import module可以在任何源文件中使用,无论该文件是否是显式模块定义的一部分。例如,java.basejava.sql是标准Java运行时的一部分,可以被仅通过类路径部署的类导入。(有关技术背景,见JEP 261。)

在作为显式模块定义一部分的源文件中, 使用import module可以方便地导入该模块导出的所有包,无需任何条件限制。在这样的源文件中,模块中未导出或导出时带有条件限制的包必须继续以传统方式导入。(换句话说,import module M对于模块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 = ... // 错误 - 有歧义的名称!
...

解决歧义很简单:使用另一个导入声明。例如,添加一个单类型导入声明可以解决Date的歧义。通过隐藏导入的Date类来代替上一个示例的import module声明:

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顶级类和接口,因为M1p1导出给所有人;
  • 从包p2导入public顶级类和接口,因为M1p2导出到与C.java关联的模块M0;并且
  • 从包p10导入public顶级类和接口,因为M1需要传递M4,而M4又导出p10

C.java未导入包p3p11中的任何内容。

本JEP与JEP“紧凑源文件与实例main方法”共同开发,其中规定由java.base模块导出的每个包中的每个public顶级类和接口都会按需自动导入到紧凑的源文件中。换句话说,就好像import module java.base出现在每个这种文件的开头一样。紧凑的源文件可以导入其他模块,例如java.desktop,也可以显式导入java.base模块,即使这样做是多余的。

JShell工具会根据需要自动导入10个包。包列表是临时的。因此,我们建议将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模块。因此,无论有多少模块参与导出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模块不在未命名模块的默认根模块集中。在作为自动模块定义一部分的源文件中,如果其他一些已解析的模块已有require java.se,那么import module java.se就会生效。

  • 除了import module ...之外,还有一种替代方案是自动导入除java.lang之外的更多包。这样可以将更多类纳入范围,即可以通过其简单的名称使用,并延迟初学者学习各种导入方式的需要。但是,我们应该自动导入哪些额外的包呢?
    每个读者都会对从无处不在的java.base模块自动导入哪些包提出建议:java.iojava.util是几乎通用的建议;java.util.streamjava.util.function也很常见;还有java.mathjava.netjava.time都有支持。对于JShell工具,我们找到了10个java.*包,它们在以下情况下非常有用:尝试一次性的Java代码,但很难看出java.*包的哪些哪些子集应该永久自动导入到每个紧凑的源文件中。此外,这样的列表会随着Java平台的演进而变化;例如,java.util.streamjava.util.function仅在Java 8中引入。开发人员可能会需要依赖IDE来提醒他们哪些自动导入已生效——这不是一个理想的结果。
  • 此功能的一个重要用例是,在隐式声明的类中自动按需从java.base模块导入。也可以通过自动导入java.base导出的54个包来实现。但是,当隐式类迁移到普通显式类(这是预期的生命周期)时,开发人员要么必须编写54个按需包导入,要么弄清楚哪些导入是必要的。

使用一个或多个模块导入声明会导致名称歧义的风险,因为不同的包会使用相同的简单名称声明成员。这种歧义只有在程序中使用了歧义的简单名称时才会被检测到,从而引发编译时错误。虽然可以通过添加单类型导入声明来解决这种歧义,但管理和解决此类名称歧义可能会非常繁琐,并导致代码变得脆弱、难以阅读和维护。