Dela via


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 handledningen kommer 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

Skapa en simulering av ett kanallås

I den här handledningen 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. Säkerhetsåtgärder ser 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 din CanalLock-klass. 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 namnet CanalLock. 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 som vägleder 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 klassen CanalLock. 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;
}

Testerna du har skrivit hittills har godkänts. Du implementerade 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 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ängd Stängd Högt Stängd
Stängd Stängd Låg Stängd
Stängd Öppna Högt Stängd
Stängd Öppna Låg Stängd
Öppna Stängd Högt Öppna
Öppna Stängd Låg Stängd (fel)
Öppna Öppna Högt Öppna
Öppna Öppna Låg Stängt (fel)

Den fjärde och sista raden i tabellen har överstruken text eftersom den är ogiltig. 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. Ännu enklare är det att få hjälp av kompilatorn. 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 inmatningarna ä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 bara de värden som deklareras i enum. Om du vill ta bort varningen kan du lägga till ett "catch-all"-avvisningsmö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 ditt switch uttryck 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 möjligheten att kombinera växlingsarmar med jokertecken. Kompilatorn utfärdar fel om din kombination 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. Det var avsett att den här nya switcharmen skulle 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 metoden 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"),
    };
}

Implementera mönster själv

Nu när du har sett tekniken fyller du i SetLowGate och SetWaterLevel metoder 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 applikationen 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 har 16 brytarmar totalt att fylla i. Testa och förenkla sedan.

Har du skapat metoder på det här sättet?

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

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 metod som är mer dataorienterad och mönsterbaserad för att implementera dessa klasser.