DEV Community

Cover image for I Spent a Full Day Learning OOP in Python, Here's Everything, Explained Like You're 15
Kishan vyas
Kishan vyas

Posted on

I Spent a Full Day Learning OOP in Python, Here's Everything, Explained Like You're 15

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:

  1. Encapsulation
  2. Inheritance
  3. Abstraction
  4. Polymorphism
  5. 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 😡
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

** Wait, why does Python bother hiding __pin if 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

πŸ’Ž 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
Enter fullscreen mode Exit fullscreen mode
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? 🀯
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 calling ParentClass.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}"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Why is this useful in real projects?
Imagine you're building a payment system. You define an abstract PaymentGateway class with charge() and refund() 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! πŸ¦†
# ...
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Why is this cool? One Validator class works for BOTH price and quantity. You write the validation logic once. @property would 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)