Patroonkoppeling gebruiken om uw klassegedrag te bouwen voor betere code

De patroonkoppelingsfuncties in C# bieden syntaxis om uw algoritmen uit te drukken. U kunt deze technieken gebruiken om het gedrag in uw klassen te implementeren. U kunt objectgeoriënteerd klasseontwerp combineren met een gegevensgeoriënteerde implementatie om beknopte code te bieden tijdens het modelleren van echte objecten.

In deze zelfstudie leert u het volgende:

  • Uw objectgeoriënteerde klassen uitdrukken met behulp van gegevenspatronen.
  • Implementeer deze patronen met behulp van de patroonkoppelingsfuncties van C#.
  • Gebruik diagnostische compilergegevens om uw implementatie te valideren.

Vereisten

U moet uw computer instellen om .NET uit te voeren. Download Visual Studio 2022 of de .NET SDK.

Een simulatie van een kanaalslot bouwen

In deze zelfstudie bouwt u een C#-klasse die een kanaalslot simuleert. Kort gezegd, een kanaalslot is een apparaat dat boten ophekt en verlaagt tijdens het reizen tussen twee stukken water op verschillende niveaus. Een slot heeft twee poorten en een mechanisme om het waterniveau te wijzigen.

Bij de normale werking gaat een boot naar een van de poorten terwijl het waterniveau in de slot overeenkomt met het waterniveau aan de zijkant van de boot. Eenmaal in de vergrendeling wordt het waterniveau aangepast aan het waterniveau waar de boot de vergrendeling verlaat. Zodra het waterniveau aan die kant overeenkomt, wordt de poort aan de uitgangszijde geopend. Veiligheidsmaatregelen zorgen ervoor dat een operator geen gevaarlijke situatie in het kanaal kan creëren. Het waterniveau kan alleen worden gewijzigd wanneer beide poorten gesloten zijn. Maximaal één poort kan open zijn. Om een poort te openen, moet het waterniveau in de slot overeenkomen met het waterniveau buiten de poort die wordt geopend.

U kunt een C#-klasse bouwen om dit gedrag te modelleren. Een CanalLock klasse ondersteunt opdrachten om een poort te openen of te sluiten. Het zou andere opdrachten hebben om het water te verhogen of te verlagen. De klasse moet ook eigenschappen ondersteunen om de huidige toestand van zowel poorten als het waterniveau te lezen. Uw methoden implementeren de veiligheidsmaatregelen.

Een klasse definiëren

U bouwt een consoletoepassing om uw CanalLock klasse te testen. Maak een nieuw consoleproject voor .NET 5 met Visual Studio of de .NET CLI. Voeg vervolgens een nieuwe klasse toe en geef deze CanalLockeen naam. Ontwerp vervolgens uw openbare API, maar laat de methoden niet geïmplementeerd:

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

Met de voorgaande code wordt het object geïnitialiseerd, zodat beide poorten worden gesloten en het waterniveau laag is. Schrijf vervolgens de volgende testcode in uw Main methode om u te begeleiden bij het maken van een eerste implementatie van de klasse:

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

Voeg vervolgens een eerste implementatie van elke methode in de CanalLock klasse toe. Met de volgende code worden de methoden van de klasse geïmplementeerd zonder dat u zich zorgen hoeft te maken over de veiligheidsregels. U voegt later veiligheidstests toe:

// 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 tests die u tot nu toe hebt geschreven, zijn geslaagd. U hebt de basisbeginselen geïmplementeerd. Schrijf nu een test voor de eerste foutvoorwaarde. Aan het einde van de vorige tests zijn beide poorten gesloten en is het waterniveau ingesteld op laag. Voeg een test toe om de bovenste poort te openen:

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

Deze test mislukt omdat de poort wordt geopend. Als eerste implementatie kunt u deze oplossen met de volgende code:

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

Uw tests zijn geslaagd. Maar naarmate u meer tests toevoegt, voegt u meer en meer if componenten toe en test u verschillende eigenschappen. Deze methoden worden binnenkort te ingewikkeld wanneer u meer voorwaarden toevoegt.

De opdrachten implementeren met patronen

Een betere manier is om patronen te gebruiken om te bepalen of het object een geldige status heeft om een opdracht uit te voeren. U kunt uitdrukken als een opdracht is toegestaan als een functie van drie variabelen: de status van de poort, het niveau van het water en de nieuwe instelling:

Nieuwe instelling Poortstatus Waterniveau Resultaat
Gesloten Gesloten Hoog Gesloten
Gesloten Gesloten Beperkt Gesloten
Gesloten Startkoers Hoog Gesloten
Gesloten Open Laag Gesloten
Openen Gesloten Hoog Startkoers
Openen Gesloten Beperkt Gesloten (fout)
Startkoers Startkoers Hoog Startkoers
Open Open Laag Gesloten (fout)

De vierde en laatste rijen in de tabel hebben tekst doorhalen omdat ze ongeldig zijn. De code die u nu toevoegt, moet ervoor zorgen dat de hoge waterpoort nooit wordt geopend wanneer het water laag is. Deze statussen kunnen worden gecodeerd als één switchexpressie (onthoud dat false 'Gesloten' aangeeft):

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

Probeer deze versie. Uw tests slagen en valideren de code. In de volledige tabel ziet u de mogelijke combinaties van invoer en resultaten. Dat betekent dat u en andere ontwikkelaars snel naar de tabel kunnen kijken en zien dat u alle mogelijke invoer hebt behandeld. Nog eenvoudiger, de compiler kan ook helpen. Nadat u de vorige code hebt toegevoegd, ziet u dat de compiler een waarschuwing genereert: CS8524 geeft aan dat de switchexpressie niet alle mogelijke invoer omvat. De reden voor deze waarschuwing is dat een van de invoerwaarden een enum type is. De compiler interpreteert 'alle mogelijke invoer' als alle invoer van het onderliggende type, meestal een int. Met deze switch expressie worden alleen de waarden gecontroleerd die in de enumexpressie zijn gedeclareerd. Als u de waarschuwing wilt verwijderen, kunt u een catch-all-verwijderingspatroon toevoegen voor de laatste arm van de expressie. Deze voorwaarde genereert een uitzondering, omdat deze ongeldige invoer aangeeft:

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

De voorgaande schakelarm moet voor het laatst in de switch expressie staan, omdat deze overeenkomt met alle invoerwaarden. Experimenteer door het eerder in de volgorde te verplaatsen. Dit veroorzaakt een compilerfout CS8510 voor onbereikbare code in een patroon. Met de natuurlijke structuur van switchexpressies kan de compiler fouten en waarschuwingen genereren voor mogelijke fouten. Met de compiler 'safety net' kunt u eenvoudiger juiste code maken in minder iteraties en de vrijheid om schakelarmen met jokertekens te combineren. De compiler veroorzaakt fouten als uw combinatie resulteert in onbereikbare armen die u niet had verwacht en waarschuwingen als u een benodigde arm verwijdert.

De eerste wijziging is het combineren van alle armen waar de opdracht is om de poort te sluiten; dat is altijd toegestaan. Voeg de volgende code toe als de eerste arm in uw switchexpressie:

(false, _, _) => false,

Nadat u de vorige schakelarm hebt toegevoegd, krijgt u vier compilerfouten, één op elk van de armen waarin de opdracht zich bevindt false. Die armen zijn al bedekt door de nieuw toegevoegde arm. U kunt deze vier regels veilig verwijderen. U bedoelde deze nieuwe schakelarm om deze voorwaarden te vervangen.

Vervolgens kunt u de vier armen vereenvoudigen waar het commando is om de poort te openen. In beide gevallen waar het waterniveau hoog is, kan de poort worden geopend. (In een ervan is het al geopend.) Een geval waarin het waterniveau laag is, werpt een uitzondering op en de andere mag niet gebeuren. Het moet veilig zijn om dezelfde uitzondering te gooien als de watervergrendeling al een ongeldige status heeft. U kunt de volgende vereenvoudigingen voor deze armen maken:

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

Voer uw tests opnieuw uit en ze slagen. Dit is de laatste versie van de SetHighGate methode:

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

Patronen zelf implementeren

Nu u de techniek hebt gezien, vult u de SetLowGate en SetWaterLevel methoden zelf in. Voeg eerst de volgende code toe om ongeldige bewerkingen op deze methoden te testen:

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

Voer uw toepassing opnieuw uit. U kunt zien dat de nieuwe tests mislukken en dat de kanaalvergrendeling een ongeldige status krijgt. Probeer zelf de resterende methoden te implementeren. De methode voor het instellen van de onderste poort moet vergelijkbaar zijn met de methode om de bovenste poort in te stellen. De methode waarmee het waterniveau verandert, heeft verschillende controles, maar moet een vergelijkbare structuur volgen. Het kan handig zijn om hetzelfde proces te gebruiken voor de methode waarmee het waterniveau wordt ingesteld. Begin met alle vier de ingangen: de toestand van beide poorten, de huidige toestand van het waterniveau en het aangevraagde nieuwe waterniveau. De switchexpressie moet beginnen met:

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

U hebt 16 totale schakelarmen om in te vullen. Test en vereenvoudig vervolgens.

Heb je methoden zoiets gedaan?

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

Uw tests moeten slagen en de gracht moet veilig werken.

Samenvatting

In deze zelfstudie hebt u geleerd om patroonkoppeling te gebruiken om de interne status van een object te controleren voordat u wijzigingen toepast op die status. U kunt combinaties van eigenschappen controleren. Zodra u tabellen voor een van deze overgangen hebt gemaakt, test u uw code en vereenvoudigt u de leesbaarheid en onderhoudbaarheid. Deze initiële herstructureringen kunnen verdere herstructureringen voorstellen die interne status valideren of andere API-wijzigingen beheren. In deze zelfstudie zijn klassen en objecten gecombineerd met een meer gegevensgeoriënteerde, patroongebaseerde benadering om deze klassen te implementeren.