Virtual Functions in C++ — vtables, Pure Virtual Functions, and Abstract Classes

One virtual function grew a class from 4 bytes to 16. Three kept it at 16. What vtables cost, measured, plus the destructor rule nobody mentions.

Two objects each pointing to their own table of function slots, illustrating how C++ vtables dispatch virtual function calls

Adding one virtual function to a class grew it from 4 bytes to 16. Adding two more kept it at 16 — because the cost is one hidden pointer per object, not per function. And the runtime cost of calling through that pointer? Somewhere between nothing at all and 3.7× slower, depending entirely on whether the compiler can see what type it is dealing with.

Both numbers are measured below, along with the mechanism that produces them. This guide covers virtual functions from the virtual keyword through vtables, override and final, pure virtual functions and abstract classes, and the one omission that turns correct-looking inheritance code into undefined behaviour. Every program was compiled and run on Ubuntu 24.04 with GCC 13.3 under -std=c++17 with -Wall -Wextra; sizes come from sizeof, timings from 200 million calls, and the sanitizer reports are verbatim.

Table of Contents

What Is a Virtual Function in C++?

A virtual function is a member function declared with the virtual keyword in a base class, which derived classes can override with their own implementation. When you call it through a base-class pointer or reference, C++ selects the version belonging to the object’s actual type at runtime rather than the version belonging to the pointer’s declared type. This is called dynamic dispatch, and it is the mechanism behind runtime polymorphism.

Without virtual, the compiler picks the function at compile time based on the pointer’s type — so a Shape* pointing at a Circle calls Shape::draw(). With virtual, it calls Circle::draw(). That single difference is what allows one piece of code to operate on a whole family of types.

#include <iostream>

class Shape {
public:
    virtual void draw() const { std::cout << "Drawing a shape\n"; }
    virtual ~Shape() = default;          // see the destructor section - not optional
};

class Circle : public Shape {
public:
    void draw() const override { std::cout << "Drawing a circle\n"; }
};

class Square : public Shape {
public:
    void draw() const override { std::cout << "Drawing a square\n"; }
};

int main() {
    Circle c;
    Square s;
    Shape* p = &c;
    p->draw();        // Drawing a circle
    p = &s;
    p->draw();        // Drawing a square
}
Drawing a circle
Drawing a square

Same call site, same pointer type, different function — chosen by the object, not the pointer.

For the precise rules on virtual function overriding and dispatch, cppreference’s virtual function page is the reference.

How It Works: vtables and the vptr

Dynamic dispatch is not magic and it is not free. Every class with at least one virtual function gets a vtable — a static table of function pointers, one per virtual function, created once per class. Every object of that class gets a hidden vptr pointing at its class’s vtable.

A call through a base pointer becomes: follow the object’s vptr to the vtable, look up the slot, call the address found there.

How a virtual call finds the right functionOne hidden pointer per object, one table per class. Sizes measured with sizeof.OBJECTSCircle cvptrSquare svptrVTABLES (one per class)Circle vtable&Circle::drawSquare vtable&Square::drawFUNCTIONdraw() bodythe code that runsdraw() bodythe code that runsMeasured costno virtual functions4 bytesone virtual function16 bytesthree virtual functions16 bytesOne vptr per object — not one per function.Dispatch cost depends on the compiler.When it can prove the type, it removesthe lookup entirely: no measurable cost.Through a mixed container it cannot, andthe same call ran 3.7× slower.

The size cost is measurable:

sizeof(int)              = 4
sizeof(NoVirtual)        = 4    (just the int)
sizeof(OneVirtual)       = 16   (int + vptr + padding)
sizeof(ManyVirtual)      = 16   (still ONE vptr, not three)
sizeof(Derived)          = 16   (inherits the same vptr)

One vptr per object, regardless of how many virtual functions the class has. A class with three virtual functions is the same size as one with a single virtual function. The vtable itself exists once per class, not once per object.

This is why adding virtual to a class used in an array of millions matters, and why adding a fourth virtual function to a class that already has three costs nothing.

What Dynamic Dispatch Actually Costs

The folklore says virtual functions are slow. The measurement says: it depends on whether the compiler can figure out the type.

Case 1 — the compiler can see the type. A Base* assigned from a known Impl in the same function, 500 million calls:

  non-virtual call : 1159 ms
  virtual call     : 1163 ms

A 0.3% difference — noise. The optimizer proved which function would be called and removed the lookup entirely. This is called devirtualization.

Case 2 — the compiler cannot. Calls through a vector of mixed A and B objects, where the type varies per element, 200 million calls:

200000000 calls through a heterogeneous vector:
  non-virtual : 459 ms
  virtual     : 1699 ms
  difference  : 270.2%

3.7× slower. Reproduced across runs. The cost here is not really the pointer indirection — it is that the CPU cannot predict which function will be called, so the branch predictor fails and the pipeline stalls.

The honest summary: virtual dispatch is free when the compiler can devirtualize, and genuinely expensive in tight loops over heterogeneous collections. Both facts are true, which is why blanket advice in either direction is wrong. Profile before you restructure.

The Virtual Destructor: The Omission That Breaks Everything

This is the most important section in the article.

If a class has any virtual function, it needs a virtual destructor. Deleting a derived object through a base-class pointer when the destructor is not virtual is undefined behaviour — and in practice, the derived destructor simply never runs.

Two classes, identical except for one keyword:

struct BaseNoVirt { ~BaseNoVirt() { /* not virtual */ } };
struct DerivedNoVirt : BaseNoVirt {
    int* data;
    DerivedNoVirt() : data(new int[100]) {}
    ~DerivedNoVirt() { delete[] data; }
};

BaseNoVirt* p = new DerivedNoVirt;
delete p;                              // undefined behaviour
non-virtual destructor:
  BaseNoVirt destroyed

virtual destructor:
  DerivedVirt destroyed
  BaseVirt destroyed

In the first case the derived destructor never ran — the 100-element array leaks, silently, on every delete. AddressSanitizer does not merely warn about this; it aborts the program:

ERROR: AddressSanitizer: new-delete-type-mismatch
  object passed to delete has wrong type:
  size of the allocated type:   8 bytes;
  size of the deallocated type: 1 bytes.

The rule is simple and has no exceptions worth learning as a beginner: a class with virtual functions gets virtual ~ClassName() = default;. If you intend a class to be a base class but do not want polymorphic deletion, make the destructor protected and non-virtual instead — but that is a deliberate design choice, not a default.

override and final (C++11)

override tells the compiler you intend to override a base-class function. If you are wrong, it is an error instead of a silent new function.

Consider a typo — float where the base declared int:

struct Base { virtual void render(int scale) const {} };
struct Bad  : Base { void render(float scale) const {} };            // compiles!
struct Good : Base { void render(float scale) const override {} };   // error
error: 'void Good::render(float) const' marked 'override', but does not override

Without override, Bad::render compiles cleanly as a brand-new function that hides the base version. Calls through a Base* keep going to Base::render, and the bug surfaces later as behaviour that is simply wrong. Mark every intended override with override. It costs nothing and converts a runtime mystery into a compile error.

final prevents further overriding:

struct Middle : Base {
    void render(int scale) const final {}   // no derived class may override this
};

class Leaf final : public Middle {};        // no class may derive from Leaf

Beyond expressing design intent, final helps the optimizer devirtualize — it can prove no further override exists.

Pure Virtual Functions and Abstract Classes

A pure virtual function is declared with = 0 and has no implementation in the base class:

class Shape {
public:
    virtual void draw() const = 0;        // pure virtual
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

A class with at least one pure virtual function is an abstract class and cannot be instantiated. It exists to define an interface that derived classes must implement.

Attempting to create one is a compile error, and GCC explains exactly why:

error: cannot declare variable 's' to be of abstract type 'Shape'
note:   because the following virtual functions are pure within 'Shape':
note:     'virtual void Shape::draw() const'

Derived classes must implement every pure virtual function, or they remain abstract themselves:

class Circle : public Shape {
    double r;
public:
    explicit Circle(double radius) : r(radius) {}
    void draw() const override { std::cout << "Circle\n"; }
    double area() const override { return 3.14159265 * r * r; }
};

You cannot create a Shape, but you can hold one by pointer or reference — which is the entire point:

std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
for (const auto& s : shapes) s->draw();

virtual vs pure virtual

AspectVirtual functionPure virtual function
Declarationvirtual void f();virtual void f() = 0;
Base implementationRequiredOptional (rare, but legal)
Derived must overrideNoYes, or it stays abstract
Class can be instantiatedYesNo — the class is abstract
Typical useA sensible default behaviourAn interface contract

A pure virtual function may have an implementation, which derived classes call explicitly as Base::f(). This is uncommon and mostly appears for pure virtual destructors, which need a body because derived destructors always call the base.

The interface idiom

An abstract class with only pure virtual functions and no data is C++’s equivalent of an interface:

class Drawable {
public:
    virtual void draw() const = 0;
    virtual ~Drawable() = default;
};

Any class inheriting Drawable promises to be drawable. This is the pattern behind most plugin architectures and dependency-injection designs in C++.

Common Mistakes

MistakeWhat happensFix
No virtual destructorDerived destructor never runs; resources leakvirtual ~Base() = default;
Forgetting overrideA typo creates a new function instead of overridingMark every override
Calling a virtual function in a constructorDispatches to the base version, not the derived oneRestructure or use a two-phase init
Slicing: Base b = derivedObj;The derived part is copied awayHold by pointer or reference
Making everything virtualPays vptr cost with no benefitOnly what is actually overridden
Default arguments on virtual functionsDefaults bind statically, the function dynamicallyAvoid entirely

Slicing is the subtlest of these. Assigning a derived object to a base value copies only the base part — the object is not merely un-polymorphic, it has lost its derived data. Polymorphism requires a pointer or a reference, always.

Key Takeaways

  • One vptr per object, not per virtual function. Measured: 4 bytes with no virtual functions, 16 with one, and still 16 with three.
  • Dispatch cost is conditional. Measured at 0.3% when the compiler could devirtualize, and 3.7× slower through a heterogeneous vector where it could not.
  • A class with virtual functions needs a virtual destructor. Without it, the derived destructor never ran and AddressSanitizer aborted with new-delete-type-mismatch.
  • Always write override. A float/int typo compiled silently without it and became a compile error with it.
  • = 0 makes a function pure virtual and its class abstract — the class cannot be instantiated, and GCC names the offending function when you try.
  • Polymorphism requires pointers or references. Assigning to a base value slices the object.

Frequently Asked Questions

Conclusion

Virtual functions are usually introduced as a keyword and a behaviour: write virtual, get the derived version. That framing is enough to pass an exam and not enough to write correct code, because the two things most likely to hurt you are not in it — the destructor you did not mark virtual, and the override you did not write.

The mechanism is worth carrying in your head precisely because it explains both. There is a hidden pointer in every polymorphic object, and a table per class it points into. Once that picture is concrete, the virtual destructor stops being a rule to memorise and becomes obvious: the delete has to go through the same table as every other call. The rest of the object model works the same way — the C++ section covers the classes, inheritance and memory rules these functions are built on.

Scroll to Top