# Inheritance and Polymorphism

## 1. Basic Inheritance

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

    def speak(self):
        raise NotImplementedError("subclasses must implement speak()")

class Dog(Animal):
    def speak(self):
        return f"{self.name} says Woof"

class Cat(Animal):
    def speak(self):
        return f"{self.name} says Meow"

for animal in [Dog("Rex"), Cat("Whiskers")]:
    print(animal.speak())  # polymorphism: same call, different behavior per subclass
```

`Dog(Animal)` means `Dog` inherits all of `Animal`'s attributes and methods, overriding whatever it defines itself. Calling `.speak()` on any `Animal` subclass dispatches to that specific subclass's implementation — this is polymorphism: one interface, many concrete behaviors, chosen at runtime based on the actual object's type.

## 2. `super()` — Calling the Parent's Implementation

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

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)   # delegates to Animal.__init__, don't repeat its logic
        self.breed = breed

d = Dog("Rex", "Labrador")
```

`super()` gives you a proxy for calling the next class in the method resolution order (MRO) — usually the immediate parent, but not always in multiple inheritance (section 5). Using `super().__init__(...)` instead of `Animal.__init__(self, ...)` matters because it respects the MRO, which becomes essential once multiple inheritance is involved.

## 3. Overriding and Extending Methods

```python
class Vehicle:
    def describe(self):
        return "A vehicle"

class Car(Vehicle):
    def describe(self):
        base = super().describe()   # extend, don't just replace
        return f"{base} that is a car"

print(Car().describe())  # "A vehicle that is a car"
```

Overriding entirely replaces a method; calling `super().method()` inside the override lets you extend the parent's behavior instead of duplicating it.

## 4. Abstract Base Classes

```python
from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

class Square(Shape):
    def __init__(self, side):
        self.side = side
    def area(self):
        return self.side ** 2

Shape()          # TypeError — can't instantiate an abstract class
Square(4).area()  # 16
```

`ABC` + `@abstractmethod` enforces, at instantiation time, that concrete subclasses actually implement every abstract method — a stronger contract than just raising `NotImplementedError` in the base method body, which only fails if/when that specific method is actually called.

## 5. Multiple Inheritance and the Diamond Problem

```python
class A:
    def greet(self):
        return "A"

class B(A):
    def greet(self):
        return "B->" + super().greet()

class C(A):
    def greet(self):
        return "C->" + super().greet()

class D(B, C):
    pass

print(D().greet())  # "B->C->A" — NOT "B->A", thanks to cooperative MRO
print(D.__mro__)     # (D, B, C, A, object)
```

Naively, you might expect `super()` inside `B` to jump straight to `A`, skipping `C` entirely. It doesn't — `super()` follows the class's linearized MRO, not "my direct parent," which is exactly what lets a diamond hierarchy call every class exactly once, in a consistent order (full mechanics in section 6).

## 6. Under the Hood: C3 Linearization and Cooperative `super()`

### The MRO is computed once, at class-creation time, by the C3 algorithm

When a class statement with bases executes, CPython computes a full, fixed ordering of every ancestor class — the **Method Resolution Order** — using the **C3 linearization** algorithm, and stores it as `__mro__` on the resulting type object. C3 guarantees two properties: a class always appears before its parents (**local precedence**), and the relative order of appearance of the *bases themselves* in a class's declaration is preserved across the whole hierarchy (**monotonicity**). If no consistent order can satisfy both constraints (a genuinely contradictory hierarchy), class creation fails immediately with `TypeError: Cannot create a consistent method resolution order`.

```python
class A: pass
class B(A): pass
class C(A): pass
class D(B, C): pass

print(D.__mro__)
# (D, B, C, A, object) — computed once when `class D(B, C):` executes
```

### `super()` is not "my parent" — it's "the next class after me in the CALLER's MRO"

This is the single most misunderstood mechanic in Python inheritance. `super()` inside a method defined on class `X`, called on an instance of (possibly a further subclass) `Y`, looks up `Y.__mro__`, finds `X`'s position in *that* list, and dispatches to whatever comes immediately after `X` in `Y`'s MRO — not necessarily `X`'s own direct base.

```python
# from section 5: D.__mro__ = (D, B, C, A, object)
# B.greet() calls super().greet() — but self is a D instance, so
# super() consults D's MRO, finds B, and calls the NEXT entry: C.
# C.greet() calls super().greet() — same D.__mro__, finds C, calls A.
```

![super() inside B.greet(), called on a D instance, walks D's MRO (not B's own base) to find C next, then A](diagrams/01-mro-super-chain.svg)

This is exactly how every class in a diamond gets visited exactly once despite each one only knowing about its own direct declared bases — the *instance's* full MRO is what every `super()` call in the chain actually consults, which is why the pattern only works reliably when *every* class in the hierarchy cooperatively calls `super()` instead of hardcoding a parent class name.

### `isinstance`/`issubclass` walk the MRO too

```python
isinstance(D(), A)   # True — A is in D's MRO
issubclass(D, A)      # True — same check, class-to-class
```

Both built-ins are, mechanically, membership checks against `__mro__` (with additional support for `__subclasshook__`/ABC virtual subclassing, e.g. how `collections.abc.Sequence` recognizes `list` without `list` literally inheriting from it).

## 7. Common Pitfalls

1. **Hardcoding the parent class name instead of `super()`** — `Animal.__init__(self, name)` works for simple single inheritance but breaks cooperative multiple inheritance, since it bypasses the MRO entirely and can call a class twice or skip one.
2. **Assuming `super()` means "my direct parent"** — as shown in section 6, it means "the next class in the *actual instance's* MRO after me" — these differ under multiple inheritance.
3. **Diamond hierarchies where not every class calls `super()`** — if any class in the chain skips the `super().method()` call, the chain breaks and classes after it never get invoked.
4. **Catching `TypeError` about inconsistent MRO too late** — it happens at class-definition time (when the `class` statement runs), not when you instantiate — design multiple-inheritance hierarchies carefully, don't discover the conflict at first use.

## 8. Quick Reference

```python
class Dog(Animal): ...             # single inheritance
class D(B, C): ...                   # multiple inheritance — MRO computed via C3
super().__init__(...)                 # delegates to next class in the INSTANCE's MRO
D.__mro__                              # tuple showing the full resolution order
isinstance(obj, Cls)                    # True if type(obj) or any ancestor is Cls
issubclass(Sub, Base)                    # True if Base is in Sub's MRO
from abc import ABC, abstractmethod       # enforce subclasses implement required methods
```
