Skip to content

Instantly share code, notes, and snippets.

@shalvah
Forked from damien-roche/rubymethodlookup.md
Last active November 5, 2022 12:10
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save shalvah/cc0c72c463aa9a5f8ebd4419b1aad726 to your computer and use it in GitHub Desktop.
Save shalvah/cc0c72c463aa9a5f8ebd4419b1aad726 to your computer and use it in GitHub Desktop.
A Primer on Ruby Method Lookup

A Primer on Ruby Method Lookup

Forked from @damien-roche's gist. Here's my addendum.

Method lookup is a simple affair in most languages without multiple inheritance. You start from the receiver and move up the ancestors chain until you locate the method. Because Ruby allows you to mix in modules and extend singleton classes at runtime, this is an entirely different affair.

I will not build contrived code to exemplify the more complicated aspects of Ruby method lookup, as this will only serve to confuse the matter. If you are having trouble following method lookup in your own programs, it is not because Ruby has strange rules (it does), it is because your code is too tangled.

When you pass a message to an object, here is how Ruby finds what method to call:

1. Look within singleton class

Simply put, a singleton method is a method that is defined on an instance as oppose to a class where the method would be available on all instances.

This takes precedence even over methods defined on the class.

Here is an example to demonstrate:

class MyCar
  def method; 'defined on car'; end
end

object = MyCar.new

def object.method
  'defined on singleton'
end

object.method # => 'defined on singleton'

2. Look within modules that extend singleton

If we can't find the method on the singleton, we'll take a look in any modules that extend the singleton.

You can extend singletons like so:

module MyModule
  def method; 'defined on MyModule'; end
end

object = MyCar.new
object.extend(MyModule)
object.method # => 'defined on MyModule'

If you extend with multiple modules, later modules take precedence:

module MyModule2
  def method; 'defined on MyModule2'; end
end

object.extend(MyModule)
object.extend(MyModule2)
object.method # => 'defined on MyModule2'

3. Look within methods defined on class

Considering you rarely extend singletons, this is usually where you would start in practice:

class MyCar
  def method; 'defined on MyCar'; end
end

MyCar.new.method # => 'defined on MyCar'

4. Look within modules that were mixed in when class was defined

So, the method doesn't exist within the class, but we mixed in a bunch of methods from a few modules:

module A
  def method; 'defined on A'; end
end

module B
  def method; 'defined on B'; end
end

class MyCar
  include A
  include B
end

Similar to when we extend a singleton, the later modules take precedence:

MyCar.new.method # => 'defined on B'

Note:

Ruby 2.0 introduced a prepend method to accompany include. Methods defined in a prepended module take precedence over methods defined on the class.

module A
  def method; 'defined on A'; end
end

class MyCar
  prepend A
  
  def method; 'defined on MyCar'; end
end

# with include A
MyCar.new.method # => 'defined on MyCar'

# with prepend A
MyCar.new.method # => 'defined on A'

5. Climb the ancestors chain

Ok, so we can't find the method on the singleton, we can't find it within the class definition, and we can't find it within any mixed in modules. What now? Well, we move up the ancestors chain and start from (3). Why not (1) you might ask? That's because the first two lookups involve singletons, whereas your ancestors are just modules/classes, not instances. This means we start with methods on the superclass, then lookup modules mixed into that class, and on we go to the next superclass.

6. Start again, check method_missing

We've reached the end of the ancestors chain and still can't find our method. What now? Ruby will now return back to (1) and run method_missing. To give an example:

class BasicObject
  def method; 'defined on Object'; end
end

class MyCar
  def method_missing(name, *args, &block)
    "called #{name}"
  end
end

MyCar.ancestors  # => [MyCar, Object, Kernel, BasicObject] 
MyCar.new.method # => 'defined on Object'

Super

The super method looks up the ancestors chain for the method name where super is called. It accepts optional arguments which are then passed onto that method. This will follow the same lookup path as outlined above from when the super method was called.

class Vehicle
  def method; 'defined on Vehicle'; end
end

class MyCar < Vehicle
  def method
    "defined on MyCar\n"
    super
  end
end

MyCar.new.method
# => "defined on MyCar"
# => "defined on Vehicle"

One More Step

There is a rule that is used to think about the above behaviour called "one step to the right, and up", which can be described as: go right into the receiver's class (singleton), then up the ancestors chain. Here is a simple visualisation to further clarify:

          Object
              ^
              |
          Parent class A
              ^
              |
              Module included by Parent Class B
              ^
              |
          Parent class B
              ^
              |
          object's class
              ^
              |
obj  ->   obj's singelton_class

BONUS: Helper methods

Here are a few methods to help you inspect the above in your own applications:

Array.ancestors
# =>  [Array, Enumerable, Object, Kernel, BasicObject]

# note: below returns Object because Enumerable is mixed in module (not a superclass)
Array.superclass
# => Object

# This will return all instance methods from the class, modules, superclasses
# (i.e. anything available to the class)

Array.instance_methods
# => [:huge, :list, :of, :methods]

# `false` argument below will only return methods defined within the Array class
Array.instance_methods(false)

# The following shows where the instance method is defined (Enumerable)
Array.instance_method(:reduce)
# => #<UnboundMethod: Array(Enumerable)#reduce> 

# `false` argument below returns methods defined only the singleton

arr = Array.new
def arr.new_method; end

arr.methods(false)
# => [:new_method]

References:

Addendum

I'm not sure how accurate this is in terms of terminology, but I think we could generalize these rules to a single one:

Walk up the ancestor chain of the object's singleton class and look for methods defined on them. If all else fails, start again with method_missing.

(ignoring modules for a moment)

Essentially, Ruby will search object.singleton_class.ancestors for the first place where the method is defined, applying the module rules at each stage.

I came across this while trying to apply these principles to class methods (SomeClass.some_method), since classes are also objects in Ruby.

class Class
  def hi = "Class hi"
end

class Parent
  def self.hi = "Parent (singleton) hi"
end

class Child < Parent
end

p Child.hi # => "Parent (singleton) hi"

If we remove the Parent.hi method, Class#hi is found. This makes sense (Child inherits from Parent, and is an instance of Class), but not exactly according to your rules; according to your rules, after Child's singleton class and modules were checked, Class should have been checked (rule 3) before the ancestor Parent's singleton class (rule 5). So I tried to dig some more:

p Child.singleton_class
# => #<Class:Child>
p Child.singleton_class.ancestors
# => [#<Class:Child>, #<Class:Parent>, #<Class:Object>, #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]

object = Child.new
p object.singleton_class
# => #<Class:#<Child:0x00000181853d05b0>>
p object.singleton_class.ancestors
# => [#<Class:#<Child:0x00000181854095e0>>, Child, Parent, Object, Kernel, BasicObject]

You can see that for a regular object, the relevant chain (singleton_class.ancestors) is what you describe (singleton, then ancestors). But for a class, it's a chain of singleton classes for each ancestor, which then leads back into Class and its ancestors.

So, in summary, rle 3 is actually an instance of rule 5, not a separate thing. Ruby moves up the singleton class ancestor chain. For regular objects, it happens to only contain the singleton class and then regular ancestors, but for classes, it will have a mini-chain of multiple singleton classes first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment