Search code examples
ruby-on-railsrubysuperactivesupportactivesupport-concern

Calling super for included method results in "no superclass method" error - ActiveSupport


There is "super" keyword in Ruby that is looking through the ancestors chain in order to find the first method implementation up the chain and execute it. So, this is how it works in Ruby, no surprises:

module Mammal
  def walk
    puts "I'm walking"
  end
end
require '~/Documents/rubytest/super/mammal.rb'

class Cat
  include Mammal

  def walk
    super
  end
end
2.7.0 :001 > simba = Cat.new
2.7.0 :002 > simba.walk
I'm walking
 => nil

Which is the desirable behavior. Now, in Rails there is ActiveSupport::Concern that provides some extra functionality for Modules. Here's what happens if you do kind of similar way using ActiveSupport helpers:

module MammalMixin
  extend ActiveSupport::Concern
    
  included do
    def show
      @mammal = Mammal.find(params[:id])
    end
  end
end
class SomeController < ApplicationController
  include MammalMixin

  def show
    super
  end
end

If you reach that controller, this will error out: super: no superclass method `show' for #SomeController:0x000055f07c549bc0

Of course, it's possible to not use "included do" helper and revert to plain Ruby style, but could someone please suggest what exactly in ActiveSupport::Concern prevents "super" from working fine and (maybe) explain the rationale behind this?

I've been looking through the source code in active_support/concern.rb, but failing to understand.


Solution

  • The answer is right in the documentation of ActiveSupport::Concern#included [bold emphasis mine]:

    Evaluate given block in context of base class, so that you can write class macros here.

    So, here's the content of your block:

    def show
     @mammal = Mammal.find(params[:id])
    end
    

    And this block is evaluated in the context of the base class, as per the documentation. Now, what happens when you evaluate a method definition expression in the context of a class? You define a method in that class!

    So, what you are doing here is you define a method named show in the SomeController class as if you had written:

    class SomeController < ApplicationController
      def show
        @mammal = Mammal.find(params[:id])
      end
    
      def show
        super
      end
    end
    

    In other words, your second definition is overwriting the first definition, not overriding it, and so there is no super method.

    The correct way to use ActiveSupport::Concern#included is like this:

    module MammalMixin
      extend ActiveSupport::Concern
    
      def show
        @mammal = Mammal.find(params[:id])
      end
        
      included do
        acts_as_whatever
      end
    end
    

    ActiveSupport::Concern#included, as the documentation says, is for executing code (such as "class macros" like acts_as_*, has_many, belongs_to, etc.) in the context of the class.

    Here's how including a module normally works:

    When you write

    class C
      include M
    end
    

    You are calling the Module#include method (which is not overriden by Class and thus inherited without change).

    Now, Module#include doesn't actually do anything interesting. It basically just looks like this:

    class Module
      def include(mod)
        mod.append_features(self)
      end
    end
    

    This is a classic Double Dispatch idiom to give the module full control over how it wants to be included into the class. While you are calling

    C.include(M)
    

    which means that C is in control, it simply delegates to

    M.append_features(C)
    

    which puts M in control.

    What Module#append_features normally does, is the following (I will describe it in pseudo-Ruby, because the behavior cannot be explained in Ruby, since the necessary data structures are internal to the engine):

    class Module
      def append_features(base)
        if base.is_a?(Module)
          base.included_modules << self unless base.included_modules.include?(self)
        else
          old_superclass = base.__superclass__
    
          klazz = Class.new(old_superclass)
          klazz.__constant_table__ = __constant_table__
          klazz.__class_variable_table__ = __class_variable_table__
          klazz.__instance_variable_table__ = __instance_variable_table__
          klazz.__method_table__ = __method_table__
          klazz.__virtual__ = true
    
          base.__superclass__ = klazz
        end
    
        included(base)
    
        self
      end
    end
    

    So, what happens is that Ruby creates a new class, called an include class whose constant table pointer, class variable table pointer, instance variable table pointer, and method table pointer point to the constant table, class variable table, instance variable table, and method table of the module. Basically, we are creating a class that shadows the module.

    Then it makes this class the new superclass of the class, and makes the old superclass the superclass of the include class. Effectively, it inserts the include class between the class and the superclass into the inheritance chain.

    This is done this way, because then the method lookup algorithm doesn't need to know anything about mixins and can be kept very simple: go to the class, check if the method exists, if not fetch the superclass, check if the method exists, and so on, and so forth. Since method lookup is one of the most common and most important operations in an execution engine for an OO language, it is crucial that the algorithm is simple and fast.

    This include class will be skipped by the Class#superclass method, so you don't see it, but it will be displayed by Module#ancestors.

    And that is why super works: because the module literally becomes a superclass.

    We start off with with C < Object and we end up with C < M' < Object.

    Now, ActiveSupport::Concern completely screws with this.

    The interesting part of the ActiveSupport::Concern#included method is this:

    @_included_block = block
    

    It simply stores the block for later use.

    As I explained above, when MammalMixin gets included into SomeController, i.e. when SomeController.include(MammalMixin) gets called, SomeController.include (which is Module#include) will in turn call MammalMixin.append_features(SomeController). MammalMixin.append_features in this case is ActiveSupport::Concern#append_features, and the most interesting part is this:

    base.class_eval(&@_included_block) if instance_variable_defined?(:@_included_block)
    

    As you can see, it is using Module#class_eval in order to evaluate the block it saved earlier in the context of the base class it is included into. And that's what makes your method end up as an instance method of the base class instead of the module.