C++ Programming

Runtime Polymorphism, Abstract Classes, Casts and RTTI

PGCP-AC

1. One Interface, Several Implementations

Polymorphism allows the same operation to behave differently for different object types. C++ supports compile-time polymorphism through function overloading, operator overloading and templates. It supports runtime polymorphism through virtual functions and inheritance.

Runtime polymorphism is useful when the caller knows the common role of an object but does not need to know its exact derived type. A drawing routine can ask every Shape to draw itself. A payment service can ask every PaymentMethod to process an amount. The caller sends the same request while each object supplies its own implementation.

Three elements are normally required:

  1. a base class that declares a virtual operation;
  2. a derived class that overrides that operation;
  3. a call made through a base pointer or reference.

2. Static and Dynamic Type

An expression involving a class object can have both a static type and a dynamic type. The static type is known from the declaration and is used during compilation. The dynamic type is the type of the complete object that actually exists at runtime.

class Shape {
public:
    virtual double area() const { return 0.0; }
    virtual ~Shape() = default;
};

class Circle : public Shape {
public:
    explicit Circle(double radius) : radius_(radius) {}
    double area() const override {
        return 3.141592653589793 * radius_ * radius_;
    }
private:
    double radius_;
};

Circle circle(2.0);
Shape& shape = circle;
double result = shape.area();

The static type of shape is Shape&, while the dynamic type of the referred object is Circle. Because area is virtual, the call selects Circle::area.

3. Virtual Functions

The virtual keyword on a base member enables dynamic dispatch. Once a function is virtual in a base class, matching overrides remain virtual in all descendants even if later declarations omit the keyword. Repeating virtual is allowed, but writing override communicates the derived declaration more precisely.

Dynamic dispatch chooses the final overrider for the object's dynamic type. A nonvirtual call is selected from static type and ordinary name lookup.

Virtual dispatch works through references and pointers. A call made on an object copied by value has only that object's own type; if a derived object was copied into a base value, slicing has already removed its derived portion.

4. Correct Overriding

A derived member overrides a base virtual member only when its signature is compatible. The function name, parameter types, cv-qualification and reference qualification participate. Return types normally match, although a pointer or reference return may use a permitted covariant derived type.

class Document {
public:
    virtual void save() const;
};

class Report : public Document {
public:
    void save() const override;
};

If Report accidentally writes void save() without const, it declares a different function and hides the base name. The override specifier asks the compiler to reject such a mismatch. Use override on every intended override.

5. The final Specifier

The final specifier can stop further overriding:

void save() const final;

It can also stop further derivation:

class FixedReport final : public Document {
};

Final is useful when an implementation depends on behavior no descendant may change or when a concrete class was never designed as another inheritance base. It should express a design decision rather than serve as routine decoration.

6. Pure Virtual Functions

A pure virtual function is declared by placing = 0 after its declaration:

class Shape {
public:
    virtual double area() const = 0;
    virtual void draw() const = 0;
    virtual ~Shape() = default;
};

A class with at least one unimplemented pure virtual function is abstract. It cannot be instantiated directly. It can still have constructors, data members, nonvirtual functions, implemented virtual functions and a destructor. Its constructor initializes the base part of concrete derived objects.

A pure virtual function may have a definition outside the class in specialized designs, but it remains pure and does not by itself make the class concrete. A pure virtual destructor must have a definition because base destruction still executes.

7. Abstract Classes as Interfaces

An abstract base defines a common contract. A small interface-like class often contains pure virtual operations and a virtual destructor:

class Printable {
public:
    virtual void print(std::ostream& out) const = 0;
    virtual ~Printable() = default;
};

class Invoice : public Printable {
public:
    void print(std::ostream& out) const override {
        out << "Invoice";
    }
};

C++ does not have a separate interface keyword. An abstract class serves that role. It may be stateless or it may provide shared implementation and protected facilities. Keeping an interface narrow makes independent implementations easier to write and reduces coupling.

8. Virtual Destructors

If a base pointer may own and delete a derived object, the base needs a virtual destructor:

std::unique_ptr<Shape> p = std::make_unique<Circle>(2.0);

When p is destroyed, virtual dispatch begins destruction with Circle and then completes Shape destruction. Without a virtual base destructor, deleting the object through Shape* has undefined behavior.

A polymorphic base commonly declares:

virtual ~Shape() = default;

Declaring a destructor virtual affects generated move operations, so a resource-owning hierarchy should also consider its copy and move policy. Interface bases are often noncopyable or use shared ownership rules explicitly.

9. A Polymorphic Collection

Objects of different sizes cannot be stored as intact derived objects in a vector of base values. Such insertion slices them. Store owning smart pointers instead:

std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(2.0));
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));

for (const auto& shape : shapes) {
    std::cout << shape->area() << '\n';
}

Each smart pointer owns one complete object. The virtual call selects the correct area operation and the virtual destructor supports correct cleanup.

10. Virtual Calls During Construction

During base construction, the derived portion does not yet have a completed lifetime. A virtual call made by a base constructor therefore uses the implementation for the construction stage, not an override in a more-derived class.

During destruction the reverse principle applies. Once a derived destructor has finished, calls made from a base destructor do not dispatch back into the destroyed derived portion.

Calling virtual functions from constructors or destructors is legal in many cases, but it rarely provides the behavior beginners expect. Calling a pure virtual function for which no appropriate definition is available can cause failure. Complete initialization first, then invoke polymorphic behavior from ordinary operations or a factory.

11. Default Arguments and Virtual Functions

Virtual dispatch chooses the function body dynamically, but default arguments are selected statically from the expression's static type:

class Base {
public:
    virtual void log(int level = 1) const;
};

class Derived : public Base {
public:
    void log(int level = 2) const override;
};

Derived d;
Base& b = d;
b.log();

The body is Derived::log, but the argument supplied is 1 from Base's declaration. Avoid different defaults on virtual overrides. A nonvirtual public wrapper that calls a protected virtual implementation can provide one stable default.

12. Virtual Dispatch and Qualification

An explicitly qualified call suppresses virtual dispatch:

derived.Base::operation();

This calls the named base implementation. Derived overrides sometimes use such a qualified call to extend rather than completely replace base behavior.

Virtual dispatch is also bypassed when the expression names a nonvirtual member. Virtuality is a property of the declared member function, not of the object alone.

13. Runtime Type Information

Runtime type information or RTTI, provides limited information about object types during execution. C++ primarily exposes RTTI through dynamic_cast and typeid. For class expressions to reveal the dynamic type, the relevant base must be polymorphic, meaning that it declares at least one virtual function.

RTTI is valuable at boundaries where an operation truly depends on a more specific type. Repeated type tests throughout a program often indicate that behavior should instead be represented by a virtual operation in the common interface.

14. dynamic_cast

dynamic_cast performs checked conversions within a polymorphic class hierarchy. It is commonly used to downcast from a base pointer to a derived pointer:

Shape* shape = obtainShape();
if (auto* circle = dynamic_cast<Circle*>(shape)) {
    std::cout << circle->radius();
}

If the complete object contains an appropriate Circle subobject, the result points to it. If it does not, the pointer result is nullptr. The check prevents the program from treating an unrelated object as Circle.

For references, failure cannot be represented by null:

try {
    Circle& circle = dynamic_cast<Circle&>(shapeReference);
} catch (const std::bad_cast&) {
    // object is not a Circle
}

A failed reference dynamic_cast throws std::bad_cast. A cast to void* from a pointer to a polymorphic subobject can obtain a pointer to the most-derived object.

15. static_cast

static_cast performs well-defined compile-time conversions such as numeric conversions, explicit use of converting constructors and derived-to-base conversions. It can also express a base-to-derived conversion when the relationship permits it, but it generally does not check the object's dynamic type.

Shape* shape = obtainShape();
Circle* circle = static_cast<Circle*>(shape);

The statement may compile even when shape actually points to a Rectangle. Using the resulting pointer as a Circle then has undefined behavior. Use this form only when program logic has already proved the dynamic type. Prefer dynamic_cast when the type is uncertain.

Static casting cannot cast away constness and it does not perform arbitrary unrelated pointer reinterpretation.

16. const_cast

const_cast adds or removes cv-qualification:

void legacy(char* text);

std::string value = "data";
legacy(value.data());

It may be used carefully when an older API wrongly lacks const qualification and is known not to modify the object. Removing const from a pointer does not change the original object. If an object was originally declared const, modifying it through the cast produces undefined behavior.

Well-designed const-correct APIs rarely need const_cast. Its presence deserves an explanation because it weakens a promise made by the type system.

17. reinterpret_cast

reinterpret_cast requests a low-level reinterpretation supported by the language, commonly between certain pointer or integer representations:

std::uintptr_t address =
    reinterpret_cast<std::uintptr_t>(pointer);

It does not validate object type, lifetime, alignment or aliasing. A cast that compiles does not guarantee that dereferencing the result is legal. This cast belongs mainly in systems code, hardware interfaces, serialization internals and carefully reviewed interoperability boundaries.

Use standard facilities such as bit_cast where an actual bitwise representation conversion is intended and its requirements are met. Do not use reinterpret_cast as a shortcut around an uncertain class hierarchy.

18. C-Style Casts

A C-style cast can attempt several different C++ conversions, including removal of constness and low-level reinterpretation. Because the source text does not reveal which power is being used, it is harder to search for and review.

The named C++ casts communicate intent:

  • static_cast for checked-at-compilation language conversions;
  • dynamic_cast for runtime-checked hierarchy conversions;
  • const_cast for qualification changes;
  • reinterpret_cast for low-level representation conversions.

Selecting the narrowest cast makes both the intended operation and its risk visible.

19. typeid and type_info

The typeid operator produces a reference to std::type_info. Applied to a glvalue expression of polymorphic class type, it describes the dynamic type:

Shape& shape = circle;
if (typeid(shape) == typeid(Circle)) {
    // exact dynamic type is Circle
}

For a pointer expression, typeid(pointer) describes the pointer's static type. Dereference the pointer when the pointed-to object's dynamic type is needed. Applying typeid to a dereferenced null pointer of polymorphic type throws std::bad_typeid.

std::type_info supports equality and ordering-related facilities. Its name() result is implementation-defined and may be mangled. It is unsuitable as a portable file format, network identifier or persistent serialization contract. Define stable application identifiers for those uses.

20. Designing Without Excessive Type Tests

Suppose code repeatedly asks whether each Shape is a Circle, Rectangle or Triangle before drawing it. Drawing is behavior that naturally belongs in the Shape contract, so a virtual draw function is clearer and easier to extend.

Type queries remain reasonable when the desired operation is outside the hierarchy's responsibility, when integrating with a fixed external hierarchy or when performing occasional diagnostics. The design question is whether a new derived type should require edits throughout existing conditional logic. A well-chosen virtual interface usually lets the new class supply its behavior locally.

21. Practical Hierarchy Rules

Give polymorphic bases a virtual destructor when deletion through the base is allowed. Mark intended overrides with override. Use final only where extension must stop. Pass polymorphic objects by reference or pointer and use smart pointers to express ownership.

Keep base contracts stable and small. Avoid calling overridable behavior from constructors and destructors. Do not give virtual overrides inconsistent default arguments. Treat every downcast as a design decision: use dynamic_cast when failure is possible and use static_cast only when an invariant proves the target type.

Runtime polymorphism is most effective when clients depend on an operation rather than a concrete class. The base states what can be requested, each derived type defines how it is performed and dynamic dispatch connects the request to the complete object's implementation.

Continue learning

Related notes

Put this topic into timed practice

Open mock tests when you want full-exam pacing, or keep drilling in practice mode.