2022-07-17に更新

malでかんたんなテンプレートエンジンを書いてみた

これは Lisp Advent Calendar 2021 の25日目の記事です。Qiita に書いていた記事のクロスポストです。


言語非依存なテンプレートエンジンがあったらいいなと昔からボンヤリ考えていた(切実にほしいという程ではない)のですが、mal(Make a Lisp)で作ったらどうだろうと思って試しにやってみました。「なんかやってみたくなってやってみた」系の記事です。

Lisp を書くのは年に数回という感じの人が書いていますので、拙いところは大目に見ていただければと思います。

↓ちなみに去年のアドベントカレンダーではこんなのを書きました。

LibreOffice BasicでLispインタプリタ(mal)を書いた - Qiita

できたもの

https://github.com/sonota88/malten

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-charss.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}
Originally published at qiita.com
ツイッターでシェア
みんなに共有、忘れないようにメモ

sonota486

Crieitは誰でも投稿できるサービスです。 是非記事の投稿をお願いします。どんな軽い内容でも投稿できます。

また、「こんな記事が読みたいけど見つからない!」という方は是非記事投稿リクエストボードへ!

有料記事を販売できるようになりました!

こじんまりと作業ログやメモ、進捗を書き残しておきたい方はボード機能をご利用ください。
ボードとは?

コメント