Таблица виртуальных методов
- Другие названия
- virtual method table
- vtable
- VMT
- virtual table
Таблица виртуальных методов – таблица указателей на реализации методов конкретного типа. Через неё язык выбирает, какую реализацию вызвать, когда конкретный тип значения становится известен только во время выполнения. Такой вызов называют динамической диспетчеризацией (dynamic dispatch).
Пример
Возьмём C++: абстрактный класс Animal с виртуальными методами и два его наследника – Dog и Cat.
#include <iostream>
#include <string>
struct Animal {
virtual void Eat() = 0;
virtual void Say() = 0;
virtual void Sleep() = 0;
protected:
~Animal() = default;
};
struct Dog final : Animal {
std::string name;
void Eat() override {}
void Say() override { std::cout << "Гав-гав\n"; }
void Sleep() override {}
};
struct Cat final : Animal {
std::string name;
void Eat() override {}
void Say() override { std::cout << "Мяу\n"; }
void Sleep() override {}
};
void Greet(Animal* p) {
p->Say(); // Dog::Say или Cat::Say?
}
int main() {
Dog dog;
dog.name = "Шарик";
Cat cat;
cat.name = "Мурка";
std::string answer;
std::cin >> answer;
Animal* p = &cat;
if (answer == "dog") {
p = &dog;
}
Greet(p);
}cppЧто окажется в p, зависит от того, что введёт пользователь, – у компилятора нет способа это узнать. Так же бывает, когда тип зависит от результата сетевого запроса или содержимого файла. К тому же Greet компилируется один раз, а вызывают её и с Dog, и с Cat. При компиляции вызова p->Say() известен только тип указателя – Animal*. Какую Say выполнить, выясняется во время работы программы – через vtable.
Как вызывается метод
У каждого класса с виртуальными методами своя vtable, а каждый его объект хранит скрытое поле vptr – указатель на таблицу своего класса. Вызов p->Say() – это два чтения из памяти и косвенный переход: по p прочитать vptr из объекта, достать из нужной ячейки таблицы адрес метода и перейти по нему:
Номер ячейки компилятор знает заранее: у Say он одинаковый во всех наследниках Animal. И в таблице Dog, и в таблице Cat это ячейка [1]. Поэтому для Dog и Cat выполняется один и тот же машинный код и искать метод по имени не нужно. Разные реализации вызываются потому, что p приводит к разным таблицам.
У такого вызова есть цена. Какой метод выполнится, становится известно только в рантайме, поэтому компилятор не может встроить (inline) его тело в место вызова. Обойти это помогает девиртуализация (devirtualization) – замена косвенного вызова прямым. Если компилятор может доказать, какой тип окажется в p, он просто вызывает нужный метод. Если не может, но знает, какой тип там бывает чаще всего, – вставляет проверку: тип совпал – прямой вызов, нет – обычный, через таблицу. Самый частый тип JIT-компилятор Java видит, пока программа работает, а Go берёт из профиля прошлых запусков (PGO, с Go 1.21).
Где лежит указатель на таблицу и как устроена сама таблица, зависит от языка.
C++ и Java
Таблица одна на класс и содержит все его виртуальные методы. Путь к ней начинается в самом объекте. В C++ это vptr, как на схеме выше. При множественном наследовании vptr в объекте несколько, но здесь разбираем только одиночное. В Java – по крайней мере в HotSpot, основной реализации JVM, – в заголовке каждого объекта лежит указатель на описание его класса, а таблица хранится внутри этого описания.
Номер ячейки совпадает, потому что таблица Dog начинается с методов Animal в том же порядке, что и таблица Animal, а новые методы Dog идут после них. Чтобы так построить таблицу, нужно заранее знать предка, поэтому класс объявляет его сам: struct Dog : Animal в C++, extends в Java.
С интерфейсами Java так не выйдет: класс может реализовать несколько интерфейсов, и методы каждого в начало таблицы не поставить. Поэтому в описании класса есть отдельная таблица на каждый его интерфейс (itable) – из тех, что он перечислил в implements или получил от предков. При вызове через интерфейс JVM сначала ищет среди них таблицу нужного интерфейса и уже в ней берёт ячейку.
Go
Возьмём интерфейс Sayer и структуру Dog с тремя методами. Методы объявлены на указателе *Dog, поэтому в Sayer кладут &Dog{…} – как &cat и &dog в примере на C++:
package vtable
type Sayer interface {
Say() string
}
type Dog struct {
Name string
}
func (d *Dog) Eat() {}
func (d *Dog) Say() string {
return "Гав-гав"
}
func (d *Dog) Sleep() {}
func Greet(s Sayer) string {
return s.Say()
}goИнтерфейсное значение в Go – это два машинных слова: tab указывает на структуру itab, а data – на данные конкретного типа. Роль vtable играет таблица Fun внутри itab. Названия полей взяты из исходников рантайма: runtime.iface и internal/abi.ITab (Go 1.23 и новее):
Таблица Fun устроена иначе, чем vtable:
- Таблица одна на пару «интерфейсный тип – конкретный тип», а не на тип. Если
*Dogположить и вSayer, и в интерфейсSleeperс методомSleep(), у этих пар будут разныеitab. - В
Funтолько методы интерфейса, а не все методы типа. Вitabпары(Sayer, *Dog)одна ячейка –Say, хотя у*Dogтри метода. - Номер ячейки задаёт интерфейс: методы в
Funидут в порядке методов интерфейса, отсортированных по имени. ПоэтомуSayлежит вFun[0]вitabлюбого типа, который положили вSayer, и вызовs.Say()компилируется в чтениеFun[0], хотя тип внутриsнеизвестен. - Указатель на
itabхранит интерфейсное значение, а не объект. В самом значенииDogнет скрытых полей. itabстроится отдельно от типа: компилятором при сборке или рантаймом, когда пара впервые встречается во время работы.
Сравните схемы. В C++ путь к таблице идёт через объект: от p к объекту, от его vptr к таблице. В Go указатель на itab лежит в самом интерфейсном значении, поэтому до ячейки на одно чтение меньше. Цена – интерфейсное значение занимает два машинных слова, а указатель в C++ – одно.
Отсюда и главное отличие от C++ и Java. Там таблицы – часть класса, и класс заранее перечисляет, от кого наследуется и какие интерфейсы реализует. В Go таблица строится для пары, отдельно от типа, – для любого интерфейса, все методы которого у типа есть. Это и позволяет типам в Go не перечислять, какие интерфейсы они реализуют: достаточно иметь нужные методы.
Подробнее об устройстве интерфейсов и о том, как рантайм строит и кеширует itab, – в статье Интерфейсы в Go.