opensoul.org

Concerning ActiveSupport::Concern

I pushed some changes to MongoMapper that replace the custom plugin system with ActiveSupport::Concern. ActiveSupport::Concern has been around for a while, but it’s capabilities are not widely known.

Concerns wrap up the pattern of including a module, and having it extend class methods, include instance methods, and apply some configuration. If you’ve ever extended ActiveRecord, then you’ve likely written code that looks like this:

module M
  def self.included(base)
    base.extend ClassMethods
    base.send :include, InstanceMethods
    base.some_class_method
  end

  module ClassMethods
    # ...
  end

  module InstanceMethods
    # ...
  end
end

When our module M is included into a class, it extends the base class with ClassMethods, includes InstanceMethods, and invokes some_class_method.

Cosmetics

The first feature provided by ActiveSupport::Concern is purely cosmetic. Here is our module above rewritten:

module M
  extend ActiveSupport::Concern
  
  included do
    some_class_method
  end

  module ClassMethods
    # ...
  end

  module InstanceMethods
    # ...
  end
end

We no longer have to manually extend and include our class an instance methods. Instead, we call #included with a block that gets class evaled when our concern is included.

Dependency resolution

The second feature provided by ActiveSupport::Concern is very helpful for a library like MongoMapper with numerous of modules that depend on each other. Each concern can include the concerns that it depends on, and they will get included into the class first. For example:

module A
  extend ActiveSupport::Concern
  # … define class and instance methods …
end

module B
  extend ActiveSupport::Concern
  include A

  included do
    class_method_from_module_a
  end

  # … define class and instance methods …
end

Now, users can include our module B into a class, and module A will get included.

class C
  include B
end

How It Works

I find the implementation of ActiveSupport::Concern fascinating. With only 3 methods and just over 20 lines of code, it cleverly overrides some of Ruby’s default behavior. He is the source in full:

module ActiveSupport
  module Concern
    def self.extended(base)
      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.send :include, const_get("InstanceMethods") if const_defined?("InstanceMethods")
        base.class_eval(&@_included_block) if instance_variable_defined?("@_included_block")
      end
    end

    def included(base = nil, &block)
      if base.nil?
        @_included_block = block
      else
        super
      end
    end
  end
end

Let’s walk through each section.

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

When our module is extended with ActiveSupport::Concern, Ruby calls the self.extended method, and passes in our module. ActiveSupport::Concern sets an instance variable on our module to an empty array. This will be used to keep track of the modules that our concern depends on.

def append_features(base)
  # …
end

Next ActiveSupport::Concern defines #append_features, which Ruby calls on our module whenever it is included into another module. Ruby’s default implementation of this method will add constants, methods, and variables of this module to the base module. This is where ActiveSupport::Concern starts to get really sneaky.

if base.instance_variable_defined?("@_dependencies")
  base.instance_variable_get("@_dependencies") << self
  return false

If the module that our concern is included into has the dependencies instance variable, then it is assumed to be another concern and our concern is appended to it’s dependencies. Note that #super is never called, and thus Ruby’s default behavior never happens. (Seriously, Ruby is awesome.)

else
  return false if base < self
  @_dependencies.each { |dep| base.send(:include, dep) }
  super
  base.extend const_get("ClassMethods") if const_defined?("ClassMethods")
  base.send :include, const_get("InstanceMethods") if const_defined?("InstanceMethods")
  base.class_eval(&@_included_block) if instance_variable_defined?("@_included_block")
end

If the module that our concern is included into does not have the dependencies instance variable, then it’s a plain ol’ module and we want to actually include our concern into it. If this module has already been included then it simply returns false. It then loops over each dependency, includes it into the base (since our dependencies are always concerns too, they will each follow this same code path), and calls #super to perform Ruby’s default behavior. It then extends ClassMethods and includes InstanceMethods if they are defined.

Lastly, it calls #class_eval on a block if it’s defined, but where does this block come from?

def included(base = nil, &block)
  if base.nil?
    @_included_block = block
  else
    super
  end
end

Ruby normally calls #included on a module whenever it is included into another module or class, passing in the module or class that it was included into. ActiveSupport::Concern hijacks this method to give us a pretty syntax for declaring what should happen when our concern is included.

Since Ruby will still call this method whenever our module is included, the original behavior has to be preserved. If a base class or module is passed in, then Ruby is calling this method so #super is called. If a base module or class is not passed in, then our concern is calling it with a block that should be saved for when it actually is included.

It’s full of trickery, but I love it.

activesupport, popular, and rails February 07, 2011

7 Comments

  1. Dave Woodward Dave Woodward February 8, 2011

    These are the sorts of things I really like that they’ve added (extracted?) in Rails 3.  

    It shows a deep knowledge of Ruby and also a willingness to embrace what is already there in the language instead of re-inventing the wheel like older versions of Rails did.

    Ruby is already a pretty great language out of the box, and these sorts of things are even more awesome little tweaks on top of that.  

    These and other changes in Rails 3 make me think I’m actually starting to like Rails code!

  2. Brad Fults Brad Fults February 8, 2011

    Great post. I’ve read elsewhere that the module InstanceMethods bit is unnecessary because, after all, your overall module M is exactly what you’re including into your Ruby module/class, so the default behavior suffices.
    Is there some other benefit to using that InstanceMethods module?

  3. Brandon Keepers Brandon Keepers February 8, 2011

    Dave: I agree. Rails 3 internals are better in so many ways (except Callbacks, don’t get me started on that implementation).

    Brad: Correct, it’s not needed. The only time I can think of that it would be needed is if you wanted to include another module into your instance methods.  Since ActiveSupport::Concern changes the behavior of #include, you’d need to put instance methods in their own module.

  4. Elliot Winkler Elliot Winkler February 8, 2011

    I always thought that ActiveSupport::Concern was some extra sugar I didn’t need (seriously, is it that hard to do the InstanceMethods-ClassMethods thing?), but I didn’t know about the dependency stuff. That puts an interesting spin on things.

  5. Matt Jones Matt Jones February 8, 2011

    @Elliot Winkler: I also seem to recall that there’s a load-ordering issue with the included hook, where modules that are included in other modules get that hook called too early (with base set to the including module) rather than the desired effect of loading them all when they’re included in a real class. For instance, if you’ve got a module A that includes another module B which calls (for instance) base.attr_accessible in the included hook, things go very very wrong (since that’s not defined on Module).

    This may not be a perfect explanation, but it’s the best I can remember from wycats’s BoF session last Railsconf.

  6. Srdjan Pejic Srdjan Pejic February 9, 2011

    So, how does this fit into MongoMapper’s plugin architecture? I’m assuming you’re going to post about that next.

  7. Brandon Keepers Brandon Keepers February 14, 2011

    Srdjan Pejic: Just check out MongoMapper’s docs at [REDACTED]…oh wait, nevermind.  :)

    A MongoMapper plugin is just a concern now.  That’s all their is to it.  You still use the #plugin method inside your model to include it.

Post a Comment

Comments use textile. Anonymous comments will be deleted.

I am Brandon Keepers. I build Internet things, usually with Ruby or JavaScript. I work at GitHub and live in Holland, MI.

Popular Posts