Użyj dopasowania wzorców, aby zbudować zachowanie klasy i poprawić kod.
Funkcje dopasowywania wzorców w języku C# zapewniają składnię do wyrażania algorytmów. Za pomocą tych technik można zaimplementować zachowanie w klasach. Projekt klasy zorientowanej obiektowo można połączyć z implementacją zorientowaną na dane, aby zapewnić zwięzły kod podczas modelowania obiektów w świecie rzeczywistym.
Z tego samouczka dowiesz się, jak wykonywać następujące działania:
- Wyrażanie klas zorientowanych na obiekty przy użyciu wzorców danych.
- Zaimplementuj te wzorce przy użyciu funkcji dopasowywania wzorców języka C#.
- Skorzystaj z diagnostyki kompilatora, aby zweryfikować implementację.
Warunki wstępne
Aby uruchomić platformę .NET, musisz skonfigurować maszynę. Pobierz visual Studio 2022 lub zestaw SDK platformy .NET .
Tworzenie symulacji blokady kanału
W tym samouczku utworzysz klasę języka C#, która symuluje blokadę kanału . Krótko mówiąc, blokada kanału jest urządzeniem, które podnosi i obniża łodzie podczas podróży między dwoma odcinkami wody na różnych poziomach. Śluza ma dwie bramy i jakiś mechanizm zmiany poziomu wody.
W normalnej pracy łódź wchodzi do jednej z bram, podczas gdy poziom wody w blokadzie pasuje do poziomu wody po stronie, do której wchodzi łódź. Po przejściu do blokady poziom wody jest zmieniany tak, aby był zgodny z poziomem wody, w którym łódź opuszcza blokadę. Gdy poziom wody dopasuje się do tej strony, brama po stronie wyjścia zostanie otwarta. Środki bezpieczeństwa zapewniają, że operator nie może stworzyć niebezpiecznej sytuacji w kanale. Poziom wody można zmienić tylko wtedy, gdy obie bramy są zamknięte. Co najwyżej jedna brama może być otwarta. Aby otworzyć bramę, poziom wody w blokadzie musi być zgodny z poziomem wody poza otwartą bramą.
Możesz utworzyć klasę języka C#, aby modelować to zachowanie. Klasa CanalLock
obsługuje polecenia otwierania lub zamykania bramy. Miałby inne polecenia, aby podnieść lub obniżyć wodę. Klasa powinna również obsługiwać atrybuty umożliwiające odczytywanie bieżącego stanu obu bram oraz poziomu wody. Twoje metody wdrażają środki bezpieczeństwa.
Definiowanie klasy
Utworzysz aplikację konsolową, aby przetestować klasę CanalLock
. Utwórz nowy projekt konsoli dla platformy .NET 5 przy użyciu programu Visual Studio lub interfejsu wiersza polecenia platformy .NET. Następnie dodaj nową klasę i nadaj jej nazwę CanalLock
. Następnie zaprojektuj publiczny interfejs API, ale pozostaw metody, które nie zostały zaimplementowane:
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}.";
}
Powyższy kod inicjuje obiekt tak, aby oba bramy zostały zamknięte, a poziom wody jest niski. Następnie napisz następujący kod testowy w metodzie Main
, aby poprowadzić Cię podczas tworzenia pierwszej implementacji klasy:
// 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}");
Następnie dodaj pierwszą implementację każdej metody w klasie CanalLock
. Poniższy kod implementuje metody klasy bez obaw dotyczących reguł bezpieczeństwa. Testy bezpieczeństwa zostaną dodane później:
// 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;
}
Testy, które napisałeś do tej pory, przeszły. Zaimplementowano podstawy. Teraz napisz test dla pierwszego warunku niepowodzenia. Na końcu poprzednich testów oba bramy są zamknięte, a poziom wody jest ustawiony na niski. Dodaj test, aby spróbować otworzyć górną bramę:
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}");
Ten test kończy się niepowodzeniem, ponieważ brama zostanie otwarta. Jako pierwsza implementacja można rozwiązać ten problem przy użyciu następującego kodu:
// 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");
}
Twoje testy przechodzą pomyślnie. Jednak w miarę dodawania kolejnych testów dodajesz więcej if
klauzul i testujesz różne właściwości. Wkrótce te metody są zbyt skomplikowane w miarę dodawania kolejnych warunkowych.
Implementowanie poleceń za pomocą wzorców
Lepszym sposobem jest użycie wzorców w celu określenia, czy obiekt jest w prawidłowym stanie do wykonania polecenia. Można wyrazić, czy polecenie jest dozwolone jako funkcja trzech zmiennych: stan bramy, poziom wody i nowe ustawienie:
Nowe ustawienie | Stan bramy | Poziom wody | Wynik |
---|---|---|---|
Zamknięty | Zamknięty | Wysoki | Zamknięty |
Zamknięty | Zamknięty | Niski | Zamknięty |
Zamknięty | Otwórz | Wysoki | Zamknięty |
|
|
|
|
Otwórz | Zamknięty | Wysoki | Otwórz |
Otwórz | Zamknięty | Niski | Zamknięte (błąd) |
Otwórz | Otwórz | Wysoki | Otwórz |
|
|
|
|
Czwarty i ostatni wiersz w tabeli są przekreślone, ponieważ są nieprawidłowe. Kod, który teraz dodajesz, powinien upewnić się, że górna brama wodna nigdy nie zostaje otwarta, gdy poziom wody jest niski. Stany te mogą być kodowane jako jedno wyrażenie przełącznika (pamiętaj, że false
wskazuje "Zamknięte"):
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
};
Wypróbuj tę wersję. Testy kończą się powodzeniem, walidacją kodu. W pełnej tabeli przedstawiono możliwe kombinacje danych wejściowych i wyników. Oznacza to, że ty i inni deweloperzy mogą szybko przyjrzeć się tabeli i zobaczyć, że zostały omówione wszystkie możliwe dane wejściowe. Jeszcze łatwiej, kompilator może również pomóc. Po dodaniu poprzedniego kodu widać, że kompilator generuje ostrzeżenie: CS8524 wskazuje, że wyrażenie przełącznika nie obejmuje wszystkich możliwych danych wejściowych. Przyczyną tego ostrzeżenia jest to, że jednym z danych wejściowych jest typ enum
. Kompilator interpretuje "wszystkie możliwe dane wejściowe" jako wszystkie dane wejściowe z typu bazowego, zazwyczaj int
. To wyrażenie switch
sprawdza tylko wartości zadeklarowane w enum
. Aby usunąć ostrzeżenie, możesz dodać wzorzec "catch-all" dla ostatniego ramienia wyrażenia. Ten warunek zgłasza wyjątek, ponieważ wskazuje nieprawidłowe dane wejściowe:
_ => throw new InvalidOperationException("Invalid internal state"),
Poprzednie ramię przełącznika musi być ostatnie w wyrażeniu switch
, ponieważ pasuje do wszystkich danych wejściowych. Eksperymentuj, przesuwając go wcześniej w kolejności. Powoduje to błąd kompilatora CS8510 z powodu nieosiągalnego kodu w konstrukcji wzorcowej. Naturalna struktura wyrażeń przełącznika umożliwia kompilatorowi generowanie błędów i ostrzeżeń dotyczących możliwych błędów. Kompilator zapewniający "siatkę bezpieczeństwa" ułatwia tworzenie poprawnego kodu w mniejszej liczbie iteracji oraz daje swobodę łączenia gałęzi przełącznika z symbolami wieloznacznymi. Kompilator zgłasza błędy, jeśli kombinacja powoduje niedostępną broń, której nie oczekiwano, i ostrzeżenia, jeśli usuniesz wymagane ramię.
Pierwsza zmiana polega na połączeniu wszystkich oddziałów, gdzie polecenie brzmi zamknąć bramę; jest to zawsze dozwolone. Dodaj następujący kod jako pierwsze ramię w wyrażeniu switch:
(false, _, _) => false,
Po dodaniu poprzedniego ramienia przełącznika otrzymasz cztery błędy kompilatora, po jednym na każdym z ramion, w których polecenie jest false
. Ramiona te są już pokryte nowo dodanym ramieniem. Możesz bezpiecznie usunąć te cztery wiersze. Zamierzałeś, aby nowe ramię przełącznika zastąpiło te warunki.
Następnie można uprościć cztery ramiona, w których polecenie ma otworzyć bramę. W obu przypadkach, gdy poziom wody jest wysoki, można otworzyć bramę. (W jednym z nich jest już otwarty). Jeden przypadek, w którym poziom wody jest niski, zgłasza wyjątek, a drugi nie powinien się zdarzyć. Można bezpiecznie zgłosić ten sam wyjątek, jeśli blokada wody jest już w nieprawidłowym stanie. Dla tych broni można wprowadzić następujące uproszczenia:
(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"),
Ponownie uruchom testy i przechodzą. Oto ostateczna wersja metody 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"),
};
}
Implementowanie wzorców samodzielnie
Teraz, gdy znasz już technikę, samodzielnie wypełnij metody SetLowGate
i SetWaterLevel
. Zacznij od dodania następującego kodu w celu przetestowania nieprawidłowych operacji na tych metodach:
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}");
Ponownie uruchom aplikację. Można zobaczyć, że nowe testy kończą się niepowodzeniem, a blokada kanału przechodzi w nieprawidłowy stan. Spróbuj samodzielnie zaimplementować pozostałe metody. Metoda ustawiania bramki dolnej powinna być podobna do metody ustawiania górnej bramki. Metoda, która zmienia poziom wody, ma różne kontrole, ale powinna być zgodna z podobną strukturą. Warto użyć tego samego procesu dla metody, która ustawia poziom wody. Zacznij od wszystkich czterech danych wejściowych: stan obu bram, bieżący stan poziomu wody i żądany nowy poziom wody. Wyrażenie przełącznika powinno zaczynać się od:
CanalLockWaterLevel = (newLevel, CanalLockWaterLevel, LowWaterGateOpen, HighWaterGateOpen) switch
{
// elided
};
Masz łącznie 16 ramion przełączników do wypełnienia. Następnie przetestuj i uprosz.
Czy utworzyłeś metody w coś podobnego do tego?
// 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"),
};
}
Testy powinny zostać wykonane, a blokada kanału powinna działać bezpiecznie.
Streszczenie
W tym samouczku nauczyłeś się używać dopasowania wzorca do sprawdzania stanu wewnętrznego obiektu przed wprowadzeniem jakichkolwiek zmian w tym stanie. Możesz sprawdzić kombinacje właściwości. Po utworzeniu tabel dla dowolnego z tych przejść przetestujesz kod, a następnie uprościsz czytelność i łatwość konserwacji. Te początkowe refaktoryzacje mogą wskazywać na dalsze refaktoryzacje, które weryfikują stan wewnętrzny lub dostosowują się do innych zmian interfejsu API. W tym samouczku połączono klasy i obiekty z bardziej zorientowanym na dane podejściem opartym na wzorcu w celu zaimplementowania tych klas.