C++Architecture

C++ OOP vs Composition: Inheritance, virtual dispatch, override, Interfaces & Dependency Injection

TT
TopicTrick Team
C++ OOP vs Composition: Inheritance, virtual dispatch, override, Interfaces & Dependency Injection

C++ OOP vs Composition: Inheritance, virtual dispatch, override, Interfaces & Dependency Injection


Table of Contents


Inheritance vs Composition: The Fundamental Choice


Virtual Functions and vtable Mechanics

When a class has virtual functions, the compiler creates a vtable (virtual dispatch table) — an array of function pointers. Each object of the class has a hidden vptr (vtable pointer) as its first member:

cpp
class Animal {
public:
    virtual void speak() const { std::cout << "..."; }
    virtual ~Animal() = default;
};

class Dog : public Animal {
public:
    void speak() const override { std::cout << "Woof!\n"; }
};

class Cat : public Animal {
public:
    void speak() const override { std::cout << "Meow!\n"; }
};

// Runtime dispatch:
Animal* a = new Dog();
a->speak();  // Calls Dog::speak() via vtable lookup
// Compiled as: (*a->vptr[0])(a)  — one extra pointer dereference

delete a;  // Calls Dog::~Dog() via vtable (virtual destructor!)

vtable layout (conceptual):

text
Dog vtable:
  [0] → &Dog::speak          ← overridden
  [1] → &Dog::~Dog           ← overridden destructor
  
Cat vtable:
  [0] → &Cat::speak          ← overridden
  [1] → &Cat::~Cat           ← overridden destructor

Each Dog/Cat object:
  [vptr] → Dog/Cat vtable    ← 8 bytes overhead per object
  [data members]

Virtual dispatch cost: One extra indirection (load vptr, load vtable entry, call). On modern in-order CPUs: ~1-3ns. In tight loops processing millions of objects, this is visible — use CRTP or std::variant for performance-critical polymorphism.


Pure Virtual Functions: C++ Interfaces

A class with at least one pure virtual function (= 0) is abstract — cannot be instantiated, only inherited from. This is C++'s interface mechanism:

cpp
// C++ Interface: behavioral contract with no implementation
class IRenderer {
public:
    virtual ~IRenderer() = default;   // Must be virtual!
    
    virtual void begin_frame() = 0;
    virtual void draw_mesh(const Mesh& mesh, const Transform& transform) = 0;
    virtual void end_frame()   = 0;
    
    // Optional: interface may provide default implementations for some methods
    virtual void set_viewport(int x, int y, int w, int h) {
        // Default viewport — subclass may override
    }
};

// Concrete implementations:
class OpenGLRenderer : public IRenderer {
public:
    void begin_frame() override { glClear(GL_COLOR_BUFFER_BIT); }
    void draw_mesh(const Mesh& m, const Transform& t) override {
        // OpenGL draw calls
    }
    void end_frame() override { glSwapBuffers(); }
};

class VulkanRenderer : public IRenderer {
public:
    void begin_frame() override { /* Begin Vulkan command buffer */ }
    void draw_mesh(const Mesh& m, const Transform& t) override {
        /* Vulkan draw commands */
    }
    void end_frame() override { /* Submit command buffer, present */ }
};

// Polymorphic usage:
void render_scene(IRenderer& renderer, const Scene& scene) {
    renderer.begin_frame();
    for (const auto& obj : scene.objects) {
        renderer.draw_mesh(obj.mesh, obj.transform);
    }
    renderer.end_frame();
}
// Works with ANY IRenderer implementation — OpenGL, Vulkan, DirectX, software

override and final: Preventing Mistakes

cpp
class Base {
public:
    virtual void process(int x) const;
    virtual void update(float dt);
};

class Derived : public Base {
public:
    // WITHOUT override: silent bug if signature doesn't match
    void process(int x);        // ERROR: missing const → defines NEW function!
    void upate(float dt);       // TYPO: silently defines new function, doesn't override!
    
    // WITH override: compiler catches all mismatches
    void process(int x) const override;  // OK: matches exactly
    void upate(float dt) override;       // COMPILE ERROR: 'upate' doesn't exist in Base!
    void update(float dt) override;      // OK: correct spelling
};

// final: prevents further overriding or inheritance
class OptimizedImpl final : public Base {
    // final class: cannot be subclassed
    void process(int x) const override final; // Also locks this specific override
};

// class SubOptimized : public OptimizedImpl {}; // COMPILE ERROR: final class

Rule: Always use override on overriding functions. The compiler catches mismatched signatures that would be silent bugs without it.


The virtual Destructor Rule

cpp
// Without virtual destructor — memory leak (common bug):
class Base {
public:
    ~Base() { std::cout << "Base dtor\n"; }  // NOT virtual!
};

class Derived : public Base {
    std::vector<int> data_;  // 100MB of data
public:
    ~Derived() { std::cout << "Derived dtor\n"; } // NEVER CALLED via Base*!
};

Base* b = new Derived();
delete b;  // Calls Base::~Base() only! data_ is LEAKED!

// With virtual destructor — correct:
class SafeBase {
public:
    virtual ~SafeBase() = default; // virtual — enables correct derived destruction
};

class SafeDerived : public SafeBase {
    std::vector<int> data_;
public:
    ~SafeDerived() override = default; // Called correctly via SafeBase*
};

SafeBase* sb = new SafeDerived();
delete sb;  // Calls SafeDerived::~SafeDerived(), then SafeBase::~SafeBase() ✅

Rule: Any class with virtual functions must have a virtual destructor. = default is sufficient if no custom cleanup is needed.


Multiple Inheritance and the Diamond Problem

cpp
// The Diamond Problem:
class A { public: virtual void foo(); };
class B : public A {};
class C : public A {};
class D : public B, public C {}; // D has TWO copies of A!

D d;
// d.foo(); // AMBIGUOUS: through B or through C?
d.B::foo(); // Explicit disambiguation (ugly)

// Solution: Virtual Inheritance
class A2 { public: virtual void foo(); };
class B2 : virtual public A2 {};
class C2 : virtual public A2 {};
class D2 : public B2, public C2 {}; // Only ONE copy of A2 via virtual inheritance

D2 d2;
d2.foo(); // Unambiguous now

// Modern advice: Avoid the diamond entirely.
// Use composition + interfaces instead of multiple concrete inheritance.

Dependency Injection via Abstract Interfaces

Dependency Injection (DI) replaces hard-coded dependencies with injected interfaces — enabling testability, swappability, and composability:

cpp
// Without DI — tightly coupled, untestable:
class OrderService {
    PostgresDB db_;        // Hard dependency!
    EmailSender email_;    // Hard dependency!
public:
    void place_order(const Order& o) {
        db_.save(o);
        email_.send_confirmation(o);
    }
};

// With DI via interfaces — decoupled, testable:
class IDatabase {
public:
    virtual ~IDatabase() = default;
    virtual void save(const Order& order) = 0;
    virtual std::optional<Order> find(int id) = 0;
};

class INotifier {
public:
    virtual ~INotifier() = default;
    virtual void notify(const Order& order) = 0;
};

class OrderService {
    std::unique_ptr<IDatabase> db_;
    std::unique_ptr<INotifier> notifier_;
public:
    OrderService(std::unique_ptr<IDatabase> db,
                 std::unique_ptr<INotifier> notifier)
        : db_(std::move(db))
        , notifier_(std::move(notifier)) {}
    
    void place_order(const Order& o) {
        db_->save(o);
        notifier_->notify(o);
    }
};

// Production usage:
auto service = OrderService(
    std::make_unique<PostgresDB>("connection_string"),
    std::make_unique<EmailSender>("smtp://mail.example.com")
);

// Test usage — no database, no email server needed:
class MockDB : public IDatabase {
    std::vector<Order> orders_;
public:
    void save(const Order& o) override { orders_.push_back(o); }
    std::optional<Order> find(int id) override { /* search orders_ */ }
    const auto& saved_orders() const { return orders_; }
};

auto test_service = OrderService(
    std::make_unique<MockDB>(),
    std::make_unique<MockNotifier>()
);

Policy-Based Design (CRTP): Zero-Cost Static Polymorphism

CRTP (Curiously Recurring Template Pattern) provides compile-time polymorphism — no vtable, no virtual dispatch overhead:

cpp
// CRTP base — T is the derived class
template<typename Derived>
class Serializable {
public:
    std::string serialize() const {
        return static_cast<const Derived*>(this)->serialize_impl();
    }
    
    bool save_to_file(const std::string& path) const {
        auto data = serialize();
        // Write data to file
        return true;
    }
};

class Config : public Serializable<Config> {
    std::string host_;
    int port_;
public:
    std::string serialize_impl() const {
        return std::format("host={}&port={}", host_, port_);
    }
};

class UserProfile : public Serializable<UserProfile> {
    std::string name_;
    int age_;
public:
    std::string serialize_impl() const {
        return std::format("name={}&age={}", name_, age_);
    }
};

// Usage:
Config cfg;
cfg.save_to_file("config.txt");  // Calls Config::serialize_impl() — ZERO overhead

// Unlike virtual dispatch, CRTP calls are resolved at compile time.
// The compiler inlines serialize_impl() directly — identical to non-polymorphic code.

Frequently Asked Questions

When is inheritance genuinely the right choice? Use inheritance for: (1) implementing a behavioral contract defined by an abstract interface class, (2) adding a specialization with shared core behavior where "is-a" is genuinely true (a Dog is-an Animal), (3) CRTP for compile-time extensibility. Avoid for "sharing code" — use composition or free functions for that.

What is the NVI (Non-Virtual Interface) pattern? NVI makes public functions non-virtual and virtual functions private/protected. Public non-virtual functions provide pre/post conditions, then call the virtual hook. This prevents derived classes from changing invariants enforced by the base's public interface. Example: std::ostream::operator<< is non-virtual; derived classes override virtual do_unshift.

Should I always use unique_ptr for polymorphic objects? Yes — std::unique_ptr<IBase> with a virtual destructor is the standard pattern for polymorphic ownership. If multiple owners exist, use std::shared_ptr<IBase>. IBase* (raw pointer) is acceptable in non-owning observer roles when lifetime is managed elsewhere.


Key Takeaway

Modern C++ OOP is selective. Virtual dispatch (runtime polymorphism) is for open extension points where the type set grows at runtime. CRTP (static polymorphism) is for fixed type sets requiring zero overhead. Composition + dependency injection enables the flexibility of polymorphism without the fragility of deep hierarchies. Prefer interfaces (pure abstract classes) over concrete base classes to keep inheritance trees shallow and contracts clear.

Read next: Templates & Generic Programming: Logic Without Types →


Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.