Python Exceptions
Published: 2024-04-27 16:39:09

An `Exception` is a special type of object in Python, and Python has many [built-in exception types](https://docs.python.org/3/library/exceptions.html). All exceptions must be instances of a class that derives from [BaseException](https://docs.python.org/3/library/exceptions.html#BaseException), which means that Python exceptions form a [hierarchy](https://docs.python.org/3/library/exceptions.html#exception-hierarchy).

To **raise an exception**, means to **start an exception event flow**. Exception handling is interacting with an exception flow in some manner. An **unhandled exception** is an exception flow that is not handled by your code, and generally it means that your program will terminate abruptly.

There are several built-in functions you can use to inspect the exceptions:

```python
# Create an exception object
ex = ValueError("An unexpected value has been provided.")

# Inspect an exception
type(ex) # -> ValueError
print(ex) # -> An unexpected value has been provided.
repr(ex) # -> ValueError("An unexpected value has been provided.")

# Raise an exception
raise ex
```

## LBYL vs EAFP

Something that is "exceptional" should be infrequent. If we are dividing two integers in a loop that repeats 1.000 times, and if, out of every 1.000 we run, we expect division by zero to occur 5 times, we might have 2 approaches:

- **LBYL (Look Before You Leap)** – Test that divisor is non-zero 1.000 times.
- **EAFP (Easier to Ask Forgiveness than Permission)** – Just do it, and handle the division by zero error 5 times (this is often more efficient).

Also, trying to fully determine if something is going to go wrong is a lot harder to write than just handling things when they do go wrong.

> Read the [Look Before You Leap](https://codingnomads.com/look-before-you-leap) article for more info on LBYL and EAFP approaches for exceptions.

## Optimizing Performance with Exceptions

In some cases, relying on exceptions can help you improve your code performance. Consider the following Python code:

```python
from timeit import timeit

def process():
    l = list(range(1_000))
    while len(l) > 0: # Calling the len() function in each iteration
        l.pop()

timeit("process()", globals=globals(), number=500_000) # 40.69522000011057
```

The issue with this code is that it checks the length of the list by calling Python's built-in function `len()` at every iteration.

A significant performance improvement can be achieved by using the `for` loop, which checks the length of the list only once:

```python
from timeit import timeit

def process():
    l = list(range(1_000))
    for i in range(len(l)): # Calling the len() function once
        l.pop()

timeit("process()", globals=globals(), number=500_000) # 25.074866899987683
```

In this case, the code executes almost 50% faster (25s compared to previous 40s). The issue here is that the `process()` function is again being called `500.000` times by the `timeit()` function, and the `process()` function still invokes the `len()` built-in function at every iteration.

A better solution turns out to be using the `try-except` block, where you simply ignore the `IndexError` exception that gets raised if the `pop()` method is called on an empty list:

```python
from timeit import timeit

def process():
    try:
        l = list(range(1_000))
        while True:
            l.pop()
    except IndexError:
        ... # Ignore the exception

timeit("process()", globals=globals(), number=500_000) # 18.16189290001057
```

With this approach the same code takes only 18 seconds to execute. It is worth mentioning that you should always target a specific exception – in this case `IndexError`, not `Exception`, which is too broad.

## Using Your Own Exceptions

In Python you can define your own custom exceptions:

```python
# Define a custom exception class
class MyCustomException(Exception):
    def __init__(self, message="A custom exception occurred"):
        self.message = message
        super().__init__(self.message)

# Check if MyCustomException is a subclass of Exception
print(issubclass(MyCustomException, Exception)) # True

# Example usage of the custom exception:
try:
    raise MyCustomException
except MyCustomException as ex:
    print(f"Caught exception #1: {ex}") # Caught exception #1: A custom exception occurred

# Example usage of the custom exception with different error message:
try:
    raise MyCustomException("This is a custom exception")
except MyCustomException as ex:
    print(f"Caught exception #2: {ex}") # Caught exception #2: This is a custom exception
```

## `try-except-else-finally`

In combination with the `try-except` block, Python's syntax allows you to use the `else` clause that executes only if the `try-except` block was successful, and the `finally` clause that is always executed, so you can use it to ensure that certain tasks are performed if your application terminates abruptly:

```python
# No exceptions raised
x = 4
y = 2

try:
    z = x / y
except ZeroDivisionError:
    raise
else:
    print(f"ELSE: {x} / {y} = {z}")
finally:
    print("FINALLY: Perform some tasks...")

# ELSE: 4 / 2 = 2.0
# FINALLY: Perform some tasks...
```

```python
# Dividing by zero raises ZeroDivisionError
x = 4
y = 0

try:
    z = x / y
except ZeroDivisionError:
    raise
else:
    print(f"{x} / {y} = {z}")
finally:
    print("Perform some tasks...")

# FINALLY: Perform some tasks...
# ---------------------------------------------------------------------------
# ZeroDivisionError                         Traceback (most recent call last)
# Cell In[9], line 5
#       2 y = 0
#       4 try:
# ----> 5     z = x / y
#       6 except ZeroDivisionError:
#       7     raise
# 
# ZeroDivisionError: division by zero
```