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)