Tutoriel : Notions de base sur la mise en correspondance de motifs

Tip

Vous débutez avec le développement de logiciels ? Commencez par commencer par les didacticiels De prise en main . Ils introduisent des classes, des méthodes et un flux de contrôle.

Maîtrisez-vous une autre langue ? Ce tutoriel montre comment les patterns C# peuvent exprimer clairement le comportement de l’objet lorsque les règles dépendent de l’état actuel d’un objet.

Dans ce tutoriel, vous créez une application console qui modélise les règles d’une écluse.

Une écluse permet aux bateaux de monter ou de descendre entre deux biefs situés à des niveaux d’eau différents. Il a deux portes d’écluse et une chambre dont le niveau d’eau change entre un niveau bas et un niveau élevé. L’écluse ne peut fonctionner en toute sécurité que lorsque le niveau d’eau et les positions des portes d’écluse restent dans des combinaisons valides.

Dans ce tutoriel, vous allez apprendre à :

  • Décrivez le comportement de l’objet en fonction de son état.
  • Implémentez ces règles à l’aide de la mise en correspondance de motifs en C#.
  • Utilisez les diagnostics du compilateur pour valider votre implémentation.

Prerequisites

Instructions d’installation

Sur Windows, ce fichier de configuration WinGet est utilisé pour installer tous les prérequis. Si vous avez déjà installé quelque chose, WinGet ignore cette étape.

  1. Téléchargez le fichier et double-cliquez pour l’exécuter.
  2. Lisez le contrat de licence, tapez et, puis sélectionnez Entrer lorsque vous êtes invité à accepter.
  3. Si vous recevez une invite de contrôle de compte d’utilisateur (UAC) qui clignote dans votre barre des tâches, autorisez la poursuite de l'installation.

Sur d’autres plateformes, vous devez installer chacun de ces composants séparément.

  1. Téléchargez le programme d’installation recommandé à partir de la page de téléchargement du Kit de développement logiciel (SDK) .NET et double-cliquez pour l’exécuter. La page de téléchargement détecte votre plateforme et recommande le dernier programme d’installation de votre plateforme.
  2. Téléchargez le dernier programme d’installation à partir de la page d’accueil Visual Studio Code et double-cliquez pour l’exécuter. Cette page détecte également votre plateforme et le lien doit être correct pour votre système.
  3. Cliquez sur le bouton « Installer » dans la page d’extension devKit C#. Cela ouvre Visual Studio Code et vous demande si vous souhaitez installer ou activer l’extension. Sélectionnez « installer ».

Créer une simulation d’une écluse

Une écluse élève et abaisse les bateaux entre les voies d’eau à différents niveaux. Dans ce tutoriel, l’écluse simulée a une porte aval, une porte amont et un niveau d’eau qui peut être bas ou élevé.

En fonctionnement normal, un bateau entre lorsque le niveau d’eau à l’intérieur de l’écluse correspond au niveau côté entrée. Une fois le bateau à l’intérieur, les deux portes se ferment. Le niveau d’eau change pour correspondre au côté de sortie, puis la porte de sortie s’ouvre. Pour garantir la sécurité du modèle, le niveau d’eau ne peut varier que lorsque les deux portes sont fermées, et une porte ne peut s’ouvrir que si le niveau d’eau correspond à celui du bief situé de son côté.

Vous pouvez modéliser ces règles avec une CanalLock classe. L’objet expose des commandes permettant d’ouvrir ou de fermer chacune des portes, ainsi que de faire monter ou descendre le niveau d’eau. Il expose également les propriétés qui signalent l’état actuel du verrou.

Définir la classe

Créez un projet de console, puis ajoutez une classe nommée CanalLock. Commencez par concevoir l’API publique et laisser les méthodes non implémentées :

public enum WaterLevel
{
    Low,
    High
}
public class CanalLock
{
    // Query canal lock state:
    public WaterLevel CanalLockWaterLevel { get; private set; } = WaterLevel.Low;
    public bool HighWaterGateOpen { get; private set; } = false;
    public bool LowWaterGateOpen { get; private set; } = false;

    // Change the upper gate.
    public void SetHighGate(bool open)
    {
        throw new NotImplementedException();
    }

    // Change the lower gate.
    public void SetLowGate(bool open)
    {
        throw new NotImplementedException();
    }

    // Change water level.
    public void SetWaterLevel(WaterLevel newLevel)
    {
        throw new NotImplementedException();
    }

    public override string ToString() =>
        $"The lower gate is {(LowWaterGateOpen ? "Open" : "Closed")}. " +
        $"The upper gate is {(HighWaterGateOpen ? "Open" : "Closed")}. " +
        $"The water level is {CanalLockWaterLevel}.";
}

Le code précédent initialise l’écluse avec les deux portes fermées et le niveau d’eau bas. Ensuite, ajoutez le code suivant à Main pour guider votre première implémentation :

// Create a new canal lock:
var canalGate = new CanalLock();

// State should be doors closed, water level low:
Console.WriteLine(canalGate);

canalGate.SetLowGate(open: true);
Console.WriteLine($"Open the lower gate:  {canalGate}");

Console.WriteLine("Boat enters lock from lower gate");

canalGate.SetLowGate(open: false);
Console.WriteLine($"Close the lower gate:  {canalGate}");

canalGate.SetWaterLevel(WaterLevel.High);
Console.WriteLine($"Raise the water level: {canalGate}");

canalGate.SetHighGate(open: true);
Console.WriteLine($"Open the higher gate:  {canalGate}");

Console.WriteLine("Boat exits lock at upper gate");
Console.WriteLine("Boat enters lock from upper gate");

canalGate.SetHighGate(open: false);
Console.WriteLine($"Close the higher gate: {canalGate}");

canalGate.SetWaterLevel(WaterLevel.Low);
Console.WriteLine($"Lower the water level: {canalGate}");

canalGate.SetLowGate(open: true);
Console.WriteLine($"Open the lower gate:  {canalGate}");

Console.WriteLine("Boat exits lock at upper gate");

canalGate.SetLowGate(open: false);
Console.WriteLine($"Close the lower gate:  {canalGate}");

Ajoutez maintenant une première implémentation qui modifie chaque valeur d’état sans appliquer les règles de sécurité :

// Change the upper gate.
public void SetHighGate(bool open)
{
    HighWaterGateOpen = open;
}

// Change the lower gate.
public void SetLowGate(bool open)
{
    LowWaterGateOpen = open;
}

// Change water level.
public void SetWaterLevel(WaterLevel newLevel)
{
    CanalLockWaterLevel = newLevel;
}

Ces premières vérifications réussissent. Les mécanismes fonctionnent désormais correctement. Ensuite, ajoutez un test pour la première condition d’échec. À la fin de la séquence précédente, les deux portes sont fermées et le niveau d’eau est faible. Essayez d’ouvrir la barrière supérieure :

Console.WriteLine("=============================================");
Console.WriteLine("     Test invalid commands");
// Open "wrong" gate (2 tests)
try
{
    canalGate = new CanalLock();
    canalGate.SetHighGate(open: true);
}
catch (InvalidOperationException)
{
    Console.WriteLine("Invalid operation: Can't open the high gate. Water is low.");
}
Console.WriteLine($"Try to open upper gate: {canalGate}");

Ce test échoue, car la porte supérieure s’ouvre quand elle ne le devrait pas. Un premier correctif peut ressembler à ceci :

// Change the upper gate.
public void SetHighGate(bool open)
{
    if (open && (CanalLockWaterLevel == WaterLevel.High))
        HighWaterGateOpen = true;
    else if (open && (CanalLockWaterLevel == WaterLevel.Low))
        throw new InvalidOperationException("Cannot open high gate when the water is low");
}

Vos tests passent à nouveau. Mais lorsque vous ajoutez d’autres conditions, vous accumulez plus if d’instructions. Le code est plus difficile à analyser, car chaque règle est séparée des autres.

Implémenter les commandes avec des modèles

Une approche plus claire consiste à utiliser des motifs pour décrire directement les combinaisons valides. À l’étape suivante, chaque expression switch utilise un tuple comme entrée du motif. C# évalue ce tuple une fois, et chaque branche de switch teste l’état actuel de la vanne, le niveau d’eau et le nouveau paramètre demandé.

Pour la porte supérieure, vous pouvez résumer ces combinaisons comme suit :

Nouveau paramètre État de la porte Niveau d’eau Result
Fermé Fermé Élevé Fermé
Fermé Fermée Faible Fermé
Fermé Open Élevé Fermé
Fermé Ouvrir Faible Fermé
Open Fermé Élevé Open
Open Fermée Faible Fermé (erreur)
Open Open Élevé Open
Ouvrir Ouvrir Faible Fermé (erreur)

Les lignes barrées représentent des états internes non valides. L’expression switch peut encoder directement les transitions valides. false signifie toujours que la porte est fermée :

HighWaterGateOpen = (open, HighWaterGateOpen, CanalLockWaterLevel) switch
{
    (false, false, WaterLevel.High) => false,
    (false, false, WaterLevel.Low) => false,
    (false, true, WaterLevel.High) => false,
    (false, true, WaterLevel.Low) => false, // should never happen
    (true, false, WaterLevel.High) => true,
    (true, false, WaterLevel.Low) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
    (true, true, WaterLevel.High) => true,
    (true, true, WaterLevel.Low) => false, // should never happen
};

Essayez cette version. Vos tests réussissent. Le compilateur signale également que l’expression switch n’est pas exhaustive (autrement dit, qu’elle ne couvre pas toutes les valeurs possibles), car WaterLevel est une énumération et que C# autorise la conversion de n’importe quelle valeur du type numérique sous-jacent en cette énumération, même si aucune valeur nommée ne lui correspond. Ajoutez un bras final avec le modèle d’abandon (_) pour gérer ces états internes impossibles :

_ => throw new InvalidOperationException("Invalid internal state"),

Cette branche doit être placée en dernier, car le motif de rejet correspond à toutes les entrées restantes.

Vous pouvez ensuite simplifier les branches précédentes. La fermeture de la porte est toujours autorisée. Une seule branche peut donc remplacer les quatre cas distincts correspondant à une porte fermée :

(false, _, _) => false,

Vous pouvez également regrouper les cas valides d’ouverture tout en conservant l’erreur liée à la sécurité :

(true, _, WaterLevel.High) => true,
(true, false, WaterLevel.Low) => throw new InvalidOperationException("Cannot open high gate when the water is low"),
_ => throw new InvalidOperationException("Invalid internal state"),

Réexécutez le programme. Les tests réussissent toujours. Voici l’implémentation finale SetHighGate :

// Change the upper gate.
public void SetHighGate(bool open)
{
    HighWaterGateOpen = (open, HighWaterGateOpen, CanalLockWaterLevel) switch
    {
        (false, _,    _)               => false,
        (true, _,     WaterLevel.High) => true,
        (true, false, WaterLevel.Low)  => throw new InvalidOperationException("Cannot open high gate when the water is low"),
        _                              => throw new InvalidOperationException("Invalid internal state"),
    };
}

Implémenter les règles restantes

Maintenant, appliquez la même idée à SetLowGate et SetWaterLevel. Commencez par ajouter des tests qui exposent des opérations non valides :

Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetWaterLevel(WaterLevel.High);
    canalGate.SetLowGate(open: true);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't open the lower gate. Water is high.");
}
Console.WriteLine($"Try to open lower gate: {canalGate}");
// change water level with gate open (2 tests)
Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetLowGate(open: true);
    canalGate.SetWaterLevel(WaterLevel.High);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't raise water when the lower gate is open.");
}
Console.WriteLine($"Try to raise water with lower gate open: {canalGate}");
Console.WriteLine();
Console.WriteLine();
try
{
    canalGate = new CanalLock();
    canalGate.SetWaterLevel(WaterLevel.High);
    canalGate.SetHighGate(open: true);
    canalGate.SetWaterLevel(WaterLevel.Low);
}
catch (InvalidOperationException)
{
    Console.WriteLine("invalid operation: Can't lower water when the high gate is open.");
}
Console.WriteLine($"Try to lower water with high gate open: {canalGate}");

Réexécutez l’application. Ces tests échouent et l’écluse atteint des états non valides. Implémentez les méthodes restantes en effectuant un filtrage sur l’état complet. SetLowGate est semblable à SetHighGate. SetWaterLevel utilise le niveau d’eau actuel ainsi que les deux positions des vannes :

CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
    // arms go here
};

Vous avez 16 combinaisons à prendre en compte. Commencez par la table complète, écrivez les cas du switch, exécutez les tests, puis simplifiez les résultats répétés.

Avez-vous obtenu des méthodes similaires à celles-ci ?

// Change the lower gate.
public void SetLowGate(bool open)
{
    LowWaterGateOpen = (open, LowWaterGateOpen, CanalLockWaterLevel) switch
    {
        (false, _, _) => false,
        (true, _, WaterLevel.Low) => true,
        (true, false, WaterLevel.High) => throw new InvalidOperationException("Cannot open low gate when the water is high"),
        _ => throw new InvalidOperationException("Invalid internal state"),
    };
}

// Change water level.
public void SetWaterLevel(WaterLevel newLevel)
{
    CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
    {
        (WaterLevel.Low, WaterLevel.Low, true, false) => WaterLevel.Low,
        (WaterLevel.High, WaterLevel.High, false, true) => WaterLevel.High,
        (WaterLevel.Low, _, false, false) => WaterLevel.Low,
        (WaterLevel.High, _, false, false) => WaterLevel.High,
        (WaterLevel.Low, WaterLevel.High, false, true) => throw new InvalidOperationException("Cannot lower water when the high gate is open"),
        (WaterLevel.High, WaterLevel.Low, true, false) => throw new InvalidOperationException("Cannot raise water when the low gate is open"),
        _ => throw new InvalidOperationException("Invalid internal state"),
    };
}

Vos tests doivent maintenant réussir, et l’écluse doit appliquer ses règles de sécurité.

Résumé

Dans ce tutoriel, vous avez utilisé la mise en correspondance de motifs pour décrire comment un objet peut passer d’un état valide à un autre. Les motifs regroupent les transitions autorisées, ce qui permet de les comparer plus facilement qu’avec une longue série d’instructions conditionnelles. Cette approche fonctionne bien lorsque le comportement dépend de la forme combinée de plusieurs valeurs d’état.