Chapitre 919 minIntermédiaire
Classe abstraite et interface : différence et usages
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- écrire une classe abstraite et des membres abstraits ;
- définir et implémenter une interface ;
- programmer pour une interface plutôt que pour une classe ;
- rendre une classe triable avec
IComparable<T>; - choisir entre classe abstraite et interface.
4 exercices corrigés1 schéma
Une classe abstraite est une classe qu'on ne peut pas instancier, qui sert de base commune à des classes filles et peut leur imposer des méthodes à écrire (méthodes abstraites). Une interface est un contrat : une liste de méthodes et de propriétés qu'une classe s'engage à fournir, sans dire comment. Les deux mettent en œuvre l'abstraction, le quatrième pilier de la POO. Ce chapitre montre comment écrire l'une et l'autre en C#, à quoi servent les interfaces de .NET comme IComparable<T>, et comment choisir entre classe abstraite et interface.
L'abstraction : manipuler des concepts
Dans le parc de Sorbier Logistique, il n'existe aucun « matériel » tout court : il y a des postes, des portables, des imprimantes. Materiel est un concept, utile pour écrire du code commun (la valeur nette, l'étiquette), mais créer un objet Materiel n'a pas de sens. De même, beaucoup de traitements n'ont besoin que d'une capacité (« peut s'exporter en CSV », « peut se comparer ») sans s'intéresser à ce qu'est l'objet.
L'abstraction consiste à ne retenir que l'essentiel pour un usage donné, et à cacher le reste. Le code qui travaille avec une abstraction ne dépend pas des détails : il fonctionne avec toutes les classes, présentes et futures, qui la respectent.
La classe abstraite
Une classe abstract ne peut pas être instanciée : new Materiel(…) provoque l'erreur CS0144, « Impossible de créer une instance du type abstract ou de l'interface 'Materiel' ». Elle peut contenir du code ordinaire (champs, constructeurs, méthodes complètes) et des membres abstract, sans corps, que chaque classe fille concrète doit redéfinir.
abstract class Materiel : IExportableCsv, IComparable<Materiel>
{
protected Materiel(string numInventaire, string marque, string modele, decimal prixAchat)
{
NumInventaire = numInventaire;
Marque = marque;
Modele = modele;
PrixAchat = prixAchat;
}
public string NumInventaire { get; }
public string Marque { get; }
public string Modele { get; }
public decimal PrixAchat { get; }
public abstract int DureeAmortissementAns { get; } // chaque fille doit la donner
public abstract string Description();
public string LigneCsv() =>
string.Join(';', NumInventaire, GetType().Name, Marque, Modele,
PrixAchat.ToString(System.Globalization.CultureInfo.InvariantCulture));
public int CompareTo(Materiel? autre) => autre is null ? 1 : PrixAchat.CompareTo(autre.PrixAchat);
}
class Poste : Materiel
{
public Poste(string numInventaire, string marque, string modele, decimal prixAchat)
: base(numInventaire, marque, modele, prixAchat)
{
}
public override int DureeAmortissementAns => 4;
public override string Description() => "poste fixe";
}
class Portable : Poste
{
public Portable(string numInventaire, string marque, string modele, decimal prixAchat)
: base(numInventaire, marque, modele, prixAchat)
{
}
public override int DureeAmortissementAns => 3;
public override string Description() => "ordinateur portable";
}
class Imprimante : Materiel
{
public Imprimante(string numInventaire, string marque, string modele, decimal prixAchat, bool couleur)
: base(numInventaire, marque, modele, prixAchat)
{
Couleur = couleur;
}
public bool Couleur { get; }
public override int DureeAmortissementAns => 5;
public override string Description() => Couleur ? "imprimante couleur" : "imprimante noir et blanc";
}
Par rapport au chapitre précédent, DureeAmortissementAns n'a plus de valeur par défaut de 5 ans : elle est abstract, et le compilateur oblige chaque classe concrète à la fournir. Une classe fille qui oublie provoque l'erreur CS0534 : « 'Imprimante' n'implémente pas le membre abstrait hérité 'Materiel.Description()' ». Oublier une règle devient impossible.
| Membre | Mot-clé dans la mère | Dans la fille |
|---|---|---|
| Méthode complète, non modifiable | (aucun) | héritée telle quelle |
| Méthode avec un comportement par défaut, modifiable | virtual | override facultatif |
| Méthode sans comportement, à fournir | abstract (seulement dans une classe abstraite) | override obligatoire dans une classe concrète |
Le constructeur d'une classe abstraite est souvent protected : seules les classes filles l'appellent, via base(...). Une classe fille peut elle-même rester abstraite si elle ne redéfinit pas tous les membres abstraits.
L'interface : un contrat
Une interface déclare ce qu'une classe sait faire, sans rien dire de ce qu'elle est. Par convention, son nom commence par un I majuscule.
interface IExportableCsv
{
string LigneCsv();
}
Une classe qui implémente l'interface s'engage à fournir tous ses membres, en public. L'implémentation s'écrit avec deux-points, comme l'héritage :
class Ticket : IExportableCsv
{
public Ticket(int numero, string titre, string priorite)
{
Numero = numero;
Titre = titre;
Priorite = priorite;
}
public int Numero { get; }
public string Titre { get; }
public string Priorite { get; }
public string LigneCsv() => $"{Numero};{Titre};{Priorite}";
}
Un membre manquant provoque l'erreur CS0535 : « 'Ticket' n'implémente pas le membre d'interface 'IExportableCsv.LigneCsv()' ». Dans une interface, les membres sont publics par défaut et n'ont pas besoin du mot-clé public ; dans la classe, l'implémentation doit l'avoir.
Une interface ne contient ni champ ni constructeur, et on ne l'instancie pas. Mais on peut déclarer une variable, un paramètre ou une liste de son type : ils accepteront tout objet d'une classe qui l'implémente.
Programmer pour une interface
Materiel et Ticket n'ont aucun lien d'héritage : l'un est un équipement, l'autre une demande d'assistance. Grâce à l'interface, un même code les exporte tous les deux.
static class Export
{
public static string VersCsv(IEnumerable<IExportableCsv> elements) =>
string.Join('\n', elements.Select(e => e.LigneCsv()));
}
List<Materiel> parc =
[
new Poste("PC-0001", "Dell", "OptiPlex 7020", 899m),
new Imprimante("IM-0001", "Brother", "HL-L8360", 529m, couleur: true),
new Portable("PC-0002", "Lenovo", "ThinkPad T14", 1290m),
];
List<IExportableCsv> aExporter = [.. parc, new Ticket(4, "Serveur de fichiers inaccessible", "critique")];
foreach (IExportableCsv e in aExporter)
{
Console.WriteLine(e.LigneCsv());
}
PC-0001;Poste;Dell;OptiPlex 7020;899
IM-0001;Imprimante;Brother;HL-L8360;529
PC-0002;Portable;Lenovo;ThinkPad T14;1290
4;Serveur de fichiers inaccessible;critique
[.. parc, autre] est une expression de collection avec l'opérateur d'étalement .. : il recopie les éléments de parc, puis ajoute le ticket. La classe Export ne connaît que IExportableCsv : demain, un Logiciel exportable s'ajoutera sans modifier une ligne d'Export. Programmer pour une interface, pas pour une implémentation est l'un des deux principes posés dès le premier chapitre du livre fondateur des patrons de conception (1994), et le cœur des chapitres 12 à 14.
Implémenter plusieurs interfaces
Une classe n'a qu'une classe mère, mais elle peut implémenter autant d'interfaces qu'elle veut. On écrit la classe mère en premier, puis les interfaces : abstract class Materiel : IExportableCsv, IComparable<Materiel>. Materiel sait ainsi à la fois s'exporter et se comparer, deux capacités indépendantes.
Les interfaces de .NET
Le framework définit des interfaces que tes classes peuvent implémenter pour s'intégrer aux outils existants.
| Interface | Ce qu'elle promet | Ce qu'elle débloque |
|---|---|---|
IComparable<T> | int CompareTo(T? autre) : négatif, zéro ou positif | List<T>.Sort(), Order(), Min(), Max() |
IEquatable<T> | bool Equals(T? autre) | une égalité rapide et typée (chapitre 11) |
IEnumerable<T> | pouvoir être parcouru | foreach et toutes les méthodes LINQ |
IDisposable | void Dispose() : libérer une ressource | l'instruction using (chapitre 10) |
Avec IComparable<Materiel>, parc.Sort() trie le matériel par prix d'achat croissant :
parc.Sort();
foreach (Materiel m in parc)
{
Console.WriteLine($"{m.NumInventaire} {m.PrixAchat,8:N2} {m.DureeAmortissementAns} ans {m.Description()}");
}
IM-0001 529,00 5 ans imprimante couleur
PC-0001 899,00 4 ans poste fixe
PC-0002 1 290,00 3 ans ordinateur portable
Sans l'interface, Sort() lève une InvalidOperationException : .NET ne sait pas comparer deux matériels. CompareTo renvoie un nombre négatif si l'objet courant vient avant l'autre, zéro s'ils sont à égalité, positif s'il vient après. Le plus simple est de déléguer à la comparaison d'une propriété, comme ici PrixAchat.CompareTo(autre.PrixAchat).
Les membres par défaut d'une interface
Depuis C# 8, une interface peut fournir une implémentation par défaut à un membre. Les classes existantes n'ont alors pas à l'écrire, ce qui permet de faire évoluer une interface sans casser tout le code qui l'implémente.
interface IExportableCsv
{
string LigneCsv();
// Implémentation par défaut (C# 8 et plus)
string Entete() => "donnees";
}
Particularité : ce membre par défaut n'est visible qu'à travers une variable du type de l'interface. premier.Entete() fonctionne si premier est déclaré IExportableCsv, pas s'il est déclaré Materiel. Garde cette possibilité pour les bibliothèques qui évoluent ; dans ton code, une interface reste d'abord un contrat sans code.
Classe abstraite ou interface : comment choisir
| Critère | Classe abstraite | Interface |
|---|---|---|
| Relation exprimée | « est un » : une famille d'objets | « sait faire » : une capacité |
| Combien par classe | une seule (héritage simple) | autant que nécessaire |
| Peut contenir des champs, un état | oui | non |
| Peut contenir des constructeurs | oui | non |
| Peut contenir du code | oui, librement | seulement des membres par défaut, avec restrictions |
| Visibilité des membres | toutes (protected compris) | publics par défaut |
| Classes concernées | des classes de même nature | des classes sans lien entre elles |
| Exemple GestParc | Materiel : numéro, prix, amortissement communs | IExportableCsv, INotificateur, ITicketRepository |
En UML
| Élément | Notation |
|---|---|
| Classe abstraite | nom en italique, ou suivi de {abstract} |
| Opération abstraite | en italique, ou suivie de {abstract} |
| Interface | rectangle avec le stéréotype «interface» au-dessus du nom (ou un petit cercle, notation « sucette ») |
| Héritage d'une classe | flèche pleine à triangle vide, de la fille vers la mère |
| Réalisation d'une interface | flèche en pointillés à triangle vide, de la classe vers l'interface |
Sur papier, le correcteur ne voit pas toujours l'italique : écris {abstract} pour lever le doute.
Exercices
Exercice 1 sur 4 Ça compile ? Réussi À revoir
Avec les classes du chapitre (Materiel abstraite, Poste, Portable, Imprimante, l'interface IExportableCsv), dis si chaque ligne compile. Si non, donne la raison.
Materiel m = new Poste("PC-0001", "Dell", "OptiPlex 7020", 899m);Materiel m = new Materiel("PC-0001", "Dell", "OptiPlex 7020", 899m);IExportableCsv e = new Imprimante("IM-0001", "Brother", "HL-L8360", 529m, true);IExportableCsv e = new IExportableCsv();List<IExportableCsv> l = [new Ticket(1, "Écran noir", "haute")];class Scanner : Materiel { public override string Description() => "scanner"; }
Voir la réponseMasquer la réponse de l'exercice 1
- Oui : un
Posteest unMateriel. - Non :
Materielest abstraite, erreur CS0144. De plus son constructeur estprotected. - Oui :
Imprimantehérite deMateriel, qui implémente l'interface ; les filles l'implémentent donc aussi. - Non : on n'instancie pas une interface (CS0144).
- Oui :
TicketimplémenteIExportableCsv. - Non, pour deux raisons :
Scannerne fournit pasDureeAmortissementAns, membre abstrait (CS0534), et n'a pas de constructeur qui appellebase(...)(CS7036,Materieln'a pas de constructeur sans paramètre).
Tu avais trouvé ?
Exercice 2 sur 4 Écrire une interface Réussi À revoir
Le service informatique veut envoyer des notifications à un employé ou à tout un service. Écris l'interface INotifiable, avec une méthode IEnumerable<string> Adresses(), et deux classes qui l'implémentent : Employe (une adresse) et Service (la liste des adresses de ses membres). Combien d'adresses distinctes reçoivent un message envoyé à Julie Lefèvre et au service Comptabilité (Karim Morel et Julie Lefèvre) ?
Voir la réponseMasquer la réponse de l'exercice 2
interface INotifiable
{
IEnumerable<string> Adresses();
}
class Employe(string email) : INotifiable
{
public string Email { get; } = email;
public IEnumerable<string> Adresses() => [Email];
}
class Service(string code, List<string> emails) : INotifiable
{
public string Code { get; } = code;
public IEnumerable<string> Adresses() => emails;
}
List<INotifiable> destinataires =
[
new Employe("julie.lefevre@sorbier.example"),
new Service("COMPTA", ["karim.morel@sorbier.example", "julie.lefevre@sorbier.example"]),
];
int nb = destinataires.SelectMany(d => d.Adresses()).Distinct().Count(); // 2
2 adresses distinctes : Julie apparaît deux fois, Distinct() la dédoublonne. SelectMany aplatit les listes d'adresses en une seule séquence. L'expéditeur ne sait pas s'il écrit à une personne ou à un service : il ne connaît que le contrat INotifiable. Un Employe et un Service ne partagent aucune nature commune, d'où le choix d'une interface plutôt que d'une classe abstraite.
Tu avais trouvé ?
Exercice 3 sur 4 Classe abstraite ou interface ? Réussi À revoir
Pour chaque besoin, choisis une classe abstraite ou une interface, et justifie en une phrase.
- Les frais d'un déplacement : frais kilométriques, nuitée, repas. Tous ont une date, un montant et un justificatif ; chacun calcule son montant remboursable à sa façon.
- Pouvoir journaliser un message, dans un fichier, dans la console ou dans une base.
- Pouvoir être affiché dans une liste déroulante avec un libellé : des employés, des services, des logiciels.
- Les comptes d'une application : utilisateur, administrateur, partenaire externe, avec un identifiant, un mot de passe haché et une méthode de vérification commune.
Voir la réponseMasquer la réponse de l'exercice 3
- Classe abstraite
Frais: les frais sont de même nature (« est un frais »), partagent un état (date, montant, justificatif) et une méthode abstraiteMontantRemboursable(). - Interface
IJournal: une capacité, avec des implémentations sans code commun ; c'est le cas typique de l'injection de dépendances (chapitre 14). - Interface
IAffichable(propriétéLibelle) : des classes sans lien de nature partagent une capacité. - Classe abstraite
Compte: un état commun (identifiant, empreinte du mot de passe) et une vérification commune, écrite une fois.
Tu avais trouvé ?
Exercice 4 sur 4 Trier des tickets Réussi À revoir
Fais implémenter IComparable<Ticket> à la classe Ticket (propriétés Numero et Priorite, une énumération Basse, Normale, Haute, Critique) pour que Sort() range les tickets par priorité décroissante, puis par numéro croissant à priorité égale. Donne l'ordre obtenu pour les tickets 3 (normale), 1 (haute), 7 (haute), 2 (critique) et 5 (basse).
Voir la réponseMasquer la réponse de l'exercice 4
class Ticket(int numero, Priorite priorite) : IComparable<Ticket>
{
public int Numero { get; } = numero;
public Priorite Priorite { get; } = priorite;
public int CompareTo(Ticket? autre)
{
if (autre is null) return 1;
int parPriorite = autre.Priorite.CompareTo(Priorite); // inversé : décroissant
return parPriorite != 0 ? parPriorite : Numero.CompareTo(autre.Numero);
}
}
Ordre obtenu : #2 (critique), #1 (haute), #7 (haute), #3 (normale), #5 (basse). Pour un ordre décroissant, on compare autre à l'objet courant au lieu de l'inverse. Avec LINQ, sans toucher à la classe : tickets.OrderByDescending(t => t.Priorite).ThenBy(t => t.Numero).
Tu avais trouvé ?