Back

Runtime Type Identification and Polymorphism in Modern C++

This post explores various techniques for determining the dynamic type of an object at runtime in C++ when dealing with inheritance hierarchies, along with best practices for using polymorphism and smart pointers.

Scenario

We have a base class A and derived classes B and C:

A (Base Class)
├── B (Derived Class)
└── C (Derived Class)

We want to be able to determine, at runtime, whether a pointer to A is actually pointing to an object of type B or C.

Techniques

1. dynamic_cast (Recommended for Safe Downcasting)

dynamic_cast is the safest and most standard way to perform downcasting in a polymorphic hierarchy.

How it works: Requirements: Example with Smart Pointers:
#include <iostream>
#include <memory>

class A {
public:
    virtual ~A() {} // Important: A must have at least one virtual function
};

class B : public A {
public:
    void bSpecific() { std::cout << "B specific\n"; }
};

class C : public A {
public:
    void cSpecific() { std::cout << "C specific\n"; }
};

int main() {
    std::unique_ptr<A> aPtr = std::make_unique<B>();

    if (B* bPtr = dynamic_cast<B*>(aPtr.get())) {
        std::cout << "aPtr is pointing to an object of type B\n";
        bPtr->bSpecific();
    } else {
        std::cout << "aPtr is not pointing to an object of type B\n";
    }

    if (C* cPtr = dynamic_cast<C*>(aPtr.get())) {
        std::cout << "aPtr is pointing to an object of type C\n";
        cPtr->cSpecific();
    } else {
        std::cout << "aPtr is not pointing to an object of type C\n";
    }

    return 0;
}
Pros: Cons:

2. typeid and type_info (Less Common for Downcasting)

typeid returns a reference to a std::type_info object, which contains type information.

How it works: Example with Smart Pointers:
#include <iostream>
#include <memory>
#include <typeinfo>

// ... (A, B, C classes as before)

int main() {
    std::shared_ptr<A> aPtr = std::make_shared<C>();

    if (typeid(*aPtr) == typeid(B)) {
        std::cout << "aPtr is pointing to an object of type B\n";
    } else if (typeid(*aPtr) == typeid(C)) {
        std::cout << "aPtr is pointing to an object of type C\n";
    } else {
        std::cout << "aPtr is pointing to an object of type A or another derived type\n";
    }

    return 0;
}
Pros: Cons:

3. Polymorphism with Virtual Functions (Preferred Design)

Instead of explicit type checking, define virtual functions in the base class and override them in derived classes.

How it works: Example with Smart Pointers and Pure Virtual Function:
#include <iostream>
#include <memory>
#include <string>

class A {
public:
    virtual std::string identify() = 0; // Pure virtual function
    virtual ~A() {}
};

class B : public A {
public:
    std::string identify() override { return "I am B"; }
};

class C : public A {
public:
    std::string identify() override { return "I am C"; }
};

int main() {
    std::unique_ptr<A> aPtr = std::make_unique<B>();
    std::cout << aPtr->identify() << std::endl;

    std::shared_ptr<A> aPtr2 = std::make_shared<C>();
    std::cout << aPtr2->identify() << std::endl;

    return 0;
}
Pros: Cons:

4. Using Enums and a Dictionary for Type Identification (Type-Safe and Readable)

This approach combines an enum class for type-safe identification with an unordered_map to store string representations of the types.

Example with Smart Pointers, Pure Virtual Function, and Enum:
#include <iostream>
#include <memory>
#include <string>
#include <unordered_map>

enum class ClassType {
    A,
    B,
    C
};

const std::unordered_map<ClassType, std::string> classTypeToString = {
    {ClassType::A, "Class A"},
    {ClassType::B, "Class B"},
    {ClassType::C, "Class C"}
};

class A {
public:
    virtual ClassType identify() = 0;
    virtual ~A() {}
};

class B : public A {
public:
    ClassType identify() override { return ClassType::B; }
};

class C : public A {
public:
    ClassType identify() override { return ClassType::C; }
};

int main() {
    std::unique_ptr<A> aPtr = std::make_unique<B>();
    ClassType typeB = aPtr->identify();
    std::cout << "Type: " << classTypeToString.at(typeB) << std::endl;

    std::shared_ptr<A> aPtr2 = std::make_shared<C>();
    ClassType typeC = aPtr2->identify();
    std::cout << "Type: " << classTypeToString.at(typeC) << std::endl;

    return 0;
}
Pros: Cons:

5. Adding a Type Identifier (Generally Discouraged)

Manually adding a type identifier (e.g., an enum or string member) to the base class is generally not recommended.

Cons:

Recommendations

  1. Prefer polymorphism (virtual functions) whenever possible. It's the most object-oriented and efficient approach for handling type-specific behavior.
  2. Use dynamic_cast when you need to know the exact type for reasons other than calling type-specific behavior. It's safe and standard.
  3. Consider enums and a dictionary for type identification when type safety, readability, and maintainability are high priorities.
  4. Avoid typeid unless you have a specific reason to use it and understand its limitations for downcasting.
  5. Do not use manual type identifiers. They are error-prone and lead to less maintainable code.

Smart Pointers

Throughout these examples, we've used smart pointers (std::unique_ptr and std::shared_ptr) for automatic memory management.

Using smart pointers is crucial for writing safe and robust modern C++ code. They prevent memory leaks and dangling pointers, making your programs more reliable.