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:
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:
The Character class implements
greeting, which depends on two other methods:
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:
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:
Implementing all required modules and requiring ActiveSupport in the Character class, makes our tests pass.
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:
To make this pass we need to add two methods to the Character and Occupation classes:
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:
To implement that we need to modify the Character class again:
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:
This simple example of speech modification for gnomes illustrates how easily and cleanly logic can be extended using this run-time extends design pattern.
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:
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).
Complete code from this blog post can be found here: http://github.com/dawid-sklodowski/runtime-extends