このページの目次
DSL 設計
特定のドメイン向けに洗練された語彙と構文をカスタマイズする——内部DSL(ホスト言語の構文の拡張、型チェックが無料)から外部DSL(独立したパーサ+コード生成)まで、選択はホスト言語でドメインの概念を自然に表現できるかどうかにかかっている。
概要
汎用言語(GPL)では何でも書けるが、特定のドメインの表現は汎用言語ではノイズが多くなりがちである。DSL(Domain-Specific Language、ドメイン固有言語)は、特定のドメイン向けに洗練された語彙と構文をカスタマイズする——SQL はデータを操作し、正規表現はテキストとマッチし、Terraform はインフラストラクチャを記述する。コンパイル技術の観点では、DSL には2つの根本的に異なる実装アプローチがある:内部 DSL(ホスト言語の構文の拡張、コンパイラは新しいパーサを作らない)と外部 DSL(独立した構文+パーサ+コード生成)。ここでは、2つのアプローチのメカニズム、選択基準、そしてゼロから外部 DSL を設計する際のコンパイラフロントエンドの工数について解説する。
内部 DSL: ホスト言語の構文の拡張
内部 DSL はホスト言語に寄生する——ホスト言語の構文特性(演算子オーバーロード、チェーン呼び出し、マクロ、末尾クロージャ)を利用して、コードが特別に設計された言語のように読めるようにする。新しいパーサは不要で、コンパイルはホストコンパイラによって行われる。
フルーアント API(チェーン呼び出し)
// SQL クエリを構築する fluent DSL
let results = select
.from
.where_
.order_by
.execute?;
仕組み: 各メソッド(select, from, where_, order_by)は QueryBuilder を返し、累積された AST 断片を保持する。.execute() は AST を SQL 文字列に変換して実行する。コンパイル時にホスト言語が型チェックを行う——where_ のフィールド型と値の型が一致しない場合、ホストコンパイラがエラーを報告する。内部 DSL の中核的な利点の1つ:型チェックが無料であること。
演算子オーバーロード DSL
演算子オーバーロードを利用して、式をより数学的に見せる:
// C++ Eigen (線形代数 DSL)
MatrixXd A = B * C + D.;
VectorXd x = A..;
内部では各 */+/.transpose() は C++ の関数呼び出しだが、実際の行列乗算は行わない——代わりに式テンプレート(expression template)、すなわちコンパイル時の AST を返す。最終的に x = ... の代入時に、式テンプレートは最適なループコードに展開される(コンパイル時に一時行列の割り当ては発生しない)。ランタイムオーバーヘッドゼロ——これが演算子オーバーロード DSL が「インタプリタを書く」と決定的に異なる点である。
マクロ DSL
Rust のプロシージャルマクロは、コンパイル時にカスタム構文をホスト言語の AST に変換することを可能にする:
← DSL: シリアライズルールを宣言
#[derive(Serialize)] はコンパイル時に impl Serialize for User に展開され、serialize() メソッドのフィールドごとのシリアライズコードを生成する。serde と diesel は Rust のマクロ DSL の代表例である——serde の DSL は struct 定義をシリアライズロジックに変換し、diesel の DSL はコンパイル時にスキーマ定義をデータベーステーブルにマッピングする。
マクロ DSL の利点:構文がコンパイル時に完全に展開される——誤った DSL の使用(シリアライズ不可能なフィールド型など)は、実行時ではなくコンパイル時にエラーとして検出される。
外部 DSL: 独立した言語+完全なコンパイルパイプライン
ドメインの構文がホスト言語に埋め込めない場合(正規表現、SQL、Terraform HCL など)、外部 DSL が必要になる——独自の字句解析/構文解析/意味解析を持ち、ターゲット表現(バイトコード、マシンコード、またはホスト言語への翻訳)にコンパイルする。
外部 DSL のコンパイルパイプライン
ソースコード (DSL 構文) → 字句解析 → 構文解析 (通常は Pratt/LR) → 意味解析 → コード生成
↓
AST (DSL 固有ノード)
汎用コンパイラと全く同じだが、以下の点が異なる:
- 言語が小さい(文法规則が数百ではなく数十条)。
- ターゲットは通常マシンコードではなく、別の言語のソースコード(トランスパイル)またはバイトコードである。
- エラーメッセージはこの DSL のユーザーインターフェースであり、汎用コンパイラのエラーメッセージよりも重要である——ユーザーは特定のドメインにいるため、ドメイン用語によるエラーメッセージを受け取るべきである。
コード生成ターゲット: トランスパイル vs コンパイル
- トランスパイル(Transpile): DSL ソースコード → 別の言語のソースコード。Terraform HCL → 内部の plan JSON; Protobuf
.proto→.go/.rs/.py。利点:出力が可読性が高くデバッグ可能。ダウンストリームのコンパイラが最適化を行う。 - コンパイル(Compile): DSL ソースコード → IR → マシンコード/バイトコード。Terra(Lua と低級言語のハイブリッド)はこのアプローチを取る——DSL の一部は JIT コンパイルされてマシンコードになる。利点:パフォーマンスが最高。代償として実装が複雑になる(IR→バックエンドが必要)。
選択は主に「この DSL のユーザーがパフォーマンスを望むか、デバッグ容易性を望むか」にかかっている。
外部 DSL の選択基準:いつ新言語を起こす価値があるか
次の3つの質問を投げかける:
- ドメインの語彙はホスト構文で自然に表現できるか? SQL の
SELECT...FROM...WHEREは、汎用言語で fluent API を使えば自然に近い表現が可能だが、正規表現の^a.*b$は汎用構文では自然に近い表現ができない。 - 使用頻度は十分か? 数回しか使わない設定ファイルフォーマットのためにパーサを書くのは価値がないが、システム内で数千箇所でクエリが実行されるのであれば価値がある。
- エラーメッセージにはドメイン知識が必要か? 正規表現コンパイラの「15文字目で閉じ括弧が一致していない」というエラーと、汎用コンパイラの「SyntaxError: 予期しないトークン」というエラーは、同じ品質の体験ではない——DSL はドメインの概念を理解していないと良いエラーメッセージを報告できない。
判断基準:ホスト言語で「常にコメントを使って意図を説明する必要がある」概念がある場合、それは DSL 化のシグナルである。
漸進的移行: 内部 DSL から外部 DSL へ
多くの成功した DSL は内部 DSL から始まり、ホスト構文の制限が耐えられなくなった時点で、追加で外部パーサを導入する:
フェーズ 1: fluent API + 文字列テンプレート (内部 DSL)
フェーズ 2: マクロ DSL (コンパイル時の AST 構築 + ホスト型チェック) (内部 DSL、コンパイル時)
フェーズ 3: 外部パーサ + ホスト言語へのトランスパイル (外部 DSL、トランスパイル)
Serde はフェーズ 1(手動 Serialize impl)からフェーズ 2(プロシージャルマクロ #[derive(Serialize)])へ移行し、現在はフェーズ 2 で完全に十分である。SQL はほとんどの ORM でフェーズ 1–2 で止まっているが、データベースエンジン内部の SQL パーサはフェーズ 3 である。
参考文献
- Martin Fowler: 「Domain-Specific Languages」 — DSL 設計パターンと選択ガイド
- Holden: 「Build Your Own Lisp」 — パーサ+コード生成を含む、ゼロからの Lisp DSL インタプリタ構築
- Serde: https://serde.rs — Rust マクロ DSL の見本
- Terra: https://terralang.org — 低級 DSL(JIT コンパイル)の代表例
Keywords: DSL, ドメイン固有言語, 内部DSL, 外部DSL, フルーアントAPI, ビルダーパターン, 演算子オーバーロードDSL, 式テンプレート, マクロDSL, プロシージャルマクロ, トランスパイル, コード生成, 埋め込みDSL, 言語設計, Serde, diesel