class Purchase:
def __new__(cls, name, price):
print(f'__new__ called: creating {name}')
return super().__new__(cls)
def __init__(self, name, price):
print(f'__init__ called: initializing {name}')
self.name = name
self.price = price
def __repr__(self):
return f'Purchase({self.name}, {self.price})'OOP III: Advanced Concepts
True dynamism
This is our third lecture on OOP. Here, we treat some concepts that are less of central importance than in the other lectures on OOP, so the focus is more on knowing these things exist, rather than working with them ourselves.
The __new__ method
Object creation vs. initialization
We’ve seen that __init__ initializes a new object, but how is the object actually created? That’s the job of the lesser-known __new__ method.
While we often call __init__ the “constructor,” __new__ is the true constructor that creates the object from nothing. It’s a class method that gets called before __init__:
laptop = Purchase('laptop', 1200)
print(laptop)__new__ called: creating laptop
__init__ called: initializing laptop
Purchase(laptop, 1200)
Notice that:
__new__receives the same arguments as__init____new__must return the created object__new__is called before__init__
Controlling object creation
You can use __new__ to control how objects are created. Here’s a singleton pattern (only one instance allowed):
class DatabaseConnection:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
print('Creating new database connection')
cls._instance = super().__new__(cls)
else:
print('Reusing existing database connection')
return cls._instance
def __init__(self, host='localhost'):
# Be careful: __init__ gets called every time!
if not hasattr(self, 'host'): # Only initialize once
print(f'Initializing connection to {host}')
self.host = hostdb1 = DatabaseConnection('server1')
db2 = DatabaseConnection('server2') # Same instance!
print(f'db1 is db2: {db1 is db2}')
print(f'Host: {db1.host}') # Still 'server1'Creating new database connection
Initializing connection to server1
Reusing existing database connection
db1 is db2: True
Host: server1
Here’s another example: a class that prevents duplicate objects for the same name:
class Person:
_instances = {}
def __new__(cls, name):
if name not in cls._instances:
print(f'Creating new person: {name}')
instance = super().__new__(cls)
cls._instances[name] = instance
else:
print(f'Returning existing person: {name}')
return cls._instances[name]
def __init__(self, name):
if not hasattr(self, 'name'): # Only initialize once
self.name = name
self.friends = []alice1 = Person('Alice')
alice2 = Person('Alice')
bob = Person('Bob')
print(f'alice1 is alice2: {alice1 is alice2}')
print(f'alice1 is bob: {alice1 is bob}')Creating new person: Alice
Returning existing person: Alice
Creating new person: Bob
alice1 is alice2: True
alice1 is bob: False
Be careful with __init__ when using __new__ patterns. Since __init__ is called every time, you might reinitialize your object unexpectedly!
Dynamic attribute access
__getattr__ and __setattr__
Python provides hooks to customize attribute access. The terminology: x.attr means “attr is an attribute of object x” (can be data or methods).
__getattr__ - handling missing attributes
__getattr__ is called when an attribute can’t be found through normal lookup:
class FlexibleObject:
def __init__(self):
self.existing_attr = "I exist!"
def __getattr__(self, name):
if name.startswith('get_'):
attr_name = name[4:] # Remove 'get_' prefix
return lambda: f"Getting {attr_name}"
elif name in 'abc':
return name.upper()
else:
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")obj = FlexibleObject()
print(obj.existing_attr) # Normal attribute lookup
print(obj.a) # Triggers __getattr__
print(obj.get_username()) # Creates a function dynamically
# print(obj.nonexistent) # Would raise AttributeErrorI exist!
A
Getting username
__setattr__ - controlling attribute setting
__setattr__ is called for every attribute assignment:
class ValidatedObject:
def __setattr__(self, name, value):
if name.startswith('_'):
# Allow private attributes
super().__setattr__(name, value)
elif isinstance(value, str) and len(value) < 3:
raise ValueError(f"Attribute {name} must be at least 3 characters")
elif name in ['x', 'y'] and not isinstance(value, (int, float)):
raise TypeError(f"Attribute {name} must be numeric")
else:
super().__setattr__(name, value)obj = ValidatedObject()
obj.x = 10 # OK
obj.name = "Alice" # OK
obj._private = "hidden" # OK
try:
obj.name = "Al" # Too short!
except ValueError as e:
print(f"Error: {e}")
try:
obj.x = "not a number" # Wrong type!
except TypeError as e:
print(f"Error: {e}")Error: Attribute name must be at least 3 characters
Error: Attribute x must be numeric
A practical example: AttributeDict
Here’s a dict that allows attribute-style access:
class AttributeDict:
def __init__(self, **kwargs):
# Use object.__setattr__ to avoid infinite recursion
object.__setattr__(self, '_data', kwargs)
def __getattr__(self, name):
if name in self._data:
return self._data[name]
raise AttributeError(f"No attribute '{name}'")
def __setattr__(self, name, value):
self._data[name] = value
def __repr__(self):
return f"AttributeDict({self._data})"config = AttributeDict(debug=True, host='localhost', port=8080)
print(config)
print(f"Debug mode: {config.debug}")
print(f"Server: {config.host}:{config.port}")
config.timeout = 30
print(f"Added timeout: {config.timeout}")AttributeDict({'debug': True, 'host': 'localhost', 'port': 8080})
Debug mode: True
Server: localhost:8080
Added timeout: 30
The descriptor protocol
Understanding descriptors
Descriptors are a powerful way to customize attribute access. A descriptor is an object with __get__, __set__, or __delete__ methods that controls access to an attribute.
class LoggingDescriptor:
def __init__(self, name):
self.name = name
self.data = {} # Store data per instance
def __get__(self, instance, owner):
if instance is None:
return self # Called on class
print(f"Getting {self.name}")
return self.data.get(id(instance), None)
def __set__(self, instance, value):
print(f"Setting {self.name} to {value}")
self.data[id(instance)] = value
def __delete__(self, instance):
print(f"Deleting {self.name}")
self.data.pop(id(instance), None)
class Person:
name = LoggingDescriptor('name')
age = LoggingDescriptor('age')
def __init__(self, name, age):
self.name = name # Triggers __set__
self.age = age # Triggers __set__person = Person("Alice", 30)
print(f"Person's name: {person.name}") # Triggers __get__
person.age = 31 # Triggers __set__
print(f"Person's age: {person.age}") # Triggers __get__Setting name to Alice
Setting age to 30
Getting name
Person's name: Alice
Setting age to 31
Getting age
Person's age: 31
Notice where the values are stored. The descriptor object itself lives on the class, so there is only one LoggingDescriptor for name no matter how many Person objects you create. It therefore needs somewhere to keep a separate value per instance, and here we use a dictionary keyed on id(instance).1
Practical example: Temperature converter
class TemperatureDescriptor:
def __init__(self, unit):
self.unit = unit
self.kelvin_data = {} # Store in Kelvin internally
def __get__(self, instance, owner):
if instance is None:
return self
kelvin = self.kelvin_data.get(id(instance), 0)
if self.unit == 'K':
return kelvin
elif self.unit == 'C':
return kelvin - 273.15
elif self.unit == 'F':
return (kelvin - 273.15) * 9/5 + 32
def __set__(self, instance, value):
if self.unit == 'K':
kelvin = value
elif self.unit == 'C':
kelvin = value + 273.15
elif self.unit == 'F':
kelvin = (value - 32) * 5/9 + 273.15
self.kelvin_data[id(instance)] = kelvin
class Temperature:
kelvin = TemperatureDescriptor('K')
celsius = TemperatureDescriptor('C')
fahrenheit = TemperatureDescriptor('F')
def __init__(self, celsius=0):
self.celsius = celsiustemp = Temperature(25) # 25°C
print(f"Temperature: {temp.celsius}°C = {temp.fahrenheit}°F = {temp.kelvin}K")
temp.fahrenheit = 100 # Set to 100°F
print(f"Temperature: {temp.celsius}°C = {temp.fahrenheit}°F = {temp.kelvin}K")Temperature: 25.0°C = -459.66999999999996°F = 0K
Temperature: 25.0°C = 99.99999999999999°F = 0K
Types of descriptors
- Non-data descriptors: Only have
__get__(like functions/methods) - Data descriptors: Have
__set__and/or__delete__(like properties)
Many Python features use descriptors behind the scenes: - Properties (@property) - Methods (bound/unbound) - Class methods (@classmethod) - Static methods (@staticmethod)
import types
def show_descriptor_info(cls):
for name, attr in vars(cls).items():
if hasattr(attr, '__get__'):
desc_type = "data" if hasattr(attr, '__set__') or hasattr(attr, '__delete__') else "non-data"
print(f"{name}: {type(attr).__name__} ({desc_type} descriptor)")
class Example:
class_var = "I'm just a variable"
@property
def prop(self):
return "I'm a property"
@classmethod
def class_method(cls):
return "I'm a class method"
def regular_method(self):
return "I'm a regular method"
show_descriptor_info(Example)prop: property (data descriptor)
class_method: classmethod (non-data descriptor)
regular_method: function (non-data descriptor)
__dict__: getset_descriptor (data descriptor)
__weakref__: getset_descriptor (data descriptor)
__slots__
Everything we have seen in this lecture rests on one fact: a normal Python object keeps its attributes in a dictionary, reachable as __dict__.
class Plain:
def __init__(self, x, y):
self.x = x
self.y = y
p = Plain(1, 2)
p.__dict__{'x': 1, 'y': 2}
That dictionary is what makes Python objects so flexible — you can bolt on a new attribute at any time, as we did in the very first OOP lecture. It is also why every object costs a dictionary’s worth of memory, and why a typo just quietly creates a new attribute:
p.cordinate = 5 # meant coordinate; Python is happy to oblige
p.__dict__{'x': 1, 'y': 2, 'cordinate': 5}
With __slots__ you tell Python in advance exactly which attributes instances will have. In exchange, it stops giving them a __dict__:
class Slotted:
__slots__ = ('x', 'y')
def __init__(self, x, y):
self.x = x
self.y = y
s = Slotted(1, 2)
s.x, s.y(1, 2)
s.cordinate = 5--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[21], line 1 ----> 1 s.cordinate = 5 AttributeError: 'Slotted' object has no attribute 'cordinate' and no __dict__ for setting new attributes
The typo is now an error, at the moment it happens. And the object got smaller, because there is no dictionary hanging off it:
import sys
print("Plain: ", sys.getsizeof(p), "bytes, plus", sys.getsizeof(p.__dict__), "for its __dict__")
print("Slotted:", sys.getsizeof(s), "bytes, and no __dict__ at all")Plain: 48 bytes, plus 296 for its __dict__
Slotted: 48 bytes, and no __dict__ at all
On one object this is irrelevant. On the ten million of them you are holding in memory, it is not.
How does it work? __slots__ reserves a fixed place in the object for each name and creates a descriptor on the class to read and write it — exactly the mechanism we just studied:
type(Slotted.x)member_descriptor
The trade-offs:
- You cannot add attributes that aren’t in
__slots__. That is the point, but it does break code that relies on attaching things to objects on the fly. - Every class in the inheritance chain must define
__slots__, otherwise a parent without it reintroduces__dict__and you lose the benefit. - Some libraries expect
__dict__to exist.
You rarely need to write __slots__ by hand. The common way to get it is @dataclass(slots=True), which we met in OOP I: you describe your fields once, and the dataclass machinery generates the __slots__ for you.
Inheritance design patterns
We will see more on these topics in our lectures on Architecture.
Classes designed to be subclassed
Sometimes you design a base class that’s meant to be extended, providing a template with some methods implemented and others left for subclasses:
class DataProcessor:
"""Base class for processing data with a common workflow."""
def process(self, data):
"""Main processing workflow - don't override this."""
print("Starting data processing...")
validated = self.validate(data)
if not validated:
raise ValueError("Data validation failed")
transformed = self.transform(data)
result = self.analyze(transformed)
print("Processing complete!")
return result
def validate(self, data):
"""Override this in subclasses."""
raise NotImplementedError("Subclasses must implement validate()")
def transform(self, data):
"""Override this in subclasses."""
raise NotImplementedError("Subclasses must implement transform()")
def analyze(self, data):
"""Override this in subclasses."""
raise NotImplementedError("Subclasses must implement analyze()")
class NumberProcessor(DataProcessor):
def validate(self, data):
return isinstance(data, list) and all(isinstance(x, (int, float)) for x in data)
def transform(self, data):
return [x * 2 for x in data] # Double all numbers
def analyze(self, data):
return {
'sum': sum(data),
'average': sum(data) / len(data),
'count': len(data)
}processor = NumberProcessor()
result = processor.process([1, 2, 3, 4, 5])
print(result)Starting data processing...
Processing complete!
{'sum': 30, 'average': 6.0, 'count': 5}
Abstract base classes with abc
The DataProcessor above works, but it has a weakness. Nothing stops you from creating an incomplete subclass, and nothing complains until the missing method is actually reached:
class HalfFinishedProcessor(DataProcessor):
def validate(self, data):
return True
# transform and analyze never written
processor = HalfFinishedProcessor() # no complaint at all
processor.process([1, 2, 3]) # ... the problem only surfaces hereStarting data processing...
--------------------------------------------------------------------------- NotImplementedError Traceback (most recent call last) Cell In[26], line 7 4 # transform and analyze never written 6 processor = HalfFinishedProcessor() # no complaint at all ----> 7 processor.process([1, 2, 3]) # ... the problem only surfaces here Cell In[24], line 12, in DataProcessor.process(self, data) 9 if not validated: 10 raise ValueError("Data validation failed") ---> 12 transformed = self.transform(data) 13 result = self.analyze(transformed) 15 print("Processing complete!") Cell In[24], line 24, in DataProcessor.transform(self, data) 22 def transform(self, data): 23 """Override this in subclasses.""" ---> 24 raise NotImplementedError("Subclasses must implement transform()") NotImplementedError: Subclasses must implement transform()
In a small script you notice immediately. In a real program the object may be built during start-up and only used an hour later, in a code path that runs once a week.
Python provides the abc module to move that error forward in time. It creates formal abstract base classes:
import math
from abc import ABC, abstractmethod
class Shape(ABC):
"""Abstract base class for shapes."""
@abstractmethod
def area(self):
"""Calculate the area of the shape."""
pass
@abstractmethod
def perimeter(self):
"""Calculate the perimeter of the shape."""
pass
def describe(self):
"""Concrete method that uses abstract methods."""
return f"This shape has area {self.area():.2f} and perimeter {self.perimeter():.2f}"
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
def perimeter(self):
return 2 * (self.width + self.height)
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return math.pi * self.radius ** 2
def perimeter(self):
return 2 * math.pi * self.radiusThe difference from the NotImplementedError version is precisely when you find out:
class HalfFinishedShape(Shape):
def area(self):
return 42
# perimeter never written
HalfFinishedShape()--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[28], line 6 3 return 42 4 # perimeter never written ----> 6 HalfFinishedShape() TypeError: Can't instantiate abstract class HalfFinishedShape without an implementation for abstract method 'perimeter'
The object is never created, the error names the method you forgot, and it happens at the moment you try to make the object rather than whenever some later code touches it. That is worth the small amount of extra ceremony.
# Can't instantiate abstract class
try:
shape = Shape()
except TypeError as e:
print(f"Error: {e}")
# Can instantiate concrete classes
rect = Rectangle(5, 3)
circle = Circle(2)
print(rect.describe())
print(circle.describe())Error: Can't instantiate abstract class Shape without an implementation for abstract methods 'area', 'perimeter'
This shape has area 15.00 and perimeter 16.00
This shape has area 12.57 and perimeter 12.57
Protocols: the structural alternative
An abstract base class works by inheritance: Rectangle counts as a Shape because it says class Rectangle(Shape). This is called nominal typing — membership is declared by name. It is a real commitment: to plug a class into your system, its author has to import your base class and inherit from it.
That is often not possible. Suppose a third party ships a Triangle class with perfectly good area and perimeter methods, but it knows nothing about your Shape:
class Triangle: # someone else's class, not ours to change
def __init__(self, base, height, sides):
self.base, self.height, self.sides = base, height, sides
def area(self):
return self.base * self.height / 2
def perimeter(self):
return sum(self.sides)
isinstance(Triangle(3, 4, [3, 4, 5]), Shape)False
At runtime this hardly matters, because Python is duck typed: your code calls .area() and it works. The problem is that a type checker has no way to know that, and you have no way to write down what your function actually requires.
typing.Protocol is the answer. It describes a shape by its structure rather than its ancestry — any class with the right methods matches, with no inheritance and no cooperation from its author:
from typing import Protocol
class HasArea(Protocol):
def area(self) -> float: ...
def total_area(shapes: list[HasArea]) -> float:
return sum(shape.area() for shape in shapes)
total_area([Rectangle(5, 3), Circle(2), Triangle(3, 4, [3, 4, 5])])33.56637061435917
Triangle never heard of HasArea, and it still satisfies it. This is sometimes called static duck typing: the flexibility of duck typing, but written down where a tool can check it.
So which do you use?
- ABC when you own the hierarchy and want to provide shared behaviour as well as demand it —
Shape.describe()was concrete code every subclass inherits. The base class also enforces the contract at instantiation time. - Protocol when you only want to describe what you need from an object, especially when the objects come from code you do not control.
We come back to protocols, and to what a type checker does with them, in the lecture on linters and type checkers.
Mixin classes
Mixins are classes designed to be combined with other classes via multiple inheritance:
class TimestampMixin:
"""Mixin to add timestamp functionality."""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
import time
self._created_at = time.time()
def get_age(self):
import time
return time.time() - self._created_at
class ReprMixin:
"""Mixin to provide a nice string representation."""
def __repr__(self):
attrs = []
for key, value in self.__dict__.items():
if not key.startswith('_'):
attrs.append(f"{key}={value}")
return f"{self.__class__.__name__}({', '.join(attrs)})"
class Product(TimestampMixin, ReprMixin):
def __init__(self, name, price):
super().__init__()
self.name = name
self.price = priceproduct = Product("Laptop", 999)
print(product) # Uses ReprMixin
import time
time.sleep(1)
print(f"Product age: {product.get_age():.2f} seconds") # Uses TimestampMixinProduct(name=Laptop, price=999)
Product age: 1.00 seconds
Advanced attribute access patterns
__getattribute__ vs __getattr__
There’s a difference between these two methods:
__getattr__: Only called when attribute lookup fails__getattribute__: Called for every attribute access
class Monitored:
def __init__(self):
self.data = {'x': 10, 'y': 20}
def __getattribute__(self, name):
print(f"Accessing attribute: {name}")
return super().__getattribute__(name)
def __getattr__(self, name):
print(f"Attribute {name} not found, checking data dict")
return self.data.get(name, f"No attribute {name}")obj = Monitored()
print(f"obj.data: {obj.data}") # Triggers __getattribute__
print(f"obj.x: {obj.x}") # Triggers both __getattribute__ and __getattr__
print(f"obj.z: {obj.z}") # Triggers both __getattribute__ and __getattr__Accessing attribute: data
obj.data: {'x': 10, 'y': 20}
Accessing attribute: x
Attribute x not found, checking data dict
Accessing attribute: data
obj.x: 10
Accessing attribute: z
Attribute z not found, checking data dict
Accessing attribute: data
obj.z: No attribute z
Additional resources
Footnotes
This keeps the example readable, but it is not what you would ship. Two problems: entries are never removed, so the dictionary grows forever as objects come and go; and CPython reuses
idvalues once an object has been garbage collected, so a fresh object can silently inherit the value of a dead one. The real solution isweakref.WeakKeyDictionary, which uses the instance itself as the key and drops the entry automatically as soon as that instance is garbage collected. See the descriptor HOWTO if you ever need to write one for real.↩︎