DSL(domain-specific language)が何なのかを正確に説明しようとすると難しいが、特定のタスクをうまく記述することを目的としたプログラミング言語だとされている。今回は Ruby 界隈で(主に Ruby on Rails 界隈か?)特に有名なテストフレームワーク RSpec を例にして、DSL がどう実装されているかを紹介する。
RSpec での例
RSpec はこんな感じにテストを記述していくことができるテストフレームワークだ。
it do
val = 'example'
expect(val).to eq 'example'
end
なんか Ruby っぽくない感じもする。というか実際、ある意味で特殊な書き方ができるようにわざわざ設計したのが DSL なわけなので、そう見えるのは当然でもある。
DSL の実装例
さて、本記事ではこういった DSL がどうやって実装されているかを理解するために、実際に RSpec のような DSL を実装していく。DSL は、まず「こう書きたい」というものを前提にし、そこから「そう書けるようにするためにはどう作れば良いか?」を逆算してコードを書いていくと良い。今回は RSpec を参考にして、以下のように書ける DSL を自分で作ってみよう。
DSLExample.it do
val = 'example'
expect(val).to eq 'example'
end
ここでは簡単に、expect
に渡したテスト結果 val
が、 eq
の期待値と一致すれば OK!
を、違えば NG!
を表示するということにする。
肝となるのはこの部分だ。
expect(val).to eq 'example'
ふつうの Ruby コードっぽくない見た目だが、 RSpec は(そして RSpec を参考にした本例の DSL は) internal DSL であり、これ自身 Ruby のコードである。Ruby にとってこのコードがどう解釈されるか、分かりやすくするために括弧を付与するとこうなる。
expect(val).to(eq('example'))
ここから「こう書けるように」逆算して実装を行っていく。まずこの文はいくつかの構成要素に分けられる。
-
expect
:この実行文脈でのメソッド -
val
:前行で初期化されたローカル変数 -
to
:expect(val)
が返したオブジェクトのメソッド -
eq
:この実行文脈でのメソッド -
'example'
:文字列リテラル
このうち val
と 'example'
は特に難しいことはない。 to
は今すぐ考えるのは難しそうだ。最初に手を付けるのは expect
と eq
である。これらはこの文脈において呼び出し可能なメソッドでなければならない。なので、まずこの文脈自体を作っていこう。
ではこの文脈は何だったかというと、DSLExample
クラスの it
メソッドに渡されたブロックである。
DSLExample.it do
...
end
素直にそれを実装しよう。
class DSLExample
class << self
def it(&block)
# TODO: implement
end
end
end
さて、ここで引数として受け取ったブロックは、 expect
や eq
メソッドが呼び出せる文脈でなければならない。そうするためには、それらメソッドを持つオブジェクトの instance_exec
を呼び出すのが常套手段だ。
class ItContext
def expect(val)
# TODO: implement
end
def eq(val)
# TODO: implement
end
end
class DSLExample
class << self
def it(&block)
ItContext.new.instance_exec(&block)
end
end
end
これで it
に渡したブロック内で expect
と eq
が呼び出せるようになった。
次は expect
を考える。このメソッドはなんらかのテスト結果を受け取り、後々その値を期待値と比較するために保持しておく必要がある。また、このメソッドが返すオブジェクトは to
メソッドを持っていなければならない。
class Expect
def initialize(val)
@val = val
end
def to(matcher)
# TODO: implement
end
end
class ItContext
def expect(val)
Expect.new(val)
end
end
eq
はどうだろう?これはテストの期待値を保持し、比較方法(ここでは eq
すなわち ==
とする)を知っているオブジェクトを返すべきだ。比較方法を尋ねるには validate
というメソッドを使うことにしよう。
class EqMatcher
def initialize(val)
@val = val
end
def validate(other)
if @val == other
puts "OK!"
else
puts "NG!"
end
end
end
class ItContext
def eq(val)
EqMatcher.new(val)
end
end
もう少しだ。eq
が返した EqMatcher
オブジェクトはどこへ渡されるかというと、 to
メソッドである。最後の実装をしよう。 to
メソッドは渡されたオブジェクトにテスト結果を渡して、 OK なのか NG なのかを判断してもらえばよい。to
メソッドを持っている Expect
オブジェクトはテスト結果も持っているんだったよね?
class Expect
def initialize(val)
@val = val
end
def to(matcher)
matcher.validate(@val)
end
end
これで完成だ。
使ってみる
では使ってみよう。
DSLExample.it do
val = 'example'
expect(val).to eq 'example'
end
DSLExample.it do
val = 'example'
expect(val).to eq "foobar"
end
OK!
NG!
できた!
拡張
ここまでできれば、 expect
に値ではなくブロックを渡せるようにしたり、一致以外の条件でテストを行う新たな Matcher
を追加したりということも、どうすればいいかなんとなく想像がつくのではないだろうか。この例を元に実装してみるのも面白いかもしれない。
完成コード全体
class DSLExample
class << self
def it(&block)
ItContext.new.instance_exec(&block)
end
end
end
class ItContext
def expect(val)
Expect.new(val)
end
def eq(val)
EqMatcher.new(val)
end
end
class Expect
def initialize(val)
@val = val
end
def to(matcher)
matcher.validate(@val)
end
end
class EqMatcher
def initialize(val)
@val = val
end
def validate(other)
if @val == other
puts "OK!"
else
puts "NG!"
end
end
end
DSLExample.it do
val = 'example'
expect(val).to eq 'example'
end
DSLExample.it do
val = 'example'
expect(val).to eq "foobar"
end
まとめ
RSpec を参考に、簡易的な DSL の実装例を紹介した。RSpec に限らず、Ruby で実装された DSL は instance_exec
でその言語の文脈に入り、メソッドとして言語機能を提供し、それらメソッドはそこで必要な情報や、そこから続けて提供したい言語機能を提供するオブジェクトを返す、といった形で実装されていることが殆どだ。自分で DSL 自体を作ることはないとしても、こういったことを把握しておくと、既存 DSL の拡張を作りたくなったり、あるいはトラブルシューティングをしなければならなくなったりといったときに役に立つ。見た目に騙されがちだが(ある意味で騙したいのが DSL というものでもある)、結局は Ruby コードそのものなので、注意深く読めばどうなっているのか分かるはずだ。