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.

L'image

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

Le contrat — aucun code à l'intérieur
public interface IEnvoyeurEmail
{
    Task EnvoyerAsync(string destinataire, string sujet, string corps);
    bool EstDisponible { get; }
}
Deux réalisations très différentes du même contrat
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;
    }
}
Le code qui les utilise ne connaît que le contrat
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.

Choisis une implémentation…

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.

Sans interface : intestable
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.
Avec interface : testable
// 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);
}
Le contre-exemple : l'interface inutile

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 :

InterfaceClasse abstraite
Peut contenir du code exécutableMarginalement (membres par défaut, C# 8+)Oui, c'est son intérêt principal
Peut avoir des champs / un étatNonOui
ConstructeurNonOui (appelé par les filles)
Combien par classeAutant qu'on veutUne seule
Instanciable directementNonNon
Membres private / protectedNon (tout est public)Oui
Implémentable par un structOuiNon
Ajouter un membre plus tardCasse les implémentations existantes*N'en casse aucune (si non abstrait)
Usage typiqueContrat 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.

J'ai besoin d'une abstraction Ai-je du CODE COMMUN à partager entre les implémentations ? non oui interface le choix par défaut Est-ce vraiment une famille (« est un ») ? oui → classe abstraite non → interface + une classe utilitaire injectée
En pratique : interface dans 9 cas sur 10. La classe abstraite gagne quand il y a un vrai squelette d'algorithme à partager.

Le cas où la classe abstraite est le bon choix

Squelette imposé, détails délégués (patron « méthode template »)
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.

On peut (et on devrait souvent) combiner les deux
public interface IGenerateurRapport { string Generer(Donnees d); }          // le contrat public
public abstract class GenerateurRapportBase : IGenerateurRapport { /* squelette partagé */ }
public sealed class RapportCsv : GenerateurRapportBase { }                   // une réalisation

Le 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

InterfaceCapacitéOù tu la rencontres
IEnumerable<T>« je peux être parcouru »Tout foreach, tout LINQ
IDisposable« je dois être libéré »Fichiers, connexions, using
IAsyncDisposableidem, en asynchroneawait 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.

Génériques : variance, et membres statiques abstraits

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 code

Vérifie que c'est passé

🎯 Quiz — 5 questions

1. Quelle est la définition la plus juste d'une interface ?

Elle décrit le quoi (des signatures) sans le comment. Aucun champ, donc aucun état.

2. Tu as trois classes de rapports qui partagent 40 lignes de mise en page identiques. Que choisis-tu ?

Du code commun réel = classe abstraite (ou une classe utilitaire composée). Les membres par défaut d'interface servent à faire évoluer un contrat publié, pas à héberger un algorithme.

3. Combien d'interfaces une classe peut-elle implémenter ?

C'est la réponse de C# à l'héritage multiple : plusieurs contrats, une seule lignée d'implémentation.

4. Tu écris ICalculateurTva avec une seule implémentation, jamais remplacée, et tes tests utilisent la vraie classe. Bonne idée ?

Remplacer, tester, découpler : s'il n'y a aucun de ces besoins, la classe concrète suffit. Extraire l'interface plus tard coûte un raccourci clavier.

5. IEnumerable<Chien> peut-il être affecté à une variable IEnumerable<Animal> ?

Et List<Chien>List<Animal> est refusé, car on pourrait alors ajouter un chat dans une liste de chiens.

Fiches de révision

Interface, en une image ?
Une prise électrique : une forme et un contrat, indifférente à l'appareil branché.
Interface vs classe abstraite ?
Interface = « je sais faire ». Abstraite = « je suis un genre de », avec du code commun.
Les 3 raisons d'écrire une interface ?
Remplacer une implémentation · tester sans le vrai monde · découpler deux équipes. Sinon : rien.
abstract vs virtual ?
abstract = pas de code, la fille DOIT fournir. virtual = version par défaut, la fille PEUT changer.
Peut-on instancier une interface ?
Non. Mais une variable peut être de type interface, et pointer sur n'importe quelle implémentation.
out / in dans un générique ?
out = producteur (covariant, IEnumerable). in = consommateur (contravariant, IComparer).
À retenir