Object Model & Inheritance
Overview
Everything in Python is an object — integers, functions, classes, modules, and even types themselves. The object model is built on a clean hierarchy where type is both an instance and a subclass of object. This document covers the type system internals, the Method Resolution Order (MRO), super(), metaclasses, descriptors, and the memory-saving __slots__ mechanism.
Everything is an object
The type hierarchy
# Everything is an instance of type
print(isinstance(42, type)) # False — 42 is an instance of int, not type
print(isinstance(int, type)) # True — classes are instances of type
print(isinstance(type, type)) # True — type is its own metaclass
print(isinstance(type, object)) # True
print(isinstance(object, type)) # True
print(isinstance(object, object)) # True
The circularity at the top (type is an instance of itself, object is an instance of type) is bootstrapped by the C runtime. The hierarchy looks like:
object (base of everything)
↑
type (metaclass of all classes, including itself)
↑
int, str, list, MyClass, function, module ...
Instance vs class attribute lookup
When you write obj.attr, Python performs the following search:
1. obj.__dict__["attr"]
2. type(obj).__dict__["attr"] ─── (if a data descriptor, stop here)
3. For each cls in type(obj).__mro__:
cls.__dict__["attr"]
4. type(obj).__getattr__(obj, "attr") ─── (if defined)
5. Raise AttributeError
The critical nuance: step 2 happens before the MRO walk if the attribute is a data descriptor (defines __set__ or __delete__). Non-data descriptors (only __get__) are overridden by instance __dict__ entries.
Method Resolution Order (MRO)
The problem
Given multiple inheritance, in what order should Python search for a method?
class A:
def f(self): return "A"
class B(A):
def f(self): return "B"
class C(A):
def f(self): return "C"
class D(B, C):
pass
d = D()
print(d.f()) # "B" — but why B and not C?
C3 linearization
Python 3 uses the C3 linearization algorithm to compute the MRO. The rule is:
The linearization of a class C(B1, B2, ..., Bn) is: C + merge(L[B1], L[B2], ..., L[Bn], [B1, B2, ..., Bn])
Where merge repeatedly picks the first head that does not appear in the tail of any other list, appends it, and removes all occurrences. If no valid head exists, the hierarchy is inconsistent and Python raises TypeError.
For the example above:
L[A] = [A, object]
L[B] = [B, A, object]
L[C] = [C, A, object]
L[D] = [D] + merge([B, A, object], [C, A, object], [B, C])
= [D, B] + merge([A, object], [C, A, object], [C])
= [D, B, C] + merge([A, object], [A, object])
= [D, B, C, A, object]
Check with D.__mro__ — this is the exact result.
Consistency constraints
C3 linearization enforces two properties:
- Local precedence: in
class D(B, C), B must come before C. - Monotonicity: the MRO of a parent's MRO must be preserved (no reordering).
If you attempt a diamond where these conflict, Python will refuse to create the class:
class X: pass
class Y(X): pass
class Z(X, Y): pass # TypeError: Cannot create a consistent method resolution
Accessing the MRO
print(D.__mro__) # (D, B, C, A, object)
print(D.mro()) # same, as a list
super() — cooperative multiple inheritance
In Python 3, super() has two forms
class D(B, C):
def f(self):
# Zero-argument form (binds to the enclosing class and self)
result = super().f() # equivalent to super(D, self)
# Explicit form
result = super(B, self).f() # skip B, start search after B in MRO
The zero-argument form is compile-time magic: the compiler inserts __class__ into the cell variables of the enclosing function, so super() knows which class it's called from.
How super() uses the MRO
super(D, self) does not mean "the parent of D". It means "start searching the MRO of type(self) after D":
class A:
def f(self):
print("A")
class B(A):
def f(self):
print("B")
super().f() # finds next class in MRO after B
class C(A):
def f(self):
print("C")
super().f()
class D(B, C):
def f(self):
print("D")
super().f()
D().f()
# Output: D, B, C, A
This is cooperative inheritance: every class in the chain calls super() so the entire MRO gets traversed.
Metaclasses
A metaclass is the class of a class. Just as int creates integer instances, a metaclass creates class instances.
class Meta(type):
def __new__(mcs, name, bases, namespace):
# Intercept class creation
print(f"Creating class {name}")
namespace["created_by"] = "Meta"
return super().__new__(mcs, name, bases, namespace)
class MyClass(metaclass=Meta):
x: int = 1
print(MyClass.created_by) # "Meta"
The class-creation protocol
When Python encounters class MyClass(Base1, Base2):, it calls:
- Determine metaclass: the most derived metaclass of the bases, or the explicit
metaclass=argument. metaclass.__prepare__(name, bases)— returns the namespace dictionary (typicallydictorOrderedDict).- Execute the class body, populating the namespace.
metaclass.__new__(mcs, name, bases, namespace)— construct the class object.metaclass.__init__(cls, name, bases, namespace)— initialise (rarely needed,__new__is preferred).
Common metaclass patterns
# Singleton
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
# Auto-register subclasses
class PluginRegistry(type):
registry = []
def __new__(mcs, name, bases, namespace):
cls = super().__new__(mcs, name, bases, namespace)
if name != "PluginBase":
mcs.registry.append(cls)
return cls
class PluginBase(metaclass=PluginRegistry):
pass
class MyPlugin(PluginBase):
pass
print(PluginRegistry.registry) # [MyPlugin]
Metaclasses are powerful but should be a last resort — class decorators and __init_subclass__ solve most of the same problems with less magic.
__init_subclass__ (Python 3.6+)
A lighter alternative to metaclasses for hooking subclass creation:
class Base:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
print(f"Subclass created: {cls.__name__}")
class Child(Base):
pass # prints "Subclass created: Child"
Descriptors
Descriptors are objects that implement __get__, __set__, or __delete__. They control attribute access.
Protocol
class Descriptor:
def __get__(self, obj, objtype=None):
# obj = instance, objtype = class
...
def __set__(self, obj, value):
# obj = instance, value = value being set
...
def __delete__(self, obj):
...
def __set_name__(self, owner, name):
# Python calls this at class creation, passing the attribute name
self.name = name
Data vs non-data descriptors
| Type | Has __set__/__delete__? | Priority over instance __dict__ |
|---|---|---|
| Data descriptor | Yes | Overrides instance dict |
| Non-data descriptor | Only __get__ | Instance dict overrides it |
This distinction is why @property (a data descriptor) always wins, but a plain function (a non-data descriptor via __get__ as bound method) can be shadowed by an instance attribute.
Implementing @property by hand
class Property:
def __init__(self, fget=None, fset=None, fdel=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
def __get__(self, obj, objtype=None):
if obj is None:
return self
return self.fget(obj)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def setter(self, fset):
return Property(self.fget, fset, self.fdel)
# Usage mirrors @property
class Person:
def __init__(self, name):
self._name = name
def name(self):
return self._name
@name.setter
def name(self, value):
self._name = value
name = Property(name, name.setter)
__slots__
__slots__ replaces the per-instance __dict__ with a fixed-size array of descriptors. Benefits:
- Memory savings: no dict overhead (~56 bytes per attribute vs ~112 bytes for
__dict__). - Faster attribute access: descriptor-based access is a single pointer offset.
- Prevents accidental attribute creation: setting an undeclared attribute raises
AttributeError.
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(1, 2)
print(p.x) # 1
p.z = 3 # AttributeError: 'Point' object has no attribute 'z'
print(p.__dict__) # AttributeError: no __dict__ unless __slots__ includes '__dict__'
Slots and inheritance
class Base:
__slots__ = ("a",)
class Child(Base):
__slots__ = ("b",) # Child has a and b as slots, no __dict__
class Grandchild(Child):
pass # Grandchild gets __dict__ back (slots is not inherited as a restriction)
If a non-slotted class inherits from a slotted class, the subclass gets __dict__ and the memory benefit is lost for the new attributes. To keep slots across the hierarchy, every class in the chain must define __slots__.
Multiple inheritance patterns
Mixins
Mixins are small, focused classes that add behavior without being a primary base:
class SerializableMixin:
def to_dict(self):
return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
class LoggingMixin:
def log(self, msg):
print(f"[{self.__class__.__name__}] {msg}")
class User(SerializableMixin, LoggingMixin):
def __init__(self, name, email):
self.name = name
self.email = email
u = User("Alice", "alice@example.com")
print(u.to_dict()) # SerializableMixin behavior
u.log("created") # LoggingMixin behavior
The diamond problem resolved
class Root:
def method(self):
print("Root")
class A(Root):
def method(self):
print("A")
super().method()
class B(Root):
def method(self):
print("B")
super().method()
class C(A, B):
def method(self):
print("C")
super().method()
C().method()
# Output: C, A, B, Root
Every class calls super(), so the MRO (C -> A -> B -> Root) is traversed exactly once. Python's super() implements cooperative multiple inheritance: each class cooperates by calling super(), and the MRO ensures no class is visited twice.
See also
- Python Reference — package management, FastAPI, and project patterns
- Async/Await Technical Deep-Dive — how coroutines, tasks, and the event loop work
- TypeScript Compilation & JS Prototype Model — contrast with JavaScript's prototypal inheritance