Object-Oriented Programming

Master classes, inheritance, polymorphism, dunder methods, properties, and @dataclass.

Intermediate 45 min read 🐍 Python

Classes and Objects

Classes are blueprints for creating objects

A class defines what data an object holds (attributes) and what it can do (methods). Objects are individual instances created from that blueprint.

Imagine you're building a dog simulator. Every dog has a name and age (data), and every dog can bark and be described (behavior). Rather than writing separate code for each dog, you create a class that defines what "a dog" looks like, and then create individual dog objects from that template.

This is the core idea of OOP: group related data and behavior together into reusable, self-contained units. It makes your code more organized, easier to understand, and much easier to maintain as your programs grow.

Your First Class

Let's create a simple Dog class. Notice the __init__ method (pronounced "dunder init") — it's called automatically when you create a new dog. The self parameter refers to the specific dog being created or acted upon:

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!"

    def description(self):
        return f"{self.name} is {self.age} years old"

rex = Dog("Rex", 5)
buddy = Dog("Buddy", 3)

print(rex.bark())
print(buddy.description())
print(f"Species: {rex.species}")
Output
Rex says Woof!
Buddy is 3 years old
Species: Canis familiaris

self is a reference to the current instance — it's how a method knows which dog it's talking about. When you call rex.bark(), Python automatically passes rex as the self parameter. You never pass it yourself; you just need to include it in the method definition.

Notice the difference between class attributes (like species) and instance attributes (like name and age). Class attributes are shared by ALL dogs — every dog is "Canis familiaris". Instance attributes are unique to each dog — Rex has his own name and age, separate from Buddy's.

A Practical Example

Let's build something more realistic — a bank account. This demonstrates how OOP encapsulates data (balance) and protects it with methods that enforce rules (can't withdraw more than you have):

class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Amount must be positive")
        self._balance += amount
        return self._balance

    def withdraw(self, amount):
        if amount > self._balance:
            raise ValueError("Insufficient funds")
        self._balance -= amount
        return self._balance

    def __str__(self):
        return f"Account({self.owner}: ${self._balance})"

account = BankAccount("Alice", 1000)
print(account.deposit(500))
print(account.withdraw(200))
print(account)
Output
1500
1300
Account(Alice: $1300)

Inheritance

Inheritance is one of the four pillars of OOP (along with encapsulation, polymorphism, and abstraction). It lets you create a new class based on an existing one — the child class automatically gets all the parent's attributes and methods, and can add new ones or override existing ones.

Think of it like biology: a Cat is an Animal. A Dog is an Animal. Both share common traits (name, ability to speak) but have their own unique behaviors (cats purr, dogs learn tricks). Rather than duplicating the shared code, we define it once in the parent and let children inherit it:

class Animal:
    def __init__(self, name, sound):
        self.name = name
        self.sound = sound

    def speak(self):
        return f"{self.name} says {self.sound}!"

class Cat(Animal):
    def __init__(self, name):
        super().__init__(name, "Meow")
    def purr(self):
        return f"{self.name} purrs..."

class Dog(Animal):
    def __init__(self, name, tricks=None):
        super().__init__(name, "Woof")
        self.tricks = tricks or []

# Polymorphism
for animal in [Cat("Whiskers"), Dog("Rex"), Cat("Luna")]:
    print(animal.speak())
Output
Whiskers says Meow!
Rex says Woof!
Luna says Meow!

⚠️ Common Mistake: Forgetting super().__init__()

Wrong:

class Child(Parent):
    def __init__(self, name, extra):
        self.extra = extra
        # Forgot super().__init__(name)!

Why: Parent's __init__ never runs, so parent attributes are missing.

Instead:

class Child(Parent):
    def __init__(self, name, extra):
        super().__init__(name)
        self.extra = extra

Dunder Methods (Magic Methods)

Dunder methods (short for "double underscore") are special methods that let your objects integrate with Python's built-in operations. When you write len(my_list), Python actually calls my_list.__len__() behind the scenes. When you write a + b, Python calls a.__add__(b).

By defining these methods in your class, you can make your objects work with +, ==, len(), print(), for loops, and many more. This is what makes Python objects feel so natural — your custom classes can behave just like built-in types:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = 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)
    def __eq__(self, other):
        return self.x == other.x and self.y == other.y
    def __len__(self):
        return int((self.x**2 + self.y**2) ** 0.5)

v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1 + v2)
print(len(v1))
print(v1 == Vector(3, 4))
Output
Vector(4, 6)
5
True
DunderEnablesExample
__init__Constructorobj = MyClass()
__str__print()print(obj)
__repr__repr()repr(obj)
__add__+ operatora + b
__eq__== comparisona == b
__len__len()len(obj)
__getitem__[] indexingobj[key]
__iter__for loopsfor x in obj

@property

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 positive")
        self._radius = value

    @property
    def area(self):
        import math
        return math.pi * self._radius ** 2

c = Circle(5)
print(f"Area: {c.area:.2f}")
c.radius = 10
print(f"New area: {c.area:.2f}")
Output
Area: 78.54
New area: 314.16
Key Takeaway: Use @property for validation or computed values while keeping clean obj.attribute syntax.

@dataclass

from dataclasses import dataclass, field

@dataclass
class User:
    name: str
    email: str
    age: int
    active: bool = True
    tags: list = field(default_factory=list)

user1 = User("Alice", "[email protected]", 30)
user2 = User("Alice", "[email protected]", 30)
print(user1)
print(user1 == user2)  # Auto __eq__

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(3, 4)
print(p)
Output
User(name='Alice', email='[email protected]', age=30, active=True, tags=[])
True
Point(x=3, y=4)
🔍 Deep Dive: @dataclass vs Regular Class

Use @dataclass when your class is primarily a data container. Use regular classes for complex behavior or custom __init__ logic. Also consider NamedTuple for immutable alternatives.

@classmethod and @staticmethod

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    def __str__(self):
        return f"{self.year}-{self.month:02d}-{self.day:02d}"

    @classmethod
    def from_string(cls, date_str):
        year, month, day = map(int, date_str.split("-"))
        return cls(year, month, day)

    @staticmethod
    def is_valid(year, month, day):
        return 1 <= month <= 12 and 1 <= day <= 31

d = Date.from_string("2024-12-25")
print(d)
print(Date.is_valid(2024, 13, 1))
Output
2024-12-25
False
DecoratorFirst ArgUse For
(none)selfMethods that access instance data
@classmethodclsAlternative constructors
@staticmethod(none)Utility functions