(rontolisp) docs

JVM バイトコードへのコンパイル

-o.class で終わる出力パスを rontolisp に渡すと、ソースをインタプリタで実行する代わりに JVM バイトコードへ直接コンパイルします。ASM などのライブラリは使わず、バイトコードは手作業で出力されます。バックエンドを選択するのは出力の拡張子です(JVM なら .class または .jar、WASM なら .wasm)。

echo '(print (+ 1 2))' > hello.lisp
rontolisp hello.lisp -o Hello.class
java Hello

生成されるクラスは出力ファイルにちなんで命名されるため、java に渡す名前はファイルの語幹(ステム)になります。-o Hello.class はクラス Hello を生成し、java Hello で実行します。パス中のディレクトリはクラスの Java パッケージになります: -o com/example/Kernels.classcom.example.Kernels を生成し、パスの起点ディレクトリから java -cp . com.example.Kernels で実行します(足りないディレクトリは作成されます)。プログラムのトップレベルのフォームはクラスのエントリーポイントになり、起動時に順番に実行されます。

-o out.jar は素のクラスではなく jar を書き出します。クラス本体、一緒に運ばれなければならないランタイムクラス、マニフェスト、そして --maven-coordinates があれば、コンシューマがフラグを一切付けずにインストールできる Maven メタデータが入ります。この jar は実行可能で、そのまま動きます:

rontolisp hello.lisp -o hello.jar
java -jar hello.jar

jar のパスはどのクラスも名指ししないので、中のクラスはファイルの語幹(ステム)を CamelCase にした名前を取り(hello.jar なら Hellomy-app-1.0.0.jar なら MyApp100)、マニフェストの Main-Class がそれを指します。この名前が意味を持つのはクラスを直接呼ぶときだけです。名前は --class-name で指定でき、--no-main のライブラリ jar では必須です。そちらのクラスはエントリポイントではなくアーティファクトの Java API そのものだからです。このフラグは .class 出力でも使え、その場合はパスが与える名前を置き換えます。

クラスは Java コードが直接呼び出すライブラリにもなれます: rontolisp:jvm-exportdefun に対して型付きで Java から呼び出し可能な static メソッドを宣言し、--no-mainmain エントリポイントを完全に取り除きます。JVM ライブラリのエクスポート を参照してください。

例(hello.lisp):

3

最適化(デッドコード除去)

コンパイルは、main から到達できないすべてのメソッドを、それらだけが参照していた static フィールドとともに除去し、それに合わせて定数プールを圧縮します。これは何も指定しなくても得られます:

echo '(defun fact (n) (if (<= n 1) 1 (* n (fact (- n 1)))))
(print (fact 10))' > fact.lisp
rontolisp fact.lisp -o Fact.class
java Fact

fact のような小さなプログラムでは、クラスは約 6.5 KB です。--optimize=off を渡すと、クラスは代わりにプログラムが実際に使用するものに関係なく ランタイム全体(プリンター、数値、リーダーおよび eval ヘルパーメソッド、さらにすべての組み込み関数のファーストクラスラッパー)を埋め込み、同じ fact で約 190 KB になります。この除去は動作を保存します。到達可能性はバイトコード中の実際の invoke 命令を辿るため、ファーストクラスの関数値、funcall、または組み込みの eval/load がディスパッチできるものはすべて保持され、java: 連携ブリッジのリフレクションによるエントリーポイントも明示的なルートとして残ります。各 rontolisp:jvm-export の型付きメソッドも明示的なルートです — その呼び出し側はバイトコードには現れない Java コードだからです。これがコンパイルされたライブラリがデフォルトのサイズを保てる理由です。同じレベルは WASM 出力のツリーシェイキングも行います。

funcall が経由するディスパッチメソッドに載るのは、プログラムが実際に値として取得しうる関数だけです — #'name、クォートされた 'name の指定子、lambda、そして(プログラムが internfind-symbol などのシンボルビルダーを持つ場合には)その名前を綴る文字列またはキーワードの定数。それ以外は普通のデッドコードとなり、シェイカーが除去します。この絞り込みが無効になり、すべての関数が到達可能なまま残るのは、今回のコンパイルが決して目にしないデータからプログラムが関数を名指しできる場合だけです。該当するのは evalreadread-from-string、実行時の load~/name/ という format ディレクティブのいずれかの使用(読み込んだライブラリの中にあるものも含む)で、--dynamic も同様です。-Drontolisp.debug.dispatchgate=true を付けてコンパイルすると、原因となった演算子をコンパイラが名指しします。

そこから 1 つの例外が生じます: 実行時に計算された断片から組み立てた指定子 — (funcall (intern (concatenate 'string "gre" suffix))) — はコンパイラが読める定数ではないため、その呼び出しは通常の「undefined function」エラーを通知します。戻る手段は --dynamic です。--optimize=off ではありません: この絞り込みはレベルが切り替える対象に含まれないため、最適化を断ってもそうした名前が戻ってくることはありません。

--optimize は省略可能なレベルを取り、これは WASM バックエンドと共通です。--optimize--optimize=default はどちらも、フラグを書かなかったときに既に選ばれているもの — 上に述べたすべて — を綴ったもので、ビルドスクリプトで明示したい場合に使います。--optimize=off はそれを断り、このフラグがデフォルトで有効になる前のビルドが出力していたものを出力します。--optimize=size はバックエンドが出せる最小の出力を要求します。このバックエンドでこのレベルが断るのは、速度のためにバイト数を使う 2 つのコード生成です。1 つは型付き数値ループです。本体がパックド単精度/倍精度配列を fixnum の添字計算・let の一時変数・+ - * /・単項の数学関数・if/when/unless の判定で読み書きする dotimes は、デフォルトではプリミティブな long/double のループとして、生の float[]/double[] アクセスにコンパイルされます。ループ入口の検査で、変数が型付けの仮定どおりでなければ通常のコード生成にフォールバックするので、値はどちらの経路でも同じで、数倍速く、本体が複数回出力されるぶんクラスは大きくなります。もう 1 つは整数式木の融合です。ネストした + - * mod rem logand logior logxor lognot ash の式木は、デフォルトでは木全体を生の long 演算で計算して結果だけをボックス化する共有メソッドにコンパイルされ、実行時にマシンワードの整数でない値に備えて汎用の演算チェーンがフォールバックとして併置されます — こちらも値は同じで、クラスは各木を 2 回持ちます。--optimize=size は通常のコード生成だけを残します。どちらの形も持たないプログラムは両レベルで同じクラスにコンパイルされ、そもそも同じプログラムの JVM バイトコードは WASM の約 3 分の 1 のサイズです。したがって、1 つのビルドスクリプトがすべてのターゲットに --optimize=size を渡して構いません。

--optimize=off の用途は 2 つで、そのどちらもプログラムを動かすことではありません: コンパイラの変更前にビルドした成果物との比較と、シェイクしていないクラスの挙動が違うかどうかを問うことによるシェイカーのバグの二分探索です。コンパイラが読めない名前を通してしか到達できない関数を持つプログラムに必要なのは、上に述べたとおり --dynamic です。

レベルとは独立に、コンパイルは常にスプライスしたライブラリをツリーシェイキングします。対象は同梱の Lisp ソースライブラリ(linalg:vec:、JSON、URL、equalp/string<)と、asdf:load-system / ql:quickload で読み込んだシステムです。プログラムがソース中でその名前に一切言及しない(クォートされたシンボルや文字列リテラルの中も含む)関数・変数・定数はコンパイル結果に含まれません。あなた自身のコードが刈られることはなく、load/require でスプライスされたファイルも刈られません。対象になるのはシステム由来のライブラリだけです。

クラス・総称関数・メソッド・コンディション・構造体も同じ規則で刈り込まれます。どこからも参照されないクラスはそのメソッドごと除かれ、プログラムが呼ぶ総称関数のメソッドであっても、特化先クラスのインスタンスを到達可能なコードが作れないものは除かれます。標準プロトコル名(initialize-instanceprint-objectclose など)のメソッドは呼び出しが暗黙なので、特化先クラスの生死だけに従います。

その帰結として、実行時に計算した文字列から名前を組み立てて eval/apply 経由で呼び出すライブラリ関数は、通常の「undefined function」エラーを通知します。その場合は --no-prune(または --dynamic)を付けてコンパイルすると、すべてのライブラリ定義が保持されます。

生成される .class ファイルは Java 17(クラスバージョン 61)をターゲットとするため、実行には Java 17 以降の JRE が必要です。java.langjava.io のほか、出力されるランタイムヘルパーは java.math(オーバーフロー時に昇格する整数演算と厳密な有理数演算のための BigInteger/BigDecimal/MathContext)と java.utilArrayList/Arrays、およびハッシュテーブル用の HashMap)を参照します。rontolisp:fetch を呼び出すプログラムは追加で java.net/java.net.http を参照し、rontolisp:await / rontolisp:futurep は future を java.util.concurrent のフューチャーとして表現しますが、これらはいずれも Java 17 に含まれるため、要件が上がることはありません。唯一の例外は java: 連携パッケージを使うプログラムで、コンパイラが (プロジェクト自身の Java リリースでコンパイルされた) リフレクションブリッジをクラスに埋め込むため、rontolisp をビルドした JRE と同等以上に新しい JRE が必要です。