Filter concepts by levelShowing all levels.

Python · Advanced OOP

Advanced OOP

Multiple inheritance done safely, reusable behaviour via mixins, and the protocols classes and attribute access are actually built from

  • MRO + super()super() follows the MRO, not "the" parent class
  • Mixinsone focused behaviour, combined via multiple inheritance
  • Descriptors__get__/__set__ — what @property is built from
  • __slots__trades a __dict__ for a fixed, lower-memory attribute set

1.MRO, multiple inheritance, and super()

A class can inherit from more than one base — class C(A, B):. The MRO (Cls.__mro__) is the single lookup path Python computes across all of them. super() walks that path, letting every class in a chain cooperate correctly.

Key features

  • class C(A, B): gives C multiple bases — Cls.__mro__ is the exact lookup order
  • The MRO is computed via C3 linearization — a real algorithm, not just base order
  • super() means "the next class in the MRO after this one," not "my literal parent"
  • Cooperative inheritance: every class calls super().__init__(...) so the chain runs
  • A conflicting MRO raises TypeError at class definition time, not later

What determines the MRO, and what super() does with it

What determines the MRO, and what super() does with it
ConceptWhat it means
class C(A, B):C has two direct bases — both are searched for attributes
C.__mro__the full, linear lookup order: (C, A, B, ..., object)
super()a proxy for "the next class after the current one in the MRO"
Cooperative inheritanceevery __init__ calls super().__init__(...), continuing the chain
super() follows the MRO, not "the" parent

class C(A, B)

multiple bases

C.__mro__

one linear order: C, A, B, object

super()

next name in that same order

  1. class C(A, B) — multiple bases
  2. C.__mro__ — one linear order: C, A, B, object
  3. super() — next name in that same order

Command

python
class C(A, B):
    def method(self):
        super().method()   # calls the NEXT class in C's MRO, not A specifically

super() means "the next class in the MRO," never "my literal parent" — cooperative inheritance only works when every class in the chain calls it.

2.Mixins

A mixin is a small class designed to be combined via multiple inheritance — it adds one focused behaviour (logging, comparison, serialization) and is never meant to be instantiated alone. class Widget(LoggingMixin, Base): mixes it in.

Key features

  • A mixin adds one focused piece of behaviour — it is not meant to be instantiated alone
  • Mixin class names conventionally end in "Mixin" to signal the intent
  • The real base class is usually listed LAST so the MRO puts mixin overrides first
  • A mixin should not define __init__ unless it cooperates via super().__init__(**kwargs)
  • Several small, focused mixins compose more predictably than one large base class

What makes a class a mixin, by convention

What makes a class a mixin, by convention
TraitWhy it matters
Name ends in "Mixin"signals it is not meant to stand alone
No state of its own (usually)avoids __init__ conflicts when combined with other bases
One focused responsibilitycomposability — combine only the behaviours actually needed
Listed before the real base classclass Widget(LoggingMixin, Base): puts the mixin's overrides first in the MRO
A mixin adds one behaviour to a real base class

class Base

the real, standalone class

LoggingMixin

one focused behaviour, never used alone

class X(Mixin, Base)

combines both via multiple inheritance

  1. class Base — the real, standalone class
  2. LoggingMixin — one focused behaviour, never used alone
  3. class X(Mixin, Base) — combines both via multiple inheritance

Command

python
class SomeMixin:
    def extra_behaviour(self):
        return "added by the mixin"

class Widget(SomeMixin, Base):
    pass   # Widget now has extra_behaviour(), plus everything Base defines

A mixin adds one behaviour, never instantiated alone — list it before the real base class, and forward *args/**kwargs via super().__init__() if it has one.

3.Descriptors

A descriptor is a class implementing __get__ (and optionally __set__/__delete__), assigned as a CLASS attribute on another class — Python calls those methods instead of a plain lookup. @property is built entirely from this protocol.

Key features

  • A descriptor implements __get__(self, obj, owner), called instead of a plain lookup
  • A data descriptor also implements __set__/__delete__ — it wins over instance __dict__
  • A non-data descriptor (only __get__) can be shadowed by an instance attribute
  • Descriptors must be assigned as CLASS attributes, not instance attributes, to take effect
  • @property, and ordinary methods themselves, are both implemented as descriptors

Data vs. non-data descriptors, and what wins the lookup

Data vs. non-data descriptors, and what wins the lookup
Descriptor typeInstance __dict__ vs. descriptor
Data descriptor (__get__ + __set__)descriptor always wins, even over instance __dict__
Non-data descriptor (__get__ only)instance __dict__ wins if the same name is set there
Not a descriptor at allplain attribute lookup — instance, then class
@propertya built-in data descriptor — this is how it enforces read-only
A descriptor intercepts attribute access

obj.attr

looks like a plain attribute read

__get__(self, obj, owner)

runs instead, if attr is a descriptor

validated / computed

the descriptor decides what obj.attr returns

  1. obj.attr — looks like a plain attribute read
  2. __get__(self, obj, owner) — runs instead, if attr is a descriptor
  3. validated / computed — the descriptor decides what obj.attr returns

Command

python
class Descriptor:
    def __get__(self, obj, owner):
        return ...

    def __set__(self, obj, value):
        ...

class Name:
    attr = Descriptor()   # assigned as a CLASS attribute

A descriptor must be a CLASS attribute — a data descriptor always wins over instance __dict__. Store state on obj, never on the descriptor itself.

4.__getattribute__, __getattr__, and __setattr__

__getattribute__(self, name) intercepts EVERY attribute read, even ones that would succeed. __getattr__(self, name) fires only as a fallback, after lookup already failed. __setattr__(self, name, value) intercepts every write.

Key features

  • __getattribute__ runs for EVERY read — obj.x always goes through it, success or failure
  • __getattr__ only runs as a fallback, once normal lookup already raised AttributeError
  • An attribute that already exists never reaches __getattr__ — only a missing one does
  • __setattr__ intercepts every assignment, including inside __init__
  • Overriding __setattr__ without care causes infinite recursion — delegate via super()

When each hook actually runs

When each hook actually runs
HookRuns for
__getattribute__every single attribute read, unconditionally
__getattr__only when normal lookup already failed with AttributeError
__setattr__every single attribute write, unconditionally
Plain lookup (neither defined)instance __dict__, then class, following the MRO
__getattribute__ always runs; __getattr__ only on failure

obj.x

__getattribute__ runs, always

found?

success — return it, __getattr__ skipped

not found

__getattr__ runs as a fallback

  1. obj.x — __getattribute__ runs, always
  2. found? — success — return it, __getattr__ skipped
  3. not found — __getattr__ runs as a fallback

Command

python
class Name:
    def __getattr__(self, name):
        return f"missing: {name}"      # only for attributes that don't exist

    def __setattr__(self, name, value):
        super().__setattr__(name, value)   # must delegate, or recursion follows

__getattribute__ runs for every read; __getattr__ only runs after lookup fails. Overriding __setattr__ without delegating to super() causes infinite recursion.

5.__slots__

__slots__ = ("x", "y") declares a fixed set of attribute names an instance can hold, replacing per-instance __dict__ with lower-memory storage slots. Trades away arbitrary new attributes for less memory and slightly faster access.

Key features

  • __slots__ = ("x", "y") at class level declares the ONLY attribute names instances may hold
  • Instances of a __slots__ class have no __dict__ by default — memory savings come from this
  • Assigning an attribute not listed in __slots__ raises AttributeError
  • A subclass without its own __slots__ gets a __dict__ back, silently undoing the savings
  • Multiple inheritance with more than one non-empty __slots__ base raises TypeError

What __slots__ changes about a class

What __slots__ changes about a class
Without __slots__With __slots__
Any new attribute name can be assignedonly names listed in __slots__ are allowed
Each instance carries a __dict__no __dict__ — fixed-size storage instead
More memory per instanceless memory per instance, especially at scale
Subclass inherits flexibility automaticallya subclass needs its OWN __slots__ to keep the savings

Command

python
class Name:
    __slots__ = ("a", "b")   # ONLY these two attributes are allowed

    def __init__(self, a, b):
        self.a = a
        self.b = b

__slots__ removes a __dict__ for a fixed attribute set — a subclass keeps that benefit only if every class in its MRO also declares __slots__.

6.Metaclasses

A metaclass is the class of a class — type(SomeClass) is type by default, meaning type builds every ordinary class. class Name(metaclass=Meta): lets Meta control how Name itself gets built, the way a class controls its own instances.

Key features

  • type(SomeClass) is type by default — every ordinary class already has one
  • class Name(metaclass=Meta): makes Meta build Name, instead of the default type
  • A metaclass is a subclass of type, most often overriding __new__ or __init__
  • ABCMeta (behind abc.ABC) is the most common metaclass most code actually meets
  • Goal here is conceptual: knowing WHAT a metaclass is, not writing one routinely

The parallel between object construction and class construction

The parallel between object construction and class construction
LevelWhat builds it
An instance (p = Point(1, 2))built by its class, Point
A class (class Point: ...)built by its metaclass, type by default
type(p)Point — the class an instance belongs to
type(Point)type — the metaclass a class belongs to
A metaclass builds classes, the way a class builds instances

type

the default metaclass

class Point

built by type

p = Point()

an instance, built by Point

  1. type — the default metaclass
  2. class Point — built by type
  3. p = Point() — an instance, built by Point

Command

python
class Meta(type):
    def __new__(mcls, name, bases, namespace):
        return super().__new__(mcls, name, bases, namespace)

class Name(metaclass=Meta):
    pass

A metaclass builds classes, the way a class builds instances — type is the default. Reach for __init_subclass__ if the goal is only "run code on subclassing."

Remember

  • super() means "the next class in the MRO," never "my literal parent" — cooperative inheritance needs every class to call it.
  • List a mixin before the real base class so its overrides win the MRO — order in class C(Mixin, Base): matters.
  • A descriptor must be a CLASS attribute to take effect — a data descriptor (with __set__) always wins over instance __dict__.
  • __getattribute__ runs for every read, unconditionally; __getattr__ only runs when normal lookup already failed.

Pro tip

  • A subclass only keeps __slots__' memory savings if every class in its MRO also declares __slots__ — an empty tuple if it needs none.
  • Store per-instance descriptor state via getattr(obj, ...)/setattr(obj, ...) on obj, never directly on the descriptor itself.
  • Reach for __init_subclass__ instead of a metaclass whenever the goal is just "run code when a subclass is created."
  • Understand not only how multiple inheritance works, but when composition would avoid needing it at all.

Next up

  • Python Internals

Sources

  • Data model — docs.python.org/3/reference/datamodel.html
  • Descriptor HowTo — docs.python.org/3/howto/descriptor.html
  • Every example executed on Python 3.14.3 · checked 2026-08-21

Spotted a mistake on this page? Tell us.