June 15, 2020; in Ruby

Using Ruby Prepend to make your code super

What it is and how to use it wrong, maybe.

The use case

I was building out a deploy script that ran a bunch of checks.

class CICheck < Check 
  def initialize
    puts "Checking CI"
  end
end

class SensitiveFilesCheck < Check
  def initialize
    puts "Checking sensitive files"
  end
end

And that puts got annoying. What if I wanted to change that across all checks? Well, I could do something like this:

class Check 
  def initialize(*)
    puts @description
  end
end

class CICheck < Check
  def initialize(sha)
    @description = "Checking CI"
    super 
    @sha = sha
  end
end 

But that’s not really great. I have to remember to super in every Check. Feels like writing class components in React.

I wanted something cleaner, like:

class CICheck < Check
  desc "Checking CI"
end

And have that do all the work.

Where there’s a will, there’s probably a Ruby method or keyword

I thought to myself: “there has to be a Ruby way to do this.” And with Matz as my witness, there is.

From the docs

According to RubyDoc[^1]:

Invokes Module.prepend_features on each parameter in reverse order.

Okay but what does that mean?

When this module is prepended in another, Ruby calls prepend_features in this module, passing it the receiving module in mod. Ruby’s default implementation is to overlay the constants, methods, and module variables of this module to mod if this module has not already been added to mod or one of its ancestors. See also Module#prepend.

That’s better.

Prepend to the rescue

Let’s get weird.

class Check 
  def self.inherited(klass)
    klass.extend ClassMethods
    klass.prepend InstanceMethods
  end 

  module ClassMethods
    def description
      @description
    end
    def desc(value)
      @description = value 
    end  
  end

  module InstanceMethods
    def initialize(*)
      puts self.class.description
      super 
    end
  end
end

You know where this is going:

class CICheck < Check 
  desc "Checking CI"

  attr_reader :sha 
  def initialize(sha)
    @sha = sha
  end
end

And now I can safely:

CICheck.new("1234") # "Checking CI"

References

[^1] https://ruby-doc.org/core-2.6.1/Module.html#method-i-prepend