28
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

LivesenseAdvent Calendar 2014

Day 12

sweet.jsでJavaScriptを拡張する

Last updated at Posted at 2014-12-11

sweet.jsとは

Mozilla製のJavaScriptコンパイラです。コンパイルと言っても仕事は定義したマクロの展開です。
特徴として、ここで定義することのできるマクロはSchemeやRustのようなハイジニック・マクロであることがあげられます (と、本家より。Rustってマクロあるのかー)。

GitHub では、2,600以上のstarがついている人気プロジェクトです (2014年12月現在) 。たぶん。

何に使えるのか

JavaScriptの文法をユーザが任意に拡張し、イケてるJavaScriptプログラミングをごりごりできるようにします。

  • あの言語にあるあの構文が使いたいんだけどJavaScriptにないしなあ
  • 頻繁に書くパターンがあるんだけど、関数やプロトタイプ継承ではDRYにできないやつだこれ

といった悩みを解決します。

自分の学習モチベーション

  • 世のJavaScriptライブラリに長いこと触れていなかったのでこれを期にやっておこうかと
  • この記事を投下した、クリスマスシーズンに適した名前である
  • ハイジニックなマクロを分かってないので理解したかった
    • 記事に誤りを見つけた際はご指摘いただければと思います
    • 猛者の多そうな領域ですし… (gkbr)

TL;DR ハイジニック・マクロとは

sweet.jsの話に入る前に、sweet.jsによって実現される「ハイジニック・マクロ」とはそもそも何であるのかを解説いたします。

Hygienic (衛生的な、健全な) であるマクロというのは、名前 (識別子) の衝突を回避するための仕組みを持ったマクロです。
といっても分かりにくいかと思いますので、具体例を出しながら追ってみたいと思います。

そもそもLispのマクロとは

sweet.jsはSchemeのマクロに影響を受けているようなので、その理解にあたってまずはScheme、というかLispのマクロについて説明をいたします。

Lispのマクロとは、一種のコードジェネレータのようなものです。
マクロはプログラムのコンパイル時に、その定義に従ってコードを書き換えます。

これではよく分からないと思うので具体例を出します。
ハイジニックではないマクロとの対比で説明をしたいため、まずはCommon Lispのハイジニックでないマクロ (古典的マクロ) で記述します。

;; 1を足すだけのマクロ
;; マクロでやる必要のない処理だがシンプルな例として
(defmacro m-plus-1 (val)
  `(+ ,val 1))

(m-plus-1 (* 1 2))
;; -> 3

一見ただの関数呼び出しのように見えますが、実態は異なるものです。
(m-plus-1 (* 1 2))はコンパイル時に(+ (* 1 2) 1)というコードに書き換えられます (これをマクロの展開と呼びます)。
つまり、コンパイルが終わった時点では始めから(+ (* 1 2) 1)と書かれていたのと同じ状態になります。

比較のため、関数で上記を書いてみます。

(defun f-plus-1 (val)
  (+ val 1))

(f-plus-1 (* 1 2))
;; -> 3

マクロ版と結果は同じで、見た目も大きくは変りません。
しかしこちらのコードではコンパイル後も(f-plus-1 (* 1 2))(f-plus-1 (* 1 2))のままで、実行時には関数呼び出しになります。

つまりマクロは、式を受け取って、それをゴニョゴニョいじって新たな式を生成し、それに置き換えるものです。
「コードを任意に書き換えることができる」というのが強力な機構であることは、想像に難くないかと思います。

ハイジニックでないマクロでは何が起きるのか

では、こうしたハイジニックでない古典的なマクロがどのように問題を引き起こすのかを見ていきます。

Common Lispに代表される古典的マクロは、展開した時にマクロ自身が提供する名前と、呼び出し側が記述する名前が衝突し、想定外の振る舞いをすることが問題となります。

例えばCommon Lispで以下のように呼べるforマクロを作るとします。1から5までの値を順にxに束縛しつつ、第二のフォームを都度実行するものです。

;; これは 12345 と出力される
(for (x 1 5)
     (princ x))

上記forのマクロを素朴に実装したものが下記となります。これは上記の用い方であれば、正しく動作します。

(defmacro for ((var start stop) &body body)
  `(do ((,var ,start (1+ ,var))
        (limit ,stop))
       ((> ,var limit))
     ,@body))

確認のため、展開した結果を見てみましょう。

(macroexpand-1
 '(for (x 1 5)
       (princ x)))

;; -> (DO ((X 1 (1+ X)) (LIMIT 5)) ((> X LIMIT)) (PRINC X))

では上記のxを、マクロ定義中でも使われている名前であるlimitにするとどうなるでしょうか。

;; 何が出るかな
(for (limit 1 5)
     (princ limit))

;; ANSI Common Lisp での実行結果はだいたいこんな感じ
;; -> 56789101112131415161718192021222324252627282930313233343536373839404142434445464748
;; :
;; 続く

大変なことになりました。一体何が起きたのでしょうか。

原因を確認するため、macroexpand-1で展開結果を見てみます。そこで出力されたS式を整形すると以下となっています。

;; do によるループ
(do ((limit 1 (1+ limit)) ; 変数 limit は初期値 1 でステップ毎に 1 を加算
     (limit 5))           ; 変数 limit は初期値 5
    ((> limit limit))     ; limit が limit 未満であれば繰り返し
  (princ limit))          ; 繰り返し実行される内容

名前が衝突したことでループの停止条件が満されなくなり無限ループに陥いっているのみならず、カウンタ変数に相当するものの初期値が5となってしまっています。
これがマクロにおける名前の衝突の問題です。

OOPLなんかでもシャドーイングによるバグというのはありますが、マクロの場合は関数やクラスと違ってその中身が呼び出し側に晒されることになるため、マクロ内で使われている一見ローカルな名前によって汚染 (という言い方が適切かは分かりませんが) されます。それが名前の衝突リスクの要員と見ています。

上記のような低レベルなマクロにおいては、マクロの書き手が衝突しない名前を用いることで問題を回避することになります。Common Lispであればマクロの記述でgensymという名前の衝突しないシンボルを返す関数を使います。

しかし同じくLispであるSchemeにおいてはそうもいかないようです。SchemeはCommon Lispと異なりLisp-1、すなわち関数と変数が同じ名前空間に属しているため、名前がこの両者と衝突する可能性があって非常にリスキーとなります。
(Common LispはLisp-2であり関数と変数が異なる名前空間に属しているがために衝突リスクが低く、プログラマを信用するというか、「そんなアレなネーミングしないでしょ」というスタイルを取っていると解釈しています)

そして今回の主役 (たぶん) であるJavaScriptもLisp-1, 2という括りで見るならばLisp-1です。
そんな Lisp-1属でも安全なマクロシステム、それがハイジニック・マクロです。

Schemeのハイジニックなマクロ

そんなわけでSchemeにハイジニックなマクロシステムが登場したようです。~~しかしGaucheだったらdefine-macroで古典的マクロが書けるとか何とか。~~そして後ほど解説するsweet.jsのマクロはSchemeのものを模している印象です。
というわけでこの項ではSchemeのマクロについて解説いたします。

さてSchemeのマクロなのですが、処理系によっては公式の仕様であるRnRSにないものも含んでいるようで (この辺のオシャレなドメインのやつとか) そちらにまで言及し出すと爆死する危険を感じたため、RnRSに話を限定いたします。

Schemeにおけるマクロ定義は、基本的にはR5RSより導入されたsyntax-rulesを使うようです (R6RSのマクロには後ほど少しだけ触れます) 。

;; 1を加算するマクロ
;; 相変わらずマクロでやる必要がない処理だが目をつぶって頂きたい
(define-syntax m-plus-1
  (syntax-rules ()
                ((_ x) (+ x 1))))

(m-plus-1 (* 1 2))
;; -> 3

雛形はこのようなものになります。

(define-syntax name
  (syntax-rules (literal ...)
                (pattern template)))

nameがマクロ名であることはいいとして、literalはマクロ内で使う予約語のリスト (省略可能)、patternがマクロの記述パターン、templateが展開するマクロのテンプレートとなります。

patternは、パターンマッチのパターンです。
上記の例では(m-plus-1 (* 1 2))(_ x)というパターンにマッチ、つまり_m-plus-1x(* 1 2)に相当し、x(+ 1 2)が束縛されます。
template部は、(+ x 1)ですからx(* 1 2)が束縛されていることを考えると、最終的な出力は(+ (* 1 2))となることが分かります。

パターンは複数書くこともできます。
例えば以下のようなものです。まさにパターンマッチという感じですね。

(define-syntax m-hoge
  (syntax-rules ()
                ((_ x) (+ x 1))
                ((_ x y) (+ x y 2))))
(m-hoge 1)
;; -> 2
(m-hoge 1 2)
;; -> 5

で、肝心の「Schemeのマクロが一体どのように名前の衝突を回避しているか」という話なのですが、まだ大雑把にしか理解できていないというのが本音です。
この記事が詳しい印象なのですが、書いてあることまとめると以下の2つにより実現している模様です。

  • マクロ展開時に、衝突する名前があったら自動的にリネームする
  • マクロもクロージャとして環境を持つことで、衝突の検知を確かなものにする

前者はともかく、後者が難しい。自分なりにラフですが解釈してみます。
マクロも関数のクロージャのように環境を持つ、すなわちマクロ定義内で使われている変数はその環境に対して束縛される (実行前なので具体的な値への束縛でなく、どのコンテキストの名前なのかというメタな情報への束縛) とのことで、マクロが展開された場所で同名のシンボルがあった場合にも、どちらがどのコンテキストに属しているのか判別できて、両者を衝突しない名前にリネームできるということのようです。

マクロは必ずハイジニックであるべきか?

ここまでの解説では「マクロはハイジニックであった方が良い」という印象を持つわけですが、そうとも限りません。
意図的に捕捉可能な名前を作ることで実現できるものがあるためです。

代表例としてはアナフォリック・マクロがあげられます。
言葉で説明をするよりも、実際のコードを見た方が分かりやすいかと思うので、Common Lispにおける実装をあげてみます。

;; マクロ定義
;; アナフォリック (前方照応) なif
(defmacro aif (test-form then-form &optional else-form)
  `(let ((it ,test-form))
     (if it ,then-form ,else-form)))

;; マクロ未使用ver
;; if の判定に使ったのと同じ式をその後再び呼び出している
(if (heavy-function arg0 arg1)
  (func (heavy-function arg0 arg1)))

;; マクロ使用ver
;; すっきり
(aif (heavy-function arg0 arg1)
  (func it))

これまで、マクロ定義中で使っている名前と、呼び出し側で使っている名前を衝突させないことにフォーカスを当ててきましたが、上記では意図的に名前を衝突させています。
これらは「itという名前を呼び出し側に捕捉させる」ことによって、その機能を提供しています。
しかしハイジニック・マクロではこれまで説明したように名前の衝突回避によって、このような「呼び出し側が捕捉できる名前」を提供することができないわけです。

ではSchemeでこれが作れないかといったらそうでもないようで、R6RSのsyntax-caseを使えば可能なようです。
http://community.schemewiki.org/?syntax-case-examples

sweet.jsを試しに使ってみる

ようやくハイジニック・マクロの説明が終わったところで本題に入ります。
インストールからコンパイル方法までのあたりをざっと説明いたします。

インストール

npmで入ります。

$ npm install -g sweet.js

実行

sjsコマンドを使います。-oで出力ファイルを指定します。

$ sjs -o output.js my_js_macro.js

ここで入力となる、マクロを使った内容のmy_js_macro.jsですが、本家の冒頭で紹介されているECMAScript 6的なclass構文としてみます。

// マクロ定義
macro class {
  rule {
    $className {
        constructor $cparams $cbody
        $($mname $mparams $mbody) ...
    }
  } => {
    function $className $cparams $cbody
    $($className.prototype.$mname
      = function $mname $mparams $mbody; ) ...
  }
}

// class構文が使えるようになったJavaScriptを書く
class Person {
  constructor(name) {
    this.name = name;
  }

  say(msg) {
    console.log(this.name + " says: " + msg);
  }
}
var bob = new Person("Bob");
bob.say("Macros are sweet!");

コンパイル結果であるoutput.jsは以下のようになります。マクロが展開されています。

// class構文が使えるようになったJavaScriptを書く
function Person$663(name$665) {
    this.name = name$665;
}
Person$663.prototype.say = function say(msg$666) {
    console.log(this.name + ' says: ' + msg$666);
};
var bob$664 = new Person$663('Bob');
bob$664.say('Macros are sweet!');

便利なオプション

変更のウォッチ

-wで、ファイルに変更があった場合に自動的に再コンパイルしてくれます。

$ sjs -w -o output.js my_js_macro.js

SourceMap

-cでSourceMapも併せて生成することができます。

$ sjs -c -o output.js my_js_macro.js

その他のオプション

ヘルプを見てみると他にも色々ありますが割愛します。

$ sjs --help

sweet.jsのマクロはどのように定義するか

公式ドキュメントより基本的な部分のみをピックアップしてみます。

前述したSchemeのマクロ定義にとてもよく似ています。

基本

まず、sweet.jsのマクロの雛形はこの様になっています。

macro <name> {
  rule { <pattern> } => { <template> }
}

<name>にマクロ名を、<pattern>部に「マクロをどのように書くか」を、<template>部に展開されるコードのテンプレートを記述します。

具体的なイメージを掴むため、最も簡単な例を見てみます。

以下の、引数の値がそのまま展開されるマクロidを記述することとします。

// 展開結果: 42
id (42)

マクロの定義はこのようになります。

// <name> は id
macro id {
  rule {
    // <pattern>部
    // マクロ引数を `$x` にバインドする
    // マクロ引数は (引数) の形式で渡される
    ($x)
  } => {
    // <template>部
    // 展開結果として `$x` にバインドされた値をそのまま返す
    $x
  }
}

このidマクロの<pattern>は、単一のオブジェクトであればだいたい何でもマッチします (関数リテラルと名前空間修飾したものはエラーとなりました)。
つまり以下のようにArrayのリテラルを渡すこともできます。一方で、複数の引数は受け取ることはできません

// OK
id ([1, 2, 3])

// NG
id (4, 5)

ruleは複数記述することもできます。

// 1引数と2引数それぞれにマッチする
macro m {
  rule { ($x) } => { $x }
  rule { ($x, $y) } => { [$x, $y] }
}

m (1);
// -> 1

m (1, 2);
// -> [1, 2]

再帰的なマクロ定義も可能です。

macro m {
  // 1引数であれば、それを1要素のArrayにする
  rule { ($base) } => { [$base] }
  // 2引数以上であれば
  // * 最初の要素が、先頭の引数で
  // * 2番目の要素が、2つ目以降の要素を m した結果
  // の Array にする
  rule { ($head $tail ...) } => { [$head, m ($tail ...)] }
  }

m (1 2 3 4 5)
// -> [1, [2, [3, [4, [5]]]]]

パターンの記述

繰り返し

繰り返しは...で表現されます。

任意個の引数をとるマクロは以下のような記述になります。

macro m {
  rule { ($x ...) } => {
    // ...
  }
}

m (1 2 3 4)

引数を,等のセパレータで区切りたい場合は(,)の様に記述します。

macro m {
  rule { ($x (,) ...) } => {
    [$x (,) ...]
  }
}

m (1, 2, 3, 4)

繰り返しの単位がパターンのグループである場合には、$()でグルーピングします。

macro m {
  rule { ( $($id = $val) (,) ...) } => {
    $(var $id = $val;) ...
  }
}

m (x = 10, y = 2)

健全さ

sweet.jsがどのようにマクロをハイジニック (健全) なものにしているのかの解説です。

起こり得る名前の衝突には2種類あります。束縛の衝突と、参照の衝突です。
どちらもやっていることは変数のリネームで、衝突しないsweet.jsが衝突しない名前に自動的に書き換えることで健全さを実現しています。

束縛の衝突の回避

1つ目の衝突は、束縛の衝突です。
マクロ定義内で束縛した変数の名前と、マクロの呼び出し側で使用した変数の名前の衝突となります。

具体的なコードを見てみます。
2つの変数の値を入れ替える、swapマクロを定義します。

macro swap {
  rule { ($a, $b) } => {
    var tmp = $a;
    $a = $b;
    $b = tmp;
  }
}

そしてswapマクロをこんな風に呼び出してみたとします。

var tmp = 10;
var b = 20;
swap (tmp, b)

期待される動作としては、tmpの値が20で、bの値が10と入れ替わることです。

ここでもし、sweet.jsのマクロがハイジニックでなかったとしたら、次の様に展開されてバグとなります。

var tmp = 10;
var b = 20;

var tmp = tmp;
tmp = b;
b = tmp;

この様に、マクロの定義内と呼び出し側でそれぞれ使っている名前tmpが衝突し、問題のあるコードに展開されることとなります。

sweet.jsでは以下のようにtmpをリネームすることで、名前の衝突を防ぎます。

var tmp$1 = 10;
var b = 20;

var tmp$2 = tmp$1;
tmp$1 = b;
b = tmp$2;

参照の衝突の回避

もう1つの衝突は、参照の衝突です。
これは、マクロ定義内において、定義外で宣言した変数への参照があった場合に発生します。

具体例を見てみます。
以下は、マクロ定義外の変数randomの参照をしています。

var random = function(seed) { /* ... */ }
let m = macro {
    rule {()} => {
        var n = random(42);
        // ...
    }
}

もしこれが以下のようなコンテキストで呼ばれた時、何が起きるでしょうか。

function foo() {
    var random = 42;
    m ()
}

マクロが健全でなければ、randomは関数fooのトップで定義されたものを参照してしまいます。
もしmがマクロでなく関数であれば、foo内のrandomvar宣言されているので、m内で使用されているrandomの参照先は変わりません。しかしこれはマクロでありそこにコードを展開するのでバグになるわけです。

上記の例においては、sweet.jsのハイジニックなマクロでは以下のように展開されることでrandomの衝突が防がれます。

var random$1 = function(seed) { /* ... */ }
function foo() {
    var random$2 = 42;
    var n = random$1(42);
    // ...
}

ケース・マクロ

sweet.jsには、ケース・マクロというより強力なマクロ定義方法があります。
R6RSで導入されたSchemeのsyntax-caseを模したものと見ています。こちらも、先に述べたsyntax-rulesより強力なものとなっているようです (複雑さとトレードオフ) 。
http://www.r6rs.org/final/html/r6rs-lib/r6rs-lib-Z-H-13.html

前述したマクロ定義では、決まったテンプレートに値をバインドしたものが展開されるのみでしたが、ケース・マクロにおいては JavaScript の言語機能をフルに使って展開するコードを生成できます。

ケース・マクロの雛形はこちらです。前述のマクロ定義とよく似ています。
こう書くと違いは一見、rulecaseになっていることと、<template><body>になっていることに見えます。

macro <name> {
  case { <pattern> } => { <body> }
}

実質的な違いの一点目としては、パターンの記述が「マクロ名も含んだもの」となることです。
ruleの場合は「マクロへの引数の渡し方」のみがパターンだったところにマクロ名が入り、より強力になります。

macro m {
  case { $name $x } => { ... }
}

// `$name` は <body> 内において `m` に束縛される
m 42

マクロ名の束縛が不要だという場合にはワイルドカード_を用います。

macro m {
  case { _ $x } => { ... }
}

もう1つの大きな違いは、<body>部がテンプレートとJavaScriptの混在になる点です。

以下でidマクロをケース・マクロで定義してみます。

macro id {
  case {_ $x } => {
    return #{ $x }
  }
}

#{...}の部分がテンプレートとなります。#{...}はsyntax objectというオブジェクトの配列です。
このオブジェクトをJavaScriptで操作することで、展開されるマクロを定義します。

macro m {
  case {_ $x } => {
    // `makeValue`はマクロ定義中で使えるsyntax objectの生成関数
    var y = makeValue(42, #{$x});
    return [y]
  }
}

m foo
// -> 42

その他

ここまででsweet.jsのマクロ定義の基本的な部分を解説してみました。

本家ドキュメントではこれ以外の豊富なマクロ定義方法について述べられているので、おいおい読み解いていきたいと思います。

自分なりに使ってみた: sweet.jsによる高速化

思い付いたネタがハイジニック感のないものだったのですが、マクロで実現することは変わらないので強行します。「マクロ」という機構がJavaScriptで使えるということが本質なのですから。

行うことは高速化で、JavaScriptにおけるオブジェクトのアロケートに関するものです。
まずは次のコードをご覧下さい。

// 2次元のある点を表してみよう
var point = {x : 10, y : 20};

上記のコードは非常に一般的な記述ではある一方で、pointオブジェクトのアロケートのコストが発生します。
これをケチるとするならば、以下のような形でしょうか。

var pointX = 10,
    pointY = 20;

実行時間を計測してみました (Google Chorme 39にて) 。
結果はバラついたものの、どの試行でも後者の方が高速でした。

var benchmark = function (name, f) {
    console.time(name);
    for (var i = 0; i < 10000; i++) {
        f();
    }
    console.timeEnd(name);
};

benchmark('w/  Object', function () {
    var point = {x : 10, y : 20};
});

benchmark('w/o Object', function () {
    var pointX = 10,
        pointY = 20;
});


// w/  Object: 1.101ms
// w/o Object: 0.170ms

しかし、このようなvar宣言が跋扈してしまうのはあまり美しく感じません。

そこで、マクロを使って多少マシにしてみたいと思います。
こんな記述ができたとしたらどうでしょう。

struct point = (x : 10, y : 15);
console.log(pointX);

という理想を抱いてできたのは結局これでした。竜頭蛇尾感が否めません。

// マクロ定義
macro struct {
    case {
        _ $s ($x0 $v0 , $x1 $v1)
    } => {
        var f = function (stxes, stx) {
            return makeIdent('var ' + stxes.map(unwrapSyntax).join(''), stx);
        };
        var r = [];
        r.push(f(#{$s _ $x0 = $v0}, #{x0}));
        r.push(f(#{$s _ $x1 = $v1}, #{x1}));
        return r;
    }
}

// マクロ呼び出し
struct point (x 3, y 4);
console.log(point_x);

はい、これでは2つしか属性を持てずとても残念です。
繰り返しの...を使ったら任意個の属性を持てて、かつ記述も美しくなるかと思ったのですが、まだ上手くできていません。

上記を展開した結果は以下となります。

var point_x=3;
var point_y=4;
console.log(point_x);

こちら、ダーティな方法で呼び出し側が捕捉できる名前を作っています。
変数名を文字列結合で強引に作って、それを識別子に変換しています。これだったら割と何でもアリではあるかなと。

所感

Advent Calendarにかこつけて、駆け足ですがハイジニック・マクロの概念とsweet.jsの基本的な使い方に触れる機会が得られました。

sweet.jsのうち言及できたもの (= 自分の学習カバレッジ) は30%くらいでしょうか。
自分なりに使ってみたコーナーでもあまりキレイには使えていなかったですし、習熟にはまだ時間がかかりそうです。

また、この源流となったSchemeのマクロについてもざっとは学べたものの、浅い部分に触れるのみだったなという印象です。
Chicken等の処理系に依存したマクロはおろか、syntax-caseもちゃんと調べられていないので、掘り下げる余地が多分にあります。
その一方で、「Schemeのマクロは深そうだ」という真実を知ることができたのは収穫でした。

最後に、sweet.jsもSchemeのマクロも理解が浅い状態で言うのはおこがましいかもしれませんが、
ハイジニック・マクロに触れてみて、Lispの古典的マクロ、すなわち同図像性 (Homoiconicity) バリバリというか、
データとしての式をこねこねして新たなデータとしての式を返す手法というのはプリミティブな分非常に明瞭な印象を受けました。
名前の衝突リスクというトレードオフはあるものの、Common Lispのようにそのリスクを飲めるのであれば手法としては強力なのかなと。
強力すぎて自分自身を吹き飛ばす可能性もあるのでプログラマが試されそうですが
(この辺の話題は怖い人たちがいっぱい攻め込んできそうなので、これで撤退します) 。

ではみなさま、2014年の聖夜はこんなJavaScriptでsweetにお過ごし下さい。

28
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
30

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?