BackendBit

К глоссарию

Таблица виртуальных методов

Другие названия
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 из объекта, достать из нужной ячейки таблицы адрес метода и перейти по нему:

Вызов виртуального метода в C++ через vtableПараметр p функции Greet указывает на объект Dog. Объект хранит скрытое поле vptr, оно указывает на vtable класса Dog с тремя ячейками – адресами кода всех виртуальных методов: Dog::Eat, Dog::Say и Dog::Sleep. Ниже объект Cat: его vptr указывает на vtable класса Cat, где Cat::Say лежит в той же ячейке [1], что и Dog::Say в таблице Dog. Вызов p->Say() берёт p, читает vptr из объекта, затем ячейку [1] и переходит по адресу в ней.C++: указатель на таблицу в объектеGreetp0x7ffe3c8a1f20Dog0x7ffe3c8a1f20vptr0x403d70name"Шарик"vptr – скрытое полеvtable(Dog)0x403d70[0]0x401196[1]0x4011b2[2]0x4011ceвсе виртуальные методы DogDog::Say0x4011b2Dog::Eat0x401196Dog::Sleep0x4011ceCat0x7ffe3c8a1ef0vptr0x403da0name"Мурка"vtable(Cat)0x403da0[0]0x4011ea[1]0x401206[2]0x401222Say – в той же ячейке [1]Cat::Say0x401206Cat::Eat0x4011eaCat::Sleep0x401222p->Say(): взять p → прочитать vptr → прочитать [1] → перейти

Номер ячейки компилятор знает заранее: у 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 и новее):

Вызов метода через интерфейс в GoПараметр s функции Greet – интерфейсное значение типа Sayer из двух слов-адресов: s.tab указывает на itab пары (Sayer, *Dog), s.data – указатель на значение Dog. Значение Dog хранит только поле Name, скрытых полей в нём нет. У типа *Dog три метода – (*Dog).Eat, (*Dog).Say и (*Dog).Sleep, но в таблице Fun этого itab одна ячейка – только метод Say интерфейса Sayer. Вызов s.Say() берёт s.tab, читает ячейку Fun[0] и переходит в (*Dog).Say – на одно чтение меньше, чем в C++.Go: указатель на таблицу в интерфейсном значенииGreets.tab0x4c6f38s.data0xc000012030Dog0xc000012030Name"Шарик"скрытых полей нетitab(Sayer, *Dog)0x4c6f38InterSayerType*DogFun[0]0x47e260только методы Sayer(*Dog).Say0x47e260(*Dog).Eat0x47e240(*Dog).Sleep0x47e280s.Say(): взять s.tab → прочитать Fun[0] → перейти

Таблица 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.