OOP II

Classes, inheritance, dunder methods, and the patterns that matter in real code

Why

OOP questions appear at every level — from "what's the difference between @classmethod and @staticmethod" to system design rounds testing composition vs inheritance. Dunder methods show whether you understand Python's object protocol: len(x) calls x.__len__(), a + b calls a.__add__(b), for item in x calls x.__iter__(). Writing Pythonic classes means implementing the right dunders.

Intuition

Python's object model is protocol-based — not hierarchy-based. To make a class iterable, implement __iter__. To make it work with len(), implement __len__. Python checks for these methods; it doesn't care what you inherit from. Prefer composition ("has-a") over deep inheritance ("is-a") — it's easier to test and easier to change.

Explanation
1. Class Basics

A class is a blueprint for objects. Instance variables are unique per object; class variables are shared across all instances. A constructor is a special method automatically called when a new object of a class is created — in Python, that's __init__.

class Animal: # class variable — shared across all instances kingdom = "Animalia" def __init__(self, name, sound): # instance variables — unique per instance self.name = name self.sound = sound def speak(self): return f"{self.name} says {self.sound}" def __repr__(self): return f"Animal({self.name!r})" dog = Animal("Rex", "woof") dog.speak() # "Rex says woof" Animal.kingdom # "Animalia" — via class dog.kingdom # "Animalia" — via instance (falls back to class)

self is just the convention for the first argument — Python passes the instance automatically. Class variables are shared; mutating one on an instance creates an instance variable that shadows it.

2. Inheritance & super()

Inheritance lets a subclass reuse and extend a parent class. super() calls the parent's version of a method — always use it in __init__ so parent state is properly set up before the child adds its own.

class Dog(Animal): def __init__(self, name, breed): super().__init__(name, "woof") # call parent __init__ self.breed = breed def speak(self): # override parent method return f"{super().speak()}!" # extend, don't duplicate def fetch(self): return f"{self.name} fetches!" d = Dog("Rex", "Labrador") d.speak() # "Rex says woof!" isinstance(d, Dog) # True isinstance(d, Animal) # True — inherits upward issubclass(Dog, Animal) # True

Always call super().__init__() in subclass constructors — skipping it leaves parent state uninitialised. Use isinstance over type(x) == Y; it respects inheritance.

3. Dunder Methods

Dunder (double-underscore) methods hook into Python's built-in operations. Implementing them makes your class a first-class citizen — supporting +, len(), ==, iteration, and truthiness checks just like native types.

Without them, your class is a black box Python can't work with:

class Cart: def __init__(self, items): self.items = items c = Cart([1, 2, 3]) print(c) # <__main__.Cart object at 0x10f3b2d10> — useless len(c) # TypeError: object of type 'Cart' has no len() c[0] # TypeError: 'Cart' object is not subscriptable c1 + c2 # TypeError: unsupported operand type 1 in c # TypeError: argument of type 'Cart' is not iterable

With dunders implemented:

print(c) # "Cart with 3 items" len(c) # 3 c[0] # 1 c1 + c2 # Cart([1, 2, 3, 4, 5]) 1 in c # True
class Vector: def __init__(self, x, y): self.x, self.y = x, y def __repr__(self): # for devs — shown in REPL, logs return f"Vector({self.x}, {self.y})" def __str__(self): # for users — print() uses this return f"({self.x}, {self.y})" def __add__(self, other): # v1 + v2 return Vector(self.x + other.x, self.y + other.y) def __eq__(self, other): # v1 == v2 return (self.x, self.y) == (other.x, other.y) def __hash__(self): # needed if __eq__ is defined return hash((self.x, self.y)) def __len__(self): # len(v) return 2 def __bool__(self): # bool(v), truthiness check return self.x != 0 or self.y != 0 def __iter__(self): # for val in v yield self.x yield self.y def __call__(self, scale): # v(2) — callable instance return Vector(self.x * scale, self.y * scale)

If you define __eq__ without __hash__, Python sets __hash__ = None and instances become unhashable (can't be used in sets or as dict keys). Always define both together.

4. @property, @classmethod, @staticmethod

Three decorators that control how a method is accessed and what it receives as its first argument — each serves a distinct purpose that comes up constantly in production Python.

class Circle: def __init__(self, radius): self._radius = radius @property def radius(self): # c.radius — no () return self._radius @radius.setter def radius(self, v): # c.radius = 5 if v < 0: raise ValueError self._radius = v @property def area(self): # computed, read-only return 3.14159 * self._radius ** 2 @classmethod def from_diameter(cls, d): # alternative constructor, gets class not instance return cls(d / 2) @staticmethod def is_valid_radius(r): # utility — no access to instance or class return r >= 0 c = Circle.from_diameter(10) # cls method — no instance needed Circle.is_valid_radius(-1) # False

@property exposes a method as an attribute — accessed without parentheses, with an optional setter for validation. @classmethod receives the class (cls) as its first argument — the standard pattern for alternative constructors. @staticmethod is a plain utility function scoped inside the class with no access to the instance or class.

5. dataclass

@dataclass auto-generates __init__, __repr__, and __eq__ from annotated fields — eliminating boilerplate for classes that mainly hold data.

from dataclasses import dataclass, field @dataclass class Point: x: float y: float label: str = "origin" # default value # auto-generates __init__, __repr__, __eq__ p = Point(1.0, 2.0) p # Point(x=1.0, y=2.0, label='origin') Point(1.0, 2.0) == Point(1.0, 2.0) # True @dataclass(frozen=True) # immutable — also generates __hash__ class Config: host: str port: int = 8080 @dataclass class Pipeline: steps: list = field(default_factory=list) # mutable default — use field()

Use field(default_factory=list) for mutable defaults — same rule as regular functions. frozen=True makes instances hashable and safe as dict keys.

6. Composition vs Inheritance
Inheritance — "is-a"
Use when a subclass truly specialises the parent. Avoid deep chains — tight coupling. Python supports multiple inheritance via MRO (C3) but complexity grows fast.
Composition — "has-a"
Give a class a reference to another object. More flexible — swap implementations without changing the class. Easier to test. Prefer this by default.