Utiliser la correspondance de modèle pour générer votre comportement de classe pour 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 pour fournir du code concis tout en modélisant des objets réels.

Dans ce tutoriel, vous apprendrez à :

  • Exprimez vos classes orientées objets à 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 devez configurer votre ordinateur pour exécuter .NET 5, y compris le compilateur C# 9. Le compilateur C# 9 est disponible à partir de Visual Studio 2019 version 16.8 ou du Kit de développement logiciel (SDK) .NET 5.

Créer une simulation d’un verrou de canal

Dans ce tutoriel, vous allez créer une classe C# qui simule un verrou de canal. Bref, un verrou de canal est un dispositif qui soulève et réduit les bateaux car ils voyagent entre deux étendues d’eau à différents niveaux. Un verrou a deux portes et un mécanisme pour changer le niveau d’eau.

Dans son fonctionnement normal, un bateau entre dans l’une des portes tandis que le niveau d’eau dans le verrou correspond au niveau de l’eau sur le côté du bateau entre. Une fois dans le verrou, le niveau d’eau est modifié pour correspondre au niveau de l’eau où le bateau quittera le verrou. Une fois que le niveau d’eau correspond à ce côté, la porte du côté de 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 d’eau ne peut être modifié que lorsque les deux portes sont fermées. Au plus une porte peut être ouverte. Pour ouvrir une porte, le niveau d’eau dans le verrou doit correspondre au niveau de l’eau en dehors de la porte ouverte.

Vous pouvez créer une classe C# pour modéliser ce comportement. Une CanalLock classe prend en charge les commandes pour ouvrir ou fermer une porte. Il aurait d’autres commandes pour élever ou réduire l’eau. La classe doit également prendre en charge les propriétés pour lire l’état actuel des portes et du niveau d’eau. Vos méthodes implémentent les mesures de sécurité.

Définir une classe

Vous allez créer une application console pour tester votre CanalLock classe. Créez un projet de console pour .NET 5 à l’aide de Visual Studio ou de l’interface CLI .NET. Ensuite, 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 de sorte que les deux portes sont fermées et que le niveau d’eau est faible. Ensuite, écrivez le code de test suivant dans votre Main méthode 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 CanalLock classe. Le code suivant implémente les méthodes de la classe sans s’inquiéter des règles de sécurité. Vous allez ajouter des tests de sécurité plus tard :

// 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 passent. 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 d’eau est fixé à faible. 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. Comme première implémentation, vous pouvez le 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, lorsque vous ajoutez d’autres tests, vous allez ajouter plus if de clauses et tester différentes propriétés. Bientôt, ces méthodes seront trop compliquées lorsque vous ajoutez d’autres conditions.

Implémenter les commandes avec des modèles

Une meilleure façon consiste à 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 d’eau Résultats
Fermé Fermé Élevé Fermé
Fermé Fermé Faible Fermé
Fermé 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ères lignes de la table ont frappé le texte parce qu’ils ne sont pas valides. Le code que vous ajoutez doit maintenant s’assurer que la porte d’eau élevée n’est jamais ouverte lorsque l’eau est faible. Ces états peuvent être codés en tant qu’expression de commutateur unique (n’oubliez pas que false « 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, en validant le code. Le tableau complet présente les combinaisons possibles d’entrées et de résultats. Cela signifie que vous et d’autres développeurs peuvent rapidement examiner la table et voir que vous avez couvert toutes les entrées possibles. Même 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 enum type. Le compilateur interprète « toutes les entrées possibles » comme toutes les entrées du type sous-jacent, généralement un int. Cette switch expression vérifie uniquement les valeurs déclarées dans le enum. Pour supprimer l’avertissement, vous pouvez ajouter un modèle d’abandon catch-all pour le dernier bras de l’expression. Cette condition lève une exception, car elle indique une entrée non valide :

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

Le bras de commutateur précédent doit être le dernier dans votre switch expression, car il 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 un code inaccessible dans un modèle. La structure naturelle des expressions switch permet au compilateur de générer des erreurs et des avertissements pour les erreurs possibles. Le compilateur « safety net » facilite la création d’un code correct dans moins d’itérations, et la liberté de combiner des bras de commutateur avec des caractères génériques. Le compilateur émet des erreurs si votre combinaison entraîne des armes inaccessibles que vous n’attendez pas et des avertissements si vous supprimez un bras nécessaire.

Le premier changement consiste à combiner toutes les armes où la commande doit fermer la porte; c’est toujours autorisé. Ajoutez le code suivant comme premier bras dans votre expression switch :

(false, _, _) => false,

Après avoir ajouté le bras de commutateur précédent, vous obtiendrez quatre erreurs du compilateur, une sur chacune des armes où se trouve falsela commande. Ces bras sont déjà couverts par le bras nouvellement ajouté. Vous pouvez supprimer ces quatre lignes en toute sécurité. Vous avez prévu que ce nouveau bras de commutateur remplace ces conditions.

Ensuite, vous pouvez simplifier les quatre bras où la commande est d’ouvrir la porte. Dans les deux cas où le niveau d’eau est élevé, la porte peut être ouverte. (En un, il est déjà ouvert.) Un cas où le niveau d’eau est faible lève une exception, et l’autre ne doit pas se produire. Il doit être sûr de lever la même exception si le verrou d’eau est déjà dans un état non valide. Vous pouvez effectuer les simplifications suivantes pour ces bras :

(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 vos tests, et ils réussissent. Voici la version finale de la SetHighGate méthode :

// 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 vous-même des modèles

Maintenant que vous avez vu la technique, renseignez vous-même les méthodes et SetWaterLevel les SetLowGate méthodes. Commencez par ajouter le code suivant pour tester les 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}");

Réexécutez votre application. Vous pouvez voir que les nouveaux tests échouent et que le verrou de canal passe à un état non valide. Essayez d’implémenter vous-même les méthodes restantes. La méthode permettant de définir la porte inférieure doit être similaire à la méthode pour définir la porte supérieure. La méthode qui modifie le niveau d’eau a des contrôles différents, mais doit suivre une structure similaire. Vous pouvez trouver 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 switch doit commencer par :

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

Vous aurez 16 bras de changement total pour remplir. Ensuite, testez et simplifiez.

Avez-vous fait des méthodes comme ça ?

// 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 le verrou de canal doit fonctionner en toute sécurité.

Résumé

Dans ce tutoriel, vous avez appris à utiliser des critères spéciaux 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 tables 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 tutoriel combine des classes et des objets avec une approche plus orientée données et basée sur des modèles pour implémenter ces classes.