ホーム » Juliaのマクロは凄かった。Lispは果たして勝てるのか?

Juliaのマクロは凄かった。Lispは果たして勝てるのか?


前回の記事で、LispとJuliaのマクロを比較した。

この記事の結論として、私は次のように宣言をした。

Juliaはそもそも関数定義の形とマクロ呼び出しの構文が全然違うので、うまくいかない。真似て書くなら下記のようになるが、実装をどうすればいいか私にはわからないし、呼び出し側も普通の関数定義とは違った、ぎこちないものになるだろう。

これがLispの誇る究極の同図象性だ。S式だからこそ、このような芸当が自然にできるのだ。しかし、S式に採用すると、それはLispになってしまうのではないか?これがLisp族とマクロの切っても切り離せない関係なのだ。

声高らかに、天高らかに発した宣言だったが、実際にはこれは誤りであった。私はJuliaの秘められたパワーに気づいていなかったのである。

前回の判定根拠

私はまず、Lispのマクロとして次のようなものを提示した。

(defmacro defun-with-log (funcname args &body body)
  `(defun ,funcname ,args
     (progn
       (start-log (string ',funcname))
       ,@body
       (end-log (string ',funcname)))))

このためにこんな感じの補助関数を用意している。[1] … Continue reading

(defun strconc (&rest lst)
  (apply #'concatenate 'string lst))

(defun start-log (funcname)
  (print (strconc funcname ": write start log")))

(defun end-log (funcname)
  (print (strconc fucname ": write end log")))

このマクロはこのように呼び出す。関数定義とほとんど変わらない見かけだ。

(defun-with-log add (a b)
  (print (+ a b)))

これは、次のようにマクロ展開される。

(defun add (a b)
  (progn
    (start-log (string 'add))
    (print (+ a b))
    (end-log (string 'add))))

これにより、通常の関数呼び出しのように、(add 2 3)と呼び出すだけで、ログ出力を前後に挟むことができるのだった。

(add 2 3)
;次のような出力が得られる。
"ADD: write start log"
5
"ADD: write end log"

なお、実際のところはこのdefun-with-logマクロは誤っている。それというのは、通常Lispでは一番最後の式が関数の戻り値となるのだが、このマクロは本来の関数の戻り値を消してしまい、end-log関数の戻り値で上書きしてしまうのである。そのため、実はあまり実用的ではないのだが、ここはわかりやすい例ということで許してほしい。都合の悪いことには目を瞑って突っ走ることが必要な時も人生にはあるのだ。なお、この欠点を修正した例は sin(@hyotang666)氏や黒木玄(@genkuroki)氏が例示されている。

さて、話を戻すと、前回の記事で私は、これと同等のマクロはJuliaでは書けないだろうと言った。その根拠を次に示そう。

そもそも、なぜLispは、こんなにも上手く書けるのだろうか?私はその理由として同図象性を挙げた。すなわち、マクロの呼び出しの形式と関数定義の形式が同じS式であり、S式から要素を取り出すのも、S式を組み立てるのも非常に簡単であるため、このようなことが可能になっていると言ったのである。

まず、呼び出し形式は次のようになっている。これはdefun-with-logというマクロ呼び出しであり、第一引数にadd、第二引数に(a b)、第三引数に (print (+ a b)) を渡している。

(defun-with-log add (a b)
  (print (+ a b)))

大事なのは、マクロの呼び出しを書いたら、それがあたかも普通の関数定義のように「見えた」、というところである。これらがどちらも「スペースでの分かち書きというS式のルール」を採用していることにより、このような現象が発生している。

そして、受け取った引数を元に、逆クォートとカンマを使って簡単に関数を組み上げている。これもLispのマクロの強みの一つである。だが、これはJuliaでも同じくらい得意とする分野なので、これはLisp固有の強みではない。

(defmacro defun-with-log (funcname args &body body)
  `(defun ,funcname ,args
     (progn
       (start-log (string ',funcname))
       ,@body
       (end-log (string ',funcname)))))

一方、Juliaの方はどうか?

Juliaで書くとしたらこんな風になるだろうと書いた。

macro function_with_log(funcname, args, body...)
   #???
end

そして、この形式はJuliaのマクロの呼び出し形式と全く違うので、上手くいかないだろうと言った。なぜなら、私は、マクロ呼び出しを書くとするとこんな風になるだろうと思っていたからだ。

func = quote
    function add(a, b) 
        println(a + b) 
    end
end

funcname = func.args[1].args[1]
args = func.args[1].args[2:end]
body = func.args[2:end]
@function_with_log(funcname, args, body) #カンマで分けて渡す必要があるんだから、当然こうしないとね

これは凄くぎこちないやり方に思える。こんな使い方を誰が好んでするだろうか?

結局、Lispマクロみたいに使えないのは、関数定義が「構文要素がスペース以外にも括弧やらカンマやら区切られている」一方で、マクロ呼び出しは「カンマ区切りである」という非対称が原因のためであると判断したのだ。しかしこれは誤りだった。

Juliaのマクロ呼び出し構文

さて、私の誤りを指摘して下さった方の一人があんちもん2(@antimon2)氏である。この方は、私が当初書こうとしてかけなかったJulia版withlogマクロを作成してくださった。先ほど紹介した黒木氏のマクロも有用だが、あんちもん2氏の形式の方が比較として近いので採用させていだだく。

コードを抜粋させていただく。下記がシンプル版と呼ばれていたwithlog_simpleである。もう一つ、MacroToolsというものを使うコードも例示されていたが、こちらの方が教育的だ。

macro withlog_simple(ex)
    if Meta.isexpr(ex, :function)
        s = string(ex.args[1].args[1])
        quote
            function $(esc(ex.args[1].args[1]))($(esc.(ex.args[1].args[2:end])...))
                start_log($s)
                $(esc(ex.args[2]))
                end_log($s)
            end
        end
    else
        esc(ex)
    end
end

start_logとend_logも掲載させていただこう。

function start_log(func_name)
    println("$(func_name): write start log")
end

function end_log(func_name)
    println("$(func_name): write end log")
end

さて、このマクロは下記のように呼び出される。これは上手く動き、前後にログを表示してくれる。

@withlog_simple function add_simple(a, b)
    println(a + b)
end
#次のような出力が得られる。
add_simple: write start log
5
add_simple: write end log

なんということだ。関数定義と非常に似た形式で呼び出せるマクロが作れてしまっているではないか!何が起こっているのだろうか?これにはマクロの特別な呼び出し構文が関わっている。

Juliaのマクロの呼び出し形式

Juliaのマクロは、通常の関数呼び出しの形式(括弧で括ってカンマで分ける形式)の他に、括弧を省略した形式での呼び出しがある。例えば、addというマクロがあり、引数を2つとるとすると、次のような両方の呼び出し方が可能になる。

@add(1, 2)
@add 1 2

私はなぜマクロにだけこのような呼び出し形式が存在するのか不思議だった。何が嬉しいかよくわからなかった。しかし、あんちもん2氏のマクロはこの形式を非常に有効に活用している。

括弧を省略する形式には、次の特徴がある。

  • スペースで区切られた要素を機械的に引数に割り当てるのではなく、構文として成立するかを加味しながら、なるべく少ない数の引数に割り当てる

まずこの定義だが、引数の数は1つである。

macro withlog_simple(ex)
...
end

そして、呼び出しの形式を見てみる。

@withlog_simple function add_simple(a, b)
    println(a + b)
end

withlog_simpleに与えられたものは、単純にスペースで区切ると “function”, “add_simple(a, b)”, “println(a”, “+”, “b)”, “end” である。しかし、これらは全て合わせると1つの関数定義と判断できるので、マクロの引数exに関数を丸ごと渡すことができるのだ。

マクロに引数で渡された関数はExpr型のデータになっている。ここまでくればもうこちらのものである。headやargsを指定して取得し(これはS式の先頭要素と2番目以降要素に相当)、構文を組み立てまくればいい。

Lispの場合は、マクロ呼び出しで渡す引数が、マクロ定義の仮引数に当たり前のように分配される。そのため、マクロ内でわざわざ分解して取得する必要がないというのが強みだが、それだけといえばそれだけである。呼び出し形式が自然であれば、マクロの中身が多少ごちゃごちゃしていようが、私はあまり気にならない。

なお、括弧なしで呼び出す構文を使わずに、こう呼んでも別にいい。

@withlog_simple(function add_simple(a, b)
    println(a + b)
end)

これは全く同様に動くし、関数定義っぽいという意味でも私は許容範囲である。もっとも、括弧無しバージョンの方がかっこいい。

結局のところ私の敗因は、Lispの場合は引数が自動で分配され、それがあまりに自然なものに見えたので、そこに目を奪われすぎたということにある。別にマクロ呼び出しに関数定義そのものを渡したって良かったのである。マクロ引数が抽象構文木に変換される以上、S式だろうがExpr型だろうが要素へのアクセスの容易さは同程度であり、そうなるとLisp側に格別の優位があるとは言えない。Lispの同図象性という点の主張も、見当はずれだった。

ここまで見てみると、Juliaのマクロ、本当にLispのマクロと同じくらい強力なんじゃないか?という気がしてくる。

そうなると、Juliaのマクロは本当に凄いことになる。健全さも加味すると、Lispのマクロより上なんではないだろうか。凄いぞJulia。前回の記事では君のことを過小評価してしまっていた。全く私が悪かった。大変申し訳ない。許しておくれ。君がこの世に生まれたことを、私はとても嬉しく思う。

・・・しかし、Lispよ。君は本当にそうなのかい?Juliaに負けちまうのかい?こんな、非の打ち所のない、小学校6年生の夏にやってきたハンサムでスポーツ万能な転校生みたいな奴に負けてもいいのかい?その上このハンサムボーイは勉強ができて性格まで良いんだ。どうなんだい?悔しくはないのかい?君の体は全てリストでできているんだろう?その究極の構造を発揮した、私などには思いもつかない素敵なマクロはできないのかい?

Lispの反撃

では、Lispのマクロにはもう一つも良いところはないのだろうか?Lispは見た目ばかり奇妙なくせに能力も劣っているトンチキ野郎なのだろうか?いや、そんなことはない。Lispでなければ表現できないマクロは、やはりあるのだ。もう一度だけ私の戯言に付き合ってほしい。もう一度振り返って考えてみよう。

Juliaのマクロはどんなものだったか。Juliaのマクロに式を渡すと、マクロはExpr型でそれを受ける。Expr型のデータ構造を読み取るのは簡単である。headやargsで構文要素にアクセスできる。このアクセス容易性が非常な強みだ。

Lispのマクロもどんなものだったか。LispのマクロにS式を渡すと、マクロはS型でそれを受ける。S式のデータ構造を読み取るのは簡単である。carやcdrで構文要素にアクセスできる。このアクセス容易性が非常な強みだ。[2] … Continue reading

おやおや、同じ説明になってしまった。引数の読み取りは引き分けだ。

Juliaのマクロはどんな風にコードを組み立てるだろうか?最終的に返すのはExpr型だ。Expr型ってどうやって作るのだったか。コンストラクタがある。Expr(:call, :+, :a, :b)みたいなやつだ。それからquote構文がある。:()とquote〜endである。それからMeta.parse関数というものもある。文字列で式を与えるとExpr型を返すのだ。大体こんなところだ。

Lispのマクロはどんな風にコードを組み立てるだろうか?最終的に返すのはリストだ。リストってどうやって作るのだったか。リストの作り方はたくさんある。 だから、Juliaのマクロに勝つとすればそこなのだ。

Lispはリスト処理がとても得意だ。何と言ってもLispの名前の由来はList Processingなのだ。なので、とてもここに列挙しきることはできない。Lispはリストを切ったり貼ったり変換したりがとても得意だ。あるリストから別のリストへ変形する、極めて複雑な処理を書くことができる。

そのレベルになると、私のようなトンチキ野郎にはとても手に負えない。On Lispの著者であるPaul Graham大先生の力を借りよう。

次のマクロはOn Lispからの引用だ。dbindというマクロで、構造化代入という名前がつけられている。複数の変数に一度に代入する処理だ。

http://www.asahi-net.or.jp/~kc7k-nd/onlispjhtml/destructuring.html

このdbindマクロはこのように使う。

(dbind (a b c) #(1 2 3)
  (list a b c)) ;(1 2 3)

(dbind (a (b c) d) '(1 #(2 3) 4)
  (list a b c d)) ;(1 2 3 4)

マクロの定義はこれだ。

(defmacro dbind (pat seq &body body)
  (let ((gseq (gensym)))
    `(let ((,gseq ,seq))
       ,(dbind-ex (destruc pat gseq #'atom) body))))

(defun destruc (pat seq &optional (atom? #'atom) (n 0))
  (if (null pat)
      nil
      (let ((rest (cond ((funcall atom? pat) pat)
                        ((eq (car pat) '&rest) (cadr pat))
                        ((eq (car pat) '&body) (cadr pat))
                        (t nil))))
        (if rest
            `((,rest (subseq ,seq ,n)))
            (let ((p (car pat))
                  (rec (destruc (cdr pat) seq atom? (1+ n))))
              (if (funcall atom? p)
                  (cons `(,p (elt ,seq ,n))
                        rec)
                  (let ((var (gensym)))
                    (cons (cons `(,var (elt ,seq ,n))
                                (destruc p var atom?))
                          rec))))))))

(defun dbind-ex (binds body)
  (if (null binds)
      `(progn ,@body)
      `(let ,(mapcar #'(lambda (b)
                         (if (consp (car b))
                             (car b)
                             b))
                     binds)
         ,(dbind-ex (mapcan #'(lambda (b)
                                (if (consp (car b))
                                    (cdr b)))
                            binds)
                    body))))

解説はしないが、極めて複雑な処理をしていることがわかるだろう。リストを切ったり貼ったり変換したり大変な騒ぎだ。JuliaのExprでこのようなことができるのだろうか?さて、私にはわからない。想像する範疇では、難しそうな気がする。Exprのコンストラクタをすごく頑張ったらできるのかもしれないが、あまりに複雑で手に負えなくなるのではないだろうか。しかしそれは憶測に過ぎない。[3] … Continue reading

まとめ

前回の記事で結論を出した時よりも、Juliaは大きな力を持っていることがわかった。少なくとも私が判定できるレベルの複雑さの処理では、JuliaのマクロがLispのマクロよりも劣っているという証拠はない。私の判定できないレベルで、違いがあるかもしれないということがぼんやりとわかる程度だ。

また、もしも、JuliaがExprを操作する能力が、Lispのリストを操作する能力と同等であれば、これで反論の余地はない。Juliaは完全にLispに到達したと認めるしかないだろう。この点もよくわからない。さらなる情報を求むところだ。

そういったわけで、現時点での私の実力では判定不能だ。ともかく、Juliaは思っていたよりも凄いやつだった。凄いぞJulia。頑張れJulia。私に言えるのはそれだけだ。

References

References
1 これらは別に重要ではない。ただ私はコードを写経したりコピペしたりしたときに動かないと途端にやる気をなくすので、書いた方がいいかなと思っている。
2 え?carやcdrって何だって?リストの先頭要素の取得がcarで2つ目以降の取得がcdrだ。名前がわかりづらいって?ガタガタいうんじゃない。すぐ慣れる。どうしても嫌ならcarやcdrを呼び出すマクロを作ってそれを好きなだけ呼びなさい。
3 このマクロだって手に負えないと思うかもしれない。確かに、一見するととんでもない怪物に思えるかもしれないが、自分でdbindを書こうとしてみたら、意外とそれぞれの処理が何をやっているかわかってくる。経験上、複雑なマクロは読むだけでは理解できない。macroexpandなどを使っても限界がある。自分で書いてみることだ。