Niechciane wyjątki
Wyjątki są bardzo pomocne
Wyłapywanie wyjątków
Wyjątek Exception
Wiele wyjątków
Różne wyjątki, różne zachowania
Klauzula else
Finally
Sprawdzaj tylko krytyczną sekcję kodu
Sztuczne dzielenie kodu
Dla fanów jednolinijkowców
Praktyczna porada
Warunek warunkowi nierówny
Trochę praktyki
Podsumowanie
Spis treści
Python to język dynamicznie typowany. Dzięki temu ma swoich zwolenników jak i przeciwników. Jest to jedna z tych cech, która pozwala bardzo szybko zbudować prototyp aplikacji, ale na dłuższą metę może być uciążliwa. Jedną z konsekwencji jest bardzo częste korzystanie z wyjątków. W innych językach wyjątki również występują, ale to właśnie w językach, w których nie ma statycznego typowania musimy bardziej dbać o takie szczegóły.
Niechciane wyjątki
Spróbujmy napisać prostą funkcję do mnożenia dwóch liczb. Dla porównania zobaczmy jak wyglądałaby w C++:
int multiplication(int a, int b){
return a * b;
}
Teraz napiszmy ten sam kod w języku Python:
def multiplication(a, b):
return a * b
Na pierwszy rzut wszystko wygląda w porządku. Obie funkcje zwracają to samo. Korzystając z tych funkcji zgodnie z przeznaczeniem obie zwrócą poprawne wyniki. Co jednak, gdy ktoś poda błędny argument? Na przykład zamiast dwóch liczb, dwa ciągu znaków. W C++ zostanie to wykryte już na etapie kompilacji. W Python niestety zostanie wyrzucony wyjątek i programista musi podjąć decyzję co z tym zrobi.
Możemy na przykład zwrócić 0, tak jak poniżej:
def multiplication(a, b):
try:
return a * b
except TypeError:
return 0
Możemy podejść do tego zupełnie inaczej i w funkcji zostawić to bez wyjątku, a ten przypadek rozpatrywać w momencie wywołania. Warto wtedy zaznaczyć to w doc string.
def multiplication(a, b):
'''
Mnożenie dwóch liczb całkowitych
Raises:
TypeError: W przypadku, gdy parametry nie będą liczbami
'''
return a * b
try:
multiplication('a', 'b')
except TypeError:
log.warning('Niedozwolona operacja')
Wybór rozwiązania zależy od wielu czynników i każdy należy rozpatrywać indywidualnie. Pewne jest tylko to, że nie powinniśmy zostawiać takich wyjątków bez informacji. Więc jeżeli przewidujemy, że funkcja, nad którą pracujemy może wyrzucić jakiś wyjątek, a my celowo chcemy przekazać go dalej, to warto zostawić to chociaż w komentarzu. Uwierzcie, że potrafi to oszczędzić sporo czasu i nerwów ;)
Wyjątki są bardzo pomocne
To nie tak, że wyjątki są używane tylko w językach dynamicznie typowanych. Owszem w językach bez statycznego typowania jesteśmy zmuszeni do częstego ich wykorzystywania, ale występują one we wszystkich językach w których pracowałem i dają naprawdę bardzo dużo możliwości. Rozsądne korzystanie z wyjątków jest niezwykle istotne. Często jest to jedyne sensowne rozwiązanie. Dobrym przykładem mogą być formularze w Django, które wyrzucaję ValidationError . Istnieje też wiele innych bibliotek, które wyjątki wykorzystują do sygnalizowania różnych zachowań.
Zanim przejdziemy do wyjątków, zapoznajmy się z ich wyrzucaniem. Wyobraźmy sobie zatem, że robimy formularz, w którym sprawdzamy czy komuś przysługuje ulga. Ulga przysłyguje tylko studentom, którzy mają wykupiony kupon.
from django import forms
class MyForm(forms.Form):
has_coupon = forms.BooleanField(required=False)
is_student = forms.BooleanField(required=False)
def clean(self):
cleaned_data = super(MyForm, self).clean()
if not cleaned_data.get('is_student') or not cleaned_data.get('has_coupon'):
raise ValidationError(_('Brak ulgi'), code='invalid')
return cleaned_data
Widzimy zatem, że wyrzucanie wyjątków jest stosunkowo proste dzięki słowu kluczowemu raise.
Wyłapywanie wyjątków
Skoro potrafimy już wyrzucać, zajmijmy się wyłapywaniem wyjątków. Powinniśmy przechwytywać tylko te wyjątki, których się spodziewamy. Jeżeli jesteśmy zmuszeni przechwytywać wszystkie, powinniśmy zostawić informację w postaci komentarza "dlaczego". Czyli zapisy poniżej są NIEDOZWOLONE .
try:
spam()
except:
pass
try:
spam()
except Exception as e:
pass
Jeśli faktycznie nie możemy nic zrobić w sekcji except to postarajmy się przynajmniej zalogować tę informację.
try:
spam()
except Exception as e:
log.warning(e)
Ostatecznie możemy zastąpić ten log jakimś komentarzem. Na przykład podczas importowania local_settings w projekcie:
try:
from local_settings import *
except ImportError:
pass
# Plik local_settings.py nie jest niezbędny, ale pomaga ustawić np inny backend do bazy
Wyjątek Exception
W większośći powyższych przykładów wyłapywałem wyjątek Exception. Nie jest to dobra praktyka. Jest to bowiem klasa dość ogólna, dlatego starajmy się łapać możliwie sprecyzowane wyjątki, jak na przykład ImportError w ostatnim fragmencie. Powyższe przykłady są tylko do zaprezentowania jak działa sam mechanizm wyłapywania wyjątków.
Wiele wyjątków
Czasem funkcja może zwracać więcej niż jeden wyjątek. Rozbudujmy funkcję do mnożenia liczb tak, żeby dostawała jeszcze jeden parametr. Przed mnożeniem zrzutujmy nasze zmienne na int .
def multiplication(a, b):
try:
return int(a) * int(b)
except (TypeError, ValueError):
return 0
Jak widzicie wyłapujemy tutaj TypeError , jeżeli a lub b będą w innym typie niż string oraz ValueError w przypadku, gdy w ciągu jest coś innego niż liczba i nie ma możliwości zrzutować go na liczbę.
multiplication('a', 'b') # 0, ValueError
multiplication('5', '10') # 50, OK
multiplication(None, 1) # 0, TypeError
Różne wyjątki, różne zachowania
W codziennej pracy nie raz zdarzyło mi się, że funkcja wyrzucała kilka wyjątków i każdy musiał zostać rozpatrzony indywidualnie. Mamy możliwość używania kilku bloków except i w każdym rozpatrywać inny wyjątek. Lepiej będzie to zobrazowane na przykładzie. Stwórzmy zatem funkcję, która dostaje 2 parametry. W pierwszym jest lista słowników. Każdy słownik reprezentuje użytkownika. W drugim natomiast mamy na przykład wiek. Funkcja ma wykazać ile osób nie przekroczyło podanego progu wiekowego.
Jako że nie mamy pewności co do typu, w jakim dostaniemy dane, załóżmy, że wszystko będzie ciągiem znaków. Podstawowa wersja tej funkcji może wyglądać następująco:
def check_age(users, age):
count = 0
for user in users:
if int(user['age']) < age:
count += 1
return count
Poprawne dane testowe:
valid_data = [{'name': 'Jan', 'age': '10'}, {'name': 'Dawid', 'age': '25'}, {'name': 'Marcin', 'age': '23'}]
check_age(valid_data, 15) # 1
Dla takich danych wszystko wykona się poprawnie. Może jednak się zdarzyć, że na liście znajdzie się niepoprawny albo pusty słownik. Poniżej przykład:
invalid_dict = [{}, {'name': 'Dawid', 'age': '25'}, {'name': 'Marcin', 'age': '23'}]
check_age(invalid_dict, 11) # KeyError
Wykonując naszą funkcję z takimi danymi otrzymamy wyjątek KeyError , bo w słowniku nie ma wpisu o kluczu age . Można oczywiście zamienić to na pobieranie ze słownika przez get, ale my spróbujmy wyłapać ten wyjątek i wyświetlmy jakiś komunikat. Poprawiona wersja:
def check_age(users, age):
count = 0
for user in users:
try:
if int(user['age']) < age:
count += 1
except KeyError:
print('Niepoprawne dane: {}'.format(user))
return count
Wywołując ten kod dla niepoprawnego słownika, dostaniemy komunikat przy wierszu, który był niepoprawny. Idąc dalej możemy natknąć się na jeszcze jeden problem. Jeśli nie mamy walidacji na wcześniejszym etapie, to może okazać się, że wiek nie zawsze będzie liczbą. Co wtedy?
invalid_age = [{'name': 'Jan', 'age': 'age'}, {'name': 'Dawid', 'age': '25'}, {'name': 'Marcin', 'age': '23'}]
check_age(invalid_age, 11) # ValueError
Pamiętajmy, że gdybyśmy dodali ValueError do except, ale bez nawiasów to również by to nie zadziałało. My jednak przejdźmy krok dalej. Chcemy bowiem rozróżnić te wyjątki. Poprawmy zatem nasz kod:
def check_age(users, age):
count = 0
for user in users:
try:
if int(user['age']) < age:
count += 1
except KeyError:
print('Niepoprawne dane: {}' .format(user))
except ValueError:
print('Niepoprawny wiek: {}' .format(user))
return count
Klauzula else
Prosty kod do sprawdzenia wieku już się trochę rozrósł. Możemy to trochę uporządkować dzięki klauzuli else, która wykonuje się, jeśli nie pojawił się wyjątek. A więc po drobnej refaktoryzacji, poprawiony kod może wyglądać następująco:
def check_age(users, age):
count = 0
for user in users:
try:
user_age = int(user['age'])
except KeyError:
print('Niepoprawne dane: {}'.format(user))
except ValueError:
print('Niepoprawny wiek: {}'.format(user))
else:
count += 1 if user_age < age else 0
return count
Działanie tej funkcji jest dokładnie takie samo jak wcześniej, ale zamiast otaczać wyjątkiem całą sekcję warunku, otoczyliśmy tylko pobranie ze słownika i rzutowanie na int. Dopiero w sekcji else inkrementujemy zmienną. Oczywiście cały kod jest uproszczony i na celu ma jedynie pokazać możliwości try..except..else .
Finally
Istnieje instrukcja, która wykona się niezależnie od tego czy wyjątek wystąpił czy nie. Najczęściej widziałem ją przy obsłudze plików, gdzie właśnie w sekcji finally zamyka się plik niezależnie od tego czy pobrano dane czy nie. Innym zastosowaniem może być delegowanie zadania, na przykład do Celery lub innego wątku. Możemy w ten sposób zalogować informację, że zadanie zostało oddelegowane, bez zwracania uwagi na rezultat. Pamiętajmy, że ta instrukcja wykonuje się jako ostatnia i niezależnie od tego czy wykonał się wcześniej except lub else. I tak w przykładzie niżej, wypisujemy wszystkich użytkowników. Dodałem również enumerate w pętli, żeby łatwiej można było zlokalizować, o którego użytkownika z koleii chodzi.
def check_age(users, age):
count = 0
for i, user in enumerate(users):
try:
user_age = int(user['age'])
except KeyError:
print('Niepoprawne dane: {}'.format(user))
except ValueError:
print('Niepoprawny wiek: {}'.format(user))
else:
count += 1 if user_age < age else 0
finally:
print("{}. {}".format(i, user))
return count
Sprawdzaj tylko krytyczną sekcję kodu
Nie raz widziałem, jak try otaczał bardzo duży kawałek kodu, podczas gdy błąd wyrzucała jedna konkretna linijka. Nie jest to poprawne zachowanie i to nie tylko dlatego, że zmniejsza czytelność kodu, ale potrafi również zmienić logikę działania aplikacji. Użyjmy zatem ponownie kodu ze sprawdzaniem wieku osób, ale tym razem nałóżmy warunek na całą pętle:
def check_age(users, age):
count = 0
try:
for user in users:
if int(user['age']) < age:
count += 1
except (KeyError, ValueError):
print('Niepoprawne dane')
return count
Jaki będzie wynik? To wszystko zależy od danych jakie przyjdą. Przy pierwszym wystąpieniu błędu pętla zostanie przerwana. Zatem jeśli pierwsza osoba będzie miała niepoprawny wiek, to pozostałe nie będą nawet sprawdzone.
Sztuczne dzielenie kodu
Sam kiedyś złapałem się na tym, że sztucznie dzieliłem kod. Pracując nad wspólnym kodem z innymi programistami, widząc krytyczną sekcję, wrzucałem ją do oddzielnej metody czy funkcji. Wtedy w try faktycznie była tylko jedna linijka, ale skutek był ten sam co wyżej. Sprawiłem jedynie, że błąd był trudniejszy do zlokalizowania. Na szczęście po jakimś czasie się opamiętałem ;)
Jak może wyglądać taki sztuczny podział. Patrzcie poniżej:
def _check_age(users, age):
count = 0
for user in users:
if int(user['age']) < age:
count += 1
return count
def check_age(users, age):
try:
return _check_age(users, age)
except (KeyError, ValueError):
print('Niepoprawne dane')
return 0
Nie dość, że błąd nie został poprawiony to jeszcze zostawiłem w aplikacji funkcję, ktora nie była w żaden sposób zabezpieczona, a nie zostawiłem nawet żadnej informacji.
Dla fanów jednolinijkowców
Jeżeli mamy pewność, że dane zostały wcześniej zwalidowane to możemy skorzystać z dobrodziejstw jakie daje nam Python i konstrukcje w jednej linijce. Wtedy nasza funkcja byłaby dużo bardziej kompaktowa. Ale taka konstrukcja wymusza poprawnych danych, bo niestety tutaj try już tak łatwo nie nałożymy:
def check_age(users, age):
return sum(1 for user in users if int(user['age']) < age)
Praktyczna porada
Wyjątki, jak sama nazwa wskazuje, to reakcja nieoczekiwana, ale taka która może wystąpić. Istnieje bowiem pokusa, żeby zastępować instrukcje warunkowe wyjątkami. W instrukcji warunkowej musimy za każdym przejściem pętli sprawdzić warunek, co wiąże się kosztem procesora. Wyjątki są kosztowne tylko w momencie, gdy są wyłapywane. Natomiast złapany wyjątek jest dużo bardziej kosztowny niż instrukcja warunku. Dlatego właśnie wyjątki pomogą zwiększyć wydajność aplikacji, jeżeli będą wykonywane sporadycznie, a najlepiej wcale. Pamiętajmy więc, żeby wszystkie instrukcje wykonywać zgodnie z ich przeznaczeniem, a więc warunki do sprawdzania przypadku a wyjątki do wyłapania niestandardowego zachowania aplikacji.
Warunek warunkowi nierówny
Odnosząc się do poprzedniego punktu musimy wspomnieć jeszcze o bardzo ważnej kwestii. Chodzi o różnice między warunkami. Nie zapominajmy, że warunki również są kosztowne dla procesora. Im bardziej złożony warunek, tym ten koszt jest większy. Sprawdzenie czy obiekt istnieje lub nie jest pusty jest dużo łatwiejsze niż na przykład sprawdzenie typu. W naszym przypadku musielibyśmy jeszcze sprawdzić czy ciąg znaków zawiera same cyfry, a to już jest kosztowniejsze. Może się również zdarzyć, że trzeba będzie odpytać bazę. Tu wskazówka - używajce exists.
Oczywiście przy kilku elementach prawdopodobnie nie odczujemy różnicy, ale przy kilkuset tysiącach czy milionach rekordów te drobne różnice mogą okazać się znaczące. I moim zdaniem świadome korzystanie z warunków i wyjątków jest jedną z cech dobrych i świadomych programistów.
Trochę praktyki
Wcześniej wspomniałem, że wyjątki są mniej kosztowne jeśli wyłapywane są sporadycznie. Zbadajmy więc to na przykładach i przekonajmy się jak to wygląda w praktyce.
Do testów weźmy po prostu rzutowanie zmiennej na int . Żeby nie nakładać dodatkowego czasu - w przypadku niepoprawnej danej zwracajmy po prostu 0. Zasada niech będzie prosta. Iterujemy po liście stringów. Przyjmijmy, że na liście mogą być elementy None lub ciągi niebędące liczbami. Czyli musimy sprawdzić dwa warunki. Będziemy zatem całość sprawadzać dla następujących wariantów:
def cast_to_number(num):
'''Zrzucenie zmiennej na int tylko dla poprawnych danych.''''
return int(num)
def cast_to_number(num):
'''Zrzucenie stringu na int przy sprawdzeniu czy ciąg istnieje.'''
if num:
return int(num)
else:
return 0
def cast_to_number(num):
'''Zrzucenie stringu na int przy sprawdzeniu czy ciąg zawiera tylko cyfry.'''
if not num.isdigit():
return 0
else:
return int(num)
def cast_to_number(num):
'''Zrzucenie stringu na int przy sprawdzeniu czy ciąg istnieje i zawiera tylko cyfry.'''
if not num or not num.isdigit():
return 0
else:
return int(num)
def cast_to_number(num):
'''Zrzucenie stringu na int przy wyłapaniu wyjątki jeśli ciąg nie istnieje lub nie jest liczbą.'''
try:
return int(num)
except (ValueError, TypeError):
return 0
Poniżej będę prezentować wyniki dla 20000000 powtórzeń. Dane będą wyświetlone w formie tabeli. Każdą funkcję wykonam 5 razy, żeby wyeliminować ewentualne szumy. Wyniki będą posortowane, żeby od razu była widoczna mediana. W ostatniej kolumnie dodam również średnią.
Dane przy wszystkich poprawnych danych:
Funkcja | Próba 1 | Próba 2 | Próba 3 | Próba 4 | Próba 5 | Średnia |
Bez sprawdzania | 7.94548 | 8.35792 | 8.464718 | 8.585483 | 8.692416 | 8.409203399999999 |
None | 8.155034 | 8.658919 | 8.723488 | 9.151508 | 9.199784 | 8.777746599999999 |
isdigit() | 9.974257 | 10.337567 | 10.456555 | 10.494629 | 10.538206 | 10.3602428 |
None and isdigit() | 10.850253 | 10.994993 | 11.248505 | 11.304566 | 11.326678 | 11.144999 |
try..except | 8.237527 | 8.237672 | 8.459961 | 8.584159 | 8.622683 | 8.4284004 |
Jak widać zgodnie z oczekiwaniami najlepiej wypadły metody bez sprawdzania oraz metoda z try..except. Ta pierwsza w średniej prezentuje się nieznacznie lepiej, ale to tylko dlatego, że pierwsze podejście zostało wykonane najszybciej. Mediana lepiej przedstawia się przy try..except co potwierdza teorię, że jest on kosztowny tylko gdy wyjątek zostanie złapany. Warunek na None jak widać jest bardzo szybki do sprawdzenia.
Sprawdźmy teraz jak zachowają się nasze funkcje jeśli 1% danych będzie jako None . Wyniki:
Funkcja | Próba 1 | Próba 2 | Próba 3 | Próba 4 | Próba 5 | Średnia |
None | 8.362703 | 8.364107 | 8.368124 | 8.414036 | 8.645977 | 8.430989400000001 |
try..except | 8.642916 | 8.680647 | 8.694605 | 8.917382 | 9.116781 | 8.810466199999999 |
Jak widać już przy 1% niepoprawnych danych warunek sprawdzający czy zmienna istnieje okazał się szybszy. Sprawdźmy więc to na 0.1%.
Funkcja | Próba 1 | Próba 2 | Próba 3 | Próba 4 | Próba 5 | Średnia |
None | 8.470555 | 8.703122 | 8.750829 | 8.981347 | 9.208553 | 8.8228812 |
try..except | 8.403205 | 8.403983 | 8.631235 | 8.90685 | 9.354715 | 8.739997599999999 |
Tu już wynik przechyla się w stronę łapania wyjątków. Przyznam, że to bardzo ciekawy rezultat. Sam nie spodziewałem się takiego wyniku.
Sprawdźmy teraz jak zachowa się warunek, gdy zawsze będą jakieś ciągi znaków, ale nie zawsze będą zawierać liczby. Z powyższych tabel widać, że dla 1% try..except wypadnie lepiej dlatego sprawdźmy od razu dla 10% niepoprawnych danych, czyli co dziesiąty element będzie jako 'a' .
Funkcja | Próba 1 | Próba 2 | Próba 3 | Próba 4 | Próba 5 | Średnia |
isdigit() | 10.246547 | 10.328216 | 10.869628 | 10.974596 | 11.031887 | 10.690174799999998 |
try..except | 12.186145 | 12.638534 | 12.675084 | 13.400113 | 13.490258 | 12.8780268 |
Widzimy, że 10% to zbyt duży próg. Zmniejszmy go zatem do 5%.
Funkcja | Próba 1 | Próba 2 | Próba 3 | Próba 4 | Próba 5 | Średnia |
isdigit() | 9.958079 | 9.970779 | 9.974871 | 10.150616 | 10.63001 | 10.136871 |
try..except | 10.187397 | 10.218872 | 10.232828 | 10.24023 | 11.152357 | 10.406336800000002 |
Tutaj również isdigit wypada nieco lepiej. Ale różnice są już zdecydowanie mniejsze. Zejdźmy zatem do 4%
Funkcja | Próba 1 | Próba 2 | Próba 3 | Próba 4 | Próba 5 | Średnia |
isdigit() | 10.063755 | 10.147454 | 10.231403 | 10.33005 | 10.70795 | 10.296122399999998 |
try..except | 9.994072 | 10.037852 | 10.128607 | 10.287182 | 10.558954 | 10.2013334 |
Już przy 4% użycie try..except jest bardziej opłacalne. Nie są to duże liczby, a warunki nie były specjalnie skomplikowane.
Na koniec sprawdźmy jeszcze sytuację, w której mogą wystąpić oba błędy. Rozłóżmy je równomiernie po 2% na None oraz 2% niech będą jako ' a '.
Funkcja | Próba 1 | Próba 2 | Próba 3 | Próba 4 | Próba 5 | Średnia |
None i isdigit() | 11.342404 | 11.617588 | 11.687512 | 12.021774 | 12.063413 | 11.7465382 |
try..except | 10.143873 | 10.324233 | 10.331017 | 10.639932 | 10.77575 | 10.442961 |
Podsumowanie
Mam nadzieję, że tymi tabelkami skłoniłem Was chociaż trochę do refleksji na temat korzystania z wyjątków. Potwierdziło się to co napisałem wcześniej, czyli wyjątek wyjątkowi nierówny. Te były dość proste, a zdarzają się bardziej rozbudowane. Sam kilka razy odpytywałem bazę w warunkach, co jest już bardziej kosztowane. Mnie osobiście zaskoczyły aż takie różnice. Pamiętam, jak na studiach prowadzący podkreślał, że wyjątków nie powinnismy żałować jeśli podejrzewamy, że ten zostanie wyłapany częściej niż przy 50% przypadków. Po tych, dość prostych eksperymentach z tej liczby uciąłbym nawet zero, bo 5% to już zdecydowanie za dużo.
Nie raz już pisałem, że wszystko z umiarem. Tak samo jest z wyjątkami. Są różne szkoły korzystania z wyjątków. Jedni są ich zwolennikami, inni starają się ich unikać. Jedni wrzucają maksymalnie jedną linijkę do sprawdzenia a inni kilka linijek, które mogą wyrzucić ten sam błąd. Nie ma jednoznacznej odpowiedzi, które podejście jest najlepsze.
Zachęcam Was gorąco do zapoznania się z tym bardzo ważnym elementem programowania i wypracowania własnego stylu. Sami zobaczycie, że warto ;)