skip to content

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?

level: seniorimportance: should knowfreq 30%

answer

  1. bmethod versus ISEQ method
  2. the closure keeps its scope alive
  3. captured locals are shared state
  4. YJIT bails when a block is passed
  5. un-shareable Proc in a different Ractor

basics

~20 s

A 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 s

CRuby 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 lines
ruby
class Counter
  count = 0
  define_method(:tick) { count += 1 }
end

Counter.new.tick  # => 1
Counter.new.tick  # => 2, a different instance shares count

go deeper

for a junior

Recall that a define_method body is a closure while a def body is not, and that closures keep variables alive.

for a middle

Explain the bmethod frame, the captured environment and why captured locals behave as shared state across instances.

for a senior

Demonstrate measurement first, then the specific fixes: def or UnboundMethod bodies for hot or block-taking methods, small generating scopes for memory.

for a principal

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