Okay real talk.
I used to see OOP code - with all its classes, self, super(), __dunder__ stuff - and immediately feel my brain shut down.
Today I finally sat down, refused to move until I understood it, and... it actually clicked.
So here's my attempt to explain everything I learned in the way I wish someone had explained it to me - with analogies, stories, and zero corporate jargon.
Grab a chai This is a long one. But I promise it's worth it.
What we're covering:
- Encapsulation
- Inheritance
- Abstraction
- Polymorphism
- Descriptors (bonus brain-bender)
First, the 30 second version of "What even IS OOP?"
Imagine you're building a game. You have a Hero, a Dragon, a Sword.
You could write everything as a big list of instructions:
hero_name = "Arjun"
hero_health = 100
hero_attack = 25
dragon_health = 500
# ... 300 more variables later π΅
Or - you could bundle related data and behavior into objects:
hero = Hero(name="Arjun", health=100)
dragon = Dragon(health=500)
hero.attack(dragon) # clean. readable. beautiful.
That's OOP. You model the world as things (objects) that have data (attributes) and can do stuff (methods).
Python has 4 pillars of OOP. Think of them as the 4 superpowers. Let's unlock them one by one.
Pillar 1: Encapsulation - "What happens in the class, stays in the class"
The analogy: Think of your phone. You tap icons and apps open. You don't need to know about the CPU, RAM allocation, or kernel calls happening underneath. The phone encapsulates all that complexity.
Encapsulation = bundling data + methods together, and controlling who can access what.
The Underscore Rules π
Python doesn't have "hard" access control like Java. Instead, it uses a naming convention - think of it as a gentleman's agreement between developers:
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # π’ PUBLIC - anyone can see/change this
self._balance = balance # π‘ PROTECTED - "hey, please don't touch this from outside"
self.__pin = 1234 # π΄ PRIVATE - Python actually hides this one
Let's test what happens:
account = BankAccount("Arjun", 5000)
print(account.owner) # "Arjun"
print(account._balance) # Works... but you're breaking the rules
print(account.__pin) # AttributeError! Where did it go??
# Python secretly renamed it π
print(account._BankAccount__pin) # 1234 - name mangling revealed!
** Wait, why does Python bother hiding
__pinif you can still access it with a longer name?**
Because it prevents accidental access and name collisions in subclasses - not to stop a determined hacker. Python trusts you to be an adult.
@property - The Pythonic Getter/Setter
In Java, you write getBalance() and setBalance(). In Python, we do it better:
class BankAccount:
def __init__(self, balance):
self._balance = balance
@property
def balance(self): # called when you READ acc.balance
return self._balance
@balance.setter
def balance(self, amount): # called when you WRITE acc.balance = x
if amount < 0:
raise ValueError("Bro you can't have negative money π")
self._balance = amount
acc = BankAccount(1000)
print(acc.balance) # 1000 looks like attribute access, runs the getter
acc.balance = 2000 # looks like assignment, runs the setter
acc.balance = -500 # π₯ ValueError
It looks like you're accessing an attribute. You're secretly calling a method. That's elegant.
@classmethod vs @staticmethod - What's the difference?
This confuses everyone. Here's the mental model:
| Knows about... | Use when... | |
|---|---|---|
| Regular method |
self (instance) |
Working with instance data |
| @classmethod |
cls (the class itself) |
Working with class-level data |
| @staticmethod | nothing | Utility function that belongs here logically |
class Employee:
company = "TechCorp" # class variable - shared by ALL instances
def __init__(self, name):
self.name = name # instance variable - unique per object
@classmethod
def change_company(cls, new_name):
cls.company = new_name # changes it for ALL employees!
@staticmethod
def is_valid_name(name):
return isinstance(name, str) and len(name) > 0 # no self, no cls
Employee.change_company("NewCorp")
print(Employee.company) # "NewCorp" - affects everyone
print(Employee.is_valid_name("Riya")) # True
slots - For when you need to go fast β‘
By default, every object stores its attributes in a hidden dictionary (__dict__). That's flexible but uses memory.
__slots__ says: "I know exactly what attributes this object will have. Lock it down."
class Point:
__slots__ = ['x', 'y'] # pre-declare allowed attributes
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
p.z = 5 # AttributeError - you said only x and y!
When to use it: when you're creating millions of small objects and memory is tight. Think game particles, simulation nodes, etc.
Pillar 2: Inheritance - "Why repeat yourself when you can just... inherit?"
The analogy: You inherited your dad's nose and your mom's stubbornness. You didn't write that code yourself - it came from your parents. But you also have your own traits that neither of them have.
Inheritance = a child class gets all the stuff from a parent class, plus adds its own.
The 5 Types (Yes, Five!)
# Single - the classic
class Animal:
def breathe(self):
return "inhale... exhale..."
class Dog(Animal):
def speak(self):
return "Woof!"
# Multilevel - grandparent β parent β child
class Puppy(Dog):
def play(self):
return "chasing tail..."
# Hierarchical - one parent, many children
class Cat(Animal):
def speak(self):
return "Meow!"
# Multiple - inheriting from TWO parents
class Pet:
def cuddle(self):
return "purrrr"
class DomesticCat(Cat, Pet): # gets methods from BOTH
pass
# Hybrid - combination of above (gets complex fast!)
π The Diamond Problem - Python's Trickiest Puzzle
What happens when D inherits from B and C, and B and C both inherit from A?
A
/ \
B C
\ /
D
class A:
def greet(self):
return "Hello from A"
class B(A):
def greet(self):
return "Hello from B"
class C(A):
def greet(self):
return "Hello from C"
class D(B, C):
pass
d = D()
print(d.greet()) # "Hello from B" - but WHY? π€―
Python uses MRO (Method Resolution Order) to figure this out. Think of it as Python asking: "What's the search order when I look for a method?"
print(D.__mro__)
# D β B β C β A β object
Python walks left to right through the inheritance chain. It finds greet in B first, so that's what runs. The rule is called C3 linearization - it's a whole algorithm but the key insight is: Python always respects the order you wrote the parents.
Pro tip: Always use
super()instead of callingParentClass.method(self)directly.super()respects MRO. Hardcoding the parent name doesn't.
Pillar 3: Abstraction - "Tell me WHAT to do, not HOW"
The analogy: You plug a phone charger into any socket. You don't need to know the voltage regulation, wire resistance, or circuit breaker internals. The socket gives you an interface (two holes, specific voltage). How it delivers that is hidden.
Abstraction = define what a class must do, leave the how up to subclasses.
Python gives us the abc module (Abstract Base Classes) for this:
from abc import ABC, abstractmethod
class Shape(ABC): # ABC = "I am an abstract class"
@abstractmethod
def area(self): # Every Shape MUST implement this
pass
@abstractmethod
def perimeter(self): # And this
pass
def describe(self): # This one is optional - already implemented
return f"I am a shape with area {self.area():.2f}"
Now let's make some shapes:
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def perimeter(self):
return 2 * 3.14159 * self.radius
class Rectangle(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
def perimeter(self):
return 2 * (self.w + self.h)
# shape = Shape() TypeError - you can't instantiate an abstract class!
c = Circle(5)
r = Rectangle(4, 6)
print(c.describe()) # "I am a shape with area 78.54"
print(r.describe()) # "I am a shape with area 24.00"
Why is this useful in real projects?
Imagine you're building a payment system. You define an abstractPaymentGatewayclass withcharge()andrefund()methods. Now every developer adding a new gateway (Stripe, Razorpay, PayPal) is forced to implement both. No more "oops I forgot the refund method" bugs.
Pillar 4: Polymorphism - "Same name, totally different behavior"
The analogy: The word "run" means different things to a human (legs), a computer (execute), a river (flow), and your nose (drip when sick π€§). Same word, different behavior depending on context.
Polymorphism = the same method name works differently on different objects.
Method Overriding
class Animal:
def speak(self):
return "..."
class Dog(Animal):
def speak(self):
return "Woof! πΆ"
class Cat(Animal):
def speak(self):
return "Meow! π±"
class Duck(Animal):
def speak(self):
return "Quack! π¦"
# The MAGIC - you can treat them all the same!
zoo = [Dog(), Cat(), Duck(), Animal()]
for animal in zoo:
print(animal.speak())
# Output:
# Woof! πΆ
# Meow! π±
# Quack! π¦
# ...
You don't need to know what kind of animal you have. You just call .speak() and Python figures it out. This is polymorphism in action.
Operator Overloading - Making Python's + do YOUR bidding
Did you know + doesn't always mean "add numbers"? "hello" + " world" is string concat. [1,2] + [3,4] is list merge. Python lets YOU define what + means for your own classes using dunder methods (double underscore = "dunder"):
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other): # what + does
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar): # what * does
return Vector(self.x * scalar, self.y * scalar)
def __eq__(self, other): # what == does
return self.x == other.x and self.y == other.y
def __repr__(self): # what print() shows
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2) # Vector(4, 6) - our __add__ ran!
print(v1 * 3) # Vector(3, 6) - our __mul__ ran!
print(v1 == v1) # True - our __eq__ ran!
Here are the most useful dunder methods to know:
| You write... | Dunder that runs |
|---|---|
a + b |
__add__ |
a - b |
__sub__ |
a * b |
__mul__ |
a == b |
__eq__ |
a < b |
__lt__ |
len(a) |
__len__ |
print(a) |
__str__ |
repr(a) |
__repr__ |
a[0] |
__getitem__ |
Bonus: Descriptors - "The magic behind the magic"
Okay, here's where it gets genuinely mind-bending.
Every time you use @property, you're using a descriptor. And every time Python accesses obj.attribute, there's a whole lookup protocol happening under the hood.
A descriptor is any class that implements __get__, __set__, or __delete__.
class Validator:
"""A reusable descriptor that validates integer attributes"""
def __set_name__(self, owner, name):
self.name = name # Python tells us our attribute name automatically
def __get__(self, obj, objtype=None):
if obj is None: # accessed on the class itself, not an instance
return self
return obj.__dict__.get(self.name)
def __set__(self, obj, value):
if not isinstance(value, int) or value < 0:
raise ValueError(f"'{self.name}' must be a non-negative integer. Got: {value}")
obj.__dict__[self.name] = value
class Product:
price = Validator() # β descriptor instance as a CLASS attribute
quantity = Validator()
def __init__(self, price, quantity):
self.price = price
self.quantity = quantity
p = Product(100, 10)
print(p.price) # 100
p.quantity = 5 # fine
p.price = -50 # ValueError: 'price' must be a non-negative integer. Got: -50
Why is this cool? One
Validatorclass works for BOTHpriceandquantity. You write the validation logic once.@propertywould need two separate property definitions.Mind-blowing fact:
@property,@classmethod,@staticmethod- they're all descriptors themselves. Learning descriptors is learning how Python itself works at a fundamental level.
Quick Knowledge Check - Can you answer these?
Before you scroll away, try these in your head (or your REPL):
Q1: What's the difference between _name and __name?
Q2: Why would you use @classmethod over a regular method?
Q3: What does MRO stand for, and why does it exist?
Q4: What happens if a subclass doesn't implement an @abstractmethod?
Q5: What dunder method would you override to make len(your_object) work?
(Answers are in the code examples above - go find them! π΅οΈ)
πΊοΈ The Full Picture
Here's everything we covered, in one table:
| Pillar | Core Idea | Key Python Tools |
|---|---|---|
| Encapsulation | Bundle data + control access |
_x, __x, @property, __slots__
|
| Inheritance | Reuse + extend parent code |
class Child(Parent), super(), MRO |
| Abstraction | Enforce interface, hide details |
ABC, @abstractmethod
|
| Polymorphism | Same name, different behavior | Method overriding, dunder methods |
| Descriptors | Customize attribute access itself |
__get__, __set__, __delete__
|
π¬ Let's Talk!
If you made it this far - you're a legend. Genuinely.
Drop a comment and tell me:
- Which concept clicked the most for you?
- Which one still feels fuzzy?
- What should I write about next?
I'm documenting my learning journey here in public - every day I learn something new. Follow along if you want to learn together. π

Top comments (0)