Classes and Inheritance
Defining a class
Section titled “Defining a class”The class keyword defines a new type.
The body contains methods (functions defined inside a class) and optionally class attributes (shared by all instances).
class Dog: species: str = "Canis lupus familiaris" # class attribute
def __init__(self, name: str, age: int) -> None: self.name = name # instance attribute self.age = age # instance attribute
def bark(self) -> str: return f"{self.name} says woof!"self — the instance reference
Section titled “self — the instance reference”Every instance method receives the instance as its first argument, conventionally named self.
Python passes it automatically; you never pass it yourself:
rex = Dog("Rex", 3)rex.bark() # Python calls Dog.bark(rex) under the hoodself is not a keyword — you could name it anything — but self is the universal Python convention and deviating from it confuses every reader and every tool.
Instance vs class attributes
Section titled “Instance vs class attributes”| Instance attribute | Class attribute | |
|---|---|---|
| Where defined | Inside __init__, on self | Directly in the class body |
| Where stored | On each individual object | On the class itself |
| Access | obj.attr | ClassName.attr or obj.attr (fallback) |
Mutating a class attribute through an instance creates a shadow instance attribute and does not change the class attribute for other instances. Use class attributes only for constants or shared state that truly belongs to the class.
Shared mutable class attributes — a real bug
Section titled “Shared mutable class attributes — a real bug”A class attribute is one object, owned by the class, and every instance that hasn’t shadowed it reads the same object. That’s harmless for an immutable default like a string. It’s a bug the moment the default is mutable — a list or dict — because every instance’s .append() mutates the one shared object:
class Dog: tricks: list[str] = [] # BUG: one list object shared by every instance
def add_trick(self, trick: str) -> None: self.tricks.append(trick) # mutates the SHARED class attributerex.add_trick("sit") and fido.add_trick("roll over") both mutate the exact same list — rex.tricks ends up ['sit', 'roll over'], and rex.tricks is fido.tricks is True. The fix is to make tricks an instance attribute, created fresh inside __init__:
class FixedDog: def __init__(self, name: str) -> None: self.name = name self.tricks: list[str] = [] # instance attribute -- one list per dogNow each dog gets its own list; good_rex.tricks is good_fido.tricks is False. This is the exact same defect as a mutable default argument (covered in the Functions module) wearing a different hat — a single mutable object is unintentionally shared across many owners.
__init__ vs __new__
Section titled “__init__ vs __new__”__init__ initializes an already-created instance — it never returns anything. __new__ is what actually creates the instance; it runs first, receives the class (cls) instead of self, and must return an object (usually via super().__new__(cls)). You almost never need to override __new__ — the two common reasons are enforcing a singleton and subclassing an immutable built-in type like tuple or str, which must have their value fixed before __init__ could set anything:
class Singleton: _instance: "Singleton | None" = None
def __new__(cls) -> "Singleton": if cls._instance is None: cls._instance = super().__new__(cls) return cls._instanceSingleton() called twice returns the same object both times (a is b is True) — __new__ short-circuits construction instead of building a fresh instance each call.
MRO and cooperative super().__init__(**kwargs)
Section titled “MRO and cooperative super().__init__(**kwargs)”When a class has more than one base — multiple inheritance — Python needs a deterministic order to search for methods. That order is the Method Resolution Order (MRO), computed by the C3 linearization algorithm and exposed as ClassName.__mro__. For a diamond — two classes sharing a common ancestor — the MRO visits each class exactly once:
class Base: def __init__(self, **kwargs: object) -> None: print("Base.__init__", kwargs) super().__init__()
class Left(Base): def __init__(self, **kwargs: object) -> None: print("Left.__init__") super().__init__(**kwargs)
class Right(Base): def __init__(self, **kwargs: object) -> None: print("Right.__init__") super().__init__(**kwargs)
class Diamond(Left, Right): def __init__(self, **kwargs: object) -> None: print("Diamond.__init__") super().__init__(**kwargs)flowchart TD Diamond --> Left Diamond --> Right Left --> Base Right --> Base Base --> object
Diamond.__mro__ is [Diamond, Left, Right, Base, object]. Each __init__ calls super().__init__(**kwargs) — cooperative super, meaning it forwards along the MRO chain rather than jumping straight to its own parent. Diamond(value=1) prints Diamond.__init__, then Left.__init__, then Right.__init__, then Base.__init__ {'value': 1} — super() in Left calls Right.__init__, not Base.__init__, because it follows the MRO, not the class’s own bases. Forgetting **kwargs (or dropping super().__init__() entirely) in any link breaks the chain — a common AI-generated-code bug.
isinstance vs type(x) ==
Section titled “isinstance vs type(x) ==”Both ask “is this the right type”, but they disagree on subclasses:
class Animal: passclass Dog2(Animal): pass
d = Dog2()isinstance(d, Animal) # True -- Dog2 IS-A Animaltype(d) == Animal # False -- type(d) is Dog2, not Animaltype(d) == Dog2 # Trueisinstance walks the MRO, so it correctly recognizes subclasses; type(x) == demands an exact type match and silently breaks polymorphism the moment someone subclasses your class. Prefer isinstance unless you have a specific reason to reject subclasses.
Composition over inheritance
Section titled “Composition over inheritance”Inheritance says “is-a”; composition says “has-a” — a class holds another object as an attribute and delegates to it, instead of inheriting its behavior:
class Engine: def start(self) -> str: return "vroom"
class Car: def __init__(self) -> None: self.engine = Engine() # Car HAS an Engine -- composition
def start(self) -> str: return self.engine.start()Composition avoids the fragile-base-class problem (a change to a deep parent breaking distant subclasses) and lets you swap the Engine for a different implementation without touching Car’s class hierarchy. Reach for inheritance only when the relationship is genuinely “is-a” and you want to inherit an interface along with behavior; reach for composition for everything else.
Duck typing vs ABC vs Protocol
Section titled “Duck typing vs ABC vs Protocol”Python normally doesn’t check types before calling a method — if the object has the right method, the call just works (“if it quacks like a duck…”):
class Duck: def quack(self) -> str: return "Quack!"
class Person: def quack(self) -> str: return "I'm quacking!"
def make_it_quack(thing) -> str: return thing.quack() # no shared base class requiredabc.ABC opts into nominal typing: subclasses must explicitly inherit from the abstract base, and Python refuses to instantiate a class that hasn’t implemented every @abstractmethod — Shape() on an abstract Shape raises TypeError: Can't instantiate abstract class Shape without an implementation for abstract method 'area', checked at instantiation time, at runtime.
from abc import ABC, abstractmethod
class Shape(ABC): @abstractmethod def area(self) -> float: ...typing.Protocol gives you structural typing checked statically: a class satisfies a Protocol just by having the right methods, with no inheritance and no runtime check at all (unless you add @runtime_checkable).
from typing import Protocol
class Quacker(Protocol): def quack(self) -> str: ...
def make_it_quack_typed(thing: Quacker) -> str: return thing.quack()
make_it_quack_typed(Duck()) # Duck never mentions Quacker -- structural match, mypy/pyright both accept itUse duck typing for quick scripts, ABC when you want an enforced, documented contract with shared implementation, and Protocol when you want static structural checking without forcing every implementer to inherit from your type.
Classes are objects, too
Section titled “Classes are objects, too”class doesn’t just create a type — it creates an object, an instance of type (the built-in metaclass). type(Dog2) is <class 'type'>, and so is type(type) itself — type is its own metaclass, the point where the chain stops.
type(Dog2) # <class 'type'>isinstance(Dog2, type) # TrueA related piece of syntax, name mangling: an attribute named with two leading underscores (and at most one trailing) inside a class body, like self.__secret, is rewritten by the compiler to self._ClassName__secret. It’s there to avoid accidental clashes in subclasses, not real privacy — the attribute is still reachable as obj._ClassName__secret from outside. Static type checkers hold the line further than the interpreter does: uvx mypy --strict on m._Mangled__secret reports "Mangled" has no attribute "_Mangled__secret" [attr-defined], and uvx pyright reports Cannot access attribute "_Mangled__secret" for class "Mangled" — neither exposes the mangled name as part of the class’s public surface, even though the interpreter happily returns 42.
Full runnable demo
Section titled “Full runnable demo”class Dog: tricks: list[str] = [] # BUG: one list object shared by every instance
def __init__(self, name: str) -> None: self.name = name
def add_trick(self, trick: str) -> None: self.tricks.append(trick) # mutates the SHARED class attribute
class FixedDog: def __init__(self, name: str) -> None: self.name = name self.tricks: list[str] = [] # instance attribute -- one list per dog
def add_trick(self, trick: str) -> None: self.tricks.append(trick)
rex = Dog("Rex")fido = Dog("Fido")rex.add_trick("sit")fido.add_trick("roll over")print(rex.tricks)print(rex.tricks is fido.tricks)
good_rex = FixedDog("Rex")good_fido = FixedDog("Fido")good_rex.add_trick("sit")good_fido.add_trick("roll over")print(good_rex.tricks)print(good_rex.tricks is good_fido.tricks)
class Singleton: _instance: "Singleton | None" = None
def __new__(cls) -> "Singleton": if cls._instance is None: print("creating the one instance") cls._instance = super().__new__(cls) return cls._instance
a = Singleton()b = Singleton()print(a is b)
class ImmutablePoint(tuple[float, float]): def __new__(cls, x: float, y: float) -> "ImmutablePoint": return super().__new__(cls, (x, y))
@property def x(self) -> float: return self[0]
@property def y(self) -> float: return self[1]
p = ImmutablePoint(1, 2)print(p.x, p.y, tuple(p))
class Base: def __init__(self, **kwargs: object) -> None: print("Base.__init__", kwargs) super().__init__()
class Left(Base): def __init__(self, **kwargs: object) -> None: print("Left.__init__") super().__init__(**kwargs)
class Right(Base): def __init__(self, **kwargs: object) -> None: print("Right.__init__") super().__init__(**kwargs)
class Diamond(Left, Right): def __init__(self, **kwargs: object) -> None: print("Diamond.__init__") super().__init__(**kwargs)
print([c.__name__ for c in Diamond.__mro__])Diamond(value=1)
class Animal: pass
class Dog2(Animal): pass
d = Dog2()print(isinstance(d, Animal))print(type(d) == Animal)print(type(d) == Dog2)
print(type(Dog2))print(type(type))print(isinstance(Dog2, type))
class Mangled: def __init__(self) -> None: self.__secret = 42 # rewritten by the compiler to _Mangled__secret
def reveal(self) -> int: return self.__secret
m = Mangled()print(m.reveal())print(m._Mangled__secret) # still reachable -- mangling is not real privacyLoading Python runtime (first run only)…
Mental Model
Section titled “Mental Model”flowchart LR L1["1. instance __dict__"] --> L2["2. class __dict__ (MRO order)"] L2 --> L3["3. type(instance) itself, if all else fails"] L2 -.->|"not found anywhere"| L4["AttributeError"]
An instance is a thin object whose own __dict__ holds its attributes. Look up obj.attr and Python checks the instance’s own __dict__ first; if it’s not there, it walks the class’s MRO, class by class, until it finds attr or runs out of classes and raises AttributeError. Assignment (obj.attr = x) always writes to the instance __dict__ — it never mutates the class — which is exactly why shadowing a class attribute through an instance doesn’t touch the class attribute other instances see, and why the shared-mutable-class-attribute bug only bites when you mutate the object in place (.append) instead of assigning a new one.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Class attribute | Shared state, one copy in memory | Mutating it in place leaks across every instance |
__new__ override | Full control over instance creation (singleton, immutable subclass) | Easy to get wrong; almost never needed |
Multiple inheritance + cooperative super() | Reuse from more than one base, deterministic MRO | Every class in the chain must call super().__init__(**kwargs), or the chain breaks |
isinstance | Respects subclassing / polymorphism | Slightly more permissive than an exact-type check |
ABC | Enforced contract, shared implementation, fails fast at instantiation | Forces inheritance; can’t retrofit an existing unrelated class |
Protocol | Structural, no inheritance required, works with classes you don’t own | Static-only by default; no isinstance guarantee unless @runtime_checkable |
Common Misconceptions
Section titled “Common Misconceptions”- A class attribute is just a “default value” copied into each instance. It is not copied — every instance that hasn’t shadowed it shares the same object. Mutating a mutable class attribute in place mutates it for everyone.
__init__creates the object.__new__creates it;__init__only initializes what already exists. Overriding__new__is how singletons and immutable-type subclasses work.isinstance(x, C)andtype(x) == Care interchangeable. They agree only whenxis exactly aC, never a subclass —type(x) == CisFalsefor every subclass instance.- Diamond inheritance is rare enough to ignore
super()’s cooperative behavior. Any class that might be mixed into multiple inheritance should callsuper().__init__(**kwargs)and forward unused kwargs, or it silently breaks the chain for whoever combines it with something else. - Name-mangled attributes (
__secret) are truly private. They are reachable asobj._ClassName__secretat runtime; mangling only avoids accidental name clashes in subclasses, and type checkers reject external access to the mangled name even though the interpreter allows it.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting a generated class, check for:
- A class-level attribute assigned a mutable literal —
items = [],config = {}— that a method then mutates in place (.append,.update,[key] =). That default is shared by every instance, not fresh per instance. - A subclass in a multiple-inheritance chain whose
__init__callssuper().__init__()with no arguments, or skips thesuper()call entirely, dropping kwargs the next class in the MRO needs. type(x) == SomeClassused where a subclass instance should still count — this silently rejects every subclass.- An overridden
__new__that forgets toreturnthe instance, or that callscls(...)recursively instead ofsuper().__new__(cls)— both break construction. - A class that inherits from an unrelated class purely to reuse one method — a sign composition (a
has-aattribute) was the right tool, not inheritance.
🐛 Spot the bug
class ShoppingCart: items = [] # meant as "default empty cart"
def add(self, item: str) -> None: self.items.append(item)
cart_a = ShoppingCart()cart_b = ShoppingCart()cart_a.add("book")print(cart_b.items)Show the answer
items = [] is a class attribute, not a per-instance default — there is exactly one list object, and every ShoppingCart that hasn’t shadowed items shares it. cart_a.add("book") mutates that shared list in place, so cart_b.items prints ['book'] even though nothing was ever added to cart_b. Verified: cart_a.items is cart_b.items is True. Fix it by creating the list in __init__: self.items: list[str] = [], so each cart gets its own list.