OOP III: Advanced Concepts

True dynamism

Author

Karsten Naert

Published

October 20, 2026

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__:

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})'
laptop = Purchase('laptop', 1200)
print(laptop)
__new__ called: creating laptop
__init__ called: initializing laptop
Purchase(laptop, 1200)

Notice that:

  1. __new__ receives the same arguments as __init__
  2. __new__ must return the created object
  3. __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 = host
db1 = 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
Warning

Be careful with __init__ when using __new__ patterns. Since __init__ is called every time, you might reinitialize your object unexpectedly!

Exercise

Create a ConfigManager class that:

  1. Only allows one instance per config file name
  2. Stores configuration as a dictionary
  3. Provides a method to get/set config values

Test it:

config1 = ConfigManager('app.conf')
config2 = ConfigManager('app.conf')  # Should be same instance
config3 = ConfigManager('db.conf')   # Should be different instance

config1.set('debug', True)
print(config2.get('debug'))  # Should print True

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 AttributeError
I 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
Exercise

Create a LoggingObject class that:

  1. Logs every attribute access with __getattr__
  2. Logs every attribute modification with __setattr__
  3. Stores actual data in a private dictionary
  4. Has a method to show the access log

Test it with various attribute operations.

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 = celsius
temp = 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)
Exercise

Create a ValidatedAttribute descriptor that: 1. Takes a validation function in its constructor 2. Only allows values that pass validation 3. Raises ValueError for invalid values

Use it to create a Person class with: - name: must be a non-empty string - age: must be between 0 and 150 - email: must contain ‘@’

Test with valid and invalid values.

__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.
Tip

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 here
Starting 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.radius

The 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 = price
product = Product("Laptop", 999)
print(product)  # Uses ReprMixin
import time
time.sleep(1)
print(f"Product age: {product.get_age():.2f} seconds")  # Uses TimestampMixin
Product(name=Laptop, price=999)
Product age: 1.00 seconds
Exercise
  1. Abstract Shape Factory: Create an abstract ShapeFactory class with an abstract method create_shape(). Then create RectangleFactory and CircleFactory subclasses.

  2. Logging Mixin: Create a LoggingMixin that:

    • Adds a log() method to record messages with timestamps
    • Adds a get_logs() method to retrieve all messages
    • Use it with other classes to add logging capability
  3. Template Method Pattern: Create a GameCharacter base class that defines a take_turn() method calling abstract methods choose_action(), execute_action(), and end_turn(). Create concrete characters like Warrior and Mage.

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

  1. 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 id values once an object has been garbage collected, so a fresh object can silently inherit the value of a dead one. The real solution is weakref.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.↩︎