はじめに
Crystalにはwith ... yield
という構文があります。これはブロック呼び出しのコンテキストをwith
とyield
の間の式を評価した結果のオブジェクトにして、ブロックを呼び出す構文です。
DSLを作る際に便利なのですが、あまり使われていないような印象があります。
今日Crystalに出したPull Requestでwith ... yield
を使ったので、その辺の話をします。
本編
with ... yield
って?
with yield
というのは、ブロック呼び出しの際にブロック内のスコープを指定したオブジェクトに変える構文です。
class Foo
private def private_foo
42
end
end
def foo
with Foo.new yield
end
foo do
p private_foo # => 42
end
ここでFoo
のprivate_foo
が呼び出せていることに注目してください。
このようにwith ... yield
で呼び出されたブロックの中ではレシーバーが無いためプライベートメソッドが呼び出せます。
これを使うと、ブロック内でのみ有効なメソッドを作ることをができて、DSLを作るのに便利です。
with ... yield
をCrystalのコンパイラに出したPull Requestに使った話
ところで、今日こんなPull RequestをCrystalに出しました。
Spec: detect nesting
it
/pending
and raise compilation error
これはCrystalの標準ライブラリのspec
のit
やpending
がネストしているときにコンパイルエラーにする、というPull Requestです。
例えばこのようなソースコードがあったとき、
require "spec"
it "foo" do
pending
end
こんな風にエラーになります。
$ crystal run foo.cr
Error in foo.cr:3: instantiating 'it(String)'
it "foo" do
^~
in foo.cr:3: cannot nest 'pending' of spec
it "foo" do
^~
これは、with ... yield
を使って実現しています。
まず、このようなScope
モジュールを用意します。
module Scope
macro it(description = "assert", file = __FILE__, line = __LINE__, end_line = __END_LINE__, &block)
{{ raise("cannot nest 'it' of spec") }}
end
macro pending(description = "assert", file = __FILE__, line = __LINE__, end_line = __END_LINE__, &block)
{{ raise("cannot nest 'pending' of spec") }}
end
end
そして、元のit
とpending
メソッドの中でwith Scope yield
としてブロックを呼び出します。
def it(description = "assert", file = __FILE__, line = __LINE__, end_line = __END_LINE__, &block)
return unless Spec.matches?(description, file, line, end_line)
Spec.formatters.each(&.before_example(description))
start = Time.monotonic
begin
Spec.run_before_each_hooks
with Scope yield # ←ここ
Spec::RootContext.report(:success, description, file, line, Time.monotonic - start)
rescue ex : Spec::AssertionFailed
Spec::RootContext.report(:fail, description, file, line, Time.monotonic - start, ex)
Spec.abort! if Spec.fail_fast?
rescue ex
Spec::RootContext.report(:error, description, file, line, Time.monotonic - start, ex)
Spec.abort! if Spec.fail_fast?
ensure
Spec.run_after_each_hooks
end
end
# ...
def pending(description = "assert", file = __FILE__, line = __LINE__, end_line = __END_LINE__, &block)
return unless Spec.matches?(description, file, line, end_line)
# Run block on compile-time to detect nesting `it` and `pending`.
typeof(with Scope yield) # ←ここ
Spec.formatters.each(&.before_example(description))
Spec::RootContext.report(:pending, description, file, line)
end
ここで、pending
ではtypeof(with Scope yield)
としていて、コンパイルはされるけれど実行されていないことも重要です。
このように、with ... yield
はブロックの中でしか呼び出せないメソッドを表現することもできますが、一方ブロック内で呼び出してはいけないメソッド、というものも表現できます。
(ただ、この方法だとit
やpending
がブロック外にあった場合に検出できないので、実行時のチェックも必要かな、とか思っています)
まとめ
with ... yield
は便利なのでもっと使われればいいと思いますが、一方わりとバグっぽい挙動をすることもあるので本当に薦めていいのか悩むところもあります。
とりあえずみなさんCrystalやっていきましょう。
Crystal Advent Calendar 2018の20日目でした。