Pythonic Code
Pythonic code follows Python’s design philosophy and idioms, making it more readable, maintainable, and efficient. This guide covers essential Python features that help you write idiomatic Python code.
Dunder Methods (Magic Methods)
Section titled “Dunder Methods (Magic Methods)”Dunder methods (short for “double underscore” methods) are special methods in Python that have names starting and ending with double underscores (__method__). They allow you to define the behavior of your objects for built-in operations, such as arithmetic operations, comparisons, and type conversions.
Common Dunder Methods
Section titled “Common Dunder Methods”class Vector: """A 2D vector class demonstrating dunder methods""" def __init__(self, x: float, y: float): self.x = x self.y = y
def __add__(self, other): """Enables: vector1 + vector2""" return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other): """Enables: vector1 - vector2""" return Vector(self.x - other.x, self.y - other.y)
def __mul__(self, scalar): """Enables: vector * scalar""" return Vector(self.x * scalar, self.y * scalar)
def __eq__(self, other): """Enables: vector1 == vector2""" return self.x == other.x and self.y == other.y
def __repr__(self): """Official string representation (for developers)""" return f"Vector({self.x}, {self.y})"
def __str__(self): """Informal string representation (for users)""" return f"({self.x}, {self.y})"
def __len__(self): """Enables: len(vector)""" return int((self.x ** 2 + self.y ** 2) ** 0.5)
# Usagev1 = Vector(3, 4)v2 = Vector(1, 2)v3 = v1 + v2 # Uses __add__print(v3) # Uses __str__: (4, 6)print(repr(v3)) # Uses __repr__: Vector(4, 6)print(len(v1)) # Uses __len__: 5classDiagram
class Vector {
-x: float
-y: float
+__init__(x, y)
+__add__(other) Vector
+__sub__(other) Vector
+__mul__(scalar) Vector
+__eq__(other) bool
+__repr__() str
+__str__() str
+__len__() int
}
More Dunder Methods
Section titled “More Dunder Methods”| Dunder Method | Purpose | Example Usage |
|---|---|---|
__init__ | Object initialization | obj = MyClass() |
__repr__ | Official string representation | repr(obj) |
__str__ | Informal string representation | str(obj) |
__add__ | Addition operator | obj1 + obj2 |
__sub__ | Subtraction operator | obj1 - obj2 |
__mul__ | Multiplication operator | obj1 * obj2 |
__eq__ | Equality comparison | obj1 == obj2 |
__lt__ | Less than comparison | obj1 < obj2 |
__len__ | Length | len(obj) |
__getitem__ | Index access | obj[key] |
__setitem__ | Index assignment | obj[key] = value |
__iter__ | Returns iterator | for item in obj |
__next__ | Next item in iterator | next(iterator) |
__contains__ | Membership test | item in obj |
__call__ | Makes object callable | obj() |
__enter__ | Context manager entry | with obj: |
__exit__ | Context manager exit | with obj: |
Context managers in Python are used to manage resources, ensuring they are properly acquired and released. They are typically used with the with statement and help prevent resource leaks.
Without context managers (error-prone):
file = open('file.txt', 'r')try: content = file.read()finally: file.close() # Must remember to close!With context managers (pythonic):
with open('file.txt', 'r') as file: content = file.read()# File is automatically closed after the blockClass-Based Context Manager
Section titled “Class-Based Context Manager”class DatabaseConnection: """Custom context manager for database connections""" def __init__(self, connection_string: str): self.connection_string = connection_string self.connection = None
def __enter__(self): """Called when entering the 'with' block""" print("Connecting to database...") self.connection = self.connect_to_database() return self.connection
def __exit__(self, exc_type, exc_value, traceback): """Called when exiting the 'with' block""" print("Closing database connection...") if self.connection: self.connection.close() return False # Don't suppress exceptions
def connect_to_database(self): # Simulate database connection return {"status": "connected"}
# Usagewith DatabaseConnection("postgresql://localhost/db") as db: print("Using database:", db) # Database operations here# Connection automatically closedFunction-Based Context Manager (using contextlib)
Section titled “Function-Based Context Manager (using contextlib)”from contextlib import contextmanager
@contextmanagerdef database_connection(connection_string: str): """Function-based context manager""" print("Connecting to database...") connection = connect_to_database(connection_string) try: yield connection # Value returned to 'as' variable finally: print("Closing database connection...") connection.close()
def connect_to_database(connection_string: str): return {"status": "connected"}
# Usagewith database_connection("postgresql://localhost/db") as db: print("Using database:", db)Real-World Example: Timer Context Manager
Section titled “Real-World Example: Timer Context Manager”import timefrom contextlib import contextmanager
@contextmanagerdef timer(): """Context manager to measure execution time""" start = time.time() try: yield finally: end = time.time() print(f"Execution time: {end - start:.2f} seconds")
# Usagewith timer(): # Your code here time.sleep(1) print("Task completed")# Output: Execution time: 1.00 secondsProperties, Attributes, and Methods
Section titled “Properties, Attributes, and Methods”Understanding the difference between attributes, properties, and methods is crucial for writing pythonic code.
- Attributes: Variables that belong to an object, storing data
- Properties: Methods that manage attribute access (getters/setters)
- Methods: Functions that belong to an object, defining behavior
Using Properties for Encapsulation
Section titled “Using Properties for Encapsulation”class UserAccount: """Example demonstrating properties for validation""" def __init__(self, username: str, email: str): self.username = username self._email = email # Protected attribute self._balance = 0.0 # Private attribute
@property def email(self): """Getter for email property""" return self._email
@email.setter def email(self, value: str): """Setter with validation""" if "@" not in value: raise ValueError("Invalid email address") self._email = value
@property def balance(self): """Read-only property""" return self._balance
def deposit(self, amount: float): """Method to modify balance""" if amount <= 0: raise ValueError("Amount must be positive") self._balance += amount
def withdraw(self, amount: float): """Method to modify balance""" if amount > self._balance: raise ValueError("Insufficient funds") self._balance -= amount
# Usageuser = UserAccount("john_doe", "john@example.com")print(user.email) # Accessing propertyuser.email = "newemail@example.com" # Using setter# user.email = "invalid" # Raises ValueError
user.deposit(100.0)print(user.balance) # 100.0# user.balance = 200.0 # AttributeError: can't set attributeclassDiagram
class UserAccount {
-username: str
-_email: str
-_balance: float
+email: str (property)
+balance: float (read-only property)
+deposit(amount: float)
+withdraw(amount: float)
}
Underscores in Python
Section titled “Underscores in Python”Python uses underscores to indicate different levels of access and special meanings:
Underscore Conventions
Section titled “Underscore Conventions”class MyClass: def __init__(self): # Single leading underscore: protected (internal use) self._protected_var = "I am protected"
# Double leading underscore: private (name mangling) self.__private_var = "I am private"
# Single trailing underscore: avoid keyword conflicts self.class_ = "Avoids conflict with 'class' keyword"
def get_private_var(self): """Public method to access private variable""" return self.__private_var
def _internal_method(self): """Protected method - internal use only""" return "Internal logic"
obj = MyClass()
# Protected - accessible but should be treated as internalprint(obj._protected_var) # Works, but not recommended
# Private - name mangling prevents direct access# print(obj.__private_var) # AttributeErrorprint(obj.get_private_var()) # Access through method
# Name mangling exampleprint(obj._MyClass__private_var) # Works (but don't do this!)Underscore Summary
Section titled “Underscore Summary”| Pattern | Meaning | Example |
|---|---|---|
_var | Protected (internal use) | self._internal_data |
var_ | Avoid keyword conflicts | class_, type_ |
__var | Private (name mangling) | self.__secret |
__var__ | Special methods (dunder) | __init__, __str__ |
_ | Unused variable | for _ in range(10) |
Dataclasses
Section titled “Dataclasses”The dataclasses module provides a decorator to automatically generate special methods, reducing boilerplate code for data-holding classes.
Without Dataclasses
Section titled “Without Dataclasses”class Point: def __init__(self, x: float, y: float): self.x = x self.y = y
def __repr__(self): return f"Point(x={self.x}, y={self.y})"
def __eq__(self, other): if not isinstance(other, Point): return False return self.x == other.x and self.y == other.y
def __hash__(self): return hash((self.x, self.y))
# Lots of boilerplate code!With Dataclasses
Section titled “With Dataclasses”from dataclasses import dataclass, field
@dataclassclass Point: """Much cleaner with dataclasses!""" x: float y: float
# Automatically provides:# - __init__# - __repr__# - __eq__
p1 = Point(1.0, 2.0)p2 = Point(1.0, 2.0)print(p1 == p2) # Trueprint(p1) # Point(x=1.0, y=2.0)Advanced Dataclass Features
Section titled “Advanced Dataclass Features”from dataclasses import dataclass, field
@dataclass(frozen=True) # Makes instances immutableclass Config: """Immutable configuration class""" host: str = "localhost" port: int = 8080 timeout: int = 30 # Default value for mutable types metadata: dict = field(default_factory=dict)
config = Config(port=9000)print(config) # Config(host='localhost', port=9000, timeout=30, metadata={})
# config.port = 8000 # FrozenInstanceError - cannot modify frozen dataclass
@dataclassclass User: name: str email: str age: int = 0 # Default value
def __post_init__(self): """Called after __init__""" if self.age < 0: raise ValueError("Age cannot be negative")
user = User("John", "john@example.com", 25)An object is iterable if it implements __iter__(), which returns an iterator. The iterator must implement __next__() to return the next item.
Creating Custom Iterables
Section titled “Creating Custom Iterables”from datetime import datetime, timedelta
class DateRange: """Custom iterable for date ranges""" def __init__(self, start_date: datetime, end_date: datetime): self.start_date = start_date self.end_date = end_date
def __iter__(self): """Returns iterator object""" self.current_date = self.start_date return self
def __next__(self): """Returns next item in sequence""" if self.current_date > self.end_date: raise StopIteration date_to_return = self.current_date self.current_date += timedelta(days=1) return date_to_return
# Usagefor date in DateRange(datetime(2023, 1, 1), datetime(2023, 1, 5)): print(date.strftime("%Y-%m-%d"))
# Output:# 2023-01-01# 2023-01-02# 2023-01-03# 2023-01-04# 2023-01-05Real-World Example: Paginated API Results
Section titled “Real-World Example: Paginated API Results”class PaginatedResults: """Iterable for paginated API responses""" def __init__(self, fetch_page_func, total_pages: int): self.fetch_page = fetch_page_func self.total_pages = total_pages
def __iter__(self): self.current_page = 1 return self
def __next__(self): if self.current_page > self.total_pages: raise StopIteration page_data = self.fetch_page(self.current_page) self.current_page += 1 return page_data
# Usagedef fetch_api_page(page_num): return [f"Item {page_num}-{i}" for i in range(5)]
results = PaginatedResults(fetch_api_page, total_pages=3)for page in results: print(page)Containers implement __contains__() for membership testing with the in keyword. They often also implement __len__() and __getitem__().
How in Works
Section titled “How in Works”element in containerThis translates to:
container.__contains__(element)Custom Container Example
Section titled “Custom Container Example”class Boundaries: """Container that checks if a point is within boundaries""" def __init__(self, width: float, height: float): self.width = width self.height = height
def __contains__(self, point: tuple) -> bool: """Check if point (x, y) is within boundaries""" x, y = point return 0 <= x <= self.width and 0 <= y <= self.height
class Grid: """Grid that uses Boundaries via composition""" def __init__(self, width: float, height: float): self.width = width self.height = height self.limits = Boundaries(width, height) # Composition
def __contains__(self, point: tuple) -> bool: """Delegate to Boundaries""" return point in self.limits
# Usagegrid = Grid(10, 10)print((5, 5) in grid) # Trueprint((15, 5) in grid) # Falseprint((5, 15) in grid) # FalseclassDiagram
class Grid {
-width: float
-height: float
-limits: Boundaries
+__contains__(point: tuple) bool
}
class Boundaries {
-width: float
-height: float
+__contains__(point: tuple) bool
}
Grid *-- Boundaries : composition
__getattr__ and __setattr__ allow you to customize attribute access and assignment behavior.
__getattr__(self, name): Called when accessing a non-existent attribute__setattr__(self, name, value): Called every time an attribute is assigned
These act as hooks for attribute access and assignment.
Basic Example
Section titled “Basic Example”class DynamicAttributes: def __init__(self): self.existing_attr = "I exist"
def __getattr__(self, name: str): """Called only for missing attributes""" return f"{name} not found, but here's a default value!"
def __setattr__(self, name: str, value): """Called for every attribute assignment""" print(f"Setting attribute {name} to {value}") super().__setattr__(name, value) # Important: use super()
obj = DynamicAttributes()print(obj.existing_attr) # "I exist" (doesn't trigger __getattr__)print(obj.missing_attr) # "missing_attr not found..." (triggers __getattr__)
obj.new_attr = "New Value" # Prints: "Setting attribute new_attr to New Value"print(obj.new_attr) # "New Value"Real-World Use Cases
Section titled “Real-World Use Cases”1. Lazy Loading
Section titled “1. Lazy Loading”class LazyLoader: """Load expensive data only when accessed""" def __init__(self): self._data = None self._config = None
def __getattr__(self, name: str): if name == "data": if self._data is None: print("Loading data from database...") self._data = self._load_data() return self._data elif name == "config": if self._config is None: print("Loading configuration...") self._config = self._load_config() return self._config raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
def _load_data(self): # Simulate expensive operation return {"users": 1000, "posts": 5000}
def _load_config(self): return {"theme": "dark", "language": "en"}
loader = LazyLoader()print(loader.data) # Prints: "Loading data..." then returns dataprint(loader.data) # Returns cached data (no loading message)print(loader.config) # Prints: "Loading configuration..." then returns config2. Attribute Validation
Section titled “2. Attribute Validation”class ValidatedUser: """Validate attributes before setting""" def __init__(self): self._age = None self._email = None
def __setattr__(self, name: str, value): if name == "age": if not isinstance(value, int) or value < 0: raise ValueError("Age must be a non-negative integer") elif name == "email": if "@" not in str(value): raise ValueError("Invalid email address") super().__setattr__(name, value)
user = ValidatedUser()user.age = 25 # Validuser.email = "user@example.com" # Valid# user.age = -5 # ValueError# user.email = "invalid" # ValueErrorAsynchronous programming allows you to perform multiple I/O-bound tasks concurrently without blocking execution. Python’s asyncio library provides the foundation for async code.
Synchronous (blocking) - tasks run sequentially:
import time
def fetch_data(data_id: str, delay: float): print(f"Fetching {data_id}...") time.sleep(delay) # Blocks execution print(f"Fetched {data_id}") return data_id
# Sequential executionstart = time.time()fetch_data("Data 1", 2)fetch_data("Data 2", 2)print(f"Total time: {time.time() - start:.2f} seconds")# Total time: ~4 secondsAsynchronous (non-blocking) - tasks run concurrently:
import asyncio
async def fetch_data(data_id: str, delay: float): print(f"Fetching {data_id}...") await asyncio.sleep(delay) # Non-blocking print(f"Fetched {data_id}") return data_id
async def main(): # Run tasks concurrently start = time.time() results = await asyncio.gather( fetch_data("Data 1", 2), fetch_data("Data 2", 2) ) print(f"Total time: {time.time() - start:.2f} seconds") print("Results:", results)
asyncio.run(main())# Total time: ~2 seconds (both tasks run in parallel)Real-World Example: Concurrent API Requests
Section titled “Real-World Example: Concurrent API Requests”import asyncioimport aiohttp
async def fetch_url(session: aiohttp.ClientSession, url: str): """Fetch a single URL""" async with session.get(url) as response: return await response.text()
async def fetch_multiple_urls(urls: list[str]): """Fetch multiple URLs concurrently""" async with aiohttp.ClientSession() as session: tasks = [fetch_url(session, url) for url in urls] results = await asyncio.gather(*tasks) return results
# Usageurls = [ "https://api.example.com/data1", "https://api.example.com/data2", "https://api.example.com/data3"]results = asyncio.run(fetch_multiple_urls(urls))Summary: Dunder Methods Reference
Section titled “Summary: Dunder Methods Reference”| Dunder Method | Purpose | Example Usage |
|---|---|---|
__init__ | Object initialization | obj = MyClass() |
__repr__ | Official string representation | repr(obj) |
__str__ | Informal string representation | str(obj) |
__add__ | Addition operator | obj1 + obj2 |
__eq__ | Equality comparison | obj1 == obj2 |
__len__ | Length | len(obj) |
__getitem__ | Index access | obj[key] |
__setitem__ | Index assignment | obj[key] = value |
__iter__ | Returns iterator | for item in obj |
__next__ | Next item in iterator | next(iterator) |
__contains__ | Membership test | item in obj |
__call__ | Makes object callable | obj() |
__enter__ | Context manager entry | with obj: |
__exit__ | Context manager exit | with obj: |
__getattr__ | Handle missing attributes | obj.missing |
__setattr__ | Customize attribute assignment | obj.attr = value |
Key Takeaways
Section titled “Key Takeaways”- Use dunder methods to make your classes work with Python’s built-in operations
- Context managers ensure proper resource cleanup and are more pythonic than try/finally
- Properties provide controlled access to attributes with validation
- Dataclasses reduce boilerplate for data-holding classes
- Iterables make your objects work with
forloops and comprehensions - Containers enable membership testing with the
inkeyword - Dynamic attributes allow flexible attribute access and lazy loading
- Async/await enables concurrent I/O operations without blocking
Writing pythonic code means leveraging Python’s features to write code that is clean, readable, and follows Python conventions.