Last 30 Days
No notifications
OOP organizes code around objects — instances of classes that bundle data (attributes) and behavior (methods) together.
class Dog:
species = "Canis familiaris" # Class attribute def __init__(self, name, age):
self.name = name # Instance attribute
self.age = age
def bark(self):
return f"{self.name} says Woof!"
| Type | Defined | Shared? | Access |
| Class | In class body | Yes, all instances | ClassName.attr or self.attr |
| Instance | In __init__ via self | No, per object | self.attr |
class Animal:
def __init__(self, name):
self.name = name def speak(self):
raise NotImplementedError
class Cat(Animal):
def speak(self):
return f"{self.name} says Meow!"
| Pillar | Description |
| Encapsulation | Bundle data + methods; hide internals with _ or __ prefix |
| Inheritance | Child class inherits from parent; extend or override behavior |
| Polymorphism | Same interface, different implementations (speak() varies by animal) |
| Abstraction | Hide complexity; expose simple interface |
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y def __repr__(self):
return f"Vector({self.x}, {self.y})"
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
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 non-negative")
self._radius = value
> Tip: Favor composition over inheritance when the relationship is "has-a" rather than "is-a". Use inheritance only when there's a genuine parent-child relationship.
Object-Oriented Programming bundles data (attributes) and behaviour (methods) into reusable units called *classes*. In Python, OOP is opt-in — you can write whole programs in functions — but for anything with state, lifecycle, or polymorphic behaviour (HTTP servers, ML models, game entities, parsers), classes shine.
class User:
def __init__(self, name, age):
self.name = name
self.age = age def greet(self):
return f"hi, I'm {self.name}"
u = User("Alice", 30)
print(u.greet()) # hi, I'm Alice
A class is a blueprint. An instance (u) is a specific object built from it. __init__ runs at construction; self refers to the instance being acted on.
class Admin(User):).class Counter:
total = 0 # class-level (shared)
def __init__(self):
self.value = 0 # instance-level (per object)
def step(self):
self.value += 1
Counter.total += 1Instance attrs live on each object. Class attrs are shared — great for constants and counters, but watch out: assigning to a *mutable* class attribute via self can cause subtle sharing bugs.
class Pizza:
def __init__(self, toppings):
self.toppings = toppings def describe(self): # instance method
return f"pizza with {self.toppings}"
@classmethod
def margherita(cls): # alternative constructor
return cls(["tomato", "mozzarella"])
@staticmethod
def is_vegetarian(toppings): # utility, no self/cls
return "pepperoni" not in toppings
1. Forgetting self. Every instance method's first parameter must be self.
2. Calling Class.method(arg) and confusing yourself. Use instance.method(arg) — self is supplied automatically.
3. Mutable class attributes. class C: items = [] is shared by all instances. Initialise mutables in __init__ instead.
4. is to compare instances. Use == (and define __eq__ if needed).
5. Forgetting to call super().__init__(...) in a subclass that needs the parent's initialisation to run.
6. Treating Python's _/__ as real privacy. It's convention. Don't fight name-mangling — just use it consistently.
super()class Animal:
def __init__(self, name):
self.name = name
def speak(self):
return "..."class Dog(Animal):
def speak(self):
return "woof"
class Puppy(Dog):
def speak(self):
parent = super().speak()
return f"{parent} (tiny)"
super() calls the next class in the MRO (Method Resolution Order). Always prefer super() over hard-coding a parent class name — it survives refactors.
class Money:
def __init__(self, amount, currency="USD"):
self.amount, self.currency = amount, currency
def __repr__(self): return f"Money({self.amount}, {self.currency!r})"
def __str__(self): return f"{self.amount} {self.currency}"
def __eq__(self, o): return self.amount == o.amount and self.currency == o.currency
def __lt__(self, o): return self.amount < o.amount
def __add__(self, o): return Money(self.amount + o.amount, self.currency)
def __hash__(self): return hash((self.amount, self.currency))Dunders let your class plug into Python's built-in operators and protocols (+, ==, <, len(), in, for ... in, with, f"", etc.).
Most-used: __init__, __repr__, __str__, __eq__, __hash__, __len__, __iter__, __contains__, __enter__/__exit__.
@property for Computed / Validated Attributesclass Circle:
def __init__(self, r):
self._r = r
@property
def radius(self):
return self._r
@radius.setter
def radius(self, v):
if v < 0: raise ValueError("negative")
self._r = v
@property
def area(self):
return 3.14159 * self._r ** 2c = Circle(3)
c.radius = 5 # validated
print(c.area) # like an attribute, computed on read
Get the cleanliness of attributes with the safety of methods.
from dataclasses import dataclass, field@dataclass
class Point:
x: float
y: float
tags: list[str] = field(default_factory=list)
p = Point(3, 4)
print(p) # Point(x=3, y=4, tags=[])
@dataclass auto-generates __init__, __repr__, __eq__ (and optionally ordering, hashing, immutability with frozen=True). Default for any "value object" you'd otherwise hand-write.
Python resolves multi-parent calls via C3 linearisation. Inspect with Cls.mro() or Cls.__mro__.
class A: def m(self): print("A")
class B(A): def m(self): super().m(); print("B")
class C(A): def m(self): super().m(); print("C")
class D(B, C): def m(self): super().m(); print("D")
D().m() # A C B DMultiple inheritance is powerful but easy to misuse — prefer mixins (small, focused, well-named classes) and composition over deep hierarchies.
from abc import ABC, abstractmethodclass Shape(ABC):
@abstractmethod
def area(self) -> float: ...
class Circle(Shape):
def __init__(self, r): self.r = r
def area(self): return 3.14159 * self.r ** 2
ABCs document a contract and refuse instantiation when methods are missing. Many standard collection types (Iterable, Sized, Mapping) are ABCs you can register against.
__slots__, Composition, & Performance__slots__ = ('x', 'y') — disables the per-instance __dict__, saving memory for large numbers of small objects.self.logger = ...) instead of inheriting from them. Easier to test, easier to swap.@dataclass(frozen=True)) give you immutable, hashable value objects — great as dict keys.NamedTuple or @dataclass, not a full class with custom methods.1. Write a BankAccount class with deposit, withdraw, __repr__, and a @property balance that can't go negative.
2. Convert it to a @dataclass and compare line counts.
3. Add an Animal → Dog/Cat hierarchy with a polymorphic speak(); iterate a list of them and call speak() on each.
4. Implement a context-manager class with __enter__ / __exit__ that times the block (compare with @contextmanager from contextlib).