1. Deux faces d’une même pièce
On a déjà bien creusé le concept d’abstraction et on a vu deux de ses outils les plus puissants en C# – classes abstraites et interfaces. Tu sens sûrement qu’ils se ressemblent, vu que les deux permettent de définir le "squelette" d’un comportement que les classes concrètes devront ensuite implémenter. Mais crois-moi, c’est comme comparer un marteau et un tournevis : ce sont des outils, mais pas pour les mêmes boulots.
Commençons par l’essentiel : pourquoi deux outils pour un même job ? En programmation, comme dans la vie, rien n’est "juste comme ça". S’il y a deux outils qui se ressemblent, c’est qu’ils ont chacun leurs points forts et leurs domaines d’utilisation.
Allez, on se fait un petit tableau comparatif pour avoir une vue d’ensemble. C’est un peu ta fiche de triche pour capter les points clés.
| Caractéristique | Classe abstraite | Interface |
|---|---|---|
| Création d’instance | Impossible de créer directement (). |
Impossible de créer directement (). |
| Implémentation des membres | Peut avoir : – Méthodes/propriétés complètement implémentées. – Méthodes/propriétés abstraites (sans implémentation). – Champs, constructeurs, méthodes statiques. |
Peut avoir des méthodes avec une implémentation par défaut, des membres statiques abstraits et non abstraits. Ne peut pas avoir de champs d’instance ni de constructeurs. |
| Modificateurs d’accès | Peut avoir n’importe lesquels (public, protected, internal, private). | Tous les membres d’une interface sont par défaut public. On ne précise pas les modificateurs d’accès (sauf pour les méthodes par défaut et les membres statiques). |
| Héritage | Une classe peut hériter d’une seule classe abstraite. | Une classe peut implémenter autant d’interfaces qu’elle veut. |
| Type de relation | "Est un" (is-a). Définit un type de base et la partie commune de la hiérarchie. | "Peut faire" (has-a ou can-do). Définit un contrat de comportement/capacité. |
| État | Peut stocker un état (champs d’instance). | Peut avoir des champs statiques, mais pas de champs d’instance. |
| Extension | On peut ajouter de nouvelles méthodes implémentées sans changer les héritiers. | On peut ajouter des méthodes avec une implémentation par défaut sans casser les héritiers. |
Alors, ça en jette ? On va détailler ces points tout de suite.
2. Héritage multiple
C’est sûrement la différence la plus fondamentale et la plus facile à retenir. En C#, comme dans plein d’autres langages (genre Java), une classe ne peut hériter que d’une seule classe parente. Que ce soit une classe normale ou abstraite – peu importe, une seule ! C’est fait pour éviter le fameux "problème du diamant" (Diamond Problem), où l’héritage de plusieurs classes crée des ambiguïtés sur quelle méthode utiliser si elles portent le même nom.
Mais une classe peut implémenter autant d’interfaces qu’elle veut ! Imagine que ta classe, c’est une personne. Elle peut être "étudiant" (hériter de la classe Student), mais elle "sait cuisiner" (ICookable), "sait conduire" (IDriveable) et "sait chanter" (ISingable). C’est super flexible !
abstract class Animal
{
public string Name;
public abstract void MakeSound();
public void Eat() => Console.WriteLine($"{Name} mange.");
}
interface IFlyable { void Fly(); double MaxFlyingAltitude { get; } }
interface ISwimable { void Swim(); }
class Duck : Animal, IFlyable, ISwimable
{
public double MaxFlyingAltitude => 1000;
public override void MakeSound() => Console.WriteLine($"{Name} fait coin-coin !");
public void Fly() => Console.WriteLine($"{Name} vole !");
public void Swim() => Console.WriteLine($"{Name} nage !");
}
class Program
{
static void Main()
{
var duck = new Duck { Name = "Donald" };
duck.MakeSound();
duck.Eat();
duck.Fly();
duck.Swim();
// On bosse avec l’objet via différents types :
Animal a = duck; a.Eat();
IFlyable f = duck; f.Fly();
ISwimable s = duck; s.Swim();
}
}
Si ta classe doit être dans une hiérarchie (genre, Dog est un Animal), utilise l’héritage de classe (abstraite ou pas). Si ta classe doit avoir une capacité (genre, Dog peut courir, Cat peut courir), mais que ces capacités ne sont pas liées à une hiérarchie stricte, utilise les interfaces. C’est ça le gros avantage des interfaces – elles permettent de créer des contrats pour des classes très différentes et pas du tout liées.
3. Où vivent les données, et où il n’y a que des promesses ?
Les classes abstraites peuvent contenir tout ce que tu veux :
- Méthodes et propriétés normales (non abstraites) avec une implémentation complète.
- Méthodes et propriétés abstraites sans implémentation (c’est celles qu’on override).
- Champs (variables d’instance) qui stockent l’état de l’objet.
- Constructeurs pour initialiser cet état.
- Même des méthodes et propriétés statiques.
- Et bien sûr, tous les modificateurs d’accès : public, protected, private, etc.
Ça fait de la classe abstraite un outil puissant pour définir un comportement partiellement implémenté et un état commun pour tous ses héritiers.
abstract class Employee
{
public string FirstName, LastName;
public decimal Salary { get; protected set; }
public Employee(string first, string last) { FirstName = first; LastName = last; }
public void GetPaid(decimal sum)
{
Salary += sum;
Console.WriteLine($"{FirstName} {LastName} a reçu {sum:C}. Salaire : {Salary:C}");
}
public abstract void PerformWork();
public abstract void TakeBreak();
}
class Developer : Employee
{
public Developer(string f, string l) : base(f, l) { }
public override void PerformWork() => Console.WriteLine($"{FirstName} écrit du code.");
public override void TakeBreak() => Console.WriteLine($"{FirstName} boit un café.");
}
class Tester : Employee
{
public Tester(string f, string l) : base(f, l) { }
public override void PerformWork() => Console.WriteLine($"{FirstName} cherche des bugs.");
public override void TakeBreak() => Console.WriteLine($"{FirstName} joue au foot.");
}
class Program
{
static void Main()
{
Employee[] team = {
new Developer("Ivan", "Petrov"),
new Tester("Maria", "Sidorova")
};
foreach (var emp in team)
{
Console.WriteLine($"\n--- Journée de travail pour {emp.FirstName} {emp.LastName} ---");
emp.PerformWork();
emp.GetPaid(2000);
emp.TakeBreak();
}
}
}
Les interfaces – c’est une autre histoire. Avant C# 8, elles ne pouvaient contenir que des déclarations de méthodes, propriétés, indexeurs et événements. Pas de champs, pas de constructeurs, pas d’implémentation de méthodes ! Juste la "signature" de la méthode sans corps. Et tous leurs membres étaient publics par défaut (même si tu ne mettais pas public). Ça garantissait que l’interface était un contrat pur, sans détails d’implémentation ni état caché.
Depuis C# 8, les interfaces sont devenues un peu plus "lourdes" et peuvent avoir des méthodes avec une implémentation par défaut (Default Interface Methods) et des membres statiques. C’est pour pouvoir ajouter de nouvelles méthodes à des interfaces existantes sans "casser" des millions de lignes de code qui les implémentent. Mais même avec ça, les interfaces ne peuvent toujours pas avoir de champs d’instance ni de constructeurs. Cette limite clé reste pour que les interfaces restent des "contrats de comportement", pas des "stockages d’état".
interface ISaveable
{
void Save(string file);
bool IsDirty { get; }
}
interface ILoadable
{
void Load(string file);
}
class GameProgress : ISaveable, ILoadable
{
public int Level { get; set; }
public string PlayerName { get; set; }
bool _isDirty = true;
public bool IsDirty => _isDirty;
public GameProgress(string name, int level)
{
PlayerName = name; Level = level;
}
public void Save(string file)
{
Console.WriteLine($"Sauvegarde : {PlayerName}, niveau {Level} -> {file}");
_isDirty = false;
}
public void Load(string file)
{
Console.WriteLine($"Chargement depuis {file}");
PlayerName = "Nouveau joueur"; Level = 5; _isDirty = true;
}
public void UpdateProgress(int newLevel)
{
Level = newLevel; _isDirty = true;
Console.WriteLine($"Mis à jour à {Level}.");
}
}
class Program
{
static void Main()
{
var game = new GameProgress("Héros", 1);
game.UpdateProgress(3);
ISaveable saver = game;
if (saver.IsDirty) saver.Save("save.dat");
ILoadable loader = game;
loader.Load("save.dat");
Console.WriteLine($"Après chargement : {game.PlayerName}, {game.Level}");
}
}
Conclusion : Si tu veux une classe de base qui fournit non seulement un contrat mais aussi du code déjà implémenté (logique commune) ou qui stocke un état commun, choisis une classe abstraite. Si tu veux juste une "checklist" ou un "contrat" de comportement, sans aucune implémentation ni état, alors l’interface est ton amie.
4. Quand utiliser les interfaces ?
Les interfaces sont top quand tu veux définir une capacité ou un comportement que peuvent avoir des objets très différents et pas du tout liés. Par exemple :
- IDisposable : tout objet qu’il faut "libérer" proprement après usage (fichier, connexion réseau, base de données).
- IEnumerable<T> : tout objet qu’on peut parcourir dans une boucle foreach.
- IComparable<T> : tout objet qu’on peut comparer à un autre du même type.
Ce qui compte, c’est que FileStream et SqlConnection peuvent implémenter IDisposable, et que List<T> et Dictionary<TKey, TValue> peuvent implémenter IEnumerable<T>. Ces classes sont dans des hiérarchies complètement différentes, mais elles ont une capacité commune, définie par l’interface.
Exemple : Imagine un système où tu as une Voiture et un Avion. Les deux peuvent être un Véhicule (peut-être une classe abstraite). Mais Voiture, Avion et même Bateau (si tu l’ajoutes) – peuvent se déplacer. Cette capacité "se déplacer" est parfaite pour une interface IMovable.
interface IMovable
{
void Move(int distance);
}
class Car : IMovable
{
public string Brand;
public Car(string brand) => Brand = brand;
public void Move(int d) => Console.WriteLine($"{Brand} roule {d} km.");
}
class Airplane : IMovable
{
public string Model;
public Airplane(string model) => Model = model;
public void Move(int d) => Console.WriteLine($"{Model} vole {d} km.");
}
class Human : IMovable
{
public string Name;
public Human(string name) => Name = name;
public void Move(int d) => Console.WriteLine($"{Name} marche {d} m.");
}
class Program
{
static void Main()
{
IMovable[] movers = { new Car("Toyota"), new Airplane("Boeing 747"), new Human("Arthur") };
foreach (var item in movers)
item.Move(100);
}
}
Tu vois ? On peut créer une liste de IMovable et appeler la méthode Move() pour chaque élément, sans savoir si c’est une Car, un Airplane ou un Human. C’est ça la puissance du polymorphisme via les interfaces.
5. Quand utiliser les classes abstraites ?
Les classes abstraites sont idéales quand tu veux définir une fonctionnalité de base commune pour un groupe de classes étroitement liées qui sont des variantes d’un même truc. Elles fournissent du code prêt à l’emploi pour tous les héritiers, et obligent à implémenter les parties spécifiques.
Imagine que tu as différents types de comptes bancaires : SavingAccount (épargne), CheckingAccount (courant), CreditAccount (crédit). Ils sont tous des BankAccount. Ils ont tous un solde (Balance), et tous peuvent être crédités (Deposit). Mais les règles de retrait (Withdraw) sont différentes pour chacun. C’est là qu’une classe abstraite BankAccount est parfaite !
abstract class BankAccount
{
public string AccountNumber { get; }
public decimal Balance { get; protected set; }
public BankAccount(string acc) { AccountNumber = acc; }
public void Deposit(decimal sum)
{
if (sum > 0)
{
Balance += sum;
Console.WriteLine($"{AccountNumber} : +{sum:C}, Solde : {Balance:C}");
}
}
public abstract bool Withdraw(decimal sum);
}
class CheckingAccount : BankAccount
{
public CheckingAccount(string acc) : base(acc) { }
public override bool Withdraw(decimal sum)
{
if (Balance >= sum)
{
Balance -= sum;
Console.WriteLine($"{AccountNumber} : -{sum:C}, Solde : {Balance:C}");
return true;
}
Console.WriteLine($"{AccountNumber} : Fonds insuffisants");
return false;
}
}
class CreditAccount : BankAccount
{
public decimal CreditLimit { get; }
public CreditAccount(string acc, decimal limit) : base(acc) => CreditLimit = limit;
public override bool Withdraw(decimal sum)
{
if (Balance - sum >= -CreditLimit)
{
Balance -= sum;
Console.WriteLine($"{AccountNumber} : -{sum:C}, Solde : {Balance:C}");
return true;
}
Console.WriteLine($"{AccountNumber} : Limite dépassée");
return false;
}
}
class Program
{
static void Main()
{
var checking = new CheckingAccount("12345");
checking.Deposit(1000);
checking.Withdraw(300);
checking.Withdraw(800);
Console.WriteLine("\n--- Compte crédit ---");
var credit = new CreditAccount("67890", 500);
credit.Deposit(200);
credit.Withdraw(400);
credit.Withdraw(400);
}
}
Ici, BankAccount donne à tous les comptes la logique commune de dépôt (Deposit) et gère le numéro de compte et le solde. Mais la logique de retrait (Withdraw) est différente, donc elle est abstraite.
6. Quand ils bossent ensemble : le duo parfait
Le design le plus élégant et puissant, c’est souvent une combinaison de classes abstraites et d’interfaces. Une classe abstraite peut elle-même implémenter une ou plusieurs interfaces !
Imagine : tu as abstract Animal qui définit les bases. Mais certains Animal peuvent être IMovable, ICarnivore, IPredator, etc. Ton Animal peut même fournir une implémentation de base pour IMovable (genre une méthode Move(int speed)), mais ensuite des classes concrètes comme Lion ou Fish vont override cette implémentation pour bouger à leur façon.
public interface ISaveable
{
void SaveState(string path);
bool HasChanges { get; }
}
public class Vector3
{
public float X, Y, Z;
public Vector3(float x, float y, float z) { X = x; Y = y; Z = z; }
public override string ToString() => $"({X}, {Y}, {Z})";
}
public abstract class GameObject
{
public string Id { get; }
public Vector3 Position { get; }
protected GameObject(string id, Vector3 pos) { Id = id; Position = pos; }
public abstract void Update();
public void Destroy() => Console.WriteLine($"{Id} détruit");
}
public class Player : GameObject, ISaveable
{
public int Health { get; private set; }
private bool _hasChanges = true;
public bool HasChanges => _hasChanges;
public Player(string id, Vector3 pos, int health) : base(id, pos) => Health = health;
public override void Update() =>
Console.WriteLine($"{Id} mis à jour, HP : {Health}, Pos : {Position}");
public void TakeDamage(int dmg)
{
Health -= dmg;
_hasChanges = true;
Console.WriteLine($"{Id} a pris {dmg} de dégâts. HP : {Health}");
}
public void SaveState(string path)
{
Console.WriteLine($"Sauvegarde {Id} (HP:{Health}) dans {path}");
_hasChanges = false;
}
}
class GameEngine
{
static void Main()
{
var player = new Player("P1", new Vector3(0, 0, 0), 100);
player.Update();
player.TakeDamage(20);
ISaveable saver = player;
if (saver.HasChanges) saver.SaveState("save.json");
GameObject obj = player;
obj.Update();
player.Destroy();
}
}
Dans cet exemple, Player est un GameObject (car il hérite, utilise les champs Id, Position et la méthode Destroy). Et en même temps, Player peut être sauvegardé (car il implémente l’interface ISaveable). C’est super flexible et puissant !
7. Nuances et erreurs fréquentes
Essayer de créer une instance d’une classe abstraite :
Animal myAnimal = new Animal(); – c’est une erreur classique de débutant. Rappelle-toi : une classe abstraite, c’est un modèle, pas un objet prêt à l’emploi. Le compilateur va te le dire direct.
Oublier d’implémenter les méthodes abstraites : Si tu hérites d’une classe abstraite et que ta classe n’est pas abstraite, tu dois override (override) toutes les méthodes abstraites de la classe de base. Sinon, le compilateur va râler aussi.
Essayer d’ajouter des champs dans une interface : C’est un des "interdits" qui distinguent bien les interfaces. Depuis C# 8+, il y a les champs statiques, mais les champs d’instance (ceux qui appartiennent à un objet) sont toujours interdits. Une interface, c’est un contrat de comportement, pas un stockage de données.
Confusion entre virtual et abstract :
- Une méthode/propriété virtual a une implémentation par défaut et peut être override dans les héritiers.
- Une méthode/propriété abstract n’a pas d’implémentation et doit être override dans le premier héritier non abstrait.
- Utiliser virtual là où la logique est toujours différente oblige à écrire des implémentations vides, puis à les override. C’est moins clair comme contrat que abstract.
Comprendre ces différences et bien choisir entre classe abstraite et interface – c’est une compétence clé pour tout dev C#. Ce n’est pas juste une question de syntaxe, c’est une question d’architecture. Quand tu conçois un système, pose-toi les questions : "Ces classes ont-elles une hiérarchie commune 'est un' ?" et "Ces classes ont-elles une capacité commune qui sert à des types non liés ?". Les réponses t’aideront à faire le bon choix.
Dans les prochaines conférences, on va continuer à explorer le monde des interfaces, en découvrant leurs fonctionnalités avancées apparues dans les dernières versions de C#. Reste connecté !
GO TO FULL VERSION