4

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.

azino777
  • 43
  • 5

2 Answers2

7

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.

Jörg W Mittag
  • 363,080
  • 75
  • 446
  • 653
  • 1
    Jörg, I can't tell how impressed I am with the quality of your detailed answer! Thank you so much for digging into this as deep as possible, hopefully, this will be useful not only for me, but other people as well! This is *super* clear now :) – azino777 Jun 29 '20 at 11:06
3

The issue here is that included is not doing the same thing as your first example, it's a custom hook defined by ActiveSupport::Concern that allows you to write class macros (like adding a scope to an ActiveModel). Under the hood here's what included does:

  def self.included(base)
    base.extend ClassMethods
    base.class_eval do
      <your-block-executes-here>
    end
  end

So when pass a block into included, that block is being evaluated on the class directly and there's no super to reference; you're essentially overwriting the method, not overriding

Syntactic Fructose
  • 18,936
  • 23
  • 91
  • 177
  • The OP is using mixins in *both* examples, and `super` clearly works in the first but not the second. They are asking *why* it doesn't work in the second. The answer "because mixins" is clearly wrong, as demonstrated in the question. – Jörg W Mittag Jun 25 '20 at 17:08
  • You're right, I edited my answer to address the key difference @JörgWMittag – Syntactic Fructose Jun 25 '20 at 17:26
  • 1
    Thank you very much for your response! That is something I suspected is happening, but was not too sure, thank you for the clarification. I believe this is the part, though I'm not sure where this method gets executed from https://github.com/rails/rails/blob/357e15290ab06af8b4f32dd480b48a8c6e31e9e7/activesupport/lib/active_support/concern.rb#L136 – azino777 Jun 26 '20 at 09:05