遅延評価だのメモ化だのの説明はたくさん出てくるんですが、なんだかあんまり実感を持てなかったので、もうちょっと深くまで覗いてみました。
三行
- (コンテキストのイメージ的には)
describe
はクラス、it
はそのインスタンス(のコンテキスト) - letは定義されたスコープのクラス(describe)に対して変数名の
define_method
をしている(正確ではない) - 参照時は初回はletに渡したブロック(から作られたメソッド)を評価した値をitごとに持つインスタンス変数のハッシュに登録、二回目以降はそのハッシュからkeyでfetchする、という動作
解説
RSpecの実装については大変参考になるエントリがあるので、letあたりのところまではここを参照していただければと思います。関連するところだけ簡単に説明すると、各describe
はExampleGroup
というクラスの子クラスとして生成され、入れ子になってる際はそのまた子クラス……という風になっている、そしてit
は各スコープのクラスのインタンス(コンテキスト)で実行される、ということを認識していれば良いと思います。例えば、
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の
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
include MemoizedHelpers
extend MemoizedHelpers::ClassMethods
このモジュールがletを解釈します。コメントを抜いて実装だけ取り出してみると、
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だった場合の例外はわかるとして、
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
部分にわけて考えます。
module_for
はこうなっています。
(@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
に関係する要素なので、今は置いておきましょう。大事なのは、
example_group.const_set(:LetDefinitions, mod)
mod
の部分で、要するにexample_group、つまりdescribeで形容されるクラスの:LetDefinitions
定数になにやらモジュールがひも付けられた、そしてこのメソッドはそのひも付けられたmoduleを返している、ということが重要です。そして呼び出した場所に戻ると、
MemoizedHelpers.module_for(self).__send__(:define_method, name, &block)
という風に呼び出しているんでしたね。name, &block
にはletに渡した二つの引数、すなわち名前とブロックが入っています。つまり、LetDefiniaiton
定数に紐付けられたモジュールに対して、ここで(letに渡された)ブロックがメソッドとしてdefineされているんですね。しかしまだこのモジュールはincludeされたわけではないのでこの時点ではまだここで生やされたメソッドたちは利用できません。
そのincludeをしているのが最初に貼ったExampleGroup
のsubclass
メソッドで呼び出している、MemoizedHelpers.define_helpers_on(subclass)
部分です。
再掲(コメント省略)
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メソッドのところを見てみると、
def self.define_helpers_on(example_group)
example_group.__send__(:include, module_for(example_group))
end
となっています。module_for
メソッドは先程出てきましたね。今呼ぶとすでにメソッドが生やされているLetDefinitions
定数に紐付けられたモジュールが返ってきます。これで、letに渡されたブロックがExampleGroup
の子クラスにincludeされました。
さて、それではさっき二つに分けた部分の後半です。
#(@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は普通に動作します。ただし、同じ変数を利用しているはずでも毎回ブロックが表されてしまうので、例えば、
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を分割することに対するオーバーヘッドと、テスト結果のわかりやすさのトレードオフについては諸説ありますが、例えば巨大なobjectは素直にインスタンス変数として定義してしまうなどすると、それは各子itについて継承されるので、itの数を変えないままcreate実行数は一度に抑えられます。ただDatabaseCleaner等のDB一掃gemを仕様していると、対象のobjectは良いのですがその関連先(belongs_toやhas_many先のDB recordですね)が消えてしまうので、あまり現実的な案ではないかもしれません。Cleanするタイミングをafter(:each)
からafter(:context)
などに変えるなど考えられはしますが、あまり良いプラクティスとは言えなさそうです……難しいですね。