Obsługa wyjątków w LINQ

By | September 18, 2016

Jak już wspominałem, uwielbiam LINQ. Z każdym razem gdy go używam odrobinę bardziej. Ale nie każdy pała do LINQ równie wielką miłością co ja i pojawiają się różne argumenty przemawiające przeciw korzystaniu z tej funkcjonalności w wielu przypadkach, również w takich gdzie znacznie ułatwiłoby to nam życie. Jednym z takich argumentów jest trudność w debugowaniu długich łańcuchów metod w LINQ oraz utrudniona obsługa błędów. Dziś pokażę Wam, że wcale nie jest to tak wielkim problemem.

Będziemy pracować na metodzie, która była mi potrzebna jakiś czas temu. Przyjmowała ona kolekcję stringów i wypluwała sparsowane wartości numeryczne. Musiała być przy tym częścią łańcucha LINQ. Pierwsze co możemy zrobić, to użyć zwykłego Selecta.

var strings = new List<string>
{
    "1", "2", "3", "a", "one", "4"
};

var numbers = strings.Select(s => int.Parse(s));

foreach (var n in numbers)
{
    Console.WriteLine(n.ToString());
}

Powyższy kod wypisze nam pierwsze 3 sparsowane wartości, po czym rzuci wyjątkiem, bo “a” sparsować na inta się po prostu nie da. My jednak chcemy otrzymać wartości, które udało nam się otrzymać i nie przejmować się tym, że gdzieś na wejściu wpadło coś co znaleźć się tam nie powinno. Co możemy z tym zrobić? Rozbudować nieco naszego Selecta i otrzymamy coś takiego.

var numbers = strings.Select(s => {
    int number = 0;
    int.TryParse(s, out number);
    return number;
});

Co prawda w miejsce wartości, które nie zostaną sparsowane otrzymamy 0, ale nie jest to nic czego nie załatwi Where z prostym warunkiem. Sęk w tym, że nie przepadam za umieszczaniem w Selectach większej ilości kodu niż jest to niezbędne. I o ile tutaj tego kodu nie ma zbyt wiele, to o wiele bardziej niepokoi mnie coś innego. Mianowicie, nie mamy pojęcia o tym, że na wejściu otrzymujemy jakieś śmieci. Niby aplikacja działa, nic się nie wiesza i zwraca wszystko co zwracać powinna, ale przydałoby się przynajmniej złapać wyjątek i go zalogować. I tutaj przechodzimy do sedna sprawy, otóż wewnątrz Selecta możemy spokojnie umieścić blok try/catch.

var numbers = strings.Select(s =>
{
    try
    {
        return int.Parse(s);
    }
    catch (Exception exception)
    {
        //Log Exception here...
        return 0;
    }
});

I chociaż osiągnęliśmy takie sam efekt jak powyżej, to przybyło nam całkiem sporo linijek kodu i wygląda to po prostu brzydko. Jest też mało czytelne, a jeśli będziemy musieli zrobić coś więcej niż proste parsowanie, to kod z pewnością nie będzie czysty. Dlatego też warto pokusić się o stworzenie metody rozszerzającej, która przy okazji będzie generyczna i będziemy mogli z niej korzystać w innym miejscu. 

public static IEnumerable<T> ParseMany<T>(this IEnumerable<string> input)
{
    var converter = TypeDescriptor.GetConverter(typeof(T));

    return input.Select(i =>
           {
               try
               {
                   return (T)converter.ConvertFromString(i);
               }
               catch (Exception exception)
               {
                   //Log Exception here...
                   return default(T);
               }
           })
           .Where(x => !x.Equals(default(T)));
}

Wywołanie metody wraz z odfiltrowaniem zer wygląda wtedy następująco.

var numbers = strings.ParseMany<int>();

Osiągnęliśmy zamierzony efekt, LINQ jest czytelne, wszelcy intruzi są obsłużeni, a ich obecność została odnotowana. Do tego wszystko dzieje się gdzieś pod spodem, w warstwach gdzie dzieje się magia. Czego chcieć więcej?

Teoretycznie moglibyśmy poprzestać na tym, ale tak naprawdę napisaliśmy tylko wrapper na Selecta. W tym konkretnym wypadku to jak najbardziej wystarcza, ale możemy potrzebować czegoś więcej. A żeby to zrobić i skorzystać z dobrodziejstw LINQ i IEnumerable, będziemy musieli wykorzystać yield return, który oprócz tego, że zasługuje na swój własny post, to nie możemy umieścić go w bloku try/catch (konkretniej to nie możemy go użyć, wtedy blok try występuje w parze z catchem). Co w takiej sytuacji?

Wszelką logikę, w której chcemy obsłużyć wyjątek możemy przenieść na zewnątrz, do oddzielnej metody. Możemy też wykorzystać jeden z delegatów, by trzymać wszystko w jednej metodzie. Takie podejście wymaga jednak obsłużenia zer (lub innych wartości, które wypluje nam default(T)) na zewnątrz metody rozszerzającej. Może ona wyglądać na przykład w ten sposób.

public static IEnumerable<T> ParseMany<T>(this IEnumerable<string> input)
{
    {
        var converter = TypeDescriptor.GetConverter(typeof(T));

        Converter<string, T> stringConverter = s =>
        {
            try
            {
                return (T)converter.ConvertFromString(s);
            }
            catch (Exception exception)
            {
                //Log exception
                return default(T);
            }
        };

        foreach (var item in input)
        {
            yield return stringConverter(item);
        }
    }
}

Obsługa wyjątków w LINQ nie różni się tak naprawdę wiele od tego co robimy w “zwyczajnym” kodzie. Czasem trzeba naklepać trochę kodu, by samo LINQ wyglądało przejrzyście, ale bywa, że jest to warte zachodu.

PS. Debugowanie LINQ również nie jest takie straszne, bo breakpointy możemy stawiać również na lambach wewnątrz niego. Wystarczy zaznaczyć część wyrażenia po operatorze “=>” i nacisnąć F9. Np -strings.Select(s => int.Parse(s)). Podkreślenie i pogrubienie wskazuje dokładnie część wyrażenie, którą powinniśmy zaznaczyć(lub umieścić tam kursor), by postawić breakpoint wyłącznie w tym miejscu, a nie na całym łańcuchu znaków.