C# · Le langage

Programmation orientée objet

Héritage, interfaces, polymorphisme, abstract et sealed, composition.

Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 15 min

La programmation orientée objet en C# repose sur une question qu'on pose à chaque appel : qui décide de la méthode exécutée, le compilateur d'après le type déclaré de la variable, ou le runtime d'après le type réel de l'objet ? Héritage, interfaces, abstract et sealed sont autant de façons d'y répondre, et de dire ce qu'un type permet à ceux qui le prolongent. Ce cours sert le jour où une redéfinition ne s'exécute pas, où une classe n'expose pas un membre que son interface lui fournit, ou quand il faut choisir entre hériter d'une classe et l'envelopper.

Héritage et appel virtuel

Une classe hérite d'une seule autre, et reçoit ses membres. Ce qui fait l'intérêt de l'héritage n'est pas cette réutilisation, c'est le polymorphisme : une variable de type Notification peut désigner une NotificationSms, et un appel de méthode virtuelle s'exécute d'après le type réel de l'objet. Le runtime lit, dans l'objet, un pointeur vers sa table de méthodes, et y trouve l'adresse de la version à appeler : le choix est fait à l'exécution, pas à la compilation.

En C#, une méthode n'est pas virtuelle par défaut, à l'inverse de Java. C'est un choix de conception : chaque méthode virtuelle est un point d'extension que l'auteur de la classe s'engage à maintenir, et dont il doit documenter ce qu'une redéfinition a le droit de supposer. virtual ouvre ce point, override l'occupe, et base. rappelle la version parente sans repasser par la table virtuelle.

using System;

class Notification
{
    // Virtuelle : l'appel est resolu a l'execution, sur le type reel de l'objet.
    public virtual string Formater(string message) => $"[info] {message}";
}

class NotificationSms : Notification
{
    // Un SMS tient en peu de caracteres : la redefinition reprend le format de
    // la base, puis le tronque.
    public override string Formater(string message)
    {
        // base. appelle la version de Notification sans passer par la table
        // virtuelle ; sans lui, la methode s'appellerait elle-meme.
        var texte = base.Formater(message);
        return texte.Length <= 15 ? texte : texte[..15];
    }
}

static class Envoi
{
    public static void Executer()
    {
        // Type declare : Notification. Type reel : NotificationSms. C'est le
        // second qui choisit la methode executee.
        Notification notification = new NotificationSms();

        Console.WriteLine(notification.Formater("Colis en route vers Lyon")); // [info] Colis en
    }
}

Un appel virtuel dans un constructeur

Le type réel d'un objet est fixé dès son allocation, avant qu'aucun constructeur ne tourne. Un appel virtuel fait depuis le constructeur de base atteint donc la redéfinition du dérivé avant que le corps du constructeur de celui-ci ne s'exécute. En C#, les initialiseurs de champ du dérivé s'exécutent avant l'appel au constructeur de base — c'est l'inverse de Java — mais les affectations du corps de son constructeur, elles, n'ont pas encore eu lieu : le champ vaut null malgré un type non nullable, que le compilateur tient pour garanti.

using System;

abstract class Composant
{
    protected Composant()
    {
        // L'objet est deja du type derive : c'est sa redefinition qui
        // s'execute, avant que le corps du constructeur derive ne s'execute.
        Initialiser();
    }

    protected abstract void Initialiser();
}

sealed class Connexion : Composant
{
    // Un initialiseur de champ s'execute AVANT l'appel au constructeur de base...
    private readonly string _protocole = "https";

    // ... une affectation dans le corps du constructeur, APRES.
    private readonly string _hote;

    public Connexion(string hote)
    {
        _hote = hote;
    }

    protected override void Initialiser() =>
        Console.WriteLine($"{_protocole}://{_hote ?? "(null)"}");
}

static class Demarrage
{
    public static void Executer()
    {
        _ = new Connexion("exemple.fr"); // https://(null)
    }
}

La règle d'analyse CA2214 signale ce motif ; elle n'est pas activée par défaut. Le remède est de ne jamais appeler de membre virtuel depuis un constructeur : l'initialisation passe par les paramètres du constructeur, ou par une méthode appelée une fois l'objet construit.

Masquer n'est pas redéfinir : new contre override

Un dérivé peut déclarer une méthode de même signature qu'une méthode héritée de deux façons, qui n'ont rien en commun. override remplace l'entrée de la méthode de base dans la table virtuelle : il n'existe plus qu'une méthode, et le type réel la choisit. new crée une seconde méthode, sans lien avec la première, qui masque l'autre pour qui regarde à travers le type dérivé seulement. Le choix se fait alors à la compilation, sur le type déclaré de la variable.

Le piège tient à ce que tout le code utile passe par le type de base : un service, une collection, un paramètre typé Tarif. La remise écrite avec new n'existe que pour qui connaît TarifSolde :

using System;

class Tarif
{
    public virtual int Prix(int brut) => brut;
}

class TarifSolde : Tarif
{
    // new declare une seconde methode Prix, sans lien avec la premiere : un
    // appel fait a travers une variable Tarif ne la voit pas.
    public new int Prix(int brut) => brut * 80 / 100;
}

static class Caisse
{
    // Le code qui manipule des tarifs passe, comme toujours, par le type de base.
    static int Encaisser(Tarif tarif, int brut) => tarif.Prix(brut);

    public static void Executer()
    {
        var solde = new TarifSolde();

        Console.WriteLine(solde.Prix(100));       // 80
        Console.WriteLine(Encaisser(solde, 100)); // 100 : la remise a disparu
    }
}

La même classe avec override, où la remise suit l'objet partout :

using System;

class Tarif
{
    public virtual int Prix(int brut) => brut;
}

class TarifSolde : Tarif
{
    // override remplace l'entree de Tarif.Prix dans la table virtuelle : il n'y
    // a plus qu'une methode Prix, et le type reel la choisit.
    public override int Prix(int brut) => brut * 80 / 100;
}

static class Caisse
{
    static int Encaisser(Tarif tarif, int brut) => tarif.Prix(brut);

    public static void Executer()
    {
        var solde = new TarifSolde();

        Console.WriteLine(solde.Prix(100));       // 80
        Console.WriteLine(Encaisser(solde, 100)); // 80
    }
}

Sans aucun des deux mots-clés, le compilateur avertit (CS0114 quand la méthode de base est virtuelle, CS0108 sinon) et applique new : le masquage est le comportement par défaut, l'avertissement demande seulement de l'assumer.

Déclaration dans le dérivéAppel via une variable TarifAppel via une variable TarifSolde
overrideversion du dérivéversion du dérivé
newversion de la baseversion du dérivé
aucun mot-cléversion de la base, avec avertissementversion du dérivé

new existe pour la gestion de versions, pas pour la conception. Une classe de base publiée par une bibliothèque peut ajouter, en version 2, une méthode qui porte le nom d'une méthode que votre dérivé avait déjà. Si le langage en faisait silencieusement une redéfinition, le code de la bibliothèque appellerait votre méthode sans que vous l'ayez voulu ; C# exige donc de dire lequel des deux sens est voulu, et new permet de garder le sien. L'autre usage historique, restreindre le type de retour, a disparu avec C# 9 : une redéfinition peut désormais rendre un type plus dérivé que la méthode qu'elle remplace, à condition de cibler .NET 5 ou plus, le runtime devant le prendre en charge.

Interfaces : contrat, membres par défaut, implémentation explicite

Une interface déclare ce qu'un type sait faire, sans rien dire de comment. Un type en implémente autant qu'il veut, une struct comprise, et un appel à travers l'interface se résout à l'exécution, comme un appel virtuel. L'implémentation ordinaire est implicite : une méthode publique de même signature remplit le contrat, sans mot-clé.

Depuis C# 8, un membre d'interface peut porter un corps. Le but est l'évolution : ajouter un membre à une interface publiée cassait toutes ses implémentations, un membre par défaut leur fournit de quoi continuer à compiler. Il demande une prise en charge du runtime, qui date de .NET Core 3.0 et n'existe pas dans .NET Framework. Surtout, il n'est pas hérité par la classe : il reste un membre de l'interface, joignable seulement à travers elle.

using System;
using System.Collections.Generic;

interface IJournal
{
    void Ecrire(string ligne);

    // Membre par defaut, ajoute en version 2 : les implementations ecrites
    // pour la version 1 compilent toujours, et le recoivent.
    void EcrireTout(IEnumerable<string> lignes)
    {
        foreach (var ligne in lignes)
        {
            Ecrire(ligne);
        }
    }
}

sealed class JournalConsole : IJournal
{
    public void Ecrire(string ligne) => Console.WriteLine($"> {ligne}");
}

sealed class JournalGroupe : IJournal
{
    public void Ecrire(string ligne) => Console.WriteLine(ligne);

    // Une methode publique de meme signature remplace le membre par defaut,
    // sans mot-cle override.
    public void EcrireTout(IEnumerable<string> lignes) =>
        Console.WriteLine(string.Join(" | ", lignes));
}

static class Journaux
{
    public static void Executer()
    {
        var console = new JournalConsole();

        // console.EcrireTout(["a"]); // CS1061 : la classe n'herite pas du
        //                            // membre par defaut, l'interface le garde.

        IJournal journal = console;
        journal.EcrireTout(["demarrage", "pret"]); // > demarrage, puis > pret

        journal = new JournalGroupe();
        journal.EcrireTout(["demarrage", "pret"]); // demarrage | pret
    }
}

L'implémentation explicite préfixe le membre du nom de l'interface. Elle ne prend pas de modificateur d'accès, et le membre n'est joignable qu'à travers l'interface. Deux usages : départager deux interfaces qui exigent la même signature avec des sens différents, et retirer de la surface publique du type un membre qui n'y a pas sa place — c'est ce que fait List<T> pour ICollection<T>.IsReadOnly.

using System;
using System.Collections.Generic;

interface IExportCsv
{
    string Exporter();
}

interface IExportXml
{
    string Exporter();
}

sealed class Rapport : IExportCsv, IExportXml
{
    public string Titre { get; init; } = "Ventes";

    // Implementation explicite : pas de modificateur d'acces, et chaque
    // interface recoit sa propre methode, joignable par elle seule.
    string IExportCsv.Exporter() => $"titre;{Titre}";

    string IExportXml.Exporter() => $"<titre>{Titre}</titre>";
}

static class Exports
{
    public static void Executer()
    {
        var rapport = new Rapport();

        // rapport.Exporter(); // CS1061 : Rapport n'expose aucun Exporter

        IExportCsv csv = rapport;
        IExportXml xml = rapport;
        Console.WriteLine(csv.Exporter()); // titre;Ventes
        Console.WriteLine(xml.Exporter()); // <titre>Ventes</titre>

        // La BCL s'en sert pour ecarter un membre sans objet sur le type
        // concret : une List<T> n'est jamais en lecture seule.
        var liste = new List<int>();

        // _ = liste.IsReadOnly; // CS1061
        Console.WriteLine(((ICollection<int>)liste).IsReadOnly); // False
    }
}

Classe abstraite ou interface : le critère

Une classe abstract ne s'instancie pas, et peut déclarer des membres abstract, sans corps, que chaque dérivé concret doit redéfinir. Les membres par défaut ont rapproché les deux notions, au point qu'un membre d'interface à corps marqué sealed ne se remplace plus ; le critère qui reste est l'état. Une interface ne peut porter ni champ d'instance ni constructeur : son code par défaut ne travaille qu'à travers les autres membres de l'interface. Une classe abstraite porte des champs, un constructeur qui établit un invariant, des membres protected que ses dérivés appellent, et un déroulé qu'elle leur impose. Une interface admet aussi des membres protected depuis C# 8, mais seules les interfaces qui en dérivent y accèdent, pas les classes qui l'implémentent.

Le patron de méthode en est l'exemple type : la base écrit l'algorithme dans une méthode publique non virtuelle, et ne laisse ouverts que les points prévus. Le dérivé ne peut ni sauter une étape ni oublier le comptage, parce qu'il ne touche jamais au déroulé.

using System;
using System.Collections.Generic;

abstract class Import
{
    // Un etat et un constructeur : deux choses qu'une interface ne peut pas porter.
    private int _lignesIgnorees;

    protected Import(string source)
    {
        Source = source;
    }

    public string Source { get; }

    public int LignesIgnorees => _lignesIgnorees;

    // Le deroule est fixe par la base : public et non virtuel, si bien qu'aucun
    // derive ne peut sauter le filtrage ni oublier le comptage.
    public int Importer(IEnumerable<string> lignes)
    {
        var importees = 0;

        foreach (var ligne in lignes)
        {
            if (string.IsNullOrWhiteSpace(ligne))
            {
                _lignesIgnorees++;
                continue;
            }

            Traiter(Decouper(ligne));
            importees++;
        }

        return importees;
    }

    // Point d'extension obligatoire.
    protected abstract void Traiter(string[] champs);

    // Point d'extension facultatif, avec un comportement par defaut.
    protected virtual string[] Decouper(string ligne) => ligne.Split(';');
}

sealed class ImportClients() : Import("clients.csv")
{
    protected override void Traiter(string[] champs) =>
        Console.WriteLine($"client {champs[0]} : {champs[1]}");
}

static class Imports
{
    public static void Executer()
    {
        var import = new ImportClients();
        var total = import.Importer(["C1;Durand", "", "C2;Martin"]);

        // client C1 : Durand
        // client C2 : Martin
        // 2 importee(s), 1 ignoree(s), depuis clients.csv
        Console.WriteLine($"{total} importee(s), {import.LignesIgnorees} ignoree(s), depuis {import.Source}");
    }
}
InterfaceClasse abstraite
Champs d'instance, constructeurnonoui
Nombre par typeautant qu'on veutune seule base
Implémentable par une structouinon
Imposer un dérouléoui, par un membre à corps sealed, sans étatoui, par une méthode non virtuelle
Ce qu'elle exprimeune capacitéune famille qui partage un état

La règle : une interface pour toute dépendance qu'on veut pouvoir substituer, en test ou en production ; une classe abstraite quand des types d'une même famille partagent un état et un déroulé qu'il faut garantir. Les deux se combinent souvent, la bibliothèque standard le montre avec IComparer<T> et la classe abstraite Comparer<T> qui l'implémente : le contrat reste l'interface, la classe n'est qu'une commodité pour qui l'implémente.

sealed : une garantie, et un appel direct

sealed sur une classe interdit d'en dériver ; sealed override sur une méthode arrête la chaîne des redéfinitions à ce niveau. Le compilateur refuse la violation par une erreur, CS0509 pour la classe et CS0239 pour la méthode. La garantie ne porte que sur ce qui est scellé : une classe non scellée garde ouverts tous ses autres membres virtuels. L'exemple scelle donc la méthode qui produit la ligne, et ne laisse ouvert qu'un point prévu, la signature.

using System;

class Journal
{
    public virtual string Formater(string message) => $"LOG {message}";
}

class JournalAudit : Journal
{
    // sealed override : la chaine de redefinitions de Formater s'arrete ici.
    // Tout derive de l'audit produit une ligne qui commence par AUDIT ; le seul
    // point laisse ouvert est la signature ajoutee en fin de ligne.
    public sealed override string Formater(string message) => $"AUDIT {message}{Signature()}";

    protected virtual string Signature() => "";
}

sealed class JournalAuditSigne : JournalAudit
{
    protected override string Signature() => " #signe";

    // public override string Formater(string message) => $"LOG {message}";
    // CS0239 : le membre herite est scelle
}

// class JournalAuditDouble : JournalAuditSigne { } // CS0509 : type scelle

static class Audit
{
    // Formater est scelle dans JournalAudit : quel que soit le type reel, l'appel
    // atteint cette methode-la, et le code optimise par le JIT l'appelle
    // directement, sans table virtuelle. JournalAudit n'a pas a etre scellee.
    public static string Tracer(JournalAudit journal, string message) =>
        journal.Formater(message);

    public static void Executer()
    {
        // A travers le type de base, la garantie tient : Formater est celui de
        // JournalAudit, quel que soit le derive.
        Journal journal = new JournalAuditSigne();
        Console.WriteLine(journal.Formater("connexion admin")); // AUDIT connexion admin #signe

        Console.WriteLine(Tracer(new JournalAuditSigne(), "connexion admin"));
        // AUDIT connexion admin #signe
    }
}

La garantie d'abord. Une classe scellée se lit en entier : aucune redéfinition ailleurs ne peut changer ce que fait une de ses méthodes, ni rompre un invariant ou une égalité écrite à la main (le cours Bases du langage montre ce que l'égalité des records doit à cette question). Dans une application, sceller coûte peu : le jour où un dérivé devient nécessaire, retirer le mot-clé ne casse en général aucun appelant — un record qui écrit son propre Equals doit alors le rendre virtual (CS8872), et l'appel perd la dévirtualisation décrite plus bas. Les outils qui dérivent à l'exécution, comme les proxys de chargement différé d'EF Core, exigent en revanche une classe non scellée. Une bibliothèque publique doit trancher dès la première version, car les règles de compatibilité de .NET rangent le scellement d'un type qui ne l'était pas parmi les changements cassants ; pour ce cas, les Framework Design Guidelines conseillent de ne pas sceller sans raison, les utilisateurs dérivant pour des besoins que l'auteur n'a pas prévus.

Le gain à l'exécution ensuite. Quand le type statique d'une variable est scellé, le JIT sait quelle méthode l'appel atteindra : il dévirtualise, et peut alors inliner. Une sonde sous .NET 10, en x64, avec DOTNET_JitDisasm et la compilation optimisée d'emblée (DOTNET_TieredCompilation=0, donc sans PGO), le montre sur une redéfinition qui rend une constante : la méthode qui reçoit la classe scellée se réduit à cette constante, celle qui reçoit une classe ouverte lit l'adresse dans la table de méthodes et fait un appel indirect. De même, is vers une classe scellée se réduit à comparer un pointeur, là où is vers une classe ouverte appelle une routine du runtime qui remonte la hiérarchie.

Avec la compilation par paliers par défaut, le premier code produit (tier 0) n'est pas optimisé et fait l'appel indirect dans les deux cas. Le PGO dynamique, actif par défaut depuis .NET 8, rattrape ensuite une partie de l'écart : quand la méthode est recompilée (tier 1), il teste le type le plus fréquent observé, prend pour lui un chemin direct, et garde l'appel virtuel en repli ; le test is vers une classe ouverte reçoit de même une comparaison du type exact avant la routine du runtime. Le scellement donne la certitude là où le PGO fait un pari.

Pour les types internes, la règle CA1852 signale ceux qui n'ont aucun dérivé dans l'assembly et pourraient être scellés sans rien casser. Elle n'est pas activée par défaut.

Composition plutôt qu'héritage

Hériter d'une classe, c'est dépendre non seulement de son contrat, mais de la façon dont elle s'en acquitte : quelle méthode en appelle quelle autre, dans quel ordre. Ce couplage porte un nom, la classe de base fragile : un changement interne de la base, invisible dans ses signatures, casse un dérivé qui n'a pas bougé. Voici un panier dont on veut compter les ajouts, et qui compte chaque article deux fois parce que AjouterTout rappelle Ajouter, lui aussi redéfini :

using System;
using System.Collections.Generic;
using System.Linq;

class Panier
{
    private readonly List<string> _articles = [];

    public int Nombre => _articles.Count;

    public virtual void Ajouter(string article) => _articles.Add(article);

    // Detail d'implementation : AjouterTout passe par Ajouter. Rien dans la
    // signature ne le dit, et la version suivante peut cesser de le faire.
    public virtual void AjouterTout(IEnumerable<string> articles)
    {
        foreach (var article in articles)
        {
            Ajouter(article);
        }
    }
}

// Veut compter les ajouts : herite, et redefinit les deux points d'entree.
sealed class PanierCompte : Panier
{
    public int Ajouts { get; private set; }

    public override void Ajouter(string article)
    {
        Ajouts++;
        base.Ajouter(article);
    }

    public override void AjouterTout(IEnumerable<string> articles)
    {
        var liste = articles.ToList();
        Ajouts += liste.Count;
        base.AjouterTout(liste);
    }
}

static class Boutique
{
    public static void Executer()
    {
        var panier = new PanierCompte();
        panier.AjouterTout(["stylo", "cahier"]);

        Console.WriteLine(panier.Nombre); // 2
        Console.WriteLine(panier.Ajouts); // 4 : base.AjouterTout rappelle Ajouter, redefini
    }
}

Retirer le comptage de AjouterTout donne le bon chiffre, mais seulement tant que la base passe par Ajouter. Que la version suivante optimise AjouterTout en un AddRange, et le compteur tombe à zéro pour chaque ajout groupé, sans que le dérivé ait changé d'une ligne. Réécrire AjouterTout dans le dérivé comme une boucle sur Ajouter, sans passer par base.AjouterTout, tiendrait, mais en recopiant ce que fait la base : chaque correction faite du côté du dérivé repose sur une hypothèse sur l'implémentation de la base, parce que le défaut est dans la relation.

La composition supprime le problème à la racine : le compteur enveloppe un panier au lieu d'en hériter, implémente le même contrat, et délègue. Il ne voit que l'interface ; le panier peut changer d'implémentation sans que rien ne bouge chez lui.

using System;
using System.Collections.Generic;
using System.Linq;

interface IPanier
{
    int Nombre { get; }

    void Ajouter(string article);

    void AjouterTout(IEnumerable<string> articles);
}

sealed class Panier : IPanier
{
    private readonly List<string> _articles = [];

    public int Nombre => _articles.Count;

    public void Ajouter(string article) => _articles.Add(article);

    // Libre de changer d'implementation : personne ne peut s'y brancher.
    public void AjouterTout(IEnumerable<string> articles) => _articles.AddRange(articles);
}

// Enveloppe un panier au lieu d'en heriter : il ne voit que le contrat,
// jamais la facon dont le panier s'en acquitte.
sealed class PanierCompte(IPanier interne) : IPanier
{
    public int Ajouts { get; private set; }

    public int Nombre => interne.Nombre;

    public void Ajouter(string article)
    {
        Ajouts++;
        interne.Ajouter(article);
    }

    public void AjouterTout(IEnumerable<string> articles)
    {
        var liste = articles.ToList();
        Ajouts += liste.Count;
        interne.AjouterTout(liste);
    }
}

static class Boutique
{
    public static void Executer()
    {
        var panier = new PanierCompte(new Panier());
        panier.AjouterTout(["stylo", "cahier"]);
        panier.Ajouter("gomme");

        Console.WriteLine(panier.Nombre); // 3
        Console.WriteLine(panier.Ajouts); // 3
    }
}

Le prix est la délégation, écrite à la main membre par membre : C# n'a pas de délégation automatique. Il se paie volontiers, parce que l'enveloppe se combine — journalisation, cache et comptage s'empilent autour du même panier — et se teste avec une fausse implémentation de l'interface.

L'héritage reste le bon outil dans deux cas : quand la base a été conçue pour lui, avec des points d'extension documentés comme dans le patron de méthode plus haut, et quand le dérivé est substituable à sa base partout où celle-ci est attendue — le principe de substitution de Liskov, que traite le cours SOLID. Hors de ces cas, dans une application, une classe qui n'a pas été conçue pour être dérivée devrait être sealed, et la question ne se pose plus ; une bibliothèque publique, on l'a vu, pèse ce choix autrement.

Ce cours vous a servi ? Offrir un café Signaler une erreur