In Progress
Unit 1, Lesson 1
In Progress

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