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++?
- How It Works: vtables and the vptr
- What Dynamic Dispatch Actually Costs
- The Virtual Destructor: The Omission That Breaks Everything
- override and final (C++11)
- Pure Virtual Functions and Abstract Classes
- Common Mistakes
- Key Takeaways
- Frequently Asked Questions
- Conclusion
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.
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
| Aspect | Virtual function | Pure virtual function |
|---|---|---|
| Declaration | virtual void f(); | virtual void f() = 0; |
| Base implementation | Required | Optional (rare, but legal) |
| Derived must override | No | Yes, or it stays abstract |
| Class can be instantiated | Yes | No — the class is abstract |
| Typical use | A sensible default behaviour | An 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
| Mistake | What happens | Fix |
|---|---|---|
| No virtual destructor | Derived destructor never runs; resources leak | virtual ~Base() = default; |
Forgetting override | A typo creates a new function instead of overriding | Mark every override |
| Calling a virtual function in a constructor | Dispatches to the base version, not the derived one | Restructure or use a two-phase init |
Slicing: Base b = derivedObj; | The derived part is copied away | Hold by pointer or reference |
| Making everything virtual | Pays vptr cost with no benefit | Only what is actually overridden |
| Default arguments on virtual functions | Defaults bind statically, the function dynamically | Avoid 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. Afloat/inttypo compiled silently without it and became a compile error with it. = 0makes 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.


