Reading Rails - Concern
Today we are going to look at ActiveSupport’s Concern
module. The module itself is short, but densely packed. Understanding how Concern
works will help you better understand how Ruby’s Module
works.
The Concern
provides a mechanism for composing modules, and wraps a common Rails idiom in just 26 lines of code. Typically we would jump into how this works, but the problems it solves are subtle, so we will look at what it does first.
To follow along, open each library in your editor with qwandry, or just look it up on Github.
Included
Module
defines the callback included
which is called when a module is included into another class or module. This is a useful hook for metaprogramming. For instance you might mix in a module that wires up validations on the model:
module Named
def self.included(base)
base.validates_presence_of :first_name, :last_name
end
def full_name
"#{first_name} #{last_name}"
end
end
class Person
include Named
end
When Person
includes Named
, the included
method is called on Named
with a reference to Person
. Now, what if you want a module that builds upon Named
?
module Mailable
include Named
def self.included(base)
email_regexp = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/
base.validates_format_of :email, with: email_regexp
end
def send_greeting
mail = WelcomeMailer.greet(email, first_name, last_name)
mail.deliver
end
end
This will fail when Named
is included into Mailable
. The Named.included
callback will be executed with Mailable
as the base
, but Mailable
doesn’t define validates_presence_of
:
require 'mailable'
# => NoMethodError: undefined method `validates_presence_of' for Mailable:Module
We really want these modules to wait until they are included in your model before the included
callback is invoked. Concern
works around this issue for us:
module Named
extend ActiveSupport::Concern
included do
base.validates_presence_of :first_name, :last_name
end
#...
end
module Mailable
extend ActiveSupport::Concern
include Named
included do
email_regexp = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\z/
base.validates_format_of :email, with: email_regexp
end
#...
end
First a module extends Concern
, and then instead of defining def self.included
it calls included
with a block. Concern
delays calling these blocks until your module is included in something that is not a Concern
.
Class Methods
When you include a module, Ruby makes all of the module’s instance methods available to that class. It does not however make the class methods available:
module Exclaimable
def self.shout!
puts "Look out!"
end
def exclaim!
puts "I say!"
end
end
class Person
include Exclaimable
end
Calling Person.new.exclaim!
prints "I say!"
as you would expect, but Person.shout!
fails. Calling include
only mixes in the instance methods. In the bad old days, when Rails was young, a pattern emerged to work around this:
module Exclaimable
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def shout!
puts "Look out!"
end
end
def exclaim!
puts "I say!"
end
end
When Exclaimable
is included, the baes
class is extended with the nested ClassMethods
module, making them available on the class. Now calling Person.shout!
will print "Look out!"
. The Rails team recognized this common pattern, and wrapped it up in Concern
. If there is a ClassMethods
module in a Concern
, it will automatically extend whatever it is included in.
How It Works
Let’s walk through what happens when you use Concern
, starting with extend Concern
:
module Concern
def self.extended(base) #:nodoc:
base.instance_variable_set("@_dependencies", [])
end
#...
Just like included
, modules have an extended
callback that is called whenever the module extends something. Concern
uses this to stash an instance variable on the base
class being extended. The instance_variable_set
and instance_variable_get
methods allow you to access an object’s internal instance variables:
person = Person.find(1)
# Peek inside:
person.instance_variables
#=> [:@attributes, :@column_types_override, :@column_types, @new_record, ...]
p.instance_variable_get(:@new_record)
#=> false
# Update the internal state:
person.instance_variable_set(:@new_record, true)
person.new_record?
#=> true
These should be used sparingly, but can be excellent debugging tools. We’ll see that @_dependencies
is used to identify modules using Concern
, and to hold information needed later. We can see this using instance_variable_get
on a module extending Concern
:
module Exclaimable
extend ActiveSupport::Concern
# ...
end
Exclaimable.instance_variables
#=> [:@parent_name, :@_dependencies]
Exclaimable.instance_variable_get(:@_dependencies)
#=> []
Just like extendend
, Concern
overrides included
to provide the block syntax we saw above:
def included(base = nil, &block)
if base.nil?
@_included_block = block
else
super
end
end
Normally, Ruby calls included
with a base
class. When you call included
with a block, base
will be nil
. Rails uses this distinction to switch behavior between Concern
’s block form and the default form. When using the block form, the block is stored in @_included_block
for later. When a base
object is passed in, Concern
delegates to the default implementation.
Concern
uses the data gathered from the extended
and included
hooks in append_features
. This method is called on each module being included:
def append_features(base)
if base.instance_variable_defined?("@_dependencies")
base.instance_variable_get("@_dependencies") << self
return false
else
#...
end
In the first part of append_features
, Rails checks to see if this module is being included into another Concern
by looking for @_dependencies
. If so, it adds itself to that Concern
’s list of @_dependencies
, and exits. Based on our example of Named
, Mailable
, and Person
, the Named
module is on Mailable
’s @_dependencies
.
def append_features(base)
#...
else
return false if base < self
@_dependencies.each { |dep| base.send(:include, dep) }
super
base.extend const_get("ClassMethods") if const_defined?("ClassMethods")
base.class_eval(&@_included_block) if instance_variable_defined?("@_included_block")
end
end
In the second part, Rails has a guard preventing this Concern
from being included into one of its own subclasses using base < self
. This prevents any logic from being executed out of order, or multiple times.
Next, each class in the @_dependencies
array is included. This recursively kicks off including any modules this depends on. Using our example, when Mailable
is included into Person
, Mailable
will call Person.send :include, Named
at this point. This ensures that any logic defined by Named
will be applied before Mailable
’s logic.
The third step is to extend base
with any methods defined in an inner module ClassMethods
. Much like instance_method_get
, Ruby defines const_get
, which gets constants defined in an object. Constants include inner classes and modules as well as constant values like ID = 42
. This provides the ClassMethods
idiom that we saw above.
The final step is to evaluate the @_included_block
in the context of the base
class if it was defined. Since this branch is only called when a Concern
is included in something that isn’t a Concern
, an ActiveRecord model for instance, this ensures your block will be called on the expected target. This works around the issue we saw with the validation in the Named
example at the beginning of this article.
Recap
Concern
delays calling included
blocks and mixing in ClassMethods
by keeping track of modules in @_dependencies
. When a Concern
is included in another class, it triggers all the logic on that class. You should take a moment to open up ActiveSupport’s concern.rb
and read over the file yourself now.
While unpacking how Concern
works, we also came across some other interesting things:
Module
definesincluded
andextended
callbacks.- Ruby provides methods such as
instance_method_get
to access an object’s internals. - Class methods are not mixed in with
include
. include
takes any number of arguments:include ModA, ModB, ModC
.- Classes can be compared with equality operations:
ClassA < ClassB
. - By convention, anything starting with a capital letter is a constant.
If you would like to see more uses of Concern
in Rails, poke around ActiveModel and ActiveRecord, specifically notice how ActiveModel’s Dirty
module depends on the methods in AttributeMethods
.
More articles in this series
- Reading Rails - HTTP DELETEs With a Link
- Reading Rails - Time Travel
- Reading Rails - TimeWithZone
- Reading Rails - How Does MessageEncryptor Work?
- Reading Rails - How Does MessageVerifier Work?
- Reading Rails - How Do Batched Queries Work?
- Reading Rails - The Adapter Pattern
- Reading Rails - Errors and Validators
- Reading Rails - How Validations Are Configured
- Reading Rails - Concern
- Reading Rails - More Migrations
- Reading Rails - Migrations
- Reading Rails - Attribute Methods
- Reading Rails - Change Tracking
- Reading Rails - Handling Exceptions