Как runtime выбирает версию виртуального метода для вызова в C#
Что проверяют
Понимаете ли вы разницу между статическим типом ссылки и реальным типом объекта и как CLR связывает вызов с реализацией. Типичное продолжение: чем override отличается от new и во что обходится виртуальный вызов.
Как отвечать
Каркас от компиляции к рантайму:
- компилятор C#: для вызова экземплярного метода выдаёт в IL инструкцию
callvirt, причём почти для любого, даже невиртуального, потому чтоcallvirtзаодно проверяет ссылку наnull; - загрузка типа: при первой загрузке класса CLR строит его
MethodTableсо слотами виртуальных методов. Наследник получает слоты базового класса,overrideподменяет адрес в том же слоте, аnew virtualзаводит новый; - вызов: в заголовке каждого объекта лежит указатель на
MethodTableего настоящего типа. JIT генерирует код, который берёт этот указатель, читает нужный слот по известному смещению и переходит по адресу. Поэтому выбор зависит от типа объекта, а не переменной; - интерфейсы: вызов через интерфейс идёт отдельным механизмом с кэшированием на месте вызова (в CoreCLR исторически это virtual stub dispatch).
Про цену: это пара лишних разыменований и, что важнее, невозможность встроить метод. JIT умеет девиртуализировать вызов, если тип точно известен: класс sealed или объект создан тут же через new. Начиная с .NET 8 динамический PGO включён по умолчанию и добавляет защищённую девиртуализацию: для самого частого типа ставится проверка и прямой вызов.
Где спотыкаются
Говорят, что vtable создаётся при компиляции. Компилятор C# таких таблиц не строит, они появляются в рантайме при загрузке типа.
Путают override и new. С new метод скрывается, и через ссылку базового типа вызовется базовая версия: здесь как раз решает тип переменной.
Вызывают виртуальный метод из конструктора базового класса. Сработает переопределение наследника, хотя тело его конструктора ещё не выполнилось, и поля, которые оно заполняет, пока пустые.