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