Skip to content

JEP 445: Unnamed Classes and Instance Main Methods (Preview) | 未命名类和实例主方法(预览)

摘要

对 Java 语言进行演进,以便学生在无需理解为大型程序设计的语言特性的情况下编写他们的第一个程序。学生们无需使用单独的 Java 方言,他们可以为单类程序编写简化的声明,然后随着技能的增长无缝地扩展他们的程序以使用更高级的特性。这是一个 预览语言特性

目标

  • 为 Java 提供一个平稳的入门途径,以便教育工作者可以逐步引入编程概念。

  • 帮助学生以简洁的方式编写基本程序,并随着技能的增长优雅地扩展他们的代码。

  • 减少编写简单程序(如脚本和命令行实用程序)的繁琐性。

  • 不引入单独的 Java 初学者方言。

  • 不引入单独的初学者工具链;学生程序应该使用与编译和运行任何 Java 程序相同的工具进行编译和运行。

动机

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

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

考虑经典的 “Hello, World!” 程序,它经常被用作 Java 学生的第一个程序:

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

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

  • class 声明和强制的 public 访问修饰符是大规模编程构造。当封装一个具有与外部组件定义良好的接口的代码单元时,它们很有用,但在这个小例子中却毫无意义。

  • String[] args 参数也是为了使代码与外部组件(在这种情况下是操作系统的外壳)进行交互而存在的。在这里它很神秘且没有帮助,特别是因为它从未被使用过。

  • static 修饰符是 Java 的类和对象模型的一部分。对于新手来说,static 不仅神秘而且有害:要添加更多 main 可以调用和使用的方法或字段,学生必须要么将它们全部声明为 static——从而传播一种既不常见也不是好习惯的习惯——要么面对静态和实例成员之间的差异,并学习如何实例化一个对象。

新程序员在最糟糕的时候遇到这些概念,在他们学习变量和控制流之前,并且当他们无法体会到大规模编程构造对于保持大型程序良好组织的实用性时。教育工作者经常告诫说:“别担心那个,你以后会明白的。”这对他们和他们的学生来说都不令人满意,并给学生留下了 Java 很复杂的持久印象。

这个 JEP 的动机不仅仅是减少繁琐性,而是帮助新接触 Java 或一般编程的程序员以一种按正确顺序引入概念的方式学习 Java:从基本的小规模编程概念开始,然后在实际有益且更容易理解的时候进行到高级的大规模编程概念。

我们提议不是通过改变 Java 语言的结构来实现这一点——代码仍然封装在方法中,方法封装在类中,类封装在包中,包封装在模块中——而是通过隐藏这些细节,直到它们在较大的程序中有用。我们提供一个入门途径,一个逐渐上升的斜坡,优雅地并入高速公路。当学生转向较大的程序时,他们不必丢弃在早期阶段学到的东西,而是看到它如何在更大的图景中适应。

我们在这里提供的更改只是使 Java 更容易学习的一步。它们甚至没有解决上述“Hello, World!”程序中的所有障碍:初学者可能仍然对神秘的 System.out.println 咒语感到困惑,并且在第一周的程序中仍然需要导入基本的实用类和方法以获得基本功能。我们可能在未来的 JEP 中解决这些问题。

描述

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

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

其次,我们引入 未命名类 以使 class 声明隐含:

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

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

要在 JDK 21 中尝试以下示例,您必须按如下方式启用预览特性:

  • 使用 javac --release 21 --enable-preview Main.java 编译程序,并使用 java --enable-preview Main 运行它;或者,

  • 当使用 源代码启动器 时,使用 java --source 21 --enable-preview Main.java 运行程序。

启动协议

新程序员想要编写和运行一个计算机程序,但 《Java 语言规范》 侧重于定义类的核心 Java 单元和基本编译单元,即由一个 package 声明开头,接着是一些 import 声明,然后是一个或多个 class 声明组成的源文件。它对 Java程序 所说的只是 这个

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

JLS 进一步说:

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

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

灵活的启动协议

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

  • 允许启动的类的 main 方法具有 publicprotected 或默认(即包)访问权限。

  • 如果启动的类不包含带有 String[] 参数的 static main 方法,但确实包含一个没有参数的 static main 方法,那么调用那个方法。

  • 如果启动的类没有 static main 方法,但有一个非 private 的零参数构造函数(即具有 publicprotected 或包访问权限),以及一个非 private 的实例 main 方法,那么构造该类的一个实例。如果该类有一个带有 String[] 参数的实例 main 方法,那么调用那个方法;否则,调用没有参数的实例 main 方法。

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

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

选择主方法

在启动一个类时,启动协议会选择以下方法中的第一个进行调用:

  1. 在启动的类中声明的具有非私有访问权限(即 publicprotected 或包级)的 static void main(String[] args) 方法;
  2. 在启动的类中声明的具有非私有访问权限的 static void main() 方法;
  3. 在启动的类中声明或从超类继承的具有非私有访问权限的 void main(String[] args) 实例方法;或者最后,
  4. 在启动的类中声明或从超类继承的具有非私有访问权限的 void main() 实例方法。

请注意,这是行为的改变:如果启动的类声明了一个实例主方法,那么该方法将被调用,而不是超类中声明的继承的“传统”public static void main(String[] args) 方法。因此,如果启动的类继承了一个“传统”的主方法,但选择了另一个方法(即一个实例 main 方法),JVM 将在运行时向标准错误输出一个警告。

如果选择的 main 是一个实例方法并且是一个内部类的成员,程序将无法启动。

未命名类

在 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 方法而不是一个 static 主方法,那么启动它等同于以下内容,它使用现有的 匿名类声明 构造:

java
new Object() {
    // 未命名类的主体
}.main();

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

shell
$ java HelloWorld.java

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

当被要求为一个包含未命名类的 Java 文件生成 API 文档时,javadoc 工具将失败,因为未命名类没有定义任何可从其他类访问的 API。这个行为可能在未来的版本中改变。

Class.isSynthetic 方法对于未命名类返回 true

扩展程序

一个作为未命名类编写的“Hello, World!”程序更加专注于程序实际做的事情,省略了它不需要的概念和构造。即便如此,所有成员都像在普通类中一样被解释。要将一个未命名类演变为一个普通类,我们只需要将它的声明(不包括 import 语句)包裹在一个显式的 class 声明中。

完全消除 main 方法似乎是自然的下一步,但这将违背将第一个 Java 程序优雅地演变为一个更大程序的目标,并且会强加一些不明显的限制(见 下面)。删除 void 修饰符同样会创建一个不同的 Java 方言。

替代方案

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

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

  • 将代码单元解释为静态成员——方法和字段默认情况下是非 static 的。将未命名类中的顶级成员解释为 static 将改变这样一个类中的代码单元的意义——实际上引入了一个不同的 Java 方言。为了在我们将未命名类演变为普通类时保留这些成员的意义,我们将不得不添加显式的 static 修饰符。这不是我们从少量方法扩展到一个简单类时想要的。我们想要开始将类用作类,而不是静态成员的容器。

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

    这种方法的问题是,在 Java 中,局部变量的行为与字段不同,并且以一种更受限制的方式:当局部变量是 实际上是最终的 时,它们只能在 lambda 表达式体或内部类中被访问。所提出的设计允许我们以与在 Java 中一直以来的方式相同的方式分离局部变量和字段。即使对于新学生来说,编写一个 main 方法的负担也不是繁重的。

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