Använda mönstermatchning för att skapa klassbeteendet för bättre kod

Mönstermatchningsfunktionerna i C# ger syntax för att uttrycka dina algoritmer. Du kan använda dessa tekniker för att implementera beteendet i dina klasser. Du kan kombinera objektorienterad klassdesign med en dataorienterad implementering för att ge koncis kod vid modellering av verkliga objekt.

I den här självstudien får du lära dig att:

  • Uttrycka objektorienterade klasser med hjälp av datamönster.
  • Implementera dessa mönster med hjälp av C#:s mönstermatchningsfunktioner.
  • Använd kompilatordiagnostik för att verifiera implementeringen.

Förutsättningar

Du måste konfigurera datorn för att köra .NET. Ladda ned Visual Studio 2022 eller .NET SDK.

Skapa en simulering av ett kanallås

I den här självstudien skapar du en C#-klass som simulerar ett kanallås. Kortfattat är ett kanallås en enhet som höjer och sänker båtar när de färdas mellan två vattensträckor på olika nivåer. Ett lås har två portar och en mekanism för att ändra vattennivån.

I sin normala drift går en båt in i en av grindarna medan vattennivån i låset matchar vattennivån på sidan som båten kommer in i. Väl i låset ändras vattennivån för att matcha vattennivån där båten lämnar låset. När vattennivån matchar den sidan öppnas grinden på utgångssidan. Valv ty åtgärder se till att en operatör inte kan skapa en farlig situation i kanalen. Vattennivån kan bara ändras när båda portarna är stängda. Högst en grind kan vara öppen. För att öppna en grind måste vattennivån i låset matcha vattennivån utanför grinden som öppnas.

Du kan skapa en C#-klass för att modellera det här beteendet. En CanalLock klass stöder kommandon för att öppna eller stänga någon av portarna. Det skulle ha andra kommandon för att höja eller sänka vattnet. Klassen bör också ha stöd för egenskaper för att läsa det aktuella tillståndet för både portar och vattennivån. Dina metoder implementerar säkerhetsåtgärderna.

Definiera en klass

Du skapar ett konsolprogram för att testa klassen CanalLock . Skapa ett nytt konsolprojekt för .NET 5 med antingen Visual Studio eller .NET CLI. Lägg sedan till en ny klass och ge den CanalLocknamnet . Utforma sedan ditt offentliga API, men låt metoderna inte implementeras:

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

Föregående kod initierar objektet så att båda portarna stängs och vattennivån är låg. Skriv sedan följande testkod i din Main metod för att vägleda dig när du skapar en första implementering av klassen:

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

Lägg sedan till en första implementering av varje metod i CanalLock klassen. Följande kod implementerar klassmetoderna utan att säkerhetsreglerna påverkas. Du lägger till säkerhetstester senare:

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

De tester som du har skrivit hittills har godkänts. Du har implementerat grunderna. Skriv nu ett test för det första feltillståndet. I slutet av de tidigare testerna stängs båda portarna och vattennivån är inställd på låg. Lägg till ett test för att försöka öppna den övre grinden:

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

Det här testet misslyckas eftersom grinden öppnas. Som en första implementering kan du åtgärda det med följande kod:

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

Dina tester godkänns. Men när du lägger till fler tester lägger du till fler och fler if satser och testar olika egenskaper. Snart blir dessa metoder för komplicerade när du lägger till fler villkor.

Implementera kommandona med mönster

Ett bättre sätt är att använda mönster för att avgöra om objektet är i ett giltigt tillstånd för att köra ett kommando. Du kan uttrycka om ett kommando tillåts som en funktion av tre variabler: portens tillstånd, vattennivån och den nya inställningen:

Ny inställning Grindtillstånd Vattennivå Resultat
Stängt Stängt Högt Stängt
Stängt Stängt Låg Stängt
Stängt Öppnats Högt Stängt
Stängt Ledig Låg Stängt
Öppet Stängda Högt Öppnats
Öppet Stängda Låg Stängd (fel)
Öppnats Öppnats Högt Öppnats
Ledig Ledig Låg Stängd (fel)

De fjärde och sista raderna i tabellen har genomslag genom text eftersom de är ogiltiga. Koden du lägger till nu bör se till att högvattenporten aldrig öppnas när vattnet är lågt. Dessa tillstånd kan kodas som ett enda växeluttryck (kom ihåg att false anger "Stängd"):

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

Prova den här versionen. Dina tester godkänns och koden verifieras. Den fullständiga tabellen visar möjliga kombinationer av indata och resultat. Det innebär att du och andra utvecklare snabbt kan titta på tabellen och se att du har gått igenom alla möjliga indata. Kompilatorn kan också hjälpa till ännu enklare. När du har lagt till den tidigare koden kan du se att kompilatorn genererar en varning: CS8524 anger att switch-uttrycket inte täcker alla möjliga indata. Orsaken till varningen är att en av indata är en enum typ. Kompilatorn tolkar "alla möjliga indata" som alla indata från den underliggande typen, vanligtvis en int. Det här switch uttrycket kontrollerar endast de värden som deklareras enumi . Om du vill ta bort varningen kan du lägga till ett "catch-all"-mönster för uttryckets sista arm. Det här villkoret utlöser ett undantag eftersom det anger ogiltiga indata:

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

Föregående växelarm måste vara sist i uttrycket switch eftersom den matchar alla indata. Experimentera genom att flytta det tidigare i ordningen. Det orsakar ett kompilatorfel CS8510 för oåtkomlig kod i ett mönster. Den naturliga strukturen för växeluttryck gör det möjligt för kompilatorn att generera fel och varningar för eventuella misstag. Kompilatorns "säkerhetsnät" gör det enklare för dig att skapa rätt kod i färre iterationer och friheten att kombinera växlingsarmar med jokertecken. Kompilatorn utfärdar fel om kombinationen resulterar i oåtkomliga armar som du inte förväntade dig och varningar om du tar bort en arm som behövs.

Den första ändringen är att kombinera alla armar där kommandot är att stänga grinden; det är alltid tillåtet. Lägg till följande kod som den första armen i ditt switch-uttryck:

(false, _, _) => false,

När du har lagt till föregående växelarm får du fyra kompilatorfel, ett på var och en av armarna där kommandot är false. Dessa armar är redan täckta av den nyligen tillagda armen. Du kan ta bort dessa fyra rader på ett säkert sätt. Du har tänkt att den här nya switcharmen ska ersätta dessa villkor.

Därefter kan du förenkla de fyra armarna där kommandot är att öppna grinden. I båda fallen där vattennivån är hög kan grinden öppnas. (I en är den redan öppen.) Ett fall där vattennivån är låg utlöser ett undantag, och det andra bör inte inträffa. Det bör vara säkert att utlösa samma undantag om vattenlåset redan är i ett ogiltigt tillstånd. Du kan göra följande förenklingar för dessa vapen:

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

Kör testerna igen och de godkänns. Här är den slutliga versionen av SetHighGate metoden:

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

Implementera mönster själv

Nu när du har sett tekniken fyller du i SetLowGate metoderna och SetWaterLevel själv. Börja med att lägga till följande kod för att testa ogiltiga åtgärder på dessa metoder:

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

Kör programmet igen. Du kan se att de nya testerna misslyckas och kanallåset hamnar i ett ogiltigt tillstånd. Försök att implementera de återstående metoderna själv. Metoden för att ställa in den nedre grinden bör likna metoden för att ange den övre grinden. Metoden som ändrar vattennivån har olika kontroller, men bör följa en liknande struktur. Det kan vara bra att använda samma process för metoden som anger vattennivån. Börja med alla fyra indata: Tillståndet för båda portarna, vattennivåns aktuella tillstånd och den begärda nya vattennivån. Switch-uttrycket bör börja med:

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

Du kommer att ha 16 brytare armar att fylla i. Testa och förenkla sedan.

Har du gjort metoder så här?

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

Dina tester bör passera, och kanallåset bör fungera säkert.

Sammanfattning

I den här självstudien har du lärt dig att använda mönstermatchning för att kontrollera objektets interna tillstånd innan du tillämpar några ändringar i det tillståndet. Du kan kontrollera kombinationer av egenskaper. När du har skapat tabeller för någon av dessa övergångar testar du koden och förenklar sedan för läsbarhet och underhåll. Dessa inledande refaktoriseringar kan föreslå ytterligare refaktoriseringar som validerar internt tillstånd eller hanterar andra API-ändringar. Den här självstudien kombinerade klasser och objekt med en mer dataorienterad, mönsterbaserad metod för att implementera dessa klasser.