skip to content

In CRuby 3.2 and later, why does assigning every instance variable in initialize, even to nil, keep instance-variable access fast?

level: seniorimportance: nice to knowfreq 22%

answer

  1. object shapes since 3.2
  2. same ivars, same order, same shape
  3. inline caches keyed by shape
  4. 8 variations, then too complex
  5. -W:performance shows the warning

basics

~20 s

CRuby 3.2 introduced object shapes: objects that add the same instance variables in the same order share a shape, so inline caches hit. Assigning all of them in initialize keeps one shape per class; lazy or conditional assignments branch the tree and slow reads.

solid answer

~50 s

Since CRuby 3.2 each object carries a **shape** that records which instance variables it has and in what order they were added; objects that took the same path share a shape and store `@balance` at the same slot. An `@balance` read site caches the shape and the slot, so it hits only when the next object has that shape. When some `BankAccount`s set `@closed_at` and others do not, or a reader memoizes `@statement ||= ...` later, the shape tree branches. Each branch counts as a variation for the class; after 8, CRuby stops adding branches and objects that would need one fall back to a slower hash-table layout, and with `-W:performance` (Ruby 3.3+) it warns that the class "reached 8 shape variations". Assigning every instance variable in `initialize`, in a fixed order, even to `nil`, keeps instances on one shape.

code

ruby · 25 lines
ruby
# Branches the shape tree: @closed_at and @statement appear later, on some objects only
class BankAccount
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
  end

  def close!
    @closed_at = Time.now
  end

  def statement
    @statement ||= build_statement
  end
end

# One shape per instance: every variable assigned up front, in a fixed order
class BankAccount
  def initialize(owner, balance)
    @owner = owner
    @balance = balance
    @closed_at = nil
    @statement = nil
  end
end

go deeper

for a junior

Recall the habit: assign every instance variable in initialize, even to nil, in the same order.

for a middle

Explain what a shape records, why the same names in the same order share one, and how inline caches rely on that.

for a senior

Show you can diagnose it: run tests with -W:performance, trace a class that hit 8 variations to lazy or conditional assignments, and fix only the classes that are hot.

for a principal

Weigh micro-level layout discipline against readability, applying it to classes allocated at high volume and leaving rarely built objects free of the rule.

## What an object shape is A Ruby object has no fixed field list, so the interpreter needs a way to find `@balance` inside it quickly. Since **CRuby 3.2**, every object carries a **shape**: a node in a tree of transitions that records which instance variables the object has and in what order they were added. - A new object starts at the root shape, with no instance variables. - Assigning `@owner` moves it to the child shape "has `@owner`"; assigning `@balance` next moves it to "has `@owner`, then `@balance`". - Two objects that add the **same names in the same order** end up on the **same shape**, and each variable sits at the same slot index in both. The Ruby 3.2 release notes put it plainly: objects whose instance variables are defined in a consistent order see the most performance benefit, and YJIT was optimised around shapes in the same release. ## Why consistent shapes are fast Every place in the code that reads or writes `@balance` has an **inline cache**. On the first execution it remembers the receiver's shape and the slot where `@balance` lives. Next time: 1. If the receiver has the same shape, the read is a direct slot load. This is the fast path. 2. If the shape differs, the cache misses and Ruby has to look the variable up again. A class whose instances all share one shape keeps every such cache hot. ## How classes end up with many shapes Shapes branch whenever instances of a class take different paths: | Pattern in `BankAccount` | Effect on shapes | |---|---| | `initialize` assigns `@owner`, `@balance`, `@closed_at = nil` | one path, one shape | | `@closed_at` assigned only in `close!` | open and closed accounts diverge | | `@statement ||= build_statement` in a reader | a new branch the first time the reader runs | | `initialize` assigns variables in an order that depends on arguments | a branch per ordering | CRuby counts each new branch as a **variation** of the class. The limit, `SHAPE_MAX_VARIATIONS`, is 8. Once a class has reached it, CRuby stops creating new branches for it, and an object that needs one is switched to a **"too complex"** representation that keeps its instance variables in a hash table. That still works, but reads are slower and each object uses more memory. ## Seeing the problem Ruby 3.3 introduced a `performance` warning category. It is off by default, **even in verbose mode**, and is enabled with the `-W:performance` command-line flag or `Warning[:performance] = true`. When a class reaches the variation limit, CRuby prints a warning that the class reached 8 shape variations, that instance variable accesses will be slower and memory usage higher, and it recommends defining instance variables in a consistent order, for example by eagerly defining them all in `initialize`. - Turn it on in the test suite or in a staging run, where the warning costs nothing. - Treat each warning as pointing at a class whose state is assigned lazily or conditionally. ## The fix - **Assign every instance variable in `initialize`**, in the same order every time, using `nil` for values that are not known yet. - **Replace lazy `||=` memoization of new variables** with a variable pre-set to `nil` in `initialize`, then filled on demand; the object stays on its shape because the variable already exists. - **Avoid adding instance variables from outside the class** or from rarely called methods. The gain per read is small; it matters in hot classes allocated by the million, such as value objects, parsed records or model instances. For a class instantiated a handful of times it is irrelevant, and readability should win.

  • In CRuby, does plain ruby -w show the shape-variation warning?
    No. Performance warnings are a separate category that stays off by default even in verbose mode. Enable it with `-W:performance` or `Warning[:performance] = true`, both available since Ruby 3.3.
  • In CRuby 3.2 and later, is assigning nil in initialize different from never assigning the variable, as far as shapes go?
    Yes. Assigning `@closed_at = nil` adds the variable and moves the object along the shape tree, exactly like assigning any other value. Never assigning it leaves the object on a shorter path, so a later assignment creates a transition that other instances may not share.

saying these in an interview costs you the question

  • Shapes depend only on which ivars exist, not the order they were added
  • Assigning nil does not create the variable, so it does not affect shapes
  • ruby -w prints the shape-variation warning by default
  • Too many shapes raises an error once a class passes the limit
  • Lazy @x ||= memoization never changes an object's shape