Aller au contenu

Composition, interfaces et conception

Maîtrisez la composition plutôt que l'héritage, les interfaces abstraites pures, les principes de conception (ISP, DIP), std::variant comme alternative à l'héritage, et l'effacement de type.

Pourquoi la composition plutôt que l'héritage ?

« Préférez la composition à l'héritage » est l'un des principes les plus importants de la conception POO. L'héritage crée un couplage fort entre les classes de base et dérivées — les changements dans la base se répercutent dans toute la hiérarchie. La composition (avoir un membre d'un autre type) est plus flexible : vous pouvez changer le type contenu à l'exécution, combiner les comportements librement et éviter le problème de la classe de base fragile.

L'héritage est approprié quand vous avez besoin du polymorphisme (traiter différents types de manière uniforme via une interface commune). Pour la réutilisation de code seule, la composition est presque toujours supérieure.

La composition en pratique

Au lieu d'hériter un comportement, contenez-le. Une Car n'est pas un Engine — elle a un Engine. Cela vous donne la liberté d'échanger les moteurs, de tester les composants indépendamment et de faire évoluer chaque classe sans casser l'autre.

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;
}

Interfaces abstraites pures

Une interface abstraite pure est une classe avec uniquement des fonctions virtuelles pures et un destructeur virtuel — pas de données, pas d'implémentation. Cela définit un contrat que les classes dérivées doivent respecter. Cela supporte le principe d'inversion de dépendance (DIP) : dépendez des abstractions, pas des classes concrètes.

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 — Alternative à l'héritage

std::variant est une union type-safe qui peut contenir l'un de plusieurs types. Combiné avec std::visit, il fournit un polymorphisme de compilation — une alternative aux fonctions virtuelles quand l'ensemble des types est connu à la compilation. Il évite l'allocation heap et le surcoût du dispatch virtuel.

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;
}

Effacement de type avec std::function

L'effacement de type vous permet de stocker n'importe quel callable — fonction, lambda, foncteur — avec une signature compatible dans un seul objet std::function. C'est une forme de polymorphisme d'exécution sans héritage explicite.

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;
}
Bonne pratique

Principe de ségrégation des interfaces (ISP) : préférez les petites interfaces focalisées aux grandes interfaces monolithiques. Une classe ne devrait pas être forcée d'implémenter des méthodes qu'elle n'utilise pas. Divisez IAnimal en IFlyable et ISwimmable si tous les animaux ne volent pas et ne nagent pas.

Principe d'inversion de dépendance (DIP) : les modules de haut niveau devraient dépendre d'abstractions (interfaces), pas d'implémentations concrètes. Passez IRepository& au lieu de MySQLDatabase& — cela vous permet d'échanger les implémentations pour les tests (repositories simulés) et les différents environnements.

Piège

Les hiérarchies d'héritage profondes (4+ niveaux) deviennent fragiles et difficiles à raisonner. Un changement dans une classe de base peut avoir des effets inattendus sur des descendants distants. Le problème de la classe de base fragile rend la maintenance un cauchemar. Préférez les hiérarchies plates (1-2 niveaux), utilisez les interfaces pour le polymorphisme et composez les comportements avec des objets membres ou std::variant. Si la profondeur de votre hiérarchie dépasse 3 niveaux, c'est presque certainement un signal de problème de conception.

Points clés
  • La composition est plus flexible que l'héritage — utilisez-la pour la réutilisation de code (relations a-un)
  • Les interfaces abstraites pures définissent des contrats et permettent le principe d'inversion de dépendance
  • std::variant avec std::visit fournit un polymorphisme de compilation pour les ensembles de types fermés
  • std::function fournit l'effacement de type — stockez n'importe quel callable avec une signature compatible
  • Gardez les hiérarchies d'héritage plates (1-2 niveaux) et préférez les interfaces aux classes de base concrètes
  • Appliquez l'ISP (petites interfaces) et le DIP (dépendez des abstractions) pour des conceptions maintenables et testables

Quiz — Testez vos connaissances

(15 XP)

1. Quand devriez-vous préférer la composition à l'héritage ?

2. Quel avantage `std::variant` a-t-il sur une hiérarchie d'héritage traditionnelle ?

3. Que stipule le principe d'inversion de dépendance (DIP) ?