Course outline

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.

About 4 min readContent reviewed
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.

cpp A dynamic implementation with one clear owner
#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";
}
Expected output
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.

cpp A visitor over two owned alternatives
#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";
}
Expected output
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.

Official references