JavaScriptのモジュール定義構文をParenscriptで抽象化(マクロで遊ぶ)

前書き

JavaScriptを書いていて「ここでマクロがあれば…」と思う事案があったので、マクロ欲を満たすためのエントリです。

JavaScriptでのモジュール定義

JavaScriptcounterモジュールを作ってみます。

var counter = (function() {
  var count = 0;
  var add = function(x) { // こんな感じでprivateな関数も書けます、というためだけに用意した関数
    count += x;
    return count;
  };
  
  return {
    get: function() { return count; },
    increment: function(x) { return add(x); },
    decrement: function(x) { return add(-x); }
  };
}());

// 使い方
counter.get();         // -> 0
counter.increment(10); // -> 10
counter.decrement(3);  // -> 7
counter.get();         // -> 7

モジュール定義用の構文という訳でもないですが、下記のような基本的な要素の組み合わせでクラスっぽい機能が実現できています。暗記アレルギーな人間としては、こういう考えれば辿れる系のものは覚えやすくて好きです。

  • クロージャ
  • ハッシュの要素にはドットでアクセス可能
  • 変数を通した関数アクセスに(Common Lispのfuncallのような)特別な処理は不要

とはいえ、何度も書いているとやはり面倒です。ここでマクロがあれば…ということで、Common Lisp(のサブセット)をJavsScriptに変換するライブラリParenscriptを使ってCommon Lispで書きなおしてみます。

Parenscriptで書き直す

準備:JavaScriptと(ほぼ)1対1の書き方へ

Parenscriptで上記のJavaScriptと1対1に対応するコードを書くには少し準備が必要です。

Parenscriptではなぜかhash-tableがサポートされていないので、とりあえず必要なサブセットだけサポートします*1

(defpsmacro make-hash-table ()
  `(@ {}))

(defpsmacro gethash (key hash-table)
  `(aref ,hash-table ,key))

まずはこれを直接使って書いてみます。

(defun make-js-module-1 ()
  (ps (defvar counter
        (funcall (lambda ()
                   (let* ((count 0)
                          (add (lambda (x)
                                 (incf count x)))
                          (public-body (make-hash-table)))
                     (setf (gethash :get public-body)
                           (lambda () count)
                           (gethash :increment public-body)
                           (lambda (x) (add x))
                           (gethash :decrement public-body)
                           (lambda (x) (add (* x -1))))
                     public-body))))))

少し脇道ですが、make-js-module-1関数を呼び出すと次のようなJavaScriptコードが得られます。

var counter = (function () {
    var count = 0;
    var add = function (x) {
        return count += x;
    };
    var publicBody = {  };
    publicBody['get'] = function () {
        return count;
    };
    publicBody['increment'] = function (x) {
        return add(x);
    };
    publicBody['decrement'] = function (x) {
        return add(x * -1);
    };
    return publicBody;
})();

さて、純JSなコードに比べると、make-js-module-1では一時変数public-bodyを利用していたりと、ハッシュの扱いが不格好です。Common Lispにハッシュの初期化構文に相当するものがないためですが、なければ作ればよいですね。

(defmacro+ps init-hash-table (&rest pairs)
  (let ((hash (gensym)))
    `(let ((,hash (make-hash-table)))
       ,(cons 'setf (mapcan (lambda (pair)
                              `((gethash ,(car pair) ,hash)
                                ,(cadr pair)))
                            pairs))
       ,hash)))

これを使ってmake-js-module-1を書き直すと次のようになります。

(defun make-js-module-2 ()
  (ps (defvar counter
        (funcall (lambda ()
                   (let* ((count 0)
                          (add (lambda (x)
                                 (incf count x))))
                     (init-hash-table
                      (:get (lambda () count))
                      (:increment (lambda (x) (add x)))
                      (:decrement (lambda (x) (add (* x -1)))))))))))

これで純JSのコードと大体1対1の対応になったので、ようやくスタートラインです。

マクロでイディオムを隠蔽する

まずは、頭のfuncalllambdaが鬱陶しいのでdefmoduleマクロで隠してみます。

;; make-js-moduleと番号を合わせるため1, 2は欠番
(defmacro+ps defmodule-3 (name &body body)
  `(defvar ,name
     (funcall (lambda ()
                ,@body))))

(defun make-js-module-3 ()
  (ps (defmodule-3 counter
        (let* ((count 0)
               (add (lambda (x)
                      (incf count x))))
          (init-hash-table
           (:get (lambda () count))
           (:increment (lambda (x) (add x)))
           (:decrement (lambda (x) (add (* x -1)))))))))

これだけだと、むしろ分かりにくくなっています。funcalllambdaが消えたことで、let*init-hash-tableの意味合いが不明瞭になったためです。

ということで、次の「ルール」を導入することで、この2つを隠します。

  • モジュール名の次にはプライベートな名前・値のペアをリストで渡す
  • 以降はパブリック名前・値のペアを並べる
(defmacro+ps defmodule-4 (name private-vars &body body)
  `(defvar ,name
     (funcall (lambda ()
                (let* ,private-vars
                  (init-hash-table ,@body))))))

(defun make-js-module-4 ()
  (ps (defmodule-4 counter
        ((count 0)
         (add (lambda (x)
                (incf count x))))
        (:get (lambda () count))
        (:increment (lambda (x) (add x)))
        (:decrement (lambda (x) (add (* x -1)))))))

だいぶすっきりしました。

さらに、パブリックな値の定義部分にあるlambdaを省略します。ただし、定数を直接公開するような使い方ができないという制限がつきます*2

(defmacro+ps defmodule (name private-vars &body body)
  `(defvar ,name
     (funcall (lambda ()
                (let* ,private-vars
                  (init-hash-table
                   ,@(mapcar (lambda (method-def)
                               `(,(car method-def) (lambda ,@(cdr method-def))))
                             body)))))))

(defun make-js-module ()
  (ps (defmodule counter
        ((count 0)
         (add (lambda (x)
                (incf count x))))
        (:get () count)
        (:increment (x) (add x))
        (:decrement (x) (add (* x -1))))))

そんな訳でBefore, Afterです。いくつかの「ルール」や制限*3の導入と引き換えに、モジュール作成に本質的には無関係なキーワードがきれいサッパリなくなりました。

1対1のコードから、たった8行でこれを実現できるLispのマクロは実に強力で気分が良いです。

// Before
var counter = (function() {
  var count = 0;
  var add = function(x) {
    count += x;
    return count;
  };
  
  return {
    get: function() { return count; },
    increment: function(x) { return add(x); },
    decrement: function(x) { return add(-x); }
  };
}());
;; After
(defmodule counter
  ((count 0)
   (add (lambda (x)
          (incf count x))))
  (:get () count)
  (:increment (x) (add x))
  (:decrement (x) (add (* x -1))))

マクロの功罪

今回の狭い範囲から見えるマクロの功罪は次のような感じでしょうか。

    1. 構文を簡単に抽象化できる
    2. マクロ名で元になった構文の意図を明確にできる
    1. 構文を簡単に抽象化できすぎる
    2. 知らなければならないルールが増える
    3. 意図的かどうかを問わず、何らかの制限がつく

何か書こうかと思っていましたが、こう並べてみると抽象化一般の功罪と変わらないですね。プログラムの中でもより基盤に近い部分を触るので、影響がより際立つ感じでしょうか。

コード貼り付け

最後に、ここまでを一通りまとめたRoswellスクリプトです。

*1:オプションが足りないのは見て通りですが、他に問題として 'hoge のようなquoteされたシンボルをキーにできないという問題があります。対応するには(quote hoge)のようなリストが来た場合に、hogeの部分を取り出すようにする必要があります。

*2:atomが来たらそのまま返すようにマクロを拡張することで対応は可能です

*3:ルールを追加することで制限を緩和することは可能で、トレードオフの関係です。