Utiliser la correspondance de modèle pour générer votre comportement de classe, afin d’obtenir un meilleur code

Les fonctionnalités de correspondance de modèle en C# fournissent une syntaxe pour exprimer vos algorithmes. Vous pouvez utiliser ces techniques pour implémenter le comportement dans vos classes. Vous pouvez combiner la conception de classes orientée objet avec une implémentation orientée données, afin de fournir du code concis lors de la modélisation d’objets réels.

Dans ce tutoriel, vous apprendrez à :

  • Exprimez vos classes orientées objet à l’aide de modèles de données.
  • Implémentez ces modèles à l’aide des fonctionnalités de correspondance de modèle de C#.
  • Tirez parti des diagnostics du compilateur pour valider votre implémentation.

Prérequis

Vous devrez configurer votre ordinateur pour exécuter .NET. Téléchargez Visual Studio 2022 ou le Kit de développement logiciel (SDK) .NET.

Générer une simulation d’une écluse de canal

Dans ce didacticiel, vous allez générer une classe C# qui simule une écluse de canal. En bref, une écluse de canal est un dispositif qui élève et abaisse les bateaux qui se déplacent entre deux étendues d’eau à différents niveaux. Une écluse possède deux portes et un mécanisme permettant de changer le niveau de l’eau.

En fonctionnement normal, un bateau entre dans l’une des portes, tandis que le niveau de l’eau dans l’écluse correspond au niveau de l’eau sur le côté où le bateau entre. Une fois dans l’écluse, le niveau de l’eau est modifié pour correspondre au niveau d’eau là où le bateau quittera l’écluse. Une fois que le niveau de l’eau correspond à ce côté, la porte du côté de la sortie s’ouvre. Les mesures de sécurité permettent de s’assurer qu’un opérateur ne peut pas créer de situation dangereuse dans le canal. Le niveau de l’eau ne peut être modifié que lorsque les deux portes sont fermées. Au plus, seule une porte peut être ouverte. Pour ouvrir une porte, le niveau de l’eau dans l’écluse doit correspondre au niveau de l’eau à l’extérieur de la porte ouverte.

Vous pouvez générer une classe C# pour modéliser ce comportement. Une classe CanalLock prend en charge les commandes permettant d’ouvrir ou de fermer l’une ou l’autre des portes. Elle aurait d’autres commandes pour élever ou abaisser l’eau. La classe doit également prendre en charge les propriétés pour lire l’état actuel des deux portes et du niveau d’eau. Vos méthodes implémentent les mesures de sécurité.

Définir une classe

Vous allez générer une application console pour tester votre classe CanalLock. Créez un nouveau projet de console pour .NET 5 à l’aide de Visual Studio ou de CLI .NET. Puis, ajoutez une nouvelle classe et nommez-la CanalLock. Ensuite, concevez votre API publique, mais laissez 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’objet afin que les deux portes soient fermées et que le niveau d’eau soit bas. Ensuite, écrivez le code de test suivant dans votre méthode Main pour vous guider lors de la création d’une première implémentation de la classe :

// 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}");

Ensuite, ajoutez une première implémentation de chaque méthode dans la classe CanalLock. Le code suivant implémente les méthodes de la classe sans se soucier des règles de sécurité. Vous ajouterez des tests de sécurité ultérieurement :

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

Les tests que vous avez écrits jusqu’à présent réussissent. Vous avez implémenté les principes de base. À présent, écrivez un test pour la première condition d’échec. À la fin des essais précédents, les deux portes sont fermées et le niveau de l’eau est défini sur bas. Ajoutez un test pour essayer d’ouvrir la porte 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 s’ouvre. En tant que première implémentation, vous pouvez la corriger avec le code suivant :

// 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 réussissent. Toutefois, au fur et à mesure que vous ajouterez d’autres tests, vous ajouterez de plus en plus de if clauses et vous testerez différentes propriétés. Rapidement, ces méthodes deviendront trop compliquées à mesure que vous ajouterez des instructions conditionnelles.

Implémenter les commandes avec des modèles

Il vaut mieux utiliser des modèles pour déterminer si l’objet est dans un état valide pour exécuter une commande. Vous pouvez exprimer si une commande est autorisée en fonction de trois variables : l’état de la porte, le niveau de l’eau et le nouveau paramètre :

Nouveau paramètre État de la porte Niveau de l’eau Résultats
Fermé Fermé Élevé Fermés
Fermé Fermé Faible Fermé
Fermés Ouvrir Élevé Fermés
Fermés Ouvrir Low Fermés
Ouvrir Fermés Élevé Ouvrir
Ouvrir Fermés Faible Fermé (erreur)
Ouvrir Ouvrir Élevé Ouvrir
Ouvrir Ouvrir Low Fermé (erreur)

Les quatrième et dernière lignes du tableau ont été barrées dans le texte, car elles sont non valides. Le code que vous ajoutez maintenant doit s’assurer que la porte d’eau haute n’est jamais ouverte lorsque l’eau est basse. Ces états peuvent être codés en tant qu’expression de commutateur unique (n’oubliez pas que false indique « Fermé ») :

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, ce qui valide le code. Le tableau complet présente les combinaisons possibles d’entrées et de résultats. Cela signifie que vous et les autres développeurs pouvez rapidement consulter le tableau et voir que vous avez couvert toutes les entrées possibles. Encore plus facile, le compilateur peut également vous aider. Après avoir ajouté le code précédent, vous pouvez voir que le compilateur génère un avertissement : CS8524 indique que l’expression de commutateur ne couvre pas toutes les entrées possibles. La raison de cet avertissement est que l’une des entrées est un type enum. Le compilateur interprète « toutes les entrées possibles » comme toutes les entrées du type sous-jacent, généralement une int. Cette expression switch vérifie uniquement les valeurs déclarées dans l’enum. Pour supprimer l’avertissement, vous pouvez ajouter un modèle d’abandon fourre-tout pour la dernière branche de l’expression. Cette condition lève une exception, car elle indique une entrée non valide :

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

La branche de commutateur précédente doit être la dernière de votre expression switch, car elle correspond à toutes les entrées. Expérimentez en le déplaçant plus tôt dans l’ordre. Cela provoque une erreur du compilateur CS8510 pour le code inaccessible dans un modèle. La structure naturelle des expressions de commutateur permet au compilateur de générer des erreurs et des avertissements en cas d’erreurs possibles. Le « filet de sécurité » du compilateur vous permet de créer plus facilement du code correct en moins d’itérations, et vous donne la liberté de combiner des branches de commutateur avec des caractères génériques. Le compilateur émet des erreurs si votre combinaison entraîne des branches inaccessibles que vous n’attendiez pas, et des avertissements si vous supprimez une branche nécessaire.

Le premier changement consiste à combiner toutes les branches où la commande consiste à fermer la porte ; ceci est toujours autorisé. Ajoutez le code suivant comme première branche dans votre expression de commutateur :

(false, _, _) => false,

Après avoir ajouté la branche de commutateur précédente, vous obtenez quatre erreurs du compilateur, une sur chacune des branche où la commande est false. Ces branches sont déjà couvertes par la branche nouvellement ajoutée. Vous pouvez supprimer ces quatre lignes en toute sécurité. Vous avez prévu que cette nouvelle branche de commutateur remplace ces conditions.

Ensuite, vous pouvez simplifier les quatre branche où la commande est d’ouvrir la porte. Dans les deux cas où le niveau d’eau est haut, la porte peut être ouverte. (Dans l’un des cas, la porte est déjà ouverte.) Un cas où le niveau d’eau est bas lève une exception, et l’autre ne devrait pas se produire. Lever la même exception devrait être sûr, si l’écluse est déjà dans un état non valide. Vous pouvez effectuer les simplifications suivantes pour ces branches :

(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"),

Exécutez à nouveau vos tests, et ils réussissent. Voici la version finale de la méthode 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 des modèles vous-même

Maintenant que vous avez vu la technique, remplissez les méthodes SetLowGate et SetWaterLevel vous-même. Commencez par ajouter le code suivant pour tester des opérations non valides sur ces méthodes :

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}");

Exécutez de nouveau votre application. Vous pouvez voir que les nouveaux tests échouent et que l’écluse de canal est dans un état non valide. Essayez d’implémenter les méthodes restantes vous-même. La méthode permettant de définir la porte inférieure doit être similaire à la méthode permettant de définir la porte supérieure. La méthode qui modifie le niveau d’eau a des vérifications différentes, mais doit suivre une structure similaire. Il pourrait être utile d’utiliser le même processus pour la méthode qui définit le niveau d’eau. Commencez par les quatre entrées : l’état des deux portes, l’état actuel du niveau d’eau et le nouveau niveau d’eau demandé. L’expression de commutateur doit commencer par :

CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
    // elided
};

Vous aurez 16 branches de commutateur à remplir en tout. Ensuite, testez et simplifiez.

Avez-vous réalisé des méthodes ressemblant à cela ?

// 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 high gate when the water is low"),
        _ => 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 réussir et l’écluse du canal doit fonctionner en toute sécurité.

Résumé

Dans ce didacticiel, vous avez appris à utiliser la correspondance de modèle pour vérifier l’état interne d’un objet avant d’appliquer des modifications à cet état. Vous pouvez vérifier les combinaisons de propriétés. Une fois que vous avez créé des tableaux pour l’une de ces transitions, vous testez votre code, puis simplifiez la lisibilité et la maintenance. Ces refactorisations initiales peuvent suggérer d’autres refactorisations qui valident l’état interne ou gèrent d’autres modifications d’API. Ce didacticiel a combiné des classes et des objets avec une approche plus orientée données, basée sur des modèles, pour implémenter ces classes.