たなかこういちの開発ノート

システム開発に携わる筆者が、あれこれアウトプットするブログ

モデル駆動、DSL、自動生成、などについて整理するメモ、その1

モデル駆動の実現方式には、「内部DSL式」か「外部DSL式」かの選択と、「インタープリター式」か「コンパイラー式」かの選択があると考える。
 

内部DSL

 
「内部DSL式」では、ホストとなるプログラミング言語と同じレベルでモデル記述する。Javaだったらアノーテーションを駆使するのがよいと思うが、これはさらにコンパイル時にアノーテーション・プロセッシングする方式と、実行時にリフレクションで解析する方式との選択がある。前者は「コンパイラー式」、後者は「インタープリター式」と称することとする。Scalaだったら中置記法、括弧省略、記号メソッドを駆使した内部DSLは「インタープリター式」、マクロを使った場合は「コンパイラー式」と云える。後付けになるが、「コンパイラー式」とはDSLのparseやresolveを文字通りコンパイル時に行う方式、「インタープリター式」とはそれを実行時に行う方式、という意味である。
 
内部DSLは、DSLとはいっているが、あくまでホスト言語の文法のサブセットに収まっている。ソースコードの見た目上、異なる文法を想起させる、というものである。(※もちろん、DSLの仕様として、その文法を定義はしている。)実のところ、「インタープリター式の内部DSL」と「高度なアプリケーション・フレームワーク」とは、ほとんど同義である。(※「コンパイラー式」、つまりJavaのアノーテーション・プロセッシングやScalaのマクロに基づく仕立てを「アプリケーション・フレームワーク」と称することができるかというと、なんらか誤認を誘発しそうなので、やめた方が良い気がしている。)アプリケーション・フレームワークは、それが定めるオブジェクト類の集合でアプリケーションを構成することを求める。一般に、フレームワークは、フレームワークの定めるそれらのオブジェクト類による構造物を実行時にトラバースしながら、アプリケーションの基礎的な振る舞いたる条件分岐やループが具体的にどの様に処理されるべきかを、実行時に動的に判断する。「インタープリター式内部DSL」も、このようなアプリケーション・フレームワークの動作方式と同一である。このような動作方式は広くインタープリター式だと捉える。
 
とは言えこれを「インタープリター式」と捉えることに違和感があるなら、細かくは「中間言語インタープリター式」だと捉えればより的確だろう。「インタープリター式内部DSL」とは、常に「中間言語インタープリター式」である、と理解する。
 
内部DSLでは、「インタープリター式」であれ「コンパイラー式」であれ、当該DSL専用のインタープリターもしくはコンパイラーを実装する必要はない、あるいは、その余地がない。
 
内部DSLにおける型安全性は、ホスト言語のそれに乗っかるかたちで実現される。ホスト言語が例えばJavaScriptならば、内部DSLも動的型付けとなる。
 
外部DSL
 
「外部DSL式」の場合は、独自の文法の言語を設計することとなる。当該モデル記述言語に対するインタプリターおよび/またはコンパイラーをがちに実装することとなる。
 
外部DSLには内部DSLのような「ホスト言語」という概念はない。外部DSLで、DSL自体以外に関わってくる“言語”は、「インタープリター式」の場合は「インタプリーターを実装する言語」である。これを「ネイティブ言語」と称することとしよう。「コンパイラー式」の場合は、「コンパイラーを実装する言語」と「生成ターゲットのプログラミング言語」とが関わってくる。内部DSLでは、DSLの文法は、ホストとなるプログラミング言語のサブセットである、という関係があるが、外部DSLでは、インタープリター式においてはネイティブ言語、コンパイラー式においてはコンパイラーを実装する言語と生成ターゲットの言語、これらいずれとも文法的になんらの関係性を持つ必要はない。例えば、DSLがXMLベースのマークアップ言語で、生成ターゲットはPHP、そのコンパイラーはScalaで実装されている、などという構成も可能である。
 
外部DSLで型安全性を実現するには、がちに実装するDSLコンパイラーもしくは専用検査器においてシンプルにがんばる必要がある。コンパイラー式の場合は、そのコンパイル手順の中で型検査をすることになろう。インタープリター式の場合は、別途専用静的検査器を用意することを検討する必要がある。
 
インタープリター式の外部DSL
 
一部の人にとって、「インタープリター式」の外部DSLは、各種内部DSLやコンパイラー式の外部DSLに比べて最も身近かもしれない。昔のSAStrutsやSpring/Hibernateを使ったことがある人なら"XML hell"に取り組んだことがあるだろう(*_*; あのXML類こそ、インタープリター式の外部DSLである。もちろん結果的な扱いにくさを言いいたいのではない。DSLソースコードを実行時にloadして、実行時に解析するという仕組みのことをいいたいのである。インタープリター式の外部DSLは、仕組みが比較的イメージしやすいと思う。構成定義ファイルの化け物と思えばよい。
 
そもそもなぜあれらが"XML hell"に陥ったのか?ポイントは二つある。一つ目は、マークアップ言語の静的検査環境が不十分だったことである。Spring/Hibernate/SAStrutsは静的型付け言語たるJavaユーザー向けに用意された。XML類にはXML Schema Validationなどがあるとしても、Java言語と同等に検査できる環境には無かった。(※この点はRubyユーザーやPHPユーザーはもしかしたら苦にしないのかもしれない。)より本質的であろう二点目は、DSL化率がものすごく半端だったということである。どのくらい半端かというと“約50%”である。その結果、JavaコードとDSLコードの二つを同期的にメンテする必要が生じてしまった。XML自体の検査はある程度凌げたとしても、XMLのコードとJavaコードとの間の整合性維持は、、如何ともし難い、という状況を生み出してしまった。私の考えではこれが"XML hell"の正体である。
 
"XML hell"は、インタープリター式外部DSLが持つ潜在的な構造問題を示唆していると思う。DSL化率100%が達成されない限り、DSLで記述できない部分を一般のプログラミング言語で補えるようになっている必要がある。内部DSLであれば、ホスト言語がその役を自然と担う。 コンパイラー式の外部DSLであれば、生成するコードにカスタマイズポイントを意図して設計しておく必要はあるが、生成ターゲットの言語で生成コードを拡張するかたちで、これも比較的自然に追加コードを書くことができる。内部DSLでのホスト言語や、コンパイラー式外部DSLでの生成ターゲットの言語に相当する位置付けのプログラミング言語は、インタープリター式の外部DSLにおいてはインタープリターの実装言語たる「ネイティブ言語」となる。インタープリター式外部DSLの場合は、「DSL」と「ネイティブ言語」という異なる言語のコード間で整合性を維持しなければならない。DSLコードに基づくインタープリターの振る舞いを想定しつつ、“ネイティブコード”がそれに協調するよう設計する必要がある。"hell"である。
 
インタープリター式の外部DSLが内在する"hell"問題を回避する方法は、全部で三つある。なんてことは無い、一つ目は内部DSLへ移行すること、二つ目はコンパイラー式の外部DSLに移行することである。そして、三つ目はDSL化率100%を達成することである。
 
ちなみに、新しいSpringは全てアノーテーションで記述することとなった。インタープリター式外部DSLから内部DSLに移行したのだ。ホスト言語と一体的な内部DSLを採用したことで、DSL化率が半端でも、ホスト言語とDSLとを合わせた全体として静的検査することが実現できた。(※最近のSpringや各種のデータマッパーが提供するアノーテーションベースの内部DSLを積極的に用いると、その記述はかなり激しいこととなるようだ。その様子については、、この記事が参考になる。)
 
コンパイラー式の外部DSL
 
外部DSLの「コンパイラー式」とは、一般に「自動生成」といっているものと本質的に同一のものである。
 
外部DSLにおいて、コンパイル(あるいはジェネレート)した生成物は、(現実的には、)ある特定のプログラミング言語のソースコードである。先にも述べたが、コンパイラー式外部DSLの文法は、生成ターゲットの言語となんらの関係性を持つ必要はない。
 
コンパイラー式外部DSLで型安全性を実現するには、がちに実装するDSLコンパイラーにおいてシンプルにがんばる必要がある。がんばれば、ターゲットの言語以上に精緻な検査をする事もできる。一方、生成コードを工夫すれば、生成ターゲットのコンパイラーに検査を委ねることも可能性としてはできる。ただし、実用レベルに仕立てるにはエラー情報の取得などにいろいろな考慮が必要ではある。ターゲット言語のコンパイラーに委ねる方式を実現していた身近なDSLがある。JSPだ。(※少なくとも初期のJSPはそうだった。今は知らない。)
 
 
コンパイラー式外部DSLから生成するコードは、具体的にはどのようなものとできるだろうか。
 
この問題は、生成したターゲット言語のコードに対する「ランタイム・ライブラリー」をどの程度厚く用意するか、という問題と表裏である。C言語をコンパイルすると、などの標準Cランタイムライブラリーを前提としたコードが生成される。Javaの場合はJava SEのAPIセットである。*我々の*外部DSLから生成されたターゲット言語のコードは、いかなるランタイム・ライブラリーを前提とするべきか?
 
この問題は、シンプルに、ランタイム・ライブラリーが薄ければコード生成処理が大変であり(※条件分岐やループを含むメインのロジックを生成する必要があり、トータルの生成コード量も多くなる)、ランタイム・ライブラリーが厚ければコード生成処理は容易である(※制御ロジックは、フレームワーク様構造のランタイム・ライブラリーがハンドルするのであまり生成しなくてもよく、トータルの生成コード量も少ない)、という関係にある。
 
極端な例として次のような生成コードとランタイム・ライブラリーを想定できる。生成コードは、外部DSLの構文構造をそのまま反映したようなjava.util.Mapベースの構造物を構築する手続きのみ生成する。(※Map#put、ArrayList#asListなどが延々並ぶようなコードである。)ランタイム・ライブラリーは、そのようなMap構造物を解析しつつ動作する、というものだ。これはほんとんど「インタープリター式」に等しい。真に「インタープリター式」の処理系の内部でも、大抵の場合はloadしたDSLをまずMap様構造で保持することとなるだろう。いくらコード生成していても、Map様構造を構築するだけであれば、それはDSLの初期load部分だけが生成コードになっているだけで、本質的にはインタープリター式だと見なすべきだ。このような実質的にインタープリター式と変わらないような生成のケースは、「中間言語生成式、かつ中間言語インタープリター式の外部DSL」と称する事としよう。「中間言語生成式、かつ中間言語インタープリター式の外部DSL」とは、一応は外部DSLからターゲット言語のコードを生成するが、生成されたコードが、実質的にインタープリターに食わすコードの内部構造物を構築しているだけの様な場合を指す。
 
実のところこの「中間言語」は、ある一つの「内部DSL」であると見立てられる。インタープリター式の内部DSLとは常に「中間言語インタープリター式」だと理解したことを思い起こそう。「中間言語」と「内部DSL」は、同じ位置付けのものと捉えるのだ。
 
「中間言語生成式」とは、外部DSLから内部DSLを生成すると云うことだ。であれば、その先内部DSLの処理の仕方には、生成コードの工夫で、(中間言語)インタープリター式ではなく、コンパイラー式を選ぶこともできるはずだ。コンパイラー式を選んだ場合は、もはや「中間言語生成式、かつ中間言語インタープリター式」ではない。「2パスのコンパイラー式」と云うべきだろう。
 
モデル駆動の各スキームのまとめ
 
モデル駆動のスキームは、これまで見てきたような各種DSLの取り扱い方式の違いとしてまとめることができると考える。各方式は、お互いに全く無関係なものではなく、統合された一つの図式の中における幾つかのオプションの組み合わせの違いだと捉える。
 

(※図が見にくかったらクリックしてください。)
 
コンパイラー式外部DSLでは、直接ターゲット言語の最終的なソースコードを生成することもできる。(※上図「Type B」。)これは「1パスコンパイラー式(※最終生成コードの、ターゲット言語のコンパイラーによるコンパイルを含めれば2パス)」である。一旦、中間言語=内部DSLを生成し、その先はJavaアノーテーション・プロセッシングやScalaマクロに委ねることもできる。(※上図「Type E」。)これは「2パスコンパイラー式(※アノーテーション処理やマクロ展開自体を2パスと捉えるなら、全部で3パス)」ということである。一旦中間言語=内部DSLを生成し、その先は「中間言語インタープリター式」とすることもできる。(※上図「Type C/D」。)これらの場合、コンパイラー式外部DSLにとってのターゲット言語と、内部DSLにとってのホスト言語は、もちろん同一である。
 
 
これまでの論では無視していたが、中間言語のレベルには大きく二段階ある。一つ目は、DSLの構文構造を、ターゲット言語あるいはホスト言語のネイティブな言語要素に写像するケース(※上図「Type D」)、二つ目は、ターゲット言語あるいはホスト言語上にライブラリーとして上乗せされている動的な構造物による構造へ写像するケース(※上図「Type C」)である。要は、前者は、Javaだったら、DSLの構文構造に対応したJavaのクラスを生成しようというもので、後者は、MapやListを組み上げるロジックを書く、ということである。内部DSLとしてであれば、前者では所定のコーディングルールに従ったJavaクラスを書くこととなり、後者では、多くの場合は専用のbuilderクラスやメソッドが用意されるはずなので、それを並べることとなる。この違いは、ターゲットあるいはホスト言語が静的型付けである時、ターゲットあるいはホスト言語のコンパイラーによる型検査の恩恵を受けれる程度が変わるので、どうすべきか悩みポイントとなる。
 
ちなみに、JavaScriptは、外部DSLのターゲット言語あるいは内部DSLのホスト言語として見たとき特異的な特徴を持つ。言語ネイティブなObjectと、連想配列と、外部表現の基盤としてのJSON、これらの間にセマンティックスギャップも、さらにはシンタックスギャップも、殆ど無いのである。そもそも動的型付けであるという面はあるものの、JavaScriptをターゲットあるいはホスト言語とすると、どのようなDSLの方式にしようと考えても、仕上がったものに差異が出ないのである。(※同じ動的型付けでもPHPはこのようにはいかない。JSONと連想配列はかなり透過的に扱えるが、Objectと連想配列はむしろJavaのクラスとMapに近い。)
 
◆以上