ruby

The Magic of Class-level Instance Variables

Benedikt Deicke

Benedikt Deicke on

The Magic of Class-level Instance Variables

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.

1module Wrappable
2  @@wrappers = []
3
4  def wrap(mod)
5    @@wrappers << mod
6  end
7
8  def new(*arguments, &block)
9    instance = allocate
10    @@wrappers.each { |mod| instance.singleton_class.include(mod) }
11    instance.send(:initialize, *arguments, &block)
12    instance
13  end
14end

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.

1module Logging
2  def make_noise
3    puts "Started making noise"
4    super
5    puts "Finished making noise"
6  end
7end
8
9class Bird
10  extend Wrappable
11
12  wrap Logging
13
14  def make_noise
15    puts "Chirp, chirp!"
16  end
17end

Let’s give this a try by creating a new instance of Bird and calling the make_noise method.

1bird = Bird.new
2bird.make_noise
3# Started making noise
4# Chirp, chirp!
5# 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.

1module Powered
2  def make_noise
3    puts "Powering up"
4    super
5    puts "Shutting down"
6  end
7end
8
9class Machine
10  extend Wrappable
11
12  wrap Powered
13
14  def make_noise
15    puts "Buzzzzzz"
16  end
17end
18
19machine = Machine.new
20machine.make_noise
21# Powering up
22# Started making noise
23# Buzzzzzz
24# Finished making noise
25# Shutting down
26
27bird = Bird.new
28bird.make_noise
29# Powering up
30# Started making noise
31# Chirp, chirp!
32# Finished making noise
33# 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.

1Wrappable.class_variables # => [:@@wrappers]
2Bird.class_variables # => []
3Machine.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 writing class Bird; end is equivalent to writing Bird = Class.new. To make things even more confusing Class inherits from Module which inherits from Object. As a result, classes and modules have the same methods as any other object. Most of the methods we use on classes (like the attr_accessor macro) are actually instance methods of Module.

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.

1module Wrappable
2  def wrap(mod)
3    wrappers << mod
4  end
5
6  def wrappers
7    @wrappers ||= []
8  end
9
10  def new(*arguments, &block)
11    instance = allocate
12    wrappers.each { |mod| instance.singleton_class.include(mod) }
13    instance.send(:initialize, *arguments, &block)
14    instance
15  end
16end

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.

1Wrappable.instance_variables #=> []
2Bird.instance_variables #=> [:@wrappers]
3Machine.instance_variables #=> [:@wrappers]

Not surprisingly, this also fixes the problem we observed earlier - now, both classes are wrapped with their own individual modules.

1bird = Bird.new
2bird.make_noise
3# Started making noise
4# Chirp, chirp!
5# Finished making noise
6
7machine = Machine.new
8machine.make_noise
9# Powering up
10# Buzzzzzz
11# 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.

1module Flying
2  def make_noise
3    super
4    puts "Is flying away"
5  end
6end
7
8class Pigeon < Bird
9  wrap Flying
10
11  def make_noise
12    puts "Coo!"
13  end
14end
15
16pigeon = Pigeon.new
17pigeon.make_noise
18# Coo!
19# 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.

1Pigeon.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.

1Pigeon.ancestors.grep(Wrappable).reverse # => [Bird, Pigeon]

Ruby’s #=== method

Some 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 in case statements. This is how you can use regular expressions (#=== is equivalent to #match?), or classes (#=== is equivalent to #kind_of?) in those statements. Methods like Enumerable#grep, Enumerable#all?, or Enumerable#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.

1Pigeon.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.

1module Wrappable
2  def inherited_wrappers
3    ancestors
4      .grep(Wrappable)
5      .reverse
6      .flat_map(&:wrappers)
7  end
8
9  def new(*arguments, &block)
10    instance = allocate
11    inherited_wrappers.each { |mod|instance.singleton_class.include(mod) }
12    instance.send(:initialize, *arguments, &block)
13    instance
14  end
15end

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.

1bird = Bird.new
2bird.make_noise
3# Started making noise
4# Chirp, chirp!
5# Finished making noise
6
7machine = Machine.new
8machine.make_noise
9# Powering up
10# Buzzzzz
11# Shutting down
12
13pigeon = Pigeon.new
14pigeon.make_noise
15# Started making noise
16# Coo!
17# Finished making noise
18# 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. Just chirp to @AppSignal on Twitter.

Share this article

RSS
Benedikt Deicke

Benedikt Deicke

Guest author Benedikt Deicke is a software engineer and CTO of Userlist. On the side, he’s writing a book about building SaaS applications in Ruby on Rails.

All articles by Benedikt Deicke

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps