Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

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:

  1. Local precedence: in class D(B, C), B must come before C.
  2. 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:

  1. Determine metaclass: the most derived metaclass of the bases, or the explicit metaclass= argument.
  2. metaclass.__prepare__(name, bases) — returns the namespace dictionary (typically dict or OrderedDict).
  3. Execute the class body, populating the namespace.
  4. metaclass.__new__(mcs, name, bases, namespace) — construct the class object.
  5. 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

TypeHas __set__/__delete__?Priority over instance __dict__
Data descriptorYesOverrides instance dict
Non-data descriptorOnly __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:

  1. Memory savings: no dict overhead (~56 bytes per attribute vs ~112 bytes for __dict__).
  2. Faster attribute access: descriptor-based access is a single pointer offset.
  3. 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