JEP 463:隐式声明的类和实例主方法(第二次预览)
总结
改进 Java 编程语言,让学生在无需理解专为大型程序设计的语言特性的情况下,就能编写他们的第一个程序。学生完全可以使用标准语言(而非单独的方言),他们可以为单类程序编写简化的声明,然后随着技能的提升,无缝扩展程序以使用更高级的功能。这是一个 预览语言功能。
历史
JEP 445 提议了Unnamed Classes and Instance main
Methods,它在 JDK 21 中进行了预览。反馈表明,该特性应在 JDK 22 中进行第二次预览,并做出以下重大更改,因此标题也进行了修订。
-
允许类未命名,以及允许没有封闭类声明的源文件隐式声明一个未命名类的想法,主要是一种规范手段,以确保该类不能被其他类使用。然而,这已被证明是一种干扰。我们采用了更简单的方法:没有封闭类声明的源文件被认为隐式声明了一个由主机系统选择名称的类。这些隐式声明的类表现得像普通的顶级类,不需要额外的工具、库或运行时支持。
-
选择要调用的
main
方法的过程过于复杂,既要考虑方法是否有参数,又要考虑它是静态方法还是实例方法。在此第二次预览中,我们建议将选择过程简化为两个步骤:如果有带String[]
参数的候选main
方法,则调用该方法;否则调用没有参数的候选main
方法。这里不存在歧义,因为一个类不能声明具有相同名称和签名的静态方法和实例方法。
目标
-
提供一个平滑的 Java 编程入门路径,以便讲师可以逐步引入概念。
-
帮助学生以简洁的方式编写基础程序,并随着技能的增长优雅地扩展他们的代码。
-
减少编写简单程序(如脚本和命令行工具)的繁琐步骤。
-
不要引入 Java 语言的独立初学者方言。
-
不要引入独立的初学者工具链;学生编写的程序应使用与任何 Java 程序相同的工具进行编译和运行。
动机
Java 编程语言在由大型团队多年开发和维护的大型复杂应用程序中表现出色。它具有丰富的特性,用于数据隐藏、重用、访问控制、命名空间管理和模块化,这些特性允许组件在独立开发和维护的同时能够清晰地组合在一起。通过这些特性,组件可以暴露定义良好的接口以与其他组件交互,同时隐藏内部实现细节,从而允许每个组件独立演进。实际上,面向对象的范式本身就是为了通过定义良好的协议和抽象掉实现细节来拼接交互的部分而设计的。这种大型组件的组合被称为大规模编程(programming in the large)。该语言还提供了许多适用于小规模编程(programming in the small)的结构——即组件内部的一切内容。近年来,我们通过模块增强了其大规模编程的能力,并通过数据导向编程增强了其小规模编程的能力。
然而,Java 编程语言同样也是一门初学语言。程序员在初学阶段不会参与团队合作编写大型程序 —— 他们独自编写小型程序。他们不需要封装和命名空间,这些功能有助于分别演进由不同人编写的组件。在教授编程时,讲师会从变量、控制流和子程序等基本的小型编程概念入手。在这一阶段,并不需要类、包和模块等大型编程概念。让这门语言对新手更加友好符合 Java 老手们的利益,但他们也可能觉得更简洁地编写简单程序,无需任何大型编程的脚手架,也是一种乐趣。
考虑经典的 Hello, World! 程序,它常被用作 Java 学生的第一个程序:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
这里的杂乱内容太多了——相对于程序的功能,代码太多,概念太多,构造也太多。
-
class
声明和强制性的public
访问修饰符是编程中的大型构造。它们在将代码单元封装并提供定义良好的外部组件接口时非常有用,但在这样一个小例子中却毫无意义。 -
String[] args
参数同样是为了与外部组件(在此例中为操作系统 shell)进行交互而存在。在这里它显得神秘且无用,尤其是在像HelloWorld
这样的简单程序中根本不会用到它。 -
static
修饰符是语言中类与对象模型的一部分。对于初学者来说,static
不仅神秘,而且有害:为了添加更多可以被main
调用的方法或字段,学生必须要么将它们全部声明为static
—— 从而传播一种既不常见又不是好习惯的惯用法 —— 要么面对静态成员与实例成员之间的差异,并学习如何实例化一个对象。
新程序员在最糟糕的时间遇到了这些概念,那时他们还没有学习变量和控制流,也无法理解大型编程结构在保持大型程序良好组织方面的实用性。讲师们常常告诫说:“别担心那个,你以后会明白的。” 这让他们和他们的学生都感到不满意,并且让学生始终觉得这门语言很复杂。
此 JEP 的动机不仅仅是减少仪式感。我们的目标是帮助刚接触 Java 语言或编程的新手程序员,以正确的顺序学习语言并引入相关概念:首先从基础的小规模编程概念入手,然后在高级的大规模编程概念真正有益且更容易掌握时再进行学习。
我们建议这样做不是通过改变 Java 语言的结构——代码仍然封装在方法中,方法封装在类中,类封装在包中,包封装在模块中——而是通过隐藏这些细节,直到它们在较大的程序中有用时才展现。我们提供了一个入口匝道,一个逐渐上升的斜坡,优雅地汇入高速公路。当学生进入较大程序的学习时,他们不必抛弃在早期阶段学到的知识,而是能够看到这一切是如何融入更大的图景中的。
我们在此提供的更改只是使 Java 语言更易于学习的一步。它们甚至没有解决 Hello, World! 程序中的所有障碍:初学者可能仍然对神秘的 System.out.println
语句感到困惑,并且即使在第一周的程序中,仍然需要导入基本的实用工具类来实现基本功能。我们可能会在未来的 JEP(Java 改进提案)中解决这些问题。
描述
首先,我们增强了 Java 程序启动的协议,以允许实例主方法。这些方法不是 static
的,不必是 public
的,也不必带有 String[]
参数。然后我们可以将 Hello, World! 程序简化为:
class HelloWorld {
void main() {
System.out.println("Hello, World!");
}
}
其次,我们允许一个编译单元,即源文件,来隐式声明一个类:
void main() {
System.out.println("Hello, World!");
}
这是 预览语言功能,默认情况下已禁用
要在 JDK 22 中尝试以下示例,您必须按如下方式启用预览功能:
-
使用
javac --release 22 --enable-preview Main.java
编译程序,并使用java --enable-preview Main
运行它;或者, -
当使用 源代码启动器 时,通过
java --source 22 --enable-preview Main.java
运行程序。
启动协议
Java 虚拟机通过调用某个指定类或接口的
main
方法开始执行,传递给它一个字符串数组作为参数。
JLS 进一步指出:
初始类或接口以何种方式被指定给 Java 虚拟机,这超出了本规范的范围。但在使用命令行的主机环境中,典型的做法是将类或接口的全限定名作为命令行参数指定,而后续的命令行参数则作为字符串提供给
main
方法的参数。
选择包含 main
方法的类、以模块路径或类路径(或者两者皆有)的形式组装其依赖项、加载该类、初始化它,并使用参数调用 main
方法等一系列操作构成了 启动协议。在 JDK 中,这一协议由 启动器 实现,即 java
可执行文件。
一个灵活的启动协议
我们增强了启动协议,以在声明程序入口点时提供更大的灵活性,特别是允许使用 实例 main
方法,具体如下:
-
允许启动类的
main
方法具有public
(公共)、protected
(受保护)或默认(即包级)访问权限。 -
如果启动类包含一个带有
String[]
参数的main
方法,则选择该方法。 -
否则,如果该类包含一个无参数的
main
方法,则选择该方法。 -
无论哪种情况,如果所选方法是
static
(静态)的,则直接调用它。 -
否则,所选方法是一个实例方法,并且启动类必须有一个零参数、非
private
(私有)的构造函数(即具有public
、protected
或包级访问权限)。调用该构造函数,然后调用生成对象的main
方法。如果没有这样的构造函数,则报告错误并终止。 -
如果没有合适的
main
方法,则报告错误并终止。
这些变化让我们能够编写 Hello, World! 时无需访问修饰符、无需 static
修饰符,也无需 String[]
参数,因此可以推迟引入这些结构,直到需要时再进行介绍:
class HelloWorld {
void main() {
System.out.println("Hello, World!");
}
}
隐式声明的类
在 Java 语言中,每个类都位于一个包中,而每个包又位于一个模块中。这些命名空间和封装结构适用于所有代码,但不需要它们的小型程序可以省略它们。不需要类命名空间的程序可以省略 package
语句,使其类成为未命名包的隐式成员;未命名包中的类不能被命名包中的类显式引用。不需要封装其包的程序可以省略模块声明,使其包成为未命名模块的隐式成员;未命名模块中的包不能被命名模块中的包显式引用。
在类作为对象构造模板这一主要目的服务之前,它们仅仅作为方法和字段的命名空间。我们不应该要求学生在他们还没有熟练掌握变量、控制流和子程序等更基本的构建块之前,去面对类的概念;在他们开始学习面向对象之前;当他们还在编写简单、单文件的程序时,我们不应做此要求。即使每个方法都存在于一个类中,我们可以停止要求那些不需要它的代码进行显式的类声明 —— 就像我们不会要求那些不需要它们的代码进行显式的包或模块声明一样。
因此,如果 Java 编译器遇到一个源文件,其中包含未封装在类声明中的方法,那么它会将该方法、任何其他此类方法、任何未封装的字段以及文件中的任何类视为构成一个隐式声明的顶级类的主体。
这样的隐式声明类(简称为隐式类)始终是无名包的一个成员。它也是 final
的,并且除了 Object
之外,不实现任何接口,也不扩展任何类。隐式类不能通过名称引用,因此对其静态方法不能有方法引用;但是,仍然可以使用 this
关键字,也可以使用对实例方法的方法引用。
隐式类的代码不能按名称引用该隐式类,因此无法直接构造隐式类的实例。这样的类仅作为独立程序或程序的入口点才有用。因此,隐式类必须具有一个如上所述可启动的 main
方法。这一要求由 Java 编译器强制执行。
隐式类位于未命名的包中,而未命名的包则位于未命名的模块中。虽然只能有一个未命名的包(不考虑多个类加载器的情况)和一个未命名的模块,但未命名的模块中可以有多个隐式类。每个隐式类都包含一个 main
方法,因此代表一个程序,所以未命名包中的多个此类类代表多个程序。
隐式类几乎与显式声明的类完全相同。其成员可以拥有相同的修饰符(例如,private
和 static
),并且这些修饰符具有相同的默认值(例如,package
访问权限和实例成员资格)。一个关键区别在于,虽然隐式类具有默认的无参构造函数,但它不能拥有其他任何构造函数。
通过这些变化,我们现在可以编写 Hello, World! 如下:
void main() {
System.out.println("Hello, World!");
}
顶层成员被解释为隐式类的成员,因此我们也可以将程序写作:
String greeting() { return "Hello, World!"; }
void main() {
System.out.println(greeting());
}
或者,使用一个字段,如下所示:
String greeting = "Hello, World!";
void main() {
System.out.println(greeting);
}
如果一个隐式类有一个实例 main
方法而不是 static
主方法,那么启动它等同于以下内容,其中使用了现有的匿名类声明结构:
new Object() {
// the implicit class's body
}.main();
一个名为 HelloWorld.java
的源文件包含一个隐式类,可以使用源代码启动器启动,如下所示:
$ java HelloWorld.java
Java 编译器会将该文件编译为可启动的类文件 HelloWorld.class
。在这种情况下,编译器选择 HelloWorld
作为类名是一个实现细节,但这个名称仍然不能直接在 Java 源代码中使用。
javadoc
工具无法为隐式类生成 API 文档,因为隐式类并未定义可从其他类访问的任何 API,但隐式类的字段和方法可以生成 API 文档。
扩展程序
一个以隐式类编写的 Hello, World! 程序更加聚焦于程序实际执行的功能,省略了它不需要的概念和结构。即便如此,所有的成员仍然像在普通类中一样被解析。要将一个隐式类发展为普通类,我们只需要将其声明(不包括 import
语句)包裹在一个显式的 class
声明中即可。
完全消除 main
方法似乎是顺理成章的下一步,但这与将第一个 Java 程序优雅地扩展为更大型程序的目标相悖,并且会施加一些不那么显而易见的限制(参见下方)。同样地,去掉 void
修饰符也会创建出一种独特的 Java 方言。
替代方案
-
使用 JShell 进行入门编程 — JShell 会话不是一个程序,而是一系列代码片段。输入到
jshell
中的声明被隐式视为某个未指定类的静态成员,并具有某种未指定的访问级别,语句在所有先前声明都在作用域中的上下文中执行。这对于实验非常方便 —— 这也是 JShell 的主要用例 —— 但不是学习编写 Java 程序的好模型。将 JShell 中的一批工作声明演变成一个真正的 Java 程序会导致一种非惯用的代码风格,因为它会将每个方法、类和变量声明为
static
。JShell 是一个用于探索和调试的强大工具,但它并不是我们所寻找的入门编程模型。 -
将代码单元解释为静态成员 —— 方法和字段默认是非
static
的。将隐式类中的顶级成员解释为static
会改变此类中代码单元的含义 —— 实际上引入了一种不同的 Java 方言。为了在将隐式类演变为普通类时保留这些成员的含义,我们必须添加显式的static
修饰符。这不是我们从小量方法扩展到简单类时想要的结果。我们希望开始将类作为类来使用,而不是作为静态成员的容器。 -
将代码单元解释为局部变量 —— 我们已经可以在方法内声明局部变量。假设我们也可以声明局部方法,即嵌套在其他方法中的方法。然后我们可以将简单程序的主体解释为
main
方法的主体,将变量解释为局部变量而不是字段,将方法解释为局部方法而不是类成员。这将使我们完全避免使用main
方法并编写顶级语句。这种方法的问题在于,在 Java 语言中,局部变量的行为与字段不同,并且受到更多限制:只有当局部变量是有效 final 时,它们才能从 lambda 表达式体或内部类中访问。提议的设计允许我们以一贯的方式区分局部变量和字段。即使对于新学生来说,编写
main
方法的负担也不算繁重。 -
引入包级方法和字段 —— 可以通过允许在没有显式
package
或class
声明的文件中声明包级方法和字段,来实现类似于上述的用户体验。然而,这样的功能会对一般 Java 代码的编写方式产生更广泛的影响。