Utilisez la correspondance de modèles pour façonner le comportement de votre classe et ainsi améliorer la qualité du 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 allez apprendre à :
- 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 des modèles C#.
- Tirez parti des diagnostics du compilateur pour valider votre implémentation.
Conditions préalables
Vous devez configurer votre machine pour exécuter .NET. Téléchargez visual Studio 2022 ou le kit sdk .NET .
Créer une simulation d’un verrou de canal
Dans ce tutoriel, vous créez une classe C# qui simule un verrou de canal . Bref, un verrou de canal est un dispositif qui élève et abaisse les bateaux au fur et à mesure qu’ils se déplacent 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 pendant que le niveau d’eau dans l’écluse correspond au niveau de l’eau du côté où le bateau entre. Une fois dans le verrou, le niveau d’eau est modifié pour correspondre au niveau d’eau où le bateau quitte 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é s’assurent qu’un opérateur ne peut pas créer une 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 classe CanalLock
prend en charge les commandes pour ouvrir ou fermer l’une ou l’autre porte. Il 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 créez une application console pour tester votre classe CanalLock
. 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 afin que les deux portes soient fermées et que le niveau d’eau soit faible. 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 s’inquiéter des règles de sécurité. Vous ajoutez 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 réalisés jusqu'à présent sont réussis. 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 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. En guise de 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 sont réussis. Toutefois, lorsque vous ajoutez d’autres tests, vous ajoutez d’autres clauses if
et testez différentes propriétés. Bientôt, ces méthodes sont trop compliquées lorsque vous ajoutez d’autres conditions.
Implémenter les commandes avec des modèles
Il est préférable d’utiliser 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ésultat |
---|---|---|---|
Fermé | Fermé | Élevé | Fermé |
Fermé | Fermé | Faible | Fermé |
Fermé | Ouvrir | Élevé | Fermé |
|
|
|
|
Ouvrir | Fermé | Élevé | Ouvrir |
Ouvrir | Fermé | Faible | Fermé (erreur) |
Ouvrir | Ouvrir | Élevé | Ouvrir |
|
|
|
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 vanne haute n'est jamais ouverte lorsque le niveau d'eau est bas. 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 montre les combinaisons possibles d’entrées et de résultats. Cela signifie que vous et d’autres développeurs pouvez 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 switch 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 le 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"),
Le bras de commutateur précédent doit être le dernier de votre expression switch
, car il correspond à toutes les entrées. Expérimentez-le en le déplaçant plus tôt dans l’ordre. Cela provoque une erreur du compilateur CS8510 pour du 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 éventuelles. Le compilateur « filet de sécurité » facilite la création d’un code correct en moins d’itérations, tout en offrant la liberté de combiner les branches du commutateur avec des caractères génériques. Le compilateur émet des erreurs si votre combinaison entraîne des bras inaccessibles que vous n’attendiez pas et des avertissements si vous supprimez un bras nécessaire.
Le premier changement consiste à combiner toutes les armes où la commande consiste à fermer la porte ; c’est toujours autorisé. Ajoutez le code suivant comme première branche dans votre expression switch :
(false, _, _) => false,
Après avoir ajouté le bras de commutateur précédent, vous obtenez quatre erreurs de compilateur, une sur chacune des armes où la commande est false
. 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 ce nouveau levier de commutation pour remplacer ces paramètres.
Ensuite, vous pouvez simplifier les quatre bras où la commande consiste à ouvrir la porte. Dans les deux cas où le niveau d’eau est élevé, 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 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 ; 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 vous-même des modèles
Maintenant que vous avez vu la technique, complétez les méthodes SetLowGate
et SetWaterLevel
vous-même. 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 est dans 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 change 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 avez 16 branches de commutateur à remplir en tout. Ensuite, testez et simplifiez.
Avez-vous conçu des méthodes de cette manière ?
// 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 réussir, et le verrou du canal doit fonctionner en toute sécurité.
Résumé
Dans ce tutoriel, vous avez appris à utiliser des critères correspondants 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 didacticiel a combiné 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.