~6 minutes

ActiveSupport::Concern Digression

It happened again. While reading through ActiveModel I realized that I had skipped over something cool in ActiveSupport, so I’m going to circle back and cover it.

It’s about the journey and not the destination anyway, right? And so gracious reader, we return to…

Ye Olden Days

Back in the days before we were all travelling West with Rails 4, in fact, way before them, you would find many ways discussed as best practices to extend the functionality of a Class. There is a great summary of approaches on Yehuda’s blog which I’ll just point at and leave their review as an exercise for the reader.

Clearly, with this much confusion, having a blessed way to do this would make everyone’s life a wee bit better, and so Rails extended Ruby once again with ActiveSupport::Concern.

ActiveSupport::Concern

Here is the before picture (from Rails’ docs):

module M
  def self.included(base)
    base.extend ClassMethods
    base.class_eval do
      scope :disabled, -> { where(disabled: true) }
    end
  end

  module ClassMethods
    ...
  end
end

And what the above looks like with ActiveSupport::Concern:

require 'active_support/concern'

module M
  extend ActiveSupport::Concern

  included do
    scope :disabled, -> { where(disabled: true) }
  end

  module ClassMethods
    ...
  end
end

In addition to a clean interface that standardized the approach, it also enabled automatic dependency resolution which we will see as we…

Read the Source!

Now that you now what features this delivers, let us take a gander at how it’s implemented.

  module Concern
    class MultipleIncludedBlocks < StandardError #:nodoc:
      def initialize
        super "Cannot define multiple 'included' blocks for a Concern"
      end
    end

    def self.extended(base) #:nodoc:
      base.instance_variable_set("@_dependencies", [])
    end

    def append_features(base)
      if base.instance_variable_defined?("@_dependencies")
        base.instance_variable_get("@_dependencies") << self
        return false
      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

    def included(base = nil, &block)
      if base.nil?
        raise MultipleIncludedBlocks if instance_variable_defined?("@_included_block")

        @_included_block = block
      else
        super
      end
    end
  end
end

Rather than trim out boring bits, I’ve included the whole source of the module. Everything to safely add both Class and Instance methods (and all relevant dependencies) are in the above. Some review…

Module#included

Callback invoked whenever the receiver is included in another module or class. This should be used in preference to Module.append_features if your code wants to perform some action when a module is included in another.

Looking then at the code above, we see that there’s an exception thrown if it encounters something like:

module Foo
  extend ActiveSupport::Concern

  included do
    ...
  end
  included do
    ...
  end
end

A condition it easily detects via an instance variable it sets on the first one.

Module#extended

Called when this module extends another. In the case of Concern it just uses this as an initialization hook for the creation of the @_dependencies array.

Ok, on to the real logic!

Module#append_features

When this module is included in another, Ruby calls append_features in this module, passing it the receiving module in mod. Ruby’s default implementation is to add the constants, methods, and module variables of this module to mod if this module has not already been added to mod or one of its ancestors. See also Module#include.

So let’s say we had a scenario where we had some top level class Zoo, which includes module Foo, which folds in Bar, and where Bar secretly depends on Baz. Like so:

module Baz
  extend ActiveSupport::Concern
  def baz
    puts "baz!"
  end
end

module Bar
  extend ActiveSupport::Concern
  include Baz
  def bar
    puts "bar!"
  end
end

module Foo
  extend ActiveSupport::Concern
  include Bar
end

class Zoo
  include Foo
end

If we inspect this hypothetical tree, we’ll see that @_dependencies points to the next level of the tree:

Foo.instance_variable_get("@_dependencies") == [Bar]
Bar.instance_variable_get("@_dependencies") == [Baz]

And that every method was similarly added to Zoo

Zoo.new.bar == 'bar!'
Zoo.new.baz == 'baz!'

So the big question is, how does this code work? Let’s take my example above and hack in some debugging statements:

module ActiveSupport
  module Concern
    def self.extended(base) #:nodoc:
      base.instance_variable_set("@_dependencies", [])
    end

    def included(base = nil, &block)
      unless base.nil?
        super
      end
    end

    def append_features(base)
      if base.instance_variable_defined?("@_dependencies")
        puts "Creating @_dependencies on - #{base.to_s}"
        base.instance_variable_get("@_dependencies") << self
        return false
      else
        if base < self
          puts "Nothing additional for - #{base.to_s}"
          return false
        else
          puts "Including dependencies #{@_dependencies.inspect} to - #{base.to_s}"
        end
        @_dependencies.each { |dep| base.send(:include, dep) }
        super
        puts "Checking for ClassMethods and blocks in - #{base.to_s}"
        base.extend const_get("ClassMethods") if const_defined?("ClassMethods")
        base.class_eval(&@_included_block) if instance_variable_defined?("@_included_block")
      end
    end
  end
end

Running this, we see the following:

Creating @_dependencies on - Bar
Creating @_dependencies on - Foo
Including dependencies [Bar] to - Zoo
Including dependencies [Baz] to - Zoo
Including dependencies [] to - Zoo
Checking for ClassMethods and blocks in - Zoo
Checking for ClassMethods and blocks in - Zoo
Checking for ClassMethods and blocks in - Zoo

Which makes it even more obvious than the code (which wasn’t very obvious if I’m being quite honest). The bookkeeping on dependent modules is only triggered when we decorate the eventually inclusive class (Zoo above). Since it is the destination and doesn’t have a @_dependencies set, it finally goes down the other branch to trigger a bunch of include statements.

Easy to see in retrospect, but I needed to “lab” this one.

Have fun reading the source!

Tagged with: programming

My first novel is coming soon-ish!

Check out Singular