RSpecのletって何やってるの

遅延評価だのメモ化だのの説明はたくさん出てくるんですが、なんだかあんまり実感を持てなかったので、もうちょっと深くまで覗いてみました。

三行
=
 - （コンテキストのイメージ的には）`describe`はクラス、`it`はそのインスタンス(のコンテキスト)
 - letは定義されたスコープのクラス(describe)に対して変数名の`define_method`をしている(正確ではない)
 - 参照時は初回はletに渡したブロック(から作られたメソッド)を評価した値をitごとに持つインスタンス変数のハッシュに登録、二回目以降はそのハッシュからkeyでfetchする、という動作

解説
=

RSpecの実装については[大変参考になるエントリ](http://qiita.com/joker1007/items/641961963ff459125f31#describe%E3%81%AE%E5%86%85%E5%81%B4%E3%81%AF%E5%8D%98%E3%81%AA%E3%82%8B%E3%82%AF%E3%83%A9%E3%82%B9%E5%AE%9A%E7%BE%A9)があるので、letあたりのところまではここを参照していただければと思います。関連するところだけ簡単に説明すると、各`describe`は`ExampleGroup`というクラスの子クラスとして生成され、入れ子になってる際はそのまた子クラス……という風になっている、そして`it`は各スコープのクラスのインタンス（コンテキスト）で実行される、ということを認識していれば良いと思います。例えば、

```.rb
describe "Hoge" do
  it do
  end
  context "Fuga"
    it do
    end
  end 
end
```

にようなコードからは、`RSpec::ExampleGroup::Hoge`、`RSpec::ExampleGroup::Hoge::Fuga`というクラスが生成され、各`it`はそのスコープのクラスのインスタンスコンテキストになってる、ということです。

さて、上記のエントリでは軽くしか触れられていない`MemoizedHelpers`モジュールについて、もう少し詳しくみてみようと思います。
rspec/core/example_group.rbの

```.rb
def self.subclass(parent, description, args, &example_group_block)
  subclass = Class.new(parent)
  subclass.set_it_up(description, *args, &example_group_block)
  ExampleGroups.assign_const(subclass)
  subclass.module_exec(&example_group_block) if example_group_block

  # The LetDefinitions module must be included _after_ other modules
  # to ensure that it takes precedence when there are name collisions.
  # Thus, we delay including it until after the example group block
  # has been eval'd.
  MemoizedHelpers.define_helpers_on(subclass)

  subclass
end
```
で触れられているやつですね。これについては最後の方に出てきます。

さて、実は`MemoizedHelpers`モジュール自体はすでに`include``extend`されてます。
rspec/core/example_group.rb

```.rb
  include MemoizedHelpers
  extend MemoizedHelpers::ClassMethods
```

このモジュールがletを解釈します。コメントを抜いて実装だけ取り出してみると、

```.rb
        def let(name, &block)
          raise "#let or #subject called without a block" if block.nil?
          MemoizedHelpers.module_for(self).__send__(:define_method, name, &block)
          if block.arity == 1
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(RSpec.current_example, &nil) } }
          else
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(&nil) } }
          end
        end
```
となっています。

ブロックがnilだった場合の例外はわかるとして、

```.rb
MemoizedHelpers.module_for(self).__send__(:define_method, name, &block)
```

部分と

```.rb
          if block.arity == 1
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(RSpec.current_example, &nil) } }
          else
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(&nil) } }
          end
        end
```
部分にわけて考えます。

`module_for`はこうなっています。

```.rb
(@rspec/core/memoized_helpers.rb)
def self.module_for(example_group)
  get_constant_or_yield(example_group, :LetDefinitions) do
    mod = Module.new do
      include Module.new {
        example_group.const_set(:NamedSubjectPreventSuper, self)
      }
    end
    example_group.const_set(:LetDefinitions, mod)
    mod
  end
end
```

`constant_or_yield`は`LetDefinitions`定数が定義されていない場合には以下のブロックを実行する(定義されている時はその定数の値を返す)、というメソッドで、初回(つまり、そのブロックで初めてletが読み込まれるタイミング)では続くblockが実行されます。`NamedSubjectPreventSuper`定数は`subject`に関係する要素なので、今は置いておきましょう。大事なのは、

```.rb
example_group.const_set(:LetDefinitions, mod)
mod
```
の部分で、要するにexample_group、つまりdescribeで形容されるクラスの`:LetDefinitions`定数になにやらモジュールがひも付けられた、そしてこのメソッドはそのひも付けられたmoduleを返している、ということが重要です。そして呼び出した場所に戻ると、

```.rb
MemoizedHelpers.module_for(self).__send__(:define_method, name, &block)
```
という風に呼び出しているんでしたね。`name, &block`にはletに渡した二つの引数、すなわち名前とブロックが入っています。つまり、`LetDefiniaiton`定数に紐付けられたモジュールに対して、ここで(letに渡された)ブロックがメソッドとしてdefineされているんですね。しかしまだこのモジュールはincludeされたわけではないのでこの時点ではまだここで生やされたメソッドたちは利用できません。

そのincludeをしているのが最初に貼った`ExampleGroup`の`subclass`メソッドで呼び出している、`MemoizedHelpers.define_helpers_on(subclass)`部分です。

再掲（コメント省略）

```.rb
      def self.subclass(parent, description, args, &example_group_block)
        subclass = Class.new(parent)
        subclass.set_it_up(description, *args, &example_group_block)
        ExampleGroups.assign_const(subclass)
        subclass.module_exec(&example_group_block) if example_group_block
        MemoizedHelpers.define_helpers_on(subclass) #ここ
        subclass
      end
```
helpers_onメソッドのところを見てみると、

```.rb
      def self.define_helpers_on(example_group)
        example_group.__send__(:include, module_for(example_group))
      end
```
となっています。`module_for`メソッドは先程出てきましたね。今呼ぶとすでにメソッドが生やされている`LetDefinitions`定数に紐付けられたモジュールが返ってきます。これで、letに渡されたブロックが`ExampleGroup`の子クラスにincludeされました。

さて、それではさっき二つに分けた部分の後半です。

```.rb
#(@rspec/core/memoized_helpers.rb letメソッド)
          if block.arity == 1
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(RSpec.current_example, &nil) } }
          else
            define_method(name) { __memoized.fetch(name) { |k| __memoized[k] = super(&nil) } }
          end
        end
```
ここの部分はメモ化を担当しています。つまり、この部分をコメントアウトしてもspecは普通に動作します。ただし、同じ変数を利用しているはずでも毎回ブロックが表されてしまうので、例えば、

```.rb
  let(:user) { FactoryGirl.create(:user) }
  it {expect(user).to eq user}
```
は落ちます。expect(user)の中のuserと eqの後のuserは違うインスタンスを指してしまうからです(FactoryGirl.createがそれぞれに対して実行されてしまうため)。
親切にコメントが書いてあって、

```
          # Apply the memoization. The method has been defined in an ancestor
          # module so we can use `super` here to get the value.
```
とあります。
`__memoized`というのはプライベートインスタンス変数`@__memoized`のgetterで、ここに一度呼ばれた値は登録、呼ばれてなければdefine_methodされた実装を探して先祖を遡っていくわけです。ここが呼ばれる段階で(つまり`fetch(name){}`ブロックの中で)`self.class`に`ancestors`してみると、

```
=> [RSpec::ExampleGroups::Hoge::Fuga,
 RSpec::ExampleGroups::Hoge::Fuga::LetDefinitions,
 RSpec::ExampleGroups::Hoge::Fuga::NamedSubjectPreventSuper,
 RSpec::ExampleGroups::Hoge,
 RSpec::ExampleGroups::Hoge::LetDefinitions,
 ……
```
という風になっています。 Fugaレベルのスコープのlet宣言であれば、`RSpec::ExampleGroups::Hoge::Fuga::LetDefinitions`に、Hogeレベルのスコープのlet宣言であれば` RSpec::ExampleGroups::Hoge::LetDefinitions`からdefine_methodされてるメソッドが呼ばれるようになってるわけですね。

`describe`スコープで定義された（正しくは宣言された）let変数がitごとに再評価される、というのも(書き方の)直感には反するように思っていましたが、実装を読むことで納得できました。ざっくばらんに言えば、example_groupインスタンスであるitコンテキストにそれぞれにインスタンス変数としてメモ化用テーブルハッシュが定義されている、という実装になってるからだったんですね。

[itを分割することに対するオーバーヘッドと、テスト結果のわかりやすさのトレードオフ](http://qiita.com/sora_h/items/4538e02fee2c14514834)については諸説ありますが、例えば巨大なobjectは素直にインスタンス変数として定義してしまうなどすると、それは各子itについて継承されるので、itの数を変えないままcreate実行数は一度に抑えられます。ただDatabaseCleaner等のDB一掃gemを仕様していると、対象のobjectは良いのですがその関連先(belongs_toやhas_many先のDB recordですね)が消えてしまうので、あまり現実的な案ではないかもしれません。Cleanするタイミングを`after(:each)`から`after(:context)`などに変えるなど考えられはしますが、あまり良いプラクティスとは言えなさそうです……難しいですね。