Composizione, Interfacce e Progettazione
Padroneggia la composizione al posto dell'ereditarietà, le interfacce puramente astratte, i principi di progettazione (ISP, DIP), std::variant come alternativa all'ereditarietà e la type erasure.
Perché Preferire la Composizione all'Ereditarietà?
"Preferisci la composizione all'ereditarietà" è uno dei principi più importanti nella progettazione OOP. L'ereditarietà crea un accoppiamento stretto tra classi base e derivate — le modifiche alla base si propagano a tutta la gerarchia. La composizione (avere un membro di un altro tipo) è più flessibile: puoi cambiare il tipo contenuto a runtime, combinare liberamente i comportamenti ed evitare il problema della classe base fragile.
L'ereditarietà è appropriata quando ti serve il polimorfismo (trattare in modo uniforme tipi diversi attraverso un'interfaccia comune). Per il solo riuso del codice, la composizione è quasi sempre superiore.
La Composizione in Pratica
Invece di ereditare un comportamento, contienilo. Una Car non è un Engine — ha un Engine. Questo ti dà la libertà di cambiare motore, testare i componenti in modo indipendente e far evolvere ogni classe senza rompere l'altra.
#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;
}Interfacce Puramente Astratte
Un'interfaccia puramente astratta è una classe con sole funzioni virtuali pure e un distruttore virtuale — nessun dato, nessuna implementazione. Definisce un contratto che le classi derivate devono rispettare. Supporta il Principio di Inversione delle Dipendenze (DIP): dipendi dalle astrazioni, non dalle classi concrete.
#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 — Alternativa all'Ereditarietà
std::variant è una union type-safe che può contenere uno tra diversi tipi. Combinata con std::visit, offre un polimorfismo a tempo di compilazione — un'alternativa alle funzioni virtuali quando l'insieme dei tipi è noto a tempo di compilazione. Evita l'allocazione sull'heap e il costo del dispatch virtuale.
#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 con std::function
La type erasure ti permette di memorizzare qualsiasi callable — funzione, lambda, funtore — con una firma compatibile in un unico oggetto std::function. È una forma di polimorfismo a runtime senza ereditarietà esplicita.
#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;
}Principio di Segregazione delle Interfacce (ISP): preferisci interfacce piccole e mirate a quelle grandi e monolitiche. Una classe non dovrebbe essere costretta a implementare metodi che non usa. Dividi IAnimal in IFlyable e ISwimmable se non tutti gli animali volano e nuotano.
Principio di Inversione delle Dipendenze (DIP): i moduli di alto livello dovrebbero dipendere da astrazioni (interfacce), non da implementazioni concrete. Passa IRepository& invece di MySQLDatabase& — così puoi sostituire l'implementazione per i test (repository mock) e per ambienti diversi.
Le gerarchie di ereditarietà profonde (4 o più livelli) diventano fragili e difficili da seguire. Una modifica a una classe base può avere effetti inattesi su discendenti lontani. Il problema della classe base fragile trasforma la manutenzione in un incubo. Preferisci gerarchie piatte (1-2 livelli), usa le interfacce per il polimorfismo e componi i comportamenti con oggetti membro o std::variant. Se la profondità della tua gerarchia supera i 3 livelli, è quasi certamente un campanello d'allarme progettuale.
- La composizione è più flessibile dell'ereditarietà — usala per il riuso del codice (relazioni has-a)
- Le interfacce puramente astratte definiscono contratti e abilitano il Principio di Inversione delle Dipendenze
std::variantconstd::visitoffre polimorfismo a tempo di compilazione per insiemi di tipi chiusistd::functionfornisce la type erasure — memorizza qualsiasi callable con una firma compatibile- Mantieni piatte le gerarchie di ereditarietà (1-2 livelli) e preferisci le interfacce alle classi base concrete
- Applica ISP (interfacce piccole) e DIP (dipendi dalle astrazioni) per progetti manutenibili e testabili
Quiz — Metti alla prova le tue conoscenze
(15 XP)1. Quando dovresti preferire la composizione all'ereditarietà?
2. Quale vantaggio ha `std::variant` rispetto a una gerarchia di ereditarietà tradizionale?
3. Cosa afferma il Principio di Inversione delle Dipendenze (DIP)?