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 defines included and extended 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