CodeGym /Cours /C# SELF /Problèmes avec le polymorphisme et l'abstraction

Problèmes avec le polymorphisme et l'abstraction

C# SELF
Niveau 25, Leçon 3
Disponible

1. Le polymorphisme, c'est pas toujours magique

Si tu regardes les exemples de base, le polymorphisme en C# a l'air d'être le bon sens incarné : tu hérites, tu overrides, tu appelles tout via le type de base — et hop, ça roule. Mais en vrai, y'a des subtilités. On va voir ça de plus près.

Masquage de méthodes et le mot-clé new

Imagine qu'on a une classe de base et une classe dérivée, chacune avec une méthode du même nom, mais sans le mot-clé override. Si dans la classe dérivée tu redéfinis la méthode sans override, elle masque l'implémentation de la base, elle ne l'override pas. Le compilateur, genre parent bienveillant, va direct te balancer un warning et te suggérer de mettre new explicitement :

class Animal
{
    public void Speak()
    {
        Console.WriteLine("L'animal fait un bruit.");
    }
}

class Cat : Animal
{
    public new void Speak()
    {
        Console.WriteLine("Miaou !");
    }
}

// Utilisation
Animal animal = new Cat();
animal.Speak(); // Affichera : "L'animal fait un bruit."

Wow ! Même si tu crées un objet de type Cat et que tu le mets dans une variable de type Animal, c'est la méthode originale de la base qui sera appelée. Pourquoi ? Parce que la méthode n'a pas été déclarée virtuelle ! Piège numéro un : si tu veux du polymorphisme, oublie pas les mots-clés virtual et override. Utilise new seulement si tu veux vraiment masquer, pas override (et franchement, ça sert rarement et que pour de bonnes raisons).

Appels de constructeurs et polymorphisme

Encore un truc pas évident : les constructeurs ne sont pas virtuels. Si tu déclares un constructeur dans la base et un autre dans la dérivée, ils ne sont pas polymorphes. Exemple :

class Animal
{
    public Animal()
    {
        Console.WriteLine("Constructeur Animal");
    }
}

class Cat : Animal
{
    public Cat()
    {
        Console.WriteLine("Constructeur Cat");
    }
}

// Utilisation
Animal animal = new Cat();
// Affichera :
// Constructeur Animal
// Constructeur Cat

Mais si tu appelles des méthodes depuis le constructeur de la base, qui peuvent être overridées dans la dérivée, le résultat peut être chelou — la méthode virtuelle sera appelée avant l'init du descendant ! Donc, évite d'appeler des méthodes virtuelles/abstraites dans les constructeurs.

Problème d'encapsulation pétée avec override

Les méthodes virtuelles, c'est cool, mais si dans la base tu comptes sur un comportement précis d'une méthode, et qu'ensuite elle est overridée dans la dérivée et casse la logique — tu peux avoir des bugs bien relous.

class Animal
{
    public virtual void Eat()
    {
        Console.WriteLine("L'animal mange.");
    }

    public void Live()
    {
        Eat(); // Peut appeler n'importe quelle version overridée !
    }
}

class Cat : Animal
{
    public override void Eat()
    {
        Console.WriteLine("Le chat mange du poisson.");
    }
}

Animal a = new Cat();
a.Live(); // Affichera "Le chat mange du poisson."

Si dans la base Animal la méthode Eat() affichait "l'animal mange", et qu'ensuite dans la dérivée tu ajoutes un truc dangereux dans Eat(), ça peut casser tout le fonctionnement de la classe. Ce problème s'appelle violation du principe de substitution de Liskov (Liskov Substitution Principle, LSP). Quand tu designes, demande-toi toujours si le comportement des enfants reste logique par rapport à la base.

Casting et galères de conversion de types

Le polymorphisme te permet de garder plein d'objets différents dans le même "tas" : genre une liste du type de base, où tu peux avoir des chiens, des chats (tous héritent de Animal). Mais si tu veux appeler un truc spécifique :

List<Animal> pets = new List<Animal> { new Cat(), new Dog() };

foreach (var pet in pets)
{
    if (pet is Cat cat)
    {
        cat.Purr();
    }
}

Si tu zappes la vérif de type et que tu fais un cast à l'arrache, tu vas te prendre une sale erreur InvalidCastException. Parfois ça finit en avalanche de vérifs de type, ton code devient lourd, et ça veut souvent dire que ton design est à revoir.

2. Problèmes avec l'abstraction

L'abstraction, c'est top pour simplifier la vie de l'utilisateur d'un objet et limiter l'accès à l'état interne. Mais là aussi, y'a des pièges !

Abus des niveaux d'abstraction (Over-Abstraction)

Certains débutants (et pas que) sont tellement à fond dans le "vrai OOP" qu'ils pondent des mille-feuilles de classes de base, d'interfaces et de couches abstraites. Résultat, même l'auteur pige plus rien à son code.

interface IAnimal
{
    void Speak();
}

abstract class Feline : IAnimal
{
    public abstract void Speak();
}

class Cat : Feline
{
    public override void Speak()
    {
        Console.WriteLine("Miaou !");
    }
}

Franchement, pourquoi une classe abstraite intermédiaire si elle n'ajoute rien ? L'abstraction pour l'abstraction, ça rend juste la maintenance galère et l'archi incompréhensible.

Hiérarchie mal pensée

Imagine si tu mets une action tout en haut de la hiérarchie, alors qu'en vrai elle ne va pas à tout le monde :

abstract class Animal
{
    public abstract void Fly();
}
class Cat : Animal
{
    public override void Fly()
    {
        throw new NotImplementedException("Les chats ne volent pas !");
    }
}

Du coup, tu dois remplir les classes enfants avec des exceptions bidons, ou accepter que ton interface ne colle pas à la réalité. C'est le classique anti-pattern de la "mauvaise hiérarchie". Dans ces cas-là, mieux vaut sortir ces méthodes dans des interfaces séparées (genre IFlyable).

Conseil : essaie pas de faire une abstraction "qui marche pour tout".

Problèmes avec les classes abstraites et les changements d'API

Dès qu'une classe abstraite part en prod et que des gens héritent dessus, tout changement devient risqué. Ajouter une nouvelle méthode abstraite oblige tous les enfants à l'implémenter — sinon, ça compile plus. Ça complique grave la maintenance des libs et des API publiques.

C'est pour ça qu'on a inventé les interfaces avec méthodes par défaut (Default Interface Methods — voir conf 116) : ça permet d'étendre les interfaces sans tout casser dans le code existant.

Violation de l'encapsulation avec l'abstraction

Quand tu rends une classe abstraite, tu dois souvent déclarer ses membres en protected pour que les enfants y accèdent. Mais du coup, tu leaks de la logique interne que t'aurais mieux fait de cacher. Résultat, les descendants peuvent toucher à des données et opérations qui peuvent péter l'intégrité de la base.

3. Scénarios d'erreurs en pratique

Histoire que tu crois pas que ça n'arrive que dans les exos à la maison, voilà des vrais exemples de la vraie vie — même les devs expérimentés se font avoir.

Exemple avec des méthodes pas déclarées virtual

Imaginons qu'on fait évoluer notre appli d'apprentissage pour le logging (voir Jour 24). On a un logger de base :

class BaseLogger
{
    public void Log(string message)
    {
        Console.WriteLine(message);
    }
}

class FileLogger : BaseLogger
{
    public void Log(string message)
    {
        // On écrit dans un fichier
        Console.WriteLine("Dans le fichier : " + message);
    }
}

// Utilisation :
BaseLogger logger = new FileLogger();
logger.Log("Hello!"); // Attendu : "Dans le fichier : Hello!", réalité : "Hello!"

Le dev de FileLogger pensait avoir overridé la méthode, mais il a zappé override et n'a pas mis la méthode de base en virtuelle. Résultat, c'est la version de base qui est appelée.

Recommandation : marque toujours les méthodes overridables avec virtual dans la base et override dans les dérivées.

Exemple d'abstraction foireuse : Les animaux "flexibles"

On continue avec les animaux ! On sort une interface IFlyable pour pas forcer tous les animaux à implémenter Fly :

interface IFlyable
{
    void Fly();
}

class Bird : IFlyable
{
    public void Fly() => Console.WriteLine("L'oiseau vole !");
}

class Cat
{
    // Le chat n'implémente pas IFlyable
}

Maintenant, tu peux écrire une fonction qui bosse avec les "volants", sans toucher aux chats :

void MakeItFly(object creature)
{
    if (creature is IFlyable flyingThing)
    {
        flyingThing.Fly();
    }
    else
    {
        Console.WriteLine("Cette bestiole ne sait pas voler.");
    }
}

Ce genre d'approche évite de pourrir l'archi avec des méthodes abstraites bidons.

Problèmes avec les classes abstraites "rigides" et l'extensibilité

Imagine que tu publies une lib avec cette classe abstraite :

public abstract class Creature
{
    public abstract void DoAction();
}

Les utilisateurs de ta lib créent leurs propres classes en héritant. Un an plus tard, tu veux étendre l'API et tu ajoutes :

public abstract class Creature
{
    public abstract void DoAction();
    public abstract void Sleep(); // Nouvelle méthode !
}

Maintenant, toutes les classes utilisateurs ne compilent plus, car elles doivent implémenter la nouvelle méthode abstraite. Donc, fais super gaffe au design des abstractions et privilégie les interfaces avec méthodes par défaut quand tu peux.

4. Conseils pour éviter les pièges classiques

Fais en sorte que tes applis soient flex comme des gymnastes, mais te casse pas les jambes !

  • N'abuse pas de l'héritage : si tu peux faire avec de la composition (mettre un objet dans un autre), fais-le.
  • Déclare les méthodes virtuelles seulement si tu sais qu'elles doivent être overridées.
  • Ne crée pas de classes abstraites inutiles et ne fais pas une hiérarchie "au cas où".
  • Vérifie la logique quand tu overrides : ne pète pas les invariants (règles) des classes de base.
  • N'ajoute pas de nouvelles méthodes abstraites dans les classes de base et interfaces publiques après la publication de la lib.
  • Utilise les interfaces pour un couplage faible entre les parties du programme.
  • Pour étendre l'API, utilise les Default Interface Methods.
2
Mission
C# SELF, niveau 25, leçon 3
Bloqué
Utilisation du mot-clé `new` pour masquer une méthode
Utilisation du mot-clé `new` pour masquer une méthode
2
Mission
C# SELF, niveau 25, leçon 3
Bloqué
Utilisation des classes abstraites et du principe de spécialisation
Utilisation des classes abstraites et du principe de spécialisation
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION