Java17-入门基础知识-六-

118 阅读1小时+

Java17 入门基础知识(六)

原文:Beginning Java 17 Fundamentals

协议:CC BY-NC-SA 4.0

九、构造器

在本章中,您将学习:

  • 什么是构造器以及如何使用它们

  • 一个类的不同类型的初始化器

  • 声明final变量、字段、类和方法

  • 什么是泛型类以及如何使用它们

什么是构造器?

构造器是一个命名的代码块,用于在对象创建后立即初始化类的对象。构造器的结构看起来类似于方法。然而,两者之间的相似之处就止于此。它们是两种不同的结构,用于不同的目的。

声明构造器

构造器声明的一般语法如下:

[modifiers] <constructor-name>(<parameters-list>) [throws-clause] {
    // Body of the constructor goes here
}

构造器的声明以修饰符开始。构造器的访问修饰符可以是publicprivateprotected或包级别(没有修饰符)。构造器名与类的简单名相同。构造器名后面是一对左括号和右括号,其中可能包含参数。或者,右括号后面可以跟一个throws子句,然后是一个逗号分隔的异常列表。我将在第十三章和中讨论关键词throws的用法。放置代码的构造器体用大括号括起来。

如果你比较一下声明方法的语法和声明构造器的语法,你会发现它们几乎是一样的。建议在学习构造器声明时记住方法声明,因为大多数特征都是相似的。

下面的代码展示了一个为类Test声明构造器的例子。图 9-1 显示了构造器的解剖结构:

img/323069_3_En_9_Fig1_HTML.png

图 9-1

测试类构造器的剖析

// Test.java
package com.jdojo.cls;
public class Test {
    public Test() {
        // Code goes here
    }
}

Tip

构造器的名称必须匹配类的简单名称,而不是完全限定名称。

与方法不同,构造器没有返回类型。您甚至不能将void指定为构造器的返回类型。考虑下面一个类Test2的声明:

public class Test2 {
    // Below is a method, not a constructor.
    public void Test2() {
        // Code goes here
    }
}

Test2是否声明了一个构造器?答案是否定的。类Test2没有声明构造器。相反,您可能看到的是一个方法声明,它与类的简单名称同名。它是一个方法声明,因为它指定了一个返回类型void。请注意,方法名也可以与类名相同,如本例所示。

仅仅名字本身并不能构成一个方法或构造器。如果构造的名称与类的简单名称相同,那么它可能是一个方法或构造器。如果它指定了返回类型,则它是一个方法。如果它没有指定返回类型,它就是一个构造器。

什么时候使用构造器?在新实例创建之后,使用带有new操作符的构造器来初始化一个类的实例(或对象)。有时短语“创建”和“初始化”在构造器的上下文中可以互换使用。但是,您需要清楚创建和初始化对象的区别。new操作符创建一个对象,构造器初始化该对象。

以下语句使用Test类的构造器来初始化Test类的对象:

Test t = new Test();

图 9-2 显示了这种说法的剖析。new操作符后面是对构造器的调用。new操作符,连同构造器调用,例如"new Test()",被称为实例(或对象)创建表达式。实例创建表达式在内存中创建一个对象,执行指定构造器体中的代码,最后返回新对象的引用。

img/323069_3_En_9_Fig2_HTML.png

图 9-2

用 new 运算符解析构造器调用

我已经介绍了足够多的关于声明构造器的理论。是时候看看一个构造器了。清单 9-1 有一个Cat类的代码。

// Cat.java
package com.jdojo.cls;
public class Cat {
    public Cat() {
        System.out.println("Meow...");
    }
}

Listing 9-1A Cat Class with a Constructor

Cat类声明了一个构造器。在构造器的主体内部,它打印一条消息"Meow..."。清单 9-2 包含了一个CatTest类的代码,该类在其main()方法中创建了两个Cat对象。请注意,您总是使用对象创建表达式来创建一个新的Cat类对象。由您决定将新对象的引用存储在引用变量中。第一个Cat对象被创建,其引用未被保存。创建第二个Cat对象,其引用存储在引用变量c中。

// CatTest.java
package com.jdojo.cls;
public class CatTest {
    public static void main(String[] args) {
        // Create a Cat object and ignore its reference
        new Cat();
        // Create another Cat object and store its reference in c
        Cat c = new Cat();
    }
}
Meow...
Meow...

Listing 9-2A Test Class That Creates Two Cat Objects

重载构造器

一个类可以有多个构造器。如果一个类有多个构造器,它们被称为重载构造器。由于构造器的名称必须与类的简单名称相同,因此有必要区分不同的构造器。重载构造器的规则与重载方法的规则相同。如果一个类有多个构造器,所有的构造器在数量、顺序或参数类型上都必须不同。清单 9-3 包含了一个Dog类的代码,它声明了两个构造器。一个构造器不接受任何参数,另一个接受一个String参数。

// Dog.java
package com.jdojo.cls;
public class Dog {
    // Constructor #1
    public Dog() {
        System.out.println("A dog is created.");
    }
    // Constructor #2
    public Dog(String name) {
        System.out.println("A dog named " + name + " is created.");
    }
}

Listing 9-3A Dog Class with Two Constructors, One with No Parameters and One with a String Parameter

如果一个类声明了多个构造器,您可以使用其中的任何一个来创建该类的对象。例如,下面两条语句创建了两个Dog类的对象:

Dog dog1 = new Dog();
Dog dog2 = new Dog("Cupid");

第一条语句使用不带参数的构造器,第二条语句使用带String参数的构造器。如果使用带参数的构造器创建对象,实际参数的顺序、类型和数量必须与形参的顺序、类型和数量相匹配。清单 9-4 有使用不同构造器创建两个Dog对象的完整代码。

// DogTest.java
package com.jdojo.cls;
public class DogTest {
    public static void main(String[] args) {
        Dog d1 = new Dog();         // Uses Constructor #1
        Dog d2 = new Dog ("Canis"); // Uses Constructor #2
    }
}
A dog is created.
A dog named Canis is created.

Listing 9-4Testing the Constructors of the Dog Class

运行DogTest类的输出表明,当在main()方法中创建两个Dog对象时,会调用不同的构造器。

每个对象创建表达式调用一次构造器。在对象创建过程中,一个构造器的代码只能执行一次。如果一个构造器的代码被执行了N次,这意味着该类的N个对象将被创建,你必须使用N个对象创建表达式来完成。但是,当一个对象创建表达式调用一个构造器时,被调用的构造器可能会从它的主体调用另一个构造器。本书将在本节的后面介绍一个构造器调用另一个构造器的场景。

为构造器编写代码

到目前为止,您一直在用构造器编写琐碎的代码。在构造器中应该写什么样的代码?构造器的目的是初始化新创建的对象的实例变量。在构造器中,您应该限制自己只编写初始化对象实例变量的代码。调用构造器时,对象没有完全创建。该对象仍在创建过程中。如果假设内存中存在一个完整的对象,在构造器中编写一些处理逻辑,有时可能会得到意想不到的结果。让我们创建另一个类来表示一个狗对象。您将调用这个类SmartDog,如清单 9-5 所示。

// SmartDog.java
package com.jdojo.cls;
public class SmartDog {
    private String name;
    private double price;
    public SmartDog() {
        // Initialize the name to “Unknown” and the price to 0.0
        this.name = "Unknown";
        this.price = 0.0;
        System.out.println("Using SmartDog() constructor");
    }
    public SmartDog(String name, double price) {
        // Initialize name and price instance variables with the
        // values of the name and price parameters
        this.name = name;
        this.price = price;
        System.out.println("Using SmartDog(String, double) constructor");
    }
    public void bark() {
        System.out.println(name + " is barking...");
    }
    public void setName(String name) {
        this.name = name;
    }
    public String getName() {
        return this.name;
    }
    public void setPrice(double price) {
        this.price = price;
    }
    public double getPrice() {
        return this.price;
    }
    public void printDetails() {
        System.out.print("Name: " + this.name);
        if (price > 0.0) {
            System.out.println(", price: " + this.price);
        } else {
            System.out.println(", price: Free");
        }
    }
}

Listing 9-5A SmartDog Class That Declares Two Constructors to Initialize Instance Variables Differently

SmartDog级看起来大一点。但是,它的逻辑很简单。以下是您需要了解的SmartDog类中的要点:

  • 它声明了两个实例变量;他们是namepricename实例变量存储一只聪明狗的名字。price实例变量存储它的销售价格。

  • 它声明了两个构造器。第一个构造器没有参数。它将 name 和 price 实例变量分别初始化为"Unknown"0.0。第二个构造器接受两个名为nameprice的参数。它将nameprice实例变量初始化为传递给这两个参数的任何值。注意构造器中关键字this的使用。关键字this指的是构造器的代码正在执行的对象。在第一个构造器中没有必要使用关键字this。但是,您必须使用关键字this来引用第二个构造器中的实例变量,因为形参的名称隐藏了实例变量的名称。

  • 这两个构造器在它们的主体中初始化实例变量(或者对象的状态)。它们不包括任何其他处理逻辑。

  • 实例方法bark()在标准输出中打印一条消息,带有正在吠叫的智能狗的名字。

  • setName()getName()方法用于设置和获取智能狗的名称。setPrice()getPrice()方法用于设置和获取智能狗的价格。

  • printDetails()方法打印智能狗的nameprice。如果智能狗的价格没有设置为正值,它会将价格打印为"Free"

清单 9-6 有一个SmartDogTest类的代码,演示了两个构造器如何初始化实例变量。

// SmartDogTest.java
package com.jdojo.cls;
public class SmartDogTest {
    public static void main(String[] args) {
        // Create two SmartDog objects
        SmartDog sd1 = new SmartDog();
        SmartDog sd2 = new SmartDog("Nova", 219.2);
        // Print details about the two dogs
        sd1.printDetails();
        sd2.printDetails();
        // Make them bark
        sd1.bark();
        sd2.bark();
        // Change the name and price of Unknown dog
        sd1.setName("Opal");
        sd1.setPrice(321.80);
        // Print details again
        sd1.printDetails();
        sd2.printDetails();
        // Make them bark one more time
        sd1.bark();
        sd2.bark();
    }
}
Using SmartDog() constructor
Using SmartDog(String, double) constructor
Name: Unknown, price: Free
Name: Nova, price: 219.2
Unknown is barking...
Nova is barking...
Name: Opal, price: 321.8
Name: Nova, price: 219.2
Opal is barking...
Nova is barking...

Listing 9-6A Test Class to Demonstrate the Use of the SmartDog Class

从另一个构造器调用一个构造器

一个构造器可以调用同一个类的另一个构造器。我们来考虑下面这个Test类。它声明了两个构造器;一个不接受任何参数,一个接受一个int参数:

public class Test {
    Test() {
    }
    Test(int x) {
    }
}

假设您想从不带参数的构造器中调用带int参数的构造器。你的第一次尝试是错误的,如下所示:

public class Test {
    Test() {
        // Call another constructor
        Test(103); // A compile-time error
    }
    Test(int x) {
    }
}

前面的代码无法编译。Java 有一种特殊的方式从一个构造器调用另一个构造器。你必须使用关键字this,就像它是构造器的名字一样,从另一个构造器调用一个构造器。下面的代码使用语句"this(103);"从不带参数的构造器调用带int参数的构造器。这是关键字this的另一种用法:

public class Test {
    Test() {
        // Call another constructor
        this(103); // OK. Note the use of the keyword this.
    }
    Test(int x) {
    }
}

从一个构造器调用另一个构造器有两条规则。这些规则确保一个构造器在一个类的对象创建过程中只执行一次。这些规则如下:

  • 对另一个构造器的调用必须是该构造器中的第一条语句。

  • 构造器不能调用自身。

如果一个构造器调用另一个构造器,它必须是构造器体中的第一个可执行语句。这使得编译器很容易检查一个构造器是否被调用过,并且只被调用过一次。例如,下面的代码将生成一个编译时错误,因为用int参数this(k)调用构造器是构造器体内的第二条语句,而不是第一条语句:

public class Test {
    Test() {
        int k = 10; // First statement
        this(k);    // Second statement. A compile-time error
    }
    Test(int x) {
    }
}

尝试编译此Test类的代码将生成以下错误消息:

Error(4):  call to this must be first statement in constructor

构造器不能调用自身,因为这会导致递归调用。在下面的Test类代码中,两个构造器都试图调用自己:

public class Test {
    Test() {
        this();
    }
    Test(int x ) {
        this(10);
    }
}

尝试编译此代码将导致以下错误。每次尝试调用构造器本身都会生成一条错误信息:

Error(2):  recursive constructor invocation
Error(6):  recursive constructor invocation

通常,当有多种方法来初始化类的对象时,可以为该类创建重载构造器。让我们考虑清单 9-5 中显示的SmartDog类。两个构造器给了你两种方法来初始化一个新的SmartDog对象。第一个用默认值初始化nameprice。第二个构造器让您用调用者提供的值初始化nameprice。有时,您可能会执行一些逻辑来初始化构造器中的对象。让您从一个构造器调用另一个构造器只允许您编写一次这样的逻辑。您可以为您的SmartDog类使用这个特性,如下所示:

// SmartDog.java
package com.jdojo.cls;
public class SmartDog {
    private String name;
    private double price;
    public SmartDog() {
        // Call another constructor with "Unknown" and 0.0 as parameters
        this("Unknown", 0.0);
        System.out.println("Using SmartDog() constructor");
    }
    public SmartDog(String name, double price) {
        // Initialize name and price to specified name and price
        this.name = name;
        this.price = price;
        System.out.println("Using SmartDog(String, double) constructor");
    }
    /* Rest of code remains the same */
}

请注意,您只在不接受任何参数的构造器中更改了代码。您没有在第一个构造器中为nameprice设置默认值,而是调用了第二个构造器,并将默认值作为第一个构造器的参数。

在构造器中使用 return 语句

构造器的声明中不能有返回类型。这意味着构造器不能返回任何值。回想一下,return语句有两种类型:一种有返回表达式,另一种没有返回表达式。没有返回表达式的return语句只是将控制权返回给调用者,而不返回任何值。您可以在构造器体中使用没有返回表达式的return语句,尽管这被认为是一个应该避免的坏习惯。当执行构造器中的return语句时,控制返回到调用方,忽略构造器的其余代码。

下面的代码展示了一个在构造器中使用return语句的例子。如果参数x是负数,构造器只需执行一个return语句来结束对构造器的调用。否则,它会执行一些逻辑:

public class Test {
    public Test(int x) {
        if (x < 0) {
            return;
        }
        /* Perform some logic here */
    }
}

构造器的访问级别修饰符

构造器的访问级别决定了可以在对象创建表达式中使用该构造器来创建该类对象的程序部分。与字段和方法类似,您可以为构造器指定四个访问级别之一:

  • public

  • private

  • protected

下面的代码为Test类声明了四个构造器。每个构造器的注释解释了它的访问级别:

// Class Test has public access level
public class Test {
    // Constructor #1 - package-level access
    Test() {
    }
    // Constructor #2 - public access level
    public Test(int x) {
    }
    // Constructor #3 - private access level
    private Test(int x, int y) {
    }
    // Constructor #4 - protected access level
    protected Test(int x, int y, int z){
    }
}

这些访问级别的效果与它们对方法的效果相同。如果类本身是可访问的,具有public访问级别的构造器可以在应用程序的任何部分使用。具有private访问级别的构造器只能在声明它的同一个类中使用。具有protected访问级别的构造器可以在声明其类的同一个包中的程序的任何部分使用,也可以在任何包的任何子类中使用。具有包级访问权限的构造器可以在声明其类的同一个包中使用。

您可以为一个类指定一个public访问级别或包级别的访问。一个类定义了一个新的引用类型,您可以用它来声明一个引用变量。类的访问级别决定了类名可以在程序的哪个部分使用。通常,在强制转换或引用变量声明中使用类名,如下所示:

// Test class name is used to declare the reference variable t
Test t;
// Test class name is used to cast the reference variable xyz
Test t2 = (Test)xyz;

让我们讨论一个类及其构造器的不同访问级别组合,以及它们在程序中的作用。考虑下面的代码,它声明了一个访问级别为public的类T1。它还有一个构造器,也有一个public访问级别:

// T1.java
package com.jdojo.cls.p1;
public class T1 {
    public T1() {
    }
}

因为类T1有一个public访问级别,所以你可以在同一个模块的任何地方声明一个T1类型的引用变量。如果此代码在不同的模块中,则假设包含该类的模块导出该类的包,并且具有此代码的模块读取第一个模块:

// Code inside any package
T1 t;

因为类T1的构造器有一个public访问级别,所以您可以在任何包的对象创建表达式中使用它:

// Code inside any package
new T1();

您可以在任何包的代码中将前面两个语句合并为一个:

// Code inside any package
T1 t = new T1();

让我们考虑下面这个类T2的代码,它有一个public访问级别和一个带有private访问级别的构造器:

// T2.java
package com.jdojo.cls.p1;
public class T2 {
    private T2() {
    }
}

因为类T2有一个public访问级别,所以可以用它的名字在同一个模块的任何包中声明一个引用变量。如果这个包在不同的模块中,假设这个模块可以读取包含T2类的包。类T2的构造器有一个private访问级别。拥有一个private构造器意味着你不能在T2类之外创建一个T2类的对象。回想一下,private方法、字段或构造器不能在声明它的类之外使用。因此,除非出现在T2类中,否则下面的代码不会被编译:

// Code outside the T2 class
new T2(); // A compile-time error

如果不能在T2类之外创建它的对象,那么T2类有什么用?让我们考虑一些可能的情况,你可以声明一个构造器private,并且仍然创建和使用这个类的对象。

构造器用于创建一个类的对象。您可能想要限制一个类的对象数量。限制一个类的对象数量的唯一方法是完全控制它的构造器。如果您声明一个类的所有构造器都具有private访问级别,那么您可以完全控制该类的对象将如何被创建。通常,您在该类中包含一个或多个公共静态方法,这些方法创建和/或返回该类的对象。如果你设计一个类,使得该类只有一个对象存在,这被称为单例模式。下面的代码是基于单例模式的T2类的一个版本:

// T2.java
package com.jdojo.cls.p1;
public class T2 {
    private static T2 instance = new T2();
    private T2() {
    }
    public static T2 getInstance() {
        return T2.instance;
    }
    /* Other code goes here */
}

T2类声明了一个名为instance的私有静态引用变量,它保存了对T2类的对象的引用。注意,T2类使用它自己的private构造器来创建一个对象。它的公共静态getInstance()方法返回该类的唯一对象。不能存在多个T2类的对象。

您可以使用T2.getInstance()方法来获取对T2类的对象的引用。在内部,T2类不会在每次调用T2.getInstance()方法时创建一个新对象。相反,它为对此方法的所有调用返回相同的对象引用:

T2 t1 = T2.getInstance();
T2 t2 = T2.getInstance();

有时你希望一个类只有静态成员。创建这样一个类的对象可能没有意义。例如,java.lang.Math类声明其构造器是私有的。Math类包含静态变量和静态方法来执行数字运算。创建Math类的对象是没有意义的。

也可以将类的所有构造器声明为私有,以防止继承。继承允许您通过扩展另一个类的定义来定义一个类。如果您不希望任何其他人扩展您的类,实现这一点的一种方法是将您的类的所有构造器声明为私有。另一种防止类被扩展的方法是将其声明为 final。我们将在第二十章详细讨论继承。

让我们考虑一下类T3,它的构造器有一个受保护的访问级别,如下所示:

// T3.java
package com.jdojo.cls.p1;
public class T3 {
    protected T3() {
    }
}

具有受保护访问级别的构造器可以在同一个包中的任何地方使用,也可以在任何包的子类中使用。类T3com.jdojo.cls.p1包中。您可以在com.jdojo.cls.p1包中的任何地方编写下面的语句,这将创建一个T3类的对象:

// Valid anywhere in the com.jdojo.cls.p1 package
new T3();

稍后您将详细了解更多关于继承的内容。但是,为了完成对受保护构造器的讨论,您将在下面的示例中使用继承。当我们在第二十章讨论时,关于继承的事情会更清楚。使用关键字extends继承(或扩展)一个类。下面的代码通过从T3类继承来创建一个T3Child类:

// T3Child.java
package com.jdojo.cls.p2;
import com.jdojo.cls.p1.T3;
public class T3Child extends T3 {
    public T3Child() {
        super(); // Ok. Calls T3() constructor, which is declared protected.
    }
}

T3类被称为T3Child类的父类。在创建父类的对象之前,不能创建子类的对象。注意在T3Child()构造器体内super()语句的使用。语句super()调用了T3类的受保护构造器。super关键字用于调用父类的构造器,就像您使用关键字this调用同一类的另一个构造器一样。您不能直接调用T3的受保护构造器,因为它在com.jdojo.cls.p1包之外:

new T3();

考虑一个T4类,它的构造器具有包级访问权限。回想一下,不使用访问级别修饰符会给出包级别的访问:

// T4.java
package com.jdojo.cls.p1;
public class T4 {
    // T4() has package-level access
    T4() {
    }
}

您可以使用T4的构造器在com.jdojo.cls.p1包中的任何地方创建它的对象。有时你需要一个类作为包中其他类的助手类。这些类的对象只需要在包中创建。您可以为此类帮助器类的构造器指定包级访问。

默认构造器

声明类的主要目的是创建其类型的对象。你需要一个构造器来创建一个类的对象。一个类有一个构造器的必要性是显而易见的,如果你没有声明一个构造器,Java 编译器会给你的类添加一个构造器。编译器添加的构造器称为默认构造器。默认构造器没有任何参数。有时默认构造器也被称为无参数构造器。默认构造器的访问级别与类的访问级别相同。

您一直在使用的类称为顶级类。你也可以在另一个类中声明一个类,这叫做内部类(或者嵌套类)。顶级类可以拥有公共或包级别的访问权限。但是,内部类可以具有公共、私有、受保护或包级别的访问权限。Java 编译器为一个顶级类以及一个嵌套类添加了一个默认构造器。根据类的访问级别,顶级类的默认构造器可以具有公共或包级别的访问权限。但是,内部类的默认构造器可以具有 public、private、protected 或 package 级别的访问级别,这取决于它的类访问级别。

表 9-1 展示了几个类的例子,以及编译器给它们添加了一个默认的构造器。当编译器添加默认构造器时,它还会添加一个名为super()的语句来调用父类的无参数构造器。有时,在默认构造器中调用父类的无参数构造器可能会导致类无法编译。参见第二十章对这个话题的完整讨论。

表 9-1

Java 编译器为其添加默认构造器的类的示例

|

您的类的源代码

|

您的类的编译版本

|

评论

| | --- | --- | --- | | public class Test {``} | public class Test {``public Test() {``}``} | 编译器添加了一个默认的具有public级访问权限的构造器。 | | class Test {``} | class Test {``Test() {``}``} | 编译器添加一个具有包级访问权限的默认构造器。 | | public class Test {``Test() {``}``} | public class Test {``Test() {``}``} | Test类已经有一个构造器。编译器不添加任何构造器。 | | public class Test {``public Test(int x) {``}``} | public class Test {``public Test(int x) {``}``} | Test类已经有一个构造器。编译器不添加任何构造器。 | | public class Test {``private class Inner {``}``} | public class Test {``public Test() {``}``private class Inner {``private Inner(){``}``}``} | Test是公共顶级类,Inner是私有内部类。编译器为Test类添加了一个public默认构造器,为Inner类添加了一个private默认构造器。 |

Tip

将构造器显式添加到所有的类中,而不是让编译器为您的类添加默认的构造器,这是一个很好的编程实践。构造器的故事还没有结束。你将在第二十章中重温构造器。

静态构造器

构造器用于创建新对象的上下文中;因此,它们被认为是对象上下文的一部分,而不是类上下文。不能声明构造器static。关键字this是对当前对象的引用,它在构造器体中可用,因为它在所有实例方法体中都可用。

实例初始化块

您已经看到了构造器用于初始化一个类的实例。实例初始化块,也称为实例初始化器,也用于初始化类的对象。为什么 Java 提供两个构造来执行同一件事?

不是所有的 Java 类都有构造器。得知不是所有的类都可以有构造器,你感到惊讶吗?简单来说,这本书提到了内部类,它不同于顶级类。还有另一种类型的类叫做匿名类。顾名思义,匿名类没有名字。回想一下,构造器是一个命名的代码块,其名称与类的简单名称相同。因为匿名类不能有名字,所以它也不能有构造器。你将如何初始化一个匿名类的对象?您可以使用实例初始化器来初始化匿名类的对象。使用实例初始化器初始化对象不仅限于匿名类;任何类型的类都可以用它来初始化它的对象。

实例初始化器只是一个类体内的代码块,但是在任何方法或构造器之外。回想一下,代码块是用大括号括起来的合法 Java 语句序列。实例初始值设定项没有名称。它的代码简单地放在左大括号和右大括号中。下面的代码片段展示了如何为一个Test类声明一个实例初始化器。注意,实例初始化器是在实例上下文中执行的,关键字this在实例初始化器中是可用的:

public class Test {
    private int num;
    // An instance initializer
    {
        this.num = 101;
        /* Other code for the instance initializer goes here */
    }
    /* Other code for Test class goes here */
}

一个类可以有多个实例初始化器。对于您创建的每个对象,它们都是按照文本顺序自动执行的。所有实例初始化器的代码都在任何构造器之前执行。清单 9-7 展示了构造器和实例初始化器的执行顺序。

// InstanceInitializer.java
package com.jdojo.cls;
public class InstanceInitializer {
    {
        System.out.println("Inside instance initializer 1.");
    }
    {
        System.out.println("Inside instance initializer 2.");
    }
    public InstanceInitializer() {
        System.out.println("Inside no-args constructor.");
    }
    public static void main(String[] args) {
        InstanceInitializer ii = new InstanceInitializer();
    }
}
Inside instance initializer 1.
Inside instance initializer 2.
Inside no-args constructor.

Listing 9-7Example of Using an Instance Initializer

Tip

实例初始值设定项不能有return语句。除非所有声明的构造器在它们的throws子句中列出这些检查的异常,否则它不能抛出检查的异常;这个规则的一个例外是匿名类,因为它没有构造器;匿名类的实例初始化器可能抛出检查异常。

静态初始化块

静态初始化块也称为静态初始化器。它类似于实例初始化块。它用于初始化一个类。换句话说,您可以在静态初始化器块中初始化类变量。实例初始化器对每个对象执行一次,而静态初始化器对一个类只执行一次,当类定义加载到 JVM 中时。为了将其与实例初始化器区分开来,需要在声明的开头使用static关键字。一个类中可以有多个静态初始化器。所有静态初始值设定项都是按照它们出现的文本顺序执行的,并且在任何实例初始值设定项之前执行。清单 9-8 展示了静态初始化器何时被执行。

// StaticInitializer.java
package com.jdojo.cls;
public class StaticInitializer {
    private static int num;
    // An instance initializer
    {
        System.out.println("Inside instance initializer.");
    }
    // A static initializer. Note the use of the keyword static below.
    static {
        num = 1245;
        System.out.println("Inside static initializer.");
    }
    // Constructor
    public StaticInitializer() {
        System.out.println("Inside constructor.");
    }
    public static void main(String[] args) {
        System.out.println("Inside main() #1\. num: " + num);
        // Declare a reference variable of the class
        StaticInitializer si;
        System.out.println("Inside main() #2\. num: " + num);
        // Create an object
        new StaticInitializer();
        System.out.println("Inside main() #3\. num: " + num);
        // Create another object
        new StaticInitializer();
    }
}
Inside static initializer.
Inside main() #1\. num: 1245
Inside main() #2\. num: 1245
Inside instance initializer.
Inside constructor.
Inside main() #3\. num: 1245
Inside instance initializer.
Inside constructor.

Listing 9-8An Example of Using a static Initializer in a Class

最初,输出可能会令人困惑。它表明在第一条消息显示在main()方法中之前,static初始化器已经执行。当您使用下面的命令运行StaticInitializer类时,您会得到输出:

C:\Java17Fundamentals>java --module-path dist --module jdojo.cls/com.jdojo.cls.StaticInitializer

在执行其main()方法之前,java命令必须加载StaticInitializer类的定义。当StaticInitializer类的定义被加载到内存中时,该类被初始化,并且它的静态初始化器被执行。这就是你在看到来自main()方法的消息之前看到来自静态初始化器的消息的原因。注意,实例初始化器被调用了两次,因为您创建了两个StaticInitializer类的对象。

Tip

一个static初始化器不能抛出被检查的异常,也不能有一个return语句。

最后一个关键字

在 Java 中,final关键字被用在许多上下文中。它在不同的上下文中有不同的含义。然而,顾名思义,它的主要含义在所有上下文中都是相同的。其主要含义如下:

final 关键字关联的构造不允许修改或替换构造的原始值或定义。

如果你记住了final关键字的主要含义,它将帮助你理解它在特定上下文中的专门含义。final关键字可用于以下三种情况:

  • 变量声明

  • 类别声明

  • 方法声明

在本节中,我们只讨论在变量声明的上下文中使用final关键字。第二十章详细讨论了它在类和方法声明中的使用。本节将简要描述它在所有三个上下文中的含义。

如果一个变量被声明为final,它只能被赋值一次。也就是说,final变量的值一旦被设置就不能修改。如果一个类被声明为 final,它就不能被扩展(或子类化)。如果一个方法被声明为final,它不能在包含该方法的类的子类中被重新定义(覆盖或隐藏)。

让我们讨论一下final关键字在变量声明中的用法。在这个讨论中,变量声明意味着局部变量、方法/构造器的形参、实例变量和类变量的声明。要将一个变量声明为final,需要在变量声明中使用final关键字。下面的代码片段声明了四个final变量— YESNOMSGact:

final int YES = 1;
final int NO = 2;
final String MSG = "Good-bye";
final Account act = new Account();

您只能设置一次final变量的值。第二次尝试设置final变量的值将会产生编译时错误:

final int x = 10;
int y = 101 + x; // Reading x is ok
// A compile-time error. Cannot change value of the final variable x once it is set
x = 17;

有两种方法可以初始化final变量:

  • 您可以在声明时初始化它。

  • 您可以将其初始化推迟到以后。

一个final变量的初始化可以推迟多长时间取决于变量类型。然而,您必须在第一次读取变量之前初始化该变量。

如果你没有在声明的时候初始化一个final变量,这样的变量被称为空白最终变量。以下是声明空白最终变量的示例:

// A blank final variable
final int multiplier;
/* Do something here... */
// Set the value of multiplier first time
multiplier = 3;
// Ok to read the multiplier variable
int value = 100 * multiplier;

让我们看一下每种类型变量的例子,看看如何声明它们final

最终局部变量

你可以声明一个局部变量final。如果将局部变量声明为空的最终变量,则必须在使用。如果您第二次尝试更改最终局部变量的值,将会收到一个编译时错误。下面的代码片段在一个test()方法中使用了 final 和空白 final 局部变量。代码中的注释解释了如何使用代码中的final变量:

public static void test() {
    int x = 4;        // A variable
    final int y = 10; // A final variable. Cannot change y here onward
    final int z;      // A blank final variable
    // We can read x and y, and modify x
    x = x + y;
    /* We cannot read z here because it is not initialized yet */
    /* Initialize the blank final variable z */
    z = 87;
    /* Can read z now. Cannot change z here onwards */
    x = x + y + z;
    /* Perform other logic here... */
}

最终参数

也可以声明一个形参final。当调用方法或构造器时,形参会自动用实参的值进行初始化。因此,您不能在方法或构造器体中更改最终形参的值。下面的代码片段显示了一个test2()方法的最终形参x:

public void test2(final int x) {
    // Can read x, but cannot change it
    int y = x + 11;
    /* Perform other logic here... */
}

最终实例变量

可以将实例变量声明为 final 和 blank final。实例变量(也称为字段)是对象状态的一部分。最终实例变量指定对象状态的一部分,该部分在对象创建后不会改变。创建对象时,必须初始化一个空的 final 实例变量。以下规则适用于初始化空白最终实例变量:

  • 它必须在一个实例初始化器或所有构造器中初始化。以下规则是对该规则的扩展。

  • 如果它是在实例初始化器中初始化的,就不应该在任何其他实例初始化器或构造器中再次初始化。

  • 如果它没有在任何实例初始化器中初始化,编译器会确保它只在调用任何构造器时初始化一次。这条规则可以分为两个子规则。根据经验,空白的 final 实例变量必须在所有构造器中初始化。如果遵循这条规则,当一个构造器调用另一个构造器时,一个空的 final 实例变量将被初始化多次。为了避免多次初始化空的最终实例变量,如果构造器中的第一个调用是对另一个构造器的调用,该构造器初始化空的最终实例变量,则不应在构造器中初始化该变量。

这些初始化空白 final 实例变量的规则可能看起来很复杂。但是,如果您只记住一条规则,就很容易理解了,即当调用该类的任何构造器时,空白的 final 实例变量必须初始化一次,且只能初始化一次。前面描述的所有规则都是为了确保遵守该规则。

让我们考虑初始化 final 和空白 final 实例变量的不同场景。对于最终实例变量,我们没有什么可讨论的,其中xTest类的最终实例变量:

public class Test {
    private final int x = 10;
}

final实例变量x在声明时已经被初始化,并且它的值以后不能被改变。下面的代码显示了一个带有名为y的空白最终实例变量的Test2类:

public class Test2 {
    private final int y; // A blank final instance variable
}

试图编译Test2类会产生一个错误,因为空白的最终实例变量y从未初始化。注意,编译器将为Test2类添加一个默认的构造器,但是它不会在构造器内部初始化y。下面的Test2类代码将会编译,因为它在实例初始化器中初始化了y:

public class Test2 {
    private final int y;
    {
        y = 10; // Initialized in an instance initializer
    }
}

以下代码将无法编译,因为它在两个实例初始化器中多次初始化y:

public class Test2 {
    private final int y;
    {
        y = 10; // Initialized y for the first time
    }
    {
        y = 10; // An error. Initializing y again
    }
}

这个代码对你来说可能是合法的。然而,这是不合法的,因为两个实例初始化器正在初始化y,即使它们都将y设置为相同的值,10。该规则是关于一个空的 final 实例变量应该被初始化的次数,而不考虑用于其初始化的值。由于所有的实例初始化器都是在创建Test2类的对象时执行的,y将被初始化两次,这是不合法的。

具有两个构造器的类Test2的以下代码将被编译:

public class Test2 {
    private final int y;
    public Test() {
        y = 10; // Initialize y
    }
    public Test(int z) {
        y = z; // Initialize y
    }
}

这段代码在两个构造器中初始化空白的最终实例变量y。看起来似乎y被初始化了两次——在每个构造器中一次。注意,y是一个实例变量,对于Test2类的每个对象都有一个y的副本。当一个Test2类的对象被创建时,它将使用两个构造器中的一个,而不是两个。因此,对于Test2类的每个对象,y只初始化一次。

下面是修改后的Test2类的代码,它呈现了一个棘手的情况。两个构造器都初始化空白的最终实例变量y。棘手的部分是无参数构造器调用另一个构造器:

public class Test2 {
    private final int y;
    public Test() {
        this(20); // Call another constructor
        y = 10;   // Initialize y
    }
    public Test(int z) {
        y = z;   // Initialize y
    }
}

Test2类的这段代码无法编译。编译器生成一条错误消息,内容为“变量 y 可能已经被赋值" 让我们考虑创建一个Test2类的对象,如下所示:

Test2 t = new Test2(30);

通过调用单参数构造器创建一个Test2类的对象没有问题。空白的最终实例变量y只初始化一次。让我们创建一个Test2类的对象:

Test2 t2 = new Test2();

当使用无参数构造器时,它调用单参数构造器,该构造器将y初始化为 20。无参数构造器再次将y初始化为 10,这是对y的第二次初始化。由于这个原因,前面的Test2类的代码无法编译。您需要从无参数构造器中移除y的初始化,然后代码就可以编译了。下面是将编译的Test2类的修改代码:

public class Test2 {
    private final int y;
    public Test() {
        this(20); // Another constructor will initialize y
    }
    public Test(int z) {
        y = z;    // Initialize y
    }
}

最终类别变量

您可以将类变量声明为 final 和 blank final。您必须在一个静态初始化器中初始化一个空的 final 类变量。如果一个类有多个静态初始化器,那么必须在其中一个静态初始化器中只初始化一次所有的空 final 类变量。

下面的Test3类代码展示了如何处理一个 final 类变量。习惯上使用全部大写字母来命名最终类变量。这也是在 Java 程序中定义常量的一种方式。Java 类库有许多定义public static final变量作为常量的例子:

public class Test3 {
    public static final int YES = 1;
    public static final int NO = 2;
    public static final String MSG;
    static {
        MSG = "I am a blank final static variable";
    }
}

最终参考变量

任何类型的变量(原语和引用)都可以声明为 final。在这两种情况下,final关键字的主要含义是相同的。也就是说,存储在final变量中的值一旦被设置就不能更改。我们将在本节中更详细地介绍最后一个参考变量。引用变量存储对象的引用。最终引用变量意味着一旦它引用了一个对象(或null,它就不能被修改来引用另一个对象。考虑以下语句:

final Account act = new Account();

这里,act是一个Account类型的final参考变量。它在声明时被初始化。此时,act正在引用内存中的一个对象。

现在,您不能让act变量引用内存中的另一个对象。以下语句会生成编译时错误:

act = new Account(); // A compile-time error. Cannot change act

在这种情况下,会产生一种常见的误解。程序员错误地认为由act引用变量引用的Account对象不能被改变。将act引用变量作为 final 的声明语句有两点:

  • 一个act作为参考变量,即final

  • 内存中的一个Account对象,其引用存储在act变量中

不能改变的是act引用变量,而不是它正在引用的Account对象。如果Account类允许您改变其对象的状态,您可以使用act变量来改变状态。以下是修改Account对象的balance实例变量的有效语句:

act.deposit(2001.00); // Modifies state of the Account object
act.debit(2.00);      // Modifies state of the Account object

如果您不希望类的对象在创建后被修改,您需要在类设计中包含该逻辑。在创建对象后,该类不应该让它的任何实例变量被修改。这样的对象被称为不可变对象

编译时与运行时最终变量

您使用final变量来定义常量。这就是final变量也被称为常量的原因。如果一个final变量的值可以由编译器在编译时计算出来,那么这个变量就是一个编译时常量。如果一个final变量的值不能被编译器计算出来,它就是一个运行时最终变量。所有空白最终变量的值直到运行时才知道。直到运行时才计算引用。因此,所有空白的最终变量和最终引用变量都是运行时常量

当您在表达式中使用编译时常量时,Java 会执行优化。它用常量的实际值代替了编译时常量的使用。假设您有一个如下所示的Constants类,它声明了一个名为MULTIPLIER的静态最终变量:

public class Constants {
    public static final int MULTIPLIER = 12;
}

考虑以下语句:

int x = 100 * Constants.MULTIPLIER;

当您编译这条语句时,编译器会用值 12 替换Constants.MULTIPLIER,您的语句编译如下:

int x = 100 * 12;

现在,100 * 12 也是一个编译时常量表达式。编译器会用它的值 1200 来替换它,您的原始语句将被编译如下:

int x = 1200;

这种编译器优化有一个缺点。如果你改变了Constants类中MULTIPLIER final变量的值,你必须重新编译所有引用Constants.MULTIPLIER变量的类。否则,它们将继续使用上次编译时存在的MULTIPLIER常量的旧值。

通用类

抽象和多态是面向对象编程的核心。定义变量是一个抽象的例子,变量隐藏了实际的值和存储值的位置。定义一个方法隐藏了其实现逻辑的细节,这是另一种形式的抽象。为方法定义参数是多态的一部分,多态允许方法处理不同类型的值或对象。

Java 有一个特性叫做 generics ,它允许用 Java 编写真正的多态代码。使用泛型,您可以在不知道代码所操作的Object类型的情况下编写代码。它允许您创建泛型类、构造器和方法。

泛型类是使用形式类型参数定义的。形式类型参数是一列逗号分隔的变量名,放在类声明中类名后面的尖括号(<>)中。下面的代码片段声明了一个接受一个形式类型参数的泛型类Wrapper:

public class Wrapper<T>  {
    // Code for the Wrapper class goes here
}

该参数被命名为T。此时的T是什么?答案是你不知道。此时你只知道T是一个类型变量,它可以是 Java 中的任何引用类型,比如StringIntegerDoubleHumanAccount等。当使用Wrapper类时,指定正式类型参数值。采用形式类型参数的类也被称为参数化类

您可以通过将String类型指定为其形式类型参数的值来声明Wrapper<T>类的变量,如下所示。这里,String是实际的类型参数:

Wrapper<String> stringWrapper;

Java 允许您使用泛型类,而无需指定正式的类型参数。这是为了向后兼容。您也可以声明一个Wrapper<T>类的变量,如下所示:

Wrapper aRawWrapper;

当一个泛型类在没有指定实际类型参数的情况下被使用时,它被称为原始类型。前面的声明使用了Wrapper<T>类作为原始类型,因为它没有指定T的值。

Tip

泛型类的实际类型参数(如果指定)必须是引用类型,例如,StringHuman等。不允许将基元类型作为泛型类的实际类型参数。

一个类可以接受多个形参。下面的代码片段声明了一个Mapper类,它接受两个名为TR的形参:

public class Mapper<T,R>  {
    // Code for the Mapper class goes here
}

您可以声明一个Mapper<T, R>类的变量,如下所示:

Mapper<String,Integer> mapper;

这里,实际的类型参数是StringInteger

习惯上,而不是要求,给形式类型参数取一个字符的名字,例如,TRUV等。通常,T代表“类型”,R 代表“返回”,等等。单字符名称使代码更具可读性。但是,没有什么可以阻止您声明一个泛型类,如下所示,它有四个形式类型参数,分别名为MyTypeYourTypeHelloWhoCares:

public class Fun<MyType, YourType, Hello, WhoCares> {
    // Code for the Fun class goes here
}

Java 将编译Fun类,但是你代码的读者肯定会抱怨!正式的类型参数可以在类体内作为类型使用。还有另一个选项(更清楚),使用全部大写字母,例如

public class Fun2<TYPE1, TYPE2, RETURN_TYPE> {
    // Code for the Fun2 class goes here
}

清单 9-9 声明了一个泛型类Wrapper<T>,这是在 Java 中使用泛型的一个例子。

// Wrapper.java
package com.jdojo.cls;
public class Wrapper<T> {
    private T obj;
    public Wrapper(T obj) {
        this.obj = obj;
    }
    public T get() {
        return obj;
    }
    public void set(T obj) {
        this.obj = obj;
    }
}

Listing 9-9Declaring a Generic Class Wrapper<T>

Wrapper<T>类使用形式类型参数声明实例变量obj,为其构造器和set()方法声明一个形式参数,并作为get()方法的返回类型。

您可以通过为构造器指定实际类型参数来创建泛型类型的对象,如下所示:

Wrapper<String> w1 = new Wrapper<String>("Hello");

大多数情况下,编译器可以推断出构造器的实际类型参数。在这些情况下,可以省略实际的类型参数。在下面的赋值语句中,编译器会将构造器的实际类型参数推断为String (this <> is called the diamond operator ):

Wrapper<String> w1 = new Wrapper<>("Hello");

一旦你声明了一个泛型类的变量,你就可以把形式类型参数看作是为所有实际目的指定的实际类型参数。现在,你可以认为,对于w1,Wrapper<T>类的get()方法返回一个String:

String s1 = w1.get();

清单 9-10 中的程序展示了如何使用泛型Wrapper<T>类。

// WrapperTest.java
package com.jdojo.cls;
public class WrapperTest {
    public static void main(String[] args) {
        Wrapper<String> w1 = new Wrapper<>("Hello");
        String s1 = w1.get();
        System.out.println("s1=" + s1);
        w1.set("Testing generics");
        String s2 = w1.get();
        System.out.println("s2=" + s2);
        w1.set(null);
        String s3 = w1.get();
        System.out.println("s3=" + s3);
    }
}
s1=Hello
s2=Testing generics
s3=null

Listing 9-10Using a Generic Class in Your Code

谈到泛型在 Java 中提供了什么,这只是冰山一角。要完全理解泛型,您必须先了解其他主题,比如继承。

摘要

构造器是一个命名的代码块,用于在对象创建后立即初始化类的对象。构造器的结构看起来类似于方法。然而,它们是两种不同的构造,并且用于不同的目的。构造器的名称与类的简单名称相同。像方法一样,构造器可以接受参数。与方法不同,构造器不能指定返回类型。构造器与new操作符一起使用,为新对象分配内存,构造器初始化新对象。构造器不向其调用方返回值。您可以在构造器中使用不带表达式的return语句。return语句结束构造器调用,并将控制权返回给调用者。

构造器不被视为类的成员。像字段和方法一样,构造器也有访问级别:公共、私有、受保护或包级别。在定义它们时,关键字publicprivateprotected的存在分别赋予它们公共、私有或受保护的访问级别。缺少这些关键字中的任何一个都会指定包级访问。

一个类可以有多个构造器。如果一个类有多个构造器,它们被称为重载构造器。由于构造器的名称必须与类的简单名称相同,因此有必要区分不同的构造器。一个类中的所有收缩函数在数量、顺序或参数类型上都必须不同于其他收缩函数。

一个构造器可以使用关键字this调用同一个类的另一个构造器,就好像它是一个方法名一样。如果一个类的构造器调用同一类的另一个构造器,则必须满足以下规则:

  • 对另一个构造器的调用必须是该构造器中的第一条语句。

  • 构造器不能调用自身。

如果没有在类中添加构造器,Java 编译器会添加一个。这样的构造器被称为默认构造器。默认的构造器和它的类具有相同的访问级别,并且没有参数。

一个类也可以有一个或多个实例初始化器来初始化该类的对象。实例初始化器只是一个类体内的代码块,但是在任何方法或构造器之外。回想一下,代码块是用大括号括起来的合法 Java 语句序列。实例初始值设定项没有名称。它的代码简单地放在左大括号和右大括号中。当一个类的对象被创建时,该类的所有实例初始化器都是按文本顺序执行的。通常,实例初始化器用于初始化匿名类的对象。

一个类可以有一个或多个静态初始化器,用来初始化一个类,通常是类变量。实例初始化器对每个对象执行一次,而静态初始化器对一个类只执行一次,当类定义加载到 JVM 中时。为了将其与实例初始化器区分开来,需要在声明的开头使用static关键字。一个类的所有静态初始化器都按照它们出现的文本顺序执行,并且在任何实例初始化器之前执行。

您可以最终定义一个类及其成员。如果某件事是最终的,那就意味着它的定义或价值,无论它代表什么,都不能被修改。在 Java 中,Final 变量用于定义常量。编译时常量是在程序编译时已知其值的常量。运行时常量是在程序运行之前不知道其值的常量。

变量可以声明为 blank final,在这种情况下,变量被声明为 final,但在声明时不赋值。在读取空的 final 变量的值之前,必须为其赋值。空白的 final 实例变量必须在其实例初始值设定项或构造器中初始化一次。您可以将类变量声明为空的 final。您必须在一个静态初始化器中初始化一个空的 final 类变量。如果一个类有多个静态初始化器,那么必须在其中一个静态初始化器中只初始化一次所有的空 final 类变量。

Java 允许您使用泛型编写真正的多态代码,在泛型中,代码是根据形式类型参数编写的。泛型类是使用形式类型参数定义的。形式类型参数是一列逗号分隔的变量名,放在类声明中类名后面的尖括号(<>)中。采用形式类型参数的类也被称为参数化类。实际的类型参数是在使用参数化类时指定的。

EXERCISES

  1. 什么是构造器?创建一个类的对象时,必须和构造器一起使用的操作符的名字是什么?

  2. 什么是默认构造器?默认构造器的访问级别是什么?

  3. 如何从同一个类的另一个构造器调用一个类的构造器?描述在代码中进行这种调用的任何限制。

  4. 什么是静态和实例初始化器?

  5. 什么是final变量和空白最终变量?

  6. 将方法的参数或构造器的参数声明为 final 有什么影响?

  7. Consider the following code for a Cat class:

    // Cat.java
    package com.jdojo.cls.excercise;
    public class Cat {
    }
    
    

    当编译Cat类时,编译器会给它添加一个默认的构造器。重写Cat类,就像添加默认构造器而不是编译器一样。

  8. Consider the following code for a Mouse class:

    // Mouse.java
    package com.jdojo.cls.excercise;
    class Mouse {
    }
    
    

    当编译Mouse类时,编译器会给它添加一个默认的构造器。重写Mouse类,就像添加默认构造器而不是编译器一样。

  9. 用两个名为xyint实例变量创建一个SmartPoint2D类。实例变量应该声明为 private 和 final。SmartPoint2D类的一个实例代表了 2D 平面中的一个不变点。也就是说,一旦SmartPoint2D类的对象被创建,该对象的 x 和 y 值就不能被改变。向该类添加一个公共构造器,它应该接受两个实例变量xy的值,并用传入的值初始化它们。

  10. 为您在前一个练习中创建的SmartPoint2D类中的xy实例变量添加 getters。

  11. SmartPoint2D类添加一个名为ORIGINpublic static final变量。ORIGIN变量属于SmartPoint2D类,是一个 x = 0,y = 0 的SmartPoint2D

  12. Implement a method named distance in the SmartPoint2D class that you created in the previous exercise. The method accepts an instance of the SmartPoint2D class and returns the distance between the current point and the point represented by the parameter. The method should be declared as follows:

```java
public class SmartPoint2D {
    /* Code from the previous exercise goes here. */
    public double distance(SmartPoint2D p) {
        /* Your code for this exercise goes here. */
    }
}

```

提示两点`(x1, y1)`和`(x2, y2)`之间的距离计算为`√((x2-x1)`<sup>`2`</sup>`+ (y2-y1)`<sup>`2`</sup>`)`。您可以使用`Math.sqrt(n)`方法来计算数字`n`的平方根。

13. 创建一个Circle类,它有三个名为xyradius的私有最终实例变量。xy实例变量代表圆心的xy坐标;它们属于int数据类型。radius实例变量代表圆的半径;它属于double数据类型。向Circle类添加一个构造器,该构造器接受其实例变量xyradius的值。为三个实例变量添加 getters。

  1. 通过添加四个名为centerDistancedistanceoverlapstouches的实例方法来增强Circle类。所有这些方法都接受一个Circle作为参数。centerDistance方法返回圆心和另一个作为参数传入的圆之间的距离(作为一个double))。distance方法返回两个圆之间的最小距离(作为double)。如果两个圆重叠,distance方法返回一个负数。如果两个圆重叠,overlaps方法返回true,否则返回false。如果两个圆相互接触,则touches方法返回true,否则返回falsedistance方法必须使用centerDistance方法。overlapstouches方法的主体必须只包含一个使用distance方法的语句。
提示:两个圆之间的距离是它们的圆心距离减去它们的半径。如果两个圆之间的距离为负,则这两个圆重叠。如果两个圆之间的距离为零,它们就相交。
  1. 通过添加两个名为perimeterarea的方法来增强Circle类,这两个方法分别计算并返回圆的周长和面积。

  2. Circle类添加第二个构造器,该构造器接受一个double参数,即圆的半径。这个构造器应该调用另一个现有的Circle类的构造器,用三个参数传递零作为xy的值。

  3. 双精度值可以是NaN,正无穷大,负无穷大。用三个参数xyradius增强Circle类的构造器,所以当radius参数的值不是有限数或负数时,它抛出一个RuntimeException

HintThe `java.lang.Double` class contains a static `isFinite(double n)` method, which returns `true` if the specified parameter `n` is a finite number and `false` otherwise. Use the following statement to throw a `RuntimeException`:
```java
throw new RuntimeException(
           "Radius must be a finite non-negative number.");

```

18. 考虑下面的InitializerTest类。这个类中有多少静态和实例初始化器?运行该类时将打印什么?

```java
// InitializerTest.java
package com.jdojo.cls.excercise;
public class InitializerTest {
    private static int count;
    {
        System.out.println(count++);
    }
    {
        System.out.println(count++);
    }
    static {
        System.out.println(count);
    }
    public static void main(String[] args) {
        new InitializerTest();
        new InitializerTest();
    }
}

```

19. 描述为什么下面的FinalTest类不能编译:

```java
// FinalTest.java
package com.jdojo.cls.excercise;
public class FinalTest {
    public static int square(final int x) {
        x = x * x;
        return x;
    }
}

```

20. 描述为什么下面的BlankFinalTest类不能编译:

```java
// BlankFinalTest.java
package com.jdojo.cls.excercise;
public class BlankFinalTest {
    private final int x;
    private final int y;
    {
        y = 100;
    }
    public BlankFinalTest() {
        y = 100;
    }
    /* More code goes here */
}

```

十、模块

在本章中,您将学习:

  • 什么是模块

  • 如何声明模块

  • 模块的隐式可读性意味着什么以及如何声明它

  • 不合格和合格出口的区别

  • 声明模块的运行时可选依赖项

  • 如何打开整个模块或其选定的包进行深层思考

  • 关于跨模块拆分包的规则

  • 模块声明的限制

  • 不同类型的模块:命名模块、未命名模块、显式模块、自动模块、普通模块和开放模块

  • 了解运行时的模块

  • 如何使用javap工具反汇编模块的定义

本章中一些例子的代码经历了几个步骤。本书的源代码包含了那些例子的最后一步中使用的代码。如果你想在阅读本章的每一步都看到这些例子,你需要稍微修改一下源代码,使其与你正在进行的步骤保持同步。

什么是模块?

简单来说,一个模块就是一组包。一个模块可以有选择地包含诸如图像、属性文件等资源。现在,让我们只关注作为一组包的模块。一个模块指定了它的包对其他模块的可访问性以及它对其他模块的依赖性。模块中的包的可访问性决定了其他模块是否可以访问该包。一个模块的依赖关系决定了这个模块读取的其他模块的列表。“依赖于”、“读取”和“需要”这三个术语可以互换使用,以表示一个模块对另一个模块的依赖性。如果模块M依赖于模块N,下面三个短语意思相同:“模块M依赖于模块N”;“模块M需要模块N”;或者“模块M读取模块N

默认情况下,模块中的包只能在同一个模块中访问。如果一个模块中的包需要在它的模块之外被访问,包含这个包的模块需要导出这个包。一个模块可以将其包导出到所有其他模块,或者只导出到其他模块的选定列表。

如果一个模块想要从另一个模块访问包,第一个模块必须声明对第二个模块的依赖,第二个模块必须导出包,以便第一个模块可以访问它们。

声明模块

模块是在编译单元中声明的。本书在第三章中介绍了编译单元的概念,其中编译单元包含类型声明(类和接口声明)。包含模块声明的编译单元不同于包含类型声明的编译单元。从 Java 9 开始,有两种类型的编译单元:

  • 普通编译单元

  • 模块化编译单元

一个普通的编译单元由三部分组成:包声明、导入声明和顶级类型声明。普通编译单元中的所有部分都是可选的。参见第三章了解更多关于普通编译单元的详细信息。

模块化编译单元包含一个模块声明。模块声明之前可以有可选的导入声明。模块化编译单元不能有包声明。模块化编译单元中的导入声明允许您在模块声明中使用简单的类型名称和静态类型成员。

Tip

模块化编译单元被命名为module-info,扩展名为.java.jav。本书示例中的所有模块化编译单元都被命名为module-info.java

使用模块化编译单元的语法如下:

[import-declarations]
<module-declaration>

导入声明中使用的类型可能来自同一模块或其他模块中的包。有关如何使用进口申报的更多详细信息,请参考第七章。模块声明的语法如下:

[open] module <module-name> {
    <module-statement-1>;
    <module-statement-2>;
    ...
}

module 关键字用于声明一个模块。模块声明可以选择以关键字open开始,以声明一个开放的模块(将在后面描述)。module关键字后面是一个模块名。一个模块名是一个合格的 Java 标识符,它是一个或多个 Java 标识符的序列,由一个点分隔,类似于包名。

模块声明的主体放在花括号内,花括号中可以有零个或多个模块语句。模块语句也被称为模块 指令。这本书使用了语句这个术语,而不是指令。模块语句有五种类型:

  • exports声明

  • opens声明

  • requires声明

  • uses声明

  • provides声明

对于一个模块访问另一个模块中的类型,第二个模块使包含这些类型的包可被访问,第一个模块读取第二个模块。所有五种类型的模块语句都用于这两个目的:

  • 使类型可访问

  • 访问这些类型

exportsopensprovides语句表达了一个模块中的类型对其他模块的可用性。模块中的requiresuses语句用于表达模块对其他模块使用exportsopensprovides语句读取可用类型的依赖性。这些类型的语句的区别在于模块提供的类型和其他模块使用的类型的上下文。以下是包含所有五种模块语句的模块声明示例:

module jdojo.policy {
    exports com.jdojo.policy;
    requires java.sql;
    opens com.jdojo.policy.model;
    uses com.jdojo.common.Job;
    provides com.jdojo.common.Job with com.jdojo.policy.JobImpl;
}

以下术语是 Java 中的受限关键字:openmodulerequirestransitiveexportsopenstousesprovideswith。只有当它们出现在模块化编译单元的特定位置时,才会被视为关键字。它们在其他任何地方都是正常的术语。例如,下面的模块声明是有效的,尽管模块名“module”不是很直观:

module module {
    exports com.jdojo.policy;
}

这里,第一个“模块”术语是一个受限制的关键字,第二个是一个用作模块名称的普通术语。

后续章节详细描述了exportsrequires语句。我们在本章中简要解释一下opens语句。

声明模块依赖

在 Java SE 8 之前,一个包中的公共类型可以不受任何限制地被其他包访问。换句话说,包并不控制它们所包含的类型的可访问性。Java SE 9 和更高版本中的模块系统提供了对模块包中包含的类型的可访问性的细粒度控制。

跨模块的可访问性是被使用模块和使用模块之间的双向协议。一个模块显式地将其公共类型提供给其他模块使用,使用这些公共类型的模块显式地声明对第一个模块的依赖。模块的所有非导出包都是模块私有的,不能从模块外部访问它们。

让一个包中的公共类型对其他模块可用被称为导出那个包,这是通过在模块声明中使用exports语句来完成的。模块可以将其包导出到所有其他模块或选定的模块列表中。当一个模块将其包导出到所有其他模块时,称为不合格导出。以下是将包导出到所有其他模块的语法:

exports <package>;

这里,<package>是当前模块中的包。读取当前模块的所有其他模块都可以使用这个包中的公共类型。考虑以下声明:

module jdojo.address {
    exports com.jdojo.address;
}

jdojo.address模块将名为com.jdojo.address的包导出到所有其他模块。只有在jdojo.address模块中才能访问jdojo.address模块中的所有其他包。

一个模块也可以有选择地只将包导出到一个或多个命名的模块。这种导出被称为合格导出模块友好导出。限定导出中的包中的公共类型只能由指定的命名模块访问。以下是使用限定导出的语法:

exports <package> to <friend-module> [, <friend-module>...] ;

这里,<package>是当前模块中的一个包,只导出到"to"子句中列出的友元模块。下面是一个使用限定导出的jdojo.policy模块的模块声明:

module jdojo.policy {
    exports com.jdojo.policy to jdojo.claim, jdojo.payment;
}

jdojo.policy模块包含一个名为com.jdojo.policy的包。该模块使用一个合格的导出将这个包只导出到两个模块,jdojo.claimjdojo.payment

Tip

合格输出的to条款中指定的模块不需要是可观察的。

不合格出口和合格出口哪个更好用?当您向公众共享包中的公共类型时,例如,当您开发供公众使用的模块时,应该使用非限定导出。一旦您发布了您的模块,您就不应该改变导出包中的公共 API。有时,坏的 API 会永远留在一个模块中,因为该模块是公共使用的,更改/删除 API 会影响很多用户。有时,您可能需要在模块之间共享公共类型,这些模块是库或框架的一部分;但是,这些模块中的公共类型不供公共使用。在这种情况下,您应该使用限定的导出,这将在您更改涉及那些共享公共类型的 API 时将影响降到最低。java.base模块使用几个合格的导出将它的包导出到其他 JDK 模块。您可以使用以下命令来描述java.base模块,以列出合格的导出:

C:\> java --describe-module java.base
java.base@17
exports java.io
exports java.lang
...
qualified exports jdk.internal.org.xml.sax to jdk.jfr
qualified exports sun.security.tools to jdk.jartool
...
contains sun.invoke
contains sun.invoke.util
contains sun.io
...

一个requires语句用于指定一个模块对另一个模块的依赖性。如果一个模块读取另一个模块,第一个模块需要在其声明中有一个requires语句。requires语句的一般语法如下:

requires [transitive] [static] <module>;

这里,<module>是当前模块读取的模块名称。transitivestatic修改器都是可选的。如果static修饰符存在,对<module>的依赖在编译时是强制的,但在运行时是可选的。如果没有static修饰符,read 模块在编译时和运行时都是必需的。transitive修饰符的存在意味着一个模块隐式地读取当前模块也读取<module>。我们将很快介绍一个在requires语句中使用transitive修饰符的例子。下面是一个使用requires语句的例子:

module jdojo.claim {
    requires jdojo.policy;
}

这里,jdojo.claim模块使用一个requires语句来表示它读取了jdojo.policy模块。在jdojo.claim模块中可以访问jdojo.policy模块中所有导出包的所有公共类型。

每个模块都隐式读取java.base模块。如果模块声明没有显式读取java.base模块,编译器会在模块声明中添加一条requires语句来读取java.base模块。一个jdojo.common模块的以下两个模块声明是相同的:

// Declaration #1
module jdojo.common {
    // The compiler will add a dependence to the java.base module
}
// Declaration #2
module jdojo.common {
    // Add a dependence to the java.base module explicitly
    requires java.base;
}

你可以可视化两个模块之间的依赖关系,如图 10-1 所示,它描述了两个名为jdojo.policyjdojo.claim的示例模块之间的依赖关系。

img/323069_3_En_10_Fig1_HTML.png

图 10-1

声明模块之间的依赖关系

jdojo.policy模块包含两个名为com.jdojo.policycom.jdojo.policy.impl的包;它导出了com.jdojo.policy包,该包以虚线显示,以区别于未导出的com.jdojo.policy.impl包。jdojo.claim模块包含两个包— com.jdojo.claimcom.jdojo.claim.impl;它不导出任何包,并声明依赖于jdojo.policy模块。以下两个模块声明在 Java 代码中表达了这种依赖性:

module jdojo.policy {
    exports com.jdojo.policy;
}
module jdojo.claim {
    requires jdojo.policy;
}

Tip

两个模块(被使用的模块和正在使用的模块)中的依赖声明是不对称的——被使用的模块导出一个,而正在使用的模块需要一个模块

模块依赖的一个例子

在这一节中,我们将带您看一个使用模块依赖的完整例子。假设您有两个名为jdojo.addressjdojo.person的模块。jdojo.address模块包含一个名为com.jdojo.address的包,其中包含一个名为Address的类。jdojo.person模块想要使用来自jdojo.address模块的Address类。图 10-2 显示了jdojo.person module的模块图。

img/323069_3_En_10_Fig2_HTML.jpg

图 10-2

jdojo.person 模块的模块图

在 NetBeans 中,您可以创建两个名为jdojo.addressjdojo.person的模块。清单 10-1 和 10-2 包含模块声明和Address类的代码。

// Address.java
package com.jdojo.address;
public class Address {
    private String line1 = "1111 Main Blvd.";
    private String city = "Jacksonville";
    private String state = "FL";
    private String zip = "32256";
    public Address() {
    }
    public Address(String line1, String city, String state, String zip) {
        this.line1 = line1;
        this.city = city;
        this.state = state;
        this.zip = zip;
    }
    public String getLine1() {
        return line1;
    }
    public void setLine1(String line1) {
        this.line1 = line1;
    }

    public String getCity() {
        return city;
    }
    public void setCity(String city) {
        this.city = city;
    }

    public String getState() {
        return state;
    }
    public void setState(String state) {
        this.state = state;
    }
    public String getZip() {
        return zip;
    }
    public void setZip(String zip) {
        this.zip = zip;
    }
    @Override
    public String toString() {
        return "[Line1:" + line1 + ", State:" + state +
               ", City:" + city + ", ZIP:" + zip + "]";
    }

}

Listing 10-2The Address Class

// module-info.java
module jdojo.address {
    // Export the com.jdojo.address package
    exports com.jdojo.address;
}

Listing 10-1The Module Declaration for the jdojo.address Module

Address类是一个简单的类,有四个字段以及它们的 getters 和 setters。默认值是为这些字段设置的,因此您不必在示例中键入它们。Address类中的toString()方法返回地址对象的字符串表示(本书在第十一章和 20 章中详细介绍了toString()方法的使用)。

jdojo.address模块导出com.jdojo.address包,所以Address类是公共的,在导出的com.jdojo.address包中,可以被其他模块使用。在这个例子中,您将在jdojo.person模块中使用Address类。清单 10-3 和 10-4 包含了jdojo.person模块的模块声明和Person类的代码。

// Person.java
package com.jdojo.person;
import com.jdojo.address.Address;
public class Person {
    private long personId;
    private String firstName;
    private String lastName;
    private Address address = new Address();
    public Person(long personId, String firstName, String lastName) {
        this.personId = personId;
        this.firstName = firstName;
        this.lastName = lastName;
    }
    public long getPersonId() {
        return personId;
    }
    public void setPersonId(long personId) {
        this.personId = personId;
    }
    public String getFirstName() {
        return firstName;
    }

    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    public String getLastName() {
        return lastName;
    }
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    public Address getAddress() {
        return address;
    }
    public void setAddress(Address address) {
        this.address = address;
    }
    @Override
    public String toString() {
        return "[Person Id:" + personId + ", First Name:" + firstName +
               ", Last Name:" + lastName + ", Address:" + address + "]";
    }
}

Listing 10-4A Person Class

// module-info.java
module jdojo.person {
    // Read the jdojo.address module
    requires jdojo.address;
    // Export the com.jdojo.person package
    exports com.jdojo.person;
}

Listing 10-3The Module Declaration for the jdojo.person Module

Person类在jdojo.person模块中,它使用了一个Address类型的字段,该字段在jdojo.address模块中。这意味着jdojo.person模块读取jdojo.address模块。这由jdojo.person模块声明中的requires语句表示:

// Read the jdojo.address module
requires jdojo.address;

jdojo.person模块的声明包括一个没有static修饰符的requires语句,这意味着jdojo.address模块在编译时和运行时都是必需的。当你编译jdojo.person模块时,你必须在模块路径中包含jdojo.address模块。在提供的源代码中,这两个模块是单个 NetBeans 模块化项目的一部分,您不需要执行额外的步骤来修改模块路径。

如果您使用两个单独的 NetBeans 项目创建这两个模块,那么您需要在jdojo.person模块的模块路径中包含jdojo.address模块的项目。在 NetBeans 中右键单击jdojo.person项目并选择 Properties。在“类别”列表中,选择“库”。选择 Compile 选项卡并单击 Modulepath 行上的+号。从菜单中选择添加项目…,如图 10-3 所示,从文件系统中选择jdojo.address NetBeans 项目。如果您在模块化 JAR 或目录中有一个已编译的jdojo.address模块,您可以使用 Add JAR/Folder 菜单选项。

img/323069_3_En_10_Fig3_HTML.png

图 10-3

在 NetBeans 中设置项目的模块路径

jdojo.person模块还导出了com.jdojo.person包,所以这个包中的公共类型,例如Person类,可能会被其他模块使用。清单 10-5 包含了一个Main类的代码,它在jdojo.person模块中。

// Main.java
package com.jdojo.person;
import com.jdojo.address.Address;
public class Main {
    public static void main(String[] args) {
        Person john = new Person(1001, "John", "Jacobs");
        String fName = john.getFirstName();
        String lName = john.getLastName();
        Address addr = john.getAddress();
        System.out.printf("%s %s%n", fName, lName);
        System.out.printf("%s%n", addr.getLine1());
        System.out.printf("%s, %s %s%n", addr.getCity(),
                          addr.getState(), addr.getZip());
    }
}

John Jacobs
1111 Main Blvd.
Jacksonville, FL 32256

Listing 10-5A Main Class to Test the jdojo.person Module

当您运行这个类时,输出显示您能够从jdojo.address模块中使用Address类。我们已经完成了这个展示如何使用exportsrequires模块语句的例子。如果您在运行这个示例时遇到任何问题,请参考下一节,其中列出了一些可能的错误及其解决方案。

此时,您也可以使用命令提示符运行这个示例。您需要在模块路径中包含为jdojo.personjdojo.address模块编译的展开目录或模块化 jar。以下命令使用了来自dist目录的模块化 jar:

C:\JavaFun>java --module-path dist\jdojo.person.jar;dist\jdojo.address.jar --module jdojo.person/com.jdojo.person.Main
John Jacobs
1111 Main Blvd.
Jacksonville, FL 32256

本书提供的源代码包含了JavaFun\dist目录中的所有模块化 jar。在前面的命令中,我们有选择地包含了用于jdojo.personjdojo.address模块的模块化 jar,以向您展示当您运行com.jdojo.person.Main类时,所有其他模块都没有被使用。您可以简化这个命令,只将dist目录添加到模块路径中,如下所示,Java 运行时将像以前一样使用所需的两个模块:

C:\JavaFun>java --module-path dist --module jdojo.person/com.jdojo.person.Main
John Jacobs
1111 Main Blvd.
Jacksonville, FL 32256

解决纷争

如果您是第一次使用 JDK,那么在使用这个示例时,可能会出现一些问题。下面是几个出现错误消息的场景和相应的解决方案。

空包错误

错误是

error: package is empty or does not exist: com.jdojo.address
    exports com.jdojo.address;
                     ^
1 error

当您为jdojo.address模块编译模块声明而没有包含Address类的源代码时,您会得到这个错误。该模块导出com.jdojo.address包。您必须在导出的包中定义至少一个类型。

找不到模块错误

错误是

error: module not found: jdojo.address
    requires jdojo.address;
                      ^
1 error

当您在模块路径中没有包含jdojo.address模块的情况下为jdojo.person模块编译模块声明时,您会得到这个错误。jdojo.person模块读取jdojo.address模块,因此前者必须能够在编译时和运行时在模块路径上找到后者。如果使用命令提示符,使用--module-path选项指定jdojo.address模块的模块路径。如果您使用的是 NetBeans,请参考上一节关于如何为jdojo.person模块配置模块路径的内容。

包不存在错误

错误是

error: package com.jdojo.address does not exist
import com.jdojo.address.Address;
                        ^
error: cannot find symbol
    private Address address = new Address();
            ^
  symbol:   class Address
  location: class Person

当您在jdojo.person模块中编译PersonMain类而没有在模块声明中添加适当的requires语句时,您会得到这个错误。错误消息指出编译器找不到com.jdojo.address.Address类。解决方案是在编译和运行jdojo.person模块时,在jdojo.person模块的模块声明中添加一个requires jdojo.address"语句,并在模块路径中添加jdojo.address模块。

模块解析异常

部分误差是

Error occurred during initialization of VM
java.lang.module.ResolutionException: Module jdojo.person not found
...

当您尝试使用命令提示符运行该示例时,可能会由于以下原因而出现此错误:

  • 未正确指定模块路径。

  • 模块路径是正确的,但是在模块路径上找不到指定目录或模块化 jar 中的编译代码。

假设您使用以下命令运行该示例:

C:\JavaFun>java --module-path dist --module jdojo.person/com.jdojo.person.Main

确保以下模块化 jar 存在:

  • C:\JavaFun\dist\jdojo.person.jar

  • C:\JavaFun\dist\jdojo.address.jar

如果这些模块化 jar 不存在,在 NetBeans 中构建JavaFun项目。如果您使用展开目录中的模块代码通过以下命令运行示例,请确保在 NetBeans 中编译项目:

C:\JavaFun>java --module-path build\modules\jdojo.person;build\modules\jdojo.address
--module jdojo.person/com.jdojo.person.Main

隐性依赖

如果一个模块可以读取另一个模块,而第一个模块在其声明中没有包含读取第二个模块的requires语句,那么就说第一个模块隐式地读取了第二个模块。每个模块都隐式读取java.base模块。隐式读取不限于java.base模块。一个模块也可以隐式地读取另一个模块,而不是java.base模块。在我们向您展示如何向模块添加隐式可读性之前,我们将构建一个示例来展示我们为什么需要这个特性。

在上一节中,您创建了两个名为jdojo.addressjdojo.person的模块,其中第二个模块使用以下声明读取第一个模块:

module jdojo.person {
    requires com.jdojo.address;
    ...
}

jdojo.person模块中的Person类是指jdojo.address模块中的Address类。让我们创建另一个名为jdojo.person.test的模块,它读取jdojo.person模块。模块声明如清单 10-6 所示。

// module-info.java
module jdojo.person.test {
    requires jdojo.person;
}

Listing 10-6The Module Declaration for the jdojo.person.test Module

jdojo.person.test模块的模块图如图 10-4 所示。注意,jdojo.person.test模块不读取jdojo.address模块,所以由jdojo.address模块导出的com.jdojo.address包中的公共类型在jdojo.person.test模块中是不可访问的。

img/323069_3_En_10_Fig4_HTML.png

图 10-4

jdojo.person.test 模块的模块图

清单 10-7 包含了jdojo.person.test模块中Main类的代码。

// Main.java
package com.jdojo.person.test;
import com.jdojo.person.Person;
public class Main {
    public static void main(String[] args) {
        Person john = new Person(1001, "John", "Jacobs");
        // Get John's city and print it
        String city = john.getAddress().getCity();
        System.out.printf("John lives in %s%n", city);
    }
}

Listing 10-7A Main Class to Test the jdojo.person.test Module

main()方法中的代码非常简单——它创建一个Person对象并读取一个人地址中的城市值:

Person john = new Person(1001, "John", "Jacobs");
String city = john.getAddress().getCity();

编译jdojo.person.test模块的代码会产生以下错误:

C:\JavaFun\src\jdojo.person.test\classes\com\jdojo\person\test\Main.java:11: error: Address.getCity() in package com.jdojo.address is not accessible
        String city = john.getAddress().getCity();
  (package com.jdojo.address is declared in module jdojo.address, but module jdojo.person.test does not read it)
1 error

编译器消息不是很清楚。它表明jdojo.person.test模块无法访问Address类。回想一下,Address类在jdojo.address模块中,jdojo.person.test module不读取这个模块。查看代码,很明显代码应该可以编译。您可以访问使用了Address类的Person类;所以你应该可以使用Address类。这里,对john.getAddress()方法的调用返回一个Address类型的对象,您无权访问它。模块系统只是在执行由jdojo.address模块定义的封装。如果一个模块想显式或隐式地使用Address类,它必须读取jdojo.address模块。你如何修理它?简单的答案是通过将声明改为清单 10-8 中所示的声明,让jdojo.person.test模块读取jdojo.address模块。

// module-info.java
module jdojo.person.test {
    requires jdojo.person;
    requires jdojo.address;
}

Listing 10-8The Modified Module Declaration for the jdojo.person.test Module

图 10-5 显示了jdojo.person.test模块修改后的模块图。

img/323069_3_En_10_Fig5_HTML.png

图 10-5

jdojo.person.test 模块的修改后的模块图

编译并运行jdojo.person.test模块中的Main类,它将打印以下内容:

John lives in Jacksonville

您通过在jdojo.person.test模块的声明中添加一个requires语句解决了这个问题。然而,很有可能读取jdojo.person模块的其他模块将需要处理地址,它们将需要添加相同的requires语句。如果jdojo.person模块在其公共 API 中公开了来自多个其他模块的类型,那么读取jdojo.person模块的模块将需要为每个这样的模块添加一个requires语句。对于所有这些模块来说,增加一个额外的requires语句是非常麻烦的。

还有另一个用例可以创建这种场景。假设只有两个模块——jdojo.person.testjdojo.person——其中前者读取后者,后者导出其公共类型被前者使用的所有包。com.jdojo.address包在jdojo.person模块里,jdojo.person.test模块编译好了。后来,jdojo.person模块被重构为两个模块——jdojo.personjdojo.address。现在,jdojo.person.test模块停止工作,因为jdojo.person模块中的一些公共类型现在被移动到了jdojo.address模块中,而jdojo.person.test模块不读取这些公共类型。

JDK 9 的设计师意识到了这个问题,并提供了一个简单的方法来解决这个问题。在这种情况下,您需要做的就是修改jdojo.person模块的声明,在requires语句中添加一个transitive修饰符来读取jdojo.address模块。清单 10-9 包含了对jdojo.person模块的修改声明。

// module-info.java
module jdojo.person {
    // Read the jdojo.address module
    requires transitive jdojo.address;
    // Export the com.jdojo.person package
    exports com.jdojo.person;
}

Listing 10-9The Modified Module Declaration for the jdojo.person Module That Uses a Transitive Export

现在,你可以删除这个声明

requires jdojo.address;

来自jdojo.person.test模块的声明。您需要将jdojo.address项目保留在模块路径上来编译和运行jdojo.person.test模块项目,因为jdojo.address模块仍然需要使用该模块中的Address类型。重新编译jdojo.person模块。重新编译并运行jdojo.person.test模块中的主类,以获得想要的输出。

Tip

当模块M使用来自模块N的公共类型,并且这些公共类型是模块M的公共 API 的一部分时,考虑在模块M中使用一个requires transitive N。假设您有一个导出包的模块P和另一个读取模块P的模块Q。如果您重构模块P以将其拆分为多个模块,比如说ST,请考虑将requires transitive Srequires transitive T语句添加到P的模块声明中,以确保所有读取P的模块(在本例中为模块Q)继续工作而不做任何更改。

requires语句包含transitive修饰符时,依赖于当前模块的模块隐式读取在requires语句中指定的模块。参考清单 10-9 ,任何读取jdojo.person模块的模块都会隐式读取jdojo.address模块。本质上,隐式读取使模块声明更容易阅读,将一个模块重构为多个模块更容易,但更难推理,因为仅通过查看模块声明,您无法了解它的所有依赖项。图 10-6 显示了jdojo.person.test模块的最终模块图。

img/323069_3_En_10_Fig6_HTML.png

图 10-6

jdojo.person.test 模块的模块图

当模块被解析时,模块图通过为每个传递依赖添加一个读边来扩充。在本例中,如图 10-7 中的虚线箭头所示,读取边沿将从jdojo.person.test模块添加到jdojo.address模块。图中用虚线显示了将jdojo.person.test模块连接到jdojo.address模块的边,以表示它是在模块图被解析后添加的。

img/323069_3_En_10_Fig7_HTML.png

图 10-7

jdojo.person.test 模块的模块图,在用隐式读边扩充后

选择性依赖

模块系统在编译时和运行时验证模块依赖性。有时候你想让模块依赖在编译时是强制性的,但在运行时是可选的。

如果特定的模块在运行时可用,您可以开发一个性能更好的库。否则,它会退回到另一个模块,使其性能达不到最佳状态。但是,库是针对可选模块编译的,它确保如果可选模块不可用,依赖于可选模块的代码不会被执行。

另一个例子是导出注释包的模块。Java 运行时已经忽略了不存在的注释类型。然而,模块依赖在启动时被验证;如果模块在运行时丢失,应用程序将不会启动。因此,有必要将包含注释包的模块的模块依赖声明为可选的。

您可以通过在requires语句中使用static关键字来声明可选的依赖项:

requires static <optional-module>;

以下模块声明包含对jdojo.annotation模块的可选依赖:

module jdojo.claim {
    requires static jdojo.anotation;
}

在一条requires语句中,允许同时有transitivestatic修饰语;

module jdojo.claim {
    requires transitive static jdojo.anotation;
}

打开模块和包

反思是一个浩如烟海的话题。如果这是您第一次接触 Java,您可能很难理解这一部分。当您对 Java 有了更多的经验时,或者只是阅读它而不必担心会跟不上所解释的一切时,您可以重温这一节。

反射是一种在编译时不知道 Java 类型的情况下使用它们的方法。你已经在本章中使用了诸如Person类的类型。要创建一个Person并调用它的getFirstName()方法,您需要编写如下代码:

import com.jdojo.person.Person;
...
Person john = new Person(1001, "John", "Jacobs");
String firstName = john.getFirstName();

在这种情况下,Java 编译器确保在com.jdojo.person包中有一个名为Person的类。编译器还确保这段代码可以访问Person类、它的构造器和它的getFirstName()方法。如果Person类不存在,你就不能编译这段代码。当您运行这段代码时,Java 运行时再次验证Person的存在以及这段代码使用它所需的访问权限。使用反射,您可以在不知道Person类存在的情况下重写这段代码。您的代码和编译器不知道Person类,但是您将能够实现同样的功能。为此,这段代码只需要运行时访问Person类。Java 中有两种类型的访问:

  • 编译时访问

  • 运行时访问

编译器在编译期间验证编译时访问。编译时访问必须遵循 Java 语言访问规则,例如,类之外的代码不能访问该类的私有成员。

Java 运行时验证对类型及其成员的运行时访问。在运行时,代码可以通过两种方式访问类型及其成员:

  • 第一种方法是运行根据被访问的类型编写的编译代码。在这种情况下,运行时会像在编译期间一样加强 Java 语言的可访问性规则。

  • 第二种方法是在运行时使用反射来访问类型及其成员。在这种情况下,编译器不知道您的代码将在运行时访问的类型及其成员。使用反射访问类型及其成员被称为使用反射访问。与普通访问不同,反射访问允许访问所有类型(不仅仅是公共类型)和这些类型的所有成员(甚至是私有成员)。

反射访问有好有坏。它之所以好,是因为它让您开发可以在所有未知类型上工作的库。有几个很好的框架,比如 Spring 和 Hibernate,非常依赖于对应用程序库中定义的类型成员的深度反射访问。反射访问之所以不好,是因为它破坏了封装——它可以访问类型和这些类型的成员,而使用正常的访问规则是无法访问这些类型和成员的。使用反射访问不可访问的类型及其成员有时被称为深度反射

20 多年来,Java 允许反射访问。Java 9 中模块系统的设计者在设计对模块代码的深度反射访问时面临着一个巨大的挑战。允许对导出包的类型进行深度反射违反了模块系统的强封装主题。它使得外部代码可以访问任何东西,即使模块开发者不想公开模块的某个部分。另一方面,不允许深度反射将使 Java 社区缺乏一些广泛使用的伟大框架,并且它还将破坏许多依赖于深度反射的现有应用程序。由于这一限制,许多现有的应用程序根本无法迁移到 JDK 9。

经过几次反复的设计和实验之后,模块系统的设计者们提出了一个折中的办法——鱼和熊掌不可兼得!该设计允许您拥有一个具有强封装、深度反射访问以及两者兼而有之的模块。规则如下:

  • 导出的包将只允许在编译时和运行时访问公共类型及其公共/受保护成员。如果不导出包,其他模块将无法访问该包中的所有类型。这提供了强大的封装。

  • 您可以打开一个模块,以允许在运行时对该模块中所有包中的所有类型进行深度反射。这样的模块被称为开放模块

  • 您可以拥有一个普通模块——一个没有为深度反射而打开的模块——以及在运行时为深度反射而打开的特定包。所有其他未打开的包装都被严密封装。模块中允许深度反射的包被称为开放包

  • 有时,您可能希望在编译时访问包中的类型,以便根据该包中的类型编写代码;同时,您希望在运行时对这些类型进行深度反射访问。您可以导出并打开同一个包来实现这一点。

开放模块

module关键字前使用open修饰符声明一个打开的模块:

open module jdojo.model {
    // Module statements go here
}

这里的jdojo.model模块是一个开放模块。其他模块可以在这个模块的所有包中的所有类型上使用深度反射。在一个开放模块的声明中可以有exportsrequiresusesprovides语句。在打开的模块中不能有opens语句。一个opens语句用于打开一个特定的包进行深度反射。因为开放模块打开所有包进行深度反射,所以在开放模块内部不允许使用opens语句。

打开包装

打开一个包意味着向该包中的公共类型授予对其他模块的正常运行时访问,并允许其他模块对该包中的类型使用深度反射。您可以向所有其他模块或特定模块列表打开一个包。向所有其他模块打开包的opens语句的语法如下:

opens <package>;

在这里,<package>可用于所有其他模块的深度反射。您还可以使用限定的opens语句打开特定模块的包:

opens <package> to <module1>, module2>...;

在这里,<package>只对<module1><module2>等开放深刻反思。下面是一个在模块声明中使用opens语句的例子:

module jdojo.model {
    // Export the com.jdojo.util package to all modules
    exports com.jdojo.util;
    // Open the com.jdojo.util package to all modules
    opens com.jdojo.util;
    // Open the com.jdojo.model.policy package only to the hibernate.core module
    opens com.jdojo.model.policy to hibernate.core;
}

jdojo.model模块导出com.jdojo.util包,这意味着所有公共类型及其公共成员在编译时都是可访问的,并且在运行时可以正常反射。第二条语句在运行时打开同一个包进行深度反射。总之,com.jdojo.util包的所有公共类型及其公共成员在编译时都是可访问的,并且该包允许在运行时进行深度反射。第三条语句将com.jdojo.model.policy包只对hibernate.core模块开放以进行深度反射,这意味着在编译时没有其他模块可以访问这个包的任何类型,而hibernate.core模块可以在运行时使用深度反射访问所有类型及其成员。

Tip

对另一个模块的开放包执行深度反射的模块不需要读取包含开放包的模块。但是,如果您知道模块名,那么允许并强烈建议您添加对带有开放包的模块的依赖,这样模块系统就可以在编译时和运行时验证这种依赖。

当一个模块M打开它的包P用于到另一个模块N的深度反射时,有可能模块N将它在包P上的深度反射访问授权给另一个模块Q。模块N将需要使用模块 API 以编程方式来完成。将反射访问委托给另一个模块可以避免将整个模块向所有其他模块开放;同时,它在被授予反射访问的模块部分创建了额外的工作。

跨模块拆分包

不允许将包拆分成多个模块*。也就是说,不能在多个模块中定义同一个包。如果同一个包中的类型在多个模块中,那么这些模块应该合并到一个模块中,或者您需要重命名包。有时,您可以成功编译这些模块,但会收到一个运行时错误;其他时候,您会收到编译时错误。正如我在开始提到的,拆分包并不是无条件禁止的。你需要知道这种错误背后的简单规则。*

*如果两个名为MN的模块定义了同一个名为P的包,那么MN模块中的包P就不能被Q访问。换句话说,多个模块中的同一个包不能同时被一个模块读取。否则,会发生错误。如果一个模块正在使用包P中的类型T,而该类型在两个模块中都存在,则模块系统无法决定是否使用这两个模块之一中的P.T。它会生成一个错误,并希望您修复该问题。考虑以下代码片段:

// Test.java
package java.util;
public class Test {
}

JDK 中的java.base模块包含一个java.util包,该包可供所有模块使用。如果你将 JDK 17 中的Test类作为一个模块的一部分或者单独编译,你会收到下面的错误:

error: package exists in another module: java.base
package java.util;
^
1 error

如果你在一个名为M的模块中有这个类,编译时错误会指出这个模块和java.base模块中的java.util包可以被模块M读取。您必须将这个Test类的包从java.util更改为其他东西,比如说com.jdojo.util,它不存在于任何可观察的模块中。

模块声明中的限制

声明模块有几个限制。如果违反了这些规则,您将在编译时或启动时得到错误:

  • 模块图不能包含循环依赖关系。也就是说,两个模块不能相互读取。如果有,它们应该是一个模块,而不是两个。请注意,通过以编程方式添加可读性边缘或使用命令行选项,可以在运行时拥有循环依赖关系。

  • 模块声明不支持模块版本。您需要使用jar工具或其他一些工具,比如javac,将模块的版本添加为类文件属性。

  • 模块系统没有子模块的概念。即jdojo.personjdojo.person.client是两个独立的模块;第二个不是第一个的子模块。

模块类型

Java 已经存在 20 多年了;新老应用程序都将继续使用没有模块化或永远不会模块化的库。如果 JDK 9 强迫每个人模块化他们的应用程序,JDK 9 可能不会被大多数人采用。JDK 9 的设计者考虑到了向后兼容性。您可以采用 JDK 9 及更高版本,方法是按照您自己的节奏模块化您的应用程序,或者决定根本不模块化—只运行您现有的应用程序。在大多数情况下,在 JDK 8 或更早版本中工作的应用程序将继续在 JDK 9 或更高版本中工作,而不做任何更改。为了简化迁移,JDK 定义了四种类型的模块:

  • 正规模

  • 开放模块

  • 自动模块

  • 未命名模块

事实上,你会遇到六个术语来描述六种不同类型的模块,对于一个 JDK 9 的初学者来说,这些术语很容易混淆。其他两种类型的模块用于传达这四种类型模块的更广泛的类别。图 10-8 显示了所有类型模块的示意图。

img/323069_3_En_10_Fig8_HTML.png

图 10-8

模块类型

在我描述模块的主要类型之前,我给你图 10-8 所示模块类型的简要定义。

  • 模块是代码和数据的集合。

  • 根据模块是否有名字,模块可以是命名模块未命名模块。没有未命名模块的进一步分类。

  • 当一个模块有一个名字时,这个名字可以在模块声明中显式给出,也可以自动(或隐式)生成。如果在模块声明中显式地给出了这个名字,它就被称为显式模块。如果这个名字是由模块系统通过读取模块路径上的 JAR 文件名生成的,它就被称为一个自动模块

  • 如果你声明一个没有使用open修饰符的模块,它被称为普通模块

  • 如果你使用open修饰符声明一个模块,它被称为开放模块

基于这些定义,开放模块也是显式模块和命名模块。自动模块是命名模块,因为它有一个自动生成的名称,但它不是显式模块,因为它是由模块系统在编译时和运行时隐式声明的。以下小节描述了这些模块类型。

Tip

如果 Java 平台最初是用模块系统设计的,那么您将只有一种模块类型——普通模块!所有其他模块类型的存在是为了向后兼容以及模块的平滑迁移和采用。

正规模

使用模块声明而不使用open修饰符显式声明的模块总是有一个名字,它被称为普通模块或简称为模块。到目前为止,您主要使用的是普通模块。我一直把普通模块称为模块,并且我继续在这个意义上使用这个术语,除非我需要区分这四种类型的模块。默认情况下,普通模块中的所有类型都被封装。普通模块的示例如下:

module a.normal.module {
    // Module statements go here
}

开放模块

如果一个模块声明包含了open修饰符,那么这个模块就是开放模块。开放模块的示例如下:

open module an.open.module {
    // Module statements go here
}

自动模块

为了向后兼容,用于查找类型的类路径机制在 JDK 9 中仍然有效。您可以选择将 jar 放在类路径、模块路径以及两者的组合上。请注意,您可以将模块化 jar 以及 jar 放在模块路径和类路径上。

当您将一个 JAR 放在模块路径上时,这个 JAR 被视为一个模块,它被称为一个自动模块。名称自动模块来源于这样一个事实,即该模块是从一个 JAR 中自动定义的——您不需要通过添加一个module-info.class文件来显式声明该模块。自动模块有一个名称。自动模块的名称是什么?它读取什么模块,导出什么包?我将很快回答这些问题。

自动模块的存在仅仅是为了将现有的 Java 应用程序移植到模块系统。通过将现有的 jar 放在模块路径上,它们允许您将它们作为模块使用。然而,它们是不可靠的,因为当 jar 的作者将它们转换成模块化 jar 时,他们可能会选择给它们不同于自动派生的模块名。当作者将 jar 转换为模块化 jar 时,自动模块中导出的包也可能改变。当您在应用程序中使用自动模块时,请记住这些风险。

为了防止自动模块的模块名改变,作者可以在他们将 JAR 转换成模块化 JAR 之前建议一个模块名。您可以在 JAR 的MANIFEST.MF文件中使用建议的模块名,将其指定为自动模块名。您可以在 JAR 中的MANIFEST.MF文件的主要部分指定一个自动模块名作为属性“Automatic-Module-Name”的值。

自动模块也是命名模块。假设您想使用一个 JAR com.jdojo.intro-1.0作为自动模块。它的名称和版本是使用以下规则从 JAR 文件的名称中派生出来的:

  • 如果 JAR 文件在其MANIFEST.MF文件的主要部分中有属性“Automatic-Module-Name ”,那么该属性的值就是模块名。模块名也可以通过下面的步骤从 JAR 文件的名称中获得。

  • JAR 文件的扩展名.jar被删除。这一步删除了.jar扩展名,接下来的步骤使用com.jdojo.intro-1.0来导出模块的名称及其版本。

  • 如果名称以连字符结尾,后跟至少一个数字(可以选择后跟一个点),则模块名称源自最后一个连字符之前的名称部分。如果连字符后面的部分可以被解析为有效版本,则该部分被指定为模块的版本;否则,这部分被忽略。在我们的例子中,模块名将来自于com.jdojo.intro。版本将衍生为 1.0。

  • 对于模块名称,所有尾随的数字和点都被删除。在我们的例子中,模块名的剩余部分com.jdojo.intro不包含任何尾随数字和点。所以这一步不会改变任何事情。

  • 名称部分中的每个非字母数字字符都被替换为一个点;并且,在得到的字符串中,两个连续的点被一个点代替;并且所有的前导点和尾随点都被移除。在我们的例子中,名称部分没有任何非字母数字字符,所以模块名是com.jdojo.intro

按顺序应用这些规则会给你一个模块名和一个模块版本。在本节的最后,我们将向您展示如何通过 JAR 文件来知道自动模块的名称。表 10-1 列出了几个 JAR 名和它们派生的自动模块名。注意,该表没有显示 JAR 文件名中的扩展名.jar,并且假设在 JAR 文件的MANIFEST.MF的主部分中没有指定“Automatic-Module-Name”属性。

表 10-1

从 JAR 文件名派生自动模块名称的例子

|

罐子名称

|

模块名

|

模块版本

| | --- | --- | --- | | com.jdojo.intro-1.0 | com.jdojo.intro | 1.0 | | junit-4.10.jar | Junit | 4.10 | | jdojo-logging1.5.0 | N/A |   | | spring-core-4.0.1.RELEASE | spring.core | 4.0.1.RELEASE | | jdojo-trans-api_1.5_spec-1.0.0 | N/A | N/A | | _ | N/A | N/A |

让我们看一下表中的三种奇怪情况,如果您将 jar 放在模块路径上,您将会收到一个错误。第一个 JAR 名是jdojo-logging1.5.0。应用所有的规则,派生的模块名是jdojo.logging1.5.0,这是一个无效的模块名。回想一下,模块名是一个合格的 Java 标识符。也就是说,模块名中的每一部分都必须是有效的 Java 标识符。在这种情况下,名称的两部分“5”和“0”不是有效的 Java 标识符。在模块路径上使用这个 JAR 将会产生一个错误,除非您使用清单文件中的“Automatic-Module-Name”属性指定一个有效的模块名。

第二个给出错误的 JAR 名是jdojo-trans-api_1.5_spec-1.0.0。让我们应用规则来导出自动模块名:

  • 它找到最后一个连字符,在这个连字符后面只有数字和点,并将 JAR 名称分成两部分:jdojo-trans-api_1.5_spec1.0.0。第一部分用于派生模块名。第二部分是模块版本。

  • 名称部分不包含任何尾随数字和点。因此,应用下一个规则,将所有非字母数字字符转换为点。得到的字符串是jdojo.trans.api.1.5.spec。现在,“1”和“5”是模块名中的两个部分,它们不是有效的 Java 标识符。所以派生的模块名是无效的,这就是当您将这个 JAR 文件添加到模块路径时出现错误的原因。

第三个 JAR 名称是表中的最后一个条目,是一个下划线(_)。即 JAR 文件被命名为_.jar。如果您应用这些规则,下划线将被一个点代替,该点将被删除,留下一个空字符串,这不是一个有效的模块名。模块路径上的_.jar文件会导致如下异常:

java.lang.module.ResolutionException: Unable to derive module descriptor for: _.jar

您可以使用带有–-describe-module选项的jar命令来了解将从 JAR 派生的自动模块的名称。一般语法如下:

jar --describe-module --file <your-JAR-file-path>

下面的命令打印名为jdojo.util-2.2.jar的 JAR 的自动模块名,假设 JAR 存在于C:\JavaFun目录中:

c:\JavaFun\jars>jar --describe-module --file jdojo.util-2.2.jar
No module descriptor found. Derived automatic module.
jdojo.util@2.2 automatic
requires java.base mandated
contains com.jdojo.person

输出中的第一行表明jdojo.util-2.2.jar是一个 JAR,而不是一个模块化 JAR。如果它是一个模块化的 JAR,模块名将从module-info.class文件中读取。第一行表示没有找到模块描述符。第二行打印模块名jdojo.util和模块版本2.2。在第二行的末尾,打印出单词automatic,表示这个模块名是作为自动模块名派生出来的。输出中的第三行和第四行打印自动模块的依赖和包信息。

您可以使用jar命令来更新清单条目。我们将向您展示如何向 JAR 添加“Automatic-Module-Name”属性。我们在这个例子中使用了jdojo.util-2.2.jar。您需要创建一个文本文件并添加 manifest 属性。清单 10-10 显示了名为manifest.txt的清单文件的内容。该文件包含两行。第一行指定了一个名为“Automatic-Module-Name”的属性,其值为jdojo.misc。第二行是一个你看不到的空行。确保在这个文件中有一个空行。否则,下一个命令将不起作用。

Automatic-Module-Name: jdojo.misc

Listing 10-10Contents of a manifest.txt File

下面的命令将更新jdojo.util-2.2.jar文件中的清单文件,假设 JAR 文件和manifest.txt文件都放在同一个目录下,C:\JavaFun:

c:\JavaFun\jars>jar --update --manifest manifest.txt --file jdojo.util-2.2.jar

如果您描述jdojo.util-2.2.jar文件来查看派生的自动模块名,那么将从其清单文件的“Automatic-Module-Name”属性中读取模块名。让我们重新运行前面的命令来描述模块:

c:\JavaFun\jars>jar --describe-module --file jdojo.util-2.2.jar
No module descriptor found. Derived automatic module.
jdojo.misc@2.2 automatic
requires java.base mandated
contains com.jdojo.person

一旦你知道了一个自动模块的名字,其他显式模块可以使用requires语句读取它。下面的模块声明读取来自模块路径上的jdojo.util-2.2.jar的名为jdojo.misc的自动模块,假设自动模块名来自 JAR 文件名:

module jdojo.lib {
    requires jdojo.util;
    //...
}

要有效地使用一个自动模块,它必须导出包并读取其他模块。让我们看看这方面的规则:

  • 自动模块读取所有其他模块。重要的是要注意,在模块图被解析之后,从一个自动模块到所有其他模块的可读性被增加了。

  • 自动模块中的所有包装都被导出并打开。

这两个规则基于这样一个事实,即没有实际可行的方法来判断一个自动模块依赖于哪些其他模块,以及其他模块将需要编译该自动模块的哪些包或进行深度反射。

自动模块读取所有其他模块可能会产生循环依赖,这在模块图被解析后是允许的。回想一下,在模块图解析期间,模块之间的循环依赖是不允许的。也就是说,在模块声明中不能有循环依赖。

自动模块没有模块声明,因此它们不能声明对其他模块的依赖。显式模块可以声明对其他自动模块的依赖。考虑一种情况,一个显式模块M读取一个自动模块P,而模块P使用另一个自动模块Q中的类型T。当您使用模块M中的主类启动应用程序时,模块图将只包含MP——为了简洁起见,在此讨论中不包括java.base模块。解析过程将从模块M开始,并将看到它读取另一个模块P。解析过程没有实际可行的方法来判断模块P读取模块Q。通过将模块PQ放在类路径上,您将能够编译它们。然而,当您运行这个应用程序时,您将收到一个ClassNotFoundException。当模块P试图从模块Q中访问一个类型时,异常发生。要解决这个问题,模块Q必须包含在模块图中,方法是使用--add-modules命令行选项将其添加为根模块,并将Q指定为该选项的值。

未命名模块

您可以将 jar 和模块化 jar 放在类路径上。当一个类型正在被加载,而它的包在任何已知的模块中都找不到时,模块系统会尝试从类路径加载该类型。如果在类路径上找到该类型,它将被类加载器加载,并成为该类加载器的一个名为未命名模块的模块的成员。每个类装入器都定义一个未命名的模块,其成员都是它从类路径中装入的类型。未命名模块没有名字,所以显式模块不能使用requires语句声明对它的依赖。如果有一个显式模块需要使用未命名模块中的类型,则必须通过将 JAR 放在模块路径上,将未命名模块的 JAR 用作自动模块。

试图在编译时从显式模块中访问未命名模块中的类型是一个常见的错误。这是不可能的,因为未命名的模块没有名字,而显式模块需要一个模块名才能在编译时读取另一个模块。自动模块作为显式模块和未命名模块之间的桥梁,如图 10-9 所示。显式模块可以使用requires语句访问自动模块,自动模块可以访问未命名模块。

img/323069_3_En_10_Fig9_HTML.png

图 10-9

一种自动模块,充当显式模块和未命名模块之间的桥梁

未命名模块没有名称。这并不意味着未命名模块的名称是一个空字符串,“未命名”或null。下面的模块声明试图声明对未命名模块的依赖,这是无效的:

module some.module {
    requires "";        // A compile-time error
    requires "unnamed"; // A compile-time error
    requires unnamed;   // A compile-time error, unless a named module named unnamed exists
    requires null;      // A compile-time error
}

未命名模块读取其他模块,并使用以下规则向其他模块导出和打开其所有包:

  • 未命名的模块读取所有其他模块。因此,未命名模块可以访问所有模块(包括平台模块)中所有导出包的公共类型。该规则使得使用在 Java SE 8 中编译和运行的类路径的应用程序可以继续在 Java SE 9 中编译和运行,前提是它们只使用标准的、未被弃用的 Java SE APIs。

  • 未命名的模块向所有其他模块开放其所有包。因此,显式模块可以在运行时使用反射来访问未命名模块中的类型。

  • 未命名的模块导出它的所有包。显式模块不能在编译时读取未命名的模块。在模块图被解析后,所有的自动模块都被用来读取未命名的模块。

    提示一个未命名的模块可能包含一个由命名模块导出的包。在这种情况下,未命名模块中的包将被忽略。

聚合器模块

您可以创建一个不包含自己代码的模块。它收集并重新导出其他模块的内容。这样的模块被称为聚合器模块。假设有几个模块依赖于五个模块。您可以为这五个模块创建一个聚合器模块,现在,您的模块只能依赖于一个模块—聚合器模块。聚合器模块并不是与前面章节所解释的不同的模块类型。它是一个命名模块。它有一个特殊的名字,“聚合器”,因为它没有自己的内容。相反,它将其他几个模块的内容以不同的名称组合成一个模块。

一个聚合器模块只包含一个类文件,那就是module-info.class。聚合器模块的模块声明由所有的"requires transitive <module>"语句组成。下面是一个聚合器模块声明的示例。聚合器模块名为jdojo.all,它聚合了三个模块——jdojo.policyjdojo.claimjdojo.payment:

module jdojo.all {
    requires transitive jdojo.policy;
    requires transitive jdojo.claim;
    requires transitive jdojo.payment;
}

聚合器模块的存在是为了方便。从版本 9 开始,Java 包含了几个聚合器模块,比如java.sejava.se.eejava.se模块收集了 Java SE 中不与 Java EE 重叠的部分。java.se.ee模块集合了构成 Java SE 的所有模块,包括与 Java EE 重叠的模块。

了解运行时的模块

Java SE 17 提供了一组类和接口来以编程方式处理模块。它们统称为模块 API 。模块 API 允许您查询和修改模块信息。在本节中,我们将快速预览模块 API。

JVM 中加载的每个类型都由一个java.lang.Class<T>类的实例表示。也就是说,Class<T>类的一个实例在运行时表示类型T。您可以使用该类的对象的getClass()方法来获取类型的引用。假设存在一个Person类,下面的代码片段获得了对Person类的引用:

Person p = new Person();
Class<Person> cls = p.getClass();

还可以使用类文本来获取类的引用。类文字是后面跟有一个“.class”的类的名称。例如,您可以使用类文字Person.class来获取Person类的引用。您可以重写前面的代码片段,如下所示:

Class<Person> cls = Person.class;

在运行时,每种类型都作为模块的成员加载。如果该类型是从类路径加载的,则它是加载该类型的类加载器的未命名模块的成员。如果该类型是从模块路径加载的,则它是命名模块的成员。java.lang.Module类的一个实例表示运行时的一个模块。Class类包含一个getModule()方法,该方法返回一个代表该类型模块的Module。下面的代码片段获取了对Person类所属的Module对象的引用:

Class<Person> cls = Person.class;
Module m = cls.getModule();

Module类包含几个方法,让您查询模块在编译时的声明状态和运行时的实际状态。请注意,模块状态可以从源代码中声明的方式进行更改。模块 API 中的其他类和接口在java.lang.module包中。例如,java.lang.module包中的ModuleDescriptor类的一个实例,代表了在源文件中为显式模块声明的模块描述符,以及为自动模块合成的模块描述符。您可以将Module类的getDescriptor()方法用于ModuleDescriptor类的实例。未命名的模块没有模块描述符,所以getDescriptor()方法为未命名的模块返回null。可以使用Module类的getName()方法来获取模块的名称;该方法为未命名的模块返回null

清单 10-11 包含了一个jdojo.mod模块的声明。清单 10-12 包含了一个ModuleInfo类的代码,它打印了它所属模块的信息。

// ModuleInfo.java
package com.jdojo.mod;
import java.lang.module.ModuleDescriptor;
public class ModuleInfo {
    public static void main(String[] args) {
        // Get the class reference
        Class<ModuleInfo> cls = ModuleInfo.class;
        // Get the module reference
        Module m = cls.getModule();
        if (m.isNamed()) {
            // It is a named module
            // Get the module name
            String name = m.getName();
            // Get the module descriptor
            ModuleDescriptor md = m.getDescriptor();
            // Print the module details
            System.out.println("Module Name: " + name);
            System.out.println("Module is open: " + md.isOpen());
            System.out.println("Module is automatic: " + md.isAutomatic());
        } else {
            // It is an unnamed module
            System.out.println("Unnamed module.");
        }
    }
}

Listing 10-12A ModuleInfo Class

// module-info.java
module jdojo.mod {
    exports com.jdojo.mod;
}

Listing 10-11The Module Declaration for the jdojo.mod Module

下面的命令通过将jdojo.mod模块的模块化 JAR 放在模块路径上来运行ModuleInfo类。输出清楚地显示了正确的模块信息:

C:\JavaFun>java --module-path dist\jdojo.mod.jar --module jdojo.mod/com.jdojo.mod.ModuleInfo
Module Name: jdojo.mod
Module is open: false
Module is automatic: false

下面的命令通过将jdojo.mod模块的模块化 JAR 放在类路径上来运行ModuleInfo类。这一次,类是从类路径加载的,它成为加载它的类加载器的未命名模块的成员:

C:\JavaFun>java --class-path dist\jdojo.mod.jar com.jdojo.mod.ModuleInfo
Unnamed module.

模块的迁移路径

如果您是第一次学习模块,可以跳过这一部分。当您必须迁移现有的 Java 应用程序以使用模块时,您可以重新访问。

当您将应用程序迁移到模块时,您应该记住模块系统提供的两个好处:强大的封装和可靠的配置。你的目标是拥有一个完全由普通模块组成的应用程序,除了一些打开的模块。似乎有人可以给你一个清晰的清单,列出将现有应用程序移植到模块时需要执行的步骤。然而,考虑到应用程序的多样性、它们与其他代码的相互依赖性以及不同的配置需求,这是不可能的。我们所能做的就是列出一些通用的指导方针来帮助您完成迁移,这也是本节所要做的。

一个重要的 Java 应用程序通常由位于三层的几个 jar 组成:

  • 应用程序开发人员开发的应用层中的应用程序 jar

  • 第三方提供的库层中的库 jar

  • JVM 层中的 Java 运行时 jar

Java 已经通过将 Java 运行时 jar 转换成模块,将它们模块化了。也就是说,Java 运行时由模块组成,并且只由模块组成。

库层主要由放置在类路径上的第三方 jar 组成。如果您想迁移您的应用程序以使用模块,您可能得不到第三方 jar 的模块化版本。您也无法控制供应商如何将第三方 jar 转换成模块。您可以将库 jar 放在模块路径上,并将其视为自动模块。

您可以选择完全模块化您的应用程序代码。以下是您可以选择的模块类型,从最不理想到最理想:

  • 未命名模块

  • 自动模块

  • 开放模块

  • 正规模

迁移的第一步是通过将所有的 jar(应用程序 jar 和库 jar)放在类路径上来检查您的应用程序是否在 JDK 17 中运行,而无需对您的代码进行任何修改。类路径上 jar 中的所有类型都将是未命名模块的一部分。您的应用程序在这种状态下使用 JDK 17,没有任何强大的封装和可靠的配置。

一旦您的应用程序在 JDK 17 中运行,您就可以开始将应用程序代码转换成自动模块。自动模块中的所有包都是开放的,用于深度反射访问,并被导出,用于对其公共类型的普通编译时和运行时访问。从这个意义上说,它并不比未命名的模块更好;它没有为您提供强大的封装。但是,自动模块为您提供了可靠的配置,因为其他显式模块可以声明对自动模块的依赖。

您还有另一种选择,将您的应用程序代码转换成开放模块,这提供了适度的更强的封装:在开放模块中,所有的包都是开放的,用于深度反射访问,但是您可以指定导出哪些包(如果有的话),用于普通的编译时和运行时访问。显式模块还可以声明对开放式模块的依赖,从而为您带来可靠配置的好处。

普通模块提供最强的封装,允许您选择打开、导出或同时打开和导出哪些包。显式模块也可以声明对普通模块的依赖。

表 10-2 包含了模块类型的列表,以及它们提供的强大封装和可靠配置的程度。

表 10-2

模块类型和不同程度的强大封装以及它们提供的可靠配置

|

模块类型

|

强封装

|

可靠的配置

| | --- | --- | --- | | 未命名的 | 不 | 不 | | 自动的 | 不 | 适度的 | | 打开 | 适度的 | 是 | | 标准 | 最强壮的 | 最强壮的 |

Java 类依赖分析器

为了帮助确定在转换项目以使用开放或普通模块时需要什么模块,您可以使用 Java 类依赖分析器,简称 jdeps。它是一个命令行工具,用于分析现有 JAR 文件和模块的依赖关系。

例如,假设您有一个名为 aopalliance-1.0.jar 的 JAR 文件,您可以对它运行 jdeps,如下所示:

$ jdeps aopalliance-1.0.jar
aopalliance-1.0.jar -> java.base
   org.aopalliance.aop             -> java.io                       java.base
   org.aopalliance.aop             -> java.lang                     java.base
   org.aopalliance.intercept       -> java.lang                     java.base
   org.aopalliance.intercept       -> java.lang.reflect             java.base
   org.aopalliance.intercept       -> org.aopalliance.aop           aopalliance-1.0.jar

这基本上显示了所有依赖的包以及它们包含在哪个模块中。这个 JAR 只依赖于 java.base 模块。

它还可以用来分析相反的情况—哪些模块依赖于给定的模块,例如:

$ jdeps --inverse --require java.sql
Inverse transitive dependences on [java.sql]
java.sql <- java.se
java.sql <- java.sql.rowset <- java.se

反汇编模块定义

在这一节中,我们将解释 JDK 附带的javap工具,它可以用来反汇编类文件。这个工具对于学习模块系统非常有用,尤其是反编译模块的描述符。

我们将在提供的源代码中使用来自JavaFun目录的代码。我们假设您已经在C:\JavaFun目录中提取了它。如果不同,请在下面的示例中用您的路径替换此路径。

在第三章中,您有两个jdojo.intro模块的module-info.class文件副本:一个在mod\jdojo.intro目录中,另一个在lib\com.jdojo.intro.jar文件的模块化 JAR 中。当您将模块的代码打包到 JAR 中时,您已经为模块指定了一个版本和一个主类。这些信息去了哪里?它们作为类属性被添加到module-info.class文件中。所以两个module-info.class文件的内容是不一样的。你怎么证明?首先在两个module-info.class文件中打印模块声明。您可以使用位于JDK_HOME\bin目录中的javap工具来反汇编任何类文件中的代码。您可以指定要反汇编的文件名、URL 或类名。以下命令打印模块声明:

C:\JavaFun>javap mod\jdojo.intro\module-info.class
Compiled from "module-info.java"
module jdojo.intro {
  requires java.base;
}
C:\JavaFun>javap jar:file:lib/com.jdojo.intro.jar!/module-info.class
Compiled from "module-info.java"
module jdojo.intro {
  requires java.base;
}

第一个命令使用一个文件名,第二个命令使用一个使用jar方案的 URL。这两个命令都使用相对路径。如果您愿意,可以使用绝对路径。

输出表明两个module-info.class文件包含相同的模块声明。您需要使用–verbose选项(或–v选项)打印类别信息,以查看类别属性。下面的命令打印出mod目录中的module-info.class文件信息,显示模块版本和主类名不存在。显示了部分输出:

C:\JavaFun>javap -verbose mod\jdojo.intro\module-info.class
Classfile /C:/JavaFun/mod/jdojo.intro/module-info.class
  Last modified Jul 23, 2021; size 154 bytes
  MD5 checksum 2e4a3e6b8b8b03c92fdede9a5784b1d7
  Compiled from "module-info.java"
module jdojo.intro
...

下面的命令打印来自lib\com.jdojo.intro.jar文件的module-info.class文件信息,并显示模块版本和主类名确实存在。显示了部分输出。输出中的相关行以粗体显示:

C:\JavaFun>javap -verbose jar:file:lib/com.jdojo.intro.jar!/module-info.class
Classfile jar:file:lib/com.jdojo.intro.jar!/module-info.class
  Last modified Jul 24, 2021; size 263 bytes
  MD5 checksum 60f5f169a580f02fa8085fd36e50c0e5
  Compiled from "module-info.java"
module jdojo.intro@1.0
...
   #8 = Utf8               ModuleMainClass
   #9 = Utf8               com/jdojo/intro/Welcome
  #10 = Class              #9             // com/jdojo/intro/Welcome
...
  #14 = Utf8               1.0
  ...
ModulePackages:
  #7                                      // com.jdojo.intro
ModuleMainClass: #10                      // com.jdojo.intro.Welcome
Module:
  #13,0                                   // "jdojo.intro"
  #14                                     // 1.0
 ...

您也可以在模块中反汇编类的代码。您需要指定模块路径、模块名称和类的完全限定名。以下命令从模块化 JAR 中打印出com.jdojo.intro.Welcome类的代码:

C:\JavaFun>javap --module-path lib --module jdojo.intro com.jdojo.intro.Welcome
Compiled from "Welcome.java"
public class com.jdojo.intro.Welcome {
  public com.jdojo.intro.Welcome();
  public static void main(java.lang.String[]);
}

您还可以打印系统分类的分类信息。以下命令打印来自java.base模块的java.lang.Object类的类信息。请注意,在打印系统类信息时,不需要指定模块路径:

C:\JavaFun>javap --module java.base java.lang.Object
Compiled from "Object.java"
public class java.lang.Object {
  public java.lang.Object();
  public final native java.lang.Class<?> getClass();
  public native int hashCode();
  public boolean equals(java.lang.Object);
  ...
}

如何打印系统模块的模块声明,比如java.basejava.sql?回想一下,系统模块是以一种叫做 JIMAGE 的特殊文件格式打包的,而不是模块化的 jar。JDK 9 引入了一个新的 URL 方案,称为jrt ( jrt是 Java 运行时的缩写),用来引用 Java 运行时映像(或系统模块)的内容。使用jrt方案的语法是

jrt:/<module>/<path-to-a-file>

以下命令打印名为java.sql的系统模块的模块声明:

C:\JavaFun>javap jrt:/java.sql/module-info.class
Compiled from "module-info.java"
module java.sql@17 {
  requires transitive java.logging;
  requires transitive java.xml;
  requires java.base;
  exports javax.transaction.xa;
  exports javax.sql;
  exports java.sql;
  uses java.sql.Driver;
}

以下命令打印java.se的模块声明,它是一个聚合器模块:

C:\JavaFun>javap jrt:/java.se/module-info.class
Compiled from "module-info.java"
module java.se@17 {
  requires transitive java.naming;
  requires transitive java.instrument;
  requires transitive java.compiler;
  requires transitive java.sql.rowset;
  requires transitive java.logging;
  requires transitive java.management.rmi;
  requires transitive java.desktop;
  requires transitive java.rmi;
  requires transitive java.datatransfer;
  requires transitive java.prefs;
  requires transitive java.xml.crypto;
  requires transitive java.sql;
  requires transitive java.xml;
  requires transitive java.security.sasl;
  requires transitive java.scripting;
  requires transitive java.management;
  requires java.base;
  requires transitive java.security.jgss;
}

您也可以使用jrt方案来引用一个系统类。以下命令打印java.base模块中java.lang.Object类的类信息:

C:\JavaFun>javap jrt:/java.base/java/lang/Object.class
Compiled from "Object.java"
public class java.lang.Object {
  public java.lang.Object();
  public final native java.lang.Class<?> getClass();
  public native int hashCode();
  public boolean equals(java.lang.Object);
  ...
}

摘要

简单来说,一个模块就是一组包。一个模块可以有选择地包含诸如图像、属性文件等资源。如果一个模块需要使用另一个模块中包含的公共类型,第二个模块需要导出包含这些类型的包,第一个模块需要读取第二个模块。

模块使用exports语句导出它的包。模块只能将其包导出到一组命名模块或所有其他模块。在编译时和运行时,导出包中的公共类型可供其他模块使用。导出的包不允许对公共类型的非公共成员进行深度反射。

如果一个模块希望允许其他模块使用反射访问所有类型的成员——公共的和非公共的——那么该模块必须声明为开放模块,或者该模块可以使用opens语句有选择地打开包。从打开的包中访问类型的模块不需要读取包含那些打开的包的模块。

一个模块使用requires语句声明了对另一个模块的依赖。使用transitive修饰符可以声明这种依赖是可传递的。如果模块M声明了对模块N的传递依赖,那么任何声明了对模块M的依赖的模块都声明了对模块N的隐式依赖。

通过在requires语句中使用static修饰符,可以在编译时将依赖声明为强制的,但在运行时声明为可选的。依赖关系在运行时可以是可选的,同时也是可传递的。

根据模块是如何声明的以及它是否有名字,模块有几种类型。根据模块是否有名字,模块可以是命名的模块未命名的模块。当一个模块有一个名字时,这个名字可以在模块声明中显式给出,也可以自动(或隐式)生成。如果在模块声明中显式地给出了这个名字,它就被称为显式模块。如果在 JAR 清单的“Automatic-Module-Name”属性中指定了该名称,或者该名称是由模块系统通过读取模块路径上的 JAR 文件名生成的,则该名称被称为自动模块。如果你声明一个没有使用open修饰符的模块,它被称为普通模块。如果你使用open修饰符声明一个模块,它被称为开放模块。基于这些定义,开放模块也是显式模块和命名模块。自动模块是命名模块,因为它有一个自动生成的名称,但它不是显式模块,因为它是由模块系统在编译时和运行时隐式声明的。

当您在模块路径上放置一个 JAR(不是模块化 JAR)时,JAR 表示一个自动模块,其名称在 JAR 清单的“Automatic-Module-Name”属性中指定,或者从 JAR 文件名中派生。自动模块读取所有其他模块,并且它的所有包都被导出和打开。

在 JDK 9+中,类装入器可以从模块或类路径装入类。每个类装入器都维护一个名为未命名模块的模块,该模块包含它从类路径装入的所有类型。未命名的模块读取所有其他模块。它向所有其他模块导出并打开它的所有包。未命名模块没有名称,因此显式模块不能声明对未命名模块的编译时依赖。如果显式模块需要访问未命名模块中的类型,前者可以使用自动模块作为桥梁,或者使用反射。

您可以创建一个不包含自己代码的模块。它收集并重新导出其他模块的内容。这样的模块被称为聚合器 模块。一个聚合器模块只包含一个类文件,那就是module-info.class。聚合器模块的模块声明由所有的requires transitive <module>语句组成。

不允许将包分割成多个模块*。也就是说,不能在多个模块中定义同一个包。如果两个名为MN的模块定义了同一个名为P的包,那么MN模块中的包P就不能被Q访问。换句话说,多个模块中的同一个包不能同时被一个模块读取。否则,会发生错误。*

*Java 9 提供了一组在运行时与模块一起工作的类和接口。它们统称为模块 API。模块 API 允许您查询模块信息并在运行时修改它。在运行时,模块被表示为java.lang.Module类的一个实例。你可以使用java.lang.Class<T>类的getModule()方法来获取一个类型的模块的引用。

您可以使用javap工具来打印模块声明或属性。使用工具的-verbose(或-v)选项打印模块描述符的类属性。JDK 以特殊的格式存储运行时映像。JDK 9 引入了一个叫做jrt的新文件模式,你可以用它来访问运行时映像的内容。它的语法是jrt:/<module>/<path-to-a-file>

EXERCISES

  1. 什么是模块?

  2. 你用什么关键字来声明一个模块?

  3. 指定模块名的规则是什么?以下哪些模块名称是有效的?

    jdojo.dashboard
    $jdojo.$dashboard
    jdojo.policy.1.0
    javaFundamentals
    
    
  4. 列出仅在模块声明中的特定位置使用时才被视为关键字的所有受限关键字。

  5. 您使用什么模块语句将包导出到所有其他模块或一组命名模块?

  6. Consider the following declaration for a module named jdojo.core:

    module jdojo.core {
        exports com.jdojo.core to jdojo.ext, jdojo.util;
    }
    
    

    解释这个模块声明中exports语句的作用。在编译jdojo.core模块时,jdojo.extjdojo.util这两个模块必须存在吗?

  7. 你用什么模块语句来表达一个模块对另一个模块的依赖?什么是传递依赖,使用传递依赖有什么好处?

  8. Consider the following declaration for a module named jdojo.ext:

    module jdojo.ext {
        requires jdojo.core;
    }
    
    

    jdojo.ext读取的是哪两个模块?

  9. 如何表达一个模块对另一个模块的依赖,这个模块在编译时是强制的,但在运行时是可选的?

  10. 什么是开放模块?你什么时候使用开放模块?

  11. 一个开放的模块和选择性的打开一个模块的包有什么区别?为什么不能在打开的模块内部使用opens语句?

  12. Consider the following declaration for a module named jdojo.misc:

```java
module jdojo.misc {
    opens com.jdojo.misc;
    exports com.jdojo.misc;
}

```

这个模块声明有效吗?如果有效,解释打开和导出模块的同一个包的效果。

13. 你能有两个包含相同包的模块吗?描述禁止两个模块拥有相同包的确切规则。

  1. 什么是自动模块?描述两种指定或导出自动模块名称的方法。

  2. 什么是未命名模块?如果你把一个模块化的 JAR 放在类路径上,那么这个模块化 JAR 中的所有类型都是未命名模块的成员吗?

  3. 什么是聚合器模块?举出 JDK 9 中的一个聚合器模块。

  4. 运行时表示模块的类的完全限定类名是什么?

  5. 如何在运行时获取一个类所属模块的引用?

  6. Consider the following snippet of code assuming that a Person class exists:

```java
Person john = new Person();
String moduleName = john./* Complete the code */;
System.out.println("Module name of Person class is " + moduleName);

```

用您的代码替换第二行中的注释,完成这段代码。这个代码片段应该打印出模块的名称,如果`Person`类是未命名模块的成员,则打印出`null`类的成员。

20. 使用jarjava工具描述一个模块时,你选择了哪个选项?

  1. 如果给你一个包含模块声明的编译代码的module-info.class文件,你将如何获得模块的源代码?换句话说,你用什么工具反汇编一个类文件,也可以是一个module-info.class文件?

  2. JDK 模块以一种叫做 JIMAGE 的内部格式存储。JDK 9 引入的访问 JDK 模块的类文件和资源的新方案的名称是什么?

  3. 使用javap命令打印java.sql模块的声明,它是一个 JDK 模块。**