Nowy switch w C# 8.0. Jak działa Property Pattern
Jako programista .NET bardzo cieszę się z tego, że Microsoft obok rozwoju całej platformy .NET dodaje również nowe rzeczy do samego języka C#. Wszystko po to, aby nam, programistom, pracowało się jeszcze łatwiej i efektywniej – choć nie zawsze wprowadzone mechanizmy wydają się fajne i się je wykorzystuje.
Przykładowo pattern matching dodany do switch w C# 7 (w dalszej części wpisu wyjaśniam o co chodzi) jakoś nie przypadł mi do gustu i osobiście z niego nigdy nie skorzystałem. Kiedyś nawet opisywałem we wpisie o zastąpieniu rozbudowanego switcha w aplikacji, że nie jestem fanem używania tej konstrukcji w kodzie. Ale może po zmianach w switch w C# 8 zacznę go lubić. Kto wie – pożyjemy, zobaczymy.
Spis treści
Switch w C#
Na początku zaczniemy sobie od przypomnienia, jak od zawsze wyglądał switch w C# (dla tych, którzy go nie używają). Ponadto przykład posłuży nam do porównania zmian, jakie nastąpiły w C# 8.
Na potrzeby dema przygotowałem prostą klasę Expression, która ma reprezentować operację matematyczną do wykonania. Klasa ma trzy właściwości. Jedną (Operator) dla operacji do wykonania oraz dwie (Arg1, Arg2) dla argumentów, dla których należy wykonać operację:
public class Expression { public string Operator { get; set; } public int Arg1 { get; set; } public int Arg2 { get; set; } public override string ToString() { return $"{Arg1} {Operator} {Arg2}"; } }
Następnie moglibyśmy użyć switcha do implementacji metody, która na podstawie operatora wykonywałaby odpowiednią operację:
class OperationDemo { public void Run() { var expression = new Expression() { Arg1 = 10, Arg2 = 5, Operator = "/" }; CSharp1(expression); CSharp7(expression); CSharp8(expression); CSharp8Property(expression); } void CSharp1(Expression ex) { int result = 0; switch (ex.Operator) { case "+": result = ex.Arg1 + ex.Arg2; break; case "/": if (ex.Arg2 == 0) { throw new ArgumentException("Arg2 cannot be 0"); } result = ex.Arg1 / ex.Arg2; break; default: throw new InvalidOperationException(); } Console.WriteLine($"{ex} = {result}"); } }
W powyższej implementacji zaimplementowałem tylko dwa operatory po to, aby za bardzo nie rozbudowywać kodu. Specjalnie zaimplementowałem dzielenie, aby w case dla niego dodać dodatkowy warunek (dla dzielenia przez zero), który posłuży nam do testowania rzeczy dodanych w nowszych wersjach C#.
W przypadku realnej aplikacji w powyższym kodzie prawdopodobnie nie wykorzystałbym switcha, tylko użyłbym podejścia opisanego we wspomnianym wyżej wpisie o zastąpieniu rozbudowanego switcha w aplikacji. Nie chciałbym musieć rozbudowywać tego switcha w momencie dodawania kolejnych operatorów w aplikacji. Ale ten przykład jest na tyle prosty, że łatwo go zrozumieć, by następnie móc omawiać zmiany w switchach.
Switch w C# 7
W siódmej wersji C# Microsoft rozbudował trochę możliwości switchów. Dodał Pattern Matching, który umożliwia budowanie switcha w nieco inny sposób. Najlepiej od razu zobaczyć zmieniony powyższy kod, który uwzględnia nową składnię:
void CSharp7(object o) { int result = 0; switch (o) { case Expression ex when ex.Operator == "+": result = ex.Arg1 + ex.Arg2; break; case Expression ex when ex.Operator == "/" && ex.Arg2 == 0: throw new ArgumentException("Arg2 cannot be 0"); case Expression ex when ex.Operator == "/": result = ex.Arg1 / ex.Arg2; break; default: throw new InvalidOperationException(); } Console.WriteLine($"{o} = {result}"); }
Po pierwsze, do switcha możemy przekazać większą liczbę typów. Wcześniej byliśmy ograniczeni do typów podstawowych, takich jak int, string, char, enum. Teraz możemy przekazywać całe obiekty – tak jak to jest zrobione powyżej, gdzie switch buduje się na podstawie typu object.
Po drugie, case’y dają nam dużo większe możliwości do budowania warunków. Możemy określić z jednej strony typ, jaki nas interesuje [tutaj Expression, gdzie do switcha przekazujemy typ object – tak, jak byśmy użyli operatora is w ifie – if (o is Expression)]. Z drugiej strony możemy sprawdzić wartość właściwości obiektu i wykonać inną logikę dla niego.
W przykładzie case dla operacji dzielenia, który wcześniej zawierał w sobie if, rozbiłem na dwa case’y, w którym pierwszy zawiera dodatkowo warunek z wcześniejszego ifa.
Tak jak pisałem wcześniej, mnie jakoś ta składania do końca nie odpowiada. Dodatkowy kod związany ze switchem (np. break) powoduje, że wersja kodu z ifami jest jakaś czytelniejsza (o ile oczywiście chciałbym tutaj użyć ifa :)). Ale oczywiście to może być tylko moja subiektywna opinia.
Natomiast w C# 8 ten problem został rozwiązany i sam zapis jest znacznie krótszy.
Switch w C# 8
W C# 8 Microsoft poszedł o krok dalej w składni switchów – usunął większość zbędnych rzeczy i użył składni podobnej do tej, którą znamy z wyrażeń lambda. Zmieniony kod wygląda tak:
void CSharp8(object o) { int result = o switch { Expression ex when ex.Operator == "+" => ex.Arg1 + ex.Arg2, Expression ex when ex.Operator == "/" && ex.Arg2 == 0 => throw new ArgumentException("Arg2 cannot be 0"), Expression ex when ex.Operator == "/" => ex.Arg1 / ex.Arg2, _ => throw new InvalidOperationException() }; Console.WriteLine($"{o} = {result}"); }
Po pierwsze, w tym przypadku switch zwraca wartość, którą przypisujemy do zmiennej. Po drugie, usunięte zostały zbędne słowa, takie jak case oraz break. Przed operatorem => mamy warunek, który wcześniej był w case’ie, natomiast po nim mamy to, co się wykonuje z break na końcu. Do tego słowo kluczowe default zostało zamienione na _ (podkreślenie), dzięki czemu domyślny warunek również zajmuje mniej kodu.
Dzięki temu kod jest bardziej zwarty i zawiera to, co jest istotne w kontekście jego działania. Nie tak, jak to było w wersji 7 C#.
Co fajne, możemy to jeszcze bardziej uprościć. Dodatkowo w C# 8 pojawił się Property Pattern, którego możemy użyć zamiast when w warunku. W tym przypadku określamy za pomocą typu anonimowego wartości właściwości, które musi mieć obiekt, aby spełnił dany warunek. W kodzie wygląda to tak:
void CSharp8Property(object o) { int result = o switch { Expression { Operator: "+" } ex => ex.Arg1 + ex.Arg2, Expression { Operator: "/", Arg2: 0 } ex => throw new ArgumentException("Arg2 cannot be 0"), Expression { Operator: "/" } ex => ex.Arg1 / ex.Arg2, _ => throw new InvalidOperationException() }; Console.WriteLine($"{o} = {result}"); }
Jak dla mnie jest to bardziej czytelne niż użycie when, jak to było w wcześniejszym kodzie.
Property Pattern ma jednak jedno ograniczenie. Wspiera tylko warunek równości (czyli tak jak tutaj, że Operator == „/” oraz Arg2 == 0). W przypadku zakresów musimy skorzystać z when.
W naszym przykładzie możemy jeszcze bardziej uprościć kod. Nie musimy budować switcha na postawie typu object; możemy od razu zbudować go na postawie typu Expression. Dzięki temu w warunkach możemy pominąć rzutowanie na Expression i kod wtedy już jest bardziej zwięzły i myślę, że również bardzo czytelny:
void CSharp8Property(Expression ex) { int result = ex switch { { Operator: "+" } => ex.Arg1 + ex.Arg2, { Operator: "/", Arg2: 0 } => throw new ArgumentException("Arg2 cannot be 0"), { Operator: "/" } => ex.Arg1 / ex.Arg2, _ => throw new InvalidOperationException() }; Console.WriteLine($"{ex} = {result}"); }
Tak jak pisałem we wstępie, uważam, że tego nowego switcha mogę nawet polubić. Może nie w sytuacji takiej jak powyżej, gdy mogę chcieć dodawać z czasem kolejne warunki, gdzie będzie ich dużo. Ale w sytuacji z kolejnego przykładu już tak.
Gdzie nowy switch może się przydać?
Wyobraźmy sobie sytuację, gdy w aplikacji mamy enuma określającego status zamówienia oraz drugiego enuma z możliwymi operacjami:
public enum State { Created, Processing, Processed, Canceled } public enum Operation { Process, Deliver, Cancel }
Następnie potrzebujemy napisać metodę, która by odpowiednio zmieniała aktualny stan zamówienia na podstawie operacji. W przypadku kodu sprzed C# prawdopodobnie użyłbym do tego ifów. Natomiast w C# 8 mogę użyć do tego switcha. Kod mógłby wyglądać tak:
class StateDemo { public void Run() { var state = State.Canceled; var operation = Operation.Cancel; state = (state, operation) switch { (State.Created, Operation.Process) => State.Processing, (State.Processing, Operation.Deliver) => State.Processed, (State.Canceled, _) => throw new InvalidOperationException(), (_, Operation.Cancel) => State.Canceled, _ => throw new InvalidOperationException() }; Console.WriteLine(state); } }
W powyższym kodzie wykorzystałem jeszcze inny możliwy zapis switcha. Skorzystałem z Tuple Values znanego z C# 7. W tym momencie switch buduje wartości na parze, a nie na jednym obiekcie, jak było wcześniej. Parą wartości jest aktualny stan zamówienia oraz operacja do wykonania na zamówieniu. W warunkach natomiast określamy, jakie pary nas interesują i co ma się stać.
Dwa pierwsze warunku zapewne są jasne: zmieniamy status z jednego na drugi na podstawie operacji. Dwa kolejne warunki są ciekawsze. Użyłem w nich operatora _ (podkreślenia), który w tym przypadku określa wartość dowolną. Zatem trzeci warunek wyłapuje sytuację, w której chcę zmienić status anulowanego zamówienia (czego nie możemy zrobić). Natomiast czwarty warunek zmienia dowolny status zamówienia na status „anulowany” w momencie wykonania operacji anulowania.
Tutaj warto pamiętać o kolejności wykonywania warunków. Podczas próby anulowania anulowanego zamówienia tylko warunek numer trzy zostanie wykonany, a nie warunek numer cztery, który też spełnia tę sytuację. Tak jak to jest w przypadku breaka w normalnym switchu.
Takiego switcha w takiej sytuacji naprawdę jestem w stanie polubić. Potrzebujemy napisać w jednej metodzie wszystkie możliwe kombinacje warunków i zamiast używania wielu ifów – możemy użyć właśnie takiego switcha, który w ładny i zwięzły sposób umożliwia opisanie wszystkich warunków.
Bardzo fajnie!
Przykład
Na githubie znajduje się kod, który przygotowałem do tego wpisu. W przypadku gdybyś chciał go pobrać i uruchomić przed finalną ósmą wersją C# (czyli przed wrześniem 2019, kiedy ma być dostępny wraz z .NET Core 3.0), musisz zmienić jedną rzecz w ustawieniach projektu w Visual Studio 2019. W zakładce Build z lewej strony (numer 1 na poniższym zrzucie ekranu) klikasz w przycisk Advanced (numer 2) na samym dole. W nowym oknie w Language Version wybierasz opcję C# 8.0 (beta) – numer 3:
Po tych krokach możesz przetestować nowego switcha z C# 8.0.
Podsumowanie
Bardzo cieszę się z tego, że Microsoft ciągle rozwija C# i dodaje do niego nowe funkcjonalności. Niektóre z nich mogą być bardzo przydatne (jak na przykład opisywany powyżej nowy switch), a niektóre z nich mogą być dziwne i na pierwszy rzut oka nieintuicyjne (tak jak dla mnie w tej chwili są domyślne implementacje interfejsów). Ale to może tylko trzeba „dorosnąć” do tych nowych rzeczy? Co o tym sądzisz?
Jeśli zainteresował Cię temat nowości w C#, to polecam Ci obejrzeć sesję na ten temat, która odbyła się niedawno (7 maja 2019) podczas Microsoft Build. Sesja nazywa się „The future of C#” i można ją znaleźć na stronie microsoft.com. Niestety w momencie pisania tego artykułu nie byłem w stanie znaleźć linka bezpośrednio do tej sesji. Musisz sam jej poszukać na powyższej stronie.
Warto obejrzeć sesję do końca, w szczególności od 42:55 minuty. W niej prelegenci pokazują to, co będą chcieli dodać w kolejnej, prawdopodobniej dziewiątej wersji C#. A takie rzeczy jak records, roles czy extensions zrobiły na mnie spore wrażenie. Już się nie mogę tego doczekać.
Pracując z setkami programistów, zauważyłem, że większość osób nie pracuje efektywnie w Visual Studio. W skrajnych przypadkach korzystali z kopiowania z wykorzystaniem menu Edit. Wiem, że to dziwne, ale naprawdę niektórzy tak pracują. Dlatego postanowiłem stworzyć kurs Visual Studio – aby pomóc koleżankom i kolegom w efektywniejszej pracy.
Przygotowałem 20 lekcji e-mail, w których pokażę Ci, w jaki sposób pracować efektywniej i szybciej w Visual Studio. Poznasz dodatki, bez których nie wyobrażam sobie pracy w tym IDE. Po więcej informacji zapraszam na dedykowaną stronę kursu: Darmowy Kurs Visual Studio.
Ostatnio przygotowałem również quiz C#, w którym możesz sprawdzić swoją wiedzę. Podejmiesz wyzwanie?
Artykuł został pierwotnie opublikowany na plawgo.pl. Zdjęcie główne artykułu pochodzi z unsplash.com.