跳转到内容

JEP 330:启动单个源文件的程序

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

增强java启动命令(启动器)以运行提供单个Java源码文件的程序,包括从“shebang”文件及相关技术的脚本内部使用。

为了适应shebang文件而修改Java语言规范(JLS)或javac不是目标。类似地,将Java语言发展成为一种通用的脚本语言也不是目标。

本JEP的目标并非修改Java语言规范以适应更简单的小型程序编写方式,例如消除对标准public static void main(String[] args)方法的需求。不过,我们希望任何此类Java语言更改都与本特性结合使用。

单文件程序(即整个程序放在单个源文件中)在Java学习初期以及编写小型工具程序时很常见。在这种情况下,在运行程序之前必须先编译它,这纯粹是“例行公事”。此外,单文件程序可能声明多个类,因此会编译成多个class文件,这会增加打包开销,而“运行此程序”这个简单的目标就显得微不足道了。因此,最好能够使用java启动器直接从源代码运行程序:

java HelloWorld.java

从JDK 10开始,java启动器有三种运行模式:(1)启动class文件、(2)启动JAR文件的主类以及(3)启动模块的主类。这里我们添加了第4种新模式:启动源文件中声明的类。

源文件模式通过考虑命令行中的两个条目来确定:

  1. 命令行中的第一个条目,它既不是参数选项也不是参数选项的一部分。(换句话说,就是此前作为类名的条目。)
  2. --source version选项(如有)。

如果“类名”标识了一个以.java为扩展名的现有文件,则选择源文件模式,并编译并运行该文件。可以使用--source选项指定源代码的源版本。

如果文件没有.java扩展名,则必须使用--source选项强制使用源文件模式。这适用于以下情况:源文件是要执行的“脚本”,并且源文件的名称不符合Java源文件的常规命名约定。(请参阅下文的“shebang”文件。)

使用--enable-preview选项时,还必须使用--source选项来指定源代码的源版本。(请参阅JEP 12。)

在源文件模式下,效果就像将源文件编译到内存中,并执行源文件中找到的第一个类。例如,如果名为HelloWorld.java的文件包含一个名为hello.World的类,则命令:

java HelloWorld.java

基本等同于:

javac -d <memory> HelloWorld.java
java -cp <memory> hello.World

原始命令行中,任何位于源文件名之后的参数都会在执行时传递给编译后的类。例如,如果名为Factorial.java的文件包含一个名为Factorial的类,用于计算其参数的阶乘,则该命令:

java Factorial.java 3 4 5

基本等同于:

javac -d <memory> Factorial.java
java -cp <memory> Factorial 3 4 5

在源文件模式下,任何附加命令行选项均按如下方式处理:

  • 启动器会扫描源文件之前指定的选项,查找与编译源文件相关的选项。这些选项包括:--class-path--module-path--add-exports--add-modules--limit-modules--patch-module--upgrade-module-path以及这些选项的任何变体。此外,它还包含JEP 12中描述的全新--enable-preview选项。
  • 没有规定向编译器传递任何附加选项,例如-processor-Werror
  • 命令行参数文件(@-files)可以按标准方式使用。虚拟机或被调用程序的长参数列表可以放在文件中,这些文件在命令行中通过在文件名前添加@字符来指定。

在源文件模式下,编译过程如下:

  • 所有与编译环境相关的命令行选项都会被考虑。
  • 不会查找和编译其他源文件,如同源路径设置为空一样。
  • 注解处理被禁用,如同-proc:none生效一样。
  • 如果通过--source选项指定了版本,则该值将用作编译时隐式--release选项的参数。这将设置编译器接受的源版本以及源文件中代码可能使用的系统API。
  • 源文件在未命名模块的上下文中编译。
  • 源文件应包含一个或多个顶级类,其中第一个将作为要执行的类。
  • 编译器不强制执行JLS §7.6末尾定义的可选限制,即指定包中的类型应存在于由类型名称加上.java扩展名组成的文件中。
  • 如果源文件包含错误,则相应的错误消息将写入标准错误流,并且启动器将以非零的状态码退出。

在源文件模式下,执行过程如下:

  • 要执行的类是源文件中找到的第一个顶级类。它必须包含标准public static void main(String[])方法的声明。
  • 编译后的类由自定义类加载器加载,该加载器委托给应用程序类加载器。(这意味着应用程序类路径中出现的类不能引用源文件中声明的任何类。)
  • 编译后的类在未命名模块的上下文中执行,就好像--add-modules=ALL-DEFAULT生效一样(除了在命令行上可能指定的任何其他--add-module选项之外)。
  • 命令行中文件名后出现的任何参数都会以显式方式传递给标准main方法。
  • 如果应用程序类路径中存在与要执行的类同名的类,则会出错。

请注意,在使用像java HelloWorld.java这样的简单命令行时,可能存在细微的歧义。以前,HelloWorld.java会被解释为名为HelloWorld的包中名为java的类,但现在如果存在这样的文件,则它被解析为支持名为HelloWorld.java的文件。鉴于这样的类名和这样的包名都违反了几乎普遍遵循的命名约定,并且考虑到这样的类不太可能位于类路径上,而类似名称的文件位于当前目录中,这似乎是一种可以接受的折衷方案。

源文件模式需要jdk.compiler模块。当请求文件Foo.java的源文件模式时,启动器的行为就像命令行已转换为:

java [VM args] \
-m jdk.compiler/<source-launcher-implementation-class> \
Foo.java [program args]

源码启动器的实现类以编程方式调用编译器,编译器将源码编译为内存中的表示形式。然后,源码启动器的实现类创建一个类加载器,以从该内存形式中加载已编译的类,并调用源文件中找到的第一个顶级类的标准main(String[])方法。

源码启动器的实现类可以访问任何相关的命令行选项,例如用于定义类路径、模块路径和模块图的选项,并将这些选项传递给编译器以配置编译环境。

如果调用的类引发异常,那么该异常将传递回启动器,以便以普通方式进行处理。但是,导致类执行的初始栈帧将从异常的栈跟踪中删除。目的是,异常的处理类似于类由启动器本身直接执行时的处理。初始栈帧将在对栈的任何直接访问中可见,包括(例如)Thread.dumpStack()

用于加载已编译类的类加载器本身,对引用类加载器定义的资源的任何URL使用特定于实现的协议。获取此类URL的唯一方法是使用getResourcegetResources等方法;不支持从字符串创建任何此类URL。

当手头的任务需要一个小型程序时,单文件程序也很常见。在这种情况下,最好能够使用类Unix系统(如macOS和Linux)的“#!”机制直接从源代码运行程序。这是一种由操作系统提供的机制,它允许单文件程序(例如脚本或源代码)放置在任何方便命名的可执行文件中,其第一行以#!开头,并指定用于“执行”文件内容的程序的名称。此类文件称为“shebang文件”。

希望能够利用这种机制来执行Java程序。

使用源文件模式调用Java启动器的shebang文件必须以如下内容开头:

#!/path/to/java --source version

例如,我们可以获取“Hello World”程序的源代码,并将其放在名为hello的文件中,放在第一行之后#!/path/to/java --source 10,然后将该文件标记为可执行文件。然后,如果该文件位于当前目录中,我们可以使用以下命令执行它:

$ ./hello

或者,如果文件位于用户PATH中的目录中,我们可以使用以下命令执行它:

$ hello

命令的所有参数都会传递给被执行类的main方法。例如,如果我们将计算阶乘的程序源代码放入名为factorial的shebang文件中,则可以使用如下命令执行它:

$ factorial 6

对于以下情况,必须在shebang文件中使用--source选项:

  • Shebang文件的名称不符合Java源文件的标准命名约定。
  • 建议在shebang文件的第一行指定额外的VM选项。在这种情况下,应在可执行文件名称之后首先指定--source选项。
  • 需要在文件中指定源代码所使用的`Java语言版本。

启动器还可以使用如下命令明确调用shebang文件,或许还可以使用其他选项:

$ java -Dtrace=true --source 10 factorial 3

Java启动器的源文件模式针对shebang文件做出了两项调整:

  1. 当启动器读取源文件时,如果该文件不是Java源文件(即,它不是以.java结尾的文件),并且第一行以#!开头,则在确定要传递给编译器的源代码时,该行的内容(不包括第一个换行符)将被忽略。第一行之后出现的文件内容必须由Java语言规范版本中§7.3定义的有效CompilationUnit组成,该CompilationUnit适用于--source选项中指定的平台版本(如有)或用于运行程序的平台版本(如果--source选项不存在。)
    第一行末尾的换行符被保留,以便任何编译器错误消息中的行号在shebang文件中都有意义。
  2. 某些操作系统会将可执行文件名称后第一行的文本作为单个参数传递给可执行文件。因此,如果启动器遇到以--source开头且包含空格的选项,它会被拆分成一系列单词,并以空格分隔,然后再由启动器进行进一步分析。这允许在第一行放置其他参数,但某些操作系统可能会对行的总长度施加限制。不支持使用引号来保留此类值中的空格。

无需对JLS(Java语言规范)进行任何更改即可支持此功能。

在shebang文件中,前两个字节必须是0x23 0x21,即#!的双字符ASCII编码。所有后续字节均使用生效的默认平台字符编码读取。

只有当需要使用操作系统的shebang机制执行文件时,才需要以#!开头的第一行。当明确使用Java启动器运行源文件中的代码时,无需任何特殊的第一行,例如上面给出的HelloWorld.javaFactorial.java示例。事实上,使用shebang机制执行遵循Java源文件标准命名约定的文件是不允许的。

现状已经维持了20多年;我们可以继续维持下去。

除了使用#!之外,还可以配置支持shebang文件的系统使用不同的前缀,例如//!。这样的前缀会被javac视为单行注释,不需要任何特殊处理以忽略它。然而,引入一种新的macOS和Linux等操作系统上的魔法数字需要对此类系统进行手动或自动更新,这超出了本JEP的范围。

除了使用shebang机制之外,还可以编写一个shell脚本包含Java源代码,作为一个Here document而传递给Java源码启动器。虽然这最终是一种比shebang机制更灵活的机制,但在简单情况下,它的开销也比shebang更高。

我们可以创建一个源码启动器,但不使用java的名称,例如可以叫jrun。考虑到启动器已经拥有的执行模式数量,这很可能被认为是一个不必要的差异。

我们可以将“一次性运行”的任务委托给jshell工具。虽然这乍一看可能很明显,但这在设计中显然不是目标。jshell工具被设计为一个交互式shell,并且许多设计决策都是为了提供更好的交互体验。如果给它加上批处理运行器的额外限制,将会损害其交互体验。

我们也可以使用jrunscript工具。但是,该工具与运行时环境交互的功能有限,无法满足我们简单介绍Java使用方法的需求。