Metaprogramming, ancestors chain and super.

dixpac

Dino

Posted on March 13, 2023

Metaprogramming, ancestors chain and super.

Let's imagine we are building DSL similar to ActiveRecord associations.

class Person
  associated_with :account
end

Person.new.account # => "Account associated with a Person"
Enter fullscreen mode Exit fullscreen mode

In order to build this feature, we will create a new module that dynamically defines association methods.

module Associations
  def associated_with(name)
    define_method(name) do
      puts "associated #{name}"
    end
  end
end

class Person
  extend Associations

  associated_with :account
end

Person.new.account #=> "associated account"
Enter fullscreen mode Exit fullscreen mode

define_method creates an instance method on a receiver, which is exactly what we need.

define_method basically has done this;

class Person
  ...

  def account
    "associated account"
  end
end
Enter fullscreen mode Exit fullscreen mode

We can easily validate this theory by inspecting the ancestors chain and instance methods:

Person.ancestors # => Person,Object,Kernel,BasicObject
Person.instance_methods(false) # => [account]
Enter fullscreen mode Exit fullscreen mode

Overwriting dynamically defined method

If we wish to overwrite a dynamically defined method we can do it without any problems since this is just a "regular" instance method (albeit defined with some metaprogramming)

class Person
  extend Associations

  associated_with :account

  def account
    "overridden"
  end
end

Person.new.account #=> "overridden"
Enter fullscreen mode Exit fullscreen mode

But, calling a super when overriding will fail

def account
  super
  "overridden"
end

Person.new.account # => `account': super: no superclass method `account' for ...
Enter fullscreen mode Exit fullscreen mode

This makes sense since we are calling super on the method we've completely overwritten.

In order for super to work the method need to be defined in Persons ancestors chain.

We can do this by generating a new module on the fly, including that module in the class and define dynamic methods on that module instead of the class itself.

module Associations
  # Create new module on the fly.
  # Include that module in the ancestor chain
  def generated_association_methods
    @generated_association_methods ||= begin
                                         mod = const_set(:GeneratedAssociationMethods, Module.new)
                                         include mod
                                         mod
                                       end
  end

  def associated_with(name)
    mixin = generated_association_methods

    # define methods on the newly created module
    mixin.define_method(name) do
      puts "associated #{name}"
    end
  end
end

class Person
  extend Associations

  associated_with :account
end
Enter fullscreen mode Exit fullscreen mode

Now dynamically defined methods live inside the Person::GeneratedAssociationMethods, which is part of ancestors chain.

Person.ancestors # => Person,**Person::GeneratedAssociationMethods**, Object,Kernel,BasicObject
Person.instance_methods(false) # => []
Enter fullscreen mode Exit fullscreen mode

So calling super will work fine:

def account
  super
  "overridden"
end

Person.new.account
  #=> "associated_account"
  #=> "overridden"
Enter fullscreen mode Exit fullscreen mode

I've seen this pattern used in Rails codebase in multiple places where this kind of behaviour is needed.

✌️

💖 💪 🙅 🚩
dixpac
Dino

Posted on March 13, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related