What Is the Virtual Table?

The previous lesson left a question open. A call written once, through a reference to a base class, reached a different function depending on what the reference actually referred to. The destination was not in the call expression, so it had to be stored somewhere the running program could read. That somewhere is the virtual table.

Every polymorphic class gets a virtual table: a lookup table of function pointers, built once for the class, that the running program reads to decide which override a virtual call should reach. It travels under several other names, including dispatch table, virtual method table, virtual function table, and the abbreviation vtable. Choosing an override through it is called dynamic dispatch.

Three pieces work together, and it is worth naming all three before looking at any code.

Piece Where it lives What it is for
The virtual table One per class, built at compile time, shared by every object of that class Holds the address of the function each virtual call should reach
The table pointer One hidden data member inside every object of a polymorphic class Says which class's table this particular object belongs to
The indexed call At every virtual call site in your code Reads the pointer, picks a fixed slot, and transfers control to whatever address it finds

That hidden data member has no name you can write in source code. Compilers give it an internal name of their own; this lesson calls it __vptr, which is the name most texts use.

Key Concept
The C++ standard never mentions virtual tables. It states what a virtual call must do, and leaves implementations to work out how. Every mainstream compiler settles on the arrangement described here, so it is worth knowing, but it is a description of practice rather than a rule of the language.

One Call Expression, Two Destinations

The whole mechanism exists to make the following program possible.

#include <iostream>

struct Fixture
{
    virtual ~Fixture() = default;

    virtual void aim() const
    {
        std::cout << "Fixture::aim" << '\n';
    }
};

struct Spotlight : public Fixture
{
    void aim() const override
    {
        std::cout << "Spotlight::aim" << '\n';
    }
};

int main()
{
    Fixture house{};
    Spotlight key{};

    const Fixture* rig[]{ &house, &key };

    for (const Fixture* unit : rig)
    {
        unit->aim(); // one call expression, written once
    }

    return 0;
}

Output:

Fixture::aim
Spotlight::aim

There is exactly one call expression in that program, and it reached two different functions. unit has type const Fixture* on both trips through the loop, so its static type cannot be what decided the outcome. What differed was the object on the other end of the pointer.

Here is the sequence the program follows for unit->aim(), in order:

  1. aim() is virtual, so the destination is not compiled into the call site.
  2. Read __vptr out of the object that unit points at. On the first trip that pointer leads to the Fixture table; on the second it leads to the Spotlight table.
  3. Look in the slot reserved for aim(). Which slot that is was fixed at compile time, because every table in this hierarchy lays its entries out the same way.
  4. Transfer control to the address sitting in that slot.

Step 2 is the one that carries all the weight. __vptr belongs to the object, not to the pointer or reference used to reach it, so a Fixture* aimed at a Spotlight still finds the Spotlight table. That is the whole trick, stated in one sentence.

Key Concept
A base class pointer or reference does not carry a table of its own. It reads the table pointer stored inside whatever object it refers to, which is why the object's own type decides the outcome no matter what type is used to reach it.

What Goes In the Table

Each table has one slot per virtual function that objects of that class can call, and each slot holds the address of the most derived version that is available to that class. Overriding a function changes one slot. Leaving a function alone means the slot keeps whatever the base class put there.

Two virtual functions and two derived classes are enough to show every case at once.

#include <iostream>

struct Fixture
{
    virtual ~Fixture() = default;

    virtual void aim() const
    {
        std::cout << "Fixture::aim" << '\n';
    }

    virtual void fade() const
    {
        std::cout << "Fixture::fade" << '\n';
    }
};

struct Spotlight : public Fixture
{
    void aim() const override
    {
        std::cout << "Spotlight::aim" << '\n';
    }
};

struct Floodlight : public Fixture
{
    void fade() const override
    {
        std::cout << "Floodlight::fade" << '\n';
    }
};

void runCue(const Fixture& unit)
{
    unit.aim();
    unit.fade();
}

int main()
{
    Fixture house{};
    Spotlight key{};
    Floodlight wash{};

    runCue(house);
    runCue(key);
    runCue(wash);

    return 0;
}

Output:

Fixture::aim
Fixture::fade
Spotlight::aim
Fixture::fade
Fixture::aim
Floodlight::fade

Three classes, so three tables. Two virtual functions, so two slots in each.

Table Slot for aim() Slot for fade()
Fixture Fixture::aim Fixture::fade
Spotlight Spotlight::aim Fixture::fade
Floodlight Fixture::aim Floodlight::fade

Read the table row by row and you have just read the program's output. Spotlight overrode aim() and inherited fade() untouched, so its second slot still points into Fixture. Floodlight did the opposite. Nothing about runCue() changed between the three calls: it was compiled once, it contains two call sites, and each of them found its destination through the table pointer of whichever object arrived.

That is also why adding a fourth fixture class to this program would require no edit to runCue() at all. The set of destinations is data the compiler generates, not branching you write.

When the Pointer Is Set

An object's __vptr is filled in during construction, not at some later moment and not when a call is made. That timing is observable, and it explains a rule you met earlier in this chapter.

The next program calls a virtual function from a constructor, which is something to avoid in real code. It is here because it makes the timing visible:

#include <iostream>

struct Fixture
{
    Fixture()
    {
        std::cout << "inside Fixture's constructor: ";
        aim();
    }

    virtual ~Fixture() = default;

    virtual void aim() const
    {
        std::cout << "Fixture::aim" << '\n';
    }
};

struct Spotlight : public Fixture
{
    void aim() const override
    {
        std::cout << "Spotlight::aim" << '\n';
    }
};

int main()
{
    Spotlight key{};
    const Fixture& unit{ key };

    std::cout << "once the object is complete: ";
    unit.aim();

    return 0;
}

Output:

inside Fixture's constructor: Fixture::aim
once the object is complete: Spotlight::aim

The same object dispatched to two different functions at two different moments in its life. Construction runs from the base outwards: the Fixture part is built first, and while that is happening the object's table pointer refers to the Fixture table, because the Spotlight part does not exist yet and its override has nothing to run against. Once the Spotlight constructor finishes, the pointer refers to the Spotlight table and stays that way for the rest of the object's life. Destruction unwinds the same way in reverse.

Warning
Do not rely on this in real code. A virtual call inside a constructor or destructor runs the version belonging to the class currently being built or torn down, which is almost never the version the author of the derived class expected.

What It Costs

Two costs, and they are easy to keep separate: every object gets bigger, and every virtual call does a little more work.

The size cost is measurable directly.

#include <iostream>

struct Patch // no virtual functions at all
{
    int channel{};
    int level{};
};

struct Cue // two virtual functions, counting the destructor
{
    int channel{};
    int level{};

    virtual ~Cue() = default;
    virtual void fire() const {}
};

struct Macro // five virtual functions, counting the destructor
{
    int channel{};
    int level{};

    virtual ~Macro() = default;
    virtual void fire() const {}
    virtual void hold() const {}
    virtual void release() const {}
    virtual void rewind() const {}
};

int main()
{
    std::cout << "Patch: " << sizeof(Patch) << " bytes\n";
    std::cout << "Cue: " << sizeof(Cue) << " bytes\n";
    std::cout << "Macro: " << sizeof(Macro) << " bytes\n";
    std::cout << "one pointer: " << sizeof(void*) << " bytes\n";

    return 0;
}

Output:

Patch: 8 bytes
Cue: 16 bytes
Macro: 16 bytes
one pointer: 8 bytes

Patch holds its two int members and nothing else. Making Cue polymorphic grew the object by exactly one pointer. Going from two virtual functions to five did not grow it again, because the extra entries went into the table, and the table is one shared object per class rather than a copy inside every instance. On this 64-bit build, an object of a polymorphic class therefore looks like this in memory:

Offset Contents
0 to 7 the hidden pointer to the class's table
8 onwards the class's own data members

The call cost is the second one. A non-virtual call transfers control to an address the compiler already knew. A virtual call has to load __vptr from the object, read one slot out of the table, and only then transfer control.

Non-virtual call Virtual call
Work before the jump None, the address is baked in One load for the table pointer, one load for the slot
Inlining Possible Usually blocked, since the callee is unknown
Object size Unchanged One pointer larger per polymorphic base

Two extra loads is very little on modern hardware, and both usually sit in cache. The lost inlining matters more, because an inlined call can dissolve into the surrounding code while an indirect call cannot. Even that is not guaranteed to bite: when the optimiser can prove which type an object really is, as it can for the small examples in this lesson, it is free to replace the lookup with a direct call. What it cannot do is remove the possibility of dispatch from a call site where the type genuinely is not known until runtime, which is exactly the case you use virtual functions for.

Where the Simple Picture Ends

The one-table-one-pointer description above holds for single inheritance, which covers most hierarchies. A few refinements are worth knowing about.

  • An object that inherits from several polymorphic bases carries more than one table pointer, one for each base that needs its own view of the object. Multiple inheritance is covered later in this chapter.
  • Slot order, table layout, and the name of the hidden member are all decided by the compiler and its ABI. Two compilers can lay out the same class differently, which is one reason objects cannot be passed between binaries built by different toolchains.
  • The table is not something your code can reach. There is no standard way to read __vptr, print a table, or count its entries; the only way to observe any of this is through effects like the ones in this lesson.

Summary

Virtual table: a lookup table of function pointers, one per class, built at compile time and shared by every object of that class. It has one slot per virtual function callable on objects of that class, and each slot holds the address of the most derived version available to it. Overriding a function replaces one slot; not overriding leaves the base class's entry in place.

Table pointer: every object of a polymorphic class carries a hidden pointer member, written __vptr here, that identifies which class's table the object belongs to. Unlike this, which is a function parameter, __vptr is real storage inside the object and makes it one pointer larger. It is set during construction, from the base outwards, which is why a virtual call made inside a constructor runs the version belonging to the class under construction.

Dispatch: a virtual call reads __vptr out of the object, indexes the slot fixed for that function at compile time, and jumps to the address stored there. Because __vptr belongs to the object rather than to the pointer or reference used to reach it, a base class pointer aimed at a derived object finds the derived class's table and runs the derived override.

Cost: one pointer of extra size per object, and two extra loads per call compared with a direct call, plus the loss of inlining at that call site. The overhead is small, and an optimiser that can prove an object's real type may remove it entirely.

Status: none of this is mandated by the standard. It is the arrangement every mainstream implementation uses to deliver the behaviour the standard requires, and knowing it explains why virtual functions cost what they cost.