JEP 495: Simple Source Files and Instance Main Methods (Fourth Preview) | 简单的源文件和实例主方法(第四次预览)
摘要
使 Java 编程语言进化,让初学者可以编写他们的第一个程序而无需理解为大型程序设计的语言特性。与使用单独的方言不同,初学者可以为单类程序编写简化的声明,然后随着技能的增长无缝地扩展他们的程序以使用更高级的功能。有经验的开发人员同样可以享受简洁地编写小型程序的乐趣,而无需使用专为大规模编程设计的结构。这是 预览语言功能 的一部分。
历史
此功能最早由 JEP 445(JDK 21)提议进行预览,并随后由 JEP 463(JDK 22)和 JEP 477(JDK 23)改进和完善。我们在此提议第四次预览此功能,采用新的术语和修订后的标题,但除此之外未作更改,以便获得额外的经验和反馈。
目标
- 提供一个平滑的 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 语言或编程的新手以正确顺序学习语言:从基本的小规模编程概念开始,例如使用 for
循环进行简单的文本 I/O 和数组处理,只有在实际有益且更容易掌握的时候才进入高级的大规模编程概念。
我们的这项工作的动机还不仅是为了帮助初学编程的人。我们的目标是帮助所有编写小程序的人,无论是学生、编写命令行实用程序的系统管理员,还是原型制作将在企业级软件系统核心使用的算法的领域专家。
我们建议通过隐藏这些细节直到它们变得有用,而不是通过改变 Java 语言的结构来简化小程序的编写——代码仍然包含在方法中,方法包含在类中,类包含在包中,包包含在模块中。
描述
首先,我们允许 main
方法省略臭名昭著的 public static void main(String[] args)
样板代码,这简化了 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 时,在简单源文件中我们自动导入一系列标准 API,而不仅仅是 java.lang
包。
这些变化结合起来提供了一个 入门路径,即一个逐渐上升并优雅地与高速公路合并的斜坡。当初学者转向更大的程序时,他们不需要抛弃早期阶段学到的东西,而是看到这一切如何在大图景中融合。当有经验的开发人员从原型走向生产时,他们可以顺利地将代码扩展为更大程序的一部分。
这是一个 预览语言功能,默认情况下禁用
要在 JDK 24 中尝试以下示例,必须启用预览功能:
- 使用
javac --release 24 --enable-preview Main.java
编译程序,并使用java --enable-preview Main
运行;或者, - 当使用 源代码启动器 时,使用
java --enable-preview Main.java
运行程序;或者, - 当使用
jshell
时,以jshell --enable-preview
启动它。
实例 main
方法
为了编写和运行程序,初学者将学习到程序的 入口点。Java 语言规范(JLS)解释了 Java 程序的入口点 是一个称为 main
的方法:
Java 虚拟机通过调用某个指定类或接口中的
main
方法开始执行,传递给它一个参数,该参数是字符串数组。
JLS进一步说明:
main
方法必须声明为public
、static
和void
。它必须指定一个其声明类型为String
数组的形式参数。
对于 main
声明的要求是历史性的且不必要的。我们可以通过两种方式简化 Java 程序的入口点:允许 main
是非 static
的,并取消对 public
和数组参数的要求。这些改变允许我们编写没有 public
修饰符、没有 static
修饰符和没有 String[]
参数的 Hello, World!,将这些结构的介绍推迟到实际需要的时候:
class HelloWorld {
void main() {
System.out.println("Hello, World!");
}
}
假设此程序位于 HelloWorld.java
文件中,我们可以直接使用 源代码启动器 运行它:
$ java HelloWorld.java
启动器在内存中编译 HelloWorld.java
,然后查找并调用一个 main
方法:
- 如果
HelloWorld
类包含一个带有String[]
参数的main
方法,则启动器选择该方法。
否则,如果该类包含一个无参数的 main
方法,则启动器选择该方法。
否则,启动器报告错误并终止。
- 如果选定的方法是
static
,则启动器调用它。
否则,选定的方法是 实例 main
方法。该类必须有一个非私有的无参数构造函数。启动器调用该构造函数,然后调用结果对象的 main
方法。如果没有这样的构造函数,启动器将报告错误并终止。
根据此协议可以调用的任何 main
方法都称为 可启动main
方法。例如,HelloWorld
类有一个可启动的 main
方法,即 void main()
。
简单源文件
在 Java 语言中,每个类都位于一个包内,而每个包都位于一个模块内。模块和包为类提供了命名空间和封装,但对于由几个类组成的小程序来说,并不需要这些概念。因此,开发者可以省略包和模块声明,它们的类将存在于未命名的包中的未命名模块。
类为字段和方法提供命名空间和封装。我们不应该要求初学者在熟悉变量、控制流和子程序等基本构建块之前理解这些概念。因此,我们可以停止要求由少数字段和方法组成的小程序声明类,就像我们不要求包或模块声明一样。
因此,如果 Java 编译器遇到一个没有包含在类声明中的字段和方法的源文件,它将认为该源文件 隐式地声明 了一个其成员是那些未被包裹的字段和方法的类。这样的源文件被称为 简单 源文件。
通过这项改动,我们可以编写一个 Hello, World! 作为简单的源文件:
void main() {
System.out.println("Hello, World!");
}
简单源文件的隐式声明类
- 是未命名包中的
final
顶层类; - 继承自
java.lang.Object
且不实现任何接口; - 有一个无参数的默认构造函数,且没有其他构造函数;
- 其成员是简单源文件中的字段和方法;并且
- 必须有一个可启动的
main
方法;如果没有,则会报告编译时错误。
由于在简单源文件中声明的字段和方法被视为隐式声明类的成员,我们可以通过调用附近声明的方法来编写 Hello, World!:
String greeting() { return "Hello, World!"; }
void main() {
System.out.println(greeting());
}
或者通过访问字段:
String greeting = "Hello, World!";
void main() {
System.out.println(greeting);
}
由于简单源文件声明的是一个隐式的类,该类没有可以在代码中使用的名称。我们可以通过 this
引用此类的当前实例,既可以直接使用也可以如上所述隐式使用,但我们不能用 new
实例化这个类。这反映了重要的权衡:如果初学者还没有学习面向对象的概念,比如类,那么编写简单源文件中的代码不应该需要声明一个类——这正是会给类一个可以用 new
使用的名称的做法。
假设我们的简单源文件名为 HelloWorld.java
,我们可以直接使用源码启动器运行它:
$ java HelloWorld.java
启动器会在内存中编译 HelloWorld.java
,将其字段和方法视为名为 HelloWorld
的类的成员,从文件名派生出类的名称。然后,按照前面所述的方式,启动器找到并调用 main
方法。
如果一个简单源文件有一个可启动的 main
实例方法,那么使用 java
启动器运行该文件等同于将其嵌入到 匿名类声明 中,实例化匿名类,并调用可启动的 main
方法:
new Object() {
String greeting = "Hello, World!";
void main() {
System.out.println(greeting);
}
}.main();
即使隐式声明的类不能被其他类引用且不能用于定义 API,javadoc
工具也能从简单源文件生成文档。记录隐式声明类的成员对于初学者学习 javadoc
以及经验丰富的开发人员为计划用于更大程序的代码原型设计可能是有用的。
与控制台交互
许多初学者程序需要与控制台进行交互。向控制台写入信息应该是一个简单的函数调用,但传统上这需要使用限定名 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) {
...
}
有经验的开发者已经习惯了这种样板代码,但对于初学者而言,这段代码包含了更多难以理解的概念,导致了一系列的问题:什么是 try
和 catch
,什么是 BufferedReader
,什么是 InputStreamReader
,还有究竟什么是 IOException
?虽然有其他的方法,但对初学者来说没有一种显著更好。
为了简化编写互动式小程序的过程,我们在简单源文件中提供了五个可用的方法:
public static void println(Object obj);
public static void println();
public static void print(Object obj);
public static String readln(String prompt);
public static String readln();
现在,初学者可以这样写 Hello, World!:
void main() {
println("Hello, World!");
}
然后他们可以轻松地进入最简单的互动式程序:
void main() {
String name = readln("Please enter your name: ");
print("Pleased to meet you, ");
println(name);
}
上述五个 static
方法在新类 java.io.IO
中声明,这是 JDK 24 中的预览 API。每个简单源文件自动导入这些 static
方法,就好像声明
import static java.io.IO.*;
出现在每个简单源文件的开头。
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.io
、java.math
和 java.util
等常用包中的流行 API 可以立即使用。在上面的例子中,可以移除 import java.util.List
,因为 List
将被自动导入。
JEP 伙伴 提出了一种新的导入声明,import module M
,它按需导入模块 M
导出的所有公共顶层类和接口。每个简单源文件被认为自动导入了 java.base
模块,就好像声明
import module java.base;
出现在每个简单源文件的开头。
程序的成长
简单源文件中的小程序专注于程序的功能,省略了不需要的概念和构造。即便如此,所有成员都被解释为普通类的一部分。要将一个简单源文件进化成一个普通的源文件,我们所要做的就是将其字段和方法包裹在一个明确的 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 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
方法没有任何变化。因此,将小程序转换为能够在更大程序中作为组件的类总是直接明了的。
替代方案
使用 JShell 进行小程序开发 —— JShell 会话不是一个程序,而是一系列代码片段。输入到
jshell
中的声明被隐式地视为某个未指定类的静态成员,并具有某种未指定的访问级别,所有先前声明在此上下文中均在作用域内。这对于实验非常方便——这是 JShell 的主要用例——但不适合编写小程序。将 JShell 中一批工作的声明进化为真正的程序会导致非惯用风格的代码,因为它将每个方法、类和变量声明为
static
。JShell 是探索和调试的强大工具,但它不是我们寻找的编程入门模型。将代码单元解释为静态成员 —— 方法和字段默认是非
static
的。将简单源文件中的顶层成员解释为static
会改变此类中代码单元的意义——实际上引入了一种不同的 Java 方言。为了在我们将简单源文件进化成普通源文件时保持这些成员的意义,我们必须添加显式的static
修饰符。当从小量的方法扩展到一个简单的类时,我们希望使用类作为类,而不仅仅是静态成员的容器。将简单源文件解释为方法体 —— 我们可以将其视为隐式声明类的
main
方法的主体,而不是将简单源文件视为隐式声明类的主体。换句话说,简单源文件只能包含语句、局部类或接口声明,或局部变量声明,而没有main
方法的头部。这种方法具有局限性,因为无法声明辅助方法;我们只能编写线性的程序,而不能将重复计算抽象为子例程。此外,也无法声明字段;所有变量声明都将被视为局部变量声明。这是一个限制,因为局部变量只有在实际上是 final 的情况下才能从 lambda 体或内部类内部访问,而字段则没有这种限制。
上面提出的方案支持辅助方法和字段声明。即使对初学者来说,写一个
main
方法头的负担也不重,尤其是在简单源文件中对此方法的要求放宽的情况下。引入包级别的方法和字段 —— 通过允许在没有明确
package
或class
声明的文件中声明包级别的方法和字段,我们可以实现与上述类似的用户体验。然而,这样的特性会对 Java 代码的一般编写方式产生更广泛的影响。不同的自动导入 —— 而不是让由简单源文件隐式声明的类按需导入
java.base
模块中的全部 54 个包,我们可以让它导入那些包的某个子集。但是,应该是哪个子集呢?每个读者都会有关于哪些包应该自动导入到每个小程序的建议:
java.io
和java.util
几乎是普遍的建议;java.util.stream
和java.util.function
也很常见;并且java.math
、java.net
和java.time
都有其支持者。对于 JShell 工具,我们设法找到了十个在试验一次性 Java 代码时广泛应用的java.*
包,但是很难看出java.*
包的哪个子集应该永久并自动地导入到每个小程序中。而且随着 Java 平台的发展,这个列表也会随之变化;例如,java.util.stream
和java.util.function
仅在 Java 8 中引入。开发者可能会依赖 IDE 来提醒他们当前有效的自动导入——这是一种不理想的结果。导入
java.base
模块导出的所有包对于简单源文件隐式声明的类是一个一致且合理的选择。引入 Java 语言的新方言 —— 一种根本不同的设计将是定义一种用于简单源文件的不同语言方言。这将允许为了简洁起见移除各种东西。例如,我们可以去掉要求明确声明
main
方法为void
的要求。不幸的是,这将阻碍小型程序向大型程序的优雅演变,而这其实是一个更重要的目标。我们偏好一个渐进的学习路径而不是一个悬崖边缘。