C++ / Advanced / Lesson 16
Polymorphism and alternative designs
Choose runtime interfaces, templates, or closed variants according to extension needs while preserving ownership, destruction, and value semantics.
On this page
Lesson goals
- Use a virtual interface with a virtual destructor and override.
- Avoid slicing and unsafe ownership through base pointers.
- Compare open hierarchies with variant and static polymorphism.
Choose the axis that should remain open
Polymorphism lets one interface represent multiple implementations. A runtime hierarchy suits an open set of implementations selected through base references or pointers. A template suits compile-time variation with operations visible to the compiler. std::variant models a closed set of alternatives and can make exhaustive handling explicit. Start with the expected extension: will new types arrive from another module, or will new operations be added over a fixed set of types? Avoid inheritance merely to reuse a few fields; composition often preserves a simpler contract and clearer value semantics.
Make dynamic dispatch and destruction deliberate
A virtual function selects the final override according to the dynamic object type. override asks the compiler to verify that a declaration really overrides a base virtual function. A public virtual destructor permits safe deletion through an owning base pointer. The first example stores a derived rectangle in unique_ptr<Shape>, calls area through the base interface, and destroys it through that same owner. The interface is const for observation. The small dimensions keep multiplication representable; a production geometry interface would specify units and numeric limits as part of its contract.
#include <iostream>
#include <memory>
struct Shape {
virtual ~Shape() = default;
virtual int area() const = 0;
};
class Rectangle final : public Shape {
int width_;
int height_;
public:
Rectangle(int width, int height) : width_(width), height_(height) {}
int area() const override { return width_ * height_; }
};
int main() {
std::unique_ptr<Shape> shape = std::make_unique<Rectangle>(3, 4);
std::cout << shape->area() << "\n";
}
12
The call reaches Rectangle::area and prints twelve. The virtual destructor makes destruction through unique_ptr<Shape> valid.
A base value is not a complete derived value
Copying a derived object into a base object by value discards the derived part, an effect called slicing. Base containers therefore do not preserve arbitrary derived behavior by storing base values. Use references or owning pointers for runtime polymorphic collections, or a value alternative such as variant. A derived implementation must respect the base contract: strengthening preconditions unexpectedly or weakening promised results breaks substitution. Virtual calls during construction or destruction do not dispatch as if the most-derived object were fully alive; avoid relying on such calls for derived initialization or cleanup.
Handle a closed set with visitation
The second example stores either a RectangleValue or a SquareValue in variant. A visitor handles both alternatives explicitly, and no base class or dynamic allocation is required by this design. Adding a new alternative requires reviewing the visitor, which can be desirable for exhaustive business rules. variant can become valueless_by_exception during some throwing operations; design code to respect that possibility when alternatives have fallible construction or assignment. Here both alternatives are simple integer aggregates, and construction plus a read-only visit has no such transition.
#include <iostream>
#include <variant>
struct RectangleValue { int width; int height; };
struct SquareValue { int side; };
struct Area {
int operator()(const RectangleValue& value) const { return value.width * value.height; }
int operator()(const SquareValue& value) const { return value.side * value.side; }
};
int main() {
const std::variant<RectangleValue, SquareValue> shape = SquareValue{5};
std::cout << std::visit(Area{}, shape) << "\n";
}
25
The active alternative is a square with side five, so the matching overload returns twenty-five.
Treat binary and source boundaries separately
A stable source interface does not automatically imply a stable binary interface across compilers, standard-library builds, or changed class layouts. Plugin systems need an explicit compatibility and allocation policy. Avoid passing ownership across a boundary that requires incompatible allocation and deallocation runtimes. For in-process application code, choose the simplest design that supports required extensions, then measure dynamic-dispatch costs in context. Replacing every virtual call with templates may increase compile time and code size. Document error behavior, thread safety, and whether interface objects can be copied, moved, or retained.
Common errors and fixes
A derived object is destroyed through an unsafe base owner.
- Why it happens
- The publicly deletable base lacks a virtual destructor.
- How to fix it
- Provide a public virtual destructor for that ownership contract.
- Prevent it next time
- Review destruction whenever polymorphic ownership is introduced.
Derived behavior disappears in a base-value collection.
- Why it happens
- Copying by value sliced the derived part.
- How to fix it
- Use polymorphic owners or an explicit value alternative.
- Prevent it next time
- Keep identity-oriented hierarchies separate from value types.
An intended override is never called.
- Why it happens
- Its signature differs from the base virtual function.
- How to fix it
- Match constness and parameters, and add override.
- Prevent it next time
- Require override on derived virtual implementations.
Check your understanding
1. A payment interface must accept future implementations from separate application modules. Compare a virtual interface and a fixed variant for this requirement.
Show worked solution
A virtual interface allows new derived implementations without editing a central alternative list, so it fits an open implementation set. A variant requires listing all alternatives and updating visitors, which suits a closed model better. Define ownership, virtual destruction, errors, and compatibility boundaries explicitly; openness does not remove these obligations.
2. Add RectangleValue{2, 7} as the active variant alternative. Predict the result and explain why no downcast is necessary.
Show worked solution
std::visit selects Area::operator()(const RectangleValue&) and returns fourteen. The active alternative is tracked by variant, so dispatch is type-directed without asking callers to cast a base pointer. Adding a third alternative without a compatible visitor should be caught during compilation rather than silently taking an unrelated branch.
Key takeaways
- Choose open runtime or closed value polymorphism deliberately.
- Protect polymorphic destruction and avoid slicing.
- Preserve base contracts and document boundary compatibility.