Juliaというプログラミング言語が最近ホットだという。どうやら数値計算系の界隈を特に席巻している言語だそうである。
数値計算プログラミングというのは数学や物理学などの分野で登場する数式をコンピュータに解かせるという分野のことだ。もちろんプログラムを書いて解かせるわけだが、同じソフトウェアであっても、今時のキラキラWeb系プログラムやスマホアプリなどの分野とは随分違う。
入力は数値、出力も数値、デバッグ中も数値、寝てる間も数値、数値、数値、数値。数式を理解して、プログラムに変換できるのは大前提。その上で、「その式変形、数学的には等価だけど、桁落ちの誤差の評価ってどうなってるの?」みたいな会話が飛び交う、それはそれは硬派で地味な世界なのだ。なお桁落ち誤差を改善したら、次は情報落ち誤差で責め立てられる。まあ世間一般からすると、懲役でも勘弁、みたいな仕事である。
そんな数値計算は、硬派で地味だが重要な分野である。NASAは月へロケットを飛ばすために数値計算を駆使した。台風の進路予測などは流体力学の方程式を数値計算で解くことで求める。みんな大好き人工知能ディープラーニングも裏では数値計算をゴリゴリに行っている。その他、枚挙に暇がないくらいに実例がある。気になる方はWikipediaを参照すること。
地味とは言え、シミュレーション結果を可視化すると、意外と見栄えも良かったりする。数式の解の動きを図示したシミュレーション結果を見ると、複雑な中にも調和が見られたりして、ため息が出るほど美しいこともある。私は数値計算が好きである。(※得意ではない)
さて、数値計算界で使われる主要な言語はFORTRANである。
FORTRANとは1954年に生まれた、現役の言語の中では世界最古のプログラミング言語の1つである。古い言語の代表格のC言語が1972年生まれなので、いかに古参かということがわかると思う。最長老様である。
数値計算界は今でもFORTRANを使っている。過去に多くのプログラムがFORTRANで書かれてきた。今後もFORTRANのプログラムは多く書かれるだろう。なぜそんなにFORTRANにこだわるのか?理由はいろいろある。だが、何と言っても高速なのである。数値計算業界は、とにかくコンピュータに大量の計算をさせる。宿命的にそうなのだ。今のプログラムが所望の結果を出すのに2週間かかかるとする。コンピュータの性能向上でが計算速度が2倍になったとしても、1週間でやめたりはしない。やっぱり2週間かける。それで、より良い結果を追い求めるのである。
普通のプログラムでも速度はそれなりに大事だが、ユーザーが不快に感じないくらいであればそれでいい。一方、数値計算では計算速度そのものが、シミュレーションの結果に大きく影響する。
さて、数値計算業界が、計算機をキリキリに締め上げているのを余所に、世間のプログラミング言語はもっとゆるふわな方向に進化してきた。Python、Rubyなどの動的言語の台頭である。
これらの言語は書いていて実に快適だ。細かいことをごちゃごちゃ指定しなくて良い。人間の書いたソースコードは、どこかのタイミングで機械語に変換される必要がある。FORTRANなどの静的なプログラミング言語は、プログラムを書いて、全体を機械語に変換して、それから実行する。Rubyなどの動的なプログラミング言語は、プログラムを書いて、実行しながら機械語に変換していく。実行しながら機械語に変換する性質により、プログラムを書く時点で決めておかなければならないことが少なくなる。代償として、プログラムの実行速度はかなり遅くなってしまう。
遅いと言っても問題にならないことも多い。計算機のマシンパワーは近年急速に増大してきた。そもそも、重たい処理を必要としないプログラムも多いのだ。そういったケースでは、RubyやPythonはとても良い選択肢だ。
もちろん数値計算業界ではそうはいかない。重たい処理を解くことが使命の業界なのだ。そんなわけで、PythonやRubyなどは、数値計算業界では計算の軽い部分に使うことはあっても、メインとなる処理はFORTRANで書かれる。仕方のないことだ。
FORTRANというのは幸いにして難しい言語ではない。少なくともC言語よりはずっと簡単だ。だから、数値計算業界に入ってきた新人にFORTRANを学ぶことはそう高いハードルではない。多少古めかしさはあるが別に読みづらいというほどではない。配列のインデックスが1から始まることには驚くかもしれないが、計算させたい数式の添字も1から始まることに気づき、むしろその方がやりやすいことに気づくだろう。なんだかんだで、FORTRAN悪くないじゃん、いやむしろライブラリやサンプルコードも充実しているし、最適解ではないだろうか。そんなこんなで30年、40年やってきたのである。
そんな数値計算業界に、突如として彗星の如く現れたのがプログラミング言語「Julia」であった。Juliaの宣伝文句はすごい。引用してみよう。
僕らが欲しい言語はこんな感じだ。まず、ゆるいライセンスのオープンソースで、Cの速度とRubyの動的さが欲しい。Lispのような真のマクロが使える同図象性のある言語で、Matlabのように分かりやすい数学の記述をしたい。Pythonのように汎用的に使いたいし、Rの統計処理、Perlの文字列処理、Matlabの線形代数計算も要る。シェルのように簡単にいくつかのパーツをつなぎ合わせたい。チョー簡単に習えて、超上級ハッカーも満足する言語。インタラクティブに使えて、かつコンパイルできる言語が欲しい。
(そういえば、C言語の実行速度が必要だってのは言ったっけ?)
こんなにもワガママを言った上だけど、Hadoopみたいな大規模分散コンピューティングもやりたい。もちろん、JavaとXMLで何キロバイトも常套句を書きたくないし、数千台のマシンに分散した何ギガバイトものログファイルを読んでデバッグするなんて論外だ。幾層にも重なった複雑さを押しつけられるようなことなく、純粋なパワーが欲しい。単純なスカラーのループを書いたら、一台のCPUのレジスターだけをブン回す機械語のコードが生成されて欲しい。A*Bと書くだけで千の計算をそれぞれ千のマシンに分散して実行して、巨大な行列の積をポンと計算してもらいたい。
型だって必要ないなら指定したくない。もしポリモーフィックな関数が必要な時には、ジェネリックプログラミングを使ってアルゴリズムを一度だけ書いて、あとは全ての型に使いたい。引数の型とかから自動的にメソッドを選択してくれる多重ディスパッチがあって、共通の機能がまったく違った型にも提供できるようにして欲しい。これだけのパワーがありながらも、言語としてシンプルでクリーンなものがいい。
これって、多くを望みすぎてるとは思わないよね?
https://www.geidai.ac.jp/~marui/julialang/why_we_created_julia/index.html
これを喋っているのが金融商品の勧誘員だったら詐欺としか思えない説明だ。そんな都合のいい話があるわけがない。怪しげな新興国の年利30%の企業社債(為替影響込みの元本保証あり!)みたいな話だ。
しかし、どうやらこれは金融商品ではなくプログラミング言語で、緩いライセンスのオープンソースというところは少なくとも真実で、つまり騙されるにしても失うのは自分がJuliaを試してみた時間だけで、どうやら子供の将来ための学資保険は解約せずに済みそうなのだ。
しかし私はビビッときたのだ。
「Lispのような真のマクロが使える同図象性のある言語」
ここに引っかかってしまったのだ。「Lispのような真のマクロ」は、Lispでしか実現できない。私はそう信じてずっと生きてきたのだ。いや、ずっと、というのは言いすぎだ。しかし、ここ5年くらいはそうしてきたのだ。
Lispとは
Lispについて紹介しよう。LispというのはFORTRANと同じくらい古い言語だ。生まれは1958年と言われている。
Lispはプログラミング界の異端児だ。何が異端かというと、いろいろ異端なのだが、何よりその見た目だ。普通のプログラミング言語と比較すると、地球人と火星人くらい違う。(火星人には会ったことないけど!)
Juliaと比較してみよう。Juliaについてはあまり細かい説明はしない。Juliaは読みやすいので、Julia自体知らなくても、何かのプログラミング言語を知っていればわかると思う。
Lispの大きな特徴は、前置記法である。オペレータが必ず先頭に来るのだ。xとyを足すときには、(x + y) ではなく(+ x y)となる。
もう1つの特徴は、区切りの記号に丸括弧とスペースしかないことである。
コードを見てもらった方が早いだろう。
LispのサンプルコードはCommon Lispで書いている。Lispは歴史が長いのでLisp族と呼ばれるいろいろな言語が存在するが、Common Lispというのは代表的な一派だ。
これは受け取った引数の値を合計する関数である。なお、defunというのはCommon Lispの関数宣言である。
Julia
function add(x, y)
x + y
end
Common Lisp
(defun add (x y)
(+ x y))
このくらいであれば、まだあまり違いはない。
次は、階乗の計算をするプログラムである。
Julia
function factorial(n)
if (n == 0)
1
else
n * factorial(n - 1)
end
end
Common Lisp
(defun factorial (n)
(if (= n 1)
1
(* n (factorial (- n 1)))))
かなり違いが出てきた。Juliaはそう変わっていないが、Common Lispにはかなり括弧が増えてきた。
この括弧こそが、Lispが奇妙な言語に見える部分だ。想像されるように、もっと大きな関数だともっと括弧だらけになっていく。
そしてこれこそがLispにある種のパワーを与えているのだ。この点についてはのちに触れていくことにする。
マクロとは
マクロという機能について説明しよう。一部の言語はマクロという機能を備えている。ざっくりと言うと、マクロ機能というのは「ソースコードを生成・改編するソースコード」を取り扱う機能のことである。
もう少し正確に言うと、書かれたソースコード に悪戯をして、プログラムの動作を変更する機能全般を指して、一般的にメタプログラミングと呼ぶ。そのうち特に、コンパイル時に行われる処理で発動して、ソースコードを生成・改編する機能が、「マクロ」と呼ばれることが多い。
マクロ機能を備えていない言語も多い。
- FORTRANは単に備えていない。
- JavaやPythonやRubyはマクロ機能そのものは備えていないが、メタプログラミングの範疇に属する機能は提供している。 Javaだとアノテーション、Pythonだとデコレータ、Rubyだとdefine_methodやsendなどである。
マクロ機能を備えていると言っている言語でも、提供されている機能は異なることが多い。C言語のマクロ機能とLispのマクロ機能には大きな差がある。 Julia言語はマクロ機能を備えていると宣言している。Julia言語がどのあたりの位置づけになるか、ということが本稿の主題である。
また、事実上マクロ機能なのに、マクロという名前がついていないこともある。C++のテンプレート機能はマクロ機能と呼んで差し支えないと思うが、おそらくC言語から受け継いだのマクロ機能と区別するためにこの名前になっている。C#にはT4テンプレートという機能があり、これもマクロ機能の一種である。ただこれはC#の機能というよりは統合開発環境であるVisual Studioの機能なので、言語機能に含めていいかは微妙かもしれない。
ちょっと散らかり気味になってしまったがまとめると、
- 書かれたソースコード に悪戯をして、プログラムの動作を変更する機能全般を指して、一般的にメタプログラミングと呼ぶ。
- そのうち特に、コンパイル前に行われる処理で発動して、ソースコードを生成・改編する機能が、「マクロ」と呼ばれる。
- 書かれたソースコード に悪戯する機能なので、ソースコード が見たままの動作をしてくれない。そのため、多くの場合、無闇に使うなと言われる。
このあたりはそんなにカッチリ定義の決まっているものでもなさそうが、私は大体そのような感覚で捉えている。
こちらも実例を紹介するのが早いだろう。C言語、Lisp、そしてJuliaのマクロ機能を紹介する。
C言語のマクロ
C言語のマクロはあまり強力ではない。しかしわかりやすいので、まずマクロ入門という位置づけで紹介する。このあと登場する強力なLispのマクロの引き立て役でもある。福山雅治の隣に立った私のことだ。
また、前半は、C言語のマクロ知っている人には常識的な内容なので飛ばしてもらっても良いが、コード生成の話につながる#演算子のあたりからは読んでほしい。
C言語のマクロは #define という記号で定義される。下記のコードで言うと、「”PI”って文字は、”3.14″に置き換えてコンパイルしておくれよー」という意味である。
#define PI 3.14
//半径を受け取って円の面積を計算する
float calc_circle_area(float radius) {
float area = PI * radius * radius;
return area;
}
通常ソースコード は、そのままコンパイラに渡されて機械語に翻訳される。ところが、マクロが定義されていると、そこに一段階処理が挟まるのだ。これがプリプロセスと呼ばれる処理である。プリプロセスが終わった後のプログラムは、下記のように姿を変えている。
//半径を受け取って円の面積を計算する
float calc_circle_area(float radius) {
float area = 3.14 * radius * radius;
return area;
}
ソースコードの文字列がそのまま置換されているイメージである。
マクロは、このように定数値の置き換えで使われることの他に、関数っぽく使われることも多い。
#define PI 3.14
#define SQUARE(x) x * x //注意! バグあり!!
//半径を受け取って円の面積を計算する
float calc_circle_area(float radius) {
float area = PI * SQUARE(radius);
return area;
}
プリプロセスが終わった後のプログラムは、下記のように姿を変えている。
float calc_circle_area(float radius) {
float area = 3.14 * radius * radius;
return area;
}
めでたしめでたし、とはいかない。このSQUAREマクロにはバグがあるのだ。
次のようなケースを考えよう。
#define SQUARE(x) x * x //注意! バグあり!!
SQUARE(1 + 2); //9を期待
プリプロセスが終わった後のプログラムは、下記のように姿を変えている。
1 + 2 * 1 + 2; //なんと5
これはC言語のマクロがあくまで文字列置換であるからだ。
これを防ぐには、次のように括弧をつける必要がある。
#define SQUARE(x) ((x) * (x))
SQUARE(1 + 2); //9を期待
こうすると上手くいく。
((1 + 2) * (1 + 2)); //めでたく9
こちらがひとかたまりだと思っているものがバラけてしまわないように、括弧で優先順位を明確にする必要があるのだ。ちょっと癖の強い機能ということが分かると思う。
さて、C言語のマクロのメリットとは何だろうか?これには3つある。
- コンパイルより前に文字列置換されるために、インライン化を強制できる。
- 総称関数の定義ができる。
- コード生成ができる。
1つ目のインライン化は、関数呼び出しのオーバーヘッドを避けるためのものである。関数呼び出しは僅かながらコストのかかる処理である。これを避けるために、「関数のインライン化」という手法が取られることがある。ソースコード 上では関数を呼び出しとして記述されているが、コンパイル時に、呼び出し先関数の中身を呼び出し元関数に埋め込むイメージである。こうすることで、実行時に関数を呼び出す必要がなくなる。
普通のパソコンでは余程のことがない限り気にしなくても良いと思うが、C言語は電化製品などに組み込まれたソフトによく使われる。こういったときには非常に大事になってくる。このような環境は、通常、計算機資源が非常に限定されているからである。CPUやメモリもパソコンのものと違うため、そもそもmain処理から呼び出せる関数の階層(深さ)自体が制限されていることもある。こういった制約のある環境では、マクロは非常に心強い機能となってくれる。
2つ目の総称関数の定義というのは、言葉は小難しいが、内容は大したことはない。SQUAREマクロでもやっている。SQUAREマクロはこう定義した。
#define SQUARE(x) ((x) * (x))
そして、使い方を2つ示した。
//例1
float calc_circle_area(float radius) {
float area = PI * SQUARE(radius);
return area;
}
//例2
SQUARE(1 + 2);
例1では、float型を引数にとり、例2では、int型を引数にとっている。C言語の関数ではこれはできない。内部の式が一緒であっても、float引数用の関数と、int引数用の関数を別個に定義しなければならない。これは有効なトリックなので、組み込み環境で使われるケース以外でもよく使われる。ただ、先進的な言語であれば、この問題は別の形で解決されている。総称関数、ジェネリック関数、などという名前の機能が提供されていると思う。
3つ目のコード生成については、新たに例を出す必要がある。#演算子というものである。
この演算子の働きは、マクロに渡された引数にダブルクオーテーションをつけて文字列化するのである。引数の中身にではない。引数として渡されたもの自身である。コードを見てみよう。
下記のようなSYMBOLマクロを定義する。SYMBOLマクロは、引数に#演算子を適用しているだけである。
#define SYMBOL(x) #x
次のコードで出力される結果は、変数名の”num”である。変数numの中身の100は表示されない。
#include <stdio.h>
#define SYMBOL(x) #x
int main(void){
int num = 100;
printf("%s\n", SYMBOL(num)); //numと出力される。
return 0;
}
このように処理されるからである。
#include <stdio.h>
int main(void){
int num = 100;
printf("%s\n", "num");
return 0;
}
これだけでは何に使うかわからないかもしれないが、少し面白い使い方ができる。
下記のようなマクロを定義する。このマクロはデバッグプリント時に使う。値を調べたい変数を引数にセットすると、変数名とその値を出力してくれるのだ。
#define DEBUG_PRINT_INT(x) printf("%s:%d\n", #x, x);
#include <stdio.h>
#define DEBUG_PRINT_INT(x) printf("%s:%d\n", #x, x);
int main(void){
int num = 100;
DEBUG_PRINT_INT(num); //"num:100"と表示される。
return 0;
}
マクロが下記のように展開されるからである。通常はプログラマが手でprintf(〜, “num”, num)と書くのであるが、これをマクロに代行させているのである。これが、マクロによるコード生成の意味である。
#include <stdio.h>
int main(void){
int num = 100;
printf("%s:%d\n", "num", num); //ここをマクロが生成してくれている。
return 0;
}
このような芸当はC言語ではマクロにしかできない。通常、関数のローカル変数のシンボル名の情報などは、コンパイル時に消え失せてしまうためだ。マクロが動くのはコンパイルより前のプリプロセス処理なので、こういうことができる。
さらに##演算子というものがある。例は省略するが、##演算子は、マクロ定義の中で、文字列連結をしたいときに使用する。これを活用することで、さらに複雑なコード生成を行うことができる。
C言語のマクロを使ったより高度なコード生成例が下記サイトで紹介されているのでリンクを貼っておく。
http://www.nurs.or.jp/~sug/soft/super/macro.htm
Lispのマクロ(前文)
ようやくここまで到達した。LispのマクロとJuliaのマクロを比較するのがこの記事の主題なのだ。
さて、Lispのマクロは極めて強力である。先ほど紹介したC言語のマクロはそれほど多くのことはできなかった。マクロ定義は文字列置換であり、追加で#演算子と##演算子が使えるだけである。これだけでもできることは結構あるし、マクロでなければできないことも見てきた。しかし、例えば、マクロ定義の中で、条件Aが満たされていたらこのコードを生成、そうでなければこのコードを生成、というようなことはできない。
Lispのマクロはそうではない。Lispのマクロ定義中では、Lispの文法で定義された構文は全て使うことができる。Lispのマクロの定義は、Lispの関数の作成とほとんど同じ感覚で行える。こちらも実物のコードを見てもらうのが良いだろう。
と思ったが、その前にまずはLispコードについて説明しなければならない。マクロはコードを作る。コードについて知らなければ話にならない。
Lispの構文
上の方でも触れたが、Lispのコードは非常に奇妙な形をしている。JuliaとLispの階乗を計算するコードを再掲する。
Julia
function factorial(n)
if (n == 0)
1
else
n * factorial(n - 1)
end
end
Common Lisp
(defun factorial (n)
(if (= n 1)
1
(* n (factorial (- n 1)))))
一見奇妙に見えるこの構文は実は非常なメリットがあるのだ。順序立てて解説していこう。
defunというのは、関数定義の宣言をしている。defunは次のような構文ルールになっている。
(defun 関数名 引数 関数本体)
関数宣言は、先頭がdefunのリストである。「リスト」というのはLispの中心的なデータ構造で、()で囲まれ、スペースで区切られた、データの並びのことである。データとは何かというと・・・何でも良い。リストは非常に柔軟なデータ構造だ。配列とは似ているが違う。配列は通常、同じ型の要素しか入らない。intの配列、decimalの配列のような感じだ。Lispのリストは内部にどのような要素でも含むことができる。
今回の例で言うと、
要素1: defun: シンボル
要素2: factorial: シンボル
要素3: (n): リスト
要素4: (if (= n 1) 1 (* n (factorial (- n 1)))): リスト
という4つの要素を持つリストである。
シンボルというのはこれ以上分解できない記号のことである。何かの数値や文字列や他のリストと紐づけることができる。紐付けなくても良い。Lispのシンボルは、大文字と小文字を区別しない。
重要なのは、defun構文を読み解くときに、スペースで切り出すだけで要素に分解できたと言うことである。
要素1と要素2はシンボルなのでこれ以上分解できない。要素3を見てみよう。これは要素数が1のリストである。要素4はif式と言う構文である。
(if 条件式 条件が真の時に実行される式 条件が偽の時に実行される式)
if式は、先頭がifのリストである。
今回の例で言うと、
要素1: if: シンボル
要素2: (= n 1): リスト
要素3: 1: 数値
要素4: (* n (factorial (- n 1))): リスト
要素1と3はシンボルと数値だ。要素2は先頭が=記号のリストだ。これは、与えられた2つの要素が等しいかどうかを判定する関数である。要素4は先頭が*記号のリストだ。これは、与えられた要素の積を計算する関数である。
ここまでの流れは他の構文も同様である。Lispの構文はリストで定義される。リストの先頭がオペレータである。例外はない。[1]構文ではない単なるリスト(数値の並びなど)を表現したいときには、リストの前にシングルクォートをつけるなどして、明示的に表現する。このように表現される構文をS式と呼ぶ。
これはLispのプログラムを処理するLisp言語のコンパイラやインタプリタを作成する時に、非常に役に立つ。何と言っても、単純なのだ。リストを見つけたら、スペースで区切って要素に分解する。先頭の要素はオペレータだ。該当する処理を行え。残りは引数だ。処理に渡してやれ。以上である。
もちろん代償はある。我々は幼少期から数学教育の訓練を受けてきた。そのため、数式は 1 + 2 * 3 のように、数値とオペレータを混ぜこぜで書きたくなるし、 1 + 2 * 3 のような式を見ると、 2 * 3 を先に計算してくてしょうがなくなるのだ。多くの言語はその気持ちを尊重してきた。 1 + 2 * 3 と書くことを許し、2 * 3 を先に計算するように尽力してきた。しかしLispは違う。「2と3を掛けてから1を足したいだと? その通りに書けば良いじゃないか。 (+ 1 (* 2 3)) だ。わかったな。」
もしも、Lispの構文がこのようになっている理由がコンパイラやインタプリタの作者のためだけであれば、この制約は不当である。Lispもあっという間に1 + 2 * 3と書けるようになっていただろう。しかし、Lispはそうならなかった。言語のユーザーも今の形であることを望んだ。その理由が「マクロ」にあるのだ。
Lispのマクロ(本文)
それではLispのマクロを見ていこう。
まずは、C言語と同じく与えられた引数を2乗する処理を記述しよう。Common Lispの関数版とマクロ版を記述する。なお、関数版とマクロ版で命名が違うのは、単に分かりやすさのためで、別にマクロをm-から始める必要があるわけではない。defmacroというのが、Common Lispのマクロ定義の宣言だ。
; 関数定義
(defun square (x)
(* x x))
;マクロ定義
(defmacro m-square (x)
`(* ,x ,x))
さて、関数版は特にコメント不要だろう。問題はマクロ版だ。急に意味不明な記号が登場してきた。バッククォート「`」と、カンマ「,」だ。記号の意味を解説する前に、そもそもマクロで何が行われているのかを話した方がいいだろう。C言語のマクロは文字列置換を行うのだった。では、Lispのマクロはというと、コードを組み立てているのである。関数は値を返す。マクロはコードを返す。
具体的に見ていこう。まず、Lispのマクロの呼び出し方は、関数と同じである。
; 関数呼び出し
(square (+ 1 2))
;マクロ呼び出し
(m-square (+ 1 2))
関数呼び出しの際には、次のような流れで処理が動く。
- (+ 1 2)が評価され、3に置き換えられる。
- squareの内部の(* x x) が (* 3 3)に置き換えられ、9と評価される。
- 9が返される。
コードで段階を追って表示すると、下記のようになる。
; 関数定義
(defun square (x)
(* x x))
; 関数呼び出し
(square (+ 1 2))
(square 3)
(* 3 3)
9
次にマクロ呼び出し処理を説明する。マクロの実行は2段階に分かれていると意識すると良い。マクロ展開時と実行時である。
マクロ展開というのは、コードを書き上げたあと、コンパイルを行うときに行われる。通常、書き上げられたコードは、マクロ呼び出しと関数呼び出しが混在している。マクロ展開の際には、マクロ呼び出し部分だけを拾って行き、定義したマクロにしたがって評価していく。そして、評価した結果(普通はコードとして解釈できるリスト)を、マクロ呼び出しが行われていた箇所に、ペタペタと貼って置き換えていくのである。マクロ展開が終わると、マクロ呼び出しはコードからはもはや消え失せており、普通の式や関数呼び出しだけが残るのである。[2] … Continue reading
マクロ展開時
- (+ 1 2)はそのままの形でマクロ内部に渡される。
- マクロ内部では、xに(+ 1 2)が割り当てられる。この(+ 1 2)はリストである。
- m_square内部の,xが(+ 1 2)に置き換えられ、`(* (+ 1 2) (+ 1 2))となる。
- (* (+ 1 2) (+ 1 2))が返される。
実行時
- (* (+ 1 2) (+ 1 2))で(+ 1 2)が3と評価される。
- (* 3 3)が9と評価される。
- 9が返される。
コードで段階を追って表示すると、下記のようになる。細かい部分は、後ほど説明する。
;マクロ定義
(defmacro m-square (x)
`(* ,x ,x))
;マクロ呼び出し
(m-square (+ 1 2))
;マクロ展開後
(* (+ 1 2) (+ 1 2)) ;ここに注目
;実行時
(* (+ 1 2) (+ 1 2))
(* 3 3)
9
違いがわかっていただけたと思う。関数呼び出しが完了すると、9という値が返った。マクロ呼び出しが行われると、マクロ展開により(* (+ 1 2) (+ 1 2))というコードが返り、最終的な実行時の評価で9になる。
では、マクロ定義を解説する。
;マクロ定義
(defmacro m-square (x)
`(* ,x ,x))
まず最初の「`」である。これはバッククォートや逆クォートと呼ばれる記号である。これは何だろうか?
マクロはコードを返すと言った。Lispのコードはリストである。なので、結局、マクロはリストを作って返すのだ。Lispでリストを作る方法はいくつかある。
代表的なのがlist関数である。この関数は、引数をそのままリストにする。
(list 1 2 3) ;(1 2 3)となる。
次が、quoteである。これは、働きは一見list関数と似ている。
(quote (1 2 3)) ;(1 2 3)となる。
しかし、実際には違う。その例を示そう。
(list a 2 3) ;aという変数が存在しないというエラーになる
(quote (a 2 3)) ;(a 2 3)となる。
quoteは実際にはリストを作っているわけではない。quoteの働きとは何か?それは、lispコードの評価器に、コードの評価をやめてくれるよう伝えることだ。
(list a 2 3)を渡された評価器は、こう考える。
「aと2と3をまとめたリストを作れば良いわけだな。aってなんだ?シンボルか。ん?定義されてないぞ!エラーだ!」
(quote (a 2 3))を渡された評価器は、こう考える。
「(a 2 3)ってのが何者か知らないが、調べるなって言われてるんだから調べないぞ。答えは(a 2 3)だ!」
これがquoteの正体である。なので、引数はリストでなくても良い。例えば引数にシンボルをとって、(quote a)とすると、シンボルaが返る。そして、このquote処理の省略形がクォート記号「’」である。
(quote (a 2 3));(a 2 3)
'(a 2 3);(a 2 3)
なお、引数にリストを取ったときのquoteは、list関数で引数全てにquoteをつけたものと同じになる。
(list 'a '2 '3) ;aという変数が存在しないというエラーになる
(quote (a 2 3)) ;(a 2 3)となる。
そして、逆クォート記号「`」である。バッククォートとも言う。これは、クォート記号とほとんど同じだ。
'(a 2 3);(a 2 3)
`(a 2 3);(a 2 3)
違いは、内部にカンマ記号「,」を含んでいるときに現れる。カンマを含めると何が起こるのだろうか?クォート記号の働きとは、lispコードの評価器に、コードの評価をやめてくれるよう伝えることだった。しかし、バッククォートの場合、カンマ記号があると、その部分だけは評価するのだ。
次のコードを見てみよう。letというものが出てきたが、これはローカル変数の宣言だ。aという変数は1だと宣言している。
一番上はクォート記号で、中身を何も評価しないので、中身そのままの(a 2 3)を返す。カンマを含まないバッククォートも同様だ。しかし、最後のカンマを含むバッククォートは、aの値を評価して、結果に反映する。
(let ((a 1))
'(a 2 3));(a 2 3)
(let ((a 1))
`(a 2 3));(a 2 3)
(let ((a 1))
`(,a 2 3));(1 2 3)
カンマはバッククォートをlist関数で展開したときに、クォート処理を打ち消すと考えてもいい。`(a 2 3)は(list ‘a ‘2 3’)と等価であり、`(,a 2 3)は(list a ‘2 ‘3)と同じである。
`(,a 2 3)は、(list a '2 '3)と同じ。
(let ((a 1))
`(,a 2 3));(1 2 3)
(let ((a 1))
(list a '2 '3));(1 2 3)
一応これで、マクロm-squareの動作は理解できることになる。
(defmacro m-square (x)
`(* ,x ,x))
しかし、どうせなので、なぜマクロを普通の関数みたいに書いてはいけないかを考えてみることにしよう。
まず、普通の関数と同じに書いてみる。
(defmacro m-square (x)
(* x x))
これがマクロ展開されるとどうなるか?マクロ展開処理は、関数呼び出しと非常に似たプロセスで行われる。違いは引数の評価である。関数呼び出しは、処理の開始前に引数を評価するが、マクロ呼び出しは引数を評価せずに、処理を開始するのである。引数の評価についてはプログラマに完全に任されており、一度も評価なくてもいいし、何度も評価してもいい。
そのため、下記のようにマクロを呼び出すと、
;マクロ呼び出し
(m-square (+ 1 2))
下記のように動き、エラーとなる。
;マクロ展開の途中経過を抜き出したもの。引数を評価せずに中身の処理まで持ってきている。
(* '(+ 1 2) '(+ 1 2));リストとリストの掛け算は定義されていないのでエラーとなる。
引数の評価がされないのが問題であれば、引数の評価を行えば良い。ということで下記のように書いてみると、これは文法エラーとなる。
;引数の評価はカンマを書けばいいんだよね?と書いてみた。
(defmacro m-square (x)
(* ,x ,x));しかし文法エラー
カンマはバッククォートの中でのみ有効だからだ。しかし、実は引数の評価をさせる別の方法がある。evalという処理である。これは、lispコードを評価するための処理だ。
;evalの例。リスト(+ 1 2)を、コードとして評価する。
(eval '(+ 1 2));3となる
これを使うと、どうなるだろうか?
;evalで評価してローカル変数yに値を代入してから、yの二乗を計算する。
(defmacro m-square (x)
(let ((y (eval x)))
(* y y)))
同じく次のように呼び出す。
;マクロ呼び出し
(m-square (+ 1 2))
これは、下記のように動き、結果も正しく9となる。
;マクロ展開の途中を抜き出したもの。
(let ((y 3));
(* y y); これはエラーとならず、結果も正しく9となる。
しかし、この書き方にも問題がある。次のように書くとやはりエラーとなるのだ。
;マクロ呼び出し
(let ((a 1))
(m-square (+ a 2)))
上記のようにコードを書くと、なぜエラーになるのだろうか?それはここでのマクロ定義がクォートされたリストを返す処理ではなく、値を返す処理だからである。値を返すためにはマクロ展開時に評価まで行わなければならない。すなわち、aに1が設定される前に、(m-square (+ a 2))が評価されるためである。そのため、マクロ内部のevalがうまく動作しないのである。
;マクロ展開の途中を抜き出したもの。
(let ((y (eval '(+ a 2)));(+ a 2)を評価できずにエラーとなる。
(* y y)
マクロはコードを返す、コードはリストだ、と上の方で書いたのだが、実際にはマクロはリスト以外を返してもいい。実用的には大体の場合にコードを返すように作るというだけの話で、値を返してもいい。同様に、マクロ展開時にも、処理系が気を利かせて「マクロなんだからコードを返すんだよね〜?」とリストっぽいところまで到達したら評価を止めたりはしない。マクロ展開時には、マクロ定義に従って機械的に評価を進めていくのである。
そのため、マクロ処理はたいてい次のように書く。
- マクロ展開では、式を最後まで評価することはせず、コード(=リスト)を作成されるようにしておく。
- リスト内で引数を評価できるように、バッククォートで作成する。
(defmacro m-square (x)
`(* ,x ,x))
この形であれば、次のように呼び出すと、
;マクロ呼び出し
(let ((a 1))
(m-square (+ a 2)))
次のように展開され、実行時に動く。万事うまくいく。
;マクロ呼び出し
(let ((a 1))
(* (+ a 2) (+ a 2)))
ところで、先ほど問題があると書いた下記の形式のマクロも、実はメリットが無いわけではない。
;evalで評価してローカル変数yに値を代入してから、yの二乗を計算する。
(defmacro m-square (x)
(let ((y (eval x)))
(* y y)))
マクロ展開はコンパイル前に実行される。その時点で上記の(* y y)まで実行が完了するのである。そのため、マクロに与える引数に変数が含まれていなければ、コードをコンパイルした時点で、既に必要な計算が全て完了しているのである。
;コード記述時
(m-square (+ 1 2))
;マクロ展開後
9
;実行時
9;マクロ展開で9となっているので、実行時には計算を行う必要がない。
このくらいの例であれば実行時に計算しても無視できるだろうが、非常に複雑な計算であれば、効果があるかもしれない。しかし、相当気をつけて使う必要はある。繰り返しになるが、マクロは値を返すこともある。マクロと関数の違いは、評価のタイミングと引数の制御である。それ以外に違いはない。
さて、マクロのm-squareをもとに、マクロの性質を見てきた。ここまでのコードでは、マクロのありがたみはまだわからないだろう。この例は動くマクロを説明するためのサンプルであり、実際には関数として作るべきものだからだ。もう少し我慢していただきたい。次のセクションでは、同じような例を使って、Juliaのマクロを見ていく。その後、もう少し実用的なマクロを紹介する。それを通じて、両者の比較を行っていく。
Juliaのマクロ
Lispのマクロはかなり長々とした説明になった。Juliaのマクロはよく似ているので、違いだけ説明することになる。例によって、引数で与えられた数値を二乗する関数squareとマクロm_squareを考える。
#関数定義
function square(x)
x * x
end
#マクロ定義
macro m_square(x)
:($x * $x)
end
関数は説明不要だと思うので、マクロである。JuliaのマクロはLispのマクロと見た目がよく似ている。:()で囲むのが、Lispで言うところのバッククォート、$がLispで言うところのカンマに対応する。さて、Lispのマクロはコードを作るのだった。Lispのコードとはリストであった。そのため、Lispでは、マクロはリストを作れば良かった。Juliaではマクロは何を作っているのだろうか?
答えを言おう。Juliaのマクロが作るのは、Expr型のデータである。Expr型のデータとは何か?Juliaの抽象構文木を表すデータである。そうなると、抽象構文木とは何かを話さねばなるまい。
抽象構文木
例えば、 “x = 1 + 2” という式を考える。「変数xに1+2の結果を代入する」という意味である。この式はプログラムコードとして与えられる。文字列の”x = 1 + 2″である。このままではただの文字の並び(‘x’, ‘=’, ‘1’, ‘+’, ‘2’)である。ここから何かの意味を抽出したデータ構造に変換する必要がある。意味を抽出して初めて、機械語に翻訳できてコンピュータで実行できるのである。ここで言う、「意味を抽出したデータ構造」が抽象構文木である。
さて、”x = 1 + 2″という文字の並びから、どのような工程を経て、「変数xに1+2の結果を代入する」という意味を持つ抽象構文木に変換するかは重要な問題である。重要な問題ではあるが、私の手には余るので解説しない。興味のある方は、「構文解析」と言う単語で検索されるとよい。人によっては人生が変わるくらい広く深い世界が広がっている。私はその後の人生は保証しない。入門書としては「Go言語でつくるインタプリタ」という本がおすすめである。
ともかく、構文解析という工程を終えると、”x = 1 + 2″という文字の並びから、「変数xに1+2の結果を代入する」という意味の抽象構文木となる。これを図示したのが下記のイメージである。assignというのは、代入を意味するとしておく。この情報があれば、どの順番で何の演算を行えば、所望の結果が得られるのか一目瞭然である。
画像ではなく、Julia風のテキスト形式で表現すると、次のようになる。headが処理を表し、argsが引数である。つまりこれはassign処理を表す抽象構文木で、引数の1つ目がx、2つ目が別の抽象構文木である。それは、+処理を表す抽象構文木で、引数の1つ目が1、2つ目が2である。
Expr:
head: assign
args:
1: x
2: Expr
head: +
args:
1: 1
2: 2
そして、この情報を極限まで圧縮すると、LispのS式風に表現できる。
(assign
x
(+
1
2))
いわば、LispのS式は抽象構文木そのものである。
Juliaのマクロ
さて、話を戻そう。JuliaのマクロはExpr型=抽象構文木のデータを返す。Expr型データを作るやり方はいくつかあるが、最もお手軽なのが、下記の:()記号である。この記号で括られたコードは、対応するExpr型データに変換される。なお、:()記号の代わりに、quote〜endというブロックで括ってもいい。(複数行に渡る時は、:()は使えない。)
#マクロ定義
macro m_square(x)
:($x * $x)
end
#こう書いてもいい
macro m_square(x)
quote
$x * $x
end
end
最初、私はこのやり方を見て、Lispとよく似ていると思った。しかし、LispとJuliaでquoteの使い方はよく似ているが、内部動作は違うように思う。Lispはquote内部の評価を止めるという動作をするが、Juliaの場合は、Expr型に変換するということを行っている。(Expr型のデータは評価を止めるので、結果的には同じような動作をすることにはなる。)
#マクロ呼び出し
m_square(1 + 2)
#マクロ展開の流れ
#まず、引数として渡ってきた1 + 2を評価せず、抽象構文木の状態でマクロ内部に送る。
#xに :(1 + 2) を割り当てる。
#次にマクロ内部の:($x * $x)に代入。
:($:(1 + 2) * $:(1 + 2))
#$記号はquoteを打ち消し、結果、下記のようになる。
:((1 + 2) * (1 + 2))
#値の評価
(1 + 2) * (1 + 2)
3 * 3
9
LispとJuliaで引数の評価をしないという点は同じだ。個人的な感覚だが、Lispの場合は引数として与えられた”(+ 1 2)”がそのまま渡されるイメージだが、Juliaの場合は”1 + 2″を一度クォートで包むようなイメージで捉えた方が理解しやすい気がする。[3]Lispの場合は、引数に”(+ 1 2)”が与えられているが、Juliaの場合は引数は括弧の中身の”1 + … Continue readingそして、クォートを打ち消すのが$記号だ。
なお、Lispのマクロの説明で、Lispのマクロはコードではなく値を返しても良いと書いたが、これはJuliaも同じである。一方、動作させていると少し違いもあった。Lispマクロでは上手く動かなかった下記のケースである。マクロがExprを返さず、引数を評価して2乗した値を返している。このとき、変数を渡している。Lispでは、マクロの呼び出しが最初に展開されるため、aってなんですか?とエラーになったのだった。しかし、下記コードをJuliaで実行すると、9と評価されるのだ。
macro m_square(x)
y = eval(x)
y * y
end
a = 1
println(@m_square(a+2)) #結果、9と表示される。 評価前のaを含んだ:(a+2)ではなく、:(1+2)が評価されたということだ。
Lispのマクロはマクロ展開時に評価まで行うが、この動きを見るとJuliaは違うようだ。最初は上から順に逐次マクロ展開、評価を繰り返していくのかと思ったが、「1から始めるJuliaプログラミング」によるとJuliaはマクロ展開は構文解析直後、値の評価前のかなり早いタイミングで行われると書いてあった。合わせて考えると、まずマクロ展開でコード変形のみを行なった後、上から順にコードが評価されているようだ。変数を渡すことができるぶん、Lispよりも使い勝手は良さそうだが、マクロ展開時に評価が走らないので、コンパイル時の計算処理ということは期待できないかもしれない。そうなると、こんな書き方をするメリットはあまりない。まあ、いずれにしろLispと同じく普通に書くようなものではないので、あまり気にする必要はないだろう。
2020/10/08追記:
この部分、Lispと動作の違いがあるが、Lispでもlet式に含めるのではなく、別にグローバル変数(defvar a 1)のような式を作っているとエラーにならないと指摘を受けた。
(defvar a 1)
(m-square (+ a 2)) ;エラーにならない
そのため、LispとJuliaの違いというよりは、グローバル変数かどうか、という違いのようである。
letに含まれているときには、letの内部を先にコンパイルしなければ、letそのものを評価できないので、動作に違いが出るのでは、とのことだった。
追記終わり
同図象性とは何か
通常、プログラマの書いたソースコードは、その言語の処理系が勝手に抽象構文木に変換する。とてもざっくりと書くとこのようになっている。[4]Juliaはコンパイル時点では中間コードを作り、最終的な機械語は実行時に作るが、細かいので省いた。
ソースコード → 抽象構文木 → 機械語
しかし、言語によっては、プログラマが直接、抽象構文木を作る機能が提供されている。Juliaのマクロはその一つである。プログラマはソースコードと抽象構文木を混ぜこぜで書き、言語の処理形が抽象構文木に統一する。
ソースコード + 抽象構文木 → 抽象構文木 → 機械語
プログラマがソースコードと抽象構文木を混ぜこぜで書く、というところにネックがある。ソースコードと違い、普通、抽象構文木は人間が書くように設計されていない。抽象構文木を正確に書くのはとても大変だ。JuliaでもExprを愚直に作ることはできるが、あまりやりたくはない。例えば、1 + 2 という処理のExprは、下記のように作ることになる。
Expr(:call, :+, 1, 2)
これを手でコーディングするのはいかにもぎこちないし、普通のコードとも見た目が全く違う。こんなものがソースコードと混ぜこぜで作られていても読みづらくてしょうがない。しかし、Juliaは通常のコードを抽象構文木に変換するための便利な表記を提供している。:()やquote〜end構文だ。これによって、通常のコードとほぼ同様の見かけにできる。
Lispは同図象性があると言われる。LispのコードはS式で表現され、S式はリストであり、リストはLispが得意とするデータ構造なのだ。これは「Lispのコードはデータで、データはコードだ」というような言葉にも表れている。しばしば、この点がLispのマクロの力の根源と言われている。もう一度、下記の図を振り返ってみよう。
ソースコード + 抽象構文木 → 抽象構文木 → 機械語
Juliaはquote構文により、抽象構文木の見かけをソースコードに肉薄させることができた。Lispはどうか?LispのS式は抽象構文木そのものと言っていいのだった。Lispはプログラマが手で書くあらゆるコードが抽象構文木のため、マクロが抽象構文木を作っても見かけの違いはほとんどない。LispはS式だからこそ、極めて自然にマクロの力を取り入れることができたのである。これがマクロと同図象性の関係である。
Juliaは逆のアプローチを行った。S式を採用していないJuliaは、抽象構文木の見かけをソースコードに近づけた。これはこれで同図象性を満たしていると言えそうである。少なくともマクロ機能に関しては、かなりのレベルで上手くいっているように見える。Lisp以外でこれほど上手くこなしている言語は、私は他に知らない。(Elixirという言語がJuliaのマクロと似た機能を提供しているように見えるが、Elixirについては勉強不足なので触れない。)
なお、他の言語がJuliaのマクロの真似をすることができるのかどうか、よくわからない。素人考えでは適切なquote構文を導入すれば良いような気もするが、実装レベルでは構文解析のコアの部分に手を加える必要がありそうなので、既存言語に付け足すのは簡単ではないかもしれない。
では次のセクションでは、JuliaとLispの具体的なコードを比較してみることにする。
LispとJuliaの比較
今から作るのは、assert-euqalというマクロだ。今度はm-squareのような説明のためのマクロではなく、マクロでなければできない処理である。
このマクロは評価させたい式と、期待する結果を引数にとる。期待と一致すれば”OK”と表示し、不一致であれば”NG”という情報に加えて、評価した式、結果の値、期待した値を表示する。
こんなふうに使うものだ。
Lisp
(assert-equal (square 3) 9);"OK"と表示される
(assert-equal (square 3) 10);"ERROR! Expr:(SQUARE 3), Expected:10, Actual:9"と表示される。
Julia
@assert_equal(square(3), 9) #"OK"と表示される
@assert_equal(square(3), 10) #"NG! Expr=square(3), Expected=10, Actual=9"と表示される。
なぜこれがマクロでなければ実現できないかというと、関数の場合、引数に渡したsquareが先に評価されてしまい、assert-equalの内部では評価後の値にしかアクセスできないからだ。マクロでこの引数が評価されてしまう前に、文字列に変換してしまい、出力に利用しようという目論見である。
Lispのマクロがこれだ。to-strとstrconcは補助関数で、あまり気にしなくて良い。Common Lispの名前は少々長い傾向があり、見づらいので短縮のためだけに用意した。
(defun to-str (arg)
(princ-to-string arg))
(defun strconc (&rest lst) ;&restと言うのは可変長引数を意味して、任意の数の引数を受け取り、リストにまとめて直後の引数に渡す。
(apply #'concatenate 'string lst))
(defmacro assert-equal (expr expected-value)
(let ((expr-str (to-str expr)))
`(let ((actual ,expr)
(expected ,expected-value))
(if (= actual expected)
(print "OK")
(print (strconc "NG! Expr:" ,expr-str ", Expected:" (to-str expected) ", Actual:" (to-str actual)))))))
macro assert_equal(expr, expected_value)
expr_str = string(expr)
quote
actual = $expr
expected = $expected_value
if actual == expected
println("OK")
else
println("NG! Expr=" * $expr_str * ", Expected=" * string(expected) * ", Actual=" * string(actual))
end
end
end
マクロについての細かい説明はしない。ともかく、2つのマクロが非常によく似ていることが感じられるのではないだろうか。マクロの内部には作りたいコードのテンプレートがあり、必要に応じてカンマや$記号で評価していく。上で少し見たように、JuliaはLispと割と異なる機構でマクロが動いていそうである。にもかかわらず、これだけ近い雰囲気のものができているのは、相当の設計の努力があったに違いない。
Juliaの優位性
マクロについて説明しておく必要のある事柄がもう一つある。マクロの変数捕捉という問題である。これはLispプログラマが口を酸っぱくして気を付けろと叫ぶ問題であり、Juliaプログラマは基本的に気にする必要のない問題なのだ。つまり、Juliaの方が優位なポイントである。
正直、この節は書くかどうか悩んだ。Lispのマクロに関する厄介な問題であり、マクロという機能そのものが敬遠されそうな気がするからだ。薄々感づいておられるかもしれないが、私はマクロが好きだ。本当のところを言うと、この記事だって半分はJuliaのマクロにかこつけてLispのマクロを宣伝しようと思って書いたのだ。だが、Juliaのマクロをやる上ではあまり気にしなくても良い問題だし、Lispのマクロをやる上ではどうせ避けては通れないのだ。それに何よりフェアではない気がしたので、載せることにした。
例え話をしよう。あなたがプログラムを書いていて、配列をソートする必要に迫られたとする。困ったあなたを見て親切な同僚が、自分の実家はプログラムの生成業の老舗を営んでいて僕はソートプログラムの作成が得意だとか何とか言って、自分の作ったコードを直接あなたのコードにペタッと貼ってくれたとしよう。どうなるだろうか?あなたはコードでxとかyとか言う変数を使っている。同僚の埋め込んだコードも、xとかyとか使っている。運が良ければ上手く動くかもしれないが、おそらくうまく動かないだろう。関数を書いて呼び出すようにしてくれたら、こんな心配はしなくて良いのだが、コードを直接貼り付けるものだからこんなことになるのだ。この気の利かない同僚の名前がCommon Lisp家のマクロ君だ。
Lispのマクロの説明を思い出して欲しい。Lispマクロはマクロ展開時に、呼び出し元にマクロの評価結果をペタペタ貼っていくのだった。そうすると、呼び出し元の処理の変数名と、マクロ展開された結果の変数名がバッティングしてしまうという問題が起こる可能性があるのだ。
少々わざとらしい例になるが、下記の例を出そう。これは与えられた引数の中身を交換するマクロだ。prognというのは、上から順番に実行してねという意味の式で、setqというのは代入だ。(setq a 1)でaに1を代入する。
(defmacro swap (x y)
`(let ((tmp ,x))
(progn
(setq ,x ,y)
(setq ,y tmp))))
このswapマクロは通常は問題を起こさない。(swap a b)と呼ぶと、下記のように展開される。これは上手く動く。
(let ((tmp a))
(progn
(setq a b)
(setq b tmp)))
問題は、(swap a tmp)のように呼び出し元が同名の変数を使っている時だ。
(let ((tmp a))
(progn
(setq a tmp)
(setq tmp tmp)))
これは想定どおりの動きをしない。このように、呼び出し元の変数がマクロ内の変数に意図せず取り込まれてしまう動きのことをマクロの変数捕捉と言う。変数捕捉は厄介な問題だが、幸いにして定型化された手法で回避できる。gensymというものを使うのだ。
(defmacro swap (x y)
(let ((tmp (gensym)))
`(let ((,tmp ,x))
(progn
(setq ,x ,y)
(setq ,y ,tmp)))))
gensymを使うと、絶対に他のシンボルと衝突しないことが保証されたシンボルが作成される。マクロ定義上、tmpという文字が見えているが、展開形にはtmpという文字では表現されない。#:G842というのがgensymが用意したシンボルで、これはCommon Lisp処理系が他のシンボルと衝突しないことを保証する。
(let ((#:G842 a)
(progn
(setq a tmp)
(setq tmp #:G842)))
他にも、マクロの変数捕捉にまつわるトピックは色々あるが、興味のある方は「On Lisp」という書籍がおすすめだ。ちなみに邦訳が無料で公開されている。凄いことだ。ただし、決して簡単な本ではない。
http://www.asahi-net.or.jp/~kc7k-nd/
さて、Juliaのマクロはどうかと言うと、なんとこのような悩ましい問題は気にしなくて良いのだ。Juliaはマクロ内で定義した変数が呼び出し元の変数と衝突しないように保護してくれる。イメージでいうと、Lispのgensymを勝手に適用してくれるような感じだ。Julia家のマクロ君はなかなか気の利いたやつなのである。
そう、Juliaではこのようなマクロを定義してもへっちゃらなのだ。呼び出し元の変数と衝突してしまうことはない。
macro swap(x, y)
quote
tmp = $x
$x = $y
$y = tmp
end
end
・・・と言うのは嘘だ!!いや、変数と衝突することがないと言うのは本当なのだが、へっちゃらと言うのは嘘だ。Juliaはマクロに現れるあらゆる変数を保護する。それは、ローカル変数として宣言したtmpだけでなく、引数として与えられたxやyも同様なのだ。これは普通のマクロでは問題にならないが、引数の値を書き換えたいswapのようなマクロを作る時には問題になる。マクロ呼び出しに渡された変数と、内部での変数は、例え$記号を適用しても、別の変数扱いになるのだ。(ちなみに、この処理でswapを呼び出すと、呼び出し元が書き換えられないばかりかUndefVarErrorというエラーになる。私にはこの理由はよくわからない。)
しかし、安心して欲しい。この保護機構を打ち破る処理があるのだ。それがエスケープだ。マクロの外側とあえて関わりを持たせたいときには、esc()で括ってやると良い。外側と関わりを持たせたくない変数はそのままだ。
macro swap(x, y)
quote
tmp = $(esc(x))
$(esc(x)) = $(esc(y))
$(esc(y)) = tmp
end
end
この例では、xとyをエスケープしたが、tmpもエスケープしても良い。そうすると、外側にあるtmpと言う変数と干渉する。通常これは避けたいことだが、あえて変数捕捉させるマクロというのもある。Lispマクロの奥義のような位置づけだ。先ほど紹介したOn Lispに詳しく解説されている。Juliaでも、やりたいときにはそれをやる自由は与えられている。
macro swap(x, y)
#tmpを:tmpとシンボルにしたことに注意。escの仕様。さらにそれを埋め込むための$が必要。
quote
$(esc(:tmp)) = $(esc(x))
$(esc(x)) = $(esc(y))
$(esc(y)) = $(esc(:tmp))
end
end
macro swap(x, y)
#全体をescで括っても良い
esc(
quote
tmp = $x
$x = $y
$y = tmp
end
)
end
ローカル変数だけでなく、引数まで保護するのは行き過ぎという意見もあるようだ。しかし、私はそうは思わない。そもそも関数であれ、マクロであれ、引数の値を書き換えるのはあまり良いスタイルではない。引数は渡されても値を変えず、返り値で結果を得るのが良いプログラミングスタイルだ。さらに言うと同じ引数を渡すと同じ結果を返すのが良い。参照透過性と呼ばれる性質だ。参照透過性を満たしたコードはとても明快になる。コードは明快さを優先すべきで、効率はその次だ。参照透過性を満たそうとすると、計算効率は落ちる可能性はある。どうしても計算効率が優先なところであれば、参照透過性を崩してもよい。しかし、それは例外であるべきだ。なお、JuliaにはLispから引き継いだ良い慣習がある。引数の値を変更する関数やマクロには、後ろに!記号をつけると言う慣習だ。これで、気をつけるべき箇所が明確になる。
macro swap!(x, y) #引数を変えてしまう処理には!をつけよう
quote
tmp = $(esc(x))
$(esc(x)) = $(esc(y))
$(esc(y)) = tmp
end
end
esc()も同様で、気をつけるべき箇所が目立っている。これは良いスタイルだ。
Lispのマクロは、C言語のマクロの文字列組立と少しイメージが似ている。マクロ機能は、あくまでリストを組み立てているだけだ。このような性質から、Lispのマクロは「低水準」のマクロと呼ばれることがある。低水準というのは別に程度が低いとか頭が悪いという意味ではない。C言語やアセンブリ言語が低水準言語と呼ばれるのと同じで、あれこれ抽象化の層が挟まっていないと言う意味だ。これと対比して、Juliaのようにプログラマが書いたマクロ定義を処理系があれこれ面倒見てくれるマクロを「高水準」のマクロと呼んだりする。
また、Lispのマクロはデフォルトでは変数捕捉のような問題を引き起こすので、「不健全な」マクロと呼ばれることがある。Juliaのマクロはデフォルトで変数が保護されるので、「健全な」マクロと呼ばれる。
Lispの(決して破られない)優位性
さて、ここまで、Juliaのマクロがいかに優れているかを説明してきた。ここまでのところ、JuliaのマクロはLispと同等以上に思える。書きやすさ、読みやすさ共に遜色がないし、健全だ。必要であれば、不健全なマクロと同等のこともできる。
では、Lispのマクロにはもう一つも良いところはないのだろうか?Lispは見た目ばかり奇妙なくせに能力も劣っているトンチキ野郎なのだろうか?いや、そんなことはない。Lispでなければ表現できないマクロは、やはりあるのだ。
こんな例を考えよう。あなたはあるプロジェクトに携わり、多くの関数を手がけてきた。最高技術責任者としての栄誉と名声を欲しいままにしているスーパースターだ。そんなあなたがある問題に直面している。どうにもプログラムの実行速度が低下しているのだ。原因を究明する必要がある。
こうなると、ボトルネックを特定するために関数の処理の最初と最後にログを出力したくなるのは自然な流れだ。しかし、目星をつけた関数の処理の最初と最後に片っ端からログを出力する処理を書くというのはいかにもダサい。あなたはスーパースターなのだからもっとエレガントに解決しなければならない。でなければ来期の人事異動で最低技術責任者あたりに降格させられてしまうだろう。クルーザーを手放す羽目になるかもしれない。
そう、そんなときに助けになるのがLispのマクロなのだ。何もあなたがひたすらログ出力処理を書き込む雑用みたいな仕事をする必要はない。部下にさせる必要もない。マクロにさせれば良い。あなたが発明するのは、defun-with-logマクロだ。
(defmacro defun-with-log (funcname args &body body)
`(defun ,funcname ,args
(progn
(start-log (string ',funcname))
,@body
(end-log (string ',funcname)))))
このマクロはこのように呼び出す。
(defun-with-log add (a b)
(print (+ a b)))
なんと、これはマクロ呼び出しというよりは、関数定義に近いように見える。見えるが、実際にはマクロ呼び出しだ。つまり、defun-with-logというマクロを、
第1引数 funcname : add
第2引数 args : (a b)
第3引数 body : (print (+ a b))
で呼び出しているのだ。
第3引数の前にある&bodyは、&restと同じで、ここには可変長の引数を渡すことができて、リストにまとめて後ろの引数に入れときますよと言う意味だ。ただ、このような形で関数の本体を受け取ることが多いので、特別に定義されている。エディタが気を利かせてくれることがある。
もう一つ解説すると、,@bodyというのは、bodyのいう変数はリストで、リストの中身を引っ張り出してカンマを作用させなさいという意味だ。
このマクロを展開した結果、マクロはdefunから始まるリストを返す。リストは結果的には関数の定義となっている。マクロ展開後の形を下記に示す。
(defun add (a b)
(progn
(start-log (string 'add))
(print (+ a b))
(end-log (string 'add))))
start-log とend-logは一応実装しておくとこのような形だ。開始、終了のログを関数名を含んだ形で出力する。実際には時刻を出したりするだろうが、今回話したいのはそこではないので手抜きをした。
(defun start-log (funcname)
(print (strconc funcname ": write start log")))
(defun end-log (funcname)
(print (strconc fucname ": write end log")))
これで完成だ。通常の関数呼び出しのように、(add 2 3)とでも呼び出してみる。
(add 2 3)
;次のような出力が得られる。
"ADD: write start log"
5
"ADD: write end log"
あとは、ログを出したい関数のdefunをdefun-with-logに変えてやれば良い。なんなら一括置換すれば全ての関数のログを出すこともできる。
こうして華麗に問題を解決したあなたは、さらにボーナスを得ることができた。これで別荘を買う計画を立てることができるのだ。クルーザーにも乗りたいので海辺がいいだろう。
さて、Juliaで同じことができるだろうか?私にはできなかった。頑張ってこのようなものは作れた。
#Julia版のwith_logマクロ
#Lisp版とは動作が異なり、start_logとend_logの間で与えられた関数を呼んでいる。
macro with_log(func)
s = string(func.args[1]) #関数のExprのarg[1]には関数名が入っている。
quote
start_log($s)
$func
end_log($s)
end
end
Lispと同様、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
使い方はLispと違い、このようになる。
function add(a, b)
println(a + b)
end
@with_log(add(2, 3))
#次のような出力が得られる
add: write start log
5
add: write end log
悪くはない。関数本体には手を加えずにログを出力している。しかし、ログを取りたい関数呼び出しのある箇所全てに、@with_logをつけて回る必要がある。これではボーナスを得るのは難しいかもしれない。
JuliaでLisp版のようなマクロを書くには構文レベルでの困難さがある。Lisp版では、関数定義するがの如く、マクロ呼び出しをすることができた。これは関数定義とマクロ呼び出しの構文が非常に似ていることで初めて実現できる。どちらもS式なので、マクロ呼び出しで与えられた引数をどこにどう配置したら関数定義に変形できるのかが容易にわかるのだ。Juliaはそもそも関数定義の形とマクロ呼び出しの構文が全然違うので、うまくいかない。真似て書くなら下記のようになるが、実装をどうすればいいか私にはわからないし、呼び出し側も普通の関数定義とは違った、ぎこちないものになるだろう。
macro function_with_log(funcname, args, body...)
#???
end
これがLispの誇る究極の同図象性だ。S式だからこそ、このような芸当が自然にできるのだ。しかし、S式に採用すると、それはLispになってしまうのではないか?これがLisp族とマクロの切っても切り離せない関係なのだ。
まとめ
長々とLispマクロとJuliaマクロの比較を行ってきた。マクロのみの対決で言うと私はLispに軍配を上げる。最後に意地を見せてくれた。まさにLispの真骨頂だ。
とはいえ、Juliaにも素晴らしい点が多くある。いや、素晴らしい点だらけだ。正直、Lispの構文はとっつきやすいとは言えない。慣れたらあまり気にならなくはなるのだが、それでも私自身、Juliaの方が読みやすいと思う。私はLispで数値計算プログラムをうまく書ける自信はないが、Juilaならずっと上手くやれそうな気がする。
マクロ対決だって、私はLispびいきなのでLispを勝者にしたが、審判次第ではわからない。マクロも健全という利点もあるし、最後のようなケースを除いて、実際にLispで使われるマクロの9割以上はカバーしているのではないか。となると、Juliaを勝たせる人がいてもおかしくはない。
そして、Juliaを彩る数々の現代的な機能たちだ。私はまだそれらの機能を全く使っていない。まだマクロを少しかじっただけなのだ。これからJuliaのいろいろな機能を触ってみたい。きっと楽しいプログラミング生活が待っていることだろう。
2020/10/10 追記
この記事には続きがある。色々指摘を受け、結論を再考したのだ。ぜひ続きを読んでほしい。
追記終わり
References
↑1 | 構文ではない単なるリスト(数値の並びなど)を表現したいときには、リストの前にシングルクォートをつけるなどして、明示的に表現する。 |
---|---|
↑2 | 実際のLisp処理系がこのような動いているのかはわからない。というか、おそらくもっと効率的に処理されていると思う。しかし、そのようなイメージで動く結果と一致するようにはなっているはずである。 |
↑3 | Lispの場合は、引数に”(+ 1 2)”が与えられているが、Juliaの場合は引数は括弧の中身の”1 + 2″が与えられているように見えるからかもしれない。まあこの辺りは個人の感覚なので、あまり深く考えすぎない方がいい。結局このあたりはたくさんマクロを書くと慣れてくる部分だ。 |
↑4 | Juliaはコンパイル時点では中間コードを作り、最終的な機械語は実行時に作るが、細かいので省いた。 |