05: Adding Wings to Python

Introduction

This chapter covers advanced topics that go beyond the basics of Python usage, aiming to maximize productivity and write more efficient code. The code developed through this chapter will be more robust, scalable, and easier to maintain.

1. Context Managers

Context managers are a Python feature that automates resource allocation and release in scenarios such as file opening, database connections, and using locks. They enhance code readability and reduce the likelihood of bugs.

1.1 Basic Usage of Context Managers

The most common example of a context manager in Python is opening a file using the with statement.

with open('example.txt', 'r') as file:
    data = file.read()
    # The file is automatically closed when the block ends.

1.2 Custom Context Managers

To implement a context manager, you just need to define a class with __enter__ and __exit__ methods.

class CustomContext:
    def __enter__(self):
        # Resource allocation or setup
        print("Resource allocated")
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        # Resource release
        print("Resource released")

with CustomContext() as context:
    print("Inside the block")

This example manages resources by recognizing the start and end of a block.

2. Generators

Generators are a simplified version of iterators and can save memory when processing large datasets. Generators yield one value at a time and wait until the next value is needed. This property allows generators to efficiently handle large datasets.

2.1 Generator Functions

Generator functions are defined like regular functions but use yield instead of return to provide values.

def simple_generator():
    yield 1
    yield 2
    yield 3

The above function creates a generator object that returns 1, 2, and 3 sequentially each time it is called.

2.2 Infinite Generators

Generators can easily create infinite loops, making them useful for processes that repeat periodically.

def infinite_sequence():
    num = 0
    while True:
        yield num
        num += 1

This function returns an increasing number starting from 0 indefinitely until stopped.

3. Decorators

Decorators are powerful tools that dynamically alter or extend the behavior of functions or methods. They greatly enhance code reusability and are primarily used for logging, access control, and metrics.

3.1 Definition and Use of Decorators

Decorators can wrap another function to add specific logic or modify the input and output of an existing function.

def my_decorator(func):
    def wrapper():
        print("Before function call")
        func()
        print("After function call")
    return wrapper

@my_decorator
def say_hello():
    print("Hello!")

say_hello()

In this example, the functionality added by the decorator runs when the say_hello function is called.

3.2 Combining Multiple Decorators

Multiple decorators can be applied to a single function, and their behavior can vary based on the order of the decorators.

def decorator_one(func):
    def wrapper():
        print("Decorator 1 applied")
        func()
    return wrapper

def decorator_two(func):
    def wrapper():
        print("Decorator 2 applied")
        func()
    return wrapper

@decorator_two
@decorator_one
def display():
    print("Display function")

display()

4. Parallelism and Multithreading

To improve program performance, you can use parallelism or multithreading. This allows the code to utilize multiple CPU cores to perform tasks simultaneously.

4.1 Multithreading

Multithreading is useful in cases where there are many I/O bound tasks. You can create threads using Python’s threading module.

import threading
import time

def thread_function(name):
    print(f"Thread {name} started")
    time.sleep(2)
    print(f"Thread {name} ended")

threads = []
for i in range(3):
    thread = threading.Thread(target=thread_function, args=(i,))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

This code creates three threads, each performing a task for 2 seconds.

4.2 Multiprocessing

For CPU bound tasks, the multiprocessing module is more efficient. It creates processes to fully utilize CPU cores.

from multiprocessing import Process

def process_function(name):
    print(f"Process {name} started")
    time.sleep(2)
    print(f"Process {name} ended")

processes = []
for i in range(3):
    process = Process(target=process_function, args=(i,))
    processes.append(process)
    process.start()

for process in processes:
    process.join()

5. Advanced Exception Handling

Exception handling is essential for enhancing the reliability of programs. In this section, we will explore advanced exception handling techniques.

5.1 Creating Custom Exceptions

You can create custom exceptions to explicitly express exceptions that may occur in specific situations.

class CustomError(Exception):
    pass

try:
    raise CustomError("This is a custom exception")
except CustomError as e:
    print(e)

5.2 Exception Chaining

One exception may be the result of another exception. In Python, you can create exception chains using the raise ... from ... syntax.

try:
    raise ValueError("First exception")
except ValueError as ve:
    raise KeyError("Second exception") from ve

6. Conclusion

This chapter has delved deeper into Python’s advanced features. By leveraging these techniques, you can write more robust and scalable code. In the next chapter, we will explore how to use Python for data analysis.