Macro
Video transcript & code
In episode 180, I introduced the concept of lazy loading with "ghost objects". In the Episode
class, it's possible that not all the data about an episode will be loaded at first. One such attribute is the #description
. I've overridden the getter method for #description
to first attempt to fully load the episode before returning the episode description text.
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
description
isn't the only field that needs to be lazy loaded, though. Some others that may need to be lazily loaded are the synopsis
and publish_time
fields. And there may be more in the future. Let's see about adding lazy accessors for these other attributes.
We could simply copy and paste the work I did for description
. But this is tedious, and error-prone, and it creates duplication which may bite us if we ever have to change the pattern for lazy loading. Instead, let's make a macro for this.
Of course, Ruby doesn't have actual macros, but we tend to use the term "macro" for any method that gets called at class or module load time, and which generates methods, classes, or modules for us. In this case, we're going to be generating methods.
We'll call our macro #lazy_accessor
. Just like Ruby's built in attr_accessor
macro, we'll enable it to receive an arbitrary number of attribute names as arguments. For each name, it will first declare a writer method using the standard Ruby attr_writer
macro. Then it will declare a custom reader method by calling define_method
. The method name is the same as the attribute name. define_method
takes a block which will become the body of the new method. Our reader method takes no arguments. It calls #load
to force the object to be fully loaded before continuing. Then it returns the hopefully loaded value of the corresponding instance variable using instance_variable_get
.
Now we can remove the attr_accessor
definitions for our lazy-loaded attributes and instead put them after a call to lazy_accessor
. We can also remove our hand-coded definition of the description
reader method.
module RubyTapas
class Episode
def self.lazy_accessor(*names)
names.each do |name|
attr_writer name
define_method(name) do
load
instance_variable_get("@#{name}")
end
end
end
attr_accessor :video,
:id,
:number,
:name,
:video_url,
:load_state,
:data_source
lazy_accessor :description,
:synopsis,
:publish_time
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
end
end
Let's assure ourselves that this works. We define a tiny fake data source which will fill in an episode with some hardcoded values.
Then we create an Episode
and interrogate it for its lazy-loaded attributes. When we execute this code we can see that, first, the methods exist, and second, that they reflect the lazily-loaded values.
require "./episode.rb"
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:06:09 -0500
I haven't mentioned the word "metaprogramming" up until this point, but that's exactly what we've been doing. One of the questions that has come up a few times in RubyTapas episode comments is "when to metaprogram". The code we just wrote demonstrates one answer to that question, which is: use metaprogramming when it can eliminate duplication in ways other techniques can't.
I use the technique shown here pretty often. Anytime I find myself writing the same type of method over and over again in a particular class, I'll often write a little macro to generate those methods instead. If it reduces duplication and it's still clear from the name what will be generated by the macro calls, I think it's worth the added complexity of metaprogramming. In this case, the term lazy_accessor
pretty clearly and declaratively expresses what kind of methods will be generated.
In the next episode, we'll look at how to extract this functionality out so it can be shared with other classes. Until then, happy hacking!
Responses