こんにちは。NaCl 松江本社のyharaです。今回はBiwaSchemeのリファクタリングをした話を書きます。
BiwaSchemeは私が開発しているScheme処理系で、JavaScriptで書かれていてブラウザおよびNode.js上で動作します。BiwaSchemeの開発を始めたのはまだ学生だった2007年頃で、修論のため(=卒業のため)に必要というのが主な動機でした。
それから10年以上が経ち、JavaScriptを取り巻く環境も大きく変わりました。それに伴い、BiwaSchemeのコードベースも時代に合わない記述が増えてきました。
最も大きなものは、src/library/r6rs_lib.jsを囲む巨大なwith文です。
最近JavaScriptを学び始めた人は、with文というものの存在を知らないかもしれませんね。with文は、例えば以下のようなコードを、
BiwaScheme.Pair(1, BiwaScheme.nil)
以下のように短く書けるというものです。
with (BiwaScheme) {
Pair(1, nil)
}
便利そうに見えますが、実行速度の低下などさまざまなデメリットがあり、現在では非推奨とされています。
本来はこのような問題は、モジュールシステムによって解決されるべきです。pair.jsにはPairを提供するコードがあり、それを使う側ではpair.jsをimportする。
どんなプログラミング言語にもあるようなありふれた機能ですが、JavaScriptの場合はブラウザ上で動くという特殊性もあり、なかなか仕様がまとまりませんでした。そんな中、満を持して登場したのがECMAScript modules(以下、ES Modules)という規格で、これからのJavaScript環境の標準となることが期待されています。
そこで、BiwaSchemeもES Modules形式への書き換えを行うことに決めました。といっても本体・ライブラリを合わせて1万行ほどあるため、コミット数100を超える大規模なプルリクエストになりましたが、数ヶ月かけて少しずつ作業することでなんとか完了することができました。
以下では行った作業についていくつか解説したいと思います。
複数の.jsファイルを一つに結合するツールにはWebpack, browserify, parcelなどさまざまなものがありますが、BiwaSchemeの場合は以下の要件がありました。
これらを満たすものとして、rollup.jsを選択しました。
ES ModulesはJavaScript標準のモジュールシステムで、Google Chromeなど最近のブラウザではすでに実装されています。いままで開発環境ではこういうことをして全てのjsファイルをロードしていたのですが、ES Modulesに従えばmain.jsを読み込むだけで芋づる式に依存する.jsファイルを読み込むことができます。こうしておけば、新しい.jsファイルが増えても「ファイル一覧」のようなものをメンテせずに済みます。
Node.jsにおいてはES Modules対応はまだexperimentalなので、rollupの機能を利用してcjs形式に変換します。またブラウザにおいても、一般配布する際には単一のファイル(biwascheme.js)にしたいので、rollupでiife形式に変換します。iifeはImmediately Invoked Function Expressionの略で、要するに全体を (function(){ ... })()
で囲った形式のことです。よく見るやつですね。
ここからは実際のPull Requestを見ていきましょう。まずは、各ファイルをES Moduleに沿うようimport、exportを追加します。併せて、BiwaScheme.
をグローバルな名前空間として使っていたのをやめ、適宜constに置き換えます。
Set
、Error
、Symbol
といった変数名は現代のJavaScriptでは組み込みのものと衝突するので、それぞれBiwaSet
, BiwaError
, BiwaSymbol
という変数名に置き換えました。これらはビルド後はBiwaScheme.Symbol
など従来の名前で参照できるため、後方互換性は保たれています。
BiwaSchemeは内部実装にunderscore.jsを使っています。underscore.jsはES Module版が配布されているため、以下のようにしてimportしておけば、最終的なビルド結果に含めることができます。
import * as _ from "../deps/underscore-1.10.2-esm.js"
もう一つ、underscore.stringというライブラリにも依存がありました。これについては使用箇所を確認したところ_.str.truncate
を使っているだけだったので、この機会にtruncateだけを移植して依存から外すことにしました。
BiwaSchemeが提供するライブラリ関数のうち、DOMを操作するものなどはjQueryに依存しています。jQueryにはES Module版は無いようですが、このパッチで無理やりES Module化することができました。import側は以下のようになります。
import $ from "../deps/jquery-3.5.1-esm.js"
BiwaSchemeはブラウザとNode.jsの両方に対応していますが、一部の機能はブラウザとNodeで実装が別になっています。例えばdisplay
関数で何かを出力したとき、ブラウザ上では<div id="bs-console">
の中に出力しますが、Nodeではstdoutに出力するようになっています。
さてこれをES Moduleで実現するにはどうしたら良いでしょうか?複数の実装を用意すること自体は簡単です。2つのファイル、platforms/browser/console.jsとplatforms/node/console.jsを作成するだけです。
問題はこれを使用する側です。importで両方を読み込んでも、期待した結果にはなりません。やりたいのは、エントリポイントとなるファイルがmain-browser.jsとmain-node.jsのどちらだったかに応じて実装を切り替えることです。
少し試行錯誤したところ、以下の手順でなんとかなることが分かりました。まず、以下のような空実装のconsole.jsを用意します。Consoleを使う場所ではこのファイルのみimportします。
const Console = {};
export default Console;
// Actual implementation is in src/platforms/*/console.js
次に、main-browser.jsではplatforms/browser/console.js、main-node.jsではplatforms/node/console.jsをimportするようにします。前者の中身は以下のようになっています。
import * as _ from "../../deps/underscore-1.10.2-esm.js"
import { inspect } from "../../system/_writer.js"
import Console from "../../system/console.js"
import { Port } from "../../system/port.js"
Console.puts = function(str, no_newline) {
Port.current_output.put_string(str + (no_newline ? "" : "\n"))
};
Console.p = function (/*ARGS*/){
Port.current_output.put_string(
"p> "+_.map(_.toArray(arguments), inspect).join(" ")
);
};
export default Console;
ポイントは、このファイルではConsoleを定義せず、共通のconsole.jsからimportしていることです。これによって、インターフェイスは共通にしたまま、実装のみを切り替えることができました。
これがES Module的に正しいやり方なのかは分かっていませんが(例えば、rollup.jsでしか動かないかもしれない)、とりあえずこれでいけたということだけ報告しておきます。
ES Module化によって、以下のような良いことがありました。
BiwaScheme
だけがエクスポートされるべきだが、polar_or_real
など一部の補助関数がグローバルに漏れていた。ES Module化によって、意図しないものがエクスポートされることが無くなった。時間はかかりましたが、これからの開発の基盤を整えるという意味でやる価値はあったと思っています。