Refaktoryzacja warunkowa 'if' w C#: Unikanie bałaganu warunkowego z Derekiem Comartinem
W języku C# instrukcje warunkówe, takie jak instrukcja if, instrukcja if else i instrukcja switch, są niezbędnymi narzędziami. Ale co się dzieje, gdy konstrukcje te są używane nadmiernie — zwłaszcza w połączeniu z enumami? Derek Comartin w swoim filmie "Enums Aren't Evil. "Conditionals Everywhere Are", przedstawia szczegółowy proces refaktoryzacji, który zastępuje powszechnie stosowaną logikę warunkówą czystszymi i łatwiejszymi w utrzymaniu wzorcami.
W tym artykułe prześledzimy krok po kroku tok rozumowania Dereka, wykorzystując jego znaczniki czasu jako punkty odniesienia. Zbadamy również, w jaki sposób jego pomysły mają zastosowanie do typowych wzorców warunkówych w języku C#, takich jak operator trójargumentowy, instrukcja else oraz struktury switch-case — podkreślając, gdzie mogą one powodować problemy w dużych bazach kodu i jak można je refaktoryzować w celu uzyskania lepszego projektu.
Eksplozja warunków "if": prawdziwy problem
Derek zaczyna od przedstawienia instrukcji if, która sprawdza typ produktu:
if (productType == ProductType.Template || productType == ProductType.Ebook)
if (productType == ProductType.Template || productType == ProductType.Ebook)
Na pierwszy rzut oka powyższy warunek if wygląda na prosty. Derek ostrzega jednak, że tego rodzaju instrukcja ocenia dany warunek, a następnie wykonuje blok kodu tylko wtedy, gdy warunek jest prawdziwy — co staje się problematyczne, gdy logika ta jest powtarzana wszędzie.
Ten blok może pojawić się ponownie w innej metodzie lub klasie:
if (offeringType == ProductType.Template || offeringType == ProductType.Ebook)
if (offeringType == ProductType.Template || offeringType == ProductType.Ebook)
Derek wyjaśnia, że ten wzorzec szybko rozprzestrzenia się w dużym systemie. Ta sama logika if-else pojawia się w wielu usługach, powodując niespójności i błędy podczas dodawania nowej wartości wyliczeniowej. Na przykład, co się dzieje, gdy wprowadzisz nowy typ produktu, taki jak wideo? Należy pamiętać o aktualizacji każdego bloku, w którym występuje to wyrażenie warunkówe.
Powtórzenia zwiększają złożoność
W poniższym przykładzie Derek zagłębia się w zagnieżdżone konstrukcje warunkówe. Wewnątrz jednej metody instrukcja if else sprawdza tę samą enum, a wynik jest przekazywany do kolejnej metody, która również zawiera podobną kontrolę.
Instrukcja sprawdza, czy istnieje szablon lub ebook, i zwraca wynik — w przeciwnym razie zwraca wartość null. Derek zauważa, że ta nadmiarowość nie tylko wydłuża kod, ale także stwarza zagrożenia związane z utrzymaniem. Ta sama logika jest powielana w wielu plikach, co prowadzi do chaosu w przepływie sterowania.
Jeśli Twój system wymaga dodawania domyślnego przypadku za każdym razem, gdy modyfikujesz wyliczenie, wiesz, że coś jest nie tak.
Zmiana sposobu myślenia o warunkach
Zamiast ciągłego sprawdzania typów za pomocą if else, Derek proponuje zadać lepsze pytanie:
Czy produkt posiada funkcje umożliwiające pobieranie?
To lepsze wyrażenie intencji. Sprawia to, że kod staje się bardziej czytelny i całkowicie zmniejsza zależność od enum. Zamiast pisać instrukcję if z dwoma warunkami, taką jak:
if (product.Type == ProductType.Template || product.Type == ProductType.Ebook)
if (product.Type == ProductType.Template || product.Type == ProductType.Ebook)
Można po prostu zawrzeć tę logikę w modelu i napisać:
if (product.HasDownloadableResource())
if (product.HasDownloadableResource())
Zwraca wartość true tylko wtedy, gdy dostępny jest zasób do pobrania — co ogranicza potrzebę stosowania skomplikówanych wyrażeń warunkówych.
Od instrukcji if do zachowań enkapsulowanych
Aby rozwiązać ten kluczowy problem, Derek wprowadza typ DownloadableResource. Ten typ zawiera adres URL do pobrania oraz domyślną nazwę pliku. Staje się to integralną częścią Twojej domeny, zamiast polegać na instrukcjach if w celu jej uzyskania.
Teraz, zamiast powtarzać to:
if (product.Type == ProductType.Template)
{
// Generate file name
}
else if (product.Type == ProductType.Ebook)
{
// Generate file name
}
if (product.Type == ProductType.Template)
{
// Generate file name
}
else if (product.Type == ProductType.Ebook)
{
// Generate file name
}
Napisz to:
var downloadable = product.GetDownloadableResource();
if (downloadable != null)
{
Console.WriteLine(downloadable.FileName);
}
var downloadable = product.GetDownloadableResource();
if (downloadable != null)
{
Console.WriteLine(downloadable.FileName);
}
To znacznie upraszcza logikę i eliminuje potrzebę stosowania rozgałęzień instrukcji else, a nawet instrukcji switch.
Czas wykonywania zamiast czasu kompilacji: zmiana strategiczna
Derek idzie o krok dalej, wyjaśniając istotny wybór projektowy: przeniesienie logiki z etapu kompilacji do etapu wykonywania. Oznacza to sprawdzanie w systemie w czasie wykonywania, czy dla danego produktu istnieje zasób typu DownloadableResource. Jeśli tak jest, podejmij odpowiednie działania. Jeśli nie, pomiń to.
Zauważa on, że to posunięcie zamienia statyczną logikę if-else na zapytania wykonywane w czasie rzeczywistym. Może to wymagać wywołania bazy danych, ale ogranicza zagnieżdżoną logikę if-else i centralizuje zachowanie. Poprawia to łatwość utrzymania na dużą skalę.
Wykorzystanie dziedziczenia w przypadku produktów do pobrania
Inną ścieżką, którą bada Derek, jest dziedziczenie. Można utworzyć abstrakcyjną klasę bazową Product, a następnie zdefiniować typy pochodne, takie jak Ebook, Template lub OfflineCourse.
Każde z nich nadpisuje metody takie jak:
public virtual string GetDownloadUrl() { ... }
public virtual string GetDownloadUrl() { ... }
Takie podejście pozwala każdemu produktówi obsługiwać własną logikę. Chociaż pozwala to uniknąć instrukcji switch lub wielu instrukcji warunkówych, Derek zwraca uwagę, że jeśli nie będziesz ostrożny, nadal możesz skończyć pisząc wyrażenia warunkówe wewnętrznie.
Lepsza enkapsulacja bez dziedziczenia
Jeśli dziedziczenie wydaje się zbyt uciążliwe, Derek sugeruje użycie typów jawnych, takich jak DownloadableProduct, który zawiera własne właściwości i metody — bez powiązania z hierarchią.
W programie może to wyglądać następująco:
var downloader = new DownloadableProduct(product);
Console.WriteLine(downloader.GetDefaultFileName());
var downloader = new DownloadableProduct(product);
Console.WriteLine(downloader.GetDefaultFileName());
Nie ma potrzeby stosowania instrukcji if else lub switch w celu określenia zachowania — każdy obiekt wie, co ma robić.
Proste rozwiązanie: metody rozszerzeń w typach wyliczeniowych
Jeśli nie jesteś gotowy, aby zrezygnować z enumów, Derek proponuje lekkie rozwiązanie — utwórz metodę rozszerzenia:
public static bool IsDownloadable(this ProductType type)
{
return type == ProductType.Template || type == ProductType.Ebook;
}
public static bool IsDownloadable(this ProductType type)
{
return type == ProductType.Template || type == ProductType.Ebook;
}
Teraz zamiast pisać:
if (product.Type == ProductType.Template || product.Type == ProductType.Ebook)
if (product.Type == ProductType.Template || product.Type == ProductType.Ebook)
Można to uprościć do:
if (product.Type.IsDownloadable())
if (product.Type.IsDownloadable())
Pozwala to scentralizować logikę i uniknąć ciągłego powtarzania nawiasów klamrowych oraz bloków kodu.
Należy unikać nadużywania operatorów trójargumentowych i instrukcji switch
Derek ostrzega również przed nadużywaniem skrótów, takich jak operator trójargumentowy:
string filename = product.Type == ProductType.Template ? "template.pdf" : "default.pdf";
string filename = product.Type == ProductType.Template ? "template.pdf" : "default.pdf";
Chociaż jest to poprawna składnia, może być podatna na błędy i trudna do odczytania, gdy logika staje się złożona. Zwłaszcza jeśli warunek zostanie oceniony jako fałszywy, nieprawidłowa wartość może zostać przypisana w subtelny sposób.
Podobnie, instrukcja switch z instrukcją break i przypadkiem domyślnym również wpada w tę pułapkę. Lepiej jest zapytać obiekty o zachowanie niż używać logiki switch-case.
Wniosek: Inteligentniejsze sterowanie przy mniejszym natłoku warunków
Podsumowując, film Dereka nie jest atakiem na enumy, ale krytyką sposobu, w jaki używamy wokół nich warunkówych struktur if. Rozmieszczając instrukcje if else i switch w całym kodzie, utrudniasz testowanie, utrzymanie i rozwój systemu.
Niezależnie od tego, czy zdecydujesz się na enkapsulację, wyszukiwanie w czasie wykonywania, dziedziczenie czy proste metody rozszerzeń, cel pozostaje ten sam: ograniczyć warunki i przenieść logikę tam, gdzie jej miejsce.
Pamiętaj:
-
Warunki nie są złe.
-
Bałagan warunkówy to.
-
Czysty kod nie opiera się na wielu instrukcjach if else rozrzuconych po różnych klasach.
- Oceń kontekst i odpowiednio dostosuj tekst.
Jak mówi Derek: "To zależy od kontekstu". Jedno jest jednak pewne: produkt nie zawsze jest tylko produktem — czasami jest to sygnał, aby przemyśleć swój projekt.
