`with ... yield`を使おう

# はじめに

Crystalには`with ... yield`という構文があります。これはブロック呼び出しのコンテキストを`with`と`yield`の間の式を評価した結果のオブジェクトにして、ブロックを呼び出す構文です。

DSLを作る際に便利なのですが、あまり使われていないような印象があります。

今日Crystalに出したPull Requestで`with ... yield`を使ったので、その辺の話をします。

# 本編

## `with ... yield`って？

`with yield`というのは、ブロック呼び出しの際にブロック内のスコープを指定したオブジェクトに変える構文です。

```ruby
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
>
> https://github.com/crystal-lang/crystal/pull/7207

これはCrystalの標準ライブラリの`spec`の`it`や`pending`がネストしているときにコンパイルエラーにする、というPull Requestです。

例えばこのようなソースコードがあったとき、

```ruby
require "spec"

it "foo" do
  pending
end
```

こんな風にエラーになります。

```console
$ 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`モジュールを用意します。

```ruby
  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`としてブロックを呼び出します。

```ruby
  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日目でした。