C# Pattern Matching: Mocne i słabe strony w przykładach
W niniejszym artykule będziesz miał okazję zapoznać się z kilkoma przypadkami użycia techniki nazwanej “Pattern Matching”. Wszystkie przypadki użycia są praktyczne - napotkałem je w swoim realnym projekcie. Żadnych teoretycznych scenariuszy ani przykładów z „kwadratami i prostokątami” – tylko rzeczywiste przypadki związane z produktem komercyjnym. Mam nadzieję, że ułatwi to zrozumienie tego, jak Pattern Matching może pomóc w refaktoryzacji instrukcji warunkowych i na co trzeba zwrócić uwagę, by nie stać się ofiarą overengineeringu.
Czym jest Pattern Matching?
Pattern Matchhing to nic innego jak instrukcje warunkowe na sterydach. A konkretniej – testowanie wyrażenia warunkowego i podejmowania na tej podstawie akcji. Podstawowa wersja Pattern Matchingu pojawiła się już w C# 7 a następnie ewoluowała do bardziej wyrafinowanych postaci. Spójrzmy na przykład prostego użycia tej techniki:
string? message = GetMessageOrDefault();
if (message is not null)
{
Console.WriteLine(message);
}
I nieco bardziej zaawansowany przykład z użyciem operatorów porównania:
public static string GetEggHardness(int boilingTimeinMins)
{
return boilingTimeInMins switch
{
>= 0 and <= 5 => "Soft",
> 5 and <= 8 => "Medium",
> 8 => "Hard",
_ => throw new ArgumentException("Invalid boiling time")
};
}
W ostatnim czasie nasz projekt został uaktualniony do wersji 6 .NET-a tym samym pozwalając nam na refaktor z użyciem Pattern Matchingu. Poniżej prezentuję kilka przykładów żywcem wyciągniętych z kodu, które uzmysławiają, iż Pattern Matching w praktyce może nie być tak proste jak funkcja do “gotowania” jajek.
Przykład 1: Pattern matching w zarządzaniu przepływem stanu
Na pierwszy ogień wdrażania Pattern Matchingu poszło zarządzanie stanem dokumentu elektronicznego. W systemie, który rozwijamy, każdy dokument znajduje się zawsze w jednym z kilku stanów. Stan dokumentu zmienia się w miarę jego przetwarzania, zaś każda jego zmiana wymaga określonej weryfikacji i zatwierdzeń (np. przez zespół edytorski).
Przykładowo: dokument będący wpisem na blogu początkowo zyskuje status “Draft”, następnie przechodzi w fazę weryfikacji edytorskiej zyskując status “Waiting for review” a dalej, po zatwierdzeniu przez uprawnionego do tego managera, jego stan zmienia się na “Published” jeśli nie wniesiono żadnych poprawek, bądź “Waiting for author” jeśli wymagane są zmiany (przepływ stanów widoczny jest na ilustracji 1)
Ilustracja 1: przepływ stanu dokumentu elektronicznego na przykładzie artykułu
A oto jak wyglądał przed zmianami kod źródłowy obsługujący przepływ opisanych stanów:
public Func<ArticlePayload, Task> GetAction(MoveWorkflowPayload payload)
{
if ((payload.OldStatus == null && payload.NewStatus == ApprovalStatuses.Draft)
|| (payload.OldStatus == ApprovalStatuses.Draft && payload.NewStatus == ApprovalStatuses.Draft))
{
return _moveToDraftAsync;
}
else if ((payload.OldStatus == null && payload.NewStatus == ApprovalStatuses.WaitingForReview)
|| (payload.OldStatus == ApprovalStatuses.WaitingForAuthor && payload.NewStatus == ApprovalStatuses.WaitingForReview)
|| (payload.OldStatus == ApprovalStatuses.Draft && payload.NewStatus == ApprovalStatuses.WaitingForReview))
{
return _moveToWaitingForReviewAsync;
}
else if (payload.OldStatus == ApprovalStatuses.WaitingForReview
&& payload.NewStatus == ApprovalStatuses.WaitingForAuthor
&& payload.IsManager)
{
return _moveToWaitingForAuthorAsync;
}
else if (payload.OldStatus == ApprovalStatuses.WaitingForReview
&& payload.NewStatus == ApprovalStatuses.Published
&& payload.IsManager)
{
return _moveToPublishedAsync;
}
else
{
return _defaultCase;
}
}
Cóż, bez wątpienia nie jest to dzieło sztuki, głównie ze względu na nieczytelne wyrażenie warunkowe.
Sprawdźmy jak ten sam fragment wygląda przy zastosowaniu Pattern Matchingu:
public Func<ArticlePayload, Task> GetAction(MoveWorkflowPayload payload)
{
return payload switch
{
// Draft
{ OldStatus: null, NewStatus: ApprovalStatuses.Draft } => _moveToDraftAsync,
{ OldStatus: ApprovalStatuses.Draft, NewStatus: ApprovalStatuses.Draft } => _moveToDraftAsync,
// Waiting for review
{ OldStatus: null, NewStatus: ApprovalStatuses.WaitingForReview } => _moveToWaitingForReviewAsync,
{ OldStatus: ApprovalStatuses.WaitingForAuthor, NewStatus: ApprovalStatuses.WaitingForReview } => _moveToWaitingForReviewAsync,
{ OldStatus: ApprovalStatuses.Draft, NewStatus: ApprovalStatuses.WaitingForReview } => _moveToWaitingForReviewAsync,
// Waiting for author
{ OldStatus: ApprovalStatuses.WaitingForReview, NewStatus: ApprovalStatuses.WaitingForAuthor, IsManager: true } => _moveToWaitingForAuthorAsync,
// Published
{ OldStatus: ApprovalStatuses.WaitingForReview, NewStatus: ApprovalStatuses.Published, IsManager: true } => _moveToPublishedAsync,
// Default
_ => _defaultCase,
};
}
Teraz można stwierdzić z całą pewnością, że jest przede wszystkim o wiele bardziej czytelny, jak równie elastyczny: nowe warunki I kolejne statusy mogą być teraz dodawane bez degradacji czytelności.
Przykład 2: Wygląda skomplikowanie, lecz „robi robotę”
W drugim kroku zaaplikowaliśmy Pattern Matching do fragmentu konstruującego e-maile. Mechanizm budujący wiadomości uwarunkowany jest wyborami użytkownika, który w wyskakującym okienku (widocznym na Ilustracji 2) może wybrać lub usunąć zaznaczenie poszczególnych osób. Inicjalnie okienko posiada już pewne domyślne zaznaczenia, ale użytkownik jest uprawniony do ich zmiany. W praktyce oznacza to, iż uprzednio zaznaczony „Ricky Barack” może zostać wykluczony z listy a nieujęty na wykazie „Peter Griffin” może zostać do niego dołączony, jeśli taka będzie wola użytkownika. Pociąga to za sobą konieczność analizy czy i kto został dodany bądź usunięty z listy i na tej podstawie budowane będą przyszłe e-maile.
Ilustracja 2 – Edytowalne okno z wyborem użytkowników
Kod, który przed zmianami analizował stan okienka był zlepkiem wielu instrukcji warunkowych:
string message;
if (currentlySelectedUsers.Count() == 0)
{
message = BuildNoUserSelectedMessage();
}
else if (addedUsers.Count > 0 && removedUsers.Count > 0)
{
message = BuildUserAddedOrModifiedMessage();
}
else if (addedUsers.Count > 0 && removedUsers.Count <= 0)
{
message = BuildAddedUsersMessage();
}
else if (addedUsers.Count <= 0 && removedUsers.Count > 0)
{
message = BuildRemovedUsersMessage();
}
else
{
throw new InvalidOperationException("Not supported combination");
}
A tak wygląda ten sam kod z nałożonym Pattern Matchingiem:
var message = (currentlySelectedUsers.Count, removedUsers.Count, currentlySelectedUsers.Count()) switch
{
(_, _, 0) => BuildNoUserSelectedMessage (),
( > 0, > 0, _) => BuildUserAddedOrModifiedMessage (),
( > 0, <= 0, _) => BuildAddedUsersMessage (),
( <= 0, > 0, _) => BuildRemovedUsersMessage (),
_ => throw new InvalidOperationException("Not supported combination")
};
Zwróćmy uwagę, iż poprzez użycie podkreślenia można pomijać sprawdzanie konkretnych właściwości.
Gdy po raz pierwszy zobaczyłem powyższy fragment, pomyślałem: „Czemu ktoś napisał kod z emotikonami w środku?” Ale tak na serio, to nowa wersja działa przyzwoicie a zamiast wielokrotnych instrukcji if-else użyty jest bardziej zwięzły i czytelniejszy kod, który w dodatku zajmuje mniej miejsca.
Trzeba jednak uważać, by nie przesadzić z ilością właściwości stanowiących podstawę do porównań, gdyż może to prowadzić do zjawiska nazywanego „code smell”, czyli kodu, który swoim wyglądem sugeruje, iż wkrótce może stać się prawdziwym utrapieniem. Kod analizujący tylko kilka warunków pozostanie jeszcze zrozumiały, lecz wraz ze wzrostem ich liczby będzie tracił na czytelności wymagając ciągłego skakania wzrokiem między kolejnymi liniami instrukcji switch a pozycją warunku na liście z właściwościami.
Przykład 3: Uważaj na overengineering
W poniższym przykładzie pokazany został fragment kodu już po wprowadzeniu Pattern Matchingu. Widząc go pierwszy raz musiałem poświęcić kilka minut, by zorientować się co ten kod tak naprawdę robi.
await(payload switch
{
{ IsUpdated: true, Status: not ApprovalStatus.Draft } => _randomHelper.UpdateUserAsync(
new UserRequestPayload
{
UserTypeId = userType.UserTypeId,
Data = userItemResponse.Data
},
payload, userType.Code),
{ IsUpdated: null or false, Status: not ApprovalStatus.Draft } => _randomHelper.RegisterUserAsync(payload, userType.Code),
_ => Task.FromResult(payload)
});
A tak wygląda po przeróbce zwiększającej czytelność:
if (payload.Status == ApprovalStatus.Draft)
return;
if (payload.IsUpdated.HasValue && payload.IsUpdated.Value)
{
var srcPayload = new UserRequest
{
UserTypeId = userType.UserTypeId,
Data = userItemResponse.Data
};
await _randomHelper.UpdateUserAsync(srcPayload, payload, userType.Code);
}
else
{
await _randomHelper.RegisterUserAsync(payload, userType.Code);
}
Część odpowiedzialna za modyfikację użytkownika została przeniesiona do osobnej metody. Dodatkowo został dodany warunek wczesnego wyjścia celem zredukowania złożoności cyklomatycznej.
Pierwotny kod stanowi idealny przykład jak można wpaść w pułapkę overengineeringu jedynie zapędzając się w używaniu cukru składniowego. Poświęcając jego czytelność oszczędzamy raptem cztery linie kodu. Natomiast użycie prostych technik, takich jak „early return” pozwala zachować tu właściwy balans.
Podsumowanie
Gdy zaczęliśmy używać Pattern Matchingu w naszej aplikacji doszliśmy do wniosku, że jest to technika programowania mająca wiele zalet, ale należy z niej korzystać ostrożnie tak, by nie godziła w czytelność kodu. Zdrowy rozsądek i dobór odpowiedniej techniki do konkretnego kontekstu wciąż są najlepszym podejściem.