2021/7 事前コンパイルしないJava

間丈

今年2021年の9月には新しい LTS バージョンである Java 17がリリース予定ですね。
私はここ数年、 Java 8 あたりで知識が止まっていたのですが、最近ようやく Java 9 以降について調べ直しました。

そのなかで、Java 11 で新たに実装された「事前コンパイルをしないでプログラムを実行できる」という機能を知りました。
コンパイル言語である Java を、事前コンパイルなしで実行できるなんて面白くありませんか?
機能についての提案である「JEP 330: Launch Single-File Source-Code Programs」からポイントを絞って、以下をご紹介いたします。

全体的に初心者の方にもわかるように説明しています。
「活用方法」「どんな場面で役立つか」では実践的な使い方について記述しているので、中級者以上の方にも楽しんでいただけると思います。
なお、以下で記載しているコマンドは Windows 環境のものです。

機能の概要

この記事でご紹介する「Launch Single-File Source-Code Programs」は Java SE 11 から導入された、単一ファイルのソースコードを事前コンパイルなしで実行できる機能です。

プログラムをすぐに実行できる

Java を初めて勉強したとき、javacコマンドでコンパイルして、javaコマンドで HelloWorld を実行した経験はありませんか?

C:\tmp>javac HelloWorld.java

C:\tmp>java HelloWorld
Hello World!

Java 11 からは、javaコマンドのみで単一の Java プログラムを実行できます。
javaコマンドのヘルプにも「単一のソースファイル・プログラムを実行する場合」として説明が追加されています。

C:\tmp>java --help
使用方法: java [options] <mainclass> [args...]
           (クラスを実行する場合)
   または  java [options] -jar <jarfile> [args...]
           (jarファイルを実行する場合)
   または  java [options] -m <module>[/<mainclass>] [args...]
       java [options] --module <module>[/<mainclass>] [args...]
           (モジュールのメイン・クラスを実行する場合)
   または  java [options] <sourcefile> [args]
           (単一のソースファイル・プログラムを実行する場合)

(以下省略)

内部的には、 java ファイルがメモリ上でコンパイルされ、そのメモリ上のクラスファイルが実行されるそうです。
コンパイルをスキップしているわけではないので、 JDK が必要である点はご注意ください。JRE のみでは動きません。

では、さっそく試してみます。

実際に使ってみる

詳細な説明は「JEP 330: Launch Single-File Source-Code Programs」に書かれていますが、実際に使いながら以下の仕様を確かめてみます。

最も簡単な HelloWorld

まず、最も簡単な HelloWorld のプログラムを実行してみます。
プログラムは単一のファイルである必要があります。
以下の HelloWorld.java で試してみましょう。

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

使い方はjava [javaファイル名]コマンドを実行するだけです。
従来のクラスファイルを実行するコマンドとの違いは、拡張子「.java」が付いている点だけです。

# コンパイル済みのクラスファイル(.class)を実行
C:\tmp>java HelloWorld
Hello World!

# 新機能の実行方法
C:\tmp>java HelloWorld.java
Hello World!

クラスファイルを実行する方法とは異なる仕様がありますので、以降で実際に動かしながら確認してみます。

複数のクラスが使える

この機能には単一ファイルという制限がありますが、単一のクラスである必要はありません。
Java では従来から1ファイルに複数のクラスを定義できましたね。
2つのクラスを定義した、以下の MultiClass.java を実行してみます。

public class MultiClass {
    public static void main(String[] args) {
        System.out.println(Hoge.FUGA);
    }
}
class Hoge {
    static final String FUGA = "FUGA";
}

以下が実行結果です。

C:\tmp>java MultiClass.java
FUGA

実行されるのは、一番上に定義されているクラスのmainメソッドです。一番上のクラスにmainメソッドがない場合、エラーとなります。
MultiClassクラスとHogeクラスの順番を入れ替えると、以下の実行結果となります。

C:\tmp>java MultiClassNG.java
エラー: クラスにmain(String[])メソッドが見つかりません: Hoge

なお、通常は1ファイルに複数の public なクラスを定義するとコンパイルエラーになりますが、この機能ではエラーなく実行することができます。
2つの public クラスを定義した、以下の MultiPublicClass.java を実行してみます。

public class MultiPublicClass {
    public static void main(String[] args) {
        System.out.println(Hoge.FUGA);
    }
}
public class Hoge {
    static final String FUGA = "FUGA";
}

実行結果は以下です。

# コンパイル
C:\tmp>javac MultiPublicClass.java
MultiPublicClass.java:6: エラー: クラス Hogeはpublicであり、ファイルHoge.javaで 宣言する必要があります
public class Hoge {
       ^
エラー1個

# 新機能での実行
C:\tmp>java MultiPublicClass.java
FUGA

「複数の public なクラスを定義できる」ことは驚きでしたが、よく考えるとあまりメリットはありません。
他のクラスから、コンパイルできないこのファイルのクラスにアクセスするケースはまずないでしょう。

ファイル名とクラス名が異なってもOK

通常は、ファイル名と public なクラス名が一致していなければコンパイルエラーが発生します。
この機能では、ファイル名は任意に付けることができます。ファイル内のクラスと全く関係ない名称で問題ありません。
FileName.java に、 public なClassNameクラスを定義して実行してみます。

public class ClassName {
    public static void main(String[] args) {
        System.out.println(ClassName.class.getSimpleName());
    }
}

以下が実行結果です。

C:\tmp>java FileName.java
ClassName

コンパイルがエラーになることも確認しておきましょう。

C:\tmp>javac FileName.java
FileName.java:1: エラー: クラス ClassNameはpublicであり、ファイルClassName.java で宣言する必要があります
public class ClassName {
       ^
エラー1個

「ファイル名と public なクラス名は異なってもよい」ことを踏まえると、先ほどの「複数の public なクラスを定義できる」という仕様も腑に落ちますね。

コンパイルエラーと例外時の挙動は通常通り

前述のように、一部通常のコンパイルとは異なる挙動がありますが、その他のコンパイルエラーは通常通り発生します。
文末のセミコロンが欠落した CompileError.java を実行し、通常のコンパイル時と比較してみます。

public class CompileError {
    public static void main(String[] args) {
        System.out.println("Hello World!")  // missing ';'
    }
}

以下が実行結果です。

# 新機能での実行
C:\tmp>java CompileError.java
CompileError.java:3: エラー: ';'がありません
        System.out.println("Hello World!")  // missing ';'
                                          ^
エラー1個
エラー: コンパイルが失敗しました

# 通常のコンパイル
C:\tmp>javac CompileError.java
CompileError.java:3: エラー: ';'がありません
        System.out.println("Hello World!")  // missing ';'
                                          ^
エラー1個

新機能での実行時は「エラー: コンパイルが失敗しました」という一行が追加で出力されています。
内部的には「コンパイル」→「プログラム実行」の順で処理されますが、そのうちの「コンパイル」でエラーが発生したことがわかりやすいですね。

エラー発生

例外の発生については、通常と変わりません。
ThrowException.java を実行してみます。

public class ThrowException {
    public static void main(String[] args) throws Exception {
        throw new Exception();
    }
}

結果は以下です。
コンパイルしたクラスを実行した場合と結果は全く変わりません。非検査例外のときも同じです。

C:\tmp>java ThrowException.java
Exception in thread "main" java.lang.Exception
        at ThrowException.main(ThrowException.java:3)

他のファイルを参照する方法もある

「単一のファイル」という制限がありますが、実は他のファイルを参照する方法もあります。

先にエラーになるケースをご紹介します。
以下の Caller.java は他のファイルのCalleeクラスを参照しています。

package sample;

import sample.Callee;

public class Caller {
    public static void main(String[] args) {
        System.out.println(Callee.MESSAGE);
    }
}

以下が被参照クラスを定義した Callee.java です。Caller.java と同じパッケージに配置します。

package sample;

public class Callee {
    public static final String MESSAGE = "callee message";
}

Caller.java を実行すると、以下のエラーが発生します。

C:\tmp>java sample/Caller.java
sample\Caller.java:3: エラー: シンボルを見つけられません
import sample.Callee;
             ^
  シンボル:   クラス Callee
  場所: パッケージ sample
sample\Caller.java:7: エラー: シンボルを見つけられません
        System.out.println(Callee.MESSAGE);
                           ^
  シンボル:   変数 Callee
  場所: クラス Caller
エラー2個
エラー: コンパイルが失敗しました

Callee クラスが見つからないという内容です。
であれば、 Callee クラスをコンパイルしておいた場合はどうなるでしょうか?
試してみましょう。

# Callee.java (被参照クラス)をコンパイル
C:\tmp>javac sample/Callee.java

# Callee.class ができたことを確認
C:\tmp>dir /b sample
Callee.class
Callee.java
Caller.java

# Caller.java を実行
C:\tmp>java sample/Caller.java
callee message

実行できました!
「単一ファイル」という制限がありますが、これはコンパイル&実行されるのが単一ファイルであるというだけで、他のファイル・クラスを利用できないという意味ではないことがわかります。

ここまでの結果を踏まえて、この機能の活用方法を考えてみます。

活用方法

以下2つの活用方法を考えてみました。

jar の呼び出し

先ほど、他のファイルを呼び出せることを確認しましたが、同様に jar を利用することもできます。
要するに、コンパイル済みであれば、他ファイルのクラスを利用することができるということですね。
コマンド実行時、コンパイルオプションを渡すことができますので、クラスパスとして対象の jar を指定しましょう。
以下の「ImportJar.java」では PostgreSQL の JDBC ドライバーの jar を利用しています。

import org.postgresql.PGProperty;
 
public class ImportJar {
    public static void main(String[] args) {
        System.out.println(PGProperty.APPLICATION_NAME);
    }
}

実行結果は以下です。

C:\tmp>java --class-path postgresql-42.2.22.jar ImportJar.java
APPLICATION_NAME

これにより、 jar の使い方や動作を手軽に確認できます。
また、 jar の使い方の説明資料を作る代わりに、呼び出しスクリプトを作るのもいいでしょう。具体的な呼び出しシーケンスも例外ハンドリングも含められますし、実際に動かすことすら可能です。

バッチ呼び出しの起点にする

そのほか、jar を呼び出すバッチ処理の起点にできます。
jar の処理にファイルパスを渡すための前処理が必要な場合や、実行結果をファイルに出力する後処理が必要な場合などに有用です。
従来はシェルスクリプトなどの出番でしたが、この機能により「Java のスクリプト」という選択肢ができました。
次の shebang と組み合わせると Java のスクリプトはさらに使いやすくなるでしょう。

shebang を利用したスクリプト

shebang は UNIX (Linux, macOS など) のスクリプトの#!から始まる最初の1行のことです。 shebang で指定したインタプリタによってスクリプトが読み込まれます。
javaコマンドのみでプログラムが実行できるようになったため、この shebang の仕組みで Java プログラムが実行できます。

Linux の環境で「HelloWorld」という拡張子なしのファイルを実行してみます。
ファイルの中身は後でご説明しますので、先にコマンドと実行結果を見てください。

[root@478a8cb687dd shared]# ./HelloWorld
Hello World

javacコマンドどころか、javaコマンドも java ファイルもなくなっているところが面白くありませんか?

まるで手品のよう

実行した「HelloWorld」ファイルを見てみるとタネがわかります。

#!/usr/bin/java --source 11
public class Batch {
    public static void main(String[] args) {
        System.out.println ( "Hello World" );
    }
}

1行目の shebang の仕組みにより、裏側で java コマンドが使われることがわかりますね。
ちなみに、shebang でも以下のようにクラスパスの指定をして、jar ライブラリを利用することができます。

#!/usr/bin/java --class-path postgresql-42.2.22.jar --source 11

重要な注意点として、拡張子が「.java」のファイルは shebang の仕組みを利用できません。拡張子なしとするか、別の拡張子を付ける必要があります。

この機能は初学者向きか?

この機能はコマンドひとつで、すぐに Java プログラムを実行できます。
「簡単に動かせるので初学者向き」という考え方もあると思いますが、個人的には、あまり初学者向きではないように思います。

悩んでしまうネコ

コンパイル言語に慣れている場合は問題ないと思いますが、プログラム初心者の場合は「コンパイルする」という工程が隠蔽されてしまい、言語に対する理解の妨げになるように思います。
コマンドから jar でない java ファイルを実行するのは、 HelloWorld くらいでしょうから、そのときくらいはコンパイルを意識するのがよいのではないかと思っています。

どんな場面で役立つか

ここまでで「Java である必要はあるのか?」「シェルスクリプトや Python でいいのではないか?」と思いませんでしたか?

確かにこの機能は、「他の言語にはない特別な機能を提供している」わけではありません。
むしろ「他の言語で簡単にできることを、Java でも簡単にできるようになった」ものと言えます。
ですから、以下のような「別段 Java にする理由がない」場面では積極的に採用する必要はないでしょう。

一方で、以下のような場面では採用を検討する価値があるのではないでしょうか。

ひとつめの場面について、参考にできる Java プログラムがある場合、シェルスクリプトよりも、同じ Java で書く方が流用できる度合いが大きいでしょう。
また、他の資産が Java ならば、別の言語を混在させるよりも Java で書きたいという考え方は自然なことだと思います。

ふたつめについて、社内に Java 要員が多い場合は、担当者の配置転換等があっても保守しやすいというメリットがあります。

みんなで解決しよう

Java のプログラム言語人口はいまだに多いと言えるでしょうから、外部からの補充も比較的容易だと思われます。
スクリプトを作ったばかりに「シェルスクリプトは○○さんしかわからないから」と属人的な保守作業をしつづける…そんな悲しい宿命から解放されるかもしれません。

まとめ

「コンパイル言語なのに事前コンパイルしない」というキャッチーな特徴に興味を持って調べてみました。
実際に使ってみると「あれはできるのか?」「何に活用できそうか?」と、疑問が湧いてきて深堀りできますね。
今後も Java に限らず、「まずちょっと使ってみる」ことを大事にしたいと思います。

こんな記事も読まれています