晴れときどきミニ言語。ときどきミニ言語のススメ

ドメイン固有言語、とも呼んだりする。

久しぶりにちょっとこの手のことが必要になって、大昔同僚向けに書いたものを思い出したのだが、発掘できなかったので、まっさらから書いてみようと思う。

「ドメイン固有言語」とは、直面している問題に固有の独自言語のことである。

ときどきドメイン固有言語のススメ

何種類かの警告

必ず誤解を生じるトピックなので、何重にもガードが必要だ。

まず、「いつそうしてはならないか」からである、無論。こう考えて欲しい:

  1. あなたがチームの一員であるならば、独自の判断で行わないこと。これに従わないならば、必ずやプロジェクトを失敗に導くこととなるだろう。
  2. 車輪の再発明を避けること。既存のものを検討することなく独自の言語を生み出すべきではない。これに従わないならば、必ずや「インフラ不足」に足元を掬われることとなるだろう。

次に、「なぜそうしないのか」についてだが、この実例を挙げていくことは難しい。というのも、「本当に実在するミニ言語」の正当性の評価が本当は難しいからだ。典型的には例えば JSP の el (Expression Language) なんかがそうだ。これらは下手をすれば「大々的に撒き散らされてしまった迷惑な新言語の一つ」ともなりかねない、すなわち「そうしてはならない」の実例でもありうるのだ。

ただ言えることは、「恐れないあまり発明をまったく厭わない」文化と「恐れるあまりそうしないことに固執し続ける」文化は交じり合うことはなく、後者の文化に属する開発者が、このことを想像することすらしないということが非常に多い、ということだ。今回のトピックはそうした彼らに向けるものだ。

簡単に言えば「なぜそうしないのか」については、「データ構造とそれを処理するプログラム」という二分法だけではやりにくいことを、間に一枚ドメイン固有言語を挟みこむことで「ハッピーになれることがある」ということである。

そして、「そうする」と決断することが正当であるとするならば、「それを実現するための手段についての「そうしてはならない」」ではやはり「車輪の再発明を極力避けなければならない」が再登場する。すなわち、「一から十まで独自」とすることなく、「九から十だけが独自」とすべきである。

ただのデータ構造だけでは悲しいとき

一般の「業務系」で良く発生するのは、「ロジック埋め込み」(要するに「業務作りこみ」)では対応出来ないような自由をエンドユーザに与えたい、といったケースである。

「データ構造だけで対応する」で思い出してもらえればいいのは、あなたが今持っているアプリケーションの「設定」だ。これは「箱庭の自由」をエンドユーザに与えるために、「ロジック埋め込みをユーザ設定に従って分岐する」わけである。

けれども単純なブーリアンや選択肢、文字列リテラルだけ変えさせるだけでなく、例えば「特定の時刻になって、かつ、特定のファイルが存在する場合にはこれこれをせよ」と言った「指示」をユーザ指定可能にしたい、としたら?

要件の発展性にもよるが、この手のニーズに対しては、データ構造と埋め込みロジックだけで対応し続けるよりは、ネクストステップへ進むべきときかもしれない。

スクリプト言語の埋め込み

最近は lua が流行りだろう。Python も、本家だと C/C++ と CPython の組み合わせが libpython-XX.so (や Windows なら pythonXX.dll) で可能だし、ほかの組み合わせもある。(当然だが Python から Python を「埋め込む」のは普通に config.py 的流儀でいい。)

個人的にはこのアプローチを最もお勧めする。異言語交流なら、その作りこみは得てして大変だが、得るものは大きい。すなわち「パーフェクト過ぎる自由をユーザに与える」ことが出来る。

そして、「スクリプト言語の埋め込み」が正当ではないケースが当然多い。すなわちこれはある種のリスクを背負い込む。「何でも出来る」ことは決して良いことばかりではなく、当たり前だが「危険なこと」まで丸ごとエンドユーザに許してしまうことを望んでいるとは限らない。これこそが「ドメインン固有言語を採用することが俎板に上がる」瞬間だ。そう、「もっと出来ることは少なくて良いのだ」ということである。

最初に決めるべきこと: それは人向けか機械処理向けか

すなわち、主として機械的に生成され、そのまま機械処理の入力となるものとして設計するのか、もしくは「人間(客様)」の自由記述を許容し、人が書きやすいものを目指すのか、ということである。

もしも、テキストベースでかつ人間フレンドリであることを特に考えなくて良いのなら、オススメは Unix コマンドの dc のような「逆ポーランド」で設計するのが良い。手打ちするのも読むのもかなりキビシイ dc:

1 1 5 + 2 3 + *
2 p
3 30

けれどもこれは機械処理は大変行いやすい。というのも、読み込んだ順に値をスタックに積んでゆき、命令コード(上の例の場合は「+」と「*」と「p」(表示))が現れてからスタックから値を取り出してしかるべき演算を行う、という風に、入力順と処理順が一致しているからである。要するに「先読み」がいらない。

「逆ポーランド」は「一般の人々」が目にする機会は少ないが、今でも良く使われていて、決して「前近代的な歴史的異物」ではない。メジャーなものでは PostScript がそうだ。

もしくは lisp の設計に似せるのも機械処理に非常に向いている。実際 gcc が中間コードとして採用しているのも lisp もどきである。

そうでなく人間フレンドリが欲しいのであれば、設計は既存の言語に似せるのが良いだろうが、少なくとも場当たり的な処理系を書かなくても済むような、「綺麗な」設計をしておくべきである。昔からこれの設計ツールとして使われてきたのは言うまでもなく BNF だが、そうまですることの価値は言うまでもなく「豊富なインフラが使えるかもしれない」からだ。

昔であれば本当に「BNF から直接プログラムを作れる lex/yacc (flex/bison)」で作ったところだが、これは当時も今も原則「C 言語のためのもの(に C++ 用の毛が若干生えたもの)」であって、現代だとあまりこれを採用するのは気が引ける(か採用出来ない)。

出来合いのものが利用出来ないかどうかは先に調査すべきである。
たとえば設計する言語を「Unix シェルに似たもの」にすることを考えているなら、Python の場合は shlex が使える、かもしれないのだ。これらを見る前から最初から独自にやろうとしないことである。

次に考えるべきこと: その「言語的なもの」が収まる場所は?

「最初に決めるべきこと」がこちらの場合もあるだろう。すなわち、アプリケーションの性質上、これは自明な場合もある。

すなわち、「自由記述のテキストファイル」に収めるようなものならばそれはまさしく「隅々まで独自の新言語」に見えるであろう。あるいはデータベースのカラムに埋め込むようなケースも同類だ。

そうではなく xml やら json やらの既存のデータ記述「言語」内に埋め込みたい場合に、「<![CDATA[]]>」に埋め込むならば先のケースと同じだが、例えば以下はそうではない:

 1 <command name="report">
 2   <cond>
 3     <and>
 4       <or>
 5         <equals lhs="time" rhs="10:00"/>
 6         <equals lhs="time" rhs="20:00"/>
 7       </or>
 8       <exist arg="recieve.txt"/>
 9     </and>
10   </cond>
11 </command>

こうしたものまで「ドメイン固有言語」と呼ぶのが慣わしなのかはワタシの知るところではないが、このパターンが非常に良く使われる理由は無論「これならパーサはいらない」からだ。パースだけは全て xml パーサが処理してくれて、あとはそれを読み込んだアクションに集中出来る。

なおこの xml の例は「人間に優しいとは限らないが機械処理には向いている」構造をあえて選んだ。これと較べてみて欲しい:

 1 <command name="report">
 2   <begin type="cond"/>
 3   <group type="("/>>
 4   <equals lhs="time" rhs="10:00"/>
 5   <or/>
 6   <equals lhs="time" rhs="20:00"/>
 7   <group type=")"/>>
 8   <and/>
 9   <exist arg="recieve.txt"/>
10   <end type="cond"/>
11 </command>

こちらはより「人間向け」で C 系の言語に似ているかもしれないが、処理系を書くのは結構大変である。が、さっきの方は lisp に似たものが処理しやすいのと同じ理由で処理系を書くのが簡単である。

もうちょっと変わった例だが実はそんなに変でもない「バイナリに言語的なものを埋め込む」ことも検討しても良い。独自電文に埋め込むなら本当に独自になり、例えば messagepack や protocol buffer の構造を利用して埋め込めば「パーサいらず」になる関係については全く同じである。messagepack、protocol buffer、そして json や yaml の良さは、その仕様そのものに「型」が定義されていることであって、これは「DTD や xml schema をフル活用してその処理だけで膨大な設計・実装コストとランタイムコストを背負い込む xml」とは全然違う。

なんにしてもバイナリに埋め込もうが、根本的に考えることはほとんど同じで、制御構造をどう設計してエンコードするかだけのことで、先に xml で例にしたようなそのままを、たとえば messagepack で表現するだけの話だ。特に messagepack などは map を表現出来るため、先の例の入れ子をそのまま表現出来る。

言語が設計出来たのでその処理は

既存のデータフォーマットの機能を利用して埋め込んだ場合は特に言うべきことはなく、すなわちアクションを指示に従って書いてゆくだけである。このケースはドメイン固有言語というよりは、ドメイン固有プロトコル、と呼んだほうが相応しいかもしれない。

そうでなく自由記述テキストを採用した場合は、「字句解析と構文解析を分離する」、というのが、昔からのセオリーで、経験則からこうすると見通しの良いプログラムが書けることがわかっている。とてつもなく簡単なニーズだとしても、「字句解析」だけは分離した方が良い。

もともとこのトピックを「思い出した」のは実は MSBuild Conditions を処理する必要に迫られて。そしてこのトピックを書こうと思ったのは、かつて、同僚たちが「この手の処理への過度に怖れ」るかあるいは「頑なに俎上にも上げようとしない」のを見てきたから。こんなんばっかりやられるのも困るが、全くやろうとしないのも同じくらい困る。

要するに「そこまで怖れずとも、やろうと思えば別に天地がひっくり返るほどの別世界ではなく、手が届きうるものなのだ」ということを示して見せたかったのだ。

以下はまさに昨日 Python で MSBuild Conditions の最後の$if$ ( %expression% ), $else$, $endif$を除いた仕様を処理するための実装を、30分くらいで書いたもの。ただしその実装時間 30 分の前に「どーすっかなぁ」時間が相当あったけれど。こんな:

長くなりすぎるので使っている expand_variable 実装部分は省略
 1 import re
 2 
 3 _CE_TOKENS = {  # 字句解析に使うトークンリスト
 4     'root': [
 5         (r"(==|!=|<=|>=|<|>)", "BOP", ""),
 6         (r"!", "UOP", ""),
 7         (r"\b(and|or)\b", "LOGICAL", ""),
 8         (r"\d+(\.\d*)?(e[+-]\d+)", "NUM", ""),
 9         (r"\b\.\d+(e[+-]\d+)", "NUM", ""),
10         (r"\$\([\w\d_]+\)", "LITERAL", ""),
11         (r"\w+", "WORD", ""),  # Exists, HasTrailingSlash
12         (r"\(", "LPAR", ""),
13         (r"\)", "RPAR", ""),
14         (r"'", "SQ", "sq"),
15         (r"\s+", "WS", ""),
16         ],
17     'sq': [
18         (r"'", "SQ", "#pop"),
19         (r"[^']+", "LITERAL", ""),
20         (r"\s+", "WS", ""),
21         ]
22     }
23 for k in _CE_TOKENS.keys(): # これは正規表現のプリコンパイルをしてるだけなので本質とはあまり関係ない
24     for i, (rgxstr, tok, trans) in enumerate(_CE_TOKENS[k]):
25         rgx = re.compile(rgxstr, flags=re.I)
26         _CE_TOKENS[k][i] = (rgx, tok, trans)
27 
28 def _cond_eval(cond, ctx):
29     """
30     >>> _cond_eval("'$(CONFIG)'=='DEBUG'", {"CONFIG": "DEBUG"})
31     True
32     >>> _cond_eval("'$(CONFIG)'=='DEBUG'", {"CONFIG": "RELEASE"})
33     False
34     >>> _cond_eval("!Exists('$(D)')", {"D": "aaa.txt"})
35     True
36     >>> _cond_eval("!(1 < 2)", {})
37     False
38     >>> _cond_eval("(1 <= 2)", {})
39     True
40     >>> _cond_eval("($(VAL) <= 2)", {"VAL": 3})
41     False
42     """
43     from os.path import exists
44     def hastrailingslash(s):
45         return s.endswith("/") or s.endswith("\\")
46 
47     # 字句解析機
48     def _lexer(s):
49         states = ["root"]
50         state = states[0]
51         while s:
52             for rgx, tok, trans in _CE_TOKENS[state]:
53                 m = rgx.match(s)
54                 if m:
55                     yield tok, m.group(0)
56                     if trans:
57                         if trans == "#pop":
58                             states.pop(-1)
59                             state = states[-1]
60                         else:
61                             states.append(trans)
62                             state = trans
63                     s = s[m.span()[1]:]
64                     break
65 
66     # 以下が字句解析で返ってきたトークンに基づく実処理。この手のって、
67     # lexer は欲しいが parser はいらない、ってパターンが多く、
68     # 今回のはまさしくソレ。(そしてこのケースこそが「手が届きやすい」
69     # パターン。)
70     cnv = ""
71     for tok, s in _lexer(cond):
72         if tok == "SQ":
73             continue  # ignore
74         if tok == "UOP":
75             s = "not "
76         elif tok == "LITERAL":
77             s = expand_variable(s, ctx)
78             try:
79                 float(s)
80                 s = str(s)
81             except ValueError:
82                 s = '"%s"' % s
83         elif tok == "LOGICAL":
84             s = s.lower()
85         elif tok == "WORD":
86             s = s.lower()
87         cnv += s
88     return eval(cnv)

正規表現さえ使えるならば実装言語による大変さにはそう大差はないのだけれど、字句解析部分のポイントは「状態遷移」をちゃんと把握して実装する、という点くらい。この例の場合はシングルクオートを見つけたら「クオート内モード」に遷移し、「クオート内モード」では相手となるシングルクオートを探すことだけに集中する。この手の処理はほんとに良く登場し、まさに「C 言語ライクなコメント」の処理なんかは全く同じ構造のプログラムになる。

実際この例の場合は「python が理解できるテキストに変換して最後は eval 任せ」というインチキをしているんだけれど、そうしない場合でも字句解析だけ独立させていれば、これを自力でまかなうのがそう大変ではないことは理解できるのではないかと思う。これがもし字句解析部分を分離していなかったとしたら、条件分岐だらけの醜いプログラムになることは想像に難くない。(ありがちなのが「改行がないのを期待してたのに改行が入ってやがったーっ、取り除かねば、猿んきーぱっちんぐ」みたいな連鎖ね。)

実際上でも一言触れた Python 標準モジュールの shlex はまさしく「煩わしい字句解析」を担ってくれるものだ。これが利用できない場合でもその思想は倣った方が良い。


2017-08-08 19:45 追記:
Python に関して前から気にはなっていた pyparsing、これ、伝統的な BNF ではなく PEGs (Parsing expression grammar)で構文を定義できるものなんだって。これで構文規則を作る場合は lexer はいらないみたい。pyparsing の「存在」だけはずっと気付いてはいた(これへの依存 OSS は結構多いので)けど、昨晩初めてその意味を知った。どうも知識が古くていかんわ、アタシ。

最後に

前置きで書いた通りで、個人差・チーム格差が激しい領域の話なので、この記事を「なーにあったりまえのことをえらそーに」と思うエンジニアは相当多いと思う一方で、「考えたこともなかった」とか、「もっと難しいと思ってた」と思うエンジニアが相当数存在していることは、ワタシは経験的に知っている。

根本的に「データとプログラム言語」といものの中間に何かが挟まるということを、どうやら想像しにくいようなのだ。例えば「データベースのデータ設計と java プログラム」という「世界は二つしかないのが当たり前」というようなことだ。たぶんこういうのは「データベース処理だけで手一杯でそんな「高等な」ことやってる暇がない」として考えないようにしているか、あるいは本当に脳内がこの二分法に支配されてしまっているのかどっちかだ。

同じく前置きに書いた通りで、「そうすべきかどうかについてだけを最初に集中的に真面目に考えるべきである」ということも繰り返して言っておく。「思ったより簡単だから」という理由で独自言語を撒き散らされることほど迷惑なことはなく、やはり「標準」には従って欲しい、出来るだけ。

2022年3月の追記なのよ: のススメ「ない」話

たまーにこいつが Popular Posts に上がってくるんだよね、どーせ誰も読みゃーしないだろうと思って書いたんで、その予想よりは結構多めに読まれてるんね。だったらちょっと補足しとこうかなと。

もとの話が「怖れ過ぎるべからず」を中心に展開したので、今度は反対に、前置きで強く念押しした「独自にやろうとしすぎるな」のちょっと具体的な話をしとこうかと。「ミニ言語的なもの」が欲しくなる個人的に頻出のパターンがあってね、それをまさに今日やったんで、それをちょっと示しこうかと思ったの。

その「頻出パターン」とは、「ユーザカスタマイズ可能なフィルタ」ね。ここには「モジュールのロード」みたいなプラグイン的な発想や、「ミニ言語」のような発想が活躍出来る余地がある。「プラグイン的な発想」と言っているのは Python なら「conf.py 流儀」ね、あるいは eval/exec のようなもの。そうではなくて、「実装言語とは独立のものを使いたい」と、今日のワタシは思ったわけ。けれども「独自ミニ言語が牛刀なのは明らか」なわけ、今日のワタシのニーズにとっては。

今日のニーズ、は、こんな作業の中で現れた:

  1. たくさんの音声ファイルの整理をしたい。
  2. その整理のためのキーとなるメタな情報を別途 json ファイルで記録しておけば
  3. その json データに基いた音声ファイルの検索とかが出来ろうし、なんならそれに基いてプレイリストれろう。
  4. 「検索とか」の指示を、スクリプトに埋め込むのではなくて、引数として与えたい。

伝わる…よね? 属性は例えば5種類:

attrs.json みたいな名前でフォルダに一つ配置する想定。
1 {
2     "A1": 0,
3     "A2": 1,
4     "A3": 0,
5     "A4": 2,
6     "A5": 3
7 }

して、「A1が3より小さいものが欲しい」みたいなことを自由にやりたいというわけだ。コマンドラインから条件指定したい都合、属性名は冗長なものではなく「A1」みたいな短いものにしてる。意味は例えば「A1: 意味=声優の性別, 値=0:男性/1:女性」とかね。

「今日のワレ」は一つのアプローチを却下し、二つのアプローチを遡上にあげた。

却下したのは eval/exec や「モジュールのロード、などのようなプラグイン的アプローチ」。これは上で言った「なんでも出来過ぎること」を嫌ってというのもあるが、それよりも、ここ最近のワタシのマイブームが Go 言語なので、「Python 的過ぎる」ことを避けたかった、てこと。

俎上に上げた二つのうち、採用しなかった方は SQLite の :memory:。つまり条件指定を本物の SQL で与えれるじゃん、てことだけれど、でもさ、それするくらいなら最初から SQLite データベースとして属性を管理すればいいじゃん、と思ったのでね、やめた。だいたいにして「json → メモリデータベースのセットアップ全部 → やっとこ検索」を都度やるのって全然合理的じゃないし。それにね、「SQLite データベースとして属性を管理すればいい」とは言うけれど、「フォルダにただの json が置いてある」ことそのものが「現物と一体」としての管理にはむしろやりやすいのよ、拡張子を .txt にしとけば、エクスプローラのプレビューペインで覗けたりもするしね、一枚岩のデータベースにしちゃったらこの便利さは「作り込まない限りは得られない」。てわけで「今日のワレ」向けではなかったけれど、ただこれさ、Go 言語で同じことをやろうと考えた場合に、ほとんどストレートに移植出来るだろう、って考えると、魅力的だろうとは思うよ、場合によっては。

SQLite の :memory: を使う、と考えることはつまり抽出条件の記述として SQL を再利用しよう、って発想なので、似た発想で XML の XPath だのそういったヤツを考えるのも、本質は同じ。そして「今日のワレ」にはそぐわない点も一緒。

採用したのは「テンプレートエンジンを使い回す」というもの。具体的にはこんなスクリプトにした:

 1 # -*- coding: utf-8 -*-
 2 import io
 3 import json
 4 # ...
 5 from mako.template import Template
 6 
 7 
 8 def _enum_audiodir():
 9     # 省略。
10     # (フォルダ名, (ファイルのベース名, タイトル)のリスト) のリストを返す。
11 
12 
13 def _loadattrs(k):
14     # 省略。フォルダ「k」内に含まれる「attrs.json」をロードしてその結果を返す。
15 
16 
17 if __name__ == '__main__':
18     import argparse
19     ap = argparse.ArgumentParser()
20     ap.add_argument("--outbasename", "-o", default="playlist")
21     ap.add_argument("--cond", "-c", default="${rank <= 3}")
22     args = ap.parse_args()
23     tmpl = args.cond
24     outname = args.outbasename + ".m3u8"
25     tmplinst = Template(tmpl)
26     with io.open(outname, "w", encoding="utf-8", newline="\n") as fo:
27         for k, lst in _enum_audiodir():
28             a = {
29                 ak: av if av is not None else float('nan')
30                 for ak, av in _loadattrs(k).items()}
31             rank = sum([av for ak, av in a.items()])
32             d = {"rank": rank}
33             d.update(a)
34             for bn, tit in v:
35                 d.update({"bn": bn, "tit": tit})
36                 s = tmplinst.render(**d)
37                 if s == "True":
38                     print("{}/{}.mkv".format(k, bn), file=fo)
39                     print("{}|{}/{}.mkv: {}".format(tmpl, k, bn, tit), flush=True)

rank なんてしてるのは、今日のワタシのニーズの管理する属性を、「全部足してその数が大きくなるほど評価が悪い」という風に設計したので、その値。例えば mkcustpl.py という名前にしたならこんな風に使う:

py は Windows 専用の python ランチャなので、Unix とかのあなたは適宜読み替えてちょ
1 [me@host: ~]$ py -3 mkcustpl.py --ou=playlist1 --co='${A1 <= 1 and "-003-" not in bn}'

mako に頼る時点で「Go 言語でも」とはいかないアプローチにはなるけれど、言語そのものではないので eval/exec よりは Python 言語以外でも通用する可能性はある。django に対する pongo2 みたいに、Python のライブラリが Go 言語に移植される、みたいな例は結構ある。そういう言語横断可能なインフラさえ見つかれば、それを使うのが一番いいと思う。ワタシはまだ django/pongo2 しか見つけてないけど。