Attribute Lookup and the MRO
How Python finds an attribute, C3 linearisation, what super actually does in cooperative multiple inheritance, and what __slots__ changes about all of it.
How Python finds an attribute, C3 linearisation, what super actually does in cooperative multiple inheritance, and what __slots__ changes about all of it.
A mixin adds caching to your loader. Another adds logging. Both
call super().__init__(), both work alone, and the class that
uses both silently skips the logging one — because someone wrote
Loader.__init__(self) instead of super().__init__() in a
place that looked equivalent.
super() does not mean "call the parent". It means something
more precise, and the difference only shows up with multiple
inheritance, which is exactly when it is hardest to debug. By
the end of this lesson you will know the order Python resolves
attributes in, how that order is computed, what super()
actually does, and what __slots__ changes about all of it.
Every class has a method resolution order: the sequence of classes searched when an attribute is not found on the instance.
class Loader:
def load(self): ...
class CachingLoader(Loader):
pass
print(CachingLoader.__mro__)
# (CachingLoader, Loader, object)Reading instance.load searches these in order, stopping at the
first hit:
Data descriptors on the type
They win over the instance, which is the whole point of them.
The instance __dict__
Whatever was assigned to this particular object.
Each class in the MRO, in order
Where ordinary methods and class attributes are found.
__getattr__
Called only after all of the above have failed.
With single inheritance the MRO is the obvious chain. With multiple inheritance it is computed by an algorithm called C3 linearisation, and it guarantees three things:
A class comes before all of its bases
Bases appear in the order they were listed
The order is the same for every class in the hierarchy
If no order can satisfy all three, the class statement fails immediately:
class A: pass
class B(A): pass
class C(A, B): pass
# TypeError: Cannot create a consistent method resolution orderThat is Python refusing to guess. C asks for A before B,
but B is a subclass of A and must come first — there is no
answer, so it says so at definition time.
class Loader:
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.loaded = 0
class CachingLoader(Loader):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.cache = {}
class LoggingLoader(Loader):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.log = []
class PhotoLoader(CachingLoader, LoggingLoader):
passprint([c.__name__ for c in PhotoLoader.__mro__])
# ['PhotoLoader', 'CachingLoader', 'LoggingLoader', 'Loader', 'object']Loader appears once, after both subclasses, even though
both inherit from it. That is what C3 buys: in a diamond, the
shared base is initialised once, not twice.
Now the sentence that matters: super() is the next class in
the MRO of the instance's type, not the parent of the class
containing the call.
In CachingLoader.__init__, super() is LoggingLoader when
the instance is a PhotoLoader — a class CachingLoader knows
nothing about and does not inherit from.
That is why super() is not a synonym for naming the parent:
Bad — calling the base class directly.
class CachingLoader(Loader):
def __init__(self, **kwargs):
Loader.__init__(self, **kwargs) # skips the MRO
self.cache = {}Good — following the chain.
class CachingLoader(Loader):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.cache = {}The first version jumps straight from CachingLoader to
Loader, skipping LoggingLoader entirely. So a PhotoLoader
has no self.log, and the failure is an AttributeError
somewhere far away — or worse, a logging mixin that quietly
records nothing. CachingLoader looks correct in isolation and
tests fine on its own; it only breaks in a combination its
author never saw.
The second lets each class hand off to whatever comes next for this instance, which is the only way a mixin can work without knowing its siblings.
Multiple inheritance works when every class in the chain follows the same contract:
call super() in every override, including __init__
accept **kwargs and pass them on
do your own work before or after the super() call, deliberately
never assume which class super() will reachOrder matters where the work happens. Doing your part before
super() means the most derived class acts first; after means
the base acts first. For setup, after is usually right — the
base's state exists before you build on it. For teardown, the
reverse.
And the practical constraint that decides whether to use
multiple inheritance at all: mixins should be orthogonal.
Caching and logging do not interact, so any order works. Two
mixins that both override load and both transform the result
have an order-dependent outcome, and nobody reading
class PhotoLoader(A, B) will realise it.
When they are not orthogonal, composition is the better tool — hold an object rather than inherit from it, as the foundations course put it, and then the order is written down as calls instead of implied by a base list.
Three things worth knowing how to inspect:
PhotoLoader.__mro__ # the search order
PhotoLoader.mro() # the same, as a list
type(loader).__mro__ # what actually applies
vars(loader) # the instance __dict__
type(loader).__dict__ # this class only, not inherited
dir(loader) # every name, from everywherevars() versus dir() is the useful distinction. vars shows
what this object itself holds; dir shows everything reachable,
including inherited methods and dunders.
And the name-mangling rule, which surprises people:
class Loader:
def __init__(self):
self.__cache = {} # becomes _Loader__cacheA double leading underscore inside a class body is rewritten to
include the class name. It is not privacy — it is collision
avoidance, so a subclass defining its own __cache does not
overwrite the base's. Use a single underscore for "internal";
use two only when you specifically need a subclass not to
collide.
By default every instance carries a __dict__, which is what
lets you add attributes at any time. __slots__ replaces it
with a fixed set:
class Photo:
__slots__ = ("name", "width", "height")
def __init__(self, name, width, height):
self.name = name
self.width = width
self.height = heightphoto.caption = "Dawn"
# AttributeError: 'Photo' object has no attribute 'caption'Two benefits and several catches.
The benefits: less memory — substantially, for millions of small objects — and attribute access is marginally faster, because slots are descriptors on the class rather than a dictionary lookup.
The catches, in the order they bite:
No __dict__, so nothing can add attributes later, and any
code writing to instance.__dict__ — including the descriptor
from the previous lesson — fails.
Inheritance undoes it. A subclass without its own
__slots__ gets a __dict__ back, and the memory saving is
gone. Every class in the chain must declare slots.
No class-level defaults for slotted names. __slots__ = ("width",) and width = 0 in the same body is an error,
because the slot descriptor and the class attribute claim the
same name.
Weak references need declaring. Add "__weakref__" to the
tuple if anything will hold a weak reference to the instance.
Use it when you have measured a memory problem and have many
small instances. @dataclass(slots=True) sets it up correctly
for you, which is the least error-prone route.
LOOKUP ORDER
1 data descriptors on the type
2 instance __dict__
3 each class in type(obj).__mro__, in order
4 non-data descriptors / class attributes
5 __getattr__
THE MRO
C3 linearisation guarantees:
a class before its bases
bases in the order listed
consistent across the whole hierarchy
impossible orders fail at CLASS DEFINITION time
in a diamond, the shared base appears ONCE
SUPER
super() = the NEXT class in the MRO of type(self)
NOT the parent of the enclosing class
Base.method(self) skips the rest of the chain
a mixin that does that works alone and breaks in combination
COOPERATIVE CLASSES
always super() in overrides, including __init__
accept **kwargs and forward them
object.__init__ raises on leftovers - a useful check
mixins must be ORTHOGONAL; if they are not, use composition
INSPECTING
T.__mro__ / T.mro() the order
vars(obj) what this object holds
dir(obj) everything reachable
__x inside a class -> _ClassName__x, collision avoidance
NOT privacy; one underscore for that
__SLOTS__
fixed attributes, no instance __dict__
yes: much less memory for MANY small instances
no: cannot add attributes; breaks __dict__-writing descriptors
a subclass without __slots__ brings __dict__ back
no class-level default for a slotted name
declare "__weakref__" if weak refs are needed
@dataclass(slots=True) does it correctly
measure first; do not add it for speedThe object model is now complete: how an attribute is found, in
what order, how super() walks a chain that is decided by the
instance rather than the class, and what trading the instance
dictionary away costs. The sentence to keep is the one about
super() — most multiple-inheritance bugs are a class that
named its base directly and worked fine until it was combined
with something.
Next is The Import System, which moves from how a class
finds its members to how your program finds its modules. It
covers what import really does after the foundations course's
summary — finders, loaders, sys.path, the caching that makes
reimport a no-op, and why circular imports fail where they do.
Before you move on, build the diamond from this lesson and print
its __mro__. Then change one super().__init__() to name the
base directly, and confirm that one of the mixins stops
initialising. Seeing the attribute vanish without an error is
what makes the rule stick.