Extracting Ghost Load
Video transcript & code
In the previous episode we created a macro that generates lazy-loaded attribute getters and setters for a class that implements the ghost-object pattern.
module RubyTapas
class Episode
attr_accessor :video,
:id,
:number,
:name,
:description,
:synopsis,
:video_url,
:publish_time,
:load_state,
:data_source
def initialize(attributes={})
attributes.each do |key, value|
public_send("#{key}=", value)
end
@load_state = :ghost
end
def to_s
inspect
end
def ==(other)
other.is_a?(Episode) && other.id == id
end
def published?(time_now=Time.now)
publish_time <= time_now
end
def load
return if load_state == :loaded
data_source.load(self)
end
def description
load
@description
end
end
end
It's likely that if we have one lazily-loaded class, we'll eventually want more of them. Now that we've defined a generic way to declare new lazy-loaded attributes, this code is a perfect candidate for extraction into a module of its own. Let's go ahead and do that.
We start by defining the new module. We'll call it Ghostly. We'll also include this module into the Episode
class.
Next we move the data_source
attribute into Ghostly
, followed by the load_state
attribute. We declare an attr_writer
for load_state
. The reader will need to be defined manually.
That's because currently the @load_state
instance variable starting value is set in the Episode
initializer. We can define an initializer
method in a module, but we can't depend on client classes actually calling that method. So instead, write a reader method which defaults the load state to :ghost
if it isn't already set.
Next we move the #load
method into Ghostly
.
All that's left is the lazy_accessor
macro. This part is a little trickier. When we include a module into a class, we only get the model's instance methods added to the class. Its class-level methods are left behind.
To get the lazy_accessor
class method into Ghostly
, we first define a inner module we call Macros
. Some folks might prefer to call this module ClassMethods
; I don't have very strong feelings about it. Then we move lazy_accessor
into this inner module.
Having given it a module of its own, we make lazy_accessor
an instance method instead of a class method. Now that it's an instance method, we take this opportunity to make it private. This way it can only be called as a macro within class definitions. It doesn't really make sense to call it from outside of a class.
Now we have to arrange for this inner module to be added to any classes that include Ghostly
. In order to make this happen, we define self.included
in the Ghostly
module. This module-level method name is special: Ruby treats it as a callback. Anytime the Ghostly
module is included into a class, Ruby will see that self.included
is defined, and it will call that method with the including class as an argument. This gives us an opportunity to make arbitrary modifications to the class which is including the Ghostly
module.
In this case, the modification we want to make is to tell the including class to also extend itself with the Macros
module. Extending is like including, except it happens at the singleton level. The practical upshot of this is that when the Episode
class includes the Ghostly
module, the methods in the module Macros
are added to Episode
as class methods, rather than as instance methods.
We grab the test code we used back in that episode and confirm that, indeed, the extraction works just like the original code. Our lazy-loaded attributes work as expected.
module RubyTapas
module Ghostly
module Macros
private
def lazy_accessor(*names)
names.each do |name|
attr_writer name
define_method(name) do
load
instance_variable_get("@#{name}")
end
end
end
end
def self.included(other)
other.extend(Macros)
end
attr_accessor :data_source
attr_writer :load_state
def load_state
@load_state ||= :ghost
end
def load
return if load_state == :loaded
data_source.load(self)
end
end
class Episode
include Ghostly
attr_accessor :video,
:id,
:number,
:name,
:video_url
lazy_accessor :description,
:synopsis,
:publish_time
def initialize(attributes={})
attributes.each do |key, value|
public_send("#{key}=", value)
end
end
def to_s
inspect
end
def ==(other)
other.is_a?(Episode) && other.id == id
end
def published?(time_now=Time.now)
publish_time <= time_now
end
end
end
class << (mapper = Object.new)
def load(episode)
episode.description = "Using macros to DRY up repetetive method definitions."
episode.synopsis = "Fun with metaprogramming"
episode.publish_time = Time.now
end
end
ep = RubyTapas::Episode.new(data_source: mapper)
ep.description # => "Using macros to DRY up repetetive method definitions."
ep.synopsis # => "Fun with metaprogramming"
ep.publish_time # => 2014-01-26 18:18:26 -0500
And that's all there is to it: we now have a reusable module we can mix in to other model classes in order to give them declarative lazy-loading capabilities. The steps we've just gone through are typical of the kind of extractions I often perform in my code. In particular, the use of the .include
module callback to also extend the including class is a useful idiom to know when we you want to add extra macro-style methods to a class.
And that's it for today. Happy hacking!
Responses