Parenscript上でシンボルのインポートやエクスポートを模倣する

前書き

Lisp Advent Calendar 2017の20日目の記事です。

Common LispのサブセットコードをJavaScriptに変換するParenscriptというライブラリの上でそれらしい名前空間を導入してみた記事です。考え方や実装の整理・メモという意味合いが強いので、果たしてこれを読んで何かの役に立つのかは疑問ですが、Common Lispのパッケージ・シンボルシステムを利用したメタプログラミングについて考える一つの題材になる…かもしれません。

今回の話の発端ですが、まずParenscriptで不便に思ったところを適当に拡張した自作ライブラリps-experimentというものを作っています(quicklispリポジトリ未登録)。

github.com

かれこれ2年ほどいじっていて、割合この上で快適にプログラムを書けるようになってきたのですが、ふと名前の衝突を気にしながら書いていることに気付きました。というのも、グローバル変数や関数の定義をパッケージでグループ化するという程度の簡易パッケージシステムは導入していたものの、名前空間の分割までは行っていなかったためです。そこで、Common Lispのパッケージ・シンボルが持つ情報やJavaScriptクロージャを使えば案外簡単にJavaScript上でシンボルのインポートやエクスポートを模倣できるのではないか、と思って試したところ意外と苦労したというような話です。

誤解のないように強調しておくと、今回の話はParenscript上に独自のパッケージ管理構文を作りこむような話ではなく、あくまで既存のCommon Lispのパッケージ・シンボルの情報を利用することで名前空間が分離されたJavaScriptコードを吐き出すという話です。

なお、該当のコミットは"cef5d6: Change to split namespace by package"になります(コミットログが割と本記事の要約)。

前提知識

Parenscript

超簡易紹介

ParenscriptはCommon Lispの(サブセット)コードをJavaScriptコードに変換してくれるライブラリです。下のように ps:ps マクロの中にCommon Lispコードを書くとJavascriptコードを文字列として出力してくれます。

CL-USER> (ql:quickload :parenscript :silent t)
(:PARENSCRIPT)
CL-USER> (ps:ps (test-func 10 20))
"testFunc(10, 20);"
CL-USER> (ps:ps (funcall (lambda (a b) (+ a b))
                         10
                         20))
"(function (a, b) {
    return a + b;
})(10, 20);"

参考:Parenscriptの持つ名前空間システム

参考程度の話ですが、Parenscript自身も名前空間システムを持っています。ps:ps-package-prefixというマクロを利用して、パッケージとそれに対応するプレフィックスを登録しておくというものです。すると、登録されたパッケージ下のシンボルをJavaScriptコードとして出力する際に、プレフィックスが付与されます。

実際のところ、これに素直に乗っかれば今回引っかかったような各種問題は発生しないはずです。が、下記の通り、該当するあらゆるシンボルにプレフィックスがついてしまうため余り見た目が良くない*1ですし、また曲がりなりにもJavaScriptにも名前空間を分ける仕組み(クロージャ)はあるにも関わらずそれを使っていない…という辺りが悶々とします。

CL-USER> (defpackage test-pack (:use :cl :parenscript))
#<Package "TEST-PACK">
CL-USER> (in-package :test-pack)
#<Package "TEST-PACK">
TEST-PACK> (setf (ps-package-prefix "TEST-PACK") "some_prefix_")
"some_prefix_"
TEST-PACK> (ps (defvar x 100)
               (defun test (y)
                 (let ((temp (+ x y)))
                   (+ temp 100))))
"var some_prefix_x = 100;
function some_prefix_test(some_prefix_y) {
    var some_prefix_temp = some_prefix_x + some_prefix_y;
    return some_prefix_temp + 100;
};"

ps-experimentにおける従来のパッケージの扱い

もともとps-experimentでもパッケージの情報を少し使っていたのですが、せいぜいシンボルのグループ化と、package-use-listを利用した依存性解決程度でした。

簡単なRoswellスクリプトで利用イメージを書くと下のような感じです。defvar.psdefun.psはパッケージ配下にJavaScript用の定義をひもづけるためにps-experimentで定義しているマクロです(なお、defvar.ps+defun.ps+とするとCommon Lisp用のコードも同時に出力されます)。最後にwith-use-ps-packマクロを利用して指定されたパッケージ(とそこから再帰的にuseされるパッケージ)配下のJavaScriptの定義を吐き出します。

#!/bin/sh
#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#
(ql:quickload :ps-experiment)

(defpackage pack-a
  (:use :cl :ps-experiment))
(defpackage pack-b
  (:use :cl :ps-experiment))

;; ----- Package A ----- ;;
(in-package :pack-a)

(defvar.ps *num* 0)

(defun.ps inc-num (x)
  (incf *num* x))

(defun.ps add (x y)
  (+ x y))

;; ----- Package B ----- ;;
(in-package :pack-b)

;; *num* in pack-a is not guarded
(defun.ps dec-num (x) 
  (incf *num* x))

;; :this = :pack-b
(defun main (&rest argv)
  (declare (ignorable argv))
  (print
   (with-use-ps-pack (:pack-a :this)
     (inc-num (dec-num 10)))))

結果は下記の通りです。見ての通り、名前空間の情報は失われてフラットに関数を並べているだけです。

var NUM = 0;
function incNum(x) {
    return NUM += x;
};
function add(x, y) {
    return x + y;
};
function decNum(x) {
    return NUM -= x;
};
incNum(decNum(10));

大まかなアイディアについて

まずは、実装を抜きにして順次アイディアを検討していきます。実装はその後まとめて見ていきます。

※用語について:JavaScriptコードになった時点で「パッケージ」や「シンボル」は存在しないため、JavaScript側の説明では「Common Lispコードにおいてはパッケージ(シンボル)であったもの」などと呼ぶのが正確ですが、冗長なので特に区別せずに「パッケージ」、「シンボル」と呼びます(微妙に違和感はありますが、たぶん混乱はないだろう…)

基本的なアイディア

最初の方向性として、まずは下記を満たすことを考えます。

  • パッケージ間での名前空間の分離
  • シンボルのインポートの模倣
    • インポートした別パッケージのシンボルを、プレフィックスなしに参照できる
  • シンボルのエクスポートの模倣
    • 自パッケージのエクスポートしたシンボルを、他パッケージから(プレフィックスつきで)参照できる

例えば、下記のパッケージ2つの単純なケースを考えます。

;; --- パッケージA --- ;;
(in-package :cl-user)
(defpackage :temp.pack-a
  (:use :cl :ps-experiment)
  (:export :ex-a1 :ex-a2))
(in-package :temp.pack-a)

(defun.ps+ internal-fn (x) x)

(defun.ps+ ex-a1 ()
  (internal-fn 100))

(defun.ps+ ex-a2 ()
  (internal-fn 200))

;; --- パッケージB --- ;;
(defpackage :temp.pack-b
  (:use :cl :ps-experiment)
  (:import-from :temp.pack-a
                :ex-a1)
  (:export :ex-b))
(in-package :temp.pack-b)

(defun.ps+ internal-fn (x) (* x 2))

(defun.ps+ ex-b ()
  (+ (ex-a1) (internal-fn 200)))

パッケージBに注目すると、パッケージAでエクスポートされたシンボルex-a1をインポートして利用しており、かつ、internal-fnというパッケージAにも(internalに)存在する名称の関数を定義しています。これを次のようにJavaScriptに変換すれば良さそうです。

/* --- パッケージA --- */
var temp_packA = (function() { /* (1) */
  /* --- define objects --- */
  function testA2() {
      return internalFn(200);
  };
  function internalFn(x) {
      return x;
  };
  function exA1() {
      return internalFn(100);
  };
  function exA2() {
      return internalFn(200);
  };
  /* --- extern symbols --- */ /* (2) */
  return {
    'exA1': exA1,
    'exA2': exA2,
  };
})();

/* --- パッケージB --- */
var temp_packB = (function() {
  /* --- import symbols --- */ /* (3) */
  var exA1 = temp_packA.exA1;
  /* --- define objects --- */
  function internalFn(x) {
      return x * 2;
  };
  function exB() {
      return exA1() + internalFn(200);
  };
  /* --- extern symbols --- */
  return {
    'exB': exB,
  };
})();
  • (1) 名前空間の分離を行うため、各パッケージ内の定義をクロージャで囲います。これで、例えば両パッケージに存在するinternalFninternal-fn)の名前空間が分離されます
  • (2) シンボルのエクスポートを模倣するため、シンボルと同名の文字列をキー、シンボル自身を値としたハッシュを返します。これによって、例えばtemp_packAパッケージ内のexA1にはtemp_packA.exA1として外部からアクセスできるようになります。
  • (3) シンボルのインポートを模倣するため、クロージャの冒頭で同名のローカル変数に外部パッケージでエクスポートされた変数を入れておきます。これで、パッケージB内からはプレフィックスなしにtemp.pack-a:exA1にアクセスできるようになります。

ここまでは(実装的にも)順調です。

インポートしていないシンボルの参照

上記はエクスポートしたシンボルをインポートして参照するだけのお行儀の良い例でした。しかし、周知の通りCommon Lispにおいては<パッケージ名>:<シンボル名>としてインポートしなくても(エクスポートされた)シンボルを参照できますし、<パッケージ名>::<シンボル名>とすればインターナルなシンボルに触ることもできます。特にここまでのアイディアでは後者を実現する手段がないため、対処が必要です。

インターナルなシンボルに触るコードを直接書くケースは余りないと思いますが、他パッケージのマクロを利用する場合、自パッケージでインポートしていないシンボルが展開されるということが普通に起こり得ます。

(defpackage :temp.pack-a
  (:use :cl)
  (:export :ex-macro))
(in-package :temp.pack-a)

(defun internal-fn (x) x)
(defmacro ex-macro (x) `(internal-fn ,x))

(defpackage :temp.pack-b
  (:use :cl)
  (:import-from :temp.pack-a
                :ex-macro))
(in-package :temp.pack-b)

(defun internal-fn (y) (+ y 20))

(print (macroexpand-1 '(ex-macro (internal-fn 200))))
;; -> (TEMP.PACK-A::INTERNAL-FN (INTERNAL-FN 200))

表面上パッケージBはインポートしたマクロex-macroを利用しているだけですが、実際にはマクロ展開時にインポートしていないシンボルtemp.pack-a::internal-fnがパッケージB上に展開されてしまいます。もちろん、Common Lispではprint結果のように、適切にパッケージが考慮されていることが分かります。

Parenscript上で同様のことを実現するためには次の2つの要素が足りません。

  • 未エクスポートのシンボルを外から触れるようにする
  • 未インポートのシンボルを識別して↑のシンボルを触るようなJavaScriptコードを出力する

イメージとしては次のようになります。まずはParenscript側のコード。

(in-package :cl-user)
(defpackage :temp.pack-a
  (:use :cl :ps-experiment)
  (:export :ex-macro :ex-fn))
(in-package :temp.pack-a)

(defun.ps+ internal-fn (x) x)
(defun.ps+ ex-fn (x) x)
(defmacro.ps+ ex-macro (x) `(internal-fn ,x))

(in-package :cl-user)
(defpackage :temp.pack-b
  (:use :cl :ps-experiment)
  (:import-from :temp.pack-a
                :ex-macro))
(in-package :temp.pack-b)

(defun.ps+ internal-fn (y) (+ y 20))

(defun.ps+ hoge () (ex-macro (internal-fn 200)))

(print (with-use-ps-pack (:temp.pack-a :temp.pack-b)))

これが下記のようにJavaScriptに変換されれば良さそうです。

var temp_packA = (function() {
  /* --- define objects --- */
  function internalFn(x) {
      return x;
  };
  function exFn(x) {
      return x;
  };
  /* --- extern symbols --- */
  return {
    'exFn': exFn,
    /* ★internalなシンボルを触れるようにする */
    '_internal': {
      'internalFn': internalFn,
    }
  };
})();

var temp_packB = (function() {
  /* --- define objects --- */
  function internalFn(y) {
      return y + 20;
  };
  function hoge() {
      /* ★外部のシンボルを識別してprefixをつける */
      return temp_packA._internal.internalFn(internalFn(200));
  };
  /* --- extern symbols --- */
  return {
    '_internal': {
      'internalFn': internalFn,
      'hoge': hoge,
    }
  };
})();

後ろで見るように、実装する上で、一通りのシンボルをエクスポートする部分は何ということはないのですが、未インポートシンボルの識別ではダーティな対処が必要になっています…。

(今のところ)最後の関門:Type Specifier

唐突ですが、よく知られているように、Common Lispにおいて変数と関数の名前空間は分離されています(Lisp-2)。また、それ以外に型(Type Specifier)の名前空間も分離されています。

;; ※Clozure CLで実行(各 def... の出力は省略)
CL-USER> (defparameter x 100)
CL-USER> (defun x ())
CL-USER> (defstruct x)
CL-USER> (describe 'x)
X
Type: SYMBOL
Class: #<BUILT-IN-CLASS SYMBOL>
;; --- 変数であり、関数であり、Type Specifierでもある ---
Special Variable, Function, Type Specifier, Class Name
INTERNAL in package: #<Package "COMMON-LISP-USER">
Print name: "X"
Value: 100
Function: #<Compiled-function X #x30200190257F>
Arglist: NIL
Plist: NIL
Class: #<STRUCTURE-CLASS X>
; No value

Common Lispにおいては下記のようにして使い分けられます。

x    ; 変数としてのアクセス
(x)  ; 関数としてのアクセス
;; Type specifierとしてのアクセス(の一例)
(let ((xx 'x))
  (typep (make-x) xx)) ;; ※直接'xを書くこともできる

これらをParenscriptにかけると下記のようになります(ただし、Parenscriptでtypepは実装されていないため、ps-experiment側で(Parensript用マクロとして)補ったものです)。なお、JavaScript上では変数と関数の名前空間が分離されていないため、同時にはどれか一つしか成立しません。が、これは目をつむることにします*2

x;   /* 変数としてのアクセス */
x(); /* 関数としてのアクセス */
/* Type specifierとしてのアクセス */
(function () {
    var xx = 'x';
    return (makeX() instanceof (typeof xx === 'string' ? eval(xx) : xx));
})();

さて、typepが変換されたものを見てみると非常に怪しげなものがあります。そう、evalです。なぜこんなものが必要になるのか…。Common Lispにおいて、プログラマが直接Type Specifierを指定する手段はシンボルの利用になります。一方で、Parenscriptを通すとシンボルはただの文字列に変換されます。しかし、JavaScriptの文字列は型を指示するものではありません。このため、evalによって型の情報にひもづけられた変数を取り出すことが必要になります。

これがどのように問題になるのかを次の例で考えてみます*3

;; defpackage 省略
(in-package :pack-a)
(defvar.ps+ *some-symbol-list* nil)
(defun.ps+ find-typed-symbol (type-specifier)
  (find (lambda (sym) (typep sym type-specifier)) *some-symbol-list*))

(in-package :pack-b)
(defstruct.ps+ test-st-b)
(push (make-test-st-b) pack-a::*some-symbol-list*)
(pack-a::find-typed-symbol 'test-st-b)

pack-a::find-typed-symbol内のtypepがポイントです。Common Lispとしては特に問題のないコードです。pack-bから'test-st-bというシンボルが渡ってきますが、シンボル自身がパッケージの情報を持っているため、問題なくpack-b::test-st-bにたどり着けます。一方、ここまで説明した形でJavaScriptに変換すると、'test-st-bシンボルは'test-st-b'という文字列に変換され、パッケージの情報が失われます。find-typed-symbolはこれを受け取るわけですが、pack-a名前空間にはtest-st-bなどというものは定義されていませんので、eval(type-specifier) すなわち eval('test-st-b')はエラーとなります。

なお、名前空間の分離をしていなかった世界では、test-st-bはグローバルに見えていたためこの問題は生じませんでした。また、Parenscriptの名前空間機能を素直に使った場合、'test-st-b'にはprefixがつけられてグローバルに見える名前になるため、やはりこの問題は生じません。

さて、この対処の方針は次のようになります。こうすると、上記の例で言えばfind-typed-symbolには型の実体が渡されるようになるため、evalが不要になります。

  • Type Specifierが定義されるときにそのシンボルを記録する
  • JavaScriptへの変換時に、Type Specifierを見つけたら問答無用でquoteを剥ぎ取る
    • 例. 'test-st-b →(quoteをとる)→ test-st-b →(JavaScriptへ変換)→ testStB(文字列ではなく変数として出力される)

これは、今までは変数+関数の名前空間からなんとなく遊離していたType Specifierを明確に同一の名前空間に引き込む行為と解釈できそうです。解決方法として場当たり的すぎないか悩ましかったのですが、そう考えればそれなりに理のある対処ではなかろうかと思います。…結果だけ書くと割とシンプルなのですが、これを解決することに正当性があるのか(≒根本的に何が問題なのか)、実装の方針をどうすべきなのかという部分が非常に悩ましく、あやうく本稿のタイトルが「~模倣しようとしてうまくいかなかった話」になりかけた程のものではありました。

実装について

ここから先、上記で述べたアイディアの実装の話ですが、必要と思われる部分をかいつまんで説明していきます。小さな落とし穴が色々あることや、Parenscriptの関数を上書き(ps-experiment側で同じ関数を再定義)するなどというダーティな実装をしていることに目をつむれば、Common Lispのシンボルが持つ情報を利用することでベースは非常にシンプルに作れることが分かると思います。

なお、実装はおおむねps-experiment/src/package.lisp配下で行っています。また、該当のコミットは"cef5d6: Change to split namespace by package"になります(再掲)。

前置き:ps-experimentにおけるこれまでのパッケージ管理

※今回の変更の前に色々いじったので実は以下の説明と完全に一致するコミットは存在しなかったりします…。後ろの話につなげるための細部は架空な実装です。

まずは、単なるグループ化のためだけにパッケージを利用していた頃のパッケージ管理方法の概要について述べます。ポイントとなるのは、with-use-ps-packで出力するためのシンボルを独自に管理しておくという点です*4。この部分はこの後も同じです。

(defparameter *ps-func-store* (make-hash-table))

管理主体はこの*ps-func-store*です。これはパッケージをキーとしたハッシュになっており、値は次のような構造体のリストです。

(defstruct ps-func 
    name-keyword  ; シンボルを同名のキーワードにしたもの
    (func (lambda () "")))  ; これをfuncallするとJavaScriptコード(文字列)が返される
  • 細かい話
    • name-keywordでキーワードでなく、シンボル自身を登録しておけば、(シンボル自身がパッケージの情報を持っているので)キーとしてのパッケージは不要です。が、今回はあった方が扱いやすそうです
    • ps-funcでいうname-keywordを2段目のキー、funcをその値とするような2重ハッシュとしないのは、できるだけ元の定義順を保存しておきたいためです*5

実装は省略しますが、defun.psなどはロード時に*ps-func-store*への登録を行います。このため、

(in-package :test-pack)
(defun.ps test-fn (x) x)

としておくと、下記のようにしてtest-fnJavaScript関数として書き出せます。

;; *ps-func-store*のパッケージプレフィックス略
(funcall (ps-func-func
          (find (lambda (ps-func)
                  (eq (ps-func-name-keyword ps-func) :test-fn))
                (gethash (find-package "TEST-PACK") *ps-func-store*))))
;; --- 以下出力 --- ;;
"function testFn(x) {
    return x;
};"

例えば他にも、TEST-PACKパッケージ配下のものをすべて出力したければ下記のようにすればよいことになります。

(dolist (ps-func (gethash (find-package "TEST-PACK")
                          *ps-func-store*))
  (print (funcall (ps-func-func ps-func))))

基本的なインポート・エクスポート

まずは、基本的なインポート・エクスポート部分の実装です。「基本的アイディア」節の内容に加え、「インポートしていないシンボルの参照」節で述べたうちの「未エクスポートのシンボルを外から触れるようにする」まではまとめてやってしまいます。

基礎知識:シンボルからの情報取り出し

以降で主に利用するものは、シンボルの持つパッケージについての情報と「状態」についての情報の2つだけです。

パッケージの情報の取り出しは単にsymbol-package関数を利用するだけです(※以降、REPLの出力は適宜省略します)。

CL-USER> (defvar some-var 100)
CL-USER> (symbol-package 'some-var)
#<PACKAGE "COMMON-LISP-USER">

この情報は、あるパッケージから見えているシンボルが、自パッケージで定義したものか他パッケージからインポートしてきたものかを識別するのに使います。

CL-USER> (defpackage test-pack (:use :cl) (:export :aaa))
CL-USER> (defvar test-pack:aaa 100)
CL-USER> (import 'test-pack:aaa)
CL-USER> (symbol-package 'aaa)
#<PACKAGE "TEST-PACK"> ; ← CL-USERパッケージの外から来たことが分かる

次に、シンボルの「状態」ですが、こちらはfind-symbol関数で簡単に取り出せます。

CL-USER> (defvar var-a 100)
CL-USER> (defvar var-b 100)
CL-USER> (export 'var-b)
CL-USER> (find-symbol "VAR-A")
VAR-A
:INTERNAL ; ← これ
CL-USER> (find-symbol "VAR-B")
VAR-B
:EXTERNAL ; ← これも

エクスポートしていない'var-aの状態は:internalで、エクスポートしている'var-bの状態は:externalとなっています。これを利用して、エクスポートされているシンボルなのかを判別します。なお、他パッケージからインポートしたシンボルも:internalとして扱われるため、「状態」を見てシンボルが自パッケージのものかを判別することはできません。このため、その判別はsymbol-packageを利用して行います(useしたものは、もう1つの状態である:inheritedとなるため判別はつくのですが、あえて:internalと分けて考える意味もないので、ここでは:externalか否かだけに注目します)。

インポート・エクスポートの実装

前置きが長かったですが、「基本的なアイディア」節で目標としていた次のものを実装していきます。

  • パッケージ間での名前空間の分離
  • シンボルのインポートの模倣
  • シンボルのエクスポートの模倣

ついでに、「インポートしていないシンボルの参照」節で述べたうちの下記も実現しておきます。

  • 未エクスポートのシンボルを外から触れるようにする

さて肝心の実装ですが、ここまでのsymbol-package, find-symbol、あるパッケージ内の全シンボルでループするdo-symbolsマクロさえ知っていればあとは簡単です。そのため、以降はコードを並べて中に必要なコメントを追記して流していきます。

まずは、インポート部分の実装です。今後もformatが(特にスペースの数や改行が決め打ちだったりで)汚いのですが読み流してください。

#|
Create string like the following (The sort order is not stable):
var symA = packageA.symA;
var symB = packageA.symB;
var symC = packageB.symC;
|#
(defun make-imported-js-symbols (pack)
  (let ((imported-lst nil))
    ;; インポート対象シンボルの抽出
    (do-symbols (sym pack)
      (let* ((target-pack (symbol-package sym))
             ;; find-ps-funcは*ps-func-store*を探索する関数
             (ps-func (find-ps-func sym target-pack)))
        ;; ★インポートすべきシンボルであるかの判定
        ;;         ;; 1. 他パッケージのシンボルか?
        (when (and (not (eq target-pack pack))
                   ;; 2. *ps-func-store*に登録されたシンボルか?
                   ps-func
                   ;; 3. (詳細略:Top LevelなFormを実現するためにps-funcに追加した情報)
                   (ps-func-require-exporting-p ps-func)) 
          (push sym imported-lst))))
    ;; formatで頑張って文字列化(汚い)
    (format nil "~{~{var ~A = ~A.~A;~}~%~}"
            (mapcar (lambda (sym)
                      ;; symbol-to-js-stringは読んで字のごとく。Parenscriptの持ち物
                      (let ((js-sym (symbol-to-js-string (make-keyword sym))))
                        (list js-sym
                              ;; package-to...も読んでの通り。こちらはps-experiment実装
                              (package-to-js-string (symbol-package sym))
                              js-sym)))
                    imported-lst))))

次はエクスポート部分の実装です*6

#|
Create string like the following (The sort order is not stable):
return {
  'externalSymA': externalSymA,
  'externalSymB': externalSymB,
  _internal: {
    'internalSymA': internalSymA,
    'internalSymB': internalSymB,
  }
};
|#
(defun make-exported-js-symbols (pack)
  (let ((extern-lst nil)
        (internal-lst nil))
    (flet ((keyword-to-js-string (key)
             (check-type key keyword)
             (symbol-to-js-string key)))
      ;; 自パッケージで定義したものが対象と分かっているので、
      ;; do-symbolsを使わず*ps-func-store*から直接候補を取り出す
      (let ((ps-func-lst (gethash pack *ps-func-store*)))
        (dolist (ps-func ps-func-lst)
          (when (ps-func-require-exporting-p ps-func)
            (let ((key (ps-func-name-keyword ps-func)))
              ;; ★エクスポートされたシンボルかどうかのチェック
              ;; get-symbol-statusは内部でfind-symbolを呼んでいる
              (if (eq (get-symbol-status key pack) :external)
                  (push (keyword-to-js-string key) extern-lst)
                  (push (keyword-to-js-string key) internal-lst)))))))
    ;; 出力  ※(defvar *internal-symbol-prefix* "_internal")
    (format nil
            "return {
~{  '~A': ~:*~A,~%~}  '~A': {
~{    '~A': ~:*~A,~%~}  }
};"
            extern-lst *internal-symbol-prefix* internal-lst)))

後は、これらと定義本体を合わせて出力すれば、1パッケージの完成です。仕上げ部分なので実装を載せますが、うわ汚い、と思ってスクロールするのが吉です。

(defun make-packaged-js (pack)
  (let ((ps-funcs (gethash pack *ps-func-store*)))
    (unless ps-funcs
      (return-from make-packaged-js nil))
    (let ((js-pack-name (package-to-js-string pack))
          ;; 定義本体。ここだけを出力すると、名前空間がなかった時の出力と同じになる
          (js-body (format nil "~{~A~%~}"
                           (mapcar (lambda (ps-func)
                                     (funcall (ps-func-func ps-func)))
                                   (reverse ps-funcs)))))
      ;; ★(function() { ... })();とクロージャで囲うことで名前空間を分離している
      (format nil "var ~A = (function() {~%~{~A~%~}})();~%"
              js-pack-name
              (mapcar (lambda (str)
                        (ppcre:regex-replace
                         "\\s*$"
                         (ppcre:regex-replace-all (ppcre:create-scanner "^" :multi-line-mode t)
                                                  str
                                                  "  ")
                         ""))
                      (list "/* --- import symbols --- */" (make-imported-js-symbols pack)
                            "/* --- define objects --- */" js-body
                            "/* --- extern symbols --- */" (make-exported-js-symbols pack)))))))

メイン関数の実装

名前空間が分かれたことで、実はもう一ヶ所考慮が必要な場所があります。

with-use-ps-packは第一引数としてパッケージ名(キーワード)のリストをとり、body部にメインの処理を書くことができます。

(in-package :test-pack)
(defvar.ps some-var 100)

(with-use-ps-pack (:this) ; :this == :test-pack
  (alert some-var))

名前空間の分かれていなかった今までは、単にこのbody部をJavaScriptコードに変換してベタに置いておくだけで良かったです。しかし、名前空間の分かれた今、with-use-ps-packの呼ばれたパッケージの名前空間に明示的に置いてあげる必要があります。そうしないと、上の例では(alert some-var)においてsome-varへアクセスすることができません。

そこで、同パッケージ内に一時的にメイン関数相当の__psMainFunc__という関数を作ることにします。

(defmacro with-use-ps-pack (pack-sym-lst &body body)
  (with-gensyms (pack-lst)
    `(let* ((,pack-lst ... ;; 略:依存性解決をしてパッケージのリストを作る処理
             ))
            ;; グローバル環境を汚さないように*ps-func-store*のコピーを作成
            (*ps-func-store* (copy-ps-func-store)))
       ;; __psMainFunc__ を定義する
       ;; ※defun.psは同名・同引数で内部は空のCL関数を同時に定義してしまうので、defun.ps-onlyを利用
       (defun.ps-only ,(intern "__PS-MAIN-FUNC__" *package*) () ,@body)
       (import-ps-funcs (make-package-list-with-depend ,pack-lst)
                        ;; 末尾で__psMainFunc__を呼び出す
                        (format nil "~A.~A.__psMainFunc__();"
                                (package-to-js-string ,*package*)
                                *internal-symbol-prefix*)))))

例えば、空の状態でwith-use-ps-packを呼び出すと次のようになります。

(in-package :pack-a)
(with-use-ps-pack (:this))
var packA = (function() {
  /* --- import symbols --- */

  /* --- define objects --- */
  function __psMainFunc__() {
      return null;
  };
  /* --- extern symbols --- */
  return {
    '_internal': {
      '__psMainFunc__': __psMainFunc__,
    }
  };
})();

packA._internal.__psMainFunc__();

未インポートなシンボルの識別とプレフィックスの付与

「インポートしていないシンボルの参照」節で述べたように、未インポートのシンボルを参照している場合には、「基本的なアイディア」に加えて下記を実装することが必要でした。1つ目については前節で一緒に実装したので、この節では2つ目の実装を考えます。

  • 未エクスポートのシンボルを外から触れるようにする
  • 未インポートのシンボルを識別して↑のシンボルを触るようなJavaScriptコードを出力する

これを実現するためには、シンボルをJavaScript用の文字列に変換している場所で、パッケージ情報などを利用して適切なプレフィックスをつけてあげる必要があります。このためには、Parenscriptで最終的にシンボル名の変換を司っているps:symbol-to-js-stringを再定義するしかないだろうというのが現状の結論です。利用しているライブラリの関数を上書きするなど汚い話なので避けたいのは山々なのですが…。

書き換えたものが下記になります。*original-package*は後述しますが、基本的には*package*と同じく定義場所のパッケージを格納したものです。

(defun ps:symbol-to-js-string (symbol &optional (mangle-symbol-name? t))
  ;; ※let*のsymbol-nameとidentiferは元の実装のまま。残りは追加
  (let* (;  明示的にPSのobfuscationを利用していない限り、単なるシンボル名
         (symbol-name (symbol-name (ps::maybe-obfuscate-symbol symbol)))
         ;; "some-symbol" -> "someSymbol"のように変換されたシンボル名
         (identifier (if mangle-symbol-name?
                         (ps::encode-js-identifier symbol-name)
                         symbol-name))
         (package (symbol-package symbol))
         (same-name-symbol (when *original-package*
                             (find-symbol (symbol-name symbol) *original-package*))))
    (if *original-package*
        ;; こちらが追加の実装
        ;; ★プレフィックスをつけるべきかの判定(詳細本文)
        (if (and (not (eq *original-package* package))
                 ;; Check if it is imported
                 (or (null same-name-symbol)
                     (not (eq symbol same-name-symbol)))
                 ;; Check if it is registered as a ps-function
                 (find-ps-func symbol package))
            (let ((*original-package* nil)
                  (package-name (package-to-js-string package)))
              ;; ★適切なプレフィックスの付与
              (if (eq (get-symbol-status symbol package) :external)
                  (concatenate 'string package-name "." identifier)
                  (concatenate 'string package-name "." *internal-symbol-prefix* "." identifier)))
            identifier)
        ;; こちらはオリジナルの実装
        (aif (ps-package-prefix (symbol-package symbol))
             (concatenate 'string it identifier)
             identifier))))

判別部分は次を見てプレフィックス(パッケージ名)をつけるべきか判定しています。

  1. 自パッケージのシンボルではないこと((not (eq *original-package* package))
  2. そのシンボルをインポートしていないこと((or ...)
    • 直接に判断する方法がないので次の2つの条件を見ています
      • 自パッケージから同名のシンボルが見えない((null same-name-symbol))、もしくは、
      • 別パッケージの同名のシンボルが見えている((not (eq symbol same-name-symbol))
  3. *ps-func-store*で管理しているシンボルであること((find-ps-func symbol package)

後回しにしていた*original-package*ですが、所望の場所では必ず*package*CL-USERパッケージに束縛されてしまうため、代替として用意したものです*7。なぜCL-USERが束縛されているかですが、これはps:psマクロ内で呼び出される出力用関数ps::parenscript-printの中でwith-standard-io-syntaxが利用されているためです。

この*original-package*への束縛を行っているのは、defvar.psdefun.ps等で共通して利用しているps.です。

(defvar *original-package* nil)

(defmacro ps. (&body body)
  `(let ((*original-package* ,*package*))
     (macroexpand '(ps ,@(replace-dot-in-tree body)))))

ps.は元々ドット記法をサポートするためだけに導入したps:psのラッパーでした(参考:過去記事:Parenscriptで少し遊んで見る (2)ドット記法編)が意外なところで役に立ちました。ここで、ps:psを直接呼び出さずmacroexpandを挟んでいる点も今回変更が必要になった部分です。ps:psはマクロ展開時にJavaScriptコードを生成するという少々行儀の悪い作りになっているため、このように処理を遅らせないと*original-package*の束縛前にJavaScriptコードの生成処理が走ってしまいます。なお、この変更で割と不便になった点として、今まではSLIMEのマクロ全展開ショートカット(C-c M-m)で簡単にJavaScriptコードを確認できていたのですが、それができくなったという点があります(確認用の補助関数ぐらいは用意しないと…と思いつつまだしてません)。

Type Specifierの登録とquoteの剥ぎ取り

Type Specifierをパッケージ間で取り回すには下記の実装が必要でした。どちらも方針さえ決まってしまえば実装上難しい部分はありません。

  • Type Specifierが定義されるときにそのシンボルを記録する
  • JavaScriptへの変換時に、Type Specifierを見つけたら問答無用でquoteを剥ぎ取る

まずはType Specifierとなるシンボルの記録です。

(defparameter *ps-type-store* (make-hash-table))

(defun ps-type-p (symbol)
  (gethash symbol *ps-type-store*))

(defun register-ps-type (type-specifier)
  (check-type type-specifier symbol)
  (setf (gethash type-specifier *ps-type-store*) t))

記憶する場所としては*ps-type-store*というハッシュテーブルを用意します。シンボルをキーにgethashすると、Type Specifierであればt、そうでなければnilが返るというだけのものです(ps-type-p)。Type Specifierを生み出す側(現行のps-experimentではdefsturct.ps, defsturct.ps+のみ)はロード時にregister-ps-typeが呼び出されるようにしておくだけです(コード略:defstruct.psの実装は長く良い感じに抜き出せないので…)。

次に、quoteの剥ぎ取りです。自力でコードウォークをしてquoteを正しく探し出す…というのは非常に骨なので、ダーティ覚悟でParenscriptで実装されているquoteを上書きしてしまうことにします。

(ps::define-expression-operator quote (x)
  (flet ((quote% (expr) (when expr `',expr)))
    (ps::compile-expression
     (typecase x
       (cons `(array ,@(mapcar #'quote% x)))
       ((or null (eql [])) '(array))
       (keyword x)
       ;; ★変更点はこのsymbol部分だけ、elseは元の実装のまま
       ;; Type Specifierは文字列化せずに返す。これでquoteを剥いだことになる。
       (symbol (if (ps-type-p x)
                   x
                   (symbol-to-js-string x)))
       (number x)
       (string x)
       (vector `(array ,@(loop for el across x collect (quote% el))))))))

コメントのように変更点は1ヶ所だけです。リストや配列のquoteについても、各要素のquote処理は最終的にここに辿り着くため、これだけで対応できます。

終わりに

以上で、新たな構文を一切付け足すことなく、既存のパッケージ・シンボル情報を利用することで、JavaScript側での名前空間の分離を達成することができました。実際、ps-experimentの上に実装しているEntity Component Systemもどきライブラリのcl-ps-ecsでは一切書き換えは不要でしたし、さらにその上に実装しているWeb向け2Dゲームライブラリcl-web-2d-gameではエクスポート・インポートがいい加減であった部分の修正だけで事足りました(前者は全体がCommon Lispコードとしても動かせるので整っていたのですが、後者はJavaScriptコードとしてしか動かない部分が結構あるのでそこに抜けがありました)。

今回の機能変更で吐き出されるJavaScriptコードの量が一気に増えるので、そこは少々気がかりです(特に、大きいパッケージをuseしたりすると…)。説明を省きましたが、今回useしなくともインポートさえしていれば依存パッケージとみなすような変更もし加えたので、極力インポートを使うようにして不必要なuseをしないという地道な改善は可能です。いざとなったら、インポートの模倣はあきらめて、外部パッケージのシンボルはすべてパッケージプレフィックス付きで呼ぶようにすれば、いくらかは短くなるかもしれません。

ひとまず、当面はまだ見ぬ問題に怯えながら使っていってみようと思います。

関連過去記事

付録:最終的な出力例

Roswellコード:

#!/bin/sh
#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#
(ql:quickload :ps-experiment)

(defpackage pack-a
  (:use :cl :ps-experiment)
  (:export :inc-num :negate))
(defpackage pack-b
  (:use :cl :ps-experiment)
  (:import-from :pack-a
                :inc-num))

;; ----- Package A ----- ;;
(in-package :pack-a)

(defvar.ps *num* 0)

(defun.ps inc-num (x)
  (incf *num* x))

(defun.ps negate (x)
  (* x -1))

;; ----- Package B ----- ;;
(in-package :pack-b)

(defstruct.ps test-st)

(defun.ps dec-num (x)
  (inc-num (pack-a:negate x)))

;; :this = :pack-b
(defun main (&rest argv)
  (declare (ignorable argv))
  (print
   (with-use-ps-pack (:this)
     (list 'test-st 'some-sym pack-a::*num*))))

JavaScriptコード(printの出力抜き出し):

var packA = (function() {
  /* --- import symbols --- */

  /* --- define objects --- */
  var NUM = 0;
  function incNum(x) {
      return NUM += x;
  };
  function negate(x) {
      return x * -1;
  };
  /* --- extern symbols --- */
  return {
    'incNum': incNum,
    'negate': negate,
    '_internal': {
      'NUM': NUM,
    }
  };
})();

var packB = (function() {
  /* --- import symbols --- */
  var incNum = packA.incNum;
  /* --- define objects --- */
  function testSt() {
      return this;
  };
  function makeTestSt() {
      var _js2 = arguments.length;
      for (var n1 = 0; n1 < _js2; n1 += 2) {
          switch (arguments[n1]) {
          };
      };
      var result = new testSt();
      return result;
  };
  function testStP(obj) {
      return (obj instanceof testSt);
  };
  function decNum(x) {
      return incNum(packA.negate(x));
  };
  function __psMainFunc__() {
      return [testSt, 'someSym', packA._internal.NUM];
  };
  /* --- extern symbols --- */
  return {
    '_internal': {
      'testSt': testSt,
      'makeTestSt': makeTestSt,
      'testStP': testStP,
      'decNum': decNum,
      '__psMainFunc__': __psMainFunc__,
    }
  };
})();

packB._internal.__psMainFunc__();

*1:あくまで本体はParenscript側のコードなので、JavaScript側の見た目は重要でないといえばそうなのですが…

*2:えー、と思うかもしれませんが、まずはパッケージ間の名前空間分離が目的ですので…

*3:実を言うと、(symbol-value 'x)として値を取り出すとか、(funcall (symbol-function 'x) )として関数を呼び出すような、シンボルを直接扱う操作をされると変数や関数でも同じ問題が起きます…。が、普通やらないでしょうということで制限としておきます

*4:シンボルを使ってメタなことをやろうとする場合はよくやる手段なのではないかと思います

*5:パッケージ自体の定義順序は、ここで保存しておく必要はありません。出力時に依存性を考慮して並び替えるためです

*6:細かい割りに長い話なので脚注。Common Lispではエクスポートの有無に関わらず :: (コロン2つ)でシンボルにアクセスできます。これに合わせるためには、JavaScript側でもエクスポートするオブジェクトを"internal"の内外両方に置いておく必要があります。しかし、エクスポートされたシンボルについては、コロン1つで参照されたのか2つで参照されたのかを後から知るすべはない(はず…)です。そのため、JavaScript側でわざわざ"internal"内外にそれぞれ用意しておいても、Common Lisp側の(字面上の)記述に合わせて使い分けることができないため、意味がありません

*7:なお、*original-package*のおかげでifのelse側に元の実装を残せたのですが、これは副産物でした