In CRuby 4.0, why can a method generated with define_method cost more than one written with def, and when does that difference matter?
answer
- bmethod versus ISEQ method
- the closure keeps its scope alive
- captured locals are shared state
- YJIT bails when a block is passed
- un-shareable Proc in a different Ractor
basics
~20 sA define_method body is a closure: it keeps its whole defining scope alive, shares captured variables across calls, and YJIT compiles fewer call shapes for it, such as calls passing a block. Only hot paths, big closures or Ractor code justify rewriting.
solid answer
~40 sCRuby runs a `def` method as a plain instruction sequence, while a block-bodied `define_method` method is a bmethod: each call pushes a block frame tied to the captured environment. The interpreter has a fast path, so simple calls differ little. The real costs: the closure keeps the entire defining scope alive, which can pin large temporaries for the process's life; assignments to captured locals are shared by every instance and thread; YJIT (off by default) bails to the interpreter when such a method is called with a block or a `*args` splat; and a non-shareable Proc body cannot be called from another Ractor. So I profile first, and for hot, block-taking or memory-heavy generated methods I write `def` or reuse an `UnboundMethod` instead.
code
ruby · 7 linesclass Counter
count = 0
define_method(:tick) { count += 1 }
end
Counter.new.tick # => 1
Counter.new.tick # => 2, a different instance shares countgo deeper
Recall that a define_method body is a closure while a def body is not, and that closures keep variables alive.
Explain the bmethod frame, the captured environment and why captured locals behave as shared state across instances.
Demonstrate measurement first, then the specific fixes: def or UnboundMethod bodies for hot or block-taking methods, small generating scopes for memory.
Set the team's bar for generated methods on hot paths and in Ractor-bound code, weighing brevity against memory, JIT coverage and portability.
## Two kinds of method body CRuby stores a method written with `def` as an **instruction sequence** (an ISEQ method): compiled bytecode with its own fresh local table. A method made by `define_method` with a block is a **bmethod** (block method): the stored body is a lambda, and each call pushes a **block frame** linked to the **captured environment** of the scope where the block was written. The interpreter has a dedicated fast path for bmethods, so for simple bodies the per-call difference is small. The costs that matter in production are elsewhere. ## Cost 1: the captured environment A block keeps the whole local scope it closes over alive, not only the variables it reads. In a class body such as: ```ruby class Catalog rows = File.readlines("catalog.csv") # large, temporary rows.first.chomp.split(",").each do |col| define_method(col) { @data[col] } end end ``` every generated method's closure refers to the class body's environment, and that environment holds `rows`. The file's lines stay reachable for as long as the methods exist, which for a class is the life of the process. A `def` method captures nothing. Fixes: build the values in a method that returns only what you need, or set big temporaries to `nil` before the class body ends. Captured locals are also **shared mutable state**. If a body assigns to a captured variable (`count += 1`), every instance and every thread writes the same variable. ## Cost 2: JIT coverage With **YJIT** enabled (it is off by default; `--yjit` or `RUBY_YJIT_ENABLE` turns it on), calls to a block-bodied `define_method` are compiled, but CRuby 4.0's YJIT gives up and leaves the call to the interpreter in some cases that `def` methods do not hit: | Call shape | `def` method | `define_method` block body | |---|---|---| | plain positional call | compiled | compiled | | caller passes a block | compiled | not compiled (YJIT bails) | | caller uses a `*args` splat | often compiled | not compiled (YJIT bails) | A generated `each_row` that receives a block on every call therefore runs interpreted even with YJIT on. Running with `--yjit-stats` prints counters for these fallbacks (`send_bmethod_block_arg`, `send_args_splat_bmethod`), which is how you confirm it for a hot method. ## Cost 3: Ractors A method whose body is a non-shareable Proc can only be called in the Ractor that defined it; calling it from another Ractor raises `RuntimeError` ("defined with an un-shareable Proc in a different Ractor"). A `def` method has no such restriction. Ractors are still experimental in Ruby 4.0, but code that hopes to run in them should prefer `def`. ## What does not differ It is worth being precise about what stays the same, because interviews often overstate the gap: - **Lookup** of the method name is identical: both kinds live in the class's method table and use the same caches. - **`self`**, instance variables and method calls inside the body resolve the same way. - **Arity checks** are strict in both. - **Introspection** works on both: `instance_method`, `arity` and `source_location` report them. ## When it matters, and what to do 1. **Measure first.** For methods called a few thousand times a request, none of this shows up. Profile before rewriting generated code; the benchmark-ips gem compares two implementations directly. 2. **Hot generated methods:** if a profiler shows a generated method in the hot path, generate it without a closure. Common options are a `def` per name written out by hand, or reusing an existing method via an `UnboundMethod` body. 3. **Large closures:** keep the generating scope small, or generate inside a method so the captured environment only holds the loop variable. 4. **Blocks passed in:** if the generated method yields to a caller's block on every call, prefer `def` for that one. ## What to say in an interview `define_method` is not "slow" in general. It creates a closure-backed method: it keeps its defining environment alive, it shares captured variables across instances and threads, and JITs compile fewer call shapes for it. For hot paths, closures over big scopes, block-taking methods or Ractor-bound code, write `def` methods or generate them without a closure; everywhere else, readability decides.
- How do you confirm YJIT is falling back to the interpreter for a generated method?Run with `--yjit-stats` and read the fallback counters it prints at exit; entries such as `send_bmethod_block_arg` show calls to block-bodied methods that were not compiled because the caller passed a block. Then compare timings with a `def` version using a benchmark before changing code.
- Does replacing define_method with an UnboundMethod body avoid the closure?Yes. When the body is an existing method taken with `instance_method`, the new name points to that method's instruction sequence, so there is no block frame and no captured environment. It only works when a real method with the right behaviour already exists.
saying these in an interview costs you the question
- define_method methods are always several times slower than def
- a define_method closure keeps only the variables it reads
- captured locals are copied per instance, so they are thread-safe
- YJIT compiles define_method calls exactly like def calls
- YJIT is on by default in Ruby 4.0