DEV Community

Nitin Bansal
Nitin Bansal

Posted on

A few lesser-known but pretty useful Python concepts

  1. Context Managers
  2. Generators and Generator Expressions
  3. Function Decorators
  4. Namedtuples
  5. Coroutines
  6. Operator Overloading
  7. Monkey Patching
  8. Type Hints and Annotations
  9. Metaclasses
  10. Data Classes
  11. Context Variables
  12. Callable Objects
  13. Extended Iterable Unpacking
  14. Multiple Inheritance and Method Resolution Order (MRO)

A. Context Managers:

Context managers allow you to allocate and release resources when needed, such as opening and closing files, acquiring and releasing locks, etc. They can be implemented using the with statement and the contextlib module.

Examples

  • File Handling
with open('file.txt', 'r') as file:
    data = file.read()
    # Perform operations on the file
# File automatically closed outside the 'with' block
Enter fullscreen mode Exit fullscreen mode
  • Lock Acquisition and Release
import threading

lock = threading.Lock()
with lock:
    # Perform thread-safe operations
# Lock automatically released outside the 'with' block
Enter fullscreen mode Exit fullscreen mode
  • Database Connection
import sqlite3

with sqlite3.connect('database.db') as connection:
    cursor = connection.cursor()
    # Perform database operations
# Connection automatically closed outside the 'with' block
Enter fullscreen mode Exit fullscreen mode
  • Timing Execution
import time

class Timer:
    def __enter__(self):
        self.start_time = time.time()
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        elapsed_time = time.time() - self.start_time
        print(f"Execution time: {elapsed_time} seconds")

with Timer():
    # Code to measure execution time
# Timer context manager prints the execution time
Enter fullscreen mode Exit fullscreen mode

Creating a custom context manager

class CustomContextManager:
    def __enter__(self):
        # Code to run when entering the 'with' block
        print("Entering the 'with' block")
        # You can optionally return an object or set up resources

    def __exit__(self, exc_type, exc_val, exc_tb):
        # Code to run when exiting the 'with' block
        print("Exiting the 'with' block")
        # Perform any cleanup actions or handle exceptions if necessary

# Usage example:
with CustomContextManager():
    # Code inside the 'with' block
    print("Inside the 'with' block")

# Output:
# Entering the 'with' block
# Inside the 'with' block
# Exiting the 'with' block
Enter fullscreen mode Exit fullscreen mode

B. Generators and Generator Expressions:

Generators are functions that generate values on the fly, allowing you to iterate over a sequence of values without creating the entire sequence in memory. Generator expressions are similar to list comprehensions but return a generator object instead of a list.

  • Generator Function
def countdown(n):
    while n > 0:
        yield n
        n -= 1

# Using the generator function
for num in countdown(5):
    print(num)

# Output:
# 5
# 4
# 3
# 2
# 1
Enter fullscreen mode Exit fullscreen mode
  • Generator Expression
# Using a generator expression to calculate the squares of numbers
squares = (x ** 2 for x in range(1, 6))

# Accessing the values from the generator expression
for square in squares:
    print(square)

# Output:
# 1
# 4
# 9
# 16
# 25
Enter fullscreen mode Exit fullscreen mode
  • Infinite Generator
def infinite_sequence():
    num = 0
    while True:
        yield num
        num += 1

# Using the infinite generator
for i in infinite_sequence():
    if i > 10:
        break
    print(i)

# Output:
# 0
# 1
# 2
# 3
# 4
# 5
# 6
# 7
# 8
# 9
# 10
Enter fullscreen mode Exit fullscreen mode

C. Function Decorators:

Decorators are a way to modify the behavior of functions or classes by wrapping them with another function. They are denoted by the @ symbol and can be used for tasks like logging, timing, and caching.

  • Logging Decorator: This decorator logs the name of the decorated function before and after its execution
def log_decorator(func):
    def wrapper(*args, **kwargs):
        print(f'Calling {func.__name__}...')
        result = func(*args, **kwargs)
        print(f'{func.__name__} called.')
        return result
    return wrapper

@log_decorator
def add_numbers(a, b):
    return a + b

result = add_numbers(3, 5)
print(result)

# Output:
# Calling add_numbers...
# add_numbers called.
# 8
Enter fullscreen mode Exit fullscreen mode
  • Timing Decorator: This decorator measures the execution time of the decorated function
import time

def time_decorator(func):
    def wrapper(*args, **kwargs):
        start_time = time.time()
        result = func(*args, **kwargs)
        end_time = time.time()
        execution_time = end_time - start_time
        print(f'{func.__name__} executed in {execution_time} seconds.')
        return result
    return wrapper

@time_decorator
def factorial(n):
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n - 1)

result = factorial(5)
print(result)  # Output: 120

# Output:
# factorial executed in 9.5367431640625e-07 seconds.
# factorial executed in 7.605552673339844e-05 seconds.
# factorial executed in 9.322166442871094e-05 seconds.
# factorial executed in 0.00010275840759277344 seconds.
# factorial executed in 0.00011801719665527344 seconds.
# 120
Enter fullscreen mode Exit fullscreen mode
  • Authorization Decorator: This decorator checks if the user is authorized to access the decorated function
def is_user_authorized():
    return False

def authorize_decorator(func):
    def wrapper(*args, **kwargs):
        if is_user_authorized():
            return func(*args, **kwargs)
        else:
            raise PermissionError('User is not authorized to access this function.')
    return wrapper

@authorize_decorator
def sensitive_operation():
    return 'Sensitive data'

result = sensitive_operation()
print(result)

# Output:
# Traceback (most recent call last):
#   File "/Users/dev1/python/app.py", line 16, in <module>
#     result = sensitive_operation()
#   File "/Users/dev1/mygitlab/python/app.py", line 9, in wrapper
#     raise PermissionError('User is not authorized to access this function.')
# PermissionError: User is not authorized to access this function.
Enter fullscreen mode Exit fullscreen mode

D. Namedtuples:

Namedtuples are lightweight data structures that are similar to tuples but have named fields. They provide a convenient way to define simple classes without writing a custom class definition. They are commonly used in scenarios where lightweight data containers are required.

  • Creating a Namedtuple
from collections import namedtuple

# Define a namedtuple called 'Point' with fields 'x' and 'y'
Point = namedtuple('Point', ['x', 'y'])

# Create an instance of Point
p = Point(2, 3)

# Access the fields using dot notation
print(p.x)  # Output: 2
print(p.y)  # Output: 3
Enter fullscreen mode Exit fullscreen mode
  • Namedtuple as a Return Type
from collections import namedtuple

# Define a namedtuple called 'Person' with fields 'name', 'age', and 'city'
Person = namedtuple('Person', ['name', 'age', 'city'])

# Function that returns a Person namedtuple
def get_person():
    return Person('John', 25, 'New York')

# Call the function and access the fields of the returned namedtuple
person = get_person()
print(person.name)  # Output: John
print(person.age)   # Output: 25
print(person.city)  # Output: New York
Enter fullscreen mode Exit fullscreen mode
  • Unpacking a Namedtuple
from collections import namedtuple

# Define a namedtuple called 'Color' with fields 'red', 'green', and 'blue'
Color = namedtuple('Color', ['red', 'green', 'blue'])

# Create an instance of Color
color = Color(255, 128, 0)

# Unpack the values into separate variables
red, green, blue = color

print(red)    # Output: 255
print(green)  # Output: 128
print(blue)   # Output: 0
Enter fullscreen mode Exit fullscreen mode

E. Coroutines:

Coroutines are functions that can pause their execution and yield control back to the caller while maintaining their state. They are used for asynchronous programming and are a powerful tool for managing concurrency and asynchronous programming, allowing for more efficient and flexible code execution.

  • Simple Coroutine
def coroutine_example():
    while True:
        x = yield
        print('Received:', x)

coroutine = coroutine_example()
next(coroutine)  # Initialize the coroutine
coroutine.send(10)  # Send a value to the coroutine
coroutine.send('Hello')  # Send another value to the coroutine
coroutine.close()   # close it

# Output:
# Received: 10
# Received: Hello
Enter fullscreen mode Exit fullscreen mode
  • Coroutine with Producer and Consumer
def producer(coroutine):
    for i in range(5):
        print('Producing:', i)
        coroutine.send(i)
    coroutine.close()

def consumer():
    while True:
        x = yield
        print('Consumed:', x)

coroutine = consumer()
next(coroutine)  # Initialize the coroutine
producer(coroutine)

# Output:
# Producing: 0
# Consumed: 0
# Producing: 1
# Consumed: 1
# Producing: 2
# Consumed: 2
# Producing: 3
# Consumed: 3
# Producing: 4
# Consumed: 4
Enter fullscreen mode Exit fullscreen mode
  • Coroutine Chaining
def coroutine1():
    while True:
        x = yield
        print('Coroutine 1:', x)

def coroutine2():
    while True:
        x = yield
        print('Coroutine 2:', x)

coroutine = coroutine1()
next(coroutine)  # Initialize the first coroutine
coroutine2()  # Initialize the second coroutine
coroutine.send(10)  # Send a value to the first coroutine
coroutine.send('Hello')  # Send another value to the first coroutine

# Output:
# Coroutine 1: 10
# Coroutine 1: Hello
Enter fullscreen mode Exit fullscreen mode

F. Operator Overloading:

Python allows you to redefine the behavior of operators for your custom classes by implementing special methods such as __add__, __sub__, __mul__, etc. This concept is known as operator overloading. You can overload various operators such as arithmetic operators, comparison operators, and more. Operator overloading allows you to customize the behavior of objects to make your code more expressive and intuitive

  • Arithmetic Operators
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)

    def __sub__(self, other):
        return Vector(self.x - other.x, self.y - other.y)

    def __mul__(self, scalar):
        return Vector(self.x * scalar, self.y * scalar)

    def __str__(self):
        return f"x: {self.x}, y: {self.y}"

# Usage:
v1 = Vector(2, 3)
v2 = Vector(4, 5)

v3 = v1 + v2  # Addition
v4 = v1 - v2  # Subtraction
v5 = v1 * 2   # Scalar multiplication

print(v3)
print(v4)
print(v5)

# Output:
# x: 6, y: 8
# x: -2, y: -2
# x: 4, y: 6
Enter fullscreen mode Exit fullscreen mode
  • Comparison Operators
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        return self.x == other.x and self.y == other.y

    def __lt__(self, other):
        return self.x < other.x and self.y < other.y

# Usage:
p1 = Point(2, 3)
p2 = Point(4, 5)

print(p1 == p2)  # Equality
print(p1 < p2)   # Less than

# Output:
# False
# True
Enter fullscreen mode Exit fullscreen mode
  • String Representation
class Book:
    def __init__(self, title, author):
        self.title = title
        self.author = author

    def __str__(self):
        return f"{self.title} by {self.author}"

# Usage:
book = Book("Python Programming", "John Smith")
print(book)  # String representation

# Output:
# Python Programming by John Smith
Enter fullscreen mode Exit fullscreen mode

G. Monkey Patching:

Monkey patching refers to the ability to modify or extend the behavior of existing code at runtime. In Python, you can dynamically modify classes, objects, or modules by adding, replacing, or deleting attributes or methods.

  • Adding a Method to a Class
class MyClass:
    def __init__(self, name):
        self.name = name

def say_hello(self):
    print(f"Hello, {self.name}!")

# Monkey patching
MyClass.say_hello = say_hello

# Creating an instance and calling the patched method
obj = MyClass("Alice")
obj.say_hello()

# Output:
# Hello, Alice!
Enter fullscreen mode Exit fullscreen mode
  • Modifying an Existing Method
class MyClass:
    def greeting(self):
        return "Hello!"

# Monkey patching
def modified_greeting(self):
    return "Hola!"

MyClass.greeting = modified_greeting

# Creating an instance and calling the modified method
obj = MyClass()
print(obj.greeting())

# Output:
# Hola!
Enter fullscreen mode Exit fullscreen mode
  • Adding a Function to a Module
def multiply(a, b):
    return a * b

# Monkey patching
import math
math.multiply = multiply

# Calling the patched function
result = math.multiply(5, 6)
print(result)

# Output:
# 30
Enter fullscreen mode Exit fullscreen mode

H. Type Hints and Annotations:

Type hints are a way to statically declare the expected types of variables, arguments, and return values in Python code. They are not enforced at runtime but can be used by static analysis tools to catch potential type-related errors.

  • Variable type hints:
def add(a: int, b: int) -> int:
    return a + b
Enter fullscreen mode Exit fullscreen mode
  • Class annotations:
class Person:
    def __init__(self, name: str, age: int):
        self.name = name
        self.age = age
Enter fullscreen mode Exit fullscreen mode
  • Type hints for collections
from typing import List, Tuple

def process(data: List[Tuple[str, str]]) -> List[str]:
    result: List[str] = []

    for item in data:
        result.append(item[0] * item[1])

    return result
Enter fullscreen mode Exit fullscreen mode

I. Metaclasses

Metaclasses in Python provide a way to define the behavior and structure of classes themselves. They allow you to customize the creation and initialization of classes.

Metaclasses provide a powerful mechanism for customizing class creation and behavior, but they should be used sparingly and only when necessary, as they can make the code more complex and harder to understand.

  • Creating a Singleton Metaclass
class SingletonMeta(type):
    _instances = {}

    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]


class SingletonClass(metaclass=SingletonMeta):
    def __init__(self):
        print("Initializing SingletonClass")


# Usage
instance1 = SingletonClass()
instance2 = SingletonClass()
print(instance1 is instance2)

# Output:
# Initializing SingletonClass
# True
Enter fullscreen mode Exit fullscreen mode
  • Creating an Attribute Validation Metaclass
class ValidationMeta(type):
    def __new__(cls, name, bases, attrs):
        for name, value in attrs.items():
            if name.startswith("_"):
                continue

            if not isinstance(value, (int, float)):
                raise TypeError(f"Attribute '{name}' must be numeric.")

        return super().__new__(cls, name, bases, attrs)


class MyClass(metaclass=ValidationMeta):
    x = 10
    y = "test"

# Output:
# Traceback (most recent call last):
#   File "/Users/dev1/python/learnings/app.py", line 13, in <module>
#     class MyClass(metaclass=ValidationMeta):
#   File "/Users/dev1/python/learnings/app.py", line 8, in __new__
#     raise TypeError(f"Attribute '{name}' must be numeric.")
# TypeError: Attribute 'y' must be numeric
Enter fullscreen mode Exit fullscreen mode
  • Registering Subclasses with a Metaclass
class PluginMeta(type):
    def __init__(cls, name, bases, attrs):
        if not hasattr(cls, "plugins"):
            cls.plugins = []
        else:
            cls.plugins.append(cls)


class PluginBase(metaclass=PluginMeta):
    pass


class Plugin1(PluginBase):
    pass


class Plugin2(PluginBase):
    pass


# Usage
print(PluginBase.plugins)

# Output
# [<class '__main__.Plugin1'>, <class '__main__.Plugin2'>]
Enter fullscreen mode Exit fullscreen mode

J. Data Classes

Python's data classes are a convenient way to define classes that primarily hold data, similar to structs in other programming languages. They provide several benefits, such as automatic generation of common methods like __init__, __repr__, and __eq__. Data classes provide additional features like type hints, default values, ordering, and more.

  • Basic Data Class
from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float
    z: float

p = Point(1.0, 2.0, 3.0)
q = Point(1.0, 2.0, 3.0)

print(p.x)
print(p.y)
print(p.z)

print(p)
print(p == q)

# Output
# 1.0
# 2.0
# 3.0
# Point(x=1.0, y=2.0, z=3.0)
# True
Enter fullscreen mode Exit fullscreen mode
  • Data Class with Default Values
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int = 0
    profession: str = "Unknown"

# Create an instance of the Person class
person1 = Person("Alice", 25)
person2 = Person("Bob", profession="Engineer")

# Access the fields of the instances
print(person1.name, person1.age, person1.profession)
print(person2.name, person2.age, person2.profession)

# Output:
# Alice 25 Unknown
# Bob 0 Engineer
Enter fullscreen mode Exit fullscreen mode
  • Inheritance with Data Classes
from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int

@dataclass
class Employee(Person):
    company: str

# Create an instance of the Employee class
employee = Employee("Alice", 30, "Acme Corporation")

# Access the fields of the instance
print(employee.name)
print(employee.age)
print(employee.company)

# Output:
# Alice
# 30
# Acme Corporation
Enter fullscreen mode Exit fullscreen mode

K. Context Variables

Introduced in Python 3.7, context variables allow you to create variables that retain their values within a context, even if the context is asynchronous or multi-threaded. They are useful for propagating values across functions without passing them explicitly as arguments.

  • Using contextvars module
import contextvars

user_id = contextvars.ContextVar("user_id", default=None)

def process_request(request):
    user_id.set(request.user_id)
    process_data()


def process_data():
    uid = user_id.get()
    print(f"processing for user: {uid}")
Enter fullscreen mode Exit fullscreen mode
  • Using threading.local for thread-local context
import threading

context = threading.local()

def process_request(request):
    context.user_id = request.user_id
    process_data()

def process_data():
    uid = context.user_id
    print(f"processing for user: {uid}")
Enter fullscreen mode Exit fullscreen mode
  • Using contextlib.ContextDecorator for context-based behavior
from contextlib import ContextDecorator
import time

class TimingContext(ContextDecorator):
    def __enter__(self):
        self.start_time = time.time()

    def __exit__(self, exc_type, exc_val, exc_tb):
        elapsed_time = time.time() - self.start_time
        print("Elapsed time:", elapsed_time)

@TimingContext()
def process_data():
    # Perform some time-consuming operation
    time.sleep(3)
    print("Data processing complete.")

process_data()

# Output:
# Data processing complete.
# Elapsed time: 3.0054383277893066
Enter fullscreen mode Exit fullscreen mode

L. Callable Objects

In Python, any object that can be called as a function is considered callable. This includes functions, methods, classes (which create instances when called), and objects implementing the __call__ method. You can use callable objects to create more flexible and dynamic code.

  • Classes with __call__ method: Classes that define the __call__ method can be called like functions
class Multiply:
    def __call__(self, a, b):
        return a * b

multiply = Multiply()
result = multiply(2, 3)
print(result)

# Output:
# 6
Enter fullscreen mode Exit fullscreen mode
  • Built-in callable objects: Some built-in objects in Python are callable, such as int, str, list, dict, etc.
result = str(42)
print(result)
# Output
# 42
Enter fullscreen mode Exit fullscreen mode

M. Extended Iterable Unpacking

Extended iterable unpacking, introduced in Python 3, allows you to unpack an iterable into multiple variables, including capturing remaining items into another variable. It simplifies tasks such as splitting lists, assigning values from tuples, and processing variable-length iterables.

  • Unpacking a list into variables
my_list = [1, 2, 3, 4, 5]
first, *middle, last = my_list

print(first)
print(middle)
print(last)

# Output:
# 1
# [2, 3, 4]
# 5
Enter fullscreen mode Exit fullscreen mode
  • Unpacking a string into variables
my_string = "Hello"
first, *rest = my_string

print(first)
print(rest)

# Output:
# H
# ['e', 'l', 'l', 'o']
Enter fullscreen mode Exit fullscreen mode
  • Unpacking a tuple of unknown length
my_tuple = (1, 2, 3, 4, 5)
first, *middle, last = my_tuple

print(first)
print(middle)
print(last)

# Output:
# 1
# [2, 3, 4]
# 5
Enter fullscreen mode Exit fullscreen mode

N. Multiple Inheritance and Method Resolution Order (MRO)

Python supports multiple inheritance, allowing a class to inherit from multiple base classes. Method Resolution Order (MRO) determines the order in which base classes are searched for a method. Understanding MRO helps you resolve method name conflicts and grasp the inheritance hierarchy.

class A:
    def say_hello(self):
        print("Hello from A")

class B(A):
    def say_hello(self):
        print("Hello from B")

class C(A):
    def say_hello(self):
        print("Hello from C")

class D(B, C):
    pass

d = D()
d.say_hello()

# Output:
# Hello from B
Enter fullscreen mode Exit fullscreen mode

When we create an instance of D and call the say_hello() method, Python follows the Method Resolution Order (MRO) to determine which implementation of the method should be executed.

The MRO is determined by the C3 linearization algorithm, which is a consistent and predictable method resolution order. In this case, the MRO for class D would be [D, B, C, A, object]. Thus, say_hello() of class B gets invoked.

Top comments (0)