S式の尖った特徴を少し削って書き心地を得る
自作のプログラミング言語llrlはS式を構文 (シンタックス) に全面採用している。S式はパースが容易で、階層構造が視覚的にわかりやすいのもあるが、構文の構成要素が少ないことからマクロ機能とも相性が良い。実際llrlでは lambda のような原始的な機能もマクロとして定義されていたりする。 S式の特徴、階層構造がそのまま構文に対応するということは、S式をプログラミング言語の構文に用いる上では欠点にもなる。 例えば let 式のような、新たなスコープを導入するフォームのたび、視覚的にもネストが深くなる。
code:example1.lisp
(let1 foo (read-string)
(println! foo)
(let1 bar (read-string)
(println! bar)
(let1 op (read-string)
...)))
let* のような let のバリエーションはあれど、「変数束縛、分岐、変数束縛」のようなシーケンスではやはりネストが必要だったり、あるいはシーケンシャルに書けても見た目が奇妙になったりする。
code:example2.lisp
(println! foo)
(println! bar)
...)
また let let* などの右揃えもインデント幅が大きく、個人的にあまり好きでない。これはスタイルガイド次第だが…
…とここまでS式である限り逃れられないことかのように書いてきたが、スコープと構文は一致しなくても成り立つ。Lisp系言語で共通する形式から離れ、以下のように閉じた let で変数宣言できる言語を設計することは可能だろう。
code: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式のネストが深くなる式を見ていると、ある共通するパターンが見えてくる。リストの最後の要素でネストが深くなり、それが尾のように伸びていくパターンだ。
code: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) だが、演算子の優先順位が低いので以下のように使える:
code:example.hs
-- 以下のような関数適用のネストを...
hello = foo a (bar b (baz c (hoge fuga)))
-- 以下のように記述できる
helo = foo a $ bar b $ baz c $ hoge fuga
Lisp系言語では通常、中置演算子は存在しないが、演算子でなくともこのような働きをする構文糖衣があれば、上のようなパターンをうまく書き直せるかもしれない。
@構文の導入
ということでllrlに @ を用いる構文を導入した。 @ 構文はリストの中で用いられ、 @ の後に続く要素を括弧で囲む働きをする。例えば以下のように:
code:at-syntax.llrl.lisp
(a b @ c d)
;=> (a b (c d))
(a b @ c d @ e f)
;=> (a b (c d (e f)))
これによって、冒頭のプログラムを以下のように書き直すことができる。
code: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 式などを @ で平坦にすることで、以下のように書ける。 code:mandelbrot.llrl.lisp
; このような関数を...
(function (ray-plane-intersect isect ray plane) {(-> (Ref Isect) Ray Plane unit)}
(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))
; このように書き直せる
(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とは逆に括弧を補う形になっていて、対になってて悪くないかと思う。