Грешки и изключения#

Понякога се случват неща, които не очакваме, или програмата ни влиза в състояние, което е грешно или пък е с недефинирано поведение. Например, ако се опитаме да отворим файл, който не съществува, или ако се опитаме да разделим число на 0 и т.н. Ако такава “грешка” е фатална, то е редно програмата да спре изпълнението си, съобщавайки за това с някаъв вид програмна грешка (error). Ако не е толкова фатална, например е просто специален случай, който можем да третираме по по-различен начин, то това наричаме “изключение” (exception). Ние като програмисти не е редно да “хващаме” и обработваме грешките, а само изключенията.

(Синтактични) Грешки#

В Python грешките биват единствено синтактични - от тип SyntaxError. Тази грешка се хвърля когато Python parser-a забележи синтектичен проблем в кода, което би довело до невъзможността му за изпълнение.

print(("I like brackets")
  Cell In [1], line 1
    print(("I like brackets")
                             ^
SyntaxError: incomplete input

Изключения#

Дори и кодът да е синтактично правилен обаче, изпълнението му е възможно да доведе до грешка. Такива грешки, които се засичат по време на изпълнението на програмата се наричат “изключения” (exceptions) и е възможно да бъдат “хванати” и обработени по желан от нас начин. Примери за често-срещани изключения:

42 / 0
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In [2], line 1
----> 1 42 / 0

ZeroDivisionError: division by zero
"I love " + name_of_crush
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
Cell In [3], line 1
----> 1 "I love " + name_of_crush

NameError: name 'name_of_crush' is not defined
"1" + 1
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In [4], line 1
----> 1 "1" + 1

TypeError: can only concatenate str (not "int") to str
import math
math.sqrt(-1)
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In [5], line 2
      1 import math
----> 2 math.sqrt(-1)

ValueError: math domain error
l = [1, 2, 3]
l[3]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In [6], line 2
      1 l = [1, 2, 3]
----> 2 l[3]

IndexError: list index out of range
d = {"a": 1, "b": 2}
d["c"]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
Cell In [7], line 2
      1 d = {"a": 1, "b": 2}
----> 2 d["c"]

KeyError: 'c'

Note: въпреки, че са всъщност exceptions, а не errors, имената на доста вградени изключения завършват на Error в Python.

Хващане на изключения#

С try-except конструкцията можем да хванем изключение при изпълнение на даден код и да извършим някакво действие, ако се случи такова. В try блокът се слага кодът, който искаме да изпълним, а в except блокът - кодът, който искаме да се изпълни, ако се случи изключение. При хващане на изключение програмата не се терминира, а продължава изпълнението си след try-except конструкцията.

while True:
    try:
        x = int(input("Please enter a number: "))
        print("You entered: ", x)
        break
    except ValueError:
        print("Sorry bro, that's not a valid number. Try again...")
Sorry bro, that's not a valid number. Try again...
Sorry bro, that's not a valid number. Try again...
You entered:  123

Забележете, че можем да укажем изрично типа на изключението, което искаме да хванем, като аргумент на except блока. В горният пример искаме да хванем единствено изключения от тип ValueError, т.е. проблеми с въведената стойност. Така позволяваме на потребителят например да приключи изпълнението на програмата с Ctrl+C (или подобен метод), понеже това действие би хвърлило KeyboardInterrupt изключение.

Можем естествено и да не укажем тип, а да хванем абсолютно всички възможни изключения:

try:
    risky_func()
except:
    pass  # never ever do this, for the sake of humanity

Много коварен antipattern обаче е обграждането на проблематичен код с except блок, който е празен. Повече по темата: https://realpython.com/the-most-diabolical-python-antipattern/

Вместо това, може например да логнем грешката в някой файл или на стандартния изход (или по-добре от STDOUT - в STDERR). Има библиотеки, улесняващи логването, включително и вградена такава - logging.

import logging

logger = logging.getLogger()

try:
    risky_func()
except Exception as e:
    logger.error(e)
name 'risky_func' is not defined

Както се вижда от примера, можем да присвоим изключението към променлива, която да използваме в except блока. Това е полезно ако искаме да използваме изключението или аргументите на изключението, които се намират в args атрибута на всяко изключение.

try:
    risky_func()
except Exception as e:
    logger.error(e.args)
("name 'risky_func' is not defined",)

Можем да очакваме повече от един тип изключения по няколко начина:

def f(): return 42
def g(): return 0

try:
    # f()[2]    # uncomment for third error
    # y = h()  # uncomment for second error
    y = f() / g()
except ZeroDivisionError:
    print("Division by zero.")
except NameError as e:
    print("Name error: ", e)
except Exception as e:
    print("Other error: ", e)

try:
    y = f() / g() + h()
except (ZeroDivisionError, NameError) as e:
    print("Oops: ", e)
Division by zero.
Oops:  division by zero

Създателите на Python обичат да дефинират else блокове за всевъзможни езикови контролни конструкции, и try не е изключение (no pun intended). Кодът в else след try-except би се изпълнил само тогава, когато не е засечена никаква грешка при изпълнение.

try:
    # y = 0 / 42
    y = 42 / 0
except ZeroDivisionError:
    print("Division by zero.")
else:
    print("Everything is fine.")
Division by zero.

Освен това имаме и друг опционален блок - finally. Той се изпълнява абсолютно винаги като последна част на try-(except)-(else)-finally конструкцията, независимо от това дали е била прихваната грешка или не.

try:
    # y = 0 / 42
    y = 42 / 0
except ZeroDivisionError:
    print("Division by zero.")
else:
    print("Everything is fine.")
finally:
    print("I'm always here. o.o")
Division by zero.
I'm always here. o.o

Хвърляне на изключения#

Изключенията наследяват класa Exception. Списък от вграденитe такива може да откриете тук: https://docs.python.org/3/library/exceptions.html

Изключенията ги “хвърляме” с ключовата дума raise:

raise Exception("This is an exceptionally exceptional exception.")
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In [15], line 1
----> 1 raise Exception("This is an exceptionally exceptional exception.")

Exception: This is an exceptionally exceptional exception.
raise ValueError  # може и без извикването на конструктора от нас
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In [16], line 1
----> 1 raise ValueError

ValueError: 
def get_name_by_id(id: int) -> str | None:
    if not isinstance(id, int):
        raise TypeError("id must be int!")
    
    if id < 0:
        raise ValueError("id must be a positive integer!")

    database = ["Alex", "Lyubo", "Vankata"]

    if id >= len(database):
        return None
    
    return database[id]

Те се пропагират нагоре по стека на изпълнението на програмата, докато не се срещне try блок, който може да ги обработи. Ако няма такъв, програмата се терминира и се извежда съобщение за грешка. Това пропагиране е и причината да виждаме т.нар. Stacktrace:

def a(): raise Exception("Hello, stack!")
def b(): a()
def c(): b()
def d(): c()
def e(): d()

def f():
    try:
        e()
    except:
        print(f"Goodbye, stack. ;-;")

def g(): f()
def h(): g()
def i(): h()


i()  # f will catch it

e()  # nothing will catch it and kaboom
Goodbye, stack. ;-;
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In [18], line 20
     15 def i(): h()
     18 i()  # f will catch it
---> 20 e()

Cell In [18], line 5, in e()
----> 5 def e(): d()

Cell In [18], line 4, in d()
----> 4 def d(): c()

Cell In [18], line 3, in c()
----> 3 def c(): b()

Cell In [18], line 2, in b()
----> 2 def b(): a()

Cell In [18], line 1, in a()
----> 1 def a(): raise Exception("Hello, stack!")

Exception: Hello, stack!

Можем да си дефинираме собствени изключения, като наследяваме класа Exception (или някой от неговите наследници):

class InvalidPortionException(Exception):
    def __init__(self, portion: tuple[str, str]) -> None:
        super().__init__(*portion)
    
    def __str__(self) -> str:
        ingredients = " sus ".join(self.args)
        return f"Ama kak taka, ne moje {ingredients}!"


class LelkataOtStolaDoNas:
    def __init__(self):
        osnovni = ("schnietzel", "kyufteta", "kartofeni kyufteta")
        garnituri = ("kartofi", "oriz", "zele")
        self.__allowed_portions = dict(zip(osnovni, garnituri))  # private shototo samo tq si gi znae...
    
    def order(self, osnovno: str, garnitura: str) -> None:
        portion = (osnovno, garnitura)

        if portion not in self.__allowed_portions.items():
            raise InvalidPortionException(portion)
        
        print("Krem, airqn?")

lelkata = LelkataOtStolaDoNas()
lelkata.order("kyufteta", "kartofi")
---------------------------------------------------------------------------
InvalidPortionException                   Traceback (most recent call last)
Cell In [19], line 25
     22         print("Krem, airqn?")
     24 lelkata = LelkataOtStolaDoNas()
---> 25 lelkata.order("kyufteta", "kartofi")

Cell In [19], line 20, in LelkataOtStolaDoNas.order(self, osnovno, garnitura)
     17 portion = (osnovno, garnitura)
     19 if portion not in self.__allowed_portions.items():
---> 20     raise InvalidPortionException(portion)
     22 print("Krem, airqn?")

InvalidPortionException: Ama kak taka, ne moje kyufteta sus kartofi!

Възможно е освен това да се chain-ват изключения, когато в except блока се хвърли друго:

class UnbeknownstToMeException(Exception):
    def __init__(self, fact: str) -> None:
        msg = f"Е аз откъде да знам, че не може {fact}..."
        super().__init__(msg)


lelka = LelkataOtStolaDoNas()
try:
    lelka.order("kyufteta", "zele")
except InvalidPortionException as e:
    portion = " със ".join(e.args)
    raise UnbeknownstToMeException(portion)
---------------------------------------------------------------------------
InvalidPortionException                   Traceback (most recent call last)
Cell In [20], line 9
      8 try:
----> 9     lelka.order("kyufteta", "zele")
     10 except InvalidPortionException as e:

Cell In [19], line 20, in LelkataOtStolaDoNas.order(self, osnovno, garnitura)
     19 if portion not in self.__allowed_portions.items():
---> 20     raise InvalidPortionException(portion)
     22 print("Krem, airqn?")

InvalidPortionException: Ama kak taka, ne moje kyufteta sus zele!

During handling of the above exception, another exception occurred:

UnbeknownstToMeException                  Traceback (most recent call last)
Cell In [20], line 12
     10 except InvalidPortionException as e:
     11     portion = " със ".join(e.args)
---> 12     raise UnbeknownstToMeException(portion)

UnbeknownstToMeException: Е аз откъде да знам, че не може kyufteta със zele...

Disclaimer: историята е по действителен случай, obv.

За да индикираме, че дадено изключение е директно следствие на друго, може да използваме raise ... from .... Повече инфо в документацията.

assert

Когато искаме да предпазим изпълнението на код от недифинрано поведение, можем да използваме ключовата дума assert. Тя проверява дали дадено условие е изпълнено и ако не е, то хвърля AssertionError с някакво съобщение за грешка, което може да зададем ако искаме. Обикновено се използва на фаза разработка и тестване на код, но може и да се използва и в production код, ако сме сигурни, че няма никога да се изпълни и че ако все пак се изпълни, то е окей програмата ни да крашне.

from typing import Iterable

def evaluate(test_results: Iterable[int], actual_results: Iterable[int]) -> float:
    assert len(test_results) == len(actual_results), "Expected and actual result series must be of equal length!"
    return (sum((t - a) ** 2 for t, a in zip(test_results, actual_results)) / len(test_results)) ** 0.5  # RMSE


print(evaluate([1, 2, 3], [2, 2, 4]))
print(evaluate([1, 2, 3], [2, 2, 4, 5]))
0.816496580927726
---------------------------------------------------------------------------
AssertionError                            Traceback (most recent call last)
Cell In [21], line 9
      5     return (sum((t - a) ** 2 for t, a in zip(test_results, actual_results)) / len(test_results)) ** 0.5  # RMSE
      8 print(evaluate([1, 2, 3], [2, 2, 4]))
----> 9 print(evaluate([1, 2, 3], [2, 2, 4, 5]))

Cell In [21], line 4, in evaluate(test_results, actual_results)
      3 def evaluate(test_results: Iterable[int], actual_results: Iterable[int]) -> float:
----> 4     assert len(test_results) == len(actual_results), "Expected and actual result series must be of equal length!"
      5     return (sum((t - a) ** 2 for t, a in zip(test_results, actual_results)) / len(test_results)) ** 0.5

AssertionError: Expected and actual result series must be of equal length!

В някои случаи можем да очакваме да прихванем и AssertionError, примерно за да логнем това състояние, вместо да ни крашне програмата:

try:
    evaluate([1, 2, 3], [2, 2, 4, 5])
except AssertionError as e:
    logger.error(e)
Expected and actual result series must be of equal length!