In a previous Ruby Magic, we figured out how to reliably inject modules into classes by overwriting its .new
method, allowing us to wrap methods with additional behavior.
This time, we're taking it one step further by extracting that behaviour into a module of its own so we can reuse it. We'll build a Wrappable
module that handles the class extension for us, and we'll learn all about class-level instance variables along the way. Let's dive right in!
Introducing the Wrappable
Module
In order to wrap objects with modules when they are initialized, we have to let the class know what wrapping models to use. Let’s start by creating a simple Wrappable
module that provides a wrap
method which pushes the given module into an array defined as a class attribute. Additionally, we redefine the new
method as discussed in the previous post.
module Wrappable
@@wrappers = []
def wrap(mod)
@@wrappers << mod
end
def new(*arguments, &block)
instance = allocate
@@wrappers.each { |mod| instance.singleton_class.include(mod) }
instance.send(:initialize, *arguments, &block)
instance
end
end
To add the new behavior to a class, we use extend
. The extend
method adds the given module to the class. The methods then become class methods. To add a module to wrap instances of this class with, we can now call the wrap
method.
module Logging
def make_noise
puts "Started making noise"
super
puts "Finished making noise"
end
end
class Bird
extend Wrappable
wrap Logging
def make_noise
puts "Chirp, chirp!"
end
end
Let’s give this a try by creating a new instance of Bird
and calling the make_noise
method.
bird = Bird.new
bird.make_noise
# Started making noise
# Chirp, chirp!
# Finished making noise
Great! It works as expected. However, things start to behave a bit strange once we extend a second class with the Wrappable
module.
module Powered
def make_noise
puts "Powering up"
super
puts "Shutting down"
end
end
class Machine
extend Wrappable
wrap Powered
def make_noise
puts "Buzzzzzz"
end
end
machine = Machine.new
machine.make_noise
# Powering up
# Started making noise
# Buzzzzzz
# Finished making noise
# Shutting down
bird = Bird.new
bird.make_noise
# Powering up
# Started making noise
# Chirp, chirp!
# Finished making noise
# Shutting down
Even though Machine
hasn't been wrapped with the Logging
module, it still outputs logging information. What’s worse - even the bird is now powering up and down. That can’t be right, can it?
The root of this problem lies in the way we are storing the modules. The class variable @@wrappables
is defined on the Wrappable
module and used whenever we add a new module, regardless of the class that wrap
is used in.
This get’s more obvious when looking at the class variables defined on the Wrappable
module and the Bird
and Machine
classes. While Wrappable
has a class method defined, the two classes don't.
Wrappable.class_variables # => [:@@wrappers]
Bird.class_variables # => []
Machine.class_variables # => []
To fix this, we have to modify the implementation so that it uses instance variables. However, these aren't variables on the instances of Bird
or Machine
, but instance variables on the classes themselves.
In Ruby, classes are just objects
This is definitely a bit mind boggling at first, but still a very important concept to understand. Classes are instances of
Class
and writingclass Bird; end
is equivalent to writingBird = Class.new
. To make things even more confusingClass
inherits fromModule
which inherits fromObject
. As a result, classes and modules have the same methods as any other object. Most of the methods we use on classes (like theattr_accessor
macro) are actually instance methods ofModule
.
Using Instance Variables on Classes
Let’s change the Wrappable
implementation to use instance variables. To keep things a bit cleaner, we introduce a wrappers
method that either sets up the array or returns the existing one when the instance variable already exists. We also modify the wrap
and new
methods so that they utilize that new method.
module Wrappable
def wrap(mod)
wrappers << mod
end
def wrappers
@wrappers ||= []
end
def new(*arguments, &block)
instance = allocate
wrappers.each { |mod| instance.singleton_class.include(mod) }
instance.send(:initialize, *arguments, &block)
instance
end
end
When we check the instance variables on the module and on the two classes, we can see that both Bird
and Machine
now maintain their own collection of wrapping modules.
Wrappable.instance_variables #=> []
Bird.instance_variables #=> [:@wrappers]
Machine.instance_variables #=> [:@wrappers]
Not surprisingly, this also fixes the problem we observed earlier - now, both classes are wrapped with their own individual modules.
bird = Bird.new
bird.make_noise
# Started making noise
# Chirp, chirp!
# Finished making noise
machine = Machine.new
machine.make_noise
# Powering up
# Buzzzzzz
# Shutting down
Supporting Inheritance
This all works great until inheritance is introduced. We would expect that classes would inherit the wrapping modules from the superclass. Let’s check if that's the case.
module Flying
def make_noise
super
puts "Is flying away"
end
end
class Pigeon < Bird
wrap Flying
def make_noise
puts "Coo!"
end
end
pigeon = Pigeon.new
pigeon.make_noise
# Coo!
# Is flying away
As you can see, it doesn’t work as expected, because Pigeon
is also maintaining its own collection of wrapping modules. While it makes sense that wrapping modules defined for Pigeon
aren’t defined on Bird
, it’s not exactly what we want. Let’s figure out a way to get all wrappers from the entire inheritance chain.
Lucky for us, Ruby provides the Module#ancestors
method to list all the classes and modules a class (or module) inherits from.
Pigeon.ancestors # => [Pigeon, Bird, Object, Kernel, BasicObject]
By adding a grep
call, we can pick the ones that are actually extended with Wrappable
. As we want to wrap the instances with wrappers from higher up the chain first, we call .reverse
to flip the order.
Pigeon.ancestors.grep(Wrappable).reverse # => [Bird, Pigeon]
Ruby’s
#===
methodSome of Ruby’s magic comes down to the
#===
(or case equality) method. By default, it behaves just like the#==
(or equality) method. However, several classes override the#===
method to provide different behavior incase
statements. This is how you can use regular expressions (#===
is equivalent to#match?
), or classes (#===
is equivalent to#kind_of?
) in those statements. Methods likeEnumerable#grep
,Enumerable#all?
, orEnumerable#any?
also rely on the case equality method.
Now we can call flat_map(&:wrappers)
to get a list of all wrappers defined in the inheritance chain as a single array.
Pigeon.ancestors.grep(Wrappable).reverse.flat_map(&:wrappers) # => [Logging]
All that's left is packing that into an inherited_wrappers
module and slightly modifying the new method so that it uses that instead of the wrappers
method.
module Wrappable
def inherited_wrappers
ancestors
.grep(Wrappable)
.reverse
.flat_map(&:wrappers)
end
def new(*arguments, &block)
instance = allocate
inherited_wrappers.each { |mod|instance.singleton_class.include(mod) }
instance.send(:initialize, *arguments, &block)
instance
end
end
A final test run confirms that everything is now working as expected. The wrapping modules are only applied to the class (and its subclasses) they are applied on.
bird = Bird.new
bird.make_noise
# Started making noise
# Chirp, chirp!
# Finished making noise
machine = Machine.new
machine.make_noise
# Powering up
# Buzzzzz
# Shutting down
pigeon = Pigeon.new
pigeon.make_noise
# Started making noise
# Coo!
# Finished making noise
# Is flying away
That's a wrap!
Admittedly, these noisy birds are a bit of a theoretic example (tweet, tweet). But inheritable class instance variables are not just cool to understand how classes work. They are a great example that classes are just objects in Ruby.
And we'll admit that inheritable class instance variables might even be quite useful in real life. For example, think about defining attributes and relationships on a model with the ability to introspect them later. For us the magic is to play around with this and get a better understanding of how things work. And open your mind for a next level of solutions. 🧙🏼♀️
As always, we’re looking forward to hearing what you build using this or similar patterns. Any ideas? Please leave a comment!.
Benedikt Deicke is a software engineer and CTO of Userlist.io. On the side, he’s writing a book about building SaaS applications in Ruby on Rails. You can reach out to Benedikt via Twitter.
Top comments (0)