Class methods and singleton methods

15 Apr 2015

Class methods are methods on a object's singleton class. Everyone knows this (1). I think I sort of knew it also, but recently I was working on a thing and this was brought home to me.

I was working on integration tests for filter_decrufter, so I wanted to define a sort of stubbed out ActionController::Base (2) with a class method that simulated defining a before action:

module ActionController
  class Base
    def self.before_action(*args)
    end
  end
end

And I had a subclass that attempted to call that before_action class method:

class AnotherFakeController < ApplicationController
  before_action :foo, :only => [:bar]
  def foo
  end
  def self.my_action_methods
    [:foo]
  end
end

Then filter_decrufter could define a singleton method that would check the before_action arguments and flag any options for missing actions:

# in a loop where filter_sym is before_action, after_action, around_filter, etc
ActionController::Base.define_singleton_method(filter_sym) do |*args, &blk|
  # ... gather some data about *args ...
  super(*args, &blk)
end

What I was seeing, though, was that AnotherFakeController would raise an exception when I loaded it and it attempted to call the parent class method as part of the class definition:

  1) Error:
FilterDecrufterTest#test_finds_problems:
NoMethodError: super: no superclass method `before_action' for AnotherFakeController:Class
    /lib/filter_decrufter/checker.rb:100:in `block in patch_method'
    /test/integration/another_fake_controller.rb:3:in `<class:AnotherFakeController>'

But why? The before_action method is declared right there in ActionController::Base!

The problem was that the before_action method that ActionController::Base defined was living on ActionController::Base's singleton class. No need to take my word for it though; you can verify this by defining a class method and checking the singleton methods:

irb(main):001:0> class Foo ; def self.bar ; puts "Foo#bar" ; end ; end
=> :bar
irb(main):002:0> Foo.singleton_methods
=> [:bar]

So when I defined a singleton method on ActionController::Base I was not intercepting the method call like I intended. Instead, I was redefining the existing method. And my new method definition called super, but since I'd redefined the only method with that name in this class's ancestor chain, there was no superclass method by that name available, and so bam, exception.

As a side note, singleton_methods looks up the inheritance chain, so it's not quite reliable for saying "this method is defined right here":

irb(main):001:0> class Foo ; def self.bar ; end ; end
=> :bar
irb(main):002:0> class Buz < Foo ; end ; class Biz < Buz ; end
=> nil
irb(main):003:0> Biz.singleton_methods
=> [:bar]

Back to the original problem - how to solve it? By defining the method not on the singleton class but instead further up the ancestor chain. And how to do that? By defining the method in a module and extend'ing that module:

# Define a method with our method in it
irb(main):001:0> module Buz ; def bar ; puts "Buz#bar" ; end ; end
=> :bar
# extend that module so that it's a class method
# rather than include'ing which would make it an instance method
irb(main):002:0> class Foo ; extend Buz ; end
=> Foo
# now define a singleton method that will intercept invocations of that method
irb(main):003:0> Foo.define_singleton_method(:bar) { puts "Foo#bar" ; super() }
=> :bar
# and demonstrate the that interceptor gets called first and then calls the superclass
irb(main):004:0> Foo.bar
Foo#bar
Buz#bar

I think the lessons learned are the usual ones. Unexpected exceptions are an opportunity for learning something. Don't confuse Java static methods with Ruby's class methods. Verify expected behavior in irb or in a small program. And read books written by people who have poured a lot of time and energy into the topic that's currently giving you trouble!

(1) Because everyone's read Paolo Perrotta's excellent Metaprogramming Ruby 2nd Ed.

(2) You could argue that I should just declare a dependency on actionpack and use it. That probably would be better; I might do that.