跳到主要内容

JEP 477:隐式声明的类和实例主方法(第三次预览)

QWen Max 中英对照 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! 示例,这通常是初学者的第一个程序:

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

这里的杂乱内容太多了——相对于程序的功能而言,代码太多、概念太多、结构也太多。

  • class 声明和强制性的 public 访问修饰符是大型编程结构的一部分。当需要将一个代码单元封装并提供明确的外部组件接口时,它们非常有用,但在这样一个小例子中却毫无意义。

  • 参数 String[] args 的存在也是为了与外部组件(在这里是操作系统 shell)进行交互。然而在这个例子中它显得神秘且无用,尤其是在像 HelloWorld 这样的小程序中根本不会用到它。

  • static 修饰符是语言中类与对象模型的一部分。对于初学者来说,static 不仅神秘,而且有害:如果想在这个程序中添加更多方法或字段,初学者要么必须将它们全部声明为 static —— 这样会传播一种既不常见也不良好的习惯 —— 要么就必须面对静态成员与实例成员的区别,并学习如何实例化一个对象。

  • 初学者可能还会对神秘的咒语般的 System.out.println 感到困惑,并疑惑为什么一个简单的函数调用不能满足需求。即使是在第一周的程序中,初学者也可能被迫学习如何导入基本的实用工具类来实现关键功能,并疑惑为什么这些功能不能自动提供。

新的程序员在最糟糕的时间遇到了这些概念,那时他们还没有学习变量和控制流,无法理解大规模编程结构在保持大型程序良好组织方面的实用性。讲师们常常告诫说:“别担心那个,你以后会明白的。” 这让他们和他们的学生都不满意,并使学生对这门语言留下了复杂难懂的持久印象。

这项工作的动机不仅仅是减少仪式感。我们的目标是帮助刚接触 Java 语言或编程的新手程序员,以正确的顺序学习语言中的概念:从细粒度编程的基本概念开始,例如执行简单的文本 I/O 和使用 for 循环处理数组,只有在确实有益且更容易掌握时,才继续学习大粒度编程的高级概念。

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

我们建议通过隐藏细节直至它们有用,而不是通过改变 Java 语言的结构 —— 代码仍然封装在方法中,方法封装在类中,类封装在包中,包封装在模块中 —— 来使编写小型程序变得更加容易。我们提供了一个入口匝道,即一条逐渐上升并优雅地汇入高速公路的缓坡。当初学者过渡到更大的程序时,他们不必抛弃在早期阶段学到的知识,而是看到这些知识如何融入更大的图景中。当有经验的开发者从原型设计进入到生产阶段时,他们可以将其代码平稳地扩展为更大程序的组成部分。

描述

首先,我们增强了启动 Java 程序的协议,以允许使用实例主方法。这些方法不是 static 的,不必是 public 的,也不必具有 String[] 参数。然后我们可以将 Hello, World! 程序简化为:

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

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

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

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

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 语言规范》(Java Language Specification ,JLS)的重点在于定义一个编译单元的含义,即带有 package 声明、import 声明和 class 声明的源文件。JLS 对于一个 Java 程序 所说的全部内容只有这个

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

JLS 进一步指出:

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

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

一个灵活的启动协议

我们增强了启动协议,以在程序入口点的声明上提供更大的灵活性,特别是允许 实例 main 方法,具体如下:

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

  • 如果启动类包含带有 String[] 参数的 main 方法,则选择该方法。

  • 否则,如果类包含没有参数的 main 方法,则选择该方法。

  • 无论哪种情况,如果所选方法是 static 的,则直接调用它。

  • 否则,所选方法是一个实例方法,启动类必须有一个零参数且非 private 的构造函数(即具有 publicprotected 或包级访问权限)。调用该构造函数,然后调用生成对象的 main 方法。如果没有这样的构造函数,则报告错误并终止。

  • 如果没有合适的 main 方法,则报告错误并终止。

这些变化使我们可以编写 Hello, World! 程序时无需访问修饰符、无需 static 修饰符,也无需 String[] 参数,因此可以推迟引入这些结构,直到需要时再进行讲解:

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

隐式声明的类

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

在类作为对象构造模板这一主要目的之前,它们仅仅充当方法和字段的命名空间。我们不应该要求初学者在他们还未熟练掌握变量、控制流和子程序等更基本的构建块之前,就去面对类的概念;在他们开始学习面向对象之前,以及当他们还在编写简单、单文件的程序时,也不应如此。即使每个方法都存在于一个类中,我们可以停止要求对不需要它的代码进行显式的类声明 —— 就像我们不会要求对不需要包或模块的代码进行显式的包或模块声明一样。

因此,如果 Java 编译器遇到一个源文件中包含未封装在类声明中的方法,则它会将该方法、任何其他此类方法、任何未封装的字段以及文件中的任何类视为构成一个隐式声明类的主体。

隐式声明的类(或简称为 implicit class)是一个 final 顶层类。它扩展了 Object 并且不实现任何接口。隐式类不能通过名称引用,因此不可能存在对其静态方法的方法引用;然而,仍然可以使用 this 关键字,也可以使用对实例方法的方法引用。

隐式类的实例无法直接构造,因为隐式类的代码无法通过名称引用该类。这样的类仅作为独立程序或程序的入口点才有用。因此,隐式类必须具有一个可以按上文所述启动的 main 方法。这一要求由 Java 编译器强制执行。

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

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

通过这些变化,我们现在可以编写 Hello, World! 为:

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

顶层成员被解释为隐式类的成员,因此我们也可以将程序写作:

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

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

或者,使用一个字段,如下所示:

String greeting = "Hello, World!";

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

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

new Object() {
// the implicit class's body
}.main();

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

$ java HelloWorld.java

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

javadoc 工具可以为隐式类生成文档,即使隐式类不能被其他类通过名称引用,因此无法用于定义 API。尽管如此,为隐式类的成员生成文档的能力仍然是有用的,无论是对于初学者学习如何为代码编写文档,还是对于有经验的开发者在较大程序中设计原型代码,或是编写供源代码启动器执行的可重用脚本。

与控制台交互

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

更糟糕的是从控制台读取内容,这本应是一个简单的方法调用。由于写入控制台涉及 System.out,尝试从 System.in 读取似乎也是合理的。但是,从 System.in 读取一个 String 需要编写如下代码:

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

有经验的开发者已经习惯了这些模板代码,但对于初学者来说,这段代码包含了更多神秘的概念,引发了许多问题:trycatch 是用来做什么的?为什么需要 BufferedReaderInputStreamReader 一起使用?什么是 IOException?虽然还有其他方法,但没有一种显著更好,尤其是对初学者而言。

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

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

现在,初学者可以这样编写 Hello, World!

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

然后,他们可以轻松地继续进行最简单的交互式程序:

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

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

import static java.io.IO.*;

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

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

自动导入 java.base 模块

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

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 模块,就好像声明了以下内容:

import module java.base;

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

扩展程序

一个编写为隐式类的小程序更加聚焦于程序实际所做的事情,省略了它不需要的概念和结构。即便如此,所有的成员都像在普通类中一样被解释。要将一个隐式类发展为普通类,我们只需要将其声明(排除任何 import 声明)包装在一个显式的 class 声明中,并添加自动导入即可。例如,这个隐式类:

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

可以扩展为以下顶级类:

import static java.io.IO.*;
import java.util.List; // alternatively: 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 语言中,局部变量的行为与字段不同,并且方式更加受限:局部变量只有在其 实际上是 final 的情况下才能从 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 模块导出的所有包对于隐式类来说是一个一致且合理的选择。