JEP 495: 简单源文件和实例 main 方法(第四预览)
概述
改进 Java 编程语言,使初学者能够在不需要理解为大型程序设计的语言特性的情况下编写他们的第一个程序。初学者可以为单类程序编写简化的声明,然后随着技能的提高无缝地扩展他们的程序以使用更高级的功能。经验丰富的开发人员也可以简洁地编写小型程序,而无需使用为大型编程设计的结构。这是一个预览语言功能。
历史
目标
-
提供一个平滑的 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 语言的结构——代码仍然被封装在方法中,方法被封装在类中,类被封装在包中,包被封装在模块中。
描述
首先,我们允许 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,在简单的源文件中,我们不仅自动导入 java.lang
包,还会自动导入一系列标准 API。
这些变化结合在一起,提供了一个入口坡道,即一个平缓的斜坡,可以优雅地合并到高速公路。当初学者转向更大的程序时,他们不必丢弃在早期阶段学到的知识,而是看到所有知识如何融入更大的图景中。当有经验的开发者从原型转向生产时,他们可以顺利地将代码扩展为更大程序的组件。
这是一个预览语言特性,默认情况下是禁用的
要在 JDK 24 中尝试以下示例,您必须启用预览功能:
-
使用
javac --release 24 --enable-preview Main.java
编译程序,并使用java --enable-preview Main
运行它;或者, -
当使用 source code launcher 时,使用
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();
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
?虽然还有其他方法,但没有一种明显更好,尤其是对于初学者。
为了简化交互式小程序的编写,我们在简单的源文件中提供了五种方法供使用:
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
方法的头部。这种方法是有限制的,因为没有办法声明辅助方法;我们只能编写线性程序,而不能将重复计算抽象成子例程。此外,也没有办法声明字段;所有变量声明都将被视为局部变量声明。这是一个限制,因为局部变量只能在 lambda 表达式或内部类中访问它们实际上是 final 的情况下访问,而字段则没有这种约束。
我们上面提出的方案支持辅助方法和字段声明。即使对于初学者来说,编写
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
的要求。不幸的是,这将阻止小型程序优雅地演变为更大的程序,而这才是更重要的目标。我们更喜欢渐进式的入门路径,而不是悬崖峭壁。