Ruby Custom Class Macros with Class Instance Variables

Class macros are class methods that are only used when a class is defined. They allow us to dry up shared code at across classes. In this post, we'll build a custom class macro that leverages class instance variables to define class-specific attributes.

Shared Code at the Class Level

We'll revisit a domain model from an earlier post in which we built our very own XML templating engine. We built our engine with the help of a module, XmlFormattable, that we mixed in to any classes that need to respond to #to_xml to generate strings of XML.

Previously, we included our module in our Album, Track and Artist classes to help us write XML describing audio release metadata. Now our app has grown and we have a variety of sub-classed Album classes for the specific online stores that we are sending this metadata to.

We have three album sub-classes:

  • Itunes::Album
  • Spotify::Album
  • DDEX::Album

Where the Itunes::Album and Spotify::Album classes describe albums specifically formatted for those stores, and the DDEX::Album class defines the default album that can be sent to a variety of different stores. (DDEX is the industry standard for defining audio release XML)

Our shared XmlFormattable module needs to know the XML format of a given album, i.e. the iTunes, Spotify, or DDEX format, in order to write the correct XML and validate the written XML against the correct schema.

Our XmlFormattable module looks something like this:

module XmlFormattable  
  def to_xml
    formatter.format
  end

  def render(file_name, object, options)
    formatter.render(file_name, object, options)
  end

  def formatter
    @formatter ||= XmlFormatter.new(self, "format type goes here!!")
  end
end  

The Wrong Way to DRY Up our Classes

How can we give awareness of the format type to the XmlFormattable module?

Well, this is information that will be shared across every instance of a given ::Album class. One option would be to define class constants in each class:

class Itunes::Album
  include XMlFormattable
  XML_FORMAT = "itunes_version_9"
end
class Spotify::Album
  include XmlFormattable
  XML_FORMAT = "spotify_version_7"
end
class DDEX::Album
  include XmlFormattable
  XML_FORMAT = "ddex_version_38"
end

Then, we could reference our class constant in the XmlFormattable module like this:

module XmlFormattable  
  def to_xml
    formatter.format
  end

  def render(file_name, object, options)
    formatter.render(file_name, object, options)
  end

  def formatter
    @formatter ||= XmlFormatter.new(self, XML_FORMAT)
  end
end  

This approach violates the Single Responsibility Principle. The whole point of creating the XmlFormattable module in the first place was to take the responsibility of writing XML out of the individual album classes. This approach introduced an attribute related to writing and validating XML back into the album class. And it made it the sole responsibility of each ::Album class to define and manage that attribute.

At the same time, we've made our XmlFormattable module reliant on a piece of info, XML_FORMAT, that is not defined or controlled by this module at all. Instead it is managed by the class that this module is mixed in to. This forces whoever uses this module in the future to know and remember to define the XML_FORMAT class constant in the class in which they are including the module. This split brain state makes it hard to scale the use of our module.

How can we make our module responsible for defining a class attribute and make sure that the class attribute is different for the different classes? I thought you'd never ask!

Class Macros

Class macros are class methods that are evaluated only once, when a class is defined. We'll define a class macro in our XmlFormattable module and we'll call on this class macro in each ::Album class to specify the XML format.

Here's how we'll use our macro:

class Itunes::Album
  include XmlFormattable
  xml_format "itunes_version_9"
end
class Spotify::Album
  include XmlFormattable
  xml_format "spotify_version_7"
end
class DDEX::Album
  include XmlFormattable
  xml_format "ddex_version_38"
end

Defining the Class Macro

Defining a class macro is easy--it's really just a plain old class method. We'll define our xml_format macro in a sub-module, XmlFormattable::ClassMethods so that we can make it available as a class method.

module XmlFormattable
  def self.included(base)
    base.extend ClassMethods
  end

  module ClassMethods
    def xml_format(name)
      # coming soon!
    end
  end

We have a class method .xml_format, that takes in an argument of the XML format name. We call this class method as a macro in each of the ::Album sub-classes.

Now that we have a macro that takes in an argument of the XML format, how will we store that format for each ::Album class so that it can be retrieved by instances of that class later on?

One option that comes to mind for storing class-specific information is a class variable. Let's see what happens when we try to use class variables here.

(Spoiler) Class Variables Won't Work!

We'll set a class variable @@xml_format equal to a default value of "ddex" in our XmlFormattable module. We'll give it this default value since DDEX is the industry standard for audio release XML.

module XmlFormattable
 module ClassMethods
    @@xml_format = "ddex"
  end
end

Then, we'll use our .xml_format class method to assign the @@xml_format variable to the given XML format type.

module XmlFormattable
 module ClassMethods
    @@xml_format = "ddex"

    def xml_format(format)
      @@xml_format = format
    end
  end
end

Lastly, we'll give our module a way to read the @@xml_format variable to expose it to the world.

module XmlFormattable
 module ClassMethods
    @@xml_format = "ddex"

    def xml_format(format)
      @@xml_format = format
    end

    def format
      @@xml_format
    end
  end
end

Alright, let's see what happens when we define our ::Album sub-classes. What will the xml_format macro set @@xml_format equal to for each sub-class?

class Itunes::Album
  include XmlFormattable
  xml_format "itunes_version_9"
end

class Spotify::Album
  include XmlFormattable
  xml_format "spotify_version_7"
end

class DDEX::Album
  include XmlFormattable
  xml_format "ddex_version_38"
end

itunes_album = Itunes::Album.new
itunes_album.class.format
  => "ddex_version_38"

spotify_album = Spotify::Album.new
spotify_album.class.format
  => "ddex_version_38"

ddex_album = DDEX::Album.new
ddex_album.class.format
  => "ddex_version_38"f

What?? .format evaluates to "ddex_version_38" for instances of all of our ::Album classes!

This is because the xml_format class macro is updating the value of the @@xml_format class variable that is shared by all of the classes that have our module mixed in. Remember that mixing a module into a class adds that module to the class's inheritance chain.

What we've done is bind our @@xml_format class variable to the XmlFormattable module, which is shared by all three of our album classes. This means that each time we call our xml_format macro, we are re-assigning that same shared class variable.

Our class macro gets called when a class is defined, so when we defined the Itunes::Album class, @@xml_format got set to "itunes_version_9, and when we subsequently defined our Spotify::Album class we reset that same variable to "spotify_version_7". Lastly, when we defined DDEX::Album, it overwrote that class variable one last time, setting it equal to "ddex_version_38".

If only there was a way to store a class attribute using shared code and have the value of that attribute be specific to each class...

Class Instance Variables to the Rescue!

Class instance variables will let us do exactly that.(You're so surprised, I know.) In Ruby, any object can assign an instance variable. And what is a class but a plain old object?

Classes in Ruby are first-class objects—each is an instance of class Class –– Ruby Docs

This means that a class can have an instance variable too, same as any other Ruby object.

Unlike a class variable which is shared by all of a class (or module)'s descendants, a class instance variable is specific to the given class.

Let's replace our class variable, @@xml_format with a class instance variable to resolve our problem.

module XmlFormattable
 module ClassMethods
    @xml_format = "ddex"

    def xml_format(format)
      @xml_format = format
    end

    def format
      @xml_format
    end
  end
end

We can see that all we did here was give our XmlFormattable module an instance variable on the class level:

XmlFormattable::ClassMethods.instance_variables
  => [:@xml_format]

Now, when we define our three album classes, the @xml_format instance variable gets set for each individual class. It doesn't overwrite a shared class variable.

class Itunes::Album
  include XmlFormattable
  xml_format "itunes_version_9"
end

class Spotify::Album
  include XmlFormattable
  xml_format "spotify_version_7"
end

class DDEX::Album
  include XmlFormattable
  xml_format "ddex_version_38"
end

itunes_album = Itunes::Album.new
itunes_album.class.format
  => "itunes_version_9"

spotify_album = Spotify::Album.new
spotify_album.class.format
  => "spotify_version_7"

ddex_album = DDEX::Album.new
ddex_album.class.format
  => "ddex_version_38"

And that's it!

Conclusion

Class instance variables aren't magic. Using an instance variable on the class level is no different than using an instance variable within a specific instance of a class. This is because classes are objects too.

By leveraging class instance variables, we were able to write a macro that defines a class-level and class-specific attribute for each of our ::Album sub-classes. This allowed us to move the responsibility of defining and managing the xml_format attribute out of our individual album classes and into our XmlFormattable module, keeping our code DRY and adherent to SRP.

Happy coding!

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus