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 on my personal blog 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
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:
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)
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.
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
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 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
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
.format evaluates to
"ddex_version_38" for instances of all of our
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
@@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
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!
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.
Top comments (2)
This is an amazing post, thank you! I love learning about how Ruby works in more detail. I had to read it a couple times to really wrap my head around what was going on, but now I'm super excited to know about this pattern.
Once I figured out that the key piece of magic happens here:
and what all of that does, everything clicked into place.
For anyone who needs a refresher on the differences between include and extend, I found this post by Léonard Hetsch to be helpful as well.
This is a fantastic article! Instance variables on all the things is a big part of what makes Ruby so mind-blowing.
I'm curious if you've worked with the Concern approach from Rails, and what your thoughts are on the way they handle include/extend versus the "manual" approach of using