Wyjątki w Python


Jak stosować wyjątki w Python - przechwytywanie wyjątków w języku dynamicznie typowanym


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 ;)

Mar 02, 2019

Najnowsze wpisy

Zobacz wszystkie