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

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

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

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

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

何種類かの警告

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

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

  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 プログラム」という「世界は二つしかないのが当たり前」というようなことだ。たぶんこういうのは「データベース処理だけで手一杯でそんな「高等な」ことやってる暇がない」として考えないようにしているか、あるいは本当に脳内がこの二分法に支配されてしまっているのかどっちかだ。

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