Självstudie: Grunderna för mönstermatchning

Tip

Är du nybörjare på att utveckla programvara? Börja med självstudierna Komma igång först. De introducerar klasser, metoder och kontrollflöde.

Har du erfarenhet av ett annat språk? Den här självstudien visar hur C#-mönster tydligt kan uttrycka objektbeteende när regler är beroende av objektets aktuella tillstånd.

I den här självstudien skapar du en konsolapp som modellerar reglerna för ett kanallås.

En sluss höjer eller sänker båtar mellan två vattensträckor på olika höjder. Den har två slussportar och en kammare vars vattennivå ändras mellan en låg inställning och en hög inställning. Slussen kan endast användas på ett säkert sätt när vattennivån och portarnas lägen förblir i giltiga kombinationer.

I den här tutorialen lär du dig följande:

  • Uttryck objektbeteende genom att matcha på tillstånd.
  • Implementera dessa regler med C#-mönstermatchning.
  • Använd kompilatordiagnostik för att validera din implementering.

Prerequisites

Installationsanvisningar

På Windows används den här WinGet-konfigurationsfilen för att installera alla krav. Om du redan har något installerat hoppar WinGet över det steget.

  1. Ladda ned filen och dubbelklicka för att köra den.
  2. Läs licensavtalet, skriv yoch välj Ange när du uppmanas att acceptera.
  3. Om du får en flashande UAC-fråga (User Account Control) i aktivitetsfältet tillåter du att installationen fortsätter.

På andra plattformar måste du installera var och en av dessa komponenter separat.

  1. Ladda ned det rekommenderade installationsprogrammet från nedladdningssidan .NET SDK och dubbelklicka för att köra det. Nedladdningssidan identifierar din plattform och rekommenderar den senaste installationsprogrammet för din plattform.
  2. Ladda ned det senaste installationsprogrammet från startsidan Visual Studio Code och dubbelklicka för att köra det. Den sidan identifierar även din plattform och länken bör vara korrekt för ditt system.
  3. Klicka på knappen "Installera" på sidan C# DevKit tillägg. Då öppnas Visual Studio-kod och du tillfrågas om du vill installera eller aktivera tillägget. Välj "installera".

Skapa en simulering av ett kanallås

En sluss höjer och sänker båtar mellan vattenvägar på olika nivåer. I den här handledningen har det simulerade låset en nedre grind, en övre grind och vatten som kan vara lågt eller högt.

Vid normal drift går en båt in när vattennivån inuti slussen matchar nivån på ingångssidan. När båten är inne stänger båda portarna. Vattennivån ändras så att den matchar utgångssidan och sedan öppnas utgångsporten. För att hålla modellen säker kan vattennivån bara ändras när båda portarna är stängda, och en port kan bara öppnas när vattennivån matchar nivån på den sidan.

Du kan modellera dessa regler med en CanalLock klass. Den tillhandahåller kommandon för att öppna eller stänga endera grinden och för att höja eller sänka vattnet. Den visar också attribut som rapporterar låsets aktuella tillstånd.

Definiera klassen

Skapa ett konsolprojekt och lägg sedan till en klass med namnet CanalLock. Börja med att utforma det offentliga API:et och lämna metoderna oimplementerade:

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 slussen med båda portarna stängda och vattennivån låg. Lägg sedan till följande kod i Main som vägledning för din första implementering:

// 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 nu till en första implementering som ändrar varje tillståndsvärde utan att tillämpa säkerhetsreglerna:

// 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 här första kontrollerna går igenom. Du har mekaniken som fungerar. Lägg sedan till ett test för det första feltillståndet. I slutet av föregående sekvens är båda portarna stängda och vattennivån är låg. Försök att ö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 testet misslyckas eftersom den övre grinden öppnas när den inte borde det. En första korrigering kan se ut så här:

// 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 klaras igen. Men när du lägger till fler villkor ackumulerar du fler if-satser. Koden blir svårare att skanna eftersom varje regel är separerad från de andra.

Implementera kommandona med mönster

Ett tydligare alternativ är att använda mönster för att beskriva de giltiga kombinationerna direkt. I nästa steg använder varje switch-uttryck en tuppel som mönsterindata. C# utvärderar tuplen en gång, och varje switch-gren testar slussens tillstånd, vattennivån och den begärda nya inställningen.

För den övre gaten kan du sammanfatta dessa kombinationer så här:

Ny inställning Portstatus Vattennivå Result
Stängd Stängd High Stängd
Stängd Stängd Låg Stängd
Stängd Open High Stängd
Stängt Öppna Low Stängt
Open Stängd High Open
Open Stängd Låg Stängd (fel)
Open Open High Open
Öppna Öppna Low Avslutad (fel)

De genomstrukna raderna representerar ogiltiga interna tillstånd. Switch-uttrycket kan koda de giltiga övergångarna direkt. false betyder fortfarande att grinden är 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 gick igenom. Kompilatorn varnar också för att växlingsuttrycket inte är uttömmande – det täcker inte alla möjliga värden – eftersom WaterLevel är en enum, och C# tillåter att alla värden i enumens underliggande numeriska typ typkonverteras till den enummen, även ett utan en namngiven medlem. Lägg till en sista arm med kasseringsmönstret (_) för att hantera dessa omöjliga interna tillstånd:

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

Den armen måste vara sist eftersom discard-mönstret matchar alla återstående indata.

Du kan sedan förenkla de tidigare grenarna. Det är alltid tillåtet att stänga porten, så att en arm kan ersätta de fyra separata stängda fallen:

(false, _, _) => false,

Du kan också kombinera giltiga öppna ärenden och bevara det enda säkerhetsrelaterade felet:

(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 programmet igen. Testerna lyckas fortfarande. Här är den slutliga SetHighGate implementeringen:

// 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 de återstående reglerna

Tillämpa nu samma idé på SetLowGate och SetWaterLevel. Börja med att lägga till tester som avslöjar ogiltiga operationer:

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 appen igen. Dessa tester misslyckas och kanalspärren når ogiltiga tillstånd. Implementera de återstående metoderna genom mönstermatchning mot hela tillståndet. SetLowGate liknar SetHighGate. SetWaterLevel använder den aktuella vattennivån plus båda portpositionerna:

CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
    // arms go here
};

Du har 16 kombinationer att tänka på. Börja med den fullständiga tabellen, skriv switch-grenarna, kör testerna och förenkla sedan upprepade utfall.

Fick du metoder som liknar dessa?

// 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 nu lyckas, och slussen bör tillämpa sina säkerhetsregler.

Sammanfattning

I den här självstudien använde du mönstermatchning för att uttrycka hur ett objekt kan ändras från ett giltigt tillstånd till ett annat giltigt tillstånd. Mönstren höll ihop de tillåtna övergångarna så att du enklare kunde jämföra dem än med en lång serie förgreningsuttryck. Det här angreppssättet fungerar bra när beteendet beror på den kombinerade formen för flera tillståndsvärden.