Polymorphism in C++: Virtual Functions and Vtable
Learn polymorphism in C++ using virtual functions, vtables, and dynamic dispatch to build flexible object-oriented applications.
Polymorphism lets objects that share the same interface (base type) exhibit different behaviors at runtime. In C++, dynamic polymorphism is achieved via virtual functions, and compilers typically implement this mechanism using a vtable (virtual table) and a per-object vptr (virtual pointer). In this article, we’ll examine how virtual functions work, the vtable/vptr layout, performance implications, and best practices with realistic examples.
1) What Is Dynamic Polymorphism?
Dynamic polymorphism means which function gets called is decided at runtime. This allows invoking derived behavior through a base class reference or pointer.
#include <iostream>
#include <memory>
using namespace std;
struct Book {
virtual ~Book() = default; // required for a polymorphic base
virtual void PrintInfo() const { // virtual function
cout << "[Book] Generic info" << endl;
}
};
struct EBook : Book {
void PrintInfo() const override {
cout << "[EBook] Digital edition" << endl;
}
};
struct PrintedBook : Book {
void PrintInfo() const override {
cout << "[PrintedBook] Physical edition" << endl;
}
};
int main() {
unique_ptr<Book> a = make_unique<EBook>();
unique_ptr<Book> b = make_unique<PrintedBook>();
a->PrintInfo(); // [EBook] ...
b->PrintInfo(); // [PrintedBook] ...
}
Although a and b appear as the base type (Book),
calls are dispatched at runtime to the actual types (EBook/PrintedBook).
2) How Do vtable and vptr Work?
When a class has at least one virtual function, the compiler generates a
vtable for that class. Objects typically contain a hidden
vptr that points to the class’s vtable.
- vtable: a table holding addresses of the class’s virtual functions.
- vptr: a hidden field in each object that stores the address of the vtable.
At call time: object.vptr → vtable → actual function address.
Book (vptr) ──► vtable(Book) : &Book::PrintInfo
EBook (vptr) ─► vtable(EBook) : &EBook::PrintInfo
Printed (vptr)► vtable(PrintedBook) : &PrintedBook::PrintInfo
This indirection adds a small call overhead; in most applications it’s negligible.
3) override, final, and a Virtual Destructor
The override keyword verifies at compile time that you’re truly overriding a
virtual function from the base. final marks a method or class as not overridable.
In polymorphic bases a virtual destructor is essential; otherwise deleting
via a base pointer leads to undefined behavior.
struct Book {
virtual ~Book() = default;
virtual double Price() const { return 0.0; }
};
struct EBook final : Book {
double Price() const override { return 99.0; }
};
// struct SpecialEBook : EBook {}; // ERROR: EBook is final
4) Difference from Static Polymorphism (Overload/Template)
Overloads (same name, different signature) and templates are resolved at compile time and don’t use a vtable. Dynamic polymorphism is resolved at runtime via virtual dispatch. Choose based on your needs:
- If runtime behavior switching is required → use virtual.
- If compile-time strategy/optimization fits → use templates/CRTP or overloads.
5) Designing a Polymorphic Interface (Book Example)
Abstract shared behavior in the base class and customize in derived classes. Here’s a price example for digital vs physical:
#include <iostream>
#include <memory>
#include <string>
using namespace std;
class Book {
protected:
string title, author;
public:
Book(string t, string a) : title(t), author(a) {}
virtual ~Book() = default;
virtual double FinalPrice() const = 0; // pure virtual: abstract contract
virtual void PrintInfo() const {
cout << title << " - " << author
<< " | " << FinalPrice() << " TL" << endl;
}
};
class EBook : public Book {
double base, vat; // digital price and VAT
public:
EBook(string t, string a, double b, double v)
: Book(t,a), base(b), vat(v) {}
double FinalPrice() const override {
return base*(1.0+vat);
}
};
class PrintedBook : public Book {
double base, ship; int pages; bool hard;
public:
PrintedBook(string t, string a, double b, double s, int p, bool h)
: Book(t,a), base(b), ship(s), pages(p), hard(h) {}
double FinalPrice() const override {
double cost = base + ship + 0.04*pages;
if (hard) cost *= 1.1;
return cost;
}
};
int main() {
unique_ptr<Book> b1 = make_unique<EBook>("Modern C++", "A. Dev", 100, 0.10);
unique_ptr<Book> b2 = make_unique<PrintedBook>("Clean Code", "R. Martin", 120, 20, 464, true);
b1->PrintInfo(); // EBook behavior
b2->PrintInfo(); // PrintedBook behavior
}
6) Object Slicing and Using References/Pointers
If you copy a derived object by value into a base type, the derived-specific part gets sliced off. For polymorphism, always use a base reference or base pointer.
PrintedBook pb("X","Y",100,20,200,false);
Book base = pb; // SLICING: only the Book subobject is copied
base.PrintInfo(); // Book behavior (no derived specifics)
7) dynamic_cast and Type Safety
Use dynamic_cast for safe downcasts from a base type to a derived (requires RTTI).
On failure it returns nullptr (for pointers).
void PrintIfPrinted(const Book* b) {
if (auto pb = dynamic_cast<const PrintedBook*>(b)) {
cout << "Printed price: " << pb->FinalPrice() << endl;
} else {
cout << "Not a PrintedBook." << endl;
}
}
8) Performance Notes
- Each virtual call is an indirect call via the vtable (possible branch misprediction). Usually negligible.
- Inlining a virtual function is less likely (target decided at runtime).
- If you have very hot loops with many virtual calls: consider a strategy pattern + templates (CRTP) or function objects to favor static binding.
9) final Class/Method, override Pitfalls, and Best Practices
- Use a virtual destructor in base classes.
- Mark every overridden virtual with override to catch signature mistakes early.
- Use final for methods/classes you don’t want further overridden.
- Manage polymorphic hierarchies with the heap + smart pointers (
unique_ptr,shared_ptr). - Don’t add virtuals unnecessarily if polymorphism isn’t needed (avoid over-generalization and indirection).
10) Visualizing the vtable Effect (Step by Step)
- The class contains at least one virtual function → the compiler emits a vtable for it.
- When an object is constructed, its vptr points to the class’s vtable.
- On a call, the compiler fetches the target via vptr → vtable instead of a static address.
- An override replaces the corresponding entry in the derived class’s vtable.
11) Common Mistakes
- Forgetting a virtual destructor → undefined behavior when deleting via a base pointer.
- Copying by value and causing object slicing.
- Omitting
override→ a signature mismatch silently creates a new function (override fails). - Unnecessary virtuals → needless indirection and cost in simple designs.
12) Complete Example: A Polymorphic Collection
#include <iostream>
#include <vector>
#include <memory>
using namespace std;
class Book {
public:
virtual ~Book() = default;
virtual double FinalPrice() const = 0;
virtual void Print() const = 0;
};
class EBook : public Book {
double base, vat;
public:
EBook(double b, double v) : base(b), vat(v) {}
double FinalPrice() const override { return base*(1+vat); }
void Print() const override {
cout << "[EBook] " << FinalPrice() << " TL" << endl;
}
};
class PrintedBook : public Book {
double base, ship; int pages;
public:
PrintedBook(double b, double s, int p) : base(b), ship(s), pages(p) {}
double FinalPrice() const override { return base + ship + 0.05*pages; }
void Print() const override {
cout << "[Printed] " << FinalPrice() << " TL" << endl;
}
};
int main() {
vector<unique_ptr<Book>> items;
items.push_back(make_unique<EBook>(100, 0.1));
items.push_back(make_unique<PrintedBook>(120, 20, 400));
for (const auto& it : items) it->Print(); // dynamic polymorphism
}
13) TL;DR
- Dynamic polymorphism in C++ is powered by virtual functions.
- Compilers implement virtual dispatch with a vtable/vptr.
- A virtual destructor is mandatory for polymorphic bases.
- override enforces signature correctness; final prevents further overrides.
- Use references/pointers (beware of slicing) for polymorphism instead of copying by value.
- Overhead is usually small; for hot paths consider alternatives (CRTP/overloads).
- Examples compile and run on Visual Studio 2022 and GCC 11+.