Classes abstraites et interfaces en C++
Apprenez les classes abstraites et les interfaces en C++ avec les fonctions virtuelles pures et des exemples pratiques.
En C++, les classes abstraites sont des classes contenant au moins une fonction purement virtuelle et qui ne peuvent pas être instanciées directement. Le langage C++ ne possède pas de mot-clé distinct pour « interface » ; à la place, une classe abstraite composée uniquement de fonctions virtuelles pures joue le rôle d’interface. Dans cet article, nous aborderons la conception des classes abstraites et des interfaces, la mise en œuvre de multiples interfaces, le principe d’inversion de dépendance (DIP) et un scénario pratique avec un exemple de Book.
1) Fonction virtuelle pure et classe abstraite
Une fonction virtuelle marquée avec = 0 est une fonction purement virtuelle.
Une classe contenant une telle fonction devient abstraite et ne peut pas être instanciée directement.
struct IPrintable {
virtual ~IPrintable() = default; // Bonnes pratiques : destructeur virtuel dans les interfaces
virtual void Print() const = 0; // pure virtuelle → contrat d’interface
};
struct IEntity {
virtual ~IEntity() = default;
virtual std::string Key() const = 0;
};
Ces deux classes jouent désormais le rôle « d’interfaces ».
Les classes qui les implémentent doivent définir Print() et Key().
2) Classe de base Book : classe abstraite
Exprimons le contrat commun à l’aide d’une classe abstraite BookBase.
Les classes dérivées définiront la logique de calcul du prix.
#include <iostream>
#include <memory>
#include <string>
using namespace std;
struct BookBase : IPrintable, IEntity {
protected:
string title, author;
public:
BookBase(string t, string a) : title(move(t)), author(move(a)) {}
virtual ~BookBase() = default;
// Pure virtuelle : les classes dérivées calculent le prix
virtual double FinalPrice() const = 0;
// Contrat IPrintable
void Print() const override {
cout << title << " - " << author
<< " | " << FinalPrice() << " €" << endl;
}
// Contrat IEntity
string Key() const override { return title + ":" + author; }
};
BookBase ne peut pas être instanciée directement avec new car FinalPrice() est purement virtuelle.
Cependant, nous pouvons fournir des implémentations par défaut pour Print() et Key().
3) Classes implémentant l’interface (EBook / PrintedBook)
Créons deux types de livres différents qui respectent le contrat FinalPrice().
struct EBook : BookBase {
double base, vat;
EBook(string t, string a, double b, double v)
: BookBase(move(t), move(a)), base(b), vat(v) {}
double FinalPrice() const override { return base * (1.0 + vat); }
};
enum class Cover { Soft, Hard };
struct PrintedBook : BookBase {
double base, shipping; int pages; Cover cover;
PrintedBook(string t, string a, double b, double s, int p, Cover c)
: BookBase(move(t), move(a)), base(b), shipping(s), pages(p), cover(c) {}
double FinalPrice() const override {
double cost = base + shipping + 0.04 * pages;
if (cover == Cover::Hard) cost *= 1.1;
return cost;
}
};
Les deux classes respectent le contrat BookBase et peuvent donc être utilisées polymorphiquement.
4) Implémentation multiple d’interfaces (Segregation d’interface)
Concevoir de petites interfaces ciblées plutôt qu’une seule grande interface réduit les coûts de maintenance.
Par exemple, une interface IDiscountable peut séparer la logique de remise.
struct IDiscountable {
virtual ~IDiscountable() = default;
virtual double ApplyDiscount(double rate) const = 0; // 0.10 → 10 %
};
struct DiscountedEBook : EBook, IDiscountable {
using EBook::EBook; // hérite des constructeurs
double ApplyDiscount(double rate) const override {
return FinalPrice() * (1.0 - rate);
}
};
Avec ce design, DiscountedEBook implémente à la fois le contrat BookBase et l’interface de remise.
5) Inversion de dépendance (DIP) et testabilité
Les classes doivent dépendre des abstractions (interfaces) et non des types concrets.
Cela favorise un couplage faible et facilite les tests unitaires.
Ci-dessous, un « moteur de tarification » utilise uniquement l’interface BookBase.
struct PricingEngine {
double Total(const vector<shared_ptr<BookBase>>& items) const {
double sum = 0.0;
for (const auto& b : items) sum += b->FinalPrice();
return sum;
}
};
int main() {
vector<shared_ptr<BookBase>> items;
items.push_back(make_shared<EBook>("Modern C++", "A. Dev", 100, 0.1));
items.push_back(make_shared<PrintedBook>("Clean Code", "R. Martin", 120, 20, 464, Cover::Hard));
PricingEngine eng;
cout << "Total : " << eng.Total(items) << " €" << endl;
for (const auto& b : items) b->Print(); // IPrintable
}
PricingEngine ne dépend pas de classes concrètes ; elle travaille uniquement avec BookBase.
Vous pouvez ainsi injecter un MockBook pour les tests unitaires.
6) Interfaces et héritage multiple
C++ prend en charge l’héritage multiple. Pour les interfaces, cela est généralement sûr car elles ne contiennent pas d’état. L’héritage multiple de classes avec état peut toutefois provoquer le problème du diamant. Dans ce cas, utilisez l’héritage virtuel ou de préférence la composition.
struct IStorable { virtual ~IStorable()=default; virtual void Save() const =0; };
struct ISearchable{ virtual ~ISearchable()=default; virtual bool Matches(string) const =0; };
struct SearchableBook : BookBase, IStorable, ISearchable {
using BookBase::BookBase;
double FinalPrice() const override { return 0.0; } // exemple simple
void Save() const override { /* écrire dans un fichier... */ }
bool Matches(string q) const override {
return title.find(q)!=string::npos || author.find(q)!=string::npos;
}
};
7) Exemple de fabrique (Factory) avec interface
Pour séparer la logique de création des classes concrètes, nous pouvons utiliser une interface de fabrique.
struct IBookFactory {
virtual ~IBookFactory() = default;
virtual shared_ptr<BookBase> MakeEBook(string t,string a,double b,double v)=0;
virtual shared_ptr<BookBase> MakePrinted(string t,string a,double b,double s,int p,Cover c)=0;
};
struct DefaultBookFactory : IBookFactory {
shared_ptr<BookBase> MakeEBook(string t,string a,double b,double v) override {
return make_shared<EBook>(move(t), move(a), b, v);
}
shared_ptr<BookBase> MakePrinted(string t,string a,double b,double s,int p,Cover c) override {
return make_shared<PrintedBook>(move(t), move(a), b, s, p, c);
}
};
Les modules de haut niveau dépendent uniquement de IBookFactory ;
la création concrète peut être facilement remplacée ou simulée dans les tests.
8) Conseils de conception
- Toujours définir un destructeur virtuel dans les interfaces.
- Garder les interfaces petites et ciblées (principe de ségrégation d’interface).
- Dépendre des abstractions, pas des classes concrètes (DIP).
- Utiliser des pointeurs ou références pour le polymorphisme ; la copie par valeur provoque un slicing.
- L’héritage multiple doit être réservé au partage de comportement (interfaces) ; pour le partage d’état, la composition est plus sûre.
9) Erreurs courantes
- Oublier le destructeur virtuel dans une classe abstraite.
- Concevoir une interface trop large (obligeant les implémenteurs à définir des méthodes vides).
- Dépendre directement des classes concrètes (diminue la testabilité et la flexibilité).
- Combiner des bases avec état dans un héritage multiple, provoquant le problème du diamant.
10) TL;DR
- En C++, une « interface » = une classe abstraite composée uniquement de fonctions virtuelles pures.
- Les classes abstraites ne peuvent pas être instanciées ; les classes dérivées deviennent concrètes en redéfinissant le contrat.
- Préférez des interfaces petites et cohérentes (ISP) et dépendez des abstractions (DIP).
- Utilisez des pointeurs/références pour le polymorphisme ; n’oubliez pas le destructeur virtuel.
- Utilisez l’héritage multiple pour les interfaces ; pour le partage d’état, préférez la composition.
- Tous les exemples fonctionnent sous Visual Studio 2022 et GCC 11+.