言語非依存なテンプレートエンジンがあったらいいなと昔からボンヤリ考えていた(切実にほしいという程ではない)のですが、mal(Make a Lisp)で作ったらどうだろうと思って試しにやってみました。「なんかやってみたくなってやってみた」系の記事です。
Lisp を書くのは年に数回という感じの人が書いていますので、拙いところは大目に見ていただければと思います。
ちなみに去年のアドベントカレンダーではこんなのを書きました。
できたもの
sonota88/mal
をサブモジュールとして使っているので、 git clone --recursive ...
でクローンする必要があります。
Ruby版
Ruby 版だとこんな感じ。
template = <<TEMPLATE
<h1>品目一覧</h1>
<p>購入日: <%= date %></p>
<table>
<tr>
<th>ID</th>
<th>品名</th>
<th>価格</th>
</tr>
<% (map (fn* [item] (do %>
<tr>
<td><%= (get item "id") %></td>
<td><%= (get item "name") %></td>
<td><%= (- (get item "price")
(if (get item "discount")
(get item "discount")
0))
%> 円</td>
</tr>
<% )) items) %>
</table>
TEMPLATE
context = {
date: "2021-12-25",
items: [
{ id: 1, name: "foo", price: 100, discount: nil },
{ id: 2, name: "bar", price: 200, discount: nil },
{ id: 3, name: "baz", price: 300, discount: 50 }
]
}
rendered = Malten.render(context, template)
print rendered
<% ... %>
または <%= ... %>
の中にコードを書きます。ERB や JSP に倣ってベタなスタイルにしましたが、コードの部分が mal なので、見たことあるような、ないような、怪しげな雰囲気ですね。
出力も一応貼っておきます。
<h1>品目一覧</h1>
<p>購入日: 2021-12-25</p>
<table>
<tr>
<th>ID</th>
<th>品名</th>
<th>価格</th>
</tr>
<tr>
<td>1</td>
<td>foo</td>
<td>100 円</td>
</tr>
<tr>
<td>2</td>
<td>bar</td>
<td>200 円</td>
</tr>
<tr>
<td>3</td>
<td>baz</td>
<td>250 円</td>
</tr>
</table>
Java版
Java 版も作ってみました(テンプレートは同じなので省略)。
String template = getTemplate();
Context context = new Context();
context.map.put("date", "2021-12-25");
{
List<Map<String, Object>> items = new ArrayList<>();
items.add(new Item(1, "foo", 100, null).toPlain());
items.add(new Item(2, "bar", 200, null).toPlain());
items.add(new Item(3, "baz", 300, 50).toPlain());
context.map.put("items", items);
}
String rendered = Malten.render(context, template);
System.out.println(rendered);
大体似たような感じですね。
概観
大雑把な処理の流れ。
テンプレートテキスト
↓
↓レンダリング用コード生成
↓
レンダリング用コード(mal のコード)
↓
↓eval(ここでコンテキストを参照)
↓
出力テキスト
レンダリング用コード生成
たとえば、次のようなテンプレートテキストがあったとして、
# 品目一覧
<% (map (fn* [item] (do %>
- <% (print (get item "name")) %> <% (print (get item "price")) %> 円
<% )) items) %>
これを変換すると次のような mal のコードになる。元のテンプレートと比べて眺めると何やってるかなんとなく分かりますよね。
(print "# 品目一覧\n\n")
(map (fn* [item] (do
(print "\n\n- ")
(print (get item "name"))
(print " ")
(print (get item "price"))
(print " 円\n\n")
)) items)
下請けの関数などは除いて骨組部分だけ貼ります。
;; malten.mal
;; <% ... %> の中身の長さを求める
(def! mal-code-length
(fn* [rest]
(let* [iter (fn* [rest2 pos]
(if (s#start-with? rest2 "%>")
pos
(iter (s#rest rest2) (+ pos 1))))
]
(iter rest 0))))
;; <% ... %> の部分の処理
(def! gen-renderer-code
(fn* [rest buf acc]
(let* [len (mal-code-length rest)]
(gen-renderer-text
(s#drop rest (+ len 2))
"" ; clear buf
(cons
(if (s#start-with? rest "=")
(list 'code-print (s#substring rest 1 len))
(list 'code (s#substring rest 0 len)))
(cons (list 'text buf)
acc))))))
(def! gen-renderer-text
(fn* [rest buf acc]
(if (nil? rest)
(cons (list 'text buf) acc) ; end of iteration
(if (s#start-with? rest "<%")
(gen-renderer-code
(s#drop rest 2) ; "<%" を除去
buf
acc)
(gen-renderer-text (s#drop rest 1)
(str buf (s#first rest))
acc)))))
(def! to-code
(fn* [part]
(let* [
type (nth part 0)
content (nth part 1)
]
(cond
(= type 'code) content
(= type 'code-print) (str "(print " content ")\n")
(= type 'text) (str "(print " (pr-str content) ")\n")))))
(def! gen-renderer
(fn* [template]
(let* [reversed-parts (gen-renderer-text
template
"" ; buf
'() ; acc
)
]
(l.foldr (fn* [part acc]
(str acc (to-code part)))
""
reversed-parts))))
軽い用途向けでも、たとえば入力が 1000 文字を越えると動きません、では困るので TCO(末尾呼び出しの最適化)が効くように書く必要があります。理解があやふやだったのですが、今回 mal の TCO のしくみについておさらいして、ある程度書いて慣れることができて良かったです。
レンダリング用コードの eval
基本的には eval するだけですが、コンテキストは mal の型に合わせて変換してあげる必要があります。Ruby 版だとこんな感じ。
# ruby/mal.rb
def self.to_mal_val(v)
case v
when Array
# List は mal 側で用意されているクラス
List.new(v.map { |el| to_mal_val(el) })
when Hash
v
.to_a
.map { |k, _v|
[k.to_s, to_mal_val(_v)] # Java版に合わせてキーを String にしている
}
.to_h
else
v
end
end
core への追加
文字列処理をするための最低限の関数として s#first
, s#rest
, s.cons
と、改行なしで print する print
を追加しました。
Ruby 版であればこんなの。
# ruby/mal.rb
ADDITIONAL_CORE_FUNCS = {
:print => lambda { |x| print x },
:"s#first" => lambda { |_self| _self[0] },
:"s#rest" => lambda { |_self|
if _self.size <= 1
nil
else
_self[1..-1]
end
},
:"s.cons" => lambda { |first, rest|
if rest.nil?
first
else
first + rest
end
}
}
あとはこれを $core_ns
に追加してあげればOK。
# {mal}/impls/ruby/stepA_mal.rb のこの部分
$core_ns
.merge(ADDITIONAL_CORE_FUNCS)
他にも便利な関数を追加してリッチにしていくと、mal のレイヤーで書く部分が楽になったりパフォーマンスが改善されたりすると思うのですが、移植コストとのトレードオフになるでしょう。
というわけで、 mal のコードを一度書くだけ(※1)で 86 の言語(※2)で同じように動く(※3)テンプレートエンジンが手に入りました。
※1: 実際は各言語ごとに多少手を入れる必要あり
※2: 2021-12-25 現在
※3: たぶん。試してないです。
メモ
- 動いたので満足(という程度の試みです)
- 2日くらいで書いたプロトタイピングです。細かいとこは雑。
- 自分用のツールなどでは様子を見つつ使っていこうかなという気持ちになった
- テンプレートエンジン、ミニマムな機能だけでよければ簡単。「試しに何か書いてみたい」というときのお題としてもお手頃で、実用できそうな雰囲気があるのも良いです。
-
pr-str
は言語依存の機能で実装されている- たとえば Ruby 版は inspect、 Java 版では commons-lang3 の StringEscapeUtils を使っている
- コーナーケースが気になる場合は mal で書き直すとよさそう
- 遅い。文字列の処理で、一度文字のリストにばらして加工してまた文字列に戻す(
s#to-chars
とs.from-chars
)ということをやっていて、遅いです。
文字列版の car, cdr, cons だけあればあとはリスト用の関数に丸投げできるよね、というのを(やったことなかったので)試してみたかったのでした。今回やってみて気が済みました。
直交性はあるけど普通に計算量的に厳しい、という体験ができました。後で書き直すかも。 - たとえば Java でテンプレートエンジン自作するとなると、コード生成まではいいとして実行どうするんだろ、コンパイルしないといけないよね? JavaCompiler
を使えばできそう? とかのあたりがめんどくさそうだなーと思って実際に作るとこまで至っていなかったのですが(やったことないので億劫)、Java で書かれたインタプリタで動かせばコンパイルのことを考えなくてよくなると気付いて、そっか、そういう手があるのか、なるほどとなりました。で、そういうのを思いついたときにサッと使える mal は便利。
Java版だとhashmapのキーとしてシンボルが使えない
詳しく調べてませんがメモ。
Java版は hashmap のキーとして文字列を期待しているらしく、エラーになります。
Mal [java]
user> { 'a 123 }
Uncaught java.lang.ClassCastException: mal.types$MalList cannot be cast to mal.types$MalString: mal.types$MalList cannot be cast to mal.types$MalString
# 文字列ならOK
user> { "a" 123 }
{"a" 123}
Ruby 版だとシンボルでもOK。
Mal [ruby]
user> { 'a 123 }
{a 123}