Featured image of post JEP 512:紧凑源文件与实例 main 方法

JEP 512:紧凑源文件与实例 main 方法

摘要 通过演进 Java 编程语言,使初学者无需理解为大型程序设计的语言特性也能编写他们的第一……

本文章翻译自 OpenJDK 网站 JEP 512: Compact Source Files and Instance Main Methods,其作者保留所有权利。

作者 Ron Pressler、Jim Laskey、Gavin Bierman
负责人 Gavin Bierman
类型 特性
范围 SE
状态 已关闭/已交付
发布版本 25
组件 规范/语言
讨论 amber dash dev at openjdk dot org
相关 JEP 511:模块导入声明
JEP 495:简单源文件与实例 main 方法(第四次预览)
评审 Alex Buckley、Brian Goetz
背书 Brian Goetz
创建时间 2024/11/21 11:58
更新时间 2025/07/11 06:45
问题 8344699

摘要

通过演进 Java 编程语言,使初学者无需理解为大型程序设计的语言特性也能编写他们的第一个程序。我们并非引入单独的语言变种;初学者可以为单类程序编写简化的声明,并在技能提升后无缝扩展其程序以使用更高级的特性。同样,经验丰富的开发者也可以更为简洁地编写小型程序,而无需使用那些面向大规模编程而设计的构造。

历史

该特性最初由 JEP 445(JDK 21)以预览形式提出,随后在 JEP 463(JDK 22)、JEP 477(JDK 23)和 JEP 495(JDK 24)中不断改进与完善。我们在此提议在 JDK 25 中将其定为正式特性,并将“简单源文件”(simple source files)更名为“紧凑源文件”(compact source files),同时基于实践与反馈做出若干小幅改进:

  • 用于基础控制台 I/O 的新 IO 类从 java.io 包移至 java.lang 包,因此它会被每个源文件隐式导入。
  • 不再将 IO 类的静态方法隐式导入到紧凑源文件中。因此,调用这些方法时必须带上类名,例如:IO.println("Hello, world!");除非显式导入这些方法。
  • IO 类的实现现在基于 System.outSystem.in,而非 java.io.Console 类。

目标

  • 为 Java 编程提供平滑的入门路径,便于授课者按部就班地引入各类概念。
  • 帮助学生以简洁方式编写简单程序,并在技能提升时优雅地扩展代码。
  • 减少编写脚本、命令行工具等其他小型程序时的“仪式感”。
  • 不引入独立的 Java 语言变体。
  • 不引入独立的工具链。小型 Java 程序应与大型程序使用相同的工具进行编译与运行。

动机

Java 编程语言在由大型团队、历经多年开发与维护的庞大复杂应用方面表现出色。它提供了丰富的特性,用于数据隐藏、复用、访问控制、命名空间管理与模块化,使得各组件能够在独立开发和维护的同时被干净地组合在一起。借助这些特性,组件既可以为与其他组件的交互暴露定义良好的接口,又能隐藏内部实现细节,从而允许各自独立演进。事实上,面向对象范式从根本上说,就是将通过明确协议进行交互的组件拼接在一起,同时抽象掉实现细节。这种对大型组件的组合被称为“大规模编程”(programming in the large)。

然而,Java 也被定位为一门“第一门语言”。程序员起步时并不会以团队形式编写大型程序,他们是独自编写小程序。对于由不同人编写的组件进行独立演进所依赖的封装与命名空间,他们并非一开始就需要。教授编程时,教师会从“小规模编程”(programming in the small)的基础概念讲起,如变量、控制流与子程序。在这一阶段,并不需要“大规模编程”(programming in the large)中的类、包、模块等概念。让语言对新手更友好符合资深 Java 开发者的利益;而资深开发者同样可以在无需任何大规模编程构造的情况下,更简洁地编写小程序。

思考一个常见的入门示例——经典的 Hello, World! 程序:

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

就这个程序的功能而言,这里有太多“累赘”——代码太多、概念太多、结构也太多。

  • 类声明以及强制性的 public 访问修饰符,都是“大规模编程”的构造。在需要将代码封装并通过定义良好的接口与外部组件交互时,它们很有用;但在这个小例子里并无必要。
  • 形参 String[] args 也是为了将代码连接到外部组件而存在,此处是操作系统的 shell。在这里它既神秘又无益,尤其是在像 HelloWorld 这样的微型程序中根本不会用到。
  • static 修饰符是该语言类与对象模型的一部分。对初学者而言,static 不仅令人费解,甚至有害:要在这个程序里添加更多方法或字段,初学者要么把它们都声明为 static,从而延续一种既不常见也不是好习惯的用法;要么就必须直面静态成员与实例成员的区别,并学习如何实例化对象。
  • 初学者还可能被像咒语一样的 System.out.println 搞糊涂,并纳闷为何一个简单的函数调用就不能解决问题。即便是在学习的第一周,初学者也可能被迫学习如何为基本功能导入基础工具类,并疑惑这些功能为什么不能自动提供。

新手程序员在最不合适的时机就会遇到这些概念,在他们尚未学习变量与控制流之前,也还无法理解“大规模编程”构造在保持大型程序井然有序方面的价值。授课者往往只能告诫:“先别管这些,之后你会明白的。”这对他们和学生都不尽如人意,并让学生留下一个挥之不去的印象:这门语言很复杂。

本工作的动机并不仅是减少“仪式感”。我们的目标是帮助刚接触 Java 语言,或刚接触编程的程序员,以正确的顺序学习语言:从“小规模编程”的基本概念入手,例如进行简单的文本 I/O、用 for 循环处理数组;只有在这些确实带来益处且更容易被掌握时,才引入“大规模编程”的高级概念。

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

我们希望让小程序更易于编写,并非通过改变 Java 语言的结构,代码仍然被封装在方法中,方法封装在类中,类封装在包中,包封装在模块中,而是把这些细节在真正需要之前先隐藏起来。

说明

首先,我们允许 main 方法省略那段广为诟病的样板代码 public static void main(String[] args),从而将 Hello, World! 程序简化为:

1
2
3
4
5
class HelloWorld {
    void main() {
        System.out.println("Hello, World!");
    }
}

其次,我们引入一种“紧凑”形式的源文件,使开发者无需多余的类声明,就能直接上手编写代码:

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

第三,我们在 java.lang 包中新增一个类,为初学者提供面向行的基础 I/O 方法,用更简单的形式取代那句神秘的 System.out.println

1
2
3
void main() {
    IO.println("Hello, World!");
}

最后,对于超出 Hello, World! 的程序(例如需要基本数据结构或文件 I/O),在紧凑源文件中我们会自动导入 java.lang 之外的一系列标准 API。

这些改动组合在一起,为新手提供了一条“上匝道”——一段平缓的坡道,能优雅地汇入高速主路。随着初学者迈向更大的程序,他们不必丢弃早期所学;相反,他们会看到这些知识如何融入更宏观的图景。对有经验的开发者而言,从原型走向生产时,也能顺畅地将代码扩展为大型程序的组成组件。这些知识如何融入更宏观的图景。对有经验的开发者而言,从原型走向生产时,也能顺畅地将代码扩展为大型程序的组成组件。

实例 main 方法

为了编写并运行程序,初学者需要了解程序的入口点。现行的《Java 语言规范》(JLS)指出,Java 程序的入口点是一个名为 main 的方法(§12.1):

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

JLS 进一步规定(§12.1.4):

方法 main 必须被声明为 publicstatic 且返回 void。它必须声明一个形参,其声明类型为 String 的数组。

main 方法的这些声明要求属于历史遗留,并非必要。我们可以通过两种方式精简 Java 程序的入口点:允许 main 为非静态;并取消对 public 与数组形参的要求。这样,我们就可以在不使用 public 修饰符、不使用 static 修饰符、也不需要 String[] 形参的情况下编写 Hello, World!,并将这些构造的引入推迟到真正需要时再进行:

1
2
3
4
5
class HelloWorld {
    void main() {
        System.out.println("Hello, World!");
    }
}

假设该程序保存在文件 HelloWorld.java 中,我们可以用“源代码启动器”直接运行它:

1
$ java HelloWorld.java

或者,先显式编译再运行:

1
2
$ javac HelloWorld.java
$ java HelloWorld

无论采用哪种方式,启动器都会先启动 Java 虚拟机,然后为指定的类选择并调用一个 main 方法:

  1. 如果该类声明或继承了带有 String[] 形参的 main 方法,启动器就选择该方法。

    否则,如果该类声明或继承了无参数的 main 方法,启动器就选择该方法。

    否则,启动器会报告错误并终止。

  2. 如果被选中的方法是 static,启动器就直接调用它。

    否则,被选中的方法就是实例 main 方法。此时该类必须具有无参且非私有的构造器。启动器将先调用该构造器创建对象,再调用该对象上的所选 main 方法;如果不存在这样的构造器,启动器会报告错误并终止。

任何能够按上述规则被选择并调用的 方法,称为可启动的 main 方法(launchable main method)。例如,HelloWorld 类就有一个可启动的 void main() 方法,即 。

紧凑源文件

在 Java 语言中,每个类都处于某个包中,而每个包又处于某个模块中。模块与包为类提供命名空间与封装,但只由少量类组成的小程序并不需要这些概念。因此,开发者可以省略包与模块声明,相应的类将位于未命名模块的未命名包中。

类为字段与方法提供命名空间与封装,但只由少量字段与方法构成的小程序并不需要这些概念。我们不应要求初学者在熟悉变量、控制流与子程序这些基本构件之前,就去理解这些概念。因此,对于仅包含少量字段与方法的小程序,我们可以像不要求包或模块声明那样,不再强制要求类声明。

因此,当 Java 编译器遇到一个源文件,其中包含未被类声明包裹的字段和方法时,它将把该源文件视为隐式声明了一个类,其成员就是这些未包裹的字段和方法。这样的源文件称为紧凑源文件。

借助这一改动,我们可以将 Hello, World! 写成一个紧凑源文件:

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

紧凑源文件所隐式声明的类具备如下特性:

  • 是未命名包中的 final 顶层类;
  • 继承自 java.lang.Object,且不实现任何接口;
  • 仅具有一个无参数的默认构造器,不包含其他构造器;
  • 其成员即为该紧凑源文件中声明的字段与方法;
  • 必须包含一个可启动的 main 方法;否则将报告编译期错误。

由于在紧凑源文件中声明的字段与方法会被视为该隐式声明类的成员,我们可以通过调用附近声明的方法来编写 Hello, World!

1
2
3
4
5
String greeting() { return "Hello, World!"; }

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

或通过访问字段:

1
2
3
4
5
String greeting = "Hello, World!";

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

紧凑源文件是隐式声明一个类,因此该类在源码层面没有可供使用的名称。Java 编译器在编译紧凑源文件时会生成一个类名,但该名称属于实现细节,不应在任何源码中依赖,即便是该紧凑源文件自身的源码也不应依赖。

我们可以通过 this 来引用该类的当前实例,既可以显式引用,也可以像前文那样隐式引用,但不能用 new 运算符来实例化该类。这体现了一个重要的权衡:如果初学者尚未学习面向对象概念(如“类”),那么在紧凑源文件中编写代码就不应要求编写类声明,而正是类声明才会赋予类一个可用于 new 的名称。

紧凑源文件本质上就是另一种单文件源码程序。如所示,我们既可以用“源代码启动器”直接运行紧凑源文件,也可以先显式编译再运行。

即使隐式声明的类不应被其他类引用、因此不能用来定义 API,javadoc 工具仍可依据紧凑源文件生成文档。对正在学习 javadoc 的初学者,以及为更大型程序编写原型代码的有经验开发者而言,为该隐式类的成员生成文档依然是有用的。

与控制台交互

初学者经常编写与控制台交互的程序。向控制台输出理应很直观,但传统方式需要调用那句难以理解的 System.out.println 方法。对初学者而言,这极其神秘:什么是 System?什么是 out

更糟的是从控制台读取输入,本来也应该只是一次简单的方法调用。既然向控制台输出要用 System.out,看起来从控制台读取就该用 System.in;但要从 System.in 获取一个 String 却需要大量代码,例如:

1
2
3
4
5
6
7
try {
    BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
    String line = reader.readLine();
    ...
} catch (IOException ioe) {
    ...
}

有经验的开发者习惯了这种样板代码,但对初学者来说,这段代码包含更多谜团,带来一大堆问题:trycatch 是什么?BufferedReader 是什么?InputStreamReader 又是什么?IOException 又是什么意思?也有其他做法,但没有哪一种对初学者而言显著更好。

为简化交互式程序的编写,我们新增一个类 java.lang.IO,声明了五个静态方法:

1
2
3
4
5
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!

1
2
3
void main() {
    IO.println("Hello, World!");
}

接着,他们可以轻松写出最简单的交互式程序:

1
2
3
4
5
void main() {
    String name = IO.readln("Please enter your name: ");
    IO.print("Pleased to meet you, ");
    IO.println(name);
}

初学者确实需要了解,这些面向行的基础 I/O 方法都需要使用限定符 IO,但这并非过重的教学负担。无论如何,他们很快也会接触到此类限定符;例如,对于数学函数会使用限定符 Math,如 Math.sin(x)

由于 IO 类位于 java.lang 包中,因此在任何 Java 程序中都无需 import 即可使用。此规则适用于所有程序,而不仅是紧凑源文件或声明了实例 main 方法的程序;例如:

1
2
3
4
5
6
7
class Hello {
    public static void main(String[] args) {
        String name = IO.readln("Please enter your name: ");
        IO.print("Pleased to meet you, ");
        IO.println(name);
    }
}

自动导入 java.base 模块

Java 平台 API 中还有许多类对小型程序很有用。它们可以在紧凑源文件的开头显式导入:

1
2
3
4
5
6
7
8
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.*)。但对初学者而言,任何形式的导入都是新的“谜团”,需要先理解 Java API 的包层次结构。

为进一步简化小型程序的编写,我们让由 java.base 模块导出的各包中的公共顶层类与接口在紧凑源文件中可直接使用,仿佛它们已被按需导入。这样,诸如 java.iojava.mathjava.util 等常用包中的热门类与接口都可立即使用。在上面的示例中,import java.util.List 便可省略,因为 List 会被自动导入。

一份配套的 JEP 提出了新的导入声明形式:import module M,它会“按需导入”模块 M 所导出各包中的所有公共顶层类与接口。对于每个紧凑源文件,都视为自动导入 java.base 模块,就好像在每个紧凑源文件开头都写了:

1
import module java.base;

扩展程序

紧凑源文件中的小程序专注于程序要做的事情,省略了并不需要的概念与结构。即便如此,所有成员仍按普通类中的方式进行解释。要将一个紧凑源文件演进为普通源文件,我们所需做的只是把其中的字段与方法包裹进一个显式的类声明,并添加导入声明。例如,下面这个紧凑源文件:

1
2
3
4
5
6
void main() {
    var authors = List.of("James", "Bill", "Guy", "Alex", "Dan", "Gavin");
    for (var name : authors) {
        IO.println(name + ": " + name.length());
    }
}

可以演进为声明单个类的普通源文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 方法本身不需要做任何改动。因此,将一个小程序转变为可作为更大型程序组件的类始终是直截了当的。

备选方案

自动导入控制台 I/O 方法

在该特性的早期预览中,我们曾探索过这样一种可能性:让紧凑源文件自动导入新 IO 类的静态方法。如此一来,开发者在紧凑源文件中就可以写 println(...),而不是 IO.println(...)

这虽然带来了一个令人愉悦的效果,让 IO 中的方法看起来仿佛是 Java 语言的“内建”功能,但却在“上匝道”上增加了减速带:要把紧凑源文件演进为普通源文件,初学者就必须添加静态导入声明(import static ...)——这又是一个更高级的概念。这与我们的第二个目标相悖,即让初学者能够优雅地扩展他们的代码。此设计还会带来长期负担:需要审查源源不断的提案,要求向 IO 类添加更多方法。

自动导入更少的包

与其将 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 模块导出的所有包自动导入,是对紧凑源文件隐式声明的类而言一致且合理的选择。

允许顶层语句

一种替代设计是在紧凑源文件中允许语句直接出现,从而无需声明 main 方法。该设计会将整个紧凑源文件解释为一个隐式声明的类中隐式声明的 main 方法的方法体。

不幸的是,这一设计会造成限制:在紧凑源文件中将无法声明方法。这些方法会被解释为出现在“不可见的 main 方法”的方法体内,但方法不能嵌套声明于方法之内,因此它们会变成不合法。紧凑源文件只能表示一条接一条的线性程序,而无法将重复计算抽象为子程序。

此外,在这种设计下,所有变量声明都会被解释为那个不可见 main 方法的局部变量。这也有限制,因为局部变量只有在“事实上为 final”(effectively final)时才能从 lambda 表达式中访问——这是一个进阶概念。在紧凑源文件中编写 lambda 表达式将更容易出错且令人困惑。

我们认为,之所以有人希望在方法体之外、直接在紧凑源文件中书写语句,很大程度上是因为书写 public static void main(String[] args) 的痛点。既然我们已经让 main 方法更易声明,那么让紧凑源文件由方法和字段而非散落的语句组成,才是更好的选择。

扩展 JShell

JShell 是一个可即时执行 Java 代码的交互式工具,为编程提供了增量式环境,使初学者无需繁琐步骤即可做实验。

另一种替代设计是扩展 JShell 以达成我们的目标。虽然这一想法在理论上很吸引人,但在实践中不太理想。

JShell 会话并不是一个 Java 程序,而是一系列代码片段。片段逐个执行,但并非彼此独立:当前片段的执行依赖于所有之前片段的执行结果,因此值与声明会随时间“演化”。在任一时刻,虽然存在一个“当前程序状态”的概念,但并没有该程序的真实文本表示。这对“做实验”(JShell 的主要用例)很有效,却不适合作为帮助初学者编写真实程序的基础。

从更技术的层面看,JShell 会话中的所有声明都会被解释为某个未指定类的静态成员,所有语句都在一个包含先前所有声明的作用域中执行。若将紧凑源文件解释为一系列代码片段,那么该文件只能表达其方法与字段皆为静态的类,这实质上引入了一种 Java 变种。而要把这样的紧凑源文件演进为普通源文件,就必须给每个方法和字段逐一添加 static 修饰符,这会妨碍小程序向大程序的优雅演进。

引入 Java 语言的新变种

一种截然不同的设计是:为紧凑源文件定义一套不同的语言变种。这将允许为追求简洁而删除各种要素。比如,我们可以取消要求 main 方法显式声明为 void。不幸的是,这会妨碍小程序向大型程序的优雅演进,而后者更为重要。相比之下,我们更希望提供一条平缓的上匝道,而不是把人带到悬崖边。

Licensed under CC BY-NC-SA 4.0
The interface used the HarmonyOS Sans font.
使用 Hugo 构建
主题 StackJimmy 设计