Runtime extends with Ruby

Ruby is a dynamic language that supports many ways to organise logic. We can use class inheritance or/and compose our classes by including selected modules (mixins).

We can define or un-define methods on the fly. We can even use methods that aren’t really defined (using method_missing). Another powerful feature is the ability to extend an object with new methods at run-time, by including modules in the class or singleton class (if you want to extend only one instance).

To present this design pattern, lets assume that we want to create an application which will entertain our users — an RPG game, where the plot takes place in a fantasy world.

For simplicity we will model a character class. The player can pick one of six different races: dwarf, elf, gnome, hobbit, human or ogre. And they must chose their characters occupation from: priest, programmer, smith, thief, warrior or wizard.

Our base character class is going to have a public method: greeting, where the output will depend on a character’s race and occupation. To illustrate this, lets start with a test:

#spec/character_spec.rb

require 'spec_helper'
require 'character'

describe Character do
  describe '#greeting' do
    it 'works for ogre warrior' do
      ogre = Character.new(:race=>'ogre', :occupation=>'warrior')
      ogre.greeting.should == 'Grumph! I will kill you!'
    end

    it 'works for elven wizard' do
      elf = Character.new(:race=>'elf', :occupation=>'wizard')
      elf.greeting.should == 'Heil! Did you see my staff?'
    end

    it 'works for hobbit thief' do
      hobbit = Character.new(:race=>'hobbit', :occupation=>'thief')
      hobbit.greeting.should == "Good Morning. Haven't you lost something?"
    end

    it 'works for gnome programmer' do
      gnome = Character.new(:race=>'gnome', :occupation=>'programmer')
      gnome.greeting.should == 'Guten Tag.Do You Know Ruby?'
    end

    it 'works for dwarf smith' do
      dwarf = Character.new(:race=>'dwarf', :occupation=>'smith')
      dwarf.greeting.should == 'Humpf! Your sword needs to be fixed.'
    end

    it 'works for human priest' do
      human = Character.new(:race=>'human', :occupation=>'priest')
      human.greeting.should == 'Good Day. Only Chosen One knows his path!'
    end

    it 'works for human programmer' do
      human = Character.new(:race=>'human', :occupation=>'programmer')
      human.greeting.should == 'Good Day. Do you know Ruby?'
    end
  end
end

Character#greeting is composed of two parts. The first depends on how a given race performs a greeting. For example an ogre will say Grumph!. The second part is influenced by a character’s occupation, e.g. a programmer will ask about Ruby. By combining the above an ‘ogre programmer’ will greet you with: ‘Grumph! Do you know Ruby?’

With our tests in a failing state, lets think about some possible implementations.

The easiest way to make our tests pass is to create just one class – Character – composed of multiple if (or case) statements, each modifying the output of the greeting. However, it is likely that other attributes could be introduced in the future, resulting in complicated logic that would be difficult to maintain.

Another approach might separate the logic into classes, each inheriting from the Character class. This solution is nicely supported by Rails through Single Table Inheritance (STI). Going with this approach is good for one layer of separation. For instance, when we separate logic based on the character’s race, we can create classes corresponding to races such as: Dwarf, Elf, Gnome, Hobbit, Human, Ogre, but this is not our case. We want to separate logic by both race and occupation. This would lead to two layers and 36 classes like following: OgreProgrammer, OgrePriest, GnomeThief, HobbitWizard etc. And this number will grow when even more layers are added. We could end with thousands of classes like FemaleYoungWoodenElfArcher!

The solution I would like to present uses run-time extends with Ruby. We create a module for each race and occupation and some logic to glue things together.

Lets start with Character class:

#lib/character.rb

class Character
  include Character::Race
  include Character::Occupation

  def greeting
    "#{race_greeting} #{occupation_greeting}"
  end

  def race_greeting
    raise 'Not implemented'
  end

  def occupation_greeting
    raise 'Not implemented'
  end
end

The Character class implements greeting, which depends on two other methods: race_greeting and occupation_greeting. Those two methods are expected to be implemented in the modules included in lines: 4 and 5. Also those methods are defined in the Character class, but they raise an error to indicate that they should be defined elsewhere.

Lets continue with implementation of modules that were included in Character class, Race and Occupation:

#lib/character/race.rb

class Character
  module Race

    def initialize(options={})
      @race = options[:race]
      include_race
      super if defined? super
    end

    def race_module
      ActiveSupport::Inflector::constantize("Character::Race::#{@race.capitalize}")
    end

    private
    def include_race
      singleton_class = class << self;self;end;
      singleton_class.send(:include, race_module)
    end
  end
end


#lib/character/occupation.rb

class Character
  module Occupation

    def initialize(options={})
      @occupation = options[:occupation]
      include_occupation
      super if defined? super
    end

    def occupation_module
      ActiveSupport::Inflector::constantize("Character::Occupation::#{@occupation.capitalize}")
    end

    private
    def include_occupation
      singleton_class = class << self;self;end;
      singleton_class.send(:include, occupation_module)
    end
  end
end

Those two modules look similar and can be refactored, but we will examine that later. For now take a look at the Occupation module.

The initialize method sets an instance variable @occupation and then calls include_occupation, which includes the chosen occupation to the object’s singleton class (this means that this module is available only for this object, not for all Character’s objects). The occupation_module method returns the module to be included (using ActiveSupport’s constantize).

Finally the super call in the initialize method calls initialize in any other module/class through inheritance. This is important, because it calls not only the initialize method of Character class, but it also initialize defined in all modules, which had been included before the described one was included. It assures that both: initialize defined in the Race module and in the Occupation module are both called. The Race module works in the same way.

The last thing we need to implement are the modules for each race and occupation. Since they are quite similar, I’m only going to list two here:

#lib/character/race/ogre.rb

class Character
  module Race
    module Ogre
      def race_greeting
        'Grumph!'
      end
    end
  end
end


#lib/character/race/human.rb

class Character
  module Race
    module Human
      def race_greeting
        'Good Day.'
      end
    end
  end
end

#lib/character/occupation/programmer.rb

class Character
  module Occupation
    module Programmer
      def occupation_greeting
        'Do you know Ruby?'
      end
    end
  end
end


#lib/character/occupation/wizard.rb

class Character
  module Occupation
    module Wizard
      def occupation_greeting
        "Did you see my staff?"
      end
    end
  end
end

Implementing all required modules and requiring ActiveSupport in the Character class, makes our tests pass.

#lib/character.rb

require 'rubygems'
require 'active_support'

Changing existing objects at runtime

So far we’ve implemented a structure that allows us to set character’s race and occupation at object creation, using the new method. However, this doesn’t fulfil our need, we need to be able to change the existing character’s occupation and race (this is some kind of magic) at run-time. This can be easily achieved by improving our modules. Lets write some tests first:

#spec/character_spec.rb

describe Character do
  context 'attribute readers' do
    it 'are being set during initialization' do
      ogre = Character.new(:race=>'ogre', :occupation=>'warrior')
      ogre.race.should == 'ogre'
      ogre.occupation.should == 'warrior'
    end

    it 'are changeable' do
      character = Character.new(:race=>'ogre', :occupation=>'warrior')
      character.race = 'elf'
      character.occupation = 'smith'
      character.race.should == 'elf'
      character.occupation.should == 'smith'
    end
  end
  
  describe '#greeting' do
    #Some code removed for clarity
    it 'works for hobbit priest who was dwarf thief' do
      character = Character.new(:race=>'dwarf', :occupation=>'thief')
      character.race = 'hobbit'
      character.occupation = 'priest'
      character.greeting.should == 'Good Morning. Only Chosen One knows his path!'
    end
  end
end

To make this pass we need to add two methods to the Character and Occupation classes:

#lib/character.rb

class Character
  module Race

    def self.included(base)
      base.send(:attr_reader, :race)
    end

    def race=(value)
      @race = value
      include_race
    end

    #Rest of code removed for clarity.
  end
end

The first method is called when the module is included in another module or class (parent class/module is being held by variable base) (this is the Character class in our case). This method just sets an attribute reader for the race variable on the Character class.

The second method is an attribute writer, which assigns the value to an object’s instance variable, and then includes the appropriate race module.

Playing with it even more

To make things complicated, lets implement gnome’s speaking dialect for our characters. Gnomes are very smart and they have a lot to say, so their dialect should speak faster. Gnomes will omit the pauses in between words and use special accents to substitute that. They will say for example: HowAreYouDoing? instead of How are you doing?.

To depict our needs lets start with modification of character’s tests:

#spec/character_spec.rb

describe Character do
  describe '#greeting' do
    it 'works for gnome programmer' do
      gnome = Character.new(:race=>'gnome', :occupation=>'programmer')
      gnome.greeting.should == 'GutenTag.DoYouKnowRuby?'
    end
    #Rest of tests omited for clarity.
  end
end

To implement that we need to modify the Character class again:

#lib/character.rb
class Character
  include Character::Race
  include Character::Occupation

  def greeting
    if race_module.methods.include?(:race_modifier)
      race_module.race_modifier(clean_greeting)
    else
      clean_greeting
    end
  end

  def clean_greeting
    "#{race_greeting} #{occupation_greeting}"
  end
  #Rest of code omitted for clarity

So now, if race_modifier has been implemented in race_module then it should be used, otherwise an unmodified clean_greeting will be returned. Finally we implement this race_modifier in the Gnome module:

#lib/character/race/gnome.rb

class Character
  module Race
    module Gnome
    
      def race_greeting
        'Guten Tag.'
      end
      
      def self.race_modifier(value)
        value.split(' ').map(&:capitalize).join
      end
      
    end
  end
end

This simple example of speech modification for gnomes illustrates how easily and cleanly logic can be extended using this run-time extends design pattern.

Some refactoring

So far we have a lot of code duplication. The Race and Occupation modules look almost identical. We can address by creating a Common module to be included in a Race and Occupation class:

#lib/character/race.rb

class Character
  module Race
    include Common
  end
end

#lib/character/occupation.rb

class Character
  module Occupation
    include Common
  end
end

#lib/character/common.rb

module Common
  def self.included(base)
    base_name = base.name.split('::').last.downcase
    base.send(:attr_reader, base_name.to_sym)
    base.class_eval <<-EOS
      def initialize(options={})
        @#{base_name} = options[:#{base_name}]
        include_#{base_name}
        super if defined? super
      end

      def #{base_name}=(value)
        @#{base_name} = value
        include_#{base_name}
      end

      def #{base_name}_module
        ActiveSupport::Inflector::constantize("Character::#{base_name.capitalize}::\#{@#{base_name}.capitalize}")
      end

      private
        def include_#{base_name}
          singleton_class = class << self;self;end;
          singleton_class.send(:include, #{base_name}_module)
        end
    EOS
  end
end

This code uses the included hook to read the name of the including module and then, set a reader for the attribute (with that name) and define its methods (using class_eval).

Wrapping up

Complete code from this blog post can be found here: http://github.com/dawid-sklodowski/runtime-extends

comments powered by Disqus