Zum Inhalt springen

Komposition, Schnittstellen & Design

Beherrsche Komposition statt Vererbung, rein abstrakte Schnittstellen, Designprinzipien (ISP, DIP), std::variant als Alternative zur Vererbung und Type Erasure.

Warum Komposition statt Vererbung?

"Bevorzuge Komposition gegenüber Vererbung" ist eines der wichtigsten Prinzipien im OOP-Design. Vererbung erzeugt eine enge Kopplung zwischen Basisklasse und abgeleiteten Klassen — Änderungen an der Basis pflanzen sich durch die gesamte Hierarchie fort. Komposition (also einen Member eines anderen Typs zu besitzen) ist flexibler: Du kannst den enthaltenen Typ zur Laufzeit austauschen, Verhaltensweisen frei kombinieren und das Problem der fragilen Basisklasse vermeiden.

Vererbung ist dann angebracht, wenn du Polymorphie brauchst, also verschiedene Typen einheitlich über eine gemeinsame Schnittstelle behandeln willst. Geht es nur um Code-Wiederverwendung, ist Komposition fast immer überlegen.

Komposition in der Praxis

Statt Verhalten zu erben, enthalte es. Ein Car ist kein Engine — er hat einen Engine. Das gibt dir die Freiheit, Motoren auszutauschen, Komponenten unabhängig zu testen und jede Klasse weiterzuentwickeln, ohne die andere zu brechen.

composition.cpp
#include <iostream>
#include <string>
#include <memory>

class Engine {
    int horsepower_;
    std::string type_;

public:
    Engine(int hp, const std::string& type)
        : horsepower_{hp}, type_{type} {}

    void start() const {
        std::cout << type_ << " engine (" << horsepower_ << "hp) started\n";
    }

    int horsepower() const { return horsepower_; }
};

class Transmission {
    int gears_;
public:
    explicit Transmission(int gears) : gears_{gears} {}
    void shift(int gear) const {
        if (gear >= 1 && gear <= gears_) {
            std::cout << "Shifted to gear " << gear << '\n';
        }
    }
};

// Car COMPOSES Engine and Transmission (has-a, not is-a)
class Car {
    std::string model_;
    Engine engine_;
    Transmission trans_;

public:
    Car(const std::string& model, Engine engine, Transmission trans)
        : model_{model}
        , engine_{std::move(engine)}
        , trans_{std::move(trans)} {}

    void drive() const {
        std::cout << model_ << ": ";
        engine_.start();
        trans_.shift(1);
    }
};

int main() {
    Engine v8{450, "V8"};
    Transmission auto6{6};
    Car mustang{"Mustang", std::move(v8), std::move(auto6)};
    mustang.drive();
    return 0;
}

Rein abstrakte Schnittstellen

Eine rein abstrakte Schnittstelle ist eine Klasse, die nur rein virtuelle Funktionen und einen virtuellen Destruktor enthält — keine Daten, keine Implementierung. Sie definiert einen Vertrag, den abgeleitete Klassen erfüllen müssen. Das unterstützt das Dependency-Inversion-Prinzip (DIP): Hänge von Abstraktionen ab, nicht von konkreten Klassen.

interface.cpp
#include <iostream>
#include <memory>
#include <string>
#include <vector>

// Pure abstract interface — the contract
class IRepository {
public:
    virtual ~IRepository() = default;
    virtual void save(const std::string& key, const std::string& value) = 0;
    virtual std::string load(const std::string& key) const = 0;
    virtual bool exists(const std::string& key) const = 0;
};

// Concrete implementation: in-memory storage
class MemoryRepository : public IRepository {
    std::vector<std::pair<std::string, std::string>> data_;

public:
    void save(const std::string& key, const std::string& value) override {
        for (auto& [k, v] : data_) {
            if (k == key) { v = value; return; }
        }
        data_.emplace_back(key, value);
    }

    std::string load(const std::string& key) const override {
        for (const auto& [k, v] : data_) {
            if (k == key) return v;
        }
        return "";
    }

    bool exists(const std::string& key) const override {
        for (const auto& [k, v] : data_) {
            if (k == key) return true;
        }
        return false;
    }
};

// Service depends on the INTERFACE, not the concrete class (DIP)
class UserService {
    std::unique_ptr<IRepository> repo_;

public:
    explicit UserService(std::unique_ptr<IRepository> repo)
        : repo_{std::move(repo)} {}

    void register_user(const std::string& username) {
        if (repo_->exists(username)) {
            std::cout << username << " already exists\n";
            return;
        }
        repo_->save(username, "active");
        std::cout << username << " registered\n";
    }
};

int main() {
    auto repo = std::make_unique<MemoryRepository>();
    UserService service{std::move(repo)};

    service.register_user("alice");  // alice registered
    service.register_user("bob");    // bob registered
    service.register_user("alice");  // alice already exists
    return 0;
}

std::variant — die Alternative zur Vererbung

std::variant ist eine typsichere Union, die einen von mehreren Typen halten kann. Zusammen mit std::visit bietet sie Polymorphie zur Kompilierzeit — eine Alternative zu virtuellen Funktionen, wenn die Menge der Typen bereits zur Kompilierzeit feststeht. Sie vermeidet Heap-Allokationen und den Overhead des virtuellen Dispatch.

variant_poly.cpp
#include <iostream>
#include <variant>
#include <vector>
#include <cmath>
#include <numbers>

struct Circle  { double radius; };
struct Square  { double side; };
struct Triangle { double base, height; };

// Type alias for the closed set of shapes
using Shape = std::variant<Circle, Square, Triangle>;

// Visitor: a callable that handles each type
struct AreaVisitor {
    double operator()(const Circle& c) const {
        return std::numbers::pi * c.radius * c.radius;
    }
    double operator()(const Square& s) const {
        return s.side * s.side;
    }
    double operator()(const Triangle& t) const {
        return 0.5 * t.base * t.height;
    }
};

struct NameVisitor {
    std::string operator()(const Circle&) const { return "Circle"; }
    std::string operator()(const Square&) const { return "Square"; }
    std::string operator()(const Triangle&) const { return "Triangle"; }
};

int main() {
    std::vector<Shape> shapes{
        Circle{5.0},
        Square{4.0},
        Triangle{3.0, 6.0}
    };

    for (const auto& shape : shapes) {
        std::string name = std::visit(NameVisitor{}, shape);
        double area = std::visit(AreaVisitor{}, shape);
        std::cout << name << ": area = " << area << '\n';
    }
    return 0;
}

Type Erasure mit std::function

Type Erasure erlaubt dir, jedes Callable — Funktion, Lambda, Funktor — mit passender Signatur in einem einzigen std::function-Objekt zu speichern. Das ist eine Form von Laufzeitpolymorphie ohne explizite Vererbung.

type_erasure.cpp
#include <iostream>
#include <functional>
#include <vector>
#include <string>

// std::function erases the concrete callable type
using Callback = std::function<void(const std::string&)>;

class EventEmitter {
    std::vector<Callback> listeners_;

public:
    void on(Callback cb) {
        listeners_.push_back(std::move(cb));
    }

    void emit(const std::string& event) const {
        for (const auto& cb : listeners_) {
            cb(event);
        }
    }
};

// A functor
struct FileLogger {
    void operator()(const std::string& msg) const {
        std::cout << "[File] " << msg << '\n';
    }
};

void console_log(const std::string& msg) {
    std::cout << "[Console] " << msg << '\n';
}

int main() {
    EventEmitter emitter;

    // Register different callable types — all stored as std::function
    emitter.on(console_log);             // function pointer
    emitter.on(FileLogger{});            // functor
    emitter.on([](const std::string& m) { // lambda
        std::cout << "[Lambda] " << m << '\n';
    });

    emitter.emit("user_login");
    return 0;
}
Best Practice

Interface-Segregation-Prinzip (ISP): Bevorzuge kleine, fokussierte Schnittstellen gegenüber großen, monolithischen. Keine Klasse sollte gezwungen sein, Methoden zu implementieren, die sie gar nicht nutzt. Teile IAnimal in IFlyable und ISwimmable auf, wenn nicht alle Tiere fliegen und schwimmen.

Dependency-Inversion-Prinzip (DIP): High-Level-Module sollten von Abstraktionen (Schnittstellen) abhängen, nicht von konkreten Implementierungen. Übergib IRepository& statt MySQLDatabase& — so kannst du Implementierungen für Tests (Mock-Repositories) und für verschiedene Umgebungen austauschen.

Stolperfalle

Tiefe Vererbungshierarchien (ab 4 Ebenen) werden fragil und schwer nachvollziehbar. Eine Änderung an einer Basisklasse kann unerwartete Auswirkungen auf weit entfernte Nachfahren haben. Das Problem der fragilen Basisklasse macht die Wartung zum Albtraum. Bevorzuge flache Hierarchien (1-2 Ebenen), nutze Schnittstellen für Polymorphie und setze Verhalten aus Member-Objekten oder std::variant zusammen. Übersteigt deine Hierarchie 3 Ebenen, ist das fast sicher ein Designproblem.

Wichtigste Erkenntnisse
  • Komposition ist flexibler als Vererbung — nutze sie für Code-Wiederverwendung (Hat-ein-Beziehungen)
  • Rein abstrakte Schnittstellen definieren Verträge und ermöglichen das Dependency-Inversion-Prinzip
  • std::variant mit std::visit liefert Polymorphie zur Kompilierzeit für geschlossene Typmengen
  • std::function bietet Type Erasure — speichere jedes Callable mit passender Signatur
  • Halte Vererbungshierarchien flach (1-2 Ebenen) und bevorzuge Schnittstellen gegenüber konkreten Basisklassen
  • Wende ISP (kleine Schnittstellen) und DIP (von Abstraktionen abhängen) an, um wartbare und testbare Designs zu erhalten

Quiz — Teste dein Wissen

(15 XP)

1. Wann solltest du Komposition der Vererbung vorziehen?

2. Welchen Vorteil hat `std::variant` gegenüber einer klassischen Vererbungshierarchie?

3. Was besagt das Dependency-Inversion-Prinzip (DIP)?