跳转到内容

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

原文:https://openjdk.org/jeps/512
翻译:张欢

改进Java编程语言,使初学者无需了解为大型程序设计的语言功能即可编写他们的第一个程序。初学者无需使用单独的语言方言,而是可以为单类程序编写精简的声明,然后随着技能的提升,无缝扩展他们的程序以使用更高级的功能。经验丰富的开发人员同样可以享受简洁地编写小型程序的乐趣,而无需使用专为大型程序设计的结构。

该功能最初由JEP 445(JDK 21)提出预览,随后由JEP 463(JDK 22)、JEP 477(JDK 23)和JEP 495(JDK 24)改进和完善。我们建议在JDK 25中完成该功能,将简单的源文件重命名为紧凑的源文件,根据经验和反馈进行了一些小的改进:

  • 用于基本控制台I/O的新IO类现在位于java.lang包中,而不是java.io包中。因此,每个源文件都会隐式导入它。
  • IO类的static方法不再隐式导入到紧凑的源文件中。因此,调用这些方法时必须指定类名,例如IO.println("Hello, world!"),除非显式导入这些方法。
  • IO类的实现现在基于System.outSystem.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——从而传播一种既不常见也不好的习惯——要么就要直面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!");
}

第三,我们在java.lang包中添加一个新类,提供基本的、面向行(line)的I/O方法,从而用更简单的形式取代了神秘的System.out.println

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必须声明为publicstaticvoid。它必须指定一个声明类型为String数组的形式参数。

这些对main声明的要求是历史遗留问题,没有必要。我们可以通过两种方式简化Java程序的入口点:允许main为非static,并删除对public和数组参数的要求。这些更改允许我们在编写Hello, World时不使用public修饰符,也不需要static修饰符,并且没有String[]参数,推迟引入这些结构直到需要它们为止:

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

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

Terminal window
$ java HelloWorld.java

或者,我们可以明确地编译它,然后运行它:

Terminal window
$ javac HelloWorld.java
$ java HelloWorld

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

  1. 如果类声明或继承了带有String[] main参数的方法,那么启动器选择该方法。
    否则,如果类声明或继承了没有参数的main方法,那么启动器将选择该方法。
    否则,启动器将报告错误并终止。
  2. 如果选择的方法是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一起使用的名称。

紧凑源文件只是另一个单文件源代码程序。如前所述,我们可以直接使用源代码启动器运行紧凑源文件,也可以显式编译后运行。

即便隐式声明的类不应被其他类引用,因此无法用于定义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) {
...
}

经验丰富的开发人员已经习惯了这种样板代码,但对于初学者来说,这种代码包含更多的困惑,从而引发大量的问题:什么是trycatch,什么是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("Please enter your name: ");
IO.print("Pleased to meet you, ");
IO.println(name);
}

初学者确实需要学习这些基本的面向行的I/O方法所需要的限定符IO,但这不会成为过度的教学负担。他们可能很快就会学习这些限定符;例如,限定符Math用于数学函数,例如Math.sin(x)

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

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平台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.iojava.mathjava.util将立即可用。在上面的例子中,可以删除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) {
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.iojava.util是几乎通用的建议;java.util.streamjava.util.function也很常见;还有java.mathjava.netjava.time都有支持。对于JShell工具,我们找到了10个java.*包,它们在以下情况下非常有用:尝试一次性的Java代码,但很难看出java.*包的哪些哪些子集应该永久自动导入到每个紧凑的源文件中。此外,这样的列表会随着Java平台的演进而变化;例如,java.util.streamjava.util.function仅在Java 8中引入。开发人员可能会需要依赖IDE来提醒他们哪些自动导入已生效——这不是一个理想的结果。

对于紧凑源文件隐式声明的类,导入java.base模块导出的所有包是一个一致且合理的选择。

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

不幸的是,这种设计存在局限性,因为无法在紧凑的源文件中声明方法。这些方法会被解释为出现在不可见的main方法主体中,但这会使它们变得不合法,因为方法不能在方法内部声明。紧凑的源文件只能表示由一个接一个语句组成的线性程序,而无法将重复计算抽象为子过程。

此外,在这种设计中,所有变量声明都将被解释为不可见的main方法的局部变量。这将带来限制,因为只有当局部变量是有效final时,才能从lambda表达式访问它们,这是一个高级概念。在紧凑的源文件中编写lambda表达式容易出错且令人困惑。

我们认为,人们之所以希望在紧凑的源文件中(方法主体之外)直接编写语句,很大程度上是因为编写public static void main(String[] args)非常麻烦。为了让main方法更易于声明,我们认为紧凑的源文件最好由方法和字段组成,而不是由语句组成。

JShell是一个用于立即执行Java代码的交互式工具。它提供了一个增量式编程环境,让初学者可以轻松进行实验。

另一种设计是扩展JShell来实现我们的目标。虽然理论上很有吸引力,但在实践中却不那么有吸引力。

JShell会话并非Java程序,而是一系列代码片段。代码片段一次执行一个,但它们并非独立运行:当前代码片段的执行取决于所有先前代码片段的执行结果,因此值和声明似乎会随时间推移而变化。在任何时候,都存在正在开发程序的当前状态的概念,但没有程序的实际文本表示。这对于实验(JShell的主要用例)来说非常有效,但它并不是帮助初学者编写真实程序的现实基础。

从更技术的层面来看,JShell会话中的所有声明都会被解释为某个未指定类的static成员,并且所有语句都会在所有先前声明都处于作用域内的上下文中执行。如果我们将紧凑源文件解释为一系列代码片段,那么该文件只能表达方法和字段为static的类,这实际上引入了一种Java方言。将紧凑源文件演变为普通源文件需要在每个方法和字段声明中添加static修饰符,这会阻碍小型程序向大型程序的优雅演进。

一种截然不同的设计是定义一种不同的语言方言,用于紧凑的源文件。这样,为了追求简洁,各种东西都可以被移除。例如,我们可以放弃main方法必须显式声明为void的要求。然而,这却阻碍了小程序优雅地向大型程序演进,而这才是更重要的目标。我们更喜欢上坡路,而不是悬崖边。