Noel Rappin Writes Here

Better Know A Ruby Thing: Method Lookup

Posted on March 9, 2025


Notes and Corrections

Before we get fully started here, a couple of notes on Better Know Singleton Classes, which, among other things, got mentioned on Hacker News, giving me comments there for the first time in years, maybe for the first time ever.

One Hacker News comment suggested that “Eigenclass” was coined by _why the lucky stiff as a joke and was then adopted by the community. I looked this up and that doesn’t seem to be the case… _why’s Poignant Guide To Ruby uses “metaclass” when it discusses this feature (at least the version I have does…). Matz used “eigenclass” in the O’Reilly Ruby book. (You didn’t ask, but version 1 of the Pickaxe uses “singleton class”, per https://ruby-doc.com/docs/ProgrammingRuby/).

It’s also worth mentioning that both “eigenclass” and “metaclass” are used in the Ruby source, but to mean slightly different things. I did not stare at the code long enough to figure out exactly how they are different. For example, this is a comment in module.h.

* - eigenclass: = singleton class
* - metaclass: class of a class.
Metaclass is a kind of singleton class.

I think I can get this to make sense if I stare at it hard enough, but I am not at all sure.

One or two people (including https://bsky.app/profile/ufuk.dev) correctly noted that my description of how singleton classes work in Ruby is, at best, simplified, and at worst, inaccurate. Basically, my description is consistent with what Ruby’s API allows you to determine about classes, but is not a reflection of how Ruby works internally. Ruby’s internals are closer to Smalltalk than I let on, we’ll talk more about that in a second, because it’s important to method lookup.


I haven’t had this inserted commercial message in the last few emails, because I fear that it is annoying, but then we picked up a handful of new readers from the Hacker News thing so… quick tour, bear with me.

Thanks!


Method Lookup In Ruby

And with that, let’s talk about method lookup in Ruby, which is from one perspective really clean and simple, and from another angle, kind of messy.

This is part of what the official Ruby documentation says about method lookup:

Here is the order of method lookup
for the receiver's class or module `R`:

- The prepended modules of `R` in reverse order
- For a matching method in `R`
- The included modules of `R` in reverse order

If `R` is a class with a superclass,
this is repeated with `R`'s superclass until a method is found.

What’s striking about this is it is:

  • not fully accurate, because it doesn’t include the singleton class
  • overly confusing, because there’s a simpler way to talk about the lookup order.

To be fair, this is how I talked about method lookup in Ruby for a long time. The way I’d describe now it is:

Given a receiver object, r,

  • Ruby takes the list of classes and modules that you can see defined in r.singleton_class.ancestors. This list contains all the classes and modules in r's ancestor tree.
  • Ruby looks at the first element in the ancestors list and if it has the method, that’s called. If not, it goes to the next element and so on..

Note: Ruby does not literally call this ancestors method – I don’t think – but this is conceptually the list that Ruby uses for lookup.

For example…

module Pre
  def say = "prefix"
end

class Kla
  prepend Pre
  def say = "class"
end

r = Kla.new
r.singleton_class.ancestors

Results in:

[<Class<Kla:0x0000000122d165f0>>,
 Pre,
 Kla,
 Object,
 JSON::Ext::Generator::GeneratorMethods::Object,
 PP::ObjectMixin,
 Kernel,
 BasicObject]

The point here is if I were to call the method r.say, Ruby will first look in the singleton class, where it will not find a say method, and then look in the prepended module, Pre, where it will find the method and it will execute that call. Ruby will then stop, so the fact that the method is also defined in the class Kla is not used.

If we then write this code:

def r.say = "banana"

Then the singleton class does have a say method and r.say now returns banana and stops lookup at that point.

So, how does one get on the ancestor list?

The official Ruby documentation for method lookup effectively describes this process of creating the ancestor list.

Conceptually, the ancestor list looks like this:

  • Start with the inheritance tree, from the class to its superclass and so on to Object and then BasicObject.
  • For each class in that list, any modules that are added using prepend are inserted in the ancestor list before the class that prepends them. If the class prepends more then one module, a second prepended class is added before the first prepended class, and so on. The result is that modules that are prepended later supersede modules prepended earlier.
  • For each class in the list, any modules that are added using include are inserted after the class that includes them. Again, modules included later are inserted earlier, so that the last module included wins.
  • This process is recursive, if an included module also has includes and prepends, those modules also go on the tree relative to that module.

You can see these behaviors in our example. The Kla class prepends the Pre module, so Pre is in the ancestor tree before Kla. Later, JSON::Ext::Generator::GeneratorMethods::Object, PP::ObjectMixin, and Kernel are all included by Object. (Technically, the JSON and PP modules are, I think, monkey patched in when the JSON and PP gems are auto-included, Kernel and Object also are a little non-standard in their actual internals, for performance reasons).

But I thought we said something about singleton classes?

We did. Or rather, I did.

What I said in the past blog post, and actually, the way I was taught Ruby, was that Ruby objects effectively have a pointer to their singleton class, and that method lookup is sort of special-cased to start at the singleton class.

That’s, like, true-ish, in the sense that following that model will allow you to accurately predict Ruby’s behavior, but it’s not how the internals work. Even what I said above about method lookup being based on the ancestor list of obj.singleton_class isn’t exactly how the internals work, though it’s (I think) as close as you can get given Ruby’s public API.

What Ruby actually does, and thanks to Ufuk Kayserilioglu for this, is that Ruby makes the singleton class the “Class” of the instance, and then makes the actual class the superclass of the singleton class. Ruby hides this from you in the public library methods – if you say "thing".class, you get String, not thing’s singleton class. I think – I think – that the only way you can really see the actual inheritance tree is by asking the singleton class for its ancestors list.

Why does it matter how singleton classes work? Well, integrating singleton classes into the ancestor list makes method look up much more elegant. You don’t have to do weird contortions to talk about included and prepended modules the way the official docs do. It’s just a simple walk up the ancestor list, all the extra modules and singleton classes are rolled up into that list and all you need to do is go through the list one by one. The complication is in creating that ancestor list, not in traversing it.

What if the method isn’t found?

That’s usually bad.

If you get all the way up to BasicObject and nobody has matched the method name, then the method is… wait for it… missing. I did a whole newsletter about method_missing, so we’re not going to go over that whole thing.

The point here is how Ruby behaves, and what Ruby does is start the search all over again from the singleton class of the original receiver object, but this time rather than searching for the method name, it searches for method_missing. If some class or module in the chain implements method_missing, it’s executed. Eventually you get to BasicObject, which definitely implements method_missing – specifically it throws a NameError.

That’s super

Most Object-Oriented languages let you execute a superclass method with the keyword super or something similar, and Ruby is not an exception. Ruby has keywords for when the parser needs to do something unusual, and super looks like a method call but doesn’t quite behave like a method call.

A call to super is evaluated as “take the name of the method the super call is in, call that method, but start one level up on the ancestors list”. In other words, super doesn’t only follow the inheritance hierarchy, it follows the full ancestors list with modules that have been added using include or prepend.

There are a couple of fun side-effects of that definition.

Calling super from a prepended module will almost always take you back to the original method. This means you can create wrappers to insert when debugging…

If you want to trace an unreachable method in a class you don’t own – Fill in your own name instead of unreachable_method:

module Debug
  def unreachable_method(*args, **kwargs)
    p "calling unreachable method with #{args} #{kwargs}"
    puts caller[0..3]
    super
  end
end

class ClassIDontOwn
  prepend Debug
end

I’m prepending a module to capture calls, but then using super to continue along the normal track. Calling super is a technique to use with method_missing, as well – handle the method name if you can, continue normal processing with super. (Before prepend you could do something similar by aliasing a method, but that was more complex and confusing).

The way that super handles arguments is what makes it a keyword.

Invoking super with no arguments continues the call stack implicitly passing the same arguments that were passed to the original method. But, you can override this – if you explicitly pass arguments to super, then those arguments are what’s passed to the next method in the list. This is a useful feature in the not-unheard-of case where a subclass adds arguments to initialize that are not in the parent class:

class Parent
  def initialize(name)
    @name = name
  end
end

class Child < Parent
  def initialize(name, age)
    @age = age
    super(name)
  end
end

In this case, the Child class takes an extra argument, but passes just the first argument back to the Parent class to get its functionality.

Which gets us to a legitimate piece of trivia, the only (I think) place in Ruby where empty parentheses are syntacticly meaningful is in a call to super. If you want to explicitly pass no arguments to the parent class, the call needs to be super().

class Thing
  def value
    25
  end
end

class LittleThing < Thing
  def value(expensive = true)
    50 if expensive
    super()
  end
end

The parent class method takes no arguments so the subclass needs to explicitly pass no arguments to the parent in order to use super.

Did you know refinements are a thing?

Yes, they are. Refinements are a way of customizing Ruby’s behavior locally – it’s a monkey patch but it’s only applicable where explicitly mixed in. Refinements change class method lookup, but not for the class that incudes the refinement, but for the class referenced by the refinement. Without going into a 1500 word digression for a feature I’ve used exactly once since it was added to Ruby ten years ago…

If a refinement is active for the receiving class or module, then the refinement is effectively placed in the ancestor list before the class or module and before anything prepended by the class or module. I don’t really know how this works internally and I have to save something for Future Noel to do if we ever decide to Better Know refinements, but effectively anything in the refinement goes after the singleton class and before anything that is actually connected to the class. If, for some reason, the refinement prepends or includes modules, those are placed relative to the refinement exactly as they would be for a class or module.

Hey, I was wondering… can a singleton class have a singleton class

Yes. We will not be investigating this further.



Comments

comments powered by Disqus



Copyright 2025 Noel Rappin

All opinions and thoughts expressed or shared in this article or post are my own and are independent of and should not be attributed to my current employer, Chime Financial, Inc., or its subsidiaries.