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.
#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.
#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.
#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.
#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;
}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.
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.
- 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::variantmitstd::visitliefert Polymorphie zur Kompilierzeit für geschlossene Typmengenstd::functionbietet 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)?