I spent some time digging into Rails’ ActiveSupport::Concern
module. How it behaves surprised me a little bit, so I figured I’d write up what I learned.
ℹ️ If you only have a shaky understanding of include and extend in Ruby, you might want to start with this post first, which takes a deep dive into Ruby’s built-in tools for inheritance. |
Consider this snippet, using plain Ruby inheritance features:
module IParent
end
module IChild
include IParent
end
class Parent
include IChild
end
class Child < Parent
end
We can ask Ruby to print out the ancestor hierarchy for the classes in this snippet:See the appendix for the code I used to generate these printouts
ancestors of IChild = [IParent]
ancestors of #<Class:IChild> = [...]
────────────────────────────────────────────────────────────────────────
ancestors of Parent = [IChild, IParent, ...]
ancestors of #<Class:Parent> = [...]
────────────────────────────────────────────────────────────────────────
ancestors of Child = [Parent, IChild, IParent, ...]
ancestors of #<Class:Child> = [#<Class:Parent>, ...]
… but I don’t like printouts—I’d rather have pictures. So here’s a picture of the same thing:
Some conventions in this picture:
- Classes and modules are boxes.
- A line from one box pointing to another shows ancestor information.
- The box being pointed to is the ancestor.
- All classes and modules have singleton classes (shown overlapping).
Since the snippet was plain Ruby, hopefully everything we see is familiar:
Calling
include
immediately records an ancestor. We calledinclude
twice, once insideIChild
(on line 5) and once inside Parent (on line 9). Wherever there was a call toinclude
, there’s also an arrow.Module singleton classes are never inherited.
By contrast, when one class inherits from another, the parent’s singleton class is inherited.
Don’t ask me why there’s all these rules, this is just how Ruby behaves.
From time to time, we might wish Ruby did something differently, allowing modules to define singleton class methods that can get mixed in alongside their instance methods. Luckily we don’t even have to wish: Ruby is nearly infinitely flexible, and so people created ActiveSupport::Concern
to allow this (with only a few restrictions).
Here’s how to use ActiveSupport::Concern
:
module IParent
end
module IChild
extend ActiveSupport::Concern
include IParent
module ClassMethods
# Despite being declared as an instance method,
# this will act like a singleton class method.
def foo; end
end
end
class Parent
include IChild
end
Parent.foo # ✨ magic ✨
class Child < Parent
end
In this example, let’s say that IChild
wanted to declare some singleton class methods on whatever class it was eventually include
’d into. All it has to do is extend ActiveSupport::Concern
and then define module ClassMethods
The ClassMethods
name is special to ActiveSupport::Concern
, not Ruby. It also allows defining a class_methods do ... end
block, but that does nothing more than define this module and then run the block inside it.
inside itself. The library will make sure that this module gets extend
’ed into whichever class eventually writes include IChild
.
And to drive the point home, here’s a picture:
Beautiful, just like we’d expect.
One problem though: what if it’s not IChild
that wants to define the singleton class methods, but rather IParent
?
module IParent
extend ActiveSupport::Concern
module ClassMethods
def bar; end
end
end
module IChild
include IParent
end
class Parent
include IChild
end
Parent.bar # dang 😰
class Child < Parent
end
Suddenly this doesn’t work: the ClassMethods
module gets extend
ed at the point where the include IParent
happens. That means that we’d be able to call IChild.bar
, but that’s beside the point: we wanted those methods to be available as if they were singleton class methods on Parent
. We can see where things went wrong in the picture:
And again, ActiveSupport::Concern
has already thought of this case: it defers extend
ing the ClassMethods
all the way until the first non-Concern
. So we can simply declare IChild
as a Concern
too (even if it doesn’t have any ClassMethods
). That will ensure that IParent::ClassMethods
end up on #<Class:Parent>
.
It’s a single-line change:
module IParent
extend ActiveSupport::Concern
module ClassMethods
def bar; end
end
end
module IChild
extend ActiveSupport::Concern
include IParent
end
class Parent
include IChild
end
Parent.bar # ✨ magic restored ✨
class Child < Parent
end
And when we look at the picture:
Wait, what is going on?
First of all, ClassMethods
was only extend
ed into Parent
, not also into IChild
. I suppose that makes sense; maybe there’s no real use for those ClassMethods
to also end up on the (childless) singleton class of IChild
. In all likelihood, those methods were going to be things like “create an instance of the current class” which is something that doesn’t make sense for module singleton class methods to be doing. I will admit that my expectation was that ClassMethods
would get extend
’ed onto every module singleton class that include
’d the attached class, to mimic how it works when inheriting classes.
But what really surprised me: despite literally having the include IParent
line in its class body, IParent
is not an ancestor of IChild
. 🤯
Instead, it’s only when IChild
is eventually include
’d into Parent
that that include IParent
line has an effect. We see that there are two lines coming out of Parent
: one directly to IChild
(makes sense), and then one that skips directly from Parent
to IParent
.
This is kind of wild to me,Even more wild is that it’s done all in plain Ruby, using append_features.
because it literally changes the meaning of include
inside an ActiveSupport::Concern
module.
Full disclosure: I don’t know why this is. If you’ll allow me to guess, I’d say because ActiveSupport::Concern
is not only designed to let you mix singleton class methods into the right place, but also to register callbacks to run arbitrary code once the concern is mixed in there. These callbacks might do things like “call a DSL method in the context of a database model,” or something. The methods that callback calls won’t exist until the very end of the include
chain, when the Concern
gets mixed into the model.
With any luck maybe that clears things up a bit. If I’ve gotten something wrong, or this leaves you with even more questions, feel free to reach out.
Appendix: relationship with type systems
Sorbet has a similar but different feature, called mixes_in_class_methods
. I don’t know the history of it, nor why it appears to work so differently from ActiveSupport::Concern
despite being clearly inspired by the ClassMethods
pattern. I’ll have to ask around for the history on that.
Separately, building Sorbet’s relatively new has_attached_class!
feature, I kept finding it really clunky to use in combination with mixes_in_class_methods
. The behavior ActiveSupport::Concern
has around ClassMethods
would make some annoyances with has_attached_class!
go away. But at this rate, making a change to how mixes_in_class_methods
is sure to break some other code. But maybe there’s a way to build better support for Concern
-ish things more generally, so that new code might be able to move away from mixes_in_class_methods
. I have not given this much thought.
And as one final point: by changing how include
works, it changes the meaning of this expression: if IChild < IParent
. This is a very not fun thing to think about the consequences of if you’re trying to build a type system for Ruby. I’m struggling to think of how to use this fact to come up with some sort of contradiction between what would be predicted by the type system and what would actually happen at runtime. If you can come up with one I’d be very curious to see it.
Appendix: support code
I wrote some random helper functions to print the “ancestors of …” information that I used to figure out how to draw the pictures in this post. Here’s the code:
Click to show code
require 'active_support/concern'
def interesting_ancestors(mod)
= mod.ancestors[1..]
non_self_ancestors = non_self_ancestors.filter do |ancestor|
interesting [
!Class, Module, Object, Kernel, BasicObject,
ActiveSupport::Concern,
Object.singleton_class, BasicObject.singleton_class
].include?(ancestor)
end.map(&:to_s)
if interesting.size != non_self_ancestors.size
<< "..."
interesting end
=
pretty_mod if mod.singleton_class?
"#{mod}"
else
"#{mod} "
end
puts("ancestors of #{pretty_mod} = [#{interesting.join(", ")}]")
end
def all_interesting_ancestors(*mods)
= true
first .each do |mod|
modsunless first
puts('─' * 72)
end
interesting_ancestors(mod).singleton_class)
interesting_ancestors(mod
= false
first end
end
It’s not particularly pretty, but it works for the four snippets in this post.
Appendix: all ancestor information
I only showed the first printout, and then skipped straight to pictures. Here’s the printouts for all four examples:
Click to show all printouts
❯ for i in {1..4}; do echo; ruby concern$i.rb ; echo ; done
ancestors of IChild = [IParent]
ancestors of #<Class:IChild> = [...]
────────────────────────────────────────────────────────────────────────
ancestors of Parent = [IChild, IParent, ...]
ancestors of #<Class:Parent> = [...]
────────────────────────────────────────────────────────────────────────
ancestors of Child = [Parent, IChild, IParent, ...]
ancestors of #<Class:Child> = [#<Class:Parent>, ...]
ancestors of IChild = [IParent]
ancestors of #<Class:IChild> = [...]
────────────────────────────────────────────────────────────────────────
ancestors of Parent = [IChild, IParent, ...]
ancestors of #<Class:Parent> = [IChild::ClassMethods, ...]
────────────────────────────────────────────────────────────────────────
ancestors of Child = [Parent, IChild, IParent, ...]
ancestors of #<Class:Child> = [#<Class:Parent>, IChild::ClassMethods, ...]
ancestors of IChild = [IParent]
ancestors of #<Class:IChild> = [IParent::ClassMethods, ...]
────────────────────────────────────────────────────────────────────────
ancestors of Parent = [IChild, IParent, ...]
ancestors of #<Class:Parent> = [...]
────────────────────────────────────────────────────────────────────────
ancestors of Child = [Parent, IChild, IParent, ...]
ancestors of #<Class:Child> = [#<Class:Parent>, ...]
ancestors of IChild = []
ancestors of #<Class:IChild> = [...]
────────────────────────────────────────────────────────────────────────
ancestors of Parent = [IChild, IParent, ...]
ancestors of #<Class:Parent> = [IParent::ClassMethods, ...]
────────────────────────────────────────────────────────────────────────
ancestors of Child = [Parent, IChild, IParent, ...]
ancestors of #<Class:Child> = [#<Class:Parent>, IParent::ClassMethods, ...]