Polymorphism — Virtual Functions, vtable, override, and final
In this tutorial, you will learn about Polymorphism. We cover key concepts, practical examples, and best practices to help you master this topic.
C++ polymorphism allows derived class functions to be called through base class pointers or references using virtual functions and a virtual table (vtable) for dynamic dispatch.
What You'll Learn
You will declare virtual functions for polymorphic behavior, understand the vtable mechanism and its overhead, use override to catch signature mismatches at compile time, use final to prevent overriding, call base class implementations from overridden functions, and understand when virtual dispatch is resolved at compile time versus runtime.
Why It Matters
Polymorphism is the third pillar of OOP (Encapsulation, inheritance, polymorphism). It lets you write code that operates on base class interfaces while executing derived class implementations. This is how C++ supports the Open-Closed Principle: code is open for extension (new derived classes) but closed for modification (existing code uses base class pointers/references).
Learning Path
graph LR
A["15: Inheritance"] --> B["16: Polymorphism"]
B --> C["17: Abstract Classes"]
C --> D["18: Multiple Inheritance"]
style A fill:#4a90d9,stroke:#2c5f8a,color:#fff
style B fill:#4a90d9,stroke:#2c5f8a,color:#fff
style C fill:#4a90d9,stroke:#2c5f8a,color:#fff
style D fill:#4a90d9,stroke:#2c5f8a,color:#fff
Virtual Functions
#include <iostream>
#include <vector>
#include <memory>
class Shape {
public:
virtual double area() const {
return 0.0;
}
virtual void draw() const {
std::cout << "Drawing a shape\n";
}
virtual ~Shape() = default;
};
class Circle : public Shape {
private:
double radius_;
public:
Circle(double r) : radius_(r) {}
double area() const override {
return 3.14159 * radius_ * radius_;
}
void draw() const override {
std::cout << "Drawing a circle (radius=" << radius_ << ")\n";
}
};
class Rectangle : public Shape {
private:
double width_, height_;
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
double area() const override {
return width_ * height_;
}
void draw() const override {
std::cout << "Drawing a rectangle (" << width_ << "x" << height_ << ")\n";
}
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(5.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
for (const auto& s : shapes) {
s->draw();
std::cout << "Area: " << s->area() << "\n";
}
}
Expected output:
Drawing a circle (radius=5)
Area: 78.5397
Drawing a rectangle (3x4)
Area: 12
The vtable Mechanism
When a class has at least one virtual function, the compiler generates a virtual table (vtable) for that class. Each object of that class contains a hidden pointer (vptr) pointing to the class's vtable.
Object of Circle:
+-----------+
| vptr | --> Circle's vtable:
| radius_ | +-----------------+
+-----------+ | type_info |
| ~Circle() |
| area() | --> Circle::area()
| draw() | --> Circle::draw()
+-----------------+
When you call shape->draw(), the compiler:
- Follows the vptr to the vtable
- Looks up
draw()at the correct offset in the vtable - Calls the function pointer stored there
This indirection has a small runtime cost but enables dynamic dispatch. The vtable itself is generated once per class and shared among all instances.
The override Specifier
class Base {
public:
virtual void foo(int x) {}
virtual void bar() const {}
};
class Derived : public Base {
public:
// Without override, this is a NEW function (hides Base::foo)
void foo(int x) override { // OK: matches Base::foo
}
// void bar() override { } // Error: Base::bar is const, this is not
};
override tells the compiler to verify that the function actually overrides a base class virtual function. If the signature does not match, the compiler errors. Always mark overriding functions with override.
The final Specifier
class Base {
public:
virtual void f() {}
};
class Derived final : public Base {
public:
void f() override final {
// Cannot be overridden further
}
};
// class GrandChild : public Derived { }; // Error: Derived is final
final on a class prevents further derivation. final on a virtual function prevents further overriding. Use final to seal class hierarchies and enable compiler optimizations (devirtualization).
Calling Base Class Implementations
#include <iostream>
class Base {
public:
virtual void log() const {
std::cout << "[Base] ";
}
};
class Derived : public Base {
public:
void log() const override {
Base::log(); // explicit call to base version
std::cout << "[Derived] ";
}
};
int main() {
Derived d;
d.log(); // [Base] [Derived]
Base& ref = d;
ref.Base::log(); // [Base] (bypasses virtual dispatch)
}
Calling Base::log() explicitly bypasses virtual dispatch. This is useful when a derived class wants to extend (not replace) the base behavior.
Virtual Destructors
class Base {
public:
virtual ~Base() = default; // ALWAYS virtual in polymorphic base
};
class Derived : public Base {
int* data_ = new int[100];
public:
~Derived() override {
delete[] data_;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // without virtual destructor: undefined behavior, Derived leaks
}
When Virtual Functions Are Not Used
void printAreaByValue(Shape s) { // SLICING: no polymorphism
std::cout << s.area() << "\n"; // always calls Shape::area()
}
void printAreaByRef(const Shape& s) { // polymorphism works
std::cout << s.area() << "\n";
}
Polymorphism only works with references and pointers. Passing by value slices the object, removing the vtable pointer.
Common Mistakes
Mistake 1: Forgetting virtual in Base Class
class Base {
public:
void f() {} // not virtual
};
class Derived : public Base {
public:
void f() {} // hides, not overrides
};
Base* p = new Derived();
p->f(); // calls Base::f, not Derived::f
Mistake 2: Missing override — Signature Mismatch
class Derived : public Base {
public:
void f(int x) override; // Error if Base::f takes double
};
Use override to catch these mismatches.
Mistake 3: Calling Virtual Functions in Constructor/Destructor
During construction and destruction, the dynamic type is the class being constructed/destroyed, not the most derived type. Virtual function calls resolve to the current class's version.
Mistake 4: Non-Virtual Destructor
If you delete a derived object through a base pointer and the base destructor is not virtual, the derived destructor never runs, and resources leak.
Mistake 5: Assuming Virtual Calls are Always Dynamic
The compiler can devirtualize calls when the dynamic type is known at compile time (e.g., calling a virtual function on a stack-allocated object).
Practice Questions
- What is the vtable and how does it enable polymorphism?
- What does the
overridekeyword do? Why should you always use it? - Why do virtual destructors matter in polymorphic hierarchies?
- How can you call the base class version of a virtual function from derived code?
- What is the difference between early binding and late binding?
Challenge
Create a plugin-style architecture: define an Effect base class with a virtual apply function. Implement InvertEffect, BlurEffect, and GrayscaleEffect derived classes. Store pointers in a vector and call apply polymorphically.
FAQ
Mini Project
Build a polymorphic logging system:
#include <iostream>
#include <vector>
#include <memory>
#include <fstream>
class Logger {
public:
virtual ~Logger() = default;
virtual void log(const std::string& message) = 0;
};
class ConsoleLogger : public Logger {
public:
void log(const std::string& message) override {
std::cout << "[Console] " << message << "\n";
}
};
class FileLogger : public Logger {
private:
std::ofstream file_;
public:
FileLogger(const std::string& path) {
file_.open(path);
}
void log(const std::string& message) override {
file_ << "[File] " << message << "\n";
file_.flush();
}
};
class FilteredLogger : public Logger {
private:
std::unique_ptr<Logger> wrapped_;
std::string prefix_;
public:
FilteredLogger(std::unique_ptr<Logger> wrapped, const std::string& prefix)
: wrapped_(std::move(wrapped)), prefix_(prefix) {}
void log(const std::string& message) override {
if (message.find(prefix_) == 0) {
wrapped_->log(message);
}
}
};
int main() {
std::vector<std::unique_ptr<Logger>> loggers;
loggers.push_back(std::make_unique<ConsoleLogger>());
loggers.push_back(std::make_unique<FileLogger>("log.txt"));
loggers.push_back(std::make_unique<FilteredLogger>(
std::make_unique<ConsoleLogger>(), "ERROR"));
for (const auto& l : loggers) {
l->log("INFO: System started");
l->log("ERROR: Disk full");
}
}
What's Next
Polymorphism enables runtime flexibility. The next lesson covers abstract classes: pure virtual functions, interface classes, and why virtual destructors remain essential in abstract bases.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro