How Ruby resolves methods internally
When you call user.name, Ruby performs a ritual. Method lookup, inline cache, method_missing, singleton classes — the real mechanics behind a simple call.
How Ruby resolves methods internally
You write:
user.name
And think: "Ruby called the name method on the user object."
Wrong.
Ruby performed a ritual.
And understanding that ritual is the difference between knowing Ruby and knowing Ruby.
What happens when you call a method
When you invoke user.name, Ruby needs to answer:
- What's the class of
user? - Does that class define
name? - If not, what's the next class in the hierarchy?
- Go up until
BasicObject. - Not found?
NoMethodError.
That step-by-step is called method lookup.
And it's where Ruby silently spends time.
The class isn't where you think it is
When you do:
user = User.new
You think user.class == User?
Partially yes.
But there's more to it. Each object carries a singleton class (also called eigenclass or metaclass).
user
↓
[singleton class of user]
↓
User
↓
ApplicationRecord
↓
ActiveRecord::Base
↓
Object
↓
Kernel (included module)
↓
BasicObject
That's the path Ruby walks every time you call a method.
Method lookup in practice
class A
def hello = "A#hello"
end
class B < A
def hello = "B#hello"
end
B.new.hello # => "B#hello"
Why?
Because Ruby looked at B first. Found hello there. Stopped.
Never even reached A.
Now:
class A
def hello = "A#hello"
end
class B < A
end
B.new.hello # => "A#hello"
Ruby looked at B, didn't find it. Went up to A. Found it. Used it.
That "going up" is literal. There's a chain. Ruby walks through it.
Modules slot into the middle of the chain
module Greetable
def hello = "from module"
end
class User
include Greetable
end
User.ancestors
# => [User, Greetable, Object, Kernel, BasicObject]
include inserts the module above the class and below the next ancestor.
prepend inserts below the class itself — between user and User. A surgical shortcut to intercept methods.
module Logger
def hello
puts "[log] calling hello"
super
end
end
class User
prepend Logger
def hello = "hello"
end
User.new.hello
# [log] calling hello
# => "hello"
This is how Active Support patches things in Rails without you noticing.
Inline cache: the silent savior
If Ruby did the full lookup every time you called a method, performance would be awful.
That's why the VM has an inline method cache (IC).
At each call site, Ruby remembers:
- what the receiver's class was
- which method was found
Next time that same call site executes with the same class, Ruby skips the lookup. Goes straight there.
1000.times { user.name } # IC hit on 999 of the calls
Cache hit = fast.
Cache miss = full lookup.
What invalidates the cache
- defining new methods at runtime (
define_method, monkey patches) - changing an object's class (
extend,singleton_method) - redefining constants
- altering the hierarchy (rare, but it happens)
When you do heavy metaprogramming at runtime, you're continuously invalidating caches.
That's why metaprogramming has a cost.
That's why method_missing is beautiful but expensive.
method_missing and respond_to_missing?
When lookup fails completely (reached BasicObject without finding it), Ruby calls method_missing on the receiver.
class GhostMethod
def method_missing(name, *args)
"you called #{name}"
end
end
GhostMethod.new.whatever
# => "you called whatever"
Useful. Dangerous.
Each call via method_missing is the entire lookup failing. No inline cache. Always slow.
And worse: respond_to? lies if you don't implement respond_to_missing? too.
Singleton methods break the rule
user = User.new
def user.special_method
"only I have this"
end
user.special_method # => "only I have this"
User.new.special_method # NoMethodError
Where does that method live?
In the singleton class of that specific object.
user
↓
[singleton of user] — special_method is here
↓
User
↓
...
Every time you do class << self, you're opening that class.
Class methods (def self.foo) are actually singleton methods of the class (which is also an object).
Everything in Ruby is an object.
Including classes.
Why this matters in production
- Performance: lots of dynamic metaprogramming = constant cache misses = more CPU
- Debugging: understanding why a method was overridden requires understanding the chain
- Subtle bugs:
includevsprependchanges who wins - Frameworks: Rails, Devise, AR concerns — all playing with the ancestors chain
You don't need to think about this every day.
But the day you do, either you know it or you're lost.
The big shift
Ruby doesn't "call a method".
Ruby resolves a method.
And resolution is a process. It has order. It has cost. It has cache. It has invalidation.
When you understand this, metaprogramming stops being mystical.
And debugging Rails (which is metaprogramming taken to the extreme) starts making sense.
Conclusion
Method lookup is one of the most beautiful and most dangerous things in Ruby.
Beautiful because it allows the absurd expressivity of the language.
Dangerous because you can accidentally:
- override a method without noticing
- destroy performance with
method_missing - create invisible bugs with
prepend
Ruby gives you too much freedom.
And whoever understands the lookup ritual is the one who uses that freedom well.
