Формы объектов
Предпосылки: Объекты и классы — RObject, массив значений ivar, RBasic, shape_id (кратко).
← Модули | Диспетчеризация методов →
В заметке об объектах мы видели, что RObject хранит массив значений инстанс-переменных — без имён. "Leonhard" лежит в ячейке 0, "Euler" — в ячейке 1. Но как Ruby узнаёт, что @first_name — это ячейка 0, а @last_name — ячейка 1? Когда VM выполняет getinstancevariable @first_name, ему нужна таблица, которая преобразует имя переменной в индекс.
Имя → индекс: хеш-таблица на класс
До Ruby 3.2 каждый класс хранил хеш-таблицу iv_index_tbl, которая сопоставляла имена переменных с индексами в массиве:
Mathematician
iv_index_tbl: { @first_name → 0, @last_name → 1 }
euler: ivar_array = ["Leonhard", "Euler"]
euclid: ivar_array = ["Euclid", nil ]
Все экземпляры класса ссылались на одну таблицу. При чтении @first_name Ruby искал имя в хеше, получал индекс 0 и читал значение из ivar_array[0].
Это работает. Но каждое обращение к переменной — поиск в хеш-таблице. attr_reader в горячем цикле вызывается миллионы раз — hash lookup на каждый вызов заметен. Можно ли запомнить ответ?
Идея кеша и его проблемы
Идея: инструкция getinstancevariable запоминает результат — «@first_name лежит по индексу 0». При следующем вызове — сразу ivar_array[0], без хеша. Но кеш может оказаться неправильным. Вот три конкретные ситуации.
Объект эволюционирует. Ленивая инициализация — частый паттерн:
class Mathematician
attr_accessor :first_name, :last_name
def department
@department ||= "Mathematics"
end
end
euler = Mathematician.new
euler.first_name = "Leonhard"
euler.last_name = "Euler"
# У euler два ivar: @first_name(индекс 0), @last_name(индекс 1)
euler.department
# Теперь три: @first_name(0), @last_name(1), @department(2)
# Структура объекта измениласьЕсли инструкция закешировала ответ для объекта с двумя переменными, а после вызова department у объекта стало три — кеш может быть устаревшим. В данном случае индексы @first_name и @last_name не изменились, но кеш этого не знает — ему нужен способ проверить.
Разные объекты — разная структура. Если в классе есть условная инициализация:
class Mathematician
def initialize(famous:)
@first_name = nil
@last_name = nil
@awards = [] if famous # не у всех есть @awards
end
end
euler = Mathematician.new(famous: true) # 3 ivar
gauss = Mathematician.new(famous: false) # 2 ivarОдин и тот же метод full_name вызывается то на euler (три переменные), то на gauss (две). Кеш настроился на одного — промахнулся на другом.
Разный порядок — разные индексы. Самая опасная ситуация:
m1 = Mathematician.new
m1.first_name = "Leonhard" # @first_name → индекс 0
m1.last_name = "Euler" # @last_name → индекс 1
m2 = Mathematician.new
m2.last_name = "Gauss" # @last_name → индекс 0
m2.first_name = "Carl" # @first_name → индекс 1У m1 @first_name на позиции 0, у m2 — на позиции 1. Если кеш запомнил «@first_name = индекс 0» по m1, то при обращении к m2 вернёт @last_name. Это не просто промах — это неправильный ответ.
Кешу нужен ключ валидации — одно число, которое полностью описывает структуру объекта (какие переменные, в каком порядке). Если ключ совпал — кешированный индекс верный. Если нет — пересчитать. Такой ключ должен сравниваться за одну операцию и быть одинаковым у объектов с одинаковой структурой.
Формы (shapes)
С Ruby 3.2 появились object shapes. Shape — описание структуры объекта: какие переменные, в каком порядке. Каждый объект несёт идентификатор своей формы (shape_id) в заголовке — упакован в верхние биты RBasic.flags на 64-битных системах, без дополнительного расхода памяти.
Shapes образуют глобальное дерево переходов. Корень — пустая форма (нет переменных). Каждый переход — добавление одной переменной:
root (нет переменных)
└─ @first_name → shape A (index=0)
└─ @last_name → shape B (index=1)
Когда euler получает @first_name, его shape_id переходит от root к shape A. Когда получает @last_name — от A к B. Каждый переход записывает имя переменной и её индекс в массиве.
shape_id полностью описывает структуру. Это и есть ключ валидации: если cached_shape_id == object.shape_id — структура не изменилась, кешированный индекс верный.
Важное свойство: формы не привязаны к классу. Два объекта разных классов, получившие одинаковые переменные в одинаковом порядке, разделяют одну форму:
class Mathematician; attr_accessor :first_name, :last_name; end
class Physicist; attr_accessor :first_name, :last_name; end
euler = Mathematician.new; euler.first_name = "Leonhard"; euler.last_name = "Euler"
newton = Physicist.new; newton.first_name = "Isaac"; newton.last_name = "Newton"
# Оба → shape B (одинаковые переменные, одинаковый порядок)Проверить можно через ObjectSpace.dump — у обоих объектов одинаковый shape_id, хотя классы разные.
Вернёмся к трём проблемным ситуациям и проверим, как формы их решают:
- Объект эволюционирует (
@departmentдобавлен):shape_idизменился (B → C). Кеш промахивается, пересчитывается для нового shape. Следующие вызовы попадают. - Разная структура (euler с
@awards, gauss без): разныеshape_id. Кеш промахивается при переключении между объектами, пересчитывается. Каждый промах стоит одного пересчёта. - Разный порядок (m1: first→last, m2: last→first): это разные ветки дерева, разные
shape_id, разные индексы. Кеш не может ошибиться —shape_idне совпадёт, произойдёт пересчёт с правильным индексом.
Ни один из сценариев не приведёт к ошибке. В худшем случае — промах, пересчёт и обновление кеша.
Внутри Ruby форма (rb_shape_t в shape.h) хранит три ключевых поля: parent_id (родитель в дереве переходов), edge_name (имя переменной этого перехода) и next_field_index (индекс в массиве значений).
Инлайн-кеш в действии
Дизассемблер показывает, как байткод использует этот механизм:
code = 'def full_name; "#{@first_name} #{@last_name}"; end'
puts RubyVM::InstructionSequence.compile(code).disasm== disasm: #<ISeq:full_name@<compiled>:1 (1,0)-(1,46)>
0000 getinstancevariable :@first_name, <is:0>
...
0009 getinstancevariable :@last_name, <is:1>
...
У каждой инструкции getinstancevariable два операнда: имя переменной (:@first_name) и кеш-слот (<is:0>, <is:1>). Именно в этом слоте и хранится пара (shape_id, index).
По идее это похоже на аппаратные кеши процессора — запомнить результат дорогого поиска, но не путать: shape cache — software-уровень.
Теперь разберём, как работает getinstancevariable :@first_name, <is:0>:
- VM читает
shape_idобъекта из заголовка. - Из кеш-слота
<is:0>атомарно читает пару(cached_shape_id, cached_index), упакованную в одно 64-битное число. - Сравнение:
cached_shape_id == shape_id? - Попадание:
ivar_array[cached_index]— одно сравнение целых чисел + одно чтение из массива. Всё. - Промах: VM ищет
@first_nameпо дереву форм от текущей, находит индекс, записывает новую пару(shape_id, index)в кеш-слот.
В vm_insnhelper.c это выглядит так: vm_getivar() на строке 1312 выполняет if (LIKELY(cached_id == shape_id)) — и при совпадении сразу на строке 1319 возвращает ivar_list[index]. При промахе вызывается fill_ivar_cache() (строка 1238), которая записывает новый результат.
Кеш-слот (iseq_inline_iv_cache_entry в vm_core.h) — одно 64-битное число, в которое упакованы shape_id (32 бита) и index (16 бит). Два getinstancevariable в методе full_name — два независимых кеш-слота (<is:0> и <is:1>), каждый кеширует свою переменную.
attr_reader :first_name работает по тому же пути. Разница — кеш хранится не в кеш-слоте ISeq, а в rb_callcache — структуре, связанной с вызовом метода (подробнее в заметке о диспетчеризации). При диспетчеризации типа IVAR вызывается та же vm_getivar(), но кешированная пара берётся из другого места.
Запись ivar (setinstancevariable) устроена аналогично. Дополнительная оптимизация: если текущая форма объекта — прямой родитель целевой формы (shape A, а кеш помнит shape B, дочерний от A), VM выполняет переход за один шаг — без полного пересчёта. Это типичная ситуация при первом присвоении @department объекту, у которого уже есть @first_name и @last_name.
Когда формы не спасают: вариации и too complex
Кеш промахивается при каждой смене shape_id, но после пересчёта снова попадает. Проблема начинается, когда стабильного состояния нет.
Если объекты одного класса инициализируются по-разному — разный набор переменных, разный порядок присваивания — каждый вариант порождает отдельную ветку дерева форм. Ruby считает количество «вариаций» для каждого класса. Когда вариаций становится больше восьми (SHAPE_MAX_VARIATIONS в shape.h), Ruby выдаёт предупреждение и переводит новые объекты в состояние «too complex». Такой объект хранит переменные в персональной хеш-таблице — по сути возврат к подходу с хеш-таблицей, но на уровне каждого объекта, а не класса:
Warning[:performance] = true
class Flexible
def initialize(attrs)
attrs.each { |k, v| instance_variable_set(:"@#{k}", v) }
end
end
9.times { |i| Flexible.new({"var_#{i}" => i}) }
# warning: The class Flexible reached 8 shape variations,
# instance variables accesses will be slower and memory usage increased.
# It is recommended to define instance variables in a consistent order,
# for instance by eagerly defining them all in the #initialize method.Каждый вызов с уникальным набором ключей — это новая ветка дерева. Девять вариантов — больше порога.
Решение: инициализировать все переменные явно в initialize, даже если значение nil. Тогда все объекты класса проходят одинаковый путь и разделяют одну форму:
class Mathematician
def initialize
@first_name = nil
@last_name = nil
@department = nil
end
endВстроенное и внешнее хранение
Горячий путь кеша заканчивается чтением ivar_array[index]. Но где именно лежит этот массив — зависит от количества переменных объекта.
Когда объект создаётся, GC выделяет слот определённого размера. Если переменных немного — массив значений встроен прямо в слот (embedded storage). Когда переменных становится больше, чем помещается — Ruby выносит массив в отдельный участок памяти (heap storage). С Ruby 3.2 GC выделяет слоты разного размера — Variable Width Allocation (VWA) — чтобы объекты с бóльшим числом переменных помещались в один слот без выноса массива в heap. Форма отслеживает, в каком пуле находится объект — через часть битов shape_id.
YJIT — JIT-компилятор CRuby — использует формы ещё эффективнее: при компиляции горячего метода он проверяет ожидаемый shape_id и затем читает ivar по заранее известному смещению прямо из машинного кода.
Sources
- Jemma Issroff, 2022, «Implementing Object Shapes in CRuby» — RubyKaigi talk, введение shapes в CRuby.
- Chris Seaton, 2015, «Specialising Dynamic Techniques for Implementing the Ruby Programming Language» — PhD thesis, оригинальная идея shapes для Ruby.
- Исходники Ruby (коммит
0d4538b57d, 2026-01-10):shape.h(rb_shape_t, shape_id_t, SHAPE_MAX_VARIATIONS, rb_attr_index_cache),shape.c(дерево переходов, вариации, performance warning),vm_insnhelper.c(vm_getivar — hot path, fill_ivar_cache — заполнение при промахе),vm_core.h(iseq_inline_iv_cache_entry),insns.def(getinstancevariable, setinstancevariable).