C#Pattern Matching

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.

Forme simple – expressions as et is

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.

Opérateur as

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;
}
// ...

Opérateur is

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.

Extension – expression is introduisant un nom

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;
   }
// ...

Sélective sur la base des types

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.

using System;
Obj3D mario = new Mario(),
      pacman = new Pacman();
if(mario.Collides(pacman))
{
   // ...
}

La première abstraction est Volume, classe parent de cette petite hiérarchie. Elle doit définir une méthode par type possible :

  • Une méthode générale, acceptant un autre Volume en paramètre, qui servira lors du premier test de collision (quand nous avons deux références de Volume, v0 et v1, et que nous exprimerons v0.Collides(v1) sans connaître le type exact référé par aucun des deux)
  • Une méthode spécifique par type de Volume, que les enfants devront implémenter et qui prend dans chaque cas en paramètre un type de Volume plus spécifique. Celles-ci seront utilisées quand l'un des deux types aura été découvert
public abstract class Volume
{
   public abstract bool Collides(Volume v);
   public abstract bool Collides(Sphère v);
   public abstract bool Collides(Boîte v);
   public abstract bool Collides(AABB v);
}

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 :

  • Une spécialisation de la méthode Collides acceptant un Volume en paramètre et, dans chaque cas, s'exprimant simplement par return v.Collides(this) v est le Volume reçu en paramètre
  • C'est la clé de la démarche, d'ailleurs; prenons pour exemple la classe Sphère :
    • en appelant v.Collides(this) alors que l'on a découvert que this réfère à une Sphère, nous venons de résoudre le type de this
    • à ce stade, l'appel à Collides étant polymorphique, cet appel découvrira le type de v, ce qui nous donnera les deux types dont nous avons besoin!
  • Les méthodes Collides prenant des types spécifiques en paramètre sont celles où les réels tests de collision seraient faits sur la base des algorithmes choisis (ici, je me suis limité à de simples affichages)

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).

public class Sphère : Volume
{
   public override bool Collides(Volume v) => v.Collides(this);
   public override bool Collides(Sphère v)
   {
      Console.WriteLine("Sphère x Sphère");
      return true;
   }
   public override bool Collides(Boîte v)
   {
      Console.WriteLine("Sphère x Boîte");
      return true;
   }
   public override bool Collides(AABB v)
   {
      Console.WriteLine("Sphère x AABB");
      return true;
   }
}
public class Boîte : Volume
{
   public override bool Collides(Volume v) => v.Collides(this);
   public override bool Collides(Sphère v)
   {
      Console.WriteLine("Boîte x Sphère");
      return true;
   }
   public override bool Collides(Boîte v)
   {
      Console.WriteLine("Boîte x Boîte");
      return true;
   }
   public override bool Collides(AABB v)
   {
      Console.WriteLine("Boîte x AABB");
      return true;
   }
}
public class AABB : Volume
{
   public override bool Collides(Volume v) => v.Collides(this);
   public override bool Collides(Sphère v)
   {
      Console.WriteLine("AABB x Sphère");
      return true;
   }
   public override bool Collides(Boîte v)
   {
      Console.WriteLine("AABB x Boîte");
      return true;
   }
   public override bool Collides(AABB v)
   {
      Console.WriteLine("AABB x AABB");
      return true;
   }
}

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.

abstract class Obj3D
{
   public abstract Volume VolumeEnglobant{ get; }
   public bool Collides(Obj3D autre) =>
      VolumeEnglobant.Collides(autre.VolumeEnglobant);
}
class Mario : Obj3D
{
   public override Volume VolumeEnglobant
   {
      get => new Boîte();
   }
}
class Pacman  : Obj3D
 {
   public override Volume VolumeEnglobant
   {
      get => new Sphère();
   }
}

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.

using System;

new Mario().Collides(new Pacman());

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

 

static bool Collides(Volume v0, Volume v1)
{
   switch (v0)
   {
      case Sphère s0:
         switch (v1)
         {
            case Sphère s1:
               Console.WriteLine("Sphère x Sphère");
               return true;
            case Boîte b1:
               Console.WriteLine("Sphère x Boîte");
               return true;
            case AABB a1:
               Console.WriteLine("Sphère x AABB");
               return true;
         }
         break;
      case Boîte b0:
         switch(v1)
         {
            case Sphère s1:
               Console.WriteLine("Boîte x Sphère");
               return true;
            case Boîte b1:
               Console.WriteLine("Boîte x Boîte");
               return true;
            case AABB a1:
               Console.WriteLine("Boîte x AABB");
               return true;
         }
         break;
      case AABB a0:
         switch (v1)
         {
            case Sphère s1:
               Console.WriteLine("AABB x Sphère");
               return true;
            case Boîte b1:
               Console.WriteLine("AABB x Boîte");
               return true;
            case AABB a1:
               Console.WriteLine("AABB x AABB");
               return true;
         }
         break;
      default:
         break;
   }
   throw new TypeInconnuException();
}
class TypeInconnuException : Exception { }

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.

public class Volume
{
   // ...
}
public class Sphère : Volume
{
   // ...
}
public class Boîte : Volume
{
   // ...
}
public class AABB : Volume
{
   // ...
}

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.

abstract class Obj3D
{
   public abstract Volume VolumeEnglobant { get; }
   public bool Collides(Obj3D autre) =>
      Program.Collides(VolumeEnglobant, autre.VolumeEnglobant);
}
class Mario : Obj3D
{
   public override Volume VolumeEnglobant
   {
      get => new Boîte();
   }
}
class Pacman : Obj3D
{
   public override Volume VolumeEnglobant
   {
      get => new Sphère();
   }
}

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).

Extension – sélectives avec discriminant when

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.

using System;

TraiterMessage(new MessageAvertissement("Il pleut", Sévérité.Bénin));
TraiterMessage(new MessageDéplacement(new Position(0, 0), new Position(1, 1)));
TraiterMessage(new MessageInformation("C'est frisquet"));
TraiterMessage(new MessageAvertissement("De la grèle, mes tomates!", Sévérité.Grave));

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.

static void TraiterMessage(object obj)
{
   switch(obj)
   {
      case MessageAvertissement av:
         if (av.Sévérité == Sévérité.Grave)
            Console.WriteLine($"ÉVACUEZ! {av.Contenu}");
         else
            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();
   }
}
class MessageInconnuException : Exception { }   

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.

class MessageInformation
{
   public string Contenu { get; }
   public MessageInformation(string contenu)
   {
      Contenu = contenu;
   }
}

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.

enum Sévérité { Bénin, Medium, Grave }
class MessageAvertissement
{
   public string Contenu { get; }
   public Sévérité Sévérité { get; } // masquage volontaire
   public MessageAvertissement(string contenu, Sévérité sévérité)
   {
      Contenu = contenu;
      Sévérité = sévérité;
   }
}

Certains messages décriront des déplacements entre deux positions. Ce seront des instances de MessageDéplacement.

class Position
{
   public int X { get; }
   public int Y { get; }
   public Position(int x, int y)
   {
      X = x;
      Y = y;
   }
   public override string ToString() => $"{X},{Y}";
}
class MessageDéplacement
{
   public Position De { get; }
   public Position Vers { get; }
   public MessageDéplacement(Position de, Position vers)
   {
      De = de;
      Vers = vers;
   }
}

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.

Cas particulier – case var

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.

Éviter les abus – risques d'antipolymorphisme

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
abstract class Forme
{
   public abstract void Dessiner();
}
class Carré : Forme
{
   public override void Dessiner()
   {
      Console.WriteLine("Carré");
   }
}
class Cercle : Forme
{
   public override void Dessiner()
   {
      Console.WriteLine("Cercle");
   }
}
// on aurait pu garder Forme, mais ce
// n'est pas nécessaire pour l'exemple
class Carré
{
   public void Dessiner()
   {
      Console.WriteLine("Carré");
   }
}
class Cercle
{
   public void Dessiner()
   {
      Console.WriteLine("Cercle");
   }
}
Utilisation
static void Dessiner(Forme f) => f.Dessiner();
static void Dessiner(object obj)
{
   switch(obj)
   {
   case Carré carré:
      carré.Dessiner();
      break;
   case Cercle cercle:
      cercle.Dessiner();
      break;
   default:
      // traitement d'erreur?
      break;
   }
}

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.

Lectures complémentaires

Quelques liens pour enrichir le tout.


Valid XHTML 1.0 Transitional

CSS Valide !