Accueil › Les fondations › Chapitre 5
Interface, abstraction, contrat
Si un seul chapitre devait rester, c'est celui-là. « Interface » et « abstraction » sont les deux mots qu'on emploie le plus en réunion et qu'on explique le moins. Ils désignent pourtant une idée très simple — et une fois qu'elle est en place, l'injection de dépendances, les tests et la moitié d'ASP.NET Core deviennent évidents.
En une phrase
Une interface est une liste de capacités sans aucune réalisation : « quiconque veut être ceci doit savoir faire cela ». Elle décrit le quoi, jamais le comment.
Une interface est une prise électrique normalisée.
La prise du mur ne sait rien de ton appareil : ni lampe, ni ordinateur, ni aspirateur. Elle impose seulement une forme (deux trous, une terre) et un contrat (230 V, 50 Hz). Tout appareil respectant ce contrat fonctionne. Ton mur n'a pas besoin d'être modifié quand tu achètes un nouvel appareil, et l'électricien peut changer toute l'installation derrière sans que tes appareils s'en aperçoivent.
Le code, c'est pareil : ta classe « branche » un IEnvoyeurEmail. Qu'il y ait derrière
un vrai serveur SMTP, une API SendGrid, ou un faux objet de test, elle s'en fiche complètement.
À quoi ça ressemble
public interface IEnvoyeurEmail
{
Task EnvoyerAsync(string destinataire, string sujet, string corps);
bool EstDisponible { get; }
}
public class EnvoyeurSmtp : IEnvoyeurEmail // « : » se lit « implémente »
{
private readonly SmtpClient _client;
public EnvoyeurSmtp(SmtpClient client) => _client = client;
public bool EstDisponible => _client.IsConnected;
public async Task EnvoyerAsync(string destinataire, string sujet, string corps)
=> await _client.SendAsync(destinataire, sujet, corps);
}
public class EnvoyeurConsole : IEnvoyeurEmail // pour le développement local
{
public bool EstDisponible => true;
public Task EnvoyerAsync(string destinataire, string sujet, string corps)
{
Console.WriteLine($"[email simulé] à {destinataire} — {sujet}");
return Task.CompletedTask;
}
}
public class ServiceInscription(IEnvoyeurEmail envoyeur) // ← il demande le CONTRAT
{
public async Task InscrireAsync(string email)
{
// ... enregistrement en base ...
await envoyeur.EnvoyerAsync(email, "Bienvenue", "Ton compte est prêt.");
}
}
// En production :
var service = new ServiceInscription(new EnvoyeurSmtp(client));
// En développement :
var service = new ServiceInscription(new EnvoyeurConsole());
// En test :
var service = new ServiceInscription(new EnvoyeurEspion());
// ⚠️ ServiceInscription n'a PAS changé d'une ligne.
🧪 Branche différentes implémentations
Le ServiceInscription est identique dans les trois cas. Seul le
« branchement » change.
Les trois gains concrets (et rien d'autre)
On n'écrit pas une interface « parce que c'est propre ». On l'écrit pour obtenir l'un de ces trois gains. S'il n'y en a aucun, l'interface est du bruit.
1. Remplacer
Changer de fournisseur (SMTP → SendGrid, SQL Server → PostgreSQL, disque → S3) sans toucher au code métier.
2. Tester
Écrire un test qui n'envoie pas de vrai email, n'appelle pas une vraie banque, et se termine en 3 millisecondes.
3. Découpler
Deux équipes travaillent en parallèle dès que le contrat est signé, sans attendre l'implémentation de l'autre.
public class ServiceInscription
{
public async Task InscrireAsync(string email)
{
var smtp = new SmtpClient("smtp.exemple.fr"); // ← fabriqué ici, en dur
await smtp.SendAsync(email, "Bienvenue", "...");
}
}
// Un test enverrait un VRAI email
// à chaque exécution, et échouerait
// hors connexion. Pour changer de
// fournisseur : rouvrir cette classe.// Le test, en 6 lignes, sans réseau
class EnvoyeurEspion : IEnvoyeurEmail
{
public List<string> Envoyes = [];
public bool EstDisponible => true;
public Task EnvoyerAsync(string d, string s, string c)
{ Envoyes.Add(d); return Task.CompletedTask; }
}
[Fact]
public async Task Inscription_envoie_un_email()
{
var espion = new EnvoyeurEspion();
await new ServiceInscription(espion).InscrireAsync("a@b.fr");
Assert.Single(espion.Envoyes);
}Créer IClientRepository avec une seule implémentation qui ne sera jamais remplacée,
jamais moquée (parce que tu testes contre une vraie base), c'est ajouter un fichier, une indirection et un
saut de plus dans le débogueur pour rien. Le réflexe « une interface par classe » est un anti-modèle. Écris la
classe concrète, extrais l'interface le jour où l'un des trois gains apparaît — ton éditeur le fait en un
raccourci.
Interface ou classe abstraite ? La vraie réponse
Les deux servent à l'abstraction, mais ne répondent pas à la même question :
- Une interface dit : « je sais faire ceci » — une capacité.
- Une classe abstraite dit : « je suis un genre de ça, et voici du code que nous partageons tous » — une famille.
| Interface | Classe abstraite | |
|---|---|---|
| Peut contenir du code exécutable | Marginalement (membres par défaut, C# 8+) | Oui, c'est son intérêt principal |
| Peut avoir des champs / un état | Non | Oui |
| Constructeur | Non | Oui (appelé par les filles) |
| Combien par classe | Autant qu'on veut | Une seule |
| Instanciable directement | Non | Non |
Membres private / protected | Non (tout est public) | Oui |
Implémentable par un struct | Oui | Non |
| Ajouter un membre plus tard | Casse les implémentations existantes* | N'en casse aucune (si non abstrait) |
| Usage typique | Contrat de service, capacité (IDisposable, IComparable) | Squelette d'algorithme partagé, hiérarchie métier |
* sauf si tu fournis une implémentation par défaut — voir plus bas.
Le cas où la classe abstraite est le bon choix
public abstract class GenerateurRapport
{
// Le squelette est écrit UNE fois, ici. Les filles ne peuvent pas en changer l'ordre.
public string Generer(Donnees d)
{
var sb = new StringBuilder();
sb.AppendLine(Entete()); // commun
sb.AppendLine(Corps(d)); // ← à chacun le sien : obligatoire
sb.AppendLine(PiedDePage()); // commun, mais surchargeable
return sb.ToString();
}
private string Entete() => $"Rapport du {DateTime.Now:d}"; // du VRAI code partagé
protected abstract string Corps(Donnees d); // trou à remplir
protected virtual string PiedDePage() => "— fin —"; // option
}
public class RapportCsv : GenerateurRapport
{
protected override string Corps(Donnees d) => string.Join("\n", d.Lignes);
}
public class RapportHtml : GenerateurRapport
{
protected override string Corps(Donnees d) => $"<ul>{...}</ul>";
protected override string PiedDePage() => "<footer>fin</footer>";
}
abstract = « pas de code ici, les filles doivent le fournir ».
virtual = « voici une version par défaut, les filles peuvent la changer ». Une classe avec au
moins un membre abstract doit elle-même être abstract, et ne peut pas être instanciée
avec new.
public interface IGenerateurRapport { string Generer(Donnees d); } // le contrat public
public abstract class GenerateurRapportBase : IGenerateurRapport { /* squelette partagé */ }
public sealed class RapportCsv : GenerateurRapportBase { } // une réalisationLe reste de l'application dépend de l'interface ; la classe abstraite n'est qu'une commodité
d'implémentation. C'est exactement le schéma de la bibliothèque .NET :
Stream est abstraite, mais on manipule souvent IDisposable ou
IAsyncDisposable.
Plusieurs interfaces, petites de préférence
public class FichierTemporaire : IDisposable, IComparable<FichierTemporaire>, IEquatable<FichierTemporaire>
{
public string Chemin { get; init; } = "";
public long Taille { get; init; }
public void Dispose() => File.Delete(Chemin);
public int CompareTo(FichierTemporaire? autre) => Taille.CompareTo(autre?.Taille ?? 0);
public bool Equals(FichierTemporaire? autre) => Chemin == autre?.Chemin;
public override bool Equals(object? o) => Equals(o as FichierTemporaire);
public override int GetHashCode() => Chemin.GetHashCode();
}
Chaque interface apporte une capacité indépendante : « je sais me nettoyer », « je sais me comparer », « je
sais dire si je suis égal ». C'est le I de SOLID (ségrégation des interfaces) : une interface de 15
méthodes force ses implémentations à en remplir 15, dont douze au hasard avec
throw new NotImplementedException().
Les interfaces que tu croiseras tous les jours
| Interface | Capacité | Où tu la rencontres |
|---|---|---|
IEnumerable<T> | « je peux être parcouru » | Tout foreach, tout LINQ |
IDisposable | « je dois être libéré » | Fichiers, connexions, using |
IAsyncDisposable | idem, en asynchrone | await using |
IComparable<T> | « je sais me ranger » | Sort(), OrderBy |
IEquatable<T> | « je sais me comparer » | Dictionnaires, ensembles |
ILogger<T> | « on peut journaliser à travers moi » | Injecté partout dans ASP.NET Core |
IOptions<T> | « je porte de la configuration typée » | Chapitre 12 |
IQueryable<T> | « je suis une requête traduisible » | EF Core (ch. 15) |
IServiceProvider | « je sais fabriquer des services » | Injection de dépendances (ch. 11) |
Les questions que tout le monde se pose
Pourquoi un I devant le nom ?
Pure convention .NET, mais universelle : elle permet de savoir d'un coup d'œil, dans un constructeur ou un paramètre, qu'on dépend d'un contrat et non d'une classe concrète. Suis-la, tout l'écosystème la suit.
Peut-on écrire new IEnvoyeurEmail() ?
Non : il n'y a rien à instancier, aucun code, aucun état. En revanche une variable peut être de type
interface — c'est même tout l'intérêt : IEnvoyeurEmail e = new EnvoyeurSmtp(...);
Une interface peut-elle contenir des propriétés ? Des événements ?
Oui : propriétés, méthodes, événements, indexeurs, et depuis C# 11 des membres static abstract.
Ce qu'elle ne peut pas avoir, c'est un état (des champs d'instance).
bool EstDisponible { get; } déclare l'obligation d'exposer une valeur, pas de la stocker.
Deux interfaces déclarent la même méthode. Que se passe-t-il ?
Une seule implémentation satisfait les deux. Si tu as besoin de comportements différents, utilise l'implémentation explicite :
public class Convertisseur : IFrancais, IAnglais
{
void IFrancais.Saluer() => Console.WriteLine("Bonjour"); // pas de modificateur d'accès !
void IAnglais.Saluer() => Console.WriteLine("Hello");
// Accessible seulement via le type d'interface :
// ((IFrancais)obj).Saluer();
}L'implémentation explicite sert aussi à « cacher » un membre d'interface encombrant de l'API publique de la classe.
Comment ajouter une méthode à une interface déjà publiée sans casser le monde ?
Avec un membre par défaut (C# 8+) : tu fournis une implémentation dans l'interface, les classes existantes continuent de compiler.
public interface IEnvoyeurEmail
{
Task EnvoyerAsync(string a, string sujet, string corps);
// Nouveauté : les implémentations existantes héritent de cette version
Task EnvoyerAsync(string a, string sujet, string corps, CancellationToken ct)
=> EnvoyerAsync(a, sujet, corps);
}À utiliser avec parcimonie : une interface qui accumule du code redevient une classe abstraite, en moins clair.
Covariance / contravariance. IEnumerable<out T> est covariant : un
IEnumerable<Chien> s'utilise là où on attend un IEnumerable<Animal>
(on ne fait que sortir des éléments). IComparer<in T> est contravariant :
un comparateur d'Animal sait comparer des Chien (il ne fait que les recevoir).
Mémo : out = producteur, in = consommateur.
IEnumerable<Chien> chiens = [new Chien()];
IEnumerable<Animal> animaux = chiens; // ✅ covariance
// List<Animal> l = new List<Chien>(); // ❌ List<T> est invariant (on peut y AJOUTER)Membres statiques abstraits (C# 11) : une interface peut exiger un membre statique, ce qui rend possible le « code générique mathématique » de .NET.
public interface IUnite<T> where T : IUnite<T>
{
static abstract T Zero { get; }
static abstract T operator +(T a, T b);
}
// Grâce à ce mécanisme, la bibliothèque fournit INumber<T> :
static T Somme<T>(IEnumerable<T> valeurs) where T : INumber<T>
{
T total = T.Zero;
foreach (var v in valeurs) total += v; // + sur un T générique !
return total;
}
Somme([1, 2, 3]); // int
Somme([1.5, 2.5]); // double — un seul codeVérifie que c'est passé
🎯 Quiz — 5 questions
1. Quelle est la définition la plus juste d'une interface ?
2. Tu as trois classes de rapports qui partagent 40 lignes de mise en page identiques. Que choisis-tu ?
3. Combien d'interfaces une classe peut-elle implémenter ?
4. Tu écris ICalculateurTva avec une seule implémentation, jamais remplacée, et tes tests
utilisent la vraie classe. Bonne idée ?
5. IEnumerable<Chien> peut-il être affecté à une variable
IEnumerable<Animal> ?
List<Chien> → List<Animal> est refusé, car
on pourrait alors ajouter un chat dans une liste de chiens.Fiches de révision
abstract vs virtual ?abstract = pas de code, la fille DOIT fournir. virtual = version par défaut, la fille PEUT changer.out / in dans un générique ?out = producteur (covariant, IEnumerable). in = consommateur (contravariant, IComparer).- Une interface est un contrat : des capacités, zéro implémentation, zéro état.
- Elle sert à remplacer, tester, découpler. Sans l'un des trois, elle est inutile.
- Classe abstraite quand il y a du code commun et une vraie famille « est un ».
- Dépends d'abstractions dans tes constructeurs : c'est la base de l' injection de dépendances.
- Interfaces petites : une capacité chacune.