The object model & metaclasses
P3 taught classes; this part explains the machinery underneath them. You will see how attribute access really resolves (type, then instance, then __getattr__), write a descriptor and discover that @property and even plain methods are descriptors, watch the MRO get computed by C3 linearization on a diamond, measure what __slots__ buys you, and finally reach the top of the tower: metaclasses, where a class is itself an instance of type and you can hook class creation. We prefer __init_subclass__ where it suffices and reserve metaclasses for when it does not. Every mechanism is demonstrated with runnable code and honest output.
Learning objectives
- Use the dunder protocol to make objects work with operators, ordering, hashing and built-ins.
- Trace attribute lookup through
__getattribute__, the class MRO, and the__getattr__fallback. - Write a descriptor and explain why
@propertyand methods are themselves descriptors. - Compute an MRO by C3 linearization and explain cooperative
super()on a diamond. - Apply
__slots__and measure its memory and attribute-restriction effects. - Build a class with a metaclass, and choose
__init_subclass__when it suffices.
1 · The dunder protocol
Python's built-in syntax is protocol-based: a + b calls a.__add__(b), len(x) calls x.__len__(), a < b calls a.__lt__(b), and membership in a set/dict uses __hash__ and __eq__. Implement the right dunders and your own class becomes a first-class citizen. @total_ordering fills in the remaining comparisons from __eq__ and one of __lt__/__gt__.
dunder.py# Dunders let your objects plug into built-in syntax and protocols.
from functools import total_ordering
@total_ordering
class Money:
def __init__(self, cents): self.cents = cents
def __repr__(self): return f"Money({self.cents})" # repr()
def __eq__(self, o): return isinstance(o, Money) and self.cents == o.cents
def __lt__(self, o): return self.cents < o.cents # + total_ordering => <=,>,>=
def __add__(self, o): return Money(self.cents + o.cents) # a + b
def __hash__(self): return hash(self.cents) # usable in set/dict
a, b = Money(150), Money(99)
print(a + b) # Money(249) via __add__
print(a > b, a == Money(150))# True True via __lt__/@total_ordering and __eq__
print(sorted([a, b])) # [Money(99), Money(150)] via __lt__
print(len({a, Money(150)})) # 1 via __hash__/__eq__ (deduped)
Money(249)
True True
[Money(99), Money(150)]
1
2 · Attribute lookup
Every attribute access obj.name goes through __getattribute__, which searches in a defined order: data descriptors on the type, then the instance __dict__, then non-data descriptors / class attributes along the MRO. Only if all of that fails does Python call __getattr__ — the fallback hook. Overriding __getattribute__ intercepts everything and must delegate via super() or it recurses forever.
lookup.py# Attribute lookup: __getattribute__ runs for EVERY access; __getattr__ is the
# fallback that runs ONLY when normal lookup fails.
class Config:
def __init__(self): self.host = "localhost"
def __getattr__(self, name):
# only reached when 'name' is not found the normal way
return f"<default:{name}>"
c = Config()
print(c.host) # localhost -> found normally, __getattr__ NOT called
print(c.port) # <default:port> -> missing, so __getattr__ handles it
# __getattribute__ intercepts everything (use with care -> easy infinite loops)
class Logged:
def __getattribute__(self, name):
# MUST delegate via super() to avoid recursing forever
val = super().__getattribute__(name)
print(f" access {name!r}")
return val
def __init__(self): self.x = 1
obj = Logged()
_ = obj.x # prints the access line, then returns 1
localhost
<default:port>
access 'x'
3 · Descriptors — how property & methods work
A descriptor is any object that defines __get__ (and optionally __set__/__delete__) and is stored on a class. It intercepts attribute access for every instance. This is not an exotic feature — it is the mechanism behind @property, classmethod, staticmethod, and even ordinary methods: a plain function is a descriptor whose __get__ binds self. Writing one yourself demystifies all of them.
descriptor.py# A descriptor is any object defining __get__/__set__/__delete__ that lives on a
# CLASS. It controls attribute access for instances. @property and methods are
# descriptors under the hood.
class Positive:
def __set_name__(self, owner, name): # remember the attribute's name
self.store = "_" + name
def __get__(self, obj, objtype=None):
if obj is None: return self # accessed on the class
return getattr(obj, self.store)
def __set__(self, obj, value):
if value < 0:
raise ValueError(f"{self.store[1:]} must be >= 0")
setattr(obj, self.store, value)
class Account:
balance = Positive() # a data descriptor on the class
def __init__(self, b): self.balance = b # goes through __set__ -> validated
acct = Account(100)
print("balance:", acct.balance) # via __get__
try:
acct.balance = -5 # via __set__ -> guard trips
except ValueError as e:
print("rejected:", e)
# Proof that @property and methods ARE descriptors:
class Demo:
@property
def p(self): return 1
def m(self): return 2
print("property is descriptor:", hasattr(Demo.__dict__["p"], "__get__"))
print("function is descriptor:", hasattr(Demo.__dict__["m"], "__get__"))
balance: 100
rejected: balance must be >= 0
property is descriptor: True
function is descriptor: True
__set__/__delete__ is a data descriptor and wins over the instance __dict__; one with only __get__ is non-data and can be shadowed by an instance attribute. That precedence is exactly why a @property (a data descriptor) can't be accidentally overwritten by self.x = ....4 · The MRO & C3 linearization
With multiple inheritance Python must pick a single order to search bases. It uses C3 linearization, which guarantees a consistent order that (1) keeps each class before its parents and (2) preserves the left-to-right order you wrote. The result is cls.__mro__. super() does not mean “the literal base class” — it means “the next class in the MRO,” which is what makes cooperative multiple inheritance (every __init__ calling super().__init__()) work.
mro.py# The MRO (method resolution order) is the single, linear order Python searches
# for attributes. It is computed by the C3 linearization algorithm.
class A:
def who(self): return "A"
class B(A):
def who(self): return "B"
class C(A):
def who(self): return "C"
class D(B, C): # the classic diamond
pass
print([cls.__name__ for cls in D.__mro__]) # D, B, C, A, object
print(D().who()) # 'B' -> first match along the MRO
# super() follows the MRO, NOT the literal base — cooperative multiple inheritance
class Base:
def __init__(self): self.log = ["Base"]
class L(Base):
def __init__(self): super().__init__(); self.log.append("L")
class R(Base):
def __init__(self): super().__init__(); self.log.append("R")
class LR(L, R):
def __init__(self): super().__init__(); self.log.append("LR")
print(LR().log) # ['Base', 'R', 'L', 'LR']
['D', 'B', 'C', 'A', 'object']
B
['Base', 'R', 'L', 'LR']
(X, Y) in one place and (Y, X) in another, then combined), Python raises TypeError: Cannot create a consistent method resolution order at class-definition time — a real design signal, not a bug to work around.5 · __slots__ — trading flexibility for footprint
By default each instance owns a __dict__, which is flexible but costs memory and a hash lookup per attribute. Declaring __slots__ tells Python to store a fixed set of attributes in a compact layout instead: no per-instance __dict__, lower memory, slightly faster access — and no adding attributes that aren't declared. It matters when you allocate millions of small objects (graph nodes, records, tokens).
slots.py# __slots__ replaces the per-instance __dict__ with fixed storage: less memory,
# slightly faster attribute access, and no arbitrary new attributes.
import sys, tracemalloc
class WithDict:
def __init__(self, a, b, c): self.a, self.b, self.c = a, b, c
class WithSlots:
__slots__ = ("a", "b", "c")
def __init__(self, a, b, c): self.a, self.b, self.c = a, b, c
d, s = WithDict(1, 2, 3), WithSlots(1, 2, 3)
print("dict instance has __dict__ :", hasattr(d, "__dict__")) # True
print("slots instance has __dict__:", hasattr(s, "__dict__")) # False
try:
s.d = 4 # not in __slots__
except AttributeError as e:
print("slots blocks new attr :", type(e).__name__)
def footprint(cls, n=100_000):
tracemalloc.start()
keep = [cls(1, 2, 3) for _ in range(n)]
used, _ = tracemalloc.get_traced_memory()
tracemalloc.stop()
return used
md, ms = footprint(WithDict), footprint(WithSlots)
print(f"100k objs -> dict {md/1e6:.1f} MB slots {ms/1e6:.1f} MB")
dict instance has __dict__ : True
slots instance has __dict__: False
slots blocks new attr : AttributeError
100k objs -> dict 10.4 MB slots 6.4 MB
@property — add '__dict__' to slots if you need both.6 · Metaclasses — the type of a type
Here is the keystone: a class is an object, and its type is type. That means type is a metaclass — the thing that builds classes — and you can subclass it to hook class creation. type(name, bases, namespace) is literally what the class statement calls. A custom metaclass runs code at class-definition time: enforce interfaces, auto-register plugins, inject methods.
metaclass.py# A class is an OBJECT whose type is `type`. type is the default metaclass.
class Plain: pass
print(type(Plain)) # <class 'type'>
print(type(type)) # <class 'type'> (type is its own type)
# type(name, bases, namespace) builds a class dynamically — same as `class`.
Dyn = type("Dyn", (), {"greet": lambda self: "hi"})
print(Dyn().greet()) # hi
# A real metaclass: auto-register every subclass in a plugin registry.
class PluginMeta(type):
registry = {}
def __new__(mcls, name, bases, ns, **kw):
cls = super().__new__(mcls, name, bases, ns)
if bases: # skip the base itself
PluginMeta.registry[name.lower()] = cls
return cls
class Plugin(metaclass=PluginMeta): pass
class CsvLoader(Plugin): pass
class JsonLoader(Plugin): pass
print(sorted(PluginMeta.registry)) # ['csvloader', 'jsonloader']
print(type(CsvLoader).__name__) # PluginMeta
<class 'type'>
<class 'type'>
hi
['csvloader', 'jsonloader']
PluginMeta
7 · Prefer __init_subclass__ when you can
Most “do something when a subclass is defined” needs — registration, validation, defaulting a class attribute — don't require a metaclass at all. __init_subclass__ is a classmethod-like hook on the parent that runs for each subclass, is far easier to read, and composes cleanly. Reach for a metaclass only when you must control the class object's creation itself (custom __new__/namespace) or the metaclass hierarchy.
init_subclass.py# __init_subclass__ hooks subclass creation WITHOUT a metaclass — usually the
# right tool. It runs on the PARENT each time a subclass is defined.
class Tool:
registry = {}
def __init_subclass__(cls, /, name=None, **kw):
super().__init_subclass__(**kw)
cls.tool_name = name or cls.__name__.lower()
Tool.registry[cls.tool_name] = cls
class Search(Tool, name="search"): pass
class Calc(Tool): pass
print(sorted(Tool.registry)) # ['calc', 'search']
print(Search.tool_name) # search
['calc', 'search']
search
| Need | Reach for |
|---|---|
| Register/validate subclasses, set class attrs | __init_subclass__ (simplest) |
| Customize a per-class descriptor's name | __set_name__ |
| Control the class object's construction / namespace | a metaclass |
| Change attribute access on instances | descriptors / __getattr__ |
✓ Checkpoint — you can move on when you can…
- Implement
__eq__+__hash__+__lt__and use the object in a set andsorted(). - Predict when
__getattr__fires vs when normal lookup or a data descriptor wins. - Write a descriptor with a validating
__set__and explain why@propertyis one. - Compute the MRO of a diamond and trace a cooperative
super().__init__()chain. - Explain what
__slots__removes and choose between__init_subclass__and a metaclass.
You define __eq__ on a class and suddenly it can no longer be used as a dict key — TypeError: unhashable type. Why, and what is the correct fix?
Show answer
__eq__ makes Python set __hash__ = None automatically, because the invariant “equal objects must have equal hashes” could otherwise be violated. An object with __hash__ = None is unhashable, so it can't be a dict key or set member. Fix: also define __hash__ consistently with __eq__ (hash the same fields you compare) — e.g. def __hash__(self): return hash((self.x, self.y)). If the object is mutable and you want it unhashable on purpose, leave it as is.A teammate reaches for a metaclass to auto-register agent tool classes in a registry. Is that the right tool, and what's the lighter alternative?
Show answer
__init_subclass__ on a base Tool class: it runs once per subclass definition, can read keyword arguments from the class header (class Search(Tool, name="search")), and needs no metaclass. Reserve metaclasses for controlling class construction itself — custom __new__, namespace preparation, or enforcing that the metaclass hierarchy is consistent.🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Domain value objects (money, versions, coordinates) are far nicer when they print readably and compare by value.
Your task: Write a small value class with __repr__ and __eq__ so it prints clearly and compares correctly.
Requirements:
__init__storing two fields__repr__returning a useful string__eq__comparing by field values; test equality of two equal instances
💡 Hint: __repr__ should look like a constructor call.
Show solution
class Point:
def __init__(self, x, y): self.x, self.y = x, y
def __repr__(self): return f'Point({self.x}, {self.y})'
def __eq__(self, o): return isinstance(o, Point) and (self.x, self.y) == (o.x, o.y)
print(Point(1, 2)) # Point(1, 2)
print(Point(1, 2) == Point(1, 2)) # True
print(Point(1, 2) == Point(3, 4)) # FalsePoint(1, 2)
True
False
Context: Config objects are cleaner when missing keys fall back to a default instead of raising.
Your task: Write a Config whose known attributes work normally but whose unknown attributes return a default, using __getattr__.
Requirements:
- Set one real attribute in
__init__ __getattr__returnsNone(or a marker) for anything missing- Show a known attribute and a missing one
💡 Hint: __getattr__ only fires when normal lookup fails.
Show solution
class Config:
def __init__(self): self.host = 'localhost'
def __getattr__(self, name): return None # default for anything missing
c = Config()
print(c.host) # localhost (normal lookup)
print(c.port) # None (fell through to __getattr__)localhost
None__getattr__, don't access an undefined attribute of self or you'll recurse. Reading declared attributes is fine; unknown ones re-trigger the hook.Context: Reusable field validation (non-negative, in range, correct type) is best expressed once as a descriptor and shared across classes.
Your task: Write a Typed descriptor that enforces an expected type on assignment and reuse it on two fields.
Requirements:
__set_name__to capture the field name__set__raisesTypeErrorif the value isn't the expected type__get__returns the stored value; demonstrate a good and a bad assignment
💡 Hint: Store the backing value under a mangled name like '_' + name.
Show solution
class Typed:
def __init__(self, expected): self.expected = expected
def __set_name__(self, owner, name): self.store = '_' + name
def __get__(self, obj, objtype=None):
return self if obj is None else getattr(obj, self.store)
def __set__(self, obj, value):
if not isinstance(value, self.expected):
raise TypeError(f'expected {self.expected.__name__}')
setattr(obj, self.store, value)
class User:
name = Typed(str)
age = Typed(int)
def __init__(self, name, age): self.name, self.age = name, age
u = User('Ada', 36); print(u.name, u.age) # Ada 36
try: User('Ada', 'old')
except TypeError as e: print('rejected:', e)Ada 36
rejected: expected int
Context: Cooperative multiple inheritance (mixins that each do part of setup) only works if every class calls super() and you understand the MRO order.
Your task: Build a diamond of classes that each append to a shared list in __init__ via super(), then predict and verify the order from the MRO.
Requirements:
- A
Baseand two mixinsL,R, and a combinedLR - Each
__init__callssuper().__init__()then appends its name - Print
LR.__mro__and the resulting list; confirm they match
💡 Hint: The append order is the reverse of the call order, following the MRO.
Show solution
class Base:
def __init__(self): self.log = ['Base']
class L(Base):
def __init__(self): super().__init__(); self.log.append('L')
class R(Base):
def __init__(self): super().__init__(); self.log.append('R')
class LR(L, R):
def __init__(self): super().__init__(); self.log.append('LR')
print([c.__name__ for c in LR.__mro__]) # ['LR','L','R','Base','object']
print(LR().log) # ['Base','R','L','LR']['LR', 'L', 'R', 'Base', 'object']
['Base', 'R', 'L', 'LR']Calls go down the MRO (LR→L→R→Base); appends happen as the stack unwinds, so the list is the reverse: Base first, LR last.
Context: Plugin systems and tool registries often want every subclass registered automatically and required methods enforced at definition time — failing fast, not at first call.
Your task: Write a metaclass that registers each concrete subclass and rejects any that omits a required run method.
Requirements:
- Metaclass
__new__builds the class, registers it (skip the base), and checks forrun - A missing
runraisesTypeErrorat class creation - Show a valid subclass registering and an invalid one failing
💡 Hint: Check 'run' in namespace (or on the built class) before returning it.
Show solution
class ToolMeta(type):
registry = {}
def __new__(mcls, name, bases, ns, **kw):
cls = super().__new__(mcls, name, bases, ns)
if bases: # skip the base class
if 'run' not in ns:
raise TypeError(f'{name} must define run()')
ToolMeta.registry[name.lower()] = cls
return cls
class Tool(metaclass=ToolMeta): pass
class Search(Tool):
def run(self): return 'searching'
print(sorted(ToolMeta.registry)) # ['search']
try:
class Broken(Tool): pass # no run()
except TypeError as e:
print('rejected:', e)['search']
rejected: Broken must define run()__init_subclass__ perfectly and read more simply. Use the metaclass form only if you also need to control the class object's construction.Context: You own a framework where users subclass Agent to define tools. Product wants: (a) every tool auto-registered by a slug, (b) a timeout field that must be a positive number, (c) instances to be memory-light because a fleet spawns millions. Junior engineers keep reaching for metaclasses for all three.
Your task: Decide the right mechanism for each requirement and justify it; then sketch the minimal code.
Requirements:
- Map each requirement to the lightest correct object-model feature
- Justify why a metaclass is not needed for any of them
- Give a short combined sketch
💡 Hint: Registration → subclass hook; validated field → descriptor; footprint → slots.
Show solution
(a) Auto-registration → __init_subclass__. It runs once per subclass, can read a slug= keyword from the class header, and needs no metaclass — the readable, conflict-free choice.
(b) Positive timeout → a data descriptor. A reusable Positive/Typed descriptor validates on every assignment and can't be shadowed by an instance attribute. @property is the same mechanism if only one class needs it.
(c) Memory-light instances → __slots__. Removing the per-instance __dict__ cuts footprint measurably across millions of objects and blocks accidental attributes.
None of these require controlling class construction, so a metaclass would add complexity and risk metaclass conflicts (e.g. with ABCMeta) for no benefit.
class Positive:
def __set_name__(self, o, n): self.s = '_' + n
def __get__(self, o, t=None): return self if o is None else getattr(o, self.s)
def __set__(self, o, v):
if not (isinstance(v, (int, float)) and v > 0): raise ValueError('timeout>0')
setattr(o, self.s, v)
class Agent:
registry = {}
def __init_subclass__(cls, /, slug=None, **kw):
super().__init_subclass__(**kw)
Agent.registry[slug or cls.__name__.lower()] = cls
class Search(Agent, slug='search'):
__slots__ = ('timeout',)
timeout = Positive()
def __init__(self, timeout): self.timeout = timeout'__dict__' to slots.