Skip to content

JEP 477: Implicitly Declared Classes and Instance Main Methods (Third Preview) | 隐式声明的类和实例主方法(第三次预览)

摘要

对 Java 编程语言进行演进,以便初学者可以在无需理解为大型程序设计的语言特性的情况下编写他们的第一个程序。初学者无需使用语言的单独方言,他们可以为单类程序编写简化的声明,然后随着技能的增长无缝地扩展他们的程序以使用更高级的特性。有经验的开发人员同样可以享受简洁地编写小型程序,而无需使用为大型编程设计的构造。这是一个 预览语言特性

历史

隐式声明的类和实例 main 方法最初由 JEP 445 作为预览特性提出,并在 JDK 21 中交付。该特性由 JEP 463 再次预览,并根据反馈进行了重大更改,并在 JDK 22 中交付。

我们在此提议第三次预览该特性,并添加两项内容:

  • 隐式声明的类自动导入三个用于与控制台进行简单文本 I/O 的 static 方法。这些方法在新的顶级类 java.io.IO 中声明。
  • 隐式声明的类自动按需导入 java.base 模块导出的包中的所有公共顶级类和接口。

目标

  • 为 Java 编程提供一个平滑的入门途径,以便教师可以逐步介绍概念。
  • 帮助学生以简洁的方式编写基本程序,并随着技能的增长优雅地扩展他们的代码。
  • 减少编写其他类型的小型程序(如脚本和命令行实用程序)的繁琐程度。
  • 不引入 Java 语言的单独方言。
  • 不引入单独的工具链;小型 Java 程序应该使用与大型程序相同的工具进行编译和运行。

动机

Java 编程语言在由大型团队多年开发和维护的大型、复杂应用程序中表现出色。它具有丰富的数据隐藏、重用、访问控制、命名空间管理和模块化特性,允许组件在独立开发和维护的同时进行干净地组合。有了这些特性,组件可以为与其他组件的交互公开定义良好的接口,同时隐藏内部实现细节,以便允许每个组件独立演进。实际上,面向对象范式本身就是为了将通过定义良好的协议进行交互并抽象出实现细节的部分组合在一起而设计的。这种大型组件的组合被称为“大型编程”。

然而,Java 编程语言也旨在成为第一门语言。当程序员刚开始时,他们不会在团队中编写大型程序——他们独自编写小型程序。他们不需要封装和命名空间,这对于分别演进由不同人编写的组件很有用。在教授编程时,教师从变量、控制流和子例程等基本的“小型编程”概念开始。在那个阶段,不需要类、包和模块等“大型编程”概念。使语言对新手更友好符合 Java 老手的利益,但他们也可能喜欢更简洁地编写小型程序,而无需任何“大型编程”构造。

考虑经典的 “Hello, World!” 示例,这通常是初学者的第一个程序:

java
public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

对于这个程序所做的事情来说,这里有太多的杂乱——太多的代码、太多的概念和太多的构造。

  • class 声明和强制的 public 访问修饰符是“大型编程”构造。当封装具有与外部组件定义良好的接口的代码单元时它们很有用,但在这个小例子中毫无意义。
  • String[] args 参数也是为了使代码与外部组件(在这种情况下是操作系统的外壳)进行接口。在这里它很神秘且没有帮助,特别是因为在像 HelloWorld 这样的小型程序中它没有被使用。
  • static 修饰符是语言的类和对象模型的一部分。对于初学者来说,static 不仅神秘而且有害:要向这个程序添加更多方法或字段,初学者必须要么将它们全部声明为 static——从而传播一种既不常见也不是好习惯的习惯——要么面对静态和实例成员之间的差异并学习如何实例化一个对象。
  • 初学者可能会对神秘的咒语 System.out.println 感到更加困惑,并想知道为什么一个简单的函数调用不够。即使在第一周的程序中,初学者可能也被迫学习如何导入基本的实用类以获得基本功能,并想知道为什么它们不能自动提供。

新程序员在最糟糕的时候遇到这些概念,在他们学习变量和控制流之前,并且当他们无法欣赏“大型编程”构造对于保持大型程序良好组织的实用性时。教师经常发出警告,“别担心那个,你以后会理解的。”这对他们和他们的学生来说都不令人满意,并给学生留下语言很复杂的持久印象。

这项工作的动机不仅仅是减少繁琐。我们的目标是帮助刚接触 Java 语言或一般编程的程序员以一种按正确顺序引入概念的方式学习语言:从基本的“小型编程”概念开始,例如进行简单的文本 I/O 和使用 for 循环处理数组,并仅在“大型编程”概念实际上有益且更容易理解时才进行介绍。

此外,这项工作的动机不仅是为了帮助初学者。我们的目标是帮助所有编写小型程序的人,无论是学生、编写命令行实用程序的系统管理员,还是最终将在企业级软件系统核心中使用的原型核心算法的领域专家。

我们提议通过隐藏细节直到它们有用而不是改变 Java 语言的结构来使编写小型程序更容易——代码仍然封装在方法中,方法封装在类中,类封装在包中,包封装在模块中。我们提供一个入门途径,即一个逐渐倾斜并优雅地融入高速公路的斜坡。当初学者转向更大的程序时,他们无需丢弃在早期阶段学到的东西,而是看到它如何融入更大的图景。当有经验的开发人员从原型转向生产时,他们可以将他们的代码平滑地扩展为更大程序的组件。

描述

首先,我们增强 Java 程序启动的协议以允许“实例 main 方法”。这样的方法不是 static 的,不需要是 public 的,也不需要有 String[] 参数。然后我们可以将“Hello, World!”程序简化为:

java
class HelloWorld {
    void main() {
        System.out.println("Hello, World!");
    }
}

其次,我们允许一个 编译单元,即一个源文件,“隐式声明”一个类:

java
void main() {
    System.out.println("Hello, World!");
}

第三,在隐式声明的类中,我们自动导入用于文本输入和输出的有用方法,从而避免神秘的 System.out.println

java
void main() {
    println("Hello, World!");
}

最后,对于超越“Hello, World!”并且需要例如基本数据结构或文件 I/O 的程序,在隐式声明的类中,我们自动按需导入 java.base 模块导出的包中的所有公共顶级类和接口。

这是一个 预览语言特性,默认情况下禁用。

要在 JDK 23 中尝试下面的示例,必须启用预览特性:

  • 使用 javac --release 23 --enable-preview Main.java 编译程序,并使用 java --enable-preview Main 运行它;或者,
  • 当使用 源代码启动器 时,使用 java --enable-preview Main.java 运行程序;或者,
  • 当使用 jshell 时,使用 jshell --enable-preview 启动它。

启动协议

新程序员只是想编写并运行一个计算机程序。然而,《Java 语言规范》(JLS)主要关注定义一个“编译单元”的含义,即一个带有 package 声明、import 声明和 class 声明的源文件。JLS 对于 Java“程序”所说的全部内容是 这个

Java 虚拟机通过调用某个指定类或接口的方法 main 开始执行,向它传递一个参数,该参数是一个字符串数组。

JLS 进一步说:

将包含 main 方法的初始类或接口指定给 Java 虚拟机的方式超出了本规范的范围,但在使用命令行的主机环境中,通常将类或接口的完全限定名作为命令行参数指定,并将后续的命令行参数用作字符串作为传递给方法 main 的参数。

选择包含 main 方法的类、以模块路径或类路径(或两者)的形式组装其依赖项、加载类、初始化它并使用其参数调用 main 方法的这些动作构成了“启动协议”。在 JDK 中,它由“启动器”即 java 可执行文件实现。

灵活的启动协议

我们增强启动协议以在程序入口点的声明中提供更多灵活性,特别是允许“实例”main 方法,如下所示:

  • 允许启动的类的 main 方法具有 publicprotected 或默认(即包)访问权限。
  • 如果启动的类包含带有 String[] 参数的 main 方法,则选择该方法。
  • 否则,如果类包含没有参数的 main 方法,则选择该方法。
  • 在任何一种情况下,如果选择的方法是 static,则直接调用它。
  • 否则,选择的方法是实例方法,并且启动的类必须有一个零参数、非 private 的构造函数(即具有 publicprotected 或包访问权限)。调用该构造函数,然后调用所得到的对象的 main 方法。如果没有这样的构造函数,则报告错误并终止。
  • 如果没有合适的 main 方法,则报告错误并终止。

这些更改允许我们编写“Hello, World!”时没有访问修饰符、没有 static 修饰符且没有 String[] 参数,因此可以推迟引入这些构造,直到它们被需要:

java
class HelloWorld {
    void main() {
        System.out.println("Hello, World!");
    }
}

隐式声明的类

在 Java 语言中,每个类都位于一个包中,每个包都位于一个模块中。这些命名空间和封装构造适用于所有代码,但不需要它们的小型程序可以省略它们。一个不需要类命名空间的程序可以省略 package 语句,使其类成为未命名包的隐式成员;未命名包中的类不能被命名包中的类显式引用。一个不需要封装其包的程序可以省略模块声明,使其包成为未命名模块的隐式成员;未命名模块中的包不能被命名模块中的包显式引用。

在类作为对象构造的模板发挥其主要作用之前,它们仅作为方法和字段的命名空间。在初学者对变量、控制流和子例程等更基本的构建块感到舒适之前,在他们开始学习面向对象之前,以及当他们仍在编写简单的单文件程序时,我们不应该要求他们面对类的概念。即使每个方法都位于一个类中,对于不需要它的代码,我们可以不再要求显式的类声明——就像对于不需要它们的代码我们不要求显式的包或模块声明一样。

从今以后,如果 Java 编译器遇到一个源文件,其中的方法没有被包含在类声明中,那么它将把该方法、任何其他这样的方法以及任何未封闭的字段和文件中的任何类视为一个“隐式声明”的类的主体。

一个隐式声明的类(或简称“隐式类”)是一个 final 的顶级类。它扩展 Object 并且不实现任何接口。隐式类不能通过名称引用,所以不能对其静态方法进行 方法引用;然而,this 关键字仍然可以使用,对实例方法的方法引用也可以使用。

隐式类的实例不能直接构造,因为隐式类的代码不能通过名称引用该类。这样的类仅在作为独立程序或作为程序的入口点时有用。因此,隐式类必须有一个可以按照 上面的灵活启动协议部分 所描述的方式启动的 main 方法。这个要求由 Java 编译器强制执行。

隐式类位于未命名包中,未命名包位于未命名模块中。虽然只能有一个未命名包(除非有多个类加载器)并且只能有一个未命名模块,但未命名模块中可以有多个隐式类。每个隐式类都包含一个 main 方法,因此代表一个程序,所以未命名包中的多个这样的类代表多个程序。

隐式类几乎与显式声明的类完全相同。它的成员可以有相同的修饰符(例如 privatestatic),并且修饰符具有相同的默认值(例如包访问权限和实例成员资格)。一个关键区别是,虽然隐式类有一个默认的零参数构造函数,但它不能有其他构造函数。

有了这些更改,我们现在可以将“Hello, World!”写成:

java
void main() {
    System.out.println("Hello, World!");
}

顶级成员被解释为隐式类的成员,所以我们也可以将程序写成:

java
String greeting() { return "Hello, World!"; }

void main() {
    System.out.println(greeting());
}

或者,使用一个字段,写成:

java
String greeting = "Hello, World!";

void main() {
    System.out.println(greeting);
}

如果一个隐式类有一个实例 main 方法而不是一个 staticmain 方法,那么启动它等同于将它嵌入到一个 匿名类声明 中:

java
new Object() {
    // 隐式类的主体
}.main();

一个名为 HelloWorld.java 的包含隐式类的源文件可以使用源代码启动器启动,如下所示:

shell
$ java HelloWorld.java

Java 编译器将把该文件编译为类文件 HelloWorld.class,然后启动器将启动那个文件。编译器选择 HelloWorld 作为类名是一个实现细节,但该名称仍然不能在源代码中直接使用。

javadoc 工具可以为隐式类生成文档,即使隐式类不能被其他类通过名称引用,因此不能用于定义 API。然而,为隐式类的成员生成文档的能力可能对初学者学习为他们的代码编写文档以及有经验的开发人员为在更大的程序中使用而原型化代码或为源代码启动器执行编写可重用脚本都很有用。

与控制台交互

许多初学者程序需要与控制台交互。向控制台写入应该是一个简单的方法调用,但实际上它需要使用限定名称 System.out.println。这对有经验的开发人员来说有点麻烦,但对初学者来说非常神秘:什么是 System,什么是 out,那些点是干什么的?

更糟糕的是从控制台读取,同样,它应该是一个简单的方法调用。由于向控制台写入涉及 System.out,似乎合理的是尝试从 System.in 读取。但是从 System.in 读取一个 String 需要所有这些代码:

java
try {
    BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
    String line = reader.readLine();
   ...
} catch (IOException ioe) {
   ...
}

有经验的开发人员习惯了这种样板代码,但对初学者来说,这段代码包含更多神秘的概念,导致大量问题:trycatch 是干什么的,为什么既有 BufferedReader 又有 InputStreamReader,什么是 IOException?还有其他方法,但没有一个明显更好,特别是对初学者来说。

为了简化交互式小型程序的编写,我们在每个隐式类的主体中提供三个方法以供使用:

java
public static void println(Object obj);
public static void print(Object obj);
public static String readln(String prompt);

初学者现在可以将“Hello, World!”写成:

java
void main() {
    println("Hello, World!");
}

然后他们可以很容易地继续编写最简单的交互式程序:

java
void main() {
    String name = readln("Please enter your name: ");
    print("Pleased to meet you, ");
    println(name);
}

我们通过在 java.io 包中声明一个新的顶级类来实现这个效果,这个类简单地命名为 IO。它声明了上述三个用于与控制台进行文本 I/O 的 static 方法,没有其他内容。每个隐式声明的类自动导入这些 static 方法,就好像声明

java
import static java.io.IO.*;

出现在每个包含隐式类的源文件的开头一样。

新的类 java.io.IO 在 JDK 23 中是一个预览 API。

java.base 模块的自动导入

在 Java API 中声明的许多其他类在小型程序中也很有用。当然,它们可以在源文件的开头显式导入:

java
import java.util.List;

void main() {
    var authors = List.of("James", "Bill", "Guy", "Alex", "Dan", "Gavin");
    for (var name : authors) {
        println(name + ": " + name.length());
    }
}

有经验的开发人员会觉得这很自然,尽管为了方便,有些人可能倾向于使用按需导入声明(即 import java.util.*)。然而,对于初学者来说,任何形式的 import 都是另一个神秘的来源,需要理解 Java API 的包层次结构。

为了进一步简化小型程序的编写,我们使 java.base 模块导出的包中的所有公共顶级类和接口在每个隐式类的主体中都可用,就好像它们是按需导入的一样。因此,常用包(如 java.iojava.mathjava.util)中的流行 API 可以立即使用,无需任何麻烦。在上面的例子中,import java.util.List 声明可以删除,因为该接口将自动导入。

JEP 476,“模块导入声明”,提出了一个新的导入声明,import module M,它按需导入模块 M 导出的包中的所有公共顶级类和接口。因此,每个隐式声明的类可以被认为是隐式地导入了 java.base 模块,就好像声明

java
import module java.base;

出现在每个包含隐式类的源文件的开头一样。

扩展程序

作为隐式类编写的小型程序更加专注于程序实际要做的事情,省略了它不需要的概念和构造。即便如此,所有成员的解释方式都与普通类中的成员相同。要将隐式类扩展为普通类,我们只需要将其声明(不包括任何 import 声明)包裹在显式的 class 声明中,并添加自动导入。例如,这个隐式类:

java
void main() {
    var authors = List.of("James", "Bill", "Guy", "Alex", "Dan", "Gavin");
    for (var name : authors) {
        println(name + ": " + name.length());
    }
}

可以扩展为这个顶级类:

java
import static java.io.IO.*;
import java.util.List;          // 或者:import module java.base;

class NameLengths {
    void main() {
        var authors = List.of("James", "Bill", "Guy", "Alex", "Dan", "Gavin");
        for (var name : authors) {
            println(name + ": " + name.length());
        }
    }
}

main 方法没有任何改变。将小型程序转换为可以在更大程序中作为组件的程序非常简单。

完全删除 main 方法似乎是下一个自然的步骤,但这会违背将小型 Java 程序优雅地扩展为更大程序的目标,并会施加一些不明显的限制(见 下面的“局部变量”部分)。同样,删除 void 方法的结果会创建一种不同的 Java 方言。

替代方案

  • 使用 JShell 编写小型程序——JShell 会话不是一个程序,而是一系列代码片段。输入到 jshell 中的声明被隐式地视为某个未指定类的静态成员,具有一些未指定的访问级别,并且语句在一个上下文中执行,其中所有先前的声明都在作用域内。

    这对于实验很方便——这是 JShell 的主要用例——但不是编写小型程序的好模型。将 JShell 中的一批有效的声明转换为真正的程序会导致一种非习惯的代码风格,因为它将每个方法、类和变量声明为 static。JShell 是一个很棒的探索和调试工具,但它不是我们正在寻找的入门编程模型。

  • 将代码单元解释为静态成员——方法和字段默认情况下是非 static 的。将隐式类中的顶级成员解释为 static 会改变此类中代码单元的含义——实际上引入了一种不同的 Java 方言。为了在将隐式类扩展为普通类时保留这些成员的含义,我们必须添加显式的 static 修饰符。当我们从少数几个方法扩展到一个简单类时,我们希望将类用作类,而不仅仅是静态成员的容器。

  • 将代码单元解释为局部变量——我们已经可以在方法中声明局部变量。假设我们也可以声明局部方法,即其他方法中的方法。然后我们可以将小型程序的主体解释为 main 方法的主体,将变量解释为局部变量而不是字段,将方法解释为局部方法而不是类成员。这将允许我们完全省略 main 方法并编写顶级语句。

    这种方法的问题是,在 Java 语言中,局部变量的行为与字段不同,并且以一种更受限制的方式:局部变量只有在它们是 有效最终的 时才能从 lambda 表达式体或内部类中访问。所提出的设计允许我们以与以往相同的方式将局部变量和字段分开。即使对于初学者来说,编写 main 方法的负担也不大。

  • 引入包级方法和字段——我们可以通过允许在没有显式的 packageclass 声明的文件中声明包级方法和字段来实现与上述类似的用户体验。然而,这样的特性将对 Java 代码的一般编写方式产生更广泛的影响。

  • 不同的自动导入——与其让隐式类按需导入 java.base 模块中的所有 54 个包,我们可以让它们导入这些包的一个子集。但是,应该是哪个子集呢?

    每个读者对于应该自动导入到每个小型程序中的包都有建议:java.iojava.util 将是几乎普遍的建议;java.util.streamjava.util.function 将很常见;而 java.mathjava.netjava.time 每个都有支持者。对于 JShell 工具,我们设法找到了十个 java.* 包,在尝试一次性 Java 代码时非常有用,但很难确定 java.* 包的哪个子集应该永久自动地导入到每个小型程序中。此外,随着 Java 平台的发展,这个列表会发生变化;例如,java.util.streamjava.util.function 仅在 Java 8 中引入。开发人员可能会依赖于 IDE 来提醒他们哪些自动导入是有效的——这是一个不理想的结果。

    对于隐式类来说,导入 java.base 模块导出的所有包是一个一致且合理的选择。