C++ Virtual Functions & Polymorphism: Complete Guide
What Is Polymorphism?
Polymorphism means “many forms.” In C++, it lets you call a method on a base class pointer or reference and have the derived class version execute at runtime. A single function draw(Shape* s) can draw circles, rectangles, or triangles — without knowing which type it is dealing with. The right method is called automatically based on the actual object type.
This is the most powerful feature of object-oriented programming. It enables plugin architectures, framework design, strategy patterns, and any system where behavior varies by type. Without polymorphism, you would need endless if/else chains checking the type manually. With it, you write the logic once and let the type system dispatch the correct behavior.
C++ supports two kinds of polymorphism: compile-time (templates and function overloading) and runtime (virtual functions). This lesson covers runtime polymorphism — the kind enabled by the virtual keyword.
The Problem Without Virtual
Consider this hierarchy without virtual:
#include <iostream>
using namespace std;
class Animal {
public:
void speak() const { cout << "..." << endl; }
};
class Dog : public Animal {
public:
void speak() const { cout << "Woof!" << endl; }
};
class Cat : public Animal {
public:
void speak() const { cout << "Meow!" << endl; }
};
void makeItSpeak(const Animal& a) {
a.speak(); // Which speak() gets called?
}
int main() {
Dog dog;
Cat cat;
dog.speak(); // Woof! (called on Dog directly)
cat.speak(); // Meow! (called on Cat directly)
makeItSpeak(dog); // ... (Animal::speak! Not Dog::speak!)
makeItSpeak(cat); // ... (Animal::speak! Not Cat::speak!)
return 0;
}
The function makeItSpeak takes an Animal& reference. Without virtual, the compiler uses static dispatch — it sees the declared type (Animal) and calls Animal::speak(). The actual runtime type (Dog or Cat) is ignored. This is almost never what you want.
The virtual Keyword
Adding virtual to the base class function enables dynamic dispatch — the actual type determines which version runs:
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() const { cout << "..." << endl; }
virtual ~Animal() = default; // virtual destructor (explained later)
};
class Dog : public Animal {
public:
void speak() const override { cout << "Woof!" << endl; }
};
class Cat : public Animal {
public:
void speak() const override { cout << "Meow!" << endl; }
};
class Parrot : public Animal {
public:
void speak() const override { cout << "Polly wants a cracker!" << endl; }
};
void makeItSpeak(const Animal& a) {
a.speak(); // Now calls the CORRECT version based on actual type
}
int main() {
Dog dog;
Cat cat;
Parrot parrot;
makeItSpeak(dog); // Woof!
makeItSpeak(cat); // Meow!
makeItSpeak(parrot); // Polly wants a cracker!
return 0;
}
One word — virtual — completely changes the behavior. Now makeItSpeak works correctly for any animal type, including types that don’t exist yet. You could add Snake, Horse, or Dragon without changing makeItSpeak at all.
The override Specifier
The override keyword (C++11) tells the compiler “I intend to override a virtual function.” If the base class does not have a matching virtual function, the compiler reports an error:
class Animal {
public:
virtual void speak() const { }
};
class Dog : public Animal {
public:
void speak() const override { } // OK: matches base
// void speak() override { } // ERROR: missing const — different signature
// void spek() const override { } // ERROR: typo — no such virtual in base
// void speak(int x) const override { } // ERROR: different parameters
};
Without override, a signature mismatch silently creates a new function that hides the base version instead of overriding it. This is one of the most common bugs in C++ inheritance. Always use override. It costs nothing and catches real bugs.
How It Works: The vtable
When a class has virtual functions, the compiler creates a virtual function table (vtable) — an array of function pointers. Each polymorphic object contains a hidden pointer to its class’s vtable:
#include <iostream>
using namespace std;
class Base {
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
int data = 42;
};
class Derived : public Base {
public:
void func1() override { cout << "Derived::func1" << endl; }
// func2 not overridden — uses Base::func2
};
int main() {
cout << "sizeof(int): " << sizeof(int) << endl;
cout << "sizeof(Base): " << sizeof(Base) << endl;
// sizeof(Base) = sizeof(int) + sizeof(vptr) + padding
Base b;
Derived d;
Base* ptr = &d;
ptr->func1(); // Derived::func1 (looked up through vtable)
ptr->func2(); // Base::func2 (Derived didn't override it)
return 0;
}
The vtable lookup is conceptually: ptr->vptr[0]() for func1, ptr->vptr[1]() for func2. Derived’s vtable has its own func1 pointer but points to Base::func2 since it was not overridden.
The vtable adds one pointer per object (typically 8 bytes on 64-bit systems) and one indirect function call per virtual dispatch. This overhead is minimal — do not avoid virtual functions for performance reasons unless you have measured a bottleneck.
Virtual Destructors
This is one of the most important rules in C++: if a class has virtual functions, its destructor must be virtual. Without it, deleting a derived object through a base pointer causes undefined behavior:
#include <iostream>
using namespace std;
class Base {
public:
// BUG: non-virtual destructor
~Base() { cout << "Base destroyed" << endl; }
};
class Derived : public Base {
int* data;
public:
Derived() : data(new int[1000]) { cout << "Derived created" << endl; }
~Derived() {
delete[] data;
cout << "Derived destroyed" << endl;
}
};
int main() {
Base* ptr = new Derived();
delete ptr;
// Output: Base destroyed
// Derived destructor NEVER runs — data leaked!
// This is UNDEFINED BEHAVIOR
return 0;
}
The fix:
class Base {
public:
virtual ~Base() { cout << "Base destroyed" << endl; }
// Or: virtual ~Base() = default;
};
// Now delete ptr correctly calls:
// Derived destroyed
// Base destroyed
Rule: Any class intended as a base class for polymorphism must have a virtual destructor. The simplest approach: if you write virtual anywhere in a class, make the destructor virtual too.
The final Specifier
final prevents a virtual function from being overridden further, or prevents a class from being inherited:
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() const { cout << "..." << endl; }
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() const final { cout << "Woof!" << endl; }
// No class can override Dog::speak
};
// class Puppy : public Dog {
// void speak() const override { } // ERROR: speak is final in Dog
// };
// Prevent inheritance entirely
class Singleton final {
public:
static Singleton& getInstance() {
static Singleton s;
return s;
}
private:
Singleton() {}
};
// class MySingleton : public Singleton { }; // ERROR: Singleton is final
final on a function means “this is the last override.” final on a class means “nobody can inherit from this.” The compiler can also use final to optimize away vtable lookups (devirtualization), since it knows no further overrides exist.
Pure Virtual Functions (Preview)
A pure virtual function has no implementation in the base class — derived classes must provide one:
class Shape {
public:
virtual double area() const = 0; // pure virtual — no body
virtual double perimeter() const = 0; // pure virtual
virtual ~Shape() = default;
};
// Shape s; // ERROR: cannot instantiate abstract class
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
double perimeter() const override { return 2 * 3.14159 * radius; }
};
A class with at least one pure virtual function is an abstract class. It cannot be instantiated — it exists only as a base for derived classes. This is covered in depth in the next lesson.
Polymorphic Containers
To store different derived types in one container, use pointers (or smart pointers) to the base class:
#include <iostream>
#include <vector>
#include <memory>
using namespace std;
class Shape {
public:
virtual void draw() const = 0;
virtual string name() const = 0;
virtual double area() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
double r;
public:
Circle(double radius) : r(radius) {}
void draw() const override { cout << "Drawing circle (r=" << r << ")" << endl; }
string name() const override { return "Circle"; }
double area() const override { return 3.14159 * r * r; }
};
class Rectangle : public Shape {
double w, h;
public:
Rectangle(double width, double height) : w(width), h(height) {}
void draw() const override { cout << "Drawing rect (" << w << "x" << h << ")" << endl; }
string name() const override { return "Rectangle"; }
double area() const override { return w * h; }
};
class Triangle : public Shape {
double base, height;
public:
Triangle(double b, double h) : base(b), height(h) {}
void draw() const override { cout << "Drawing triangle" << endl; }
string name() const override { return "Triangle"; }
double area() const override { return 0.5 * base * height; }
};
int main() {
// Store different shapes in one vector using smart pointers
vector<unique_ptr<Shape>> shapes;
shapes.push_back(make_unique<Circle>(5));
shapes.push_back(make_unique<Rectangle>(4, 6));
shapes.push_back(make_unique<Triangle>(3, 8));
shapes.push_back(make_unique<Circle>(2));
// Polymorphic loop — each shape draws itself correctly
double totalArea = 0;
for (const auto& shape : shapes) {
shape->draw();
cout << " " << shape->name() << " area: " << shape->area() << endl;
totalArea += shape->area();
}
cout << "Total area: " << totalArea << endl;
return 0;
}
Why pointers? Storing by value would cause object slicing — the derived part gets cut off. Storing by pointer preserves the full object. unique_ptr handles cleanup automatically. Never use raw new for polymorphic containers — smart pointers prevent memory leaks.
Runtime Type Information (RTTI)
Sometimes you need to check or convert between types at runtime. C++ provides dynamic_cast and typeid for polymorphic types:
#include <iostream>
#include <typeinfo>
using namespace std;
class Animal {
public:
virtual void speak() const { cout << "..." << endl; }
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() const override { cout << "Woof!" << endl; }
void fetch() const { cout << "Fetching ball!" << endl; }
};
class Cat : public Animal {
public:
void speak() const override { cout << "Meow!" << endl; }
void purr() const { cout << "Purrrr..." << endl; }
};
void interact(Animal* a) {
a->speak(); // polymorphic call
// typeid — get the actual type name
cout << "Type: " << typeid(*a).name() << endl;
// dynamic_cast — safe downcast
if (Dog* dog = dynamic_cast<Dog*>(a)) {
dog->fetch(); // only called if a is actually a Dog
} else if (Cat* cat = dynamic_cast<Cat*>(a)) {
cat->purr(); // only called if a is actually a Cat
}
}
int main() {
Dog dog;
Cat cat;
interact(&dog); // Woof! → Fetching ball!
interact(&cat); // Meow! → Purrrr...
return 0;
}
dynamic_cast returns nullptr if the cast fails (for pointers) or throws std::bad_cast (for references). It only works on polymorphic types (classes with at least one virtual function).
Warning: frequent use of dynamic_cast often indicates a design problem. If you are constantly checking types, you probably need more virtual functions in the base class. Good polymorphic design avoids downcasting.
Performance Cost of Virtual
Virtual function calls have a small overhead compared to regular function calls:
- Memory: one vptr per object (8 bytes on 64-bit), one vtable per class (shared by all instances)
- Speed: one extra pointer dereference per call (vtable lookup). Typically 1-3 nanoseconds
- Inlining: virtual calls usually cannot be inlined (the compiler does not know which function will be called). This is the main performance impact in tight loops
For 99% of code, this overhead is irrelevant. Measure before optimizing. When it matters (hot loops in game engines, numerical computing), alternatives include CRTP (Curiously Recurring Template Pattern) and std::variant with std::visit.
Real-World Example: Plugin System
#include <iostream>
#include <vector>
#include <memory>
#include <string>
using namespace std;
// Plugin interface — base class with pure virtuals
class Plugin {
public:
virtual string getName() const = 0;
virtual string getVersion() const = 0;
virtual void initialize() = 0;
virtual void execute(const string& input) = 0;
virtual void shutdown() = 0;
virtual ~Plugin() = default;
};
// Concrete plugins
class MarkdownPlugin : public Plugin {
public:
string getName() const override { return "Markdown Renderer"; }
string getVersion() const override { return "1.2.0"; }
void initialize() override { cout << "[MD] Loading templates..." << endl; }
void execute(const string& input) override {
cout << "[MD] Rendering: " << input << endl;
}
void shutdown() override { cout << "[MD] Cleanup complete" << endl; }
};
class SyntaxHighlighter : public Plugin {
public:
string getName() const override { return "Syntax Highlighter"; }
string getVersion() const override { return "2.0.1"; }
void initialize() override { cout << "[SH] Loading language grammars..." << endl; }
void execute(const string& input) override {
cout << "[SH] Highlighting: " << input << endl;
}
void shutdown() override { cout << "[SH] Grammars unloaded" << endl; }
};
class SpellChecker : public Plugin {
int corrections = 0;
public:
string getName() const override { return "Spell Checker"; }
string getVersion() const override { return "3.1.0"; }
void initialize() override { cout << "[SC] Loading dictionary..." << endl; }
void execute(const string& input) override {
corrections++;
cout << "[SC] Checking: " << input << " (total checks: " << corrections << ")" << endl;
}
void shutdown() override { cout << "[SC] " << corrections << " corrections made" << endl; }
};
// Plugin manager — works with ANY plugin through the base interface
class PluginManager {
vector<unique_ptr<Plugin>> plugins;
public:
void registerPlugin(unique_ptr<Plugin> p) {
cout << "Registered: " << p->getName() << " v" << p->getVersion() << endl;
plugins.push_back(std::move(p));
}
void initAll() {
for (auto& p : plugins) p->initialize();
}
void runAll(const string& input) {
for (auto& p : plugins) p->execute(input);
}
void shutdownAll() {
for (auto& p : plugins) p->shutdown();
}
};
int main() {
PluginManager manager;
manager.registerPlugin(make_unique<MarkdownPlugin>());
manager.registerPlugin(make_unique<SyntaxHighlighter>());
manager.registerPlugin(make_unique<SpellChecker>());
cout << "\n--- Initializing ---" << endl;
manager.initAll();
cout << "\n--- Processing ---" << endl;
manager.runAll("# Hello World");
manager.runAll("int main() { return 0; }");
cout << "\n--- Shutting down ---" << endl;
manager.shutdownAll();
return 0;
}
The PluginManager knows nothing about Markdown, syntax highlighting, or spell checking. It only knows the Plugin interface. You can add new plugins (image optimizer, link checker, SEO analyzer) without changing a single line in PluginManager. This is the Open/Closed Principle in action: open for extension, closed for modification.
Practice Exercises
Exercise 1: Create an Instrument base class with a virtual play() method. Derive Guitar, Piano, and Drums. Write a function concert(vector<Instrument*>) that plays all instruments. Verify polymorphism works through base pointers.
Exercise 2: Build a Logger hierarchy: base class with virtual log(string). Derive ConsoleLogger, FileLogger (writes to cout with “[FILE]” prefix), and NullLogger (discards all messages). Store them in a vector<unique_ptr<Logger>> and verify each logs differently.
Exercise 3: Create a class hierarchy without a virtual destructor. Allocate a derived object with new, store it in a base pointer, and delete it. Add cout messages to both destructors and observe that the derived destructor does not run. Then fix it with virtual.
Exercise 4: Implement a simple Calculator that uses polymorphic Operation objects. Base class Operation has virtual double compute(double a, double b). Derive Add, Subtract, Multiply, Divide. Store operations in a map and look them up by symbol string.
Summary
Virtual functions enable runtime polymorphism — the most powerful OOP mechanism in C++. Adding virtual to a base class function makes the derived version run even through base pointers and references. Always use override to catch signature mismatches. Always make destructors virtual in base classes to prevent resource leaks. The vtable mechanism adds minimal overhead (one pointer per object, one indirection per call). Use unique_ptr for polymorphic containers to avoid slicing and memory leaks. The final specifier stops further overriding and helps the optimizer. In the next lesson, you will learn about abstract classes and interfaces — the pattern for defining contracts that derived classes must fulfill.