In CRuby 4.0, what does answering a hot call through method_missing cost, and how does defining the method on the first miss change that?
answer
- failed lookup, then a second dispatch
- string work on every call
- YJIT leaves the site interpreted
- define_method, then public_send
- bounded names, per-instance data
basics
~20 sEach ghost call pays a failed lookup, a second dispatch to method_missing and the handler's own string checks, and YJIT does not compile such a call site. Defining a real method on the first miss makes later calls ordinary, compilable method calls.
solid answer
~40 sA call that ends in `method_missing` first fails lookup through the ancestors (cached as a negative entry after the first time), then the VM re-dispatches it as `method_missing(name, *args)`, and the handler typically converts and checks the name on every call. YJIT, which is off by default, gives up on call sites whose lookup finds nothing, so they stay interpreted. The remedy is to define the method on the first miss, for example `self.class.define_method(name) { @values.fetch(key) }` followed by `public_send(name)`, so later calls hit a real method. Caveats: the method then exists for every instance, so keep a key check in its body; only do this for a bounded set of names; each definition invalidates cached lookups once; and concurrent first calls may define it twice.
code
ruby · 20 linesclass Settings
def initialize(values) = @values = values
def method_missing(name, *args)
key = name.to_s
return super unless args.empty? && @values.key?(key)
self.class.define_method(name) { @values.fetch(key) }
public_send(name)
end
def respond_to_missing?(name, _priv = false) = @values.key?(name.to_s) || super
end
s = Settings.new("port" => 8080)
Settings.method_defined?(:port) # => false
s.port # first call defines port, => 8080
Settings.method_defined?(:port) # => true
Settings.new({}).port # KeyError: key not found: "port"go deeper
Recall that a ghost call does extra work on every call, and that a real method avoids it.
Explain the failed lookup, the second dispatch and the handler's own work, and how define-on-miss removes them after one call.
Show the caveats you would check: per-instance data leaking through class-wide methods, unbounded names, cache invalidation and concurrent first calls, plus measuring first.
Decide which shared objects may use ghost methods at all on hot paths, and when eager generation or a plain fetch API should be the rule.
## What a ghost call costs in CRuby A call answered by `method_missing` does more work than a call to a defined method, every time: 1. **The failed lookup.** Ruby searches the receiver's class and ancestors and finds nothing. CRuby caches negative results per name, so repeated misses are cheaper than the first, but the call site still cannot bind to a real method. 2. **A second dispatch.** The VM shifts the arguments to insert the name as a new first argument and dispatches `method_missing`, a separate method call with its own frame. 3. **Your code.** A typical `method_missing` converts the Symbol to a String, tests a prefix or a Hash, and maybe calls `super`. That runs on every call. 4. **No JIT help at that call site.** YJIT (off by default; enable with `--yjit` or `RUBY_YJIT_ENABLE`) gives up on a call site whose method lookup finds nothing and leaves it to the interpreter. A hot ghost call therefore stays interpreted, while a defined method at the same site would be compiled. For a configuration object read a few times per request, none of this matters. For a method called millions of times in a loop, it can be a visible fraction of the profile. ## Defining the method on the first miss The standard remedy keeps the convenience and removes the repeated cost: when `method_missing` sees a name it can handle, it **defines a real method** for that name and then calls it. Every later call finds the method in the table and never reaches `method_missing` again. ```ruby class Settings def initialize(values) = @values = values.transform_keys(&:to_s) def method_missing(name, *args, &block) key = name.to_s return super unless args.empty? && @values.key?(key) self.class.define_method(name) { @values.fetch(key) } public_send(name) end def respond_to_missing?(name, include_private = false) @values.key?(name.to_s) || super end end ``` The first `settings.port` goes through `method_missing`; the second is an ordinary method call that YJIT can compile. ## The caveats a senior engineer names | Caveat | Why it bites | Mitigation | |---|---|---| | **Per-instance keys, class-wide methods** | the method now exists for every Settings object, including ones without that key | keep the defined body checking the key (`fetch` raises `KeyError`), or define on the singleton class | | **Unbounded names** | each distinct name becomes a Symbol and a method that is never removed | only use it when the set of names is bounded, such as known config keys | | **Cache invalidation** | defining a method clears cached lookups for that name, and YJIT discards code that assumed the old lookup | the cost is paid once per name, so warm up before measuring | | **Concurrent first calls** | two threads may both miss and both define the same method | harmless when the body is identical, but keep the body free of side effects | | **Discoverability** | the method set depends on which names were used | document the pattern; `respond_to_missing?` keeps reflection consistent before the first call | Defining on the singleton class (`define_singleton_method`) avoids leaking a method to other instances, but allocates a singleton class per object and spreads call sites across many classes, which weakens inline caches. For shared configuration, the class-wide method with a `fetch` inside is usually the better trade. ## Alternatives when names are known up front - **Define everything eagerly** in `initialize` or when the file is loaded, from the list of keys. - **Use `Struct` or `Data.define`** when the field set is fixed. - **Expose `fetch`/`[]`** and skip ghost methods entirely on hot paths. ## How to decide - Measure first: profile the hot path before optimising it. - If `method_missing` shows up, and the name set is bounded, define on first miss. - If the name set is fixed, generate the methods eagerly or use a value class. - If the names are unbounded (user input, arbitrary keys), keep `method_missing` or, better, a plain `[]` API. ## What an interviewer is listening for The costs: a failed lookup plus a second dispatch plus your own string work on every call, and no JIT compilation at that site. The remedy: define a real method on the first miss, with the caveats about per-instance data, unbounded names, cache invalidation and thread races.
- Why call public_send(name) after defining the method, instead of returning the value directly?It proves the newly defined method works and keeps a single code path for reading the value, so the first call and every later call behave identically. Returning `@values[key]` directly would duplicate the logic, and a bug in the defined body would only show up from the second call onwards.
- Why is defining on first miss dangerous when the names come from user input?Every distinct name becomes a Symbol and a method that stays in the class for the life of the process, so arbitrary input grows memory without limit and adds methods nobody intended. It also turns input into method names on a shared class. Keep this pattern for bounded name sets and use `[]` or `fetch` for anything open-ended.
saying these in an interview costs you the question
- method_missing calls are as fast as defined methods after warm-up
- YJIT compiles ghost calls just like defined methods
- defining a method at run time has no cost beyond the first call
- a method defined on the class only affects the instance that missed
- define-on-miss is safe for any names, including user input