PHP勉強会でうまく説明できなかったので、ちゃんとした説明を。PHPが対象ですが、たぶんほかの言語でも応用できる話です。
メソッドチェーンで言語内DSLを作るとき、ブロック状の構文のようなものが必要になることがあります。XML_BuilderではXMLのツリー状の構文を再現するため、この構文を多用しています。
<?php
XML_Builder::factory()
->root()
->child1()
->child2()
->child3_()
->_()
->_()
->_()
->_echo();
わかりやすいようインデントしましたが、単にthisを返し続けるメソッドチェーンならば意味的にブロックはないわけで、こんな構文を作るのはつらいものがあります。
XML_Builderでも悩みまして、いろいろ考えた結果、コンテキストをオブジェクトとして実装しました。詳しく説明していきます。
サンプルのDSL
XML_BuilderのDOMバックエンドのコードを題材にしてもいいのですが、ちょっと余計なものが多いので、もっと単純なものを考えます。
多段に重ねられるif文と、引数を出力するだけのsay文をメソッドチェーンDSLで作ってみましょう。else句も考慮するとややこしいので、elseはありません。
<?php
dsl()
->if (1)
->if (1)
->say("1 && 1")
->endif()
->if (0)
->say("1 && 0")
->endif()
->endif()
->if (0)
->if (1)
->say("0 && 1")
->endif()
->if (0)
->say("0 && 0")
->endif()
->endif()
;
//期待値は「1 && 1」のみが出力されること
まあ、DSL上でif文を再現しても役には立ちませんが…、説明の題材としてはこの程度がいいでしょう。
PHPはメソッド呼び出しに予約語がありませんので、「if」や「endif」そのものをメソッド名に設定することも可能です。(__callで拾うように少し工夫は必要です)
言語的なif文はジャンプすることで処理を飛ばしますが、メソッドチェーンではジャンプできませんので、必要ない処理は無視してやるよう実装する必要があります。
if()は、それ自身に渡されたbool値だけでなく、親ブロック全ての真偽値まで考慮しなくてはなりません(自身がtrueでも、親ブロックにfalseがあればfalse)。ちょっとややこしいですね。
コンテキストオブジェクト
こんなとき、メソッドが返すオブジェクトを文脈、「コンテキスト」と見なし、適宜新しいオブジェクトを返すようにすれば非常にすっきりと実装できます。
今回の例だと、if()とendif()はコンテキストを変更する文ですので、if()で常に新しいコンテキスト(new Context())を返すようにし、endif()で現在のコンテキストを廃棄し、親のコンテキストに戻るようにします。便宜的に名前を付けて解説すると、下記のようなイメージになります。
dsl()
->if (1) //Context1を返す
->if (1) //Context2を返す
->say('...') //return $thisするだけ
->endif() //Context2を廃棄し、Context1を返す
->if (0) //Context3を返す
->say('...') //return $thisするだけ
->endif() //Context3を廃棄し、Context1を返す
->endif();
コンテキストオブジェクトの実装もいくつかやり方があると思いますが、せっかくオブジェクト指向言語を使っていますので、sayを実行するTrueContextと、何も実行しないFalseContextを別のクラスとして実装してみることにします。まず、どちらでも使うメソッドをまとめてabstract classを作ります。
<?php
abstract class Context {
protected $parent = null;
function __construct($parent=null) {
$this->parent = $parent;
}
function __call($method, $args) {
switch ($method) {
case 'if':
return $this->_if($args[0]);
case 'endif':
return $this->_endif();
default:
throw new Exception;
}
}
abstract function _if($bool);
abstract function say($str);
function _endif() {
return $this->parent;
}
}
newで親コンテキストのリファレンスを保存しておき、endif()で親コンテキストを返すようになっています。
あとはこれを継承し、_ifメソッドとsayメソッドを実装するだけです。
まずTrueContextの方です。こちらはsayを実行するようにし、ifは渡された条件により返すオブジェクトを変えるようにします。
class TrueContext extends Context {
function _if($bool) {
if ($bool) {
return new self($this);
} else {
return new FalseContext($this);
}
}
function say($str) {
echo $str,PHP_EOL;
return $this;
}
}
次にFalseContext。こちらはsayは何も実行しなくてよいので、単にreturn $thisするだけにします。また、すでにfalseで実行されないことがわかりきっているので、if文は常にFalseContextをnewして返すようにします。
class FalseContext extends Context {
function _if($bool) {
return new self($this);
}
function say($str) {
return $this;
}
}
falseの方が単純ですね。
これでDSLは完成ですが、PHPはnew Klass()した直後からメソッドチェーンをつなげることができません(PHP5.4以降なら可能)。 そこで、グローバル関数を用意してメソッドを即座につなげられるようにして、書きやすくしておきます。最初に返すのはTrueContextです。
function dsl() {
return new TrueContext;
}
実装は以上です。最後に全部つなげたものを置いておきますが、ちゃんと動作するはずです。
まとめ
メソッドチェーンによるDSLでは、処理を上から順にシーケンシャルに行えるほか、予約語の縛りがないという特徴があります。PHPのようにクロージャの利用に癖のある言語では、メソッドチェーンをうまく活用することで、DSLを作りやすくなることでしょう。
言語のフル機能が使えて、パーサーを実装することなく簡単に「ぼくがかんがえたさいきょうのプログラミング言語」が作れるDSLは、作っててとても楽しいので、もっと活用するといいんじゃないでしょうか。
今回作ったDSLまとめ
<?php
abstract class Context {
protected $parent = null;
function __construct($parent=null) {
$this->parent = $parent;
}
function __call($method, $args) {
switch ($method) {
case 'if':
return $this->_if($args[0]);
case 'endif':
return $this->_endif();
default:
throw new Exception;
}
}
abstract function _if($bool);
abstract function say($str);
function _endif() {
return $this->parent;
}
}
class TrueContext extends Context {
function _if($bool) {
if ($bool) {
return new self($this);
} else {
return new FalseContext($this);
}
}
function say($str) {
echo $str,PHP_EOL;
return $this;
}
}
class FalseContext extends Context {
function _if($bool) {
return new self($this);
}
function say($str) {
return $this;
}
}
function dsl() {
return new TrueContext();
}
// 動作サンプル
dsl()
->if (1)
->if (1)
->say('1 && 1')
->endif()
->if (0)
->say('1 && 0')
->endif()
->endif()
->if (0)
->if (1)
->say('0 && 1')
->endif()
->if (0)
->say('0 && 0')
->endif()
->endif();
keyword: PHP