Object-Oriented Programming in Python

Difficulty

What MRO decides

When you access obj.attr and multiple base classes could define attr, the MRO is the order Python searches classes to find it. For single inheritance it's obvious (child, then parent, then grandparent...); with multiple inheritance, it's not — and Python needs a deterministic rule.

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):
    pass

D().who()          # 'B'
D.__mro__
# (<class 'D'>, <class 'B'>, <class 'C'>, <class 'A'>, <class 'object'>)

Naive depth-first search (D → B → A → C → A) would visit A twice and find B's who before C's only by luck of ordering. C3 linearization guarantees a single, consistent order: D, B, C, A, object.

C3 linearization, in plain terms

C3 merges the MROs of each parent with the list of parents itself, subject to two rules:

  1. Local precedence order — a class appears before its own parents.
  2. Monotonicity — if a class appears before another in a parent's MRO, it must appear before it in the child's MRO too.

The algorithm repeatedly takes the head of the first list that doesn't appear in the tail of any other list — this is what guarantees D's bases (B then C, in the order declared) are respected while still visiting each ancestor exactly once.

Why this makes cooperative super() calls safe

Because the MRO is a single linear order shared by every class in the hierarchy, calling super().method() inside B doesn't go straight to A — it goes to whatever is next in D's MRO after B, which is C. This is what makes "cooperative multiple inheritance" (mixins) work: each class's super() call passes control along the same MRO, so every class in the chain gets a turn, in a consistent order, regardless of which concrete subclass you instantiate.

Inconsistent hierarchies raise an error

class X(A, B): pass
class Y(B, A): pass
class Z(X, Y): pass   # TypeError: Cannot create a consistent MRO

If two base classes disagree on relative ordering, C3 has no valid linearization and Python refuses to create the class rather than guessing.

Interview-ready summary: MRO is the deterministic search order for methods/attributes across a (possibly multiple-inheritance) class hierarchy, computed by C3 linearization and inspectable via __mro__. It guarantees each ancestor is visited once, respects declared base-class order, and is what makes super() reliably cooperative rather than hard-coded to "my direct parent."

super() is "next in MRO," not "my parent"

class LoggingMixin:
    def __init__(self, *a, **kw):
        print("LoggingMixin init")
        super().__init__(*a, **kw)

class TimestampMixin:
    def __init__(self, *a, **kw):
        print("TimestampMixin init")
        super().__init__(*a, **kw)

class Service(LoggingMixin, TimestampMixin):
    def __init__(self):
        print("Service init")
        super().__init__()

Service()
# Service init
# LoggingMixin init
# TimestampMixin init

Service.__mro__ is [Service, LoggingMixin, TimestampMixin, object]. Inside LoggingMixin.__init__, super() doesn't call object.__init__ directly — it calls whatever is next after LoggingMixin in Service's MRO, which happens to be TimestampMixin. This only works because every mixin cooperatively calls super().__init__() and accepts *args, **kwargs it doesn't recognize, passing them along.

Why "cooperative" multiple inheritance requires this discipline

If any mixin in the chain doesn't call super().__init__(), the chain breaks and classes later in the MRO never get initialized. This is why well-designed mixins always call super() even though, read in isolation, it's not obvious what "next" even refers to — it depends on the final class that combines them, which the mixin author can't know in advance.

Single inheritance: looks simple, same mechanism

class Animal:
    def __init__(self, name):
        self.name = name

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)   # calls Animal.__init__
        self.breed = breed

Here super() happens to resolve to Animal because the MRO is just [Dog, Animal, object] — but it's the same "next in MRO" lookup, not special-cased single-inheritance syntax.

super() vs calling the parent class explicitly

class Dog(Animal):
    def __init__(self, name, breed):
        Animal.__init__(self, name)   # works, but bypasses MRO
        self.breed = breed

Animal.__init__(self, name) calls Animal directly and skips the MRO entirely — in multiple inheritance this can call a class twice or skip classes, so super() is preferred whenever cooperative multiple inheritance (mixins) is in play.

Interview-ready summary: super() doesn't mean "my parent class" — it means "the next class after me in this instance's MRO." That indirection is precisely what allows mixins to be composed in any order and still all run, as long as every class in the chain forwards the call with super().__init__(*args, **kwargs).

Related Resources

The three method types side by side

class Pizza:
    def __init__(self, toppings):
        self.toppings = toppings

    def add_topping(self, topping):        # instance method: needs a specific pizza
        self.toppings.append(topping)

    @classmethod
    def margherita(cls):                    # classmethod: alternative constructor
        return cls(["mozzarella", "tomato"])

    @staticmethod
    def is_valid_topping(name):             # staticmethod: no self/cls needed
        return name.lower() in KNOWN_TOPPINGS
  • add_topping needs self because it mutates a specific pizza's toppings — calling it makes no sense without an instance.
  • margherita is a factory method: Pizza.margherita() builds a new instance without the caller needing to know the constructor's exact argument list. cls refers to the actual class it's called on, so a subclass calling margherita() correctly builds that subclass, not hard-coded to Pizza.
  • is_valid_topping doesn't touch instance or class state at all — it's grouped inside Pizza purely for namespacing/discoverability (Pizza.is_valid_topping("basil")), and could just as well be a module-level function.

Why classmethod respects subclassing and staticmethod doesn't apply

class StuffedCrustPizza(Pizza):
    pass

StuffedCrustPizza.margherita()   # returns a StuffedCrustPizza, not a Pizza!

Because margherita uses cls(...) rather than Pizza(...), calling it on a subclass constructs the subclass. This is the main reason to prefer @classmethod factories over hardcoding the class name.

When to choose which

NeedChoice
Operates on instance datainstance method
Alternative constructor / needs the actual class@classmethod
Utility function that's logically related but touches no instance/class state@staticmethod (or just a module function)

Interview-ready summary: Instance methods operate on self; classmethods operate on cls and are the standard pattern for alternative constructors that respect subclassing; staticmethods are plain functions namespaced under a class with no implicit self/cls access at all.

Basic usage

class Circle:
    def __init__(self, radius):
        self._radius = radius

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value <= 0:
            raise ValueError("radius must be positive")
        self._radius = value

    @property
    def area(self):                 # read-only, computed property
        return 3.14159 * self._radius ** 2

c = Circle(5)
c.radius          # 25 -> looks like an attribute, actually calls the getter
c.radius = 10      # calls the setter, validates
c.area             # computed on the fly, no setter defined -> read-only
c.area = 100        # AttributeError: can't set attribute

Callers write c.radius, not c.get_radius() — the property mechanism is invisible from the outside.

Why this beats Java/C#-style getters/setters written from day one

Python convention starts with a plain public attribute (self.radius = radius, no property at all). If validation or computed logic is needed later, converting it to a @property doesn't change the public API — obj.radius still works exactly the same for every existing caller. Writing get_radius()/set_radius() methods from the start (the Java convention) locks callers into method-call syntax even when no validation is needed, and later can't be un-done without breaking callers.

Read-only vs computed properties

A property with only a getter (like area above) is effectively read-only — attempting c.area = 100 raises AttributeError. This is a clean way to expose a derived value without letting callers set it directly and risk it going out of sync with radius.

@property vs __slots__ and descriptors

property is itself implemented as a descriptor (it defines __get__/__set__/__delete__) — it's the built-in, most common special case of the more general descriptor protocol used for custom attribute-access behavior (see also: descriptors question).

Interview-ready summary: @property lets an attribute-looking access run arbitrary code (validation, computed values, logging) while keeping the calling code's syntax unchanged. The idiomatic Python pattern is to start with plain attributes and only introduce a property when you actually need that behavior — never write boilerplate getters/setters preemptively.

Related Resources

The descriptor protocol

A class is a descriptor if it implements any of:

class Descriptor:
    def __get__(self, obj, objtype=None): ...
    def __set__(self, obj, value): ...
    def __delete__(self, obj): ...

When such an object is assigned as a class attribute of another class, accessing that attribute through an instance routes through the descriptor's methods instead of Python's normal attribute lookup.

A reusable validating descriptor

class PositiveNumber:
    def __set_name__(self, owner, name):
        self._name = "_" + name

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        return getattr(obj, self._name)

    def __set__(self, obj, value):
        if value <= 0:
            raise ValueError(f"{self._name[1:]} must be positive")
        setattr(obj, self._name, value)

class Circle:
    radius = PositiveNumber()     # reusable across classes/attributes

class Rectangle:
    width = PositiveNumber()
    height = PositiveNumber()

Unlike @property, which you write once per attribute, PositiveNumber is written once and reused for radius, width, and height — this is exactly how libraries like Django models and attrs/dataclasses validators implement field-level behavior under the hood.

property is a descriptor

class property:
    def __get__(self, obj, objtype=None): ...
    def __set__(self, obj, value): ...

@property is a convenience wrapper that constructs exactly this kind of descriptor object from the getter/setter/deleter functions you decorate — it's the special case of "one descriptor instance per attribute, defined inline," while a hand-written descriptor class is the general case of "one reusable descriptor type, instantiated per attribute."

Data descriptors vs non-data descriptors

  • Data descriptor: defines __set__ (and/or __delete__) — takes priority over instance __dict__ even if the instance has its own entry with the same name.
  • Non-data descriptor: defines only __get__ — instance __dict__ takes priority over it. This is how plain functions work as methods: a function is a non-data descriptor whose __get__ produces a bound method, but you could shadow it by assigning directly into instance.__dict__["method_name"].

Interview-ready summary: Descriptors are the general mechanism behind attribute access customization — property, bound methods, and classmethod/staticmethod are all built on the descriptor protocol. Write a custom descriptor when you need the same validation/behavior reused across multiple attributes or classes; use @property for a one-off attribute on a single class.