Ce que C# présente comme du Pattern Matching est un ensemble de mécanismes permettant de choisir des chemins dans l'exécution d'un programme sur la base des types impliqués. C'est un mécanisme qui peut compléter le polymorphisme classique lorsqu'il est utilisé de manière sélective -- en abuser peut mener à un cauchemar d'entretien.
Merci à mon illustre collègue Pierre Prud'homme pour l'inspiration derrière cet exemple.
Supposons que l'on ait un programme dans lequel on trouve une classe abstraite Personnage de même que des classes dérivées Humain et Zombie. Supposons aussi que le programme contienne un plateau de jeu, essentiellement un tableau 2D de références à des instances Personnage, et que chaque Personnage connaisse sa position sur la carte.
En gros :
class Position
{
public int X { get; set; } = 0;
public int Y { get; set; } = 0;
public Position()
{
}
public Position(int x, int y)
{
X = x;
Y = y;
}
}
abstract class Personnage
{
public Position Pos{ get; }
public Personnage(Position pos)
{
Pos = new (pos.X, pos.Y);
}
}
class Humain : Personnage
{
public Humain(Position pos) : base(pos)
{
}
}
class Zombie : Personnage
{
public Zombie(Position pos) : base(pos)
{
}
}
class Plateau
{
Personnage [,] Carte { get; }
public Personnage this[Position pos]
{
get => Carte[pos.Y, pos.X]; // écran console
set
{
Carte[pos.Y,pos.X] = value;
}
}
public Plateau(int hauteur, int largeur)
{
Carte = new Personnage[hauteur, largeur]; // implicitement null
}
public bool Ajouter(Personnage p)
{
if (p == null || this[p.Pos] != null) return false;
this[p.Pos] = p;
return true;
}
}
// ...
Si nous devons écrire une fonction DéplacerVers déplaçant un Personnage vers une nouvelle position sur le plateau de jeu, tout en faisant en sorte qu'un Personnage ne puisse aller que sur une case vide (représentée par un null), une écriture relativement simple serait :
// ...
// précondition : pos est valide pour plateau
static bool DéplacerVers(Plateau plateau, Personnage p, Position pos)
{
if(plateau[pos] != null)
return false;
plateau[p.Pos] = null;
plateau[pos] = p;
return true;
}
// ...
Supposons que le projet soit presque à échéance, et qu'on ajoute une subtilité de dernière minute, soit le fait que certains types dérivés de la classe Humain et certains types dérivés de la classe Zombie implémentent une interface IAssaillant et qu'un IAssaillant doive exposer une propriété Force de type float.
Quelque chose comme :
// ...
interface IAssaillant
{
float Force{ get; }
}
class Chasseur : Humain, IAssaillant // par exemple
{
public float Force{ get; }
public Chasseur(Position pos, float force) : base(pos)
{
Force = force;
}
}
// ...
Supposons aussi que deux règles de déplacement aient été ajoutées au projet, soit :
Idéalement, vous retravailleriez le design de votre projet et le choix des types et opérations impliquées, mais le temps presse et vous cherchez une solution rapide à mettre en place – c'est pragmatique, il faut livrer le produit.
L'opérateur as de C# permet de traiter un objet comme étant d'un autre type que celui indiqué; dans le cas où la conversion n'est pas possible, on obtient une référence nulle.
Si nous utilisons as, nous aurons quelque chose comme ce qui suit :
// ...
static bool DéplacerVers(Plateau plateau, Personnage p, Position pos)
{
if(plateau[pos] != null)
{
IAssaillant pa = p as IAssaillant;
if(pa == null)
return false; // p n'est pas un IAssaillant
// Ok, p est un IAssaillant; qu'en est-il de l'autre
IAssaillant pb = plateau[pos] as IAssaillant;
// si p essaie de déloger un un assaillant plus
// fort que lui
if(pb != null && pa.Force < pb.Force)
return false;
}
plateau[p.Pos] = null;
plateau[pos] = p;
return true;
}
// ...
L'opérateur is est un prédicat retournant true seulement si un objet est au moins du type indiqué par l'expression. Si nous utilisons is, alors le code pourra ressembler à :
// ...
static bool DéplacerVers(Plateau plateau, Personnage p, Position pos)
{
if(plateau[pos] != null)
{
if(!(p is IAssaillant))
return false; // p n'est pas un IAssaillant
// Ok, p est un IAssaillant; qu'en est-il de l'autre
if (plateau[pos] is IAssaillant &&
((IAssaillant) p).Force < ((IAssaillant)plateau[pos]).Force)
return false;
}
plateau[p.Pos] = null;
plateau[pos] = p;
return true;
}
// ...
Notez que les conversions explicites en IAssaillant sont faites alors que nous avons la conviction, dû aux tests réalisés au préalable, qu'elles sont légales.
Il est aussi possible d'utiliser avec is une syntaxe un peu étrange permettant d'introduire un nom suivant une expression is, ce qui permet en quelque sorte d'écrire si obj est un X et qu'on nomme ce X comme suit... Ceci permet d'alléger les acrobaties imposées par les limites du système de types de C# dans certaines situations.
Avec notre exemple, nous aurions :
// ...
static bool DéplacerVers(Plateau plateau, Personnage p, Position pos)
{
if(plateau[pos] != null)
{
if(p is IAssaillant pa)
{
if (plateau[pos] is IAssaillant pb && pa.Force < pb.Force)
return false;
}
else
return false;
}
plateau[p.Pos] = null;
plateau[pos] = p;
return true;
}
// ...
Il arrive que l'on souhaite discriminer des opérations sur la base d'un nombre fini de types. Par exemple, supposons que nous souhaitions écrire un système de collisions entre différents types de volumes, soit des instances de Sphère, de Boîte et de AABB (Axis-Aligned Bounding Boxes), et supposons que nous ayons une variété d'objets 3D pour lesquels nous modélisons les volumes englobants par ces trois types de volumes.
Sur la base du polymorphisme classique, nous faisons alors face à ce qu'on appelle un cas de multiméthode (les anglophones parlent parfois de Double-Dispatch), car du fait que nous avons deux abstractions (deux instances distinces de Volume) par collision, il faut deux appels polymorphiques pour résoudre le problème.
Le programme de test montre comment il est possible de profiter de ce code. |
|
La première abstraction est Volume, classe parent de cette petite hiérarchie. Elle doit définir une méthode par type possible :
|
|
Viennent ensuite les types de Volume plus spécifiques. En plus de leurs particularités (omises ici pour simplifier l'exemple), nous aurons pour chacun :
C'est beaucoup de code, mais c'est aussi la nature de la bête : la programmation orientée objet n'est pas à son mieux lorsque deux types abstraits sont impliqués (elle fonctionne toutefois bien, grâce au polymorphisme, lorsque placée devant une seule abstraction). |
|
La classe abstraite Obj3D exposera sur demande son volume englobant, un Volume. La collision entre deux objets 3D est exprimée en termes de collision de leurs volumes englobants respectifs. Deux exemples d'objets 3D suivent, avec des noms évocateurs et des volume englobants appropriés à leurs formes consacrées. |
|
Ce design par multiméthodes fonctionne, mais est lourd est relativement lent (les appels polymorphiques sont difficiles à optimiser pour un compilateur; s'il y en a beaucoup, l'impact peut être notable). L'écriture de toutes ces méthodes peut aussi sembler lourde à porter, surtout si l'on considère les conséquences d'ajouter un type de volume au système. En retour, il est relativement extensible, et si nous ajoutons un type de volume sans bien ajuster chacune des classes impliquées, nous aurons souvent une erreur de compilation.
J'insiste sur le peut; ce mécanisme peut aussi être inapproprié (dans cet exemple, d'ailleurs, il est... discutable)
Un mécanisme de C# permet de faire une sélective sur la base des types. Si nous avons un ensemble fermé de possibilités, cela peut donner une alternative aux multiméthodes, et de manière générale peut remplacer le polymorphisme classique.
Voyons un peu comment il peut s'articuler en pratique.
Le programme de test simpliste montre que le tout fonctionne. |
|
Dans cette implémentation, j'ai utilisé des sélectives imbriqués (des switch imbriqués). C'est quelque peu discutable comme choix, mais si nous avions souhaité travailler sur un seul type, un seul niveau de sélective aurait été requis et le tout aurait été moins déplaisant à l'oeil. Notez la condition de la sélective, qui est une référence sur une instance d'une classe particulière, et le traitement des types envisagés au cas par cas (chaque case opère sur un type distinct). L'introduction (optionnelle) d'un nom après le type dans chaque cas permet d'obtenir une référence du type souhaité pour la durée du cas en question; je ne les utilise pas ici, mais en pratique (si j'avais vraiment implémenté du code de détection de collisions) des noms auraient été utiles. Soyez prudentes et prudents : les cas sont traités dans l'ordre, alors assurez-vous que les cas plus spécifiques précèdent les cas plus généraux! Le cas default s'applique comme à l'habitude si aucun des cas plus spécifiques ne convient
|
|
La logique de collision n'a plus à être placée dans les classes de volumes (bien qu'elle puisse l'être, évidemment; une technique n'exclut pas l'autre, et des approches hybrides sont possibles). J'ai laissé place à l'imagination pour les détails d'implémentation de chaque classe. |
|
Pour les objets 3D eux-même, presque rien n'a changé, hormis la méthode Obj3D.Collides qui ne procède plus par polymorphisme classique pour arriver à ses fins. |
|
Notez que nous n'avons plus techniquement besoin de la classe Volume ici; VolumeEnglobant pourrait être de type object, les classes spécifiques de volumes pourraient n'avoir comme seul parent (implicite) que object, et la sélective sur les types demeurerait possible. En fait, ce mécanisme trouve un peu son intérêt quand la logique déterminant les actions à prendre pour un type donné n'est pas polymorphique à proprement parler, quand les types n'ont pas de parent commun (outre object, que C# impose).
Il arrive que les sélectives sur les seuls types ne suffisent pas à discriminer les cas d'intérêt pour un programme. Supposons une fonction susceptible de traiter divers types de messages, et supposons que ces messages diffèrent de manière structurelle, à ce point que les classes n'aient pas de réel point en commun outre le fait de traiter leurs instances dans une méthode générale TraiterMessage qui s'adonne au cas par cas selon les types.
Un petit programme de test permet de valider que le tout fonctionne. |
|
La traitement des messages dans ce cas est fait au cas par cas à travers une sélective sur les types. Notez toutefois que dans le cas du type MessageAvertissement, le type seul ne suffit pas pour déterminer les actions à prendre; il faut ajouter une alternative (un if) à ce cas pour discriminer entre le geste « normal » et les actions critiques demandant une évacuation. |
|
Pour les besoins de la cause, j'utiliserai trois types de messages. Le premier sera un MessageInformation, dont le contenu se limitera à une chaîne de caractères. |
|
Le deuxième sera un MessageAvertissement. Pour les besoins de l'exemple, un avertissement combinera un message textuel et une sévérité. Nous souhaiterons que, lors du traitement d'un tel message, une sévérité « grave » provoque une demande d'évacuation des lieux. |
|
Certains messages décriront des déplacements entre deux positions. Ce seront des instances de MessageDéplacement. |
|
Pour les cas de sélectives sur les types où un discriminant sur la base des valeurs est nécessaire, C# permet d'ajouter une clause when qui permettra de systématiser le recours à ce discriminant. Dans le cas de TraiterMessage, nous obtiendrons alors :
static void TraiterMessage(object obj)
{
switch(obj)
{
case MessageAvertissement av when av.Sévérité == Sévérité.Grave:
Console.WriteLine($"ÉVACUEZ! {av.Contenu}");
break;
case MessageAvertissement av:
Console.WriteLine($"Avertissement : {av.Contenu}");
break;
case MessageDéplacement depl:
Console.WriteLine($"Déplacement de {depl.De} vers {depl.Vers}");
break;
case MessageInformation info:
Console.WriteLine($"INFO : {info.Contenu}");
break;
default:
throw new MessageInconnuException();
}
}
Notez encore une fois que les cas sont testés dans l'ordre, alors il est important que le cas avec discriminant when précède le cas sans discriminant dans ce cas-ci dans l'ordre selon lequel les tests seront faits.
Il est aussi possible de faire du Pattern Matching sur des expressions plus complexes. Par exemple dans le cas de chaînes, C# permet quelque chose comme ceci :
using System;
string s = Console.ReadLine();
switch(s.ToLower())
{
case "patate":
case "carotte":
Console.WriteLine($"{s} est un légume");
break;
case "tomate":
case "concombre":
Console.WriteLine($"{s} est un fruit");
break;
case var nom when string.IsNullOrEmpty(nom?.Trim()):
Console.WriteLine("Difficile à comprendre...");
break;
default:
Console.WriteLine($"Je ne connais pas \"{s}\"");
break; // soupir...
}
Ce programme jouet lit une chaîne de caractères s au clavier, et compare sa version « minuscule » avec quelques cas connus :
Le cas var introduit le nom nom, et est accompagné d'un discriminant when pour exprimer à la fois le cas null et le cas où le texte ne contiendrait que des blancs. Ce n'est pas tout à fait un cas par défaut; c'est un cas où certaines formes de chaînes de caractères spécifiques sont traitées différemment des autres.
Le Pattern Matching peut être un outil intéressant, mais un bémol s'impose.
Puisqu'il s'agit de traitement au cas par cas, ce mécanisme sied bien aux problèmes pour lesquels le nombre de cas à traiter est fini. Pour les problèmes dans lesquels le nombre de cas possibles est ouvert, opérer à travers du Pattern Matching compliquera l'entretien du code.
Comparez par exemple les deux solutions suivantes au même problème :
Polymorphisme | Pattern Matching | |
---|---|---|
Classes |
|
|
Utilisation |
|
|
Dans un cas comme celui-ci, le polymorphisme comprend de nombreux avantages sur le Pattern Matching :
Pour ce qui est des coûts, ils sont semblables, du moins pour un test relativement naïf, mais ne vous fiez pas sur ce seul test; il n'est qu'indicatif. Par exemple, avec ce qui suit, le Pattern Matching est légèrement plus rapide que le polymorphisme, mais (j'insiste) ce test est trop simple pour tirer des conclusions (on n'y voit pas l'impact d'une variation du nombre de cas, de classes terminales, de code non-trivial, etc.) :
using System;
using System.Collections.Generic;
const int N = 10_000_000;
var lst = CréerCalculs(new Random(0), N); // germe prévisible
var (r0, dt0) = Test(() =>
{
int cumul = 0;
foreach (var p in lst)
cumul += p.Op(1, 1);
return cumul;
});
var (r1, dt1) = Test(() =>
{
int cumul = 0;
foreach (var p in lst)
{
switch (p)
{
case Somme somme:
cumul += somme.CalculerSomme(1, 1);
break;
case Produit prod:
cumul += prod.CalculerProduit(1, 1);
break;
case Max max:
cumul += max.CalculerMax(1, 1);
break;
}
}
return cumul;
});
Console.WriteLine($"Avec {N} opérations :");
Console.WriteLine($"{r0} et {dt0} ms pour le polymorphisme");
Console.WriteLine($"{r1} et {dt1} ms pour le Pattern Matching");
interface ICalcul
{
int Op(int x, int y);
}
class Somme : ICalcul
{
public int CalculerSomme(int x, int y) => x + y;
public int Op(int x, int y) => CalculerSomme(x, y);
}
class Produit : ICalcul
{
public int CalculerProduit(int x, int y) => x * y;
public int Op(int x, int y) => CalculerProduit(x, y);
}
class Max : ICalcul
{
public int CalculerMax(int x, int y) => Math.Max(x,y);
public int Op(int x, int y) => CalculerMax(x, y);
}
static List<ICalcul> CréerCalculs(Random r, int n)
{
ICalcul[] tab = new ICalcul[] { new Somme(), new Produit(), new Max() };
var lst = new List<ICalcul>();
for (int i = 0; i != n; ++i)
lst.Add(tab[r.Next(0, tab.Length)]);
return lst;
}
static (T,long) Test<T>(Func<T> f)
{
var sw = new System.Diagnostics.Stopwatch();
sw.Start();
T rés = f();
sw.Stop();
return (rés, sw.ElapsedMilliseconds);
}
Sur mon petit ordinateur personnel, j'obtiens :
Avec 10000000 opérations :
13333052 et 177 ms pour le polymorphisme
13333052 et 174 ms pour le Pattern Matching
On conviendra qu'il n'est pas immédiatement évident que la difficulté accrue d'entretien du code entraîne des bénéfices marqués ici.
Quelques liens pour enrichir le tout.