Exception Handling#
Today, we will explore exceptions in Python. However, before we delve into exceptions, let’s briefly discuss the data structure known as the stack.
Stack#
A stack is a data structure that follows the Last In, First Out (LIFO) principle, meaning that the last element added to the stack is the first one to be removed. It can be visualized as a collection of elements with two main operations: push, which adds an element to the top of the stack, and pop, which removes the top element from the stack.
Key points of stack:
Abstract data type
Collection of elements
Push operation: adds an element to the collection
Pop operation: removes the most recently added element
Note
Think of a stack data structure as a can of Pringles chips - a perfect analogy to understand how it works.
In this analogy, the can symbolizes the stack, and the chips nestled inside represent the elements stored in the stack. Here’s a breakdown of how the analogy mirrors the operations of a stack:
Push Operation (Adding Chips):
Just like adding new chips to a can, the “push” operation allows us to add elements to the stack, but always at the top.
Pop Operation (Taking the Top Chip):
The “pop” operation corresponds to taking a chip from the top of the can. It follows the principle of retrieving the most recently added element.
If you want a chip, you reach for the top one - the last chip added to the can.
By visualizing the stack as a can of Pringles, it’s easy to grasp the Last In, First Out (LIFO) nature of the stack data structure. The can’s opening serves as a reminder that we can only add or take chips from the top, illustrating the strict order in which elements are managed. This analogy provides a tangible way to understand the fundamental operations of a stack.
Representing stack functionality using the list
data type#
We can simulate a stack data structure efficiently by leveraging the built-in list data type in Python.
Warning
In reality, a list and a stack are distinct data structures. To delve deeper into their individual characteristics and functionalities, you can explore the following links:
stack = []
stack.append("element 1") # append ~ pop
print(f"Stack is: {stack}")
stack.append("element 2")
stack.append("element 3")
popped_elem = stack.pop()
print(f"Popped element: {popped_elem}")
print(f"Stack is: {stack}")
Show code cell output
Stack is: ['element 1']
Popped element: element 3
Stack is: ['element 1', 'element 2']
Call Stack#
A call stack, often referred to simply as the “stack,” is a region of memory used in computer science to manage function and method calls in a program. It keeps track of the active subroutines (functions or methods) and their respective local variables. The call stack operates on the Last In, First Out (LIFO) principle, meaning that the last function call made is the first one to be resolved.
Simple function calls#
def func_a():
print("Function A start")
func_b()
print("Function A end")
def func_b():
print("Function B start")
print("Function B end")
# Main program
func_a()
Function A start
Function B start
Function B end
Function A end
In this example, func_a
calls func_b
, and the call stack looks like this:
func_a
is called.Inside
func_a
,func_b
is called.func_b
completes its execution.Control returns to the point immediately after the call to
func_b
insidefunc_a
.func_a
completes its execution.
We can use the traceback
package to display the call stack at a particular point in our code:
import traceback
def func_a():
func_b()
def func_b():
traceback.print_stack()
# Main program
func_a()
The output for the provided code snippet is as follows:
File "...", line 11, in <module>
func_a()
File "...", line 4, in func_a
func_b()
File "...", line 7, in func_b
traceback.print_stack()
Recursive function#
def factorial(n):
if n == 0 or n == 1:
return 1
else:
return n * factorial(n - 1)
result = factorial(5)
print("Factorial of 5:", result)
Factorial of 5: 120
In this example, the factorial
function calls itself recursively.
The call stack looks like this:
factorial(5)
callsfactorial(4)
factorial(4)
callsfactorial(3)
factorial(3)
callsfactorial(2)
factorial(2)
callsfactorial(1)
factorial(1)
returns 1 (base case)Control returns to
factorial(2)
, which returns 2 * 1 = 2Control returns to
factorial(3)
, which returns 3 * 2 = 6Control returns to
factorial(4)
, which returns 4 * 6 = 24Control returns to
factorial(5)
, which returns 5 * 24 = 120
Stack Overflow#
The call stack has a specific size, meaning that if a program has an excessive number of function calls, it can lead to a stack overflow error. This occurs when the available memory for the call stack is exhausted. Let’s examine an example to illustrate this:
def factorial(arg):
"""Custom function calculating factorial"""
assert_msg = "Ordinary factorial defined for"\
" numbers greater or equal than zero"
assert arg >= 0, assert_msg
if arg == 0:
return 1
return arg * factorial(arg - 1)
print(factorial(2000))
This situation emphasizes the importance of carefully managing recursion and function calls to prevent stack overflow errors, as exceeding the stack size can lead to program termination. Recursive functions should be designed with appropriate base cases to ensure that the recursion stops and doesn’t lead to an infinite loop, ultimately causing a stack overflow.
However, In Python sometimes we could need to manually increase the call stack size. In this case we can use the sys
module:
import sys
import math
print(sys.getrecursionlimit())
sys.setrecursionlimit(10000)
def factorial(arg):
"""Custom function calculating factorial"""
assert_msg = "Ordinary factorial defined for"\
" numbers greater or equal than zero"
assert arg >= 0, assert_msg
if arg == 0:
return 1
return arg * factorial(arg - 1)
print(f"{math.log10(factorial(2000))}")
Show code cell output
3000
5735.520650549073
Note
Chances are you’ve come across a valuable resource known as Stack Overflow. If you’re curious about why it’s named the way it is, here you can find a few intriguing possibilities.
Understanding the call stack becomes particularly crucial when handling exceptions and interpreting their output. A solid grasp of the call stack allows developers to navigate through the sequence of function calls leading to an exception, aiding in the identification and resolution of issues within the code.
Syntax Errors and Exceptions#
In Python, errors can be broadly classified into two main categories: Syntax Errors and Exceptions. While they both signal that something has gone awry in our code, they play different roles in the debugging and error-handling process.
Syntax Errors#
Let’s start by looking at Syntax Errors. These occur when the Python interpreter encounters code that violates the language’s syntax rules. In other words, it’s like a grammatical mistake in our code.
# Example of a Syntax Error
print("Hello, world!"
In this example, a missing closing parenthesis will result in a SyntaxError. These errors are caught by the interpreter during the compilation phase, preventing the program from running.
Note
Understanding and fixing syntax errors is fundamental. They are often the first roadblock we face when translating our ideas into executable code. Fortunately, Python provides clear error messages that guide us to the problematic lines.
Exceptions#
In Python, an exception is a runtime error that interrupts the normal flow of a program. These errors can occur due to various reasons, such as invalid input, file not found, or division by zero. Understanding and handling exceptions is crucial for writing robust and reliable Python code.
Python comes with a set of built-in exceptions, each serving a specific purpose. Some common built-in exceptions include:
SyntaxError
: Raised when the Python interpreter encounters a syntax error.TypeError
: Raised when an operation or function is applied to an object of an inappropriate type.ValueError
: Raised when a built-in operation or function receives an argument of the correct type but an inappropriate value.FileNotFoundError
: Raised when a file or directory is requested but cannot be found.
Some examples that can cause different exceptions include:
print(1 / 0)
ZeroDivisionError: division by zero
print(int('abc'))
ValueError: invalid literal for int() with base 10: 'abc'
In Python, exceptions follow a structured inheritance hierarchy, forming a conceptual tree that represents their relationships. This hierarchy is instrumental in understanding the various types of exceptions and how they relate to one another. Think of it as an organized family tree for Python’s exception classes.
To explore this hierarchy comprehensively, you can refer to the complete structure here. This visual representation provides insights into the parent-child relationships among different exception classes, showcasing the inheritance patterns that define the Python exception system.
Exception Handling#
Python offers an elegant mechanism for dealing with exceptions. You might wonder why bother with it. However, handling exceptions in Python is essential for numerous reasons, enhancing the overall robustness, reliability, and maintainability of your code. Let’s explore key reasons why handling exceptions is so important:
Preventing Program Crashes: Without proper exception handling, unanticipated errors can cause your program to crash. Handling exceptions allows you to gracefully manage errors, preventing the entire program from terminating unexpectedly.
User-Friendly Error Messages: Exception handling enables you to provide meaningful and user-friendly error messages. This helps users or developers understand what went wrong, making it easier to identify and fix issues.
Graceful Degradation: In the presence of unexpected conditions, well-handled exceptions allow your program to degrade gracefully. Instead of abruptly stopping, your application can take appropriate actions, log the error for later analysis, or prompt the user for corrective input.
try
- except
statement#
The primary mechanism for handling exceptions in Python is the try
and except
block. Let’s start from the classic example:
while True:
try:
a = int(input("Enter integer: "))
b = int(input("Enter integer: "))
result = a / b
print("result of division is:", result)
break
except ZeroDivisionError:
print("You're trying to divide by zero ")
Let’s break down the code snippet step by step:
The code starts with an infinite loop, denoted by
while True
. This means the code inside the loop will keep executing indefinitely until abreak
statement is encountered. Within the loop, there’s atry
block. The code inside this block is the main body of the loop that attempts to execute without error Inside thetry
block, the user is prompted to enter two integers using input statements.The entered values are converted to integers (
int()
), and if the user provides non-integer input, aValueError
will be raised. After successfully obtaining two integers (a
andb
), the code proceeds to perform a division operation. If the user enters0
forb
, aZeroDivisionError
will be raised. If all the above steps are executed without encountering any errors, the code prints the result of the division: After printing the result, thebreak
statement is encountered, which exits the loop.If a
ZeroDivisionError
occurs during the division operation (result = a / b
), the control is transferred to theexcept ZeroDivisionError
block. In this case, the program prints a message indicating that the user is attempting to divide by zero. After handling the exception, the loop continues, prompting the user to enter integers again. The user keeps entering values until a valid division operation is performed (i.e., a non-zero denominator is provided), and thebreak
statement is executed, exiting the loop.
Multiple except
blocks#
In our previous example, we effectively addressed the situation of ZeroDivisionError
. However, it’s noteworthy that our program would crash if confronted with a ValueError
. To mitigate this, we can enhance our error handling by incorporating multiple except blocks in our program:
while True:
try:
a = int(input("Enter integer: "))
b = int(input("Enter integer: "))
result = a / b
print("result of division is:", result)
break
except ZeroDivisionError:
print("You're trying to divide by zero ")
except ValueError:
print("Some of the input couldn't be converted to integer number")
else
and finally
statements#
We can include additional statements to the except
blocks:
The
else
block is executed if no exceptions are raised in thetry
block.The
finally
block is always executed, whether an exception occurs or not. It is useful for cleanup operations.
try:
a = int(input("Enter integer: "))
b = int(input("Enter integer: "))
result = a / b
print("result of division is:", result)
except ZeroDivisionError:
print("You're trying to divide by zero ")
else:
print("There were no exceptions")
finally:
print("Finally block")
Exceptions Hierarchy#
The exception hierarchy in Python is a structured organization of exception classes that represent various types of errors. This hierarchy is designed to provide a systematic way to handle and categorize exceptions based on their relationships. The hierarchy can be visualized as a tree, with a base class at the root and specialized exception classes branching out from it.
The full hierarchy of exceptions could be found here
Let’s delve into the Python exception hierarchy with code examples:
while True:
try:
a = int(input("Enter integer: "))
b = int(input("Enter integer: "))
result = a / b
print("result of division is:", result)
break
except Exception:
print("General Exception happened")
except ZeroDivisionError:
print("You're trying to divide by zero")
The noteworthy aspect in this example is that if there is any exception that is a subclass of Exception
, it won’t be reached. Therefore, it is essential to consider the precedence of exceptions you intend to handle. Careful consideration of exception order ensures that specific exceptions are caught and processed before more general ones, leading to effective and precise error handling.
Typically, it’s advisable to begin with more specific exceptions and place more general ones at the end of the except block. This ensures that the program prioritizes handling specific error conditions before addressing more general cases, contributing to a more nuanced and effective exception-handling strategy.
Caution
It’s a good idea to not try to deal with specific types of issues like SystemExit
, KeyboardInterrupt
, GeneratorExit
, and AssertionError
. Handling these might cause unexpected problems, so it’s better to let them be and not interfere with how the program normally works. This helps maintain the program’s stability and expected behavior.
Raise an exception#
We can manually raise exceptions using the raise
statement. This is helpful when a specific condition is not met, and you want to signal an error.
def validate_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age
try:
user_age = validate_age(-5)
except ValueError as e:
print(f"Validation error: {e}")
Validation error: Age cannot be negative
Here we specify the situation inside the validate_age
function where we raise a ValueError
exception. Additionally, we provide a custom message for our exception.
In case if a ValueError
is raised during the execution of the try
block (which happens when the age is negative), the control transfers to the except
block.
The code inside the except
block prints a message, including the error message from the raised ValueError
.
You can see that we used the syntax ValueError as e
, which allows us to refer to the exception object later on inside the exception
block.
User-defined exceptions#
We can craft our custom exceptions through inheritance. To illustrate, let’s create an exception inheriting from the base Exception
class:
class BioSeqException(Exception):
def __init__(self, msg):
self.msg = msg
def __str__(self):
return self.msg
Now, let’s develop a class where we can apply our custom exception:
def check_seq(seq):
if set(seq) <= set(["A", "C", "G", "T"]):
return
raise BioSeqException("Sequence should contain only ACGT symbols")
class BioSeq:
def __init__(self, seq):
check_seq(seq)
self.seq = seq
Attempting to create a sequence containing symbols outside of ACGT will trigger a BioSeqException
:
s = BioSeq("ACY")
BioSeqException: Sequence should contain only ACGT symbols
This way, we’ve defined a custom exception, BioSeqException
, and utilized it in our BioSeq
class to enforce constraints on the allowed sequence symbols. This enhances code clarity and facilitates the handling of specific error conditions related to biological sequences.