The Art of Class Sizing and Separation Writing effective Python classes starts with a simple rule: keep them small. A monolithic class that handles data storage, business logic, and external communication is a maintenance nightmare. To combat this, categorize your classes as either **data-focused** or **behavior-focused**. Data-focused classes, often implemented as `@dataclass`, serve as structured containers for information. Behavior-focused classes group related methods that perform actions. By splitting a bloated `Person` class into specific components like `Address` and `Stats`, you improve readability and reusability. For instance, an `EmailService` shouldn't live inside a `Person` class; it belongs in its own module. This separation allows you to reuse the email logic across your entire application without dragging along unrelated personal data structures. Enhancing Usability with Properties and Dunder Methods Python offers powerful tools to make classes feel more "pythonic" and easier for other developers to use. Instead of writing verbose getter methods like `get_bmi()`, use the `@property` decorator. This allows users to access computed values as if they were simple attributes. ```python @property def bmi(self) -> float: return self.weight / (self.height ** 2) ``` To provide human-readable descriptions of your objects, implement the `__str__` dunder method. While dataclasses automatically generate a `__repr__` for developers, a custom `__str__` ensures that when a user prints an object, they see a clean, formatted string rather than a memory address or raw data dump. Efficient Logic with Caching If a property involves a heavy computation that doesn't change frequently, recomputing it every time it's accessed is wasteful. The functools library provides two excellent solutions: `@cached_property` and `@lru_cache`. `@cached_property` stores the result after the first access, but it can be risky if the underlying data changes. For more dynamic scenarios, `@lru_cache` (Least Recently Used cache) is superior for functions. It maps specific input arguments to their results, ensuring that if you call a function with the same parameters twice, the second call returns the cached result instantly. Decoupling via Dependency Injection and Protocols Hardcoding dependencies inside a class creates rigid code that is difficult to test. If a class needs an `EmailService`, don't instantiate it inside the `__init__` or a method. Instead, inject it as an argument. To make this even more flexible, use a `Protocol` from the `typing` module. ```python from typing import Protocol class EmailSender(Protocol): def send_message(self, to: str, subject: str, body: str) -> None: ... ``` By defining a `Protocol`, your class only cares that the object it receives has a `send_message` method. It doesn't care if it's a real SMTP service or a mock object for testing. This architectural pattern, known as dependency inversion, is the key to building scalable software. Knowing When Not to Use a Class A common mistake in Python is forced object-orientation. If you only ever need one instance of a class, or if the class is just a collection of functions that don't share state, a module is often a better choice. Modules are first-class citizens in Python and provide a simpler way to organize code. You can use `functools.partial` to pre-fill function arguments, achieving the same configuration benefits as a class constructor without the overhead of object instantiation. Encapsulation and Name Mangling While Python doesn't have strict private access modifiers like Java, it uses conventions to signal internal use. A single underscore (e.g., `_variable`) is a hint to other developers that the attribute is internal. For stronger protection, a double underscore (e.g., `__variable`) triggers name mangling. This makes the attribute harder to access from outside the class, preventing accidental overrides in subclasses and keeping the public API clean.
smtplib
Libraries
- May 19, 2023