Virtual Functions and Polymorphism
In the previous chapter, we learned about single inheritance—where a derived class inherits members from a base class and can extend new behaviors on top of that. However, inheritance only solves half the problem: if we use a base class pointer to manipulate a derived class object, the function called is always the base class version, which severely limits the expressiveness of inheritance. Virtual functions are the key to completing the puzzle—they make it possible to "call derived class implementations via a base class interface," which is known as runtime polymorphism.
Today, we will sit down and thoroughly understand this: what exactly virtual does, why override should always be written, how the vtable generated by the compiler behind the scenes works, and what kind of disaster can occur if you forget to write a virtual destructor.
The World Without virtual—The "Nearsightedness" of Base Class Pointers
Let's face the problem head-on. Suppose we have a simple class hierarchy for shapes:
class Shape {
public:
void draw() const { std::cout << "Drawing a generic shape\n"; }
};
class Circle : public Shape {
public:
void draw() const { std::cout << "Drawing a Circle\n"; }
};
class Rectangle : public Shape {
public:
void draw() const { std::cout << "Drawing a Rectangle\n"; }
};Three classes, and both Circle and Rectangle define their own draw. This looks fine—but when we call them via a base class pointer, things go wrong:
Shape* s1 = new Circle;
Shape* s2 = new Rectangle;
Shape* s3 = new Shape;
s1->draw();
s2->draw();
s3->draw();You expect to see three different drawing behaviors, but the actual result is:
Drawing a generic shape
Drawing a generic shape
Drawing a generic shapeAll three outputs are "Drawing a generic shape". When compiling s1->draw(), the compiler only sees that the static type of s1 is Shape*, so it dutifully binds Shape::draw. It doesn't know, nor does it care, that this pointer actually points to a Circle or Rectangle at runtime—this is static binding (also known as early binding). When we need "unified interface, different behaviors," static binding is a stumbling block, and virtual is the key to breaking it.
The virtual Keyword—Making Function Calls "Wait Until Runtime"
Adding virtual before a member function in the base class changes everything:
class Shape {
public:
virtual void draw() const { std::cout << "Drawing a generic shape\n"; }
};
class Circle : public Shape {
public:
// 'override' is optional here but recommended (explained later)
void draw() const override { std::cout << "Drawing a Circle\n"; }
};
class Rectangle : public Shape {
public:
void draw() const override { std::cout << "Drawing a Rectangle\n"; }
};We only need to add virtual before draw in the base class, and functions with matching signatures in derived classes automatically become virtual functions. Now let's run the loop again:
The output becomes:
Drawing a Circle
Drawing a Rectangle
Drawing a generic shapeEach object calls the corresponding version of draw based on its actual type—this is dynamic binding (also known as late binding), or runtime polymorphism. The core value of polymorphism lies in this: the caller doesn't need to know the specific type of the object, only "what this object can do." This ability of "unified interface, diverse behaviors" is the cornerstone of decoupling in object-oriented design.
The override Keyword (C++11)—The "Safety Belt" Monitored by the Compiler
C++11 introduced the override keyword. It doesn't change any runtime behavior, but it is something you must add when overriding virtual functions. The reason is simple: it forces the compiler to check whether you have actually correctly overridden a base class virtual function.
Let's look at a classic failure scenario when override is not added:
class Base {
public:
virtual void func(int x) { std::cout << "Base::func " << x << "\n"; }
};
class Derived : public Base {
public:
// Oops! Forgot the parameter 'int x'
void func() { std::cout << "Derived::func\n"; }
};Pay attention to the signature of Derived::func—it's missing the int x parameter. This differs from the signature of Base::func, so the compiler considers this a new ordinary member function added by Derived, unrelated to Base::func. When calling func via a base class pointer, static binding is used, and Base::func is still called. The scariest part is: this code compiles completely without any warnings. I have been burned by this more than once.
After adding override, the same problem is caught directly by the compiler:
class Derived : public Base {
public:
void func() override { /* ... */ } // Error!
};error: 'void Derived::func()' marked 'override', but does not overrideThe compiler explicitly tells you: you claim to be overriding a base class virtual function, but the signatures don't match. Errors override can capture include but are not limited to: the virtual function doesn't exist at all in the base class, mismatched function signatures (differences in const, reference qualifiers, etc.), or the base class function isn't virtual. So the iron rule is—whenever you are overriding a virtual function, always write override.
Warning: Missing
overridewon't cause an error, but a wrong signature spells disaster. Make it a habit: addoverrideto every virtual function override, treating it like a mandatory action of buckling up.
Unveiling vtable—The Trampoline Behind Polymorphism
Now that we understand the effect of virtual, let's look at what the compiler does behind the scenes. For every class containing virtual functions, the compiler generates a virtual function table (vtable)—essentially an array of function pointers. Each entry corresponds to a virtual function and stores the address of the actual implementation of that virtual function for that class.
Taking our shape class hierarchy as an example, the compiler roughly generates three vtables:
And every object containing virtual functions has an extra hidden member in its memory layout—the vtable pointer (vptr)—which points to the vtable of the class the object belongs to.
When you write shape_ptr->draw(), the code generated by the compiler roughly performs these steps: first, use the object to find the vptr, locate the corresponding vtable, then retrieve the function pointer for draw from the table, and finally initiate an indirect call through this pointer:
This is the total overhead of a virtual function call compared to a normal function call—one extra indirect jump. On a PC, this overhead is negligible. However, in resource-constrained embedded environments, it needs serious consideration: each class with virtual functions has an extra vtable (occupying Flash), each object has an extra vptr (usually 4 or 8 bytes, occupying RAM), and each virtual function call involves an extra indirect jump (which may affect the pipeline and branch prediction). Fortunately, in the vast majority of scenarios, these overheads are insignificant compared to the "architectural benefits of decoupling."
Warning: On an MCU with only a few KB of RAM, an extra
vptrfor every object can be fatal. If your system needs to create a large number of small objects (like sensor data points), please carefully evaluate the memory overhead of polymorphism.
Virtual Destructors—The Last Line of Defense for Polymorphism
There is a detail in using polymorphism that is often overlooked, but ignoring it results in undefined behavior: when you intend to delete a derived class object via a base class pointer, the base class's destructor must be virtual.
Let's look at the counter-example first:
class Base {
public:
~Base() { std::cout << "Base destroyed\n"; }
};
class Derived : public Base {
public:
~Derived() { std::cout << "Derived destroyed\n"; }
};
Base* ptr = new Derived;
delete ptr; // Danger!The output is only "Base destroyed". ~Derived() is never called, and the 400 bytes of memory corresponding to the buffer are leaked directly. The reason is the same as before: when delete ptr is executed, the compiler sees that the static type of ptr is Base*. Since ~Base() is not a virtual function, static binding binds it to the base class's destructor, and the derived class's destruction logic is completely skipped.
The solution is very simple—add virtual to the base class destructor:
class Base {
public:
virtual ~Base() { std::cout << "Base destroyed\n"; }
};Now execute the same operation:
Derived destroyed
Base destroyedThe destruction order is correct: first Derived, then Base, and resources are fully released. Here we used = default, because the base class destructor itself doesn't have special cleanup work to do. The key is that virtual—it allows the delete operation to use dynamic binding as well.
So there is an iron rule: as long as a class has any virtual functions, its destructor must be declared as virtual. Conversely, if a class has no virtual functions and isn't intended to be inherited, a non-virtual destructor is perfectly fine. But once you start a polymorphic design, this cannot be ambiguous.
Warning: Non-virtual destructor + deleting derived object via base class pointer = undefined behavior. In embedded systems, this usually manifests as "inexplicable memory leaks" or "peripheral state anomalies," and is extremely difficult to track down. When you see virtual functions, immediately check if the destructor is also virtual.
Practical Exercise—A Polymorphic Graphics System
Now let's string together what we learned earlier and write a complete polymorphic graphics system. This example demonstrates how virtual functions work in actual code.
Expand (54 lines)Collapse
#include <iostream>
#include <vector>
#include <memory>
#include <string>
class Shape {
public:
virtual ~Shape() = default;
virtual void draw() const = 0;
virtual double area() const = 0;
std::string name;
int color;
};
class Circle : public Shape {
public:
Circle(double r) : radius(r) {
name = "Circle";
color = 0xFF0000; // Red
}
void draw() const override {
std::cout << "Drawing " << name << " (Color: 0x" << std::hex << color << std::dec << ")\n";
}
double area() const override {
return 3.14159 * radius * radius;
}
private:
double radius;
};
class Rectangle : public Shape {
public:
Rectangle(double w, double h) : width(w), height(h) {
name = "Rectangle";
color = 0x00FF00; // Green
}
void draw() const override {
std::cout << "Drawing " << name << " (Color: 0x" << std::hex << color << std::dec << ")\n";
}
double area() const override {
return width * height;
}
private:
double width;
double height;
};Note the design of Shape: draw and area are pure virtual functions (= 0), meaning Shape itself cannot be instantiated, and any class that wants to be a "valid shape" must provide its own implementation. The destructor is declared as virtual, ensuring polymorphic safety without needing to manually write cleanup logic. name and color are placed in the public section for derived classes to set in their constructors.
Then, in main, we create a group of different shapes and manipulate them with a unified interface:
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.emplace_back(std::make_unique<Circle>(5.0));
shapes.emplace_back(std::make_unique<Rectangle>(4.0, 6.0));
shapes.emplace_back(std::make_unique<Circle>(2.5));
for (const auto& shape : shapes) {
shape->draw();
std::cout << " Area: " << shape->area() << "\n";
}
return 0;
}Running result:
Drawing Circle (Color: 0xff0000)
Area: 78.5397
Drawing Rectangle (Color: 0x0xff0000)
Area: 24
Drawing Circle (Color: 0xff0000)
Area: 19.6349The entire loop relies only on the Shape interface, completely unaware of what specific types are in the container. If we want to add a Triangle class in the future, we just need to inherit from Shape, implement draw and area, and toss it into the container—the main loop code doesn't need to change a single line. This is the extensibility brought by polymorphism.
Exercises
Polymorphic Document Printing: Design a document class hierarchy. The base class
Documenthas a pure virtual functionprint()and a virtual destructor. DeriveTextDocument(prints text content),ImageDocument(prints image description info), andBookDocument(prints page count and author). Inmain, create different types of documents, store them in astd::vector<std::unique_ptr<Document>>, iterate and callprint(), and verify that each type outputs its own content.Verify Virtual Destructors: On top of Exercise 1, add a print statement (e.g.,
std::cout) to each derived class's destructor. First, clean up normally (usingdeleteor lettingunique_ptrhandle it) and observe the destruction order. Then, remove thevirtualkeyword from the base class destructor and run it again to see what changes—you will witness the process of the derived class destructor being skipped.
Summary
In this chapter, we dissected runtime polymorphism around virtual functions. Without virtual, a base class pointer can only statically bind to the base class's function implementation—this is the root cause of why many beginners write inheritance but find "polymorphism doesn't work." The virtual keyword makes function calls dynamic binding, deciding which version to call based on the actual type of the object. override is the safety belt C++11 gave us—always add it after every virtual function override to let the compiler check if the signature really matches. The virtual destructor is the safety baseline for using polymorphism; forgetting it means that when deleting a derived class object via a base class pointer, the derived class's destruction logic is skipped, leading to resource leaks or undefined behavior.
At the underlying mechanism level, the compiler implements all of this through vtables and vptrs: one vtable per class stores function pointers, one vptr per object points to the class's vtable, and a virtual function call is completed through this indirect trampoline. The overhead is small, but in embedded scenarios with extremely tight resources, we need to be aware of it.
In the next chapter, we will enter abstract classes and pure virtual functions—pushing polymorphism to a more rigorous design level, using "capability contracts" to constrain what behaviors derived classes must provide.