S式の尖った特徴を少し削って書き心地を得る
自作のプログラミング言語
llrlはS式を構文 (シンタックス) に全面採用している。S式はパースが容易で、階層構造が視覚的にわかりやすいのもあるが、構文の構成要素が少ないことからマクロ機能とも相性が良い。実際llrlでは
lambda
のような原始的な機能も
マクロとして定義されていたりする。
S式の特徴、階層構造がそのまま構文に対応するということは、S式をプログラミング言語の構文に用いる上では欠点にもなる。 例えば let
式のような、新たなスコープを導入するフォームのたび、視覚的にもネストが深くなる。
example1.lisp(let1 foo (read-string)
(println! foo)
(let1 bar (read-string)
(println! bar)
(let1 op (read-string)
...)))
let*
のような let
のバリエーションはあれど、「変数束縛、分岐、変数束縛」のようなシーケンスではやはりネストが必要だったり、あるいはシーケンシャルに書けても見た目が奇妙になったりする。
example2.lisp(let* ([foo (read-string)]
[_ (println! foo)]
[bar (read-string)]
[_ (println! bar)]
[op (read-string)])
...)
また let
let*
などの右揃えもインデント幅が大きく、個人的にあまり好きでない。これはスタイルガイド次第だが…
…とここまでS式である限り逃れられないことかのように書いてきたが、スコープと構文は一致しなくても成り立つ。Lisp系言語で共通する形式から離れ、以下のように閉じた let
で変数宣言できる言語を設計することは可能だろう。
example3.lisp(begin
(let foo (read-string))
(println! foo)
(let bar (read-string))
(println! bar)
(let op (read-string))
...)
しかしこれは、スコープがどこで導入されるかわかりづらかったり、 begin
が必要だったり必要でなかったり (CやJava系の構文の言語では、ブロック {}
がスコープと対応するのでわかりやすい)、マクロが (let foo (read-string))
のような式を返したときにエラーとするべきかどうか考えることになったり、色々と嫌な臭いがする。
共通するパターンを見出す
S式のネストが深くなる式を見ていると、ある共通するパターンが見えてくる。リストの最後の要素でネストが深くなり、それが尾のように伸びていくパターンだ。
pattern.lisp; S式のネストが深くなる式の多くは、リストの最後の要素が伸びている
(let1 foo (read-string)
(println! foo)
(let1 bar (read-string)
(println! bar)
(let1 op (read-string)
...)))
; こういうパターン...リストの途中の要素が伸びる場合は少ない:
(let1 foo (read-string)
(let1 bar (read-string)
(let1 op (read-string)
...)
(println! bar))
(println! foo))
; なぜなら、ここでbar, opのような変数束縛のスコープを狭める必要がある場合は少ないため、
; 多くの場合は以下のように記述することができるためだ:
(let1 foo (read-string)
(let1 bar (read-string)
(let1 op (read-string)
...
(println! bar)
(println! foo))))
このパターンが多いのであれば、このパターンをうまく書き直せるならネストが深くなる場合も少なくなる。
このパターンを見て思い当たるものの一つにHaskellの $
演算子がある。 $
演算子はただ関数適用するだけの中置演算子 ( f $ x = f x
) だが、演算子の優先順位が低いので以下のように使える:
example.hs-- 以下のような関数適用のネストを...
hello = foo a (bar b (baz c (hoge fuga)))
-- 以下のように記述できる
helo = foo a $ bar b $ baz c $ hoge fuga
Lisp系言語では通常、中置演算子は存在しないが、演算子でなくともこのような働きをする構文糖衣があれば、上のようなパターンをうまく書き直せるかもしれない。
@構文の導入
ということでllrlに @
を用いる構文を導入した。 @
構文はリストの中で用いられ、 @
の後に続く要素を括弧で囲む働きをする。例えば以下のように:
これによって、冒頭のプログラムを以下のように書き直すことができる。
re.example1.llrl.lisp(begin
@let1 foo (read-string)
(println! foo)
@let1 bar (read-string)
(println! bar)
@let1 op (read-string)
...)
※視覚的な統一感のため冒頭に begin
を用いているが、 (let1 foo ...)
で始めてもよい
この構文は、S式の「データの階層構造が視覚的に見える構文の構造と直接対応する」という特徴を削るが、そのほかのS式の特徴は損なわないし、言語のその他の部分にもなんら変更を必要としない。アドホックな構文の追加ながら、少ない手間で書き心地が非常に良くなり、なかなか気に入った言語機能となったので書き記しておいた。
もちろん
@
は
let1
以外にも用いることができて、例えばllrlの
examples/aobench.llrl 内の関数を例に取ると、
let1
式の他にもパターンマッチを行う
with1
式などを
@
で平坦にすることで、以下のように書ける。
mandelbrot.llrl.lisp; このような関数を...
(function (ray-plane-intersect isect ray plane) {(-> (Ref Isect) Ray Plane unit)}
(let* ([d (- (vdot (.p plane) (.n plane)))]
[v (vdot (.dir ray) (.n plane))])
(when (< (abs v) 1.0e-17) (return))
(let1 s (/ (+ (vdot (.org ray) (.n plane)) d) -1 v)
(with1 (isect: (let t) _ _ _) ~isect
(when (< 0.0 s t)
(let1 p (v+ (.org ray) (v* (.dir ray) s))
(set! isect (isect: s p (.n plane) #t))))))))
; このように書き直せる
(function (ray-plane-intersect isect ray plane) {(-> (Ref Isect) Ray Plane unit)}
@let1 d (- (vdot (.p plane) (.n plane)))
@let1 v (vdot (.dir ray) (.n plane))
(when (< (abs v) 1.0e-17) (return))
@let1 s (/ (+ (vdot (.org ray) (.n plane)) d) -1 v)
@with1 (isect: (let t) _ _ _) ~isect
(when (< 0.0 s t)
@let1 p (v+ (.org ray) (v* (.dir ray) s))
(set! isect (isect: s p (.n plane) #t))))
@
を用いたのは、 @
が構文割り当てがなく空いているASCII characterだったからだが、準クォートの ,@
のsplicingとは逆に括弧を補う形になっていて、対になってて悪くないかと思う。