Диспетчеризация методов
Предпосылки
Исполнение — фреймы (METHOD, CFUNC, BLOCK), push/pop, PC/SP/EP. Объекты и классы — m_tbl, klass, super, метакласс. Модули — iclass, include/prepend в цепочке super, поиск методов по цепочке. Формы — shape_id, инлайн-кеш для переменных.
← Формы объектов | Определение методов →
В заметке о модулях мы видели, как Ruby находит метод: начинает с класса получателя, проверяет таблицу методов, не нашёл — переходит по super к следующему. Но нашёл — и что дальше? full_name определён через def, first_name — через attr_reader, times — встроенный C-метод. Все три найдены по одному алгоритму, но вызываются совершенно по-разному. И ещё вопрос: неужели Ruby проходит по цепочке на каждый вызов?
Два шага send
Дизассемблер показывает, что вызов метода — одна инструкция:
code = 'euler = Mathematician.new; euler.full_name'
puts RubyVM::InstructionSequence.compile(code).disasm0008 opt_send_without_block <calldata!mid:full_name, argc:0, ARGS_SIMPLE>
opt_send_without_block — рабочая лошадка вызова методов. За одной инструкцией скрываются два шага: поиск (найти метод по имени и получателю) и диспетчеризация (вызвать найденный метод подходящим способом). Эти шаги работают по-разному, но вместе образуют полный цикл вызова.
Поиск метода
Вызов euler.full_name. VM берёт класс объекта (Mathematician) и начинает обход цепочки super:
Mathematician → m_tbl: {full_name} → есть!
Метод нашёлся сразу. Если бы его не было в Mathematician, VM перешёл бы к следующему узлу — модулю или суперклассу — и проверил бы его таблицу. Алгоритм — цикл по связному списку, уже знакомый из заметки о модулях:
for (; klass; klass = RCLASS_SUPER(klass)) {
me = lookup_method_table(klass, id);
if (me) break;
}
Каждый узел — обычный RClass с таблицей методов (m_tbl) и указателем super. Include-обёртки (iclass), оригинальные классы от prepend — все неразличимы для этого цикла.
Результат поиска — описание метода (rb_callable_method_entry_t в method.h). Оно содержит три вещи, которые определят дальнейший путь: тип метода (Ruby-код, C-функция, attr_reader…), тело (ISeq, указатель на C-функцию, имя переменной) и видимость (public, private, protected).
Три разновидности — один механизм
Алгоритм всегда один: взять класс из заголовка объекта и пойти по super. Разница — только в том, какой класс оказывается стартовой точкой.
Метод экземпляра. euler.full_name — самый обычный случай. Стартовая точка — класс объекта:
Mathematician.ancestors
# => [Mathematician, Person, Object, Kernel, BasicObject]VM проверяет m_tbl в каждом узле по порядку.
Метод класса. Mathematician.field — вызов на объекте-классе. Класс объекта Mathematician — его метакласс:
Mathematician.singleton_class.ancestors
# => [#<Class:Mathematician>, #<Class:Person>, #<Class:Object>,
# #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]VM начинает с метакласса #<Class:Mathematician> и идёт по цепочке метаклассов вверх. def self.field положил метод в m_tbl метакласса — поиск найдёт его на первом шаге. «Метод класса» — не отдельный механизм. Это обычный метод экземпляра, который лежит в метаклассе. А extend Module — это include в метакласс (мы видели это в заметке о модулях).
Singleton-метод объекта. def euler.special создаёт метод только для одного объекта. Ruby лениво создаёт для euler его собственный singleton class и кладёт метод туда:
def euler.special; "only euler"; end
euler.singleton_class.ancestors
# => [#<Class:#<Mathematician:0x...>>, Mathematician, Person, Object, Kernel, BasicObject]Стартовая точка — singleton class, затем обычная цепочка класса. Другие экземпляры Mathematician не имеют этого singleton class и не видят special.
Во всех трёх случаях алгоритм поиска один. Различие — куда указывает klass в заголовке объекта: на обычный класс, на метакласс или на singleton class.
Инлайн-кеш
Обход цепочки на каждый вызов — заметная цена. В горячем цикле euler.first_name вызывается миллионы раз, а метод каждый раз лежит в том же месте. Можно ли запомнить результат?
Каждый call site в скомпилированных инструкциях хранит callcache (rb_callcache в vm_callinfo.h) — кеш из трёх полей: класс получателя, найденный метод и указатель на функцию вызова. Callcache — software-аналог аппаратного кеша: запомнить результат дорогого поиска, чтобы не повторять его при каждом вызове.
Рядом с callcache лежит callinfo (rb_callinfo) — неизменное описание вызова: имя метода, количество аргументов, флаги (есть ли блок, splat, keyword-аргументы). Callinfo не меняется; callcache обновляется при каждом промахе.
Горячий путь
При повторном вызове euler.full_name VM проверяет кеш за два сравнения:
- Класс получателя совпадает с кешированным? (
cc->klass == CLASS_OF(recv)) - Метод не был инвалидирован? (
!METHOD_ENTRY_INVALIDATED(cme))
Если оба условия выполнены — VM вызывает метод напрямую через сохранённый указатель на функцию (cc->call_), без поиска. Это горячий путь — две проверки и переход.
При промахе (другой класс получателя или инвалидированный метод) VM ищет результат в таблице кешей класса (RCLASS_CC_TBL). Это второй уровень — медленнее, но быстрее полного обхода. Если и там нет — полный поиск по цепочке super, результат записывается обратно в оба кеша.
Инвалидация
Кеш становится неактуальным, когда меняется набор методов: define_method, include, prepend, alias, удаление метода. Ruby не сбрасывает все кеши глобально — вместо этого каждый метод несёт флаг INVALIDATED. При изменении Ruby помечает затронутые методы, и при следующем обращении кеш обнаруживает, что метод помечен, и пересчитывается.
Сравнение с кешем переменных (из заметки о формах): кеш методов ключится на класс получателя и инвалидируется явно (флаг на method entry). Кеш переменных ключится на shape_id объекта и инвалидируется неявно (shape_id изменился — сравнение не прошло). Кеш методов затрагивает все объекты класса при include; кеш переменных — только конкретный объект при добавлении новой переменной.
Типы методов
Кеш вернул описание метода. Но что именно вызывать? full_name — Ruby-код с инструкциями YARV. Array#push — C-функция. attr_reader :first_name — простое чтение переменной. Каждый требует своего пути вызова.
Ruby классифицирует каждый метод одним из двенадцати типов (rb_method_type_t в method.h). При диспетчеризации VM проверяет тип и переключается на соответствующий путь (vm_call_method_each_type() в vm_insnhelper.c). Четыре типа покрывают подавляющее большинство вызовов.
ISEQ: обычный Ruby-метод
def full_name
"#{@first_name} #{@last_name}"
endТип VM_METHOD_TYPE_ISEQ. Самый распространённый путь. VM создаёт METHOD-фрейм: устанавливает PC на начало ISeq метода, выделяет порцию стека для локальных переменных (EP), записывает self = receiver. Дальше — обычный цикл «прочитай инструкцию → выполни → сдвинь PC», знакомый из заметки об исполнении. Когда метод завершается (leave) — фрейм снимается, результат на стеке вызывающего.
CFUNC: C-метод
Integer#times, String#upcase, Array#push — методы, написанные на C и встроенные в Ruby. Тип VM_METHOD_TYPE_CFUNC. VM создаёт CFUNC-фрейм (чтобы метод появлялся в backtrace при ошибке) и вызывает C-функцию напрямую. Инструкций YARV нет — C-код делает всю работу и возвращает результат. Когда вы видите CFUNC в выводе caller — это именно такой вызов.
IVAR и ATTRSET: без фрейма
class Mathematician
attr_reader :first_name # создаёт IVAR-метод
attr_writer :first_name # создаёт ATTRSET-метод
endattr_reader создаёт метод типа VM_METHOD_TYPE_IVAR. Его единственная задача — вернуть значение одной инстанс-переменной. Операция настолько проста, что VM не создаёт фрейм: снимает получателя со стека, читает переменную через vm_getivar() и кладёт результат обратно. Ни PC, ни EP, ни переключения контекста.
attr_writer — тип VM_METHOD_TYPE_ATTRSET. Аналогично: без фрейма записывает значение через vm_setivar().
Кеш для чтения/записи переменной хранится прямо в callcache — в том же формате (shape_id, index), что и в заметке о формах. Разница с кеш-слотом инструкции getinstancevariable — только в том, где лежит кешированная пара: в callcache (для attr_reader) или в ISeq (для getinstancevariable). Функция чтения — одна и та же vm_getivar().
Разница с def first_name; @first_name; end — не только в краткости записи. def создаёт ISEQ-метод: при каждом вызове — фрейм, инструкция getinstancevariable, возврат. attr_reader создаёт IVAR-метод: прямое чтение без фрейма. В дизассемблере оба вызова выглядят одинаково — opt_send_without_block :first_name. Разница проявляется только на этапе диспетчеризации, когда VM смотрит на тип найденного метода.
Видимость
Проверка видимости происходит после поиска, но до диспетчеризации — внутри vm_call_method() (vm_insnhelper.c). VM читает видимость из флагов найденного метода и проверяет контекст вызова:
- public — вызов разрешён всегда.
- private — только без явного получателя. VM проверяет флаг
FCALLв callinfo:puts "hello"— FCALL (неявныйself), вызов разрешён.euler.secret— не FCALL (есть явныйeuler.), вызов запрещён. - protected — получатель должен быть экземпляром того же класса (или подкласса), где метод определён.
rb_obj_is_kind_of(self, defined_class).
При нарушении видимости VM не бросает ошибку напрямую — вместо этого вызывает method_missing с причиной: MISSING_PRIVATE или MISSING_PROTECTED. Стандартная реализация method_missing в BasicObject превращает это в NoMethodError с соответствующим сообщением.
euler = Mathematician.new
euler.full_name # public → диспетчеризация
euler.secret # private, есть явный получатель → method_missing → NoMethodErrormethod_missing
Если поиск по всей цепочке не нашёл метод — VM не сдаётся сразу. Вместо этого вызывает method_missing на том же объекте, сдвигая аргументы:
euler.nonexistent(1, 2)
# превращается в:
euler.method_missing(:nonexistent, 1, 2)method_missing — обычный метод, который ищется по тем же правилам. Стандартная реализация в BasicObject бросает NoMethodError. Но классы могут переопределить его — это основа динамических API:
class Mathematician
def method_missing(name, *args)
if name.to_s.start_with?("find_by_")
# динамический поиск
else
super # стандартное поведение — NoMethodError
end
end
endЗащита от бесконечной рекурсии: если method_missing сам не найден (кто-то удалил его из BasicObject), VM бросает NoMethodError напрямую, без повторного поиска.
super
Допустим, Mathematician переопределяет full_name из Person и хочет вызвать оригинал:
class Person
def full_name; "default"; end
end
class Mathematician < Person
def full_name; "Math: " + super; end
endВ дизассемблере super — инструкция invokesuper:
== disasm: #<ISeq:full_name@<compiled>>
0000 putstring "Math: "
0002 putself
0003 invokesuper <calldata!argc:0, FCALL|ARGS_SIMPLE|SUPER|ZSUPER>, nil
0006 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>
0008 leave
invokesuper использует тот же алгоритм обхода, но с другой стартовой точкой: не от класса объекта, а от суперкласса того класса, где определён текущий метод. full_name определён в Mathematician → поиск начинается с Person → находит Person#full_name.
Bare super (без аргументов) автоматически передаёт все аргументы и блок текущего метода — флаг ZSUPER в calldata. super(x, y) передаёт явно указанные аргументы.
Оптимизированные инструкции
Помимо opt_send_without_block, YARV компилирует некоторые вызовы в специальные инструкции: opt_plus, opt_minus, opt_aref ([]), opt_length и другие. Каждая пробует быстрый путь — например, opt_plus проверяет, что оба операнда — целые числа, и складывает их напрямую, без поиска метода +. Если быстрый путь не сработал (получатель не Integer, или метод + переопределён) — инструкция откатывается к полному пайплайну: поиск, кеш, диспетчеризация.
Это не отдельный механизм — это short-circuit перед стандартным путём. В горячем коде с числами opt_plus экономит весь цикл поиска, но семантика остаётся той же: если + переопределён, Ruby вызовет переопределённую версию.
Остальные типы
Двенадцать типов покрывают все способы определения методов в Ruby. Четыре основных мы разобрали: ISEQ, CFUNC, IVAR, ATTRSET. Остальные встречаются реже:
- BMETHOD — метод, созданный через
define_methodс блоком. Внутри хранится Proc, который VM вызывает при диспетчеризации. - ALIAS — результат
alias_method. Хранит ссылку на оригинальный метод, при диспетчеризации разыменовывается и вызывается оригинал. - ZSUPER — метод-заглушка для делегации к
super(создаётся внутренне, например приincludeмодуля, содержащего вызовsuper). - OPTIMIZED — ускоренные версии
Kernel#send,Proc#call, обращений кStructполям. VM обрабатывает их без полного dispatch. - REFINED — метод из refinement. Поиск идёт через лексическую цепочку refinements, а не только по super chain.
- MISSING — обёртка для
method_missing, хранящая причину вызова (метод не найден, нарушение видимости,superбез суперкласса). - UNDEF — метод явно удалён через
undef_method. Поиск находит запись, но диспетчеризация вызываетmethod_missing.
Sources
- Pat Shaughnessy, 2013, Ruby Under a Microscope — глава 4: диспетчеризация методов.
- Исходники Ruby (коммит
0d4538b57d, 2026-01-10):method.h(rb_method_type_t — 12 типов, строка 117; rb_method_entry_t, rb_callable_method_entry_t — описание найденного метода; METHOD_ENTRY_INVALIDATED — флаг инвалидации),vm_callinfo.h(rb_callinfo — описание вызова, строка 65; rb_callcache — инлайн-кеш, строка 278),vm_insnhelper.c(vm_search_method_fastpath — горячий путь кеша, строка 2357; vm_call_method — проверка видимости, строка 5004; vm_call_method_each_type — dispatch switch, строка 4865; vm_call_ivar — IVAR без фрейма, строка 4060),vm_method.c(search_method0 — обход super chain, строка 1686),insns.def(opt_send_without_block, invokesuper, opt_plus — инструкции вызова).