Metaclasses: The Class of a Class ✨
A metaclass is the entity that creates a class—it is literally the “class of a class.” Metaclasses allow you to define rules or inject behavior into classes as they are being built.
- Rule Enforcement: Metaclasses are used to ensure rules on a class are followed (e.g., ensuring all methods are lowercase).
- Dynamic Injection: The AutoGreetMeta example shows a metaclass that automatically adds a
greet()method to any class that uses it, returning a dynamic greeting based on the class’s name. Frameworks like Django use this concept to dynamically generate database models. - Singleton Implementation: Metaclasses offer a clean way to implement the Singleton pattern, ensuring only one instance of a class ever exists. The metaclass overrides the __call__ method, which is invoked when an instance is created, allowing it to check if an instance already exists before creating a new one.
class AutoGreetMeta(type):
def __new__(cls, name, bases, dct):
dct['greet'] = lambda self: f"Hello, I am a {name}!"
return super().__new__(cls, name, bases, dct)
class Person(metaclass=AutoGreetMeta):
def __init__(self, name):
self.name = name
p = Person("Alice")
p.greet() # "Hello, I am a Person!"
Object Creation Hooks: __new__ vs. __init__
Python provides multiple “hooks” for controlling the lifecycle of an object:
- __new__ (The Constructor): This is a static method under the hood and is responsible for creating the object (like making the empty box 📦). It takes the class (cls) as the first argument and returns the new instance. It’s often used for complex creation control, like Singleton implementations.
- __init__ (The Initializer): This is responsible for initializing the object’s contents (like filling the box 🎁). It initializes the instance that __new__ returned.
Other hooks include __call__ (for making an instance callable) and __del__ (for destruction). These hooks — __new__, __init__, __call__, __del__ — let you intercept creation, initialization, calling, and destruction.
class Singleton(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 Mono:
__shared_state = {}
def __init__(self):
self.__dict__ = self.__shared_state
Design Patterns for Flexibility
Decorator Pattern: Augmentation via Composition 🎁
The Decorator pattern lets you dynamically add responsibilities to an object without altering its structure, favoring composition over inheritance.
- Function Decorators: Python’s built-in
@time\_itsyntax is the most common example, wrapping a function to add timing functionality. - Class Decorators (Composition): The ColoredShape example shows how to wrap a Circle or Square object to add the color attribute. This avoids the “class explosion” problem you’d get from creating RedCircle, BlueCircle, RedSquare, etc.
- Delegation Example (FileWithLogging):
- The wrapper augments methods like write() and writelines() with logging before calling the original file method.
- It uses __getattr__ to delegate all other methods (like close() or read()) to the wrapped file object, ensuring full compatibility. This delegation means the decorator doesn’t have to explicitly define every method of the object it wraps.
def time_it(func):
def wrapper():
start = time.time()
result = func()
end = time.time()
print(f"Took {end - start:.2f}s")
return result
return wrapper
@time_it
def compute(): ...
class FileWithLogging:
def __init__(self, file):
self.__file = file
print(f"[LOG] Opened file: {file.name}")
def __getattr__(self, name):
return getattr(self.__file, name)
def write(self, data):
print(f"[LOG] Writing {len(data)} chars")
return self.__file.write(data)
Composite Pattern: Treating Individuals and Groups Alike 🌳
The Composite pattern allows you to treat individual objects and groups of objects in a uniform way.
- Example: A neural network can treat a single Neuron and a NeuronLayer (a collection of neurons) similarly. By inheriting from a collection type (like NeuronLayer(list)), you leverage built-in collection methods like append and get standard list behavior.
- Scaling: To treat a single object as a collection, you can implement __iter__ to simply
yield self, effectively turning a scalar into a collection of one.
class Neuron:
def __init__(self, name):
self.name = name
class NeuronLayer(list):
def __init__(self, name, count):
super().__init__()
self.name = name
for i in range(count):
self.append(Neuron(f'{name}-{i}'))
Adapter Pattern and Bridge Pattern 🌉
- Adapter Pattern: Used to make two incompatible interfaces work together (e.g., creating a SocketAdaptor to connect a EuropeanSocket to a USASocketInterface). It’s about changing the interface.
class EuropeanSocket:
def voltage(self): return 230
class USASocketInterface:
def voltage(self): pass
class SocketAdapter(USASocketInterface):
def __init__(self, euro_socket):
self.euro_socket = euro_socket
def voltage(self): return self.euro_socket.voltage() / 2
- Bridge Pattern: Used to decouple an abstraction from its implementation so the two can vary independently. (e.g., Separating a Shape abstraction from its Renderer implementation, allowing you to have a Circle rendered by a RasterRenderer or a VectorRenderer). It’s about changing the structure.
class Renderer:
def render_circle(self, radius): pass
class VectorRenderer(Renderer):
def render_circle(self, radius):
print(f"Drawing circle with vector graphics, radius={radius}")
class RasterRenderer(Renderer):
def render_circle(self, radius):
print(f"Drawing pixels for circle, radius={radius}")
class Shape:
def __init__(self, renderer):
self.renderer = renderer
class Circle(Shape):
def __init__(self, renderer, radius):
super().__init__(renderer)
self.radius = radius
def draw(self):
self.renderer.render_circle(self.radius)