JEP 512:紧凑源文件与实例主方法
JEP 512:紧凑源文件与实例主方法
原文:JEP 512- Compact Source Files and Instance Main Methods
作者:
日期:2025-10-26
| 作者 | 罗恩·普雷斯勒(Ron Pressler)、吉姆·拉斯基(Jim Laskey)、加文·比尔曼(Gavin Bierman) |
|---|---|
| 所有者 | 加文·比尔曼(Gavin Bierman) |
| 类型 | 特性 |
| 范围 | 标准版(SE) |
| 状态 | 已关闭 / 已交付 |
| 发布版本 | 25 |
| 组件 | 规范 / 语言 |
| 讨论组 | amber - dev@openjdk.org |
| 相关内容 | JEP 511:模块导入声明 JEP 495:简单源文件与实例主方法(第四次预览) |
| 审核人 | 亚历克斯·巴克利(Alex Buckley)、布莱恩·戈茨(Brian Goetz) |
| 批准人 | 布莱恩·戈茨(Brian Goetz) |
| 创建时间 | 2024 年 11 月 21 日 11:58 |
| 更新时间 | 2025 年 7 月 11 日 06:45 |
| 问题编号 | 8344699 |
摘要
对 Java 编程语言进行演进,使初学者在编写第一个程序时无需理解为大型程序设计的语言特性。初学者无需使用单独的语言变体,而是可以为单类程序编写简化声明,然后随着技能的提升,无缝扩展程序以使用更高级的特性。有经验的开发者同样可以简洁地编写小程序,而无需使用为大型编程设计的结构。
历史
此特性最初由 JEP 445(JDK 21)提议作为预览内容,随后在 JEP 463(JDK 22)、JEP 477(JDK 23)和 JEP 495(JDK 24)中得到改进和完善。在此,我们提议在 JDK 25 中确定该特性,将“简单源文件”重命名为“紧凑源文件”,并根据经验和反馈进行一些小的改进:
- 用于基本控制台输入输出的新
IO类现在位于java.lang包中,而非java.io包。因此,每个源文件都会隐式导入它。 IO类的static方法不再隐式导入到紧凑源文件中。因此,调用这些方法时必须指定类名,例如IO.println("Hello, world!"),除非显式导入这些方法。IO类的实现现在基于System.out和System.in,而非java.io.Console类。
目标
- 为 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 循环处理数组,只有在实际有益且更容易理解时,才继续学习高级的“大型编程”概念。
此外,这项工作的动机不仅是为了帮助初学者。我们旨在帮助所有编写小程序的人,无论是学生、编写命令行实用程序的系统管理员,还是为最终将用于企业级软件系统的核心算法制作原型的领域专家。
我们提议让编写小程序变得更容易,不是通过改变 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!");
}
第三,我们在 java.lang 包中添加一个新类,为初学者提供基本的面向行的输入输出方法,从而用更简单的形式替代神秘的 System.out.println:
void main() {
IO.println("Hello, World!");
}
最后,对于超出“Hello, World!”范畴且需要(例如)基本数据结构或文件输入输出的程序,在紧凑源文件中,我们会自动导入 java.lang 包之外的一系列标准 API。
这些改变共同提供了一个“入口匝道”,即一个逐渐上升的斜坡,能平稳地汇入高速公路。随着初学者过渡到更大的程序,他们无需摒弃在早期阶段学到的东西,而是会明白这些知识如何融入更宏观的体系。有经验的开发者从原型开发进入到生产阶段时,也能够顺利地将他们的代码扩展为更大程序的组件。
实例 main 方法
为了编写和运行程序,初学者需要了解程序的“入口点”。现行的《Java 语言规范》(JLS)解释说,Java 程序的入口点是一个名为 main 的方法(§12.1):
Java 虚拟机通过调用某个指定类或接口的
main方法来开始执行,并向其传递一个参数,该参数是一个字符串数组。
JLS 进一步指出(§12.1.4):
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
或者,我们也可以显式编译它,然后再运行:
$ javac HelloWorld.java
$ java HelloWorld
无论哪种方式,启动器都会启动 Java 虚拟机,然后选择并调用指定类的 main 方法:
- 如果该类声明或继承了一个带有
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);
}
紧凑源文件隐式声明了一个类,因此该类在源代码中没有可使用的名称。Java 编译器在编译紧凑源文件时会生成一个类名,但这个名称是特定于实现的,在任何源代码中都不应依赖它 —— 即使是紧凑源文件本身的源代码也不行。
我们可以通过 this 显式或(如上述示例)隐式地引用类的当前实例,但我们不能使用 new 操作符实例化该类。这反映了一个重要的权衡:如果初学者还没有学习类等面向对象概念,那么在紧凑源文件中编写代码就不应要求类声明 —— 而正是类声明会赋予类一个可与 new 一起使用的名称。
紧凑源文件只是另一种单文件源代码程序。如 前文所示,我们可以使用源代码启动器直接运行紧凑源文件,也可以显式编译它,然后再运行。
javadoc 工具可以从紧凑源文件生成文档,尽管隐式声明的类不应被其他类引用,因此不能用于定义 API。记录隐式声明类的成员对于学习 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 又到底是什么?还有其他方法,但没有一种对初学者来说明显更好。
为了简化交互式程序的编写,我们添加了一个新类 java.lang.IO,它声明了五个 static 方法:
public static void print(Object obj);
public static void println(Object obj);
public static void println();
public static String readln(String prompt);
public static String readln();
现在,初学者可以这样编写“Hello, World!”程序:
void main() {
IO.println("Hello, World!");
}
然后,他们可以轻松编写最简单的交互式程序:
void main() {
String name = IO.readln("请输入您的名字: ");
IO.print("很高兴认识您, ");
IO.println(name);
}
初学者确实需要了解这些基本的面向行的输入输出方法需要 IO 限定符,但这并非过重的学习负担。无论如何,他们可能很快就会学习到这样的限定符;例如,像 Math.sin(x) 这样的数学函数的 Math 限定符。
由于 IO 类位于 java.lang 包中,在任何 Java 程序中都可以无需导入就使用它。这适用于所有程序,不仅仅是紧凑源文件中的程序或声明实例 main 方法的程序;例如:
class Hello {
public static void main(String[] args) {
String name = IO.readln("请输入您的名字: ");
IO.print("很高兴认识您, ");
IO.println(name);
}
}
java.base 模块的自动导入
Java 平台 API 中的许多其他类在小程序中也很有用。可以在紧凑源文件开头显式导入它们:
import java.util.List;
void main() {
var authors = List.of("James", "Bill", "Guy", "Alex", "Dan", "Gavin");
for (var name : authors) {
IO.println(name + ": " + name.length());
}
}
有经验的开发者会觉得这样很自然,不过为了方便,有些人可能倾向于使用按需导入声明(即 import java.util.*)。然而,对于初学者来说,任何形式的 import 都是另一个费解之处,这需要理解 Java API 的包层次结构。
为了进一步简化小程序的编写,我们让 java.base 模块导出的包中的所有公共顶级类和接口都可在紧凑源文件中使用,就好像它们是按需导入的一样。这样,java.io、java.math 和 java.util 等常用包中的流行类和接口可立即使用。在上面的示例中,由于 List 会被自动导入,所以 import java.util.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) {
IO.println(name + ": " + name.length());
}
}
可以扩展为声明单个类的普通源文件:
import module java.base;
class NameLengths {
void main() {
var authors = List.of("James", "Bill", "Guy", "Alex", "Dan", "Gavin");
for (var name : authors) {
IO.println(name + ": " + name.length());
}
}
}
main 方法没有任何改变。因此,将小程序转变为可作为更大程序组件的类总是很简单直接。
替代方案
自动导入控制台输入输出方法
在该特性的早期预览中,我们探讨了紧凑源文件自动导入新 IO 类的 static 方法的可能性。这样,开发者在紧凑源文件中就可以写 println(...),而不是 IO.println(...)。
这产生了一种令人满意的效果,即让 IO 中的方法看起来像是 Java 语言内置的,但它给入门带来了一个障碍:为了将紧凑源文件扩展为普通源文件,初学者必须添加一个 static 导入声明——这是另一个高级概念。这与我们的第二个目标相悖,即初学者应该能够优雅地扩展他们的代码。这种设计还会带来长期负担,需要审查可能源源不断的向 IO 类添加更多方法的提议。
自动导入更少的包
我们可以选择只自动导入 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 模块导出的所有包是一个一致且合理的选择。
允许顶级语句
另一种设计方案是允许语句直接出现在紧凑源文件中,无需声明 main 方法。这种设计会将整个紧凑源文件解释为一个隐式声明类的隐式声明 main 方法的主体。
不幸的是,这种设计存在局限性,因为在紧凑源文件中无法声明方法。这样的方法会被解释为出现在一个不可见的 main 方法主体中,但这会导致它们不合法,因为方法不能在方法内部声明。紧凑源文件只能表示由一条接一条语句组成的线性程序,无法将重复计算抽象为子程序。
此外,在这种设计中,所有变量声明都会被解释为不可见 main 方法的局部变量。这也存在局限性,因为局部变量只有在实际上是最终变量时才能从 lambda 表达式中访问,而这是一个高级概念。在紧凑源文件中编写 lambda 表达式容易出错且令人困惑。
我们认为,想要在紧凑源文件中方法体之外直接编写语句的愿望,很大程度上是由于编写 public static void main(String[] args) 很麻烦。既然已经让 main 方法更容易声明,我们认为紧凑源文件最好由方法和字段组成,而不是语句。
扩展 JShell
JShell 是一个用于立即执行 Java 代码的交互式工具。它为编程提供了一个增量式环境,让初学者可以无需繁琐步骤进行试验。
另一种设计方案是扩展 JShell 来实现我们的目标。虽然理论上这是个有吸引力的想法,但在实践中吸引力较小。
JShell 会话不是一个 Java 程序,而是一系列“代码片段”。代码片段一次执行一个,但它们不是独立的:当前代码片段的执行依赖于所有先前代码片段的执行结果,所以值和声明似乎会随着时间演变。在任何时刻,都有正在开发的程序的当前状态的概念,但没有程序的实际文本表示。这对于试验(JShell 的主要用例)来说效果很好,但对于帮助初学者编写实际程序来说,并不是一个现实的基础。
从更技术的层面来看,JShell 会话中的所有声明都被解释为一个未指定类的 static 成员,并且所有语句都在一个上下文中执行,其中所有先前的声明都在作用域内。如果我们将紧凑源文件解释为一系列代码片段,那么该文件只能表示其方法和字段都是 static 的类,实际上引入了一种 Java 变体。将紧凑源文件扩展为普通源文件将涉及向每个方法和字段声明添加 static 修饰符,这会阻碍小程序向大程序的顺利演进。
引入一种新的 Java 语言变体
一种截然不同的设计方案是为紧凑源文件定义一种不同的语言变体。为了简洁起见,这种变体可以去掉各种要求。例如,我们可以去掉 main 方法必须显式声明为 void 的要求。不幸的是,这会阻碍小程序向大程序的顺利演进,而这是一个更重要的目标。我们更倾向于平缓的入门方式,而不是陡峭的悬崖式设计。