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 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!
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
def self.included(base)
.