When writing tests, ideally you should verify your code’s behavior not only in the usual, “happy” cases, but also in the erroneous ones. Although you may very well accept that a function blows when feed with incorrect data, it should blow up predictably and consistently. An error, exception or panic is still an output; and it should be possible to capture and examine it in tests.
The Python unittest
module has a couple of ways to deal with expected error. Probably the most useful among them is the TestCase.assertRaises
method. It does pretty much exactly what it names hints to: asserting that a piece of code raises a specific type of exception:
In Python 2.7 (or with the unittest2 shim library), it can be also used much more conveniently as a context manager:
To clarify, assertRaises
will execute given block of code (or a callable, like in the first example) and throw AssertionError
if an exception of given type was not raised by the code. By calling the tested function with incorrect data, we intend to provoke the exception, affirm the assertion, and ultimately have our test pass.
I mentioned, however, that it doesn’t just matter if your code blows up in response to invalid input or state, but also how it does so. Even though assertRaises
will verify that the exception is of correct type, it is often not nearly enough for a robust test. In Python, exception types tend to be awfully broad, insofar that a simple information about throwing TypeError
may tell you next to nothing about the error’s true nature.
Ironically, designers of the unittest
module seemed to be vaguely aware of the problem. One of their solutions, though, was to introduce a proverbial second problem, taking the form of assertRaisesRegexp
method. While it may kinda-sorta work in simple cases, I wouldn’t be very confident relying on regular expressions for anything more complex.
Especially when the other possible approach appears much more sound anyway. Using the feature of with
statement, we can capture the exception object and examine it ourselves, in a normal Python code. Not just the type or message (though these are typically the only reliable things), but also whatever other data it may carry:
Sometimes, those checks might grow quite sophisticated. For example, in Python 3 you have exception chaining; it allows you to look not only at the immediate exception object, but also its __cause__
, which is analogous to Throwable.getCause
in Java or Exception.InnerException
in C#. If you need to dig this deep, I’d suggest extracting a function with all that code – essentially a specialized version of assertRaises
, preferably with all the context manager goodness that would enable us to use it like the original.
As it turns out, this can be very simple.
There’s no better way to start a new year than a hearty, poignant rant. To set the bar up high right off the bat, I’m not gonna be picking on some usual, easy target like JavaScript or PHP. To the contrary, I will lash out on everyone-and-their-mother’s favorite language; the one sitting comfortably in the middle between established, mature, boring technologies of the enterprise; and the cutting edge, misbegotten phantasms of the GitHub generation.
That’s, of course, Python. A language so great that nearly all its flaws people talk about are shared evenly by others of its kin: the highly dynamic, interpreted languages. One would be hard-pressed to find anything that’s not simply a rehashed argument about maintainability, runtime safety or efficiency – concerns that apply equally well to Ruby, Perl and the like. What about anything specifically “pythonic”?…
Well, let’s talk about Python’s exceptions, shall we?
W językach kompilowanych mechanizmy wejścia-wyjścia opiera się niemal zawsze o koncepcję strumienia (stream), czyli abstrakcyjnego obiektu z którego możemy czytać dane i/lub je do niego zapisywać. Ponieważ strumień jest opakowaniem na jakiś zewnętrzny zasób – plik, połączenie sieciowe, itp. – należy generalnie dbać o jego poprawne i szybkie zamknięcie, gdy nie jest już potrzebny. Dotyczy to także zwłaszcza języków z zarządzaną pamięcią, gdzie osierocony obiekt strumienia może nie być posprzątany przez bardzo długi czas, zajmując zewnętrzne, niezarządzane zasoby systemowe.
Poprawny sposób postępowania z obiektem strumienia, który chciałem dzisiaj omówić, dotyczy konkretnie języka Java, gdyż tam cała sprawa jest co najmniej nietrywialna. Dzieje się tak z trzech powodów:
catch
), albo zadeklarowany jako opuszczający funkcję (throws
). Jest to sprawdzane podczas kompilacji.close
, zamykająca strumień, deklaruje potencjalne wyrzucanie wyjątku IOException
.with
(obecnej np. w C# i Pythonie), która automatycznie posprzątałaby po obiekcie strumienia w momencie opuszczenia jej zasięgu.Brak instrukcji with
sprawia, iż do dyspozycji pozostaje nam wyłącznie try-catch
lub try-finally
. Naiwne zastosowanie któregoś z nich nie daje jednak pożądanych efektów:
Takim efektem byłby na przykład fakt kompilowania się kodu :) W tej wersji jest to jednak niemożliwe (o ile funkcja nie deklaruje wyrzucania IOException
), bowiem wyjątek ten może zostać rzucony przez metodę close
… A przynajmniej taka jest teoria, którą kompilator niestety pedantycznie sprawdza.
W rzeczywistości ten kod ma przynajmniej jeszcze jeden błąd, którego nie wyeliminuje otoczenie wywołania close
odpowiednim blokiem try-catch
. Jego znalezienie pozostawiam aczkolwiek jako – ahem – ćwiczenie dla czytelnika ;) W zamian pokażę dla odmiany nieco lepszy sposób na obejście zaprezentowanych problemów.
Polega on na zastosowaniu dwóch zagnieżdżonych bloków try
: jednego z catch
do złapania IOException
i drugiego z finally
do zamknięcia strumienia. W całości prezentuje się to następująco:
Przy zastosowaniu takiej konstrukcji wszystkie miejsca, w których wyjątek I/O może wystąpić, są otoczone blokiem try-catch
, więc kompilator nie będzie miał powodów do narzekań. Nadal też gwarantujemy, że strumień zostanie zawsze zamknięty, co z kolei zapewnia blok try-finally
.
A że wygląda to wszystko cokolwiek nieestetycznie? Cóż… Java :)
W wielu językach wyjątki to niemal obowiązkowa metoda zgłaszania i obsługi błędów. W innych (tj. w C++) jest ona w dużym stopniu opcjonalna. W obu przypadkach istnieją zwykle dostępne out-of-the-box klasy wyjątków, których obiekty można wyrzucać, bez tworzenia swoich własnych. Ich używanie jest zdecydowanie wskazanie. Warto więc przyjrzeć się, jakie predefiniowane obiekty wyjątków przewidują standardowej biblioteki różnych języków.
I tak w C++ mamy bazową klasę exception
, która zawiera w sumie niewiele więcej ponad komunikat o błędzie. Zresztą dotyczy to tak naprawdę wszystkich wbudowanych w C++ klas wyjątków; tym, co je rozróżnia, jest po prosty typ. Mamy tu prostą hierarchię dziedziczenia, wyróżniającą klasy bazowe: runtime_error
dla niskopoziomowych błędów czasu wykonania (jak na przykład overflow_error
) oraz logic_error
dla błędów logicznych aplikacji. Z tej drugiej, bardziej dla nas interesującej, wyprowadzone są też wyjątki nieco bardziej szczegółowe, jak np.: invalid_argument
, range_error
, domain_error
czy lenght_error
. Wszystkie cztery są zresztą podobne do siebie pojęciowo i wydają się służyć głównie do sygnalizowania nieprawidłowych wartości argumentów funkcji. Do innego rodzaju błędów sensowniej jest używać klas bazowych lub niestety napisać własne (najlepiej dziedziczące z nich).
Na platformie .NET i w Javie jest z tym dużo lepiej – tam typów wyjątków jest niemal za dużo. Nie ma tu miejsca na omawianie ich wszystkich i nie jest to zresztą konieczne, gdyż nazwy klas są zwykle wystarczające do zorientowania się, z jakim rodzajem błędu mamy tu do czynienia. Według mnie najczęstszymi sytuacjami, w których można wyrzucić obiekt którejś z natywnych klas, są:
System.ArgumentException
/ java.lang.IllegalArgumentException
null
-em w sytuacji, gdy wymagany jest obiekt – System.ArgumentNullException
System.IndexOutOfRangeException
/ java.lang.IndexOutOfBoundsException
System.InvalidOperationException
/ java.lang.IllegalStateException
System.NotImplementedException
System.NotSupportedException
/ java.lang.UnsupportedOperationException
System.IO.IOException
/ java.io.IOException
System.TimeoutException
/ java.util.concurrent.TimeoutException
Można zauważyć, że dla większości pozycji odpowiadające klasy wyjątków istnieją na obu platformach i nawet nazywają się podobnie. Zapewne to kolejny znak wzajemnej inspiracji ich twórców ;)
W każdym programie większym niż Hello World istnieje możliwość wystąpienia błędów w czasie działania. Dotyczy to zwłaszcza takich, które nie są zależne od programisty piszącego kod aplikacji, lecz na przykład od danych zewnętrznych pochodzących od użytkownika czy z plików.
W zależności od typu i stopnia dolegliwości błędy mogą być obsługiwane na różne sposoby. Dość często nie jest wcale łatwo zdecydować się na któryś z nich. Dlatego należy znać typowe metody sygnalizowania błędów i przynajmniej ogólne zasady opisujące sytuacje, w których każda z tych metod jest najwłaściwsza.
Sam przez bardzo długi czas miałem na przykład pewne wątpliwości co do przydatności asercji jako mechanizmu powiadamiania o błędach. W szczególności, nie potrafiłem odróżnić sytuacji, w których właściwsze jest korzystanie właśnie z asercji niż chociażby z wyjątków. Ostatnio aczkolwiek całkiem przypadkowo nadrobiłem te niechlubne zaległości :)
Okazuje się bowiem, że różnica między asercją a wyjątkiem jest znaczna. Asercje służą do sprawdzania pewnych warunków, które uznajemy za obiektywnie prawdziwe. Są to po prostu założenia, które muszą być spełnione w każdym okolicznościach, gdyż warunkują poprawność kodu. Pisanie asercji jest więc częściowo sprawdzaniem samego siebie: dzięki nim łatwiej wykryjemy błędy programistyczne we wczesnej fazie powstawania kodu (czyniąc naturalnie optymistyczne założenie, że same asercje są w porządku :]).
Wniosek z tego taki, że w dobrze działającej aplikacji asercje zawsze powinny być spełnione i nigdy nie przerywać działania programu. W przeciwieństwie do nich wyjątki (exceptions) mogą pojawiać się w zupełnie poprawnym kodzie, bo dotyczą rzeczy, na które program nie ma wpływu. Jednocześnie jednak musi on przewidywać ich ewentualne wystąpienie i posiadać odpowiedni kod ich obsługi. W przeciwnym razie konsekwencje bywają nieprzyjemne.
Dlatego wyjątki (i podobne do nich mechanizmy, jak np. symbianowe leaves) służą do powiadamiania o sytuacjach mających potencjalnie poważne konsekwencje. Dla pozostałych należy stosować mniej radykalne metody informowania o błędach. Najpopularniejszym jest oczywiście wartość zwracana przez funkcję, w której wystąpił błąd (ewentualnie w połączeniu z czymś takim jak errno
lub GetLastError
w Windows API). Kluczową cechą tego sposobu powiadamiania jest to, że należy specjalnie zadbać o sprawdzenie, czy błąd wystąpił; inaczej zostanie on zignorowany.
Tak w skrócie przedstawia się sprawa od strony teoretycznej. W praktyce odróżnienie sytuacji podpadającej pod asercję od tej, gdzie uprawniony jest wyjątek – a już zwłaszcza wyjątku od zwykłego “return -1;
” – bywa trudne, a często subiektywne. Zwykle jednak daje się stosować przynajmniej jedną zasadę: przynajmniej w fazie testów lepiej jest przesadzać i reagować nazbyt histerycznie niż przeoczyć lub zignorować jakiś ważny szczegół.
Kiedy w kodzie zdarza się coś niedobrego, na co nie mamy natychmiastowego rozwiązania, zwykle rzucamy wyjątek. To nam odwija stos, wychodząc po kolei z głęboko zagnieżdżonych funkcji – aż w końcu natrafimy na handler, który potrafi rzeczony błąd obsłużyć. Odwiecznym problemem wyjątków jest to, gdzie należy tak naprawdę je łapać; zwykle bardzo łatwo jest umieścić kod ich obsługi na zbyt wysokim poziomie, tłumacząc się, że przecież “niżej” nic na ten błąd nie można było poradzić.
Faktycznie zaradzić wyjątkowi często nie można, ale prawie zawsze można w tym pomóc już na wczesnym etapie jego obsługi. Błąd gdzieś “w środku” powoduje bowiem niepowodzenie wywołania wszystkich funkcji po drodze, a każda z takich porażek jest specyficzna dla czynności, która dana funkcja wykonuje. Jeśli przykładowo chcemy odczytać jakiś parametr konfiguracyjny programu, który wymaga załadowania z pliku, to może się okazać, że tego pliku nie da się otworzyć. Wówczas odczytywanie konfiguracji nie powiedzie się – co samo w sobie jest błędem, ale także powoduje, że niemożliwe jest uzyskanie wartości żądanego parametru – kolejny błąd. Widać więc, że błędy-wyjątki mogą być dla siebie kolejno przyczyną i skutkiem.
Taka szczegółowa “historia błędów” jest przydatna, bo pozwala oddzielić informacje ważniejsze od mniej ważnych. Nasza aplikacja może na przykład w ogóle nie wiedzieć, że jej konfiguracja jest zapisywana w pliku (a nie np. Rejestrze) i dlatego wyjątek ‘Nie znaleziono pliku konfiguracyjnego’ powinien być przed nią ukryty. Trzeba go opakować w błąd wyższego rzędu.
Nazywa się to wewnętrznymi wyjątkami (inner exceptions) i jako wbudowany mechanizm występuje chociażby w .NET i Javie. Idea jest prosta: kiedy złapiemy wyjątek “z dołu”, którego nie możemy do końca obsłużyć, zapakowujemy go w nowy obiekt, sygnalizujący niepowodzenie na “naszym” poziomie kodu. Ten nowy wyjątek wyrzucamy dalej; może on potem podlegać bardzo podobnemu procesowi.
I tak w przykładzie z ładowaniem konfiguracji, próba odczytania nieistniejącego pliku skończy się w .NET wyjątkiem IOException
, który zostanie pewnie natychmiast opakowany w FileNotFoundException
. Funkcja odczytująca dane konfiguracyjne z pliku najpewniej sama wsadzi ten wyjątek w nowy, np. ArgumentException
, co mówi wywołującemu, że przekazany argument – tu: nazwa pliku z konfiguracją – jest nieprawidłowy. W końcu, ponieważ nie będzie można odczytać żądanej wartości, funkcja która miała ją zwrócić może nas ostatecznie uraczyć wyjątkiem InvalidOperationException
, zawierającym w środku wszystkie wymienione wcześniej błędy. Których było zresztą całkiem sporo :)
Gdy w końcu złapiemy cały ten wielokrotnie zapakowany wyjątek w innym miejscu, możemy dostać się do całego łańcucha przyczynowo-skutkowego, który powstał podczas odwijania stosu. W tym celu korzysta się z właściwości InnerException
w .NET lub metody getCause
w Javie – na przykład tak:
Zapewne jednak rzadko będziemy sięgali aż do samego dna – zwykle tylko w celach logowania. Dzięki odpowiedniemu opakowaniu możemy bowiem zająć się od razu sytuacją wyjątkową “najwyższego rzędu” – adekwatną do czynności, którą chcieliśmy wykonać (a nie tą pochodzącą z głębokich czeluści kodu niższego poziomu, od której wszystko się zaczęło).
Jako język nie posiadający słowa kluczowego finally
, C++ preferuje nieco inną metodę na radzenie sobie z nieprzewidzianymi “wyskokami” z funkcji i związaną z tym możliwością wycieku zasobów (resource leak). Ten inny sposób jest znany skądinąd jako RAII: Resource Acquisition Is Initialization i polega na związaniu z każdym użyciem zasobu jakiegoś obiektu lokalnego, np.:
Proste i całkiem wygodne, jeśli tylko posiadamy już (lub zechcemy napisać) odpowiednią klasę, która w konstruktorze pozyskuje dany zasób – tutaj blokadę muteksa – a w destruktorze go oddaje.
Ale ten nieskomplikowany mechanizm daje możliwości popełnienia błędów, które są na swój interesujące, ale w realnym kodzie na pewno niezbyt przyjemne :) Pierwszy z nich związany jest z faktem, że lokalnych obiektów nie tworzy się znowu aż tak dużo i można popełnić w ich składni drobne, acz wielce znaczące faux pas z nawiasami:
Taki wiersz nie stworzy nam bowiem żadnego obiektu, ale zadeklaruje funkcję lock
, zwracającą obiekt typu ThreadLock
i niebiorącą żadnych argumentów. Zaskakujące? A to tylko prosta konsekwencja faktu, że cokolwiek, co można zinterpretować w C++ jako deklarację funkcji, zostanie tak właśnie zinterpretowane.
Można jednak ripostować, że nic takiego nie zdarzy się, jeśli do konstruktora naszego obiektu-blokady przekażemy chociaż jeden parametr. A zwykle tak właśnie będzie; tutaj np. byłoby nim odwołanie do obiektu typu mutex lub semafora, który chcemy zająć. Jednak nie zmienia to faktu, że w większości przypadków obiekt realizujący RAII wystarcza nam przez samo swoje istnienie, co z kolei sprawia, że w dalszym kodzie w ogóle się do niego nie odwołujemy. To zaś może spowodować, że pominiemy i tak nieużywany składnik jego deklaracji – czyli nazwę:
Takie zagranie również nie powinno wywołać protestów kompilatora, ale prawie na pewno nie jest tym, o co nam chodzi. Tworzony obiekt jest teraz bowiem nie lokalny, ale tymczasowy: jego zasięg ogranicza się do wyrażenia, w którym został wprowadzony. Czyli do… średnika kończącego powyższą instrukcję! Taki też zakres ma opakowana przez ów obiekt blokada międzywątkowa.
Jak zatem widać, jest tu kilka okazji do popełnienia błędów, które mogą być trudne do wykrycia. Powinniśmy więc zwrócić na nie uwagę tym bardziej, że wobec braku w C++ instrukcji finally
technika RAII jest jedynym sensownym wyjściem dla lokalnego pozyskiwania i zwalniania zasobów.