Skip to content

Object-Oriented Programming (OOP)

OOP lets you bundle data and behavior into objects. Instead of free-floating variables and functions, you create your own types.

The four pillars

Concept What it means
Class A blueprint — defines what every object of this type has and does.
Object An instance of a class — actual data in memory.
Inheritance A class inherits from another — reuses its code, adds more.
Polymorphism Different classes can share method names and "do the right thing."
Encapsulation Keep internals hidden; expose a clean interface.

Your first class

class Dog:
    def __init__(self, name, breed):
        self.name = name
        self.breed = breed

    def bark(self):
        print(f"{self.name} says: Woof!")

# Create instances (objects)
dog1 = Dog("Buddy", "Golden Retriever")
dog2 = Dog("Rex", "German Shepherd")

dog1.bark()
dog2.bark()
print(dog1.name, dog2.breed)

What's going on

  • class Dog: — define the class.
  • __init__ — special method that runs when you create an object. Called the constructor.
  • self — refers to "this specific object." Every method gets it as the first parameter.
  • self.name = name — store data on the object as an attribute.
  • dog1 = Dog("Buddy", "Golden Retriever") — create an object. Python passes the args to __init__.

Attributes and methods

class Circle:
    pi = 3.14159     # class attribute (shared by all instances)

    def __init__(self, radius):
        self.radius = radius     # instance attribute

    def area(self):
        return Circle.pi * self.radius ** 2

    def circumference(self):
        return 2 * Circle.pi * self.radius

c = Circle(5)
print("Area:",          c.area())
print("Circumference:", c.circumference())
print("Pi (class):",    Circle.pi)
print("Radius:",        c.radius)

__str__ — readable string

The default print(obj) is ugly. Override __str__:

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __str__(self):
        return f"Point({self.x}, {self.y})"

p = Point(3, 4)
print(p)         # Point(3, 4)  ← nice!

Inheritance — extend an existing class

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

    def speak(self):
        print(f"{self.name} makes a sound.")

class Dog(Animal):                # Dog inherits from Animal
    def speak(self):              # override Animal.speak
        print(f"{self.name} barks.")

class Cat(Animal):
    def speak(self):
        print(f"{self.name} meows.")

animals = [Dog("Buddy"), Cat("Whiskers"), Animal("Generic")]
for a in animals:
    a.speak()

Notice polymorphism: same method name speak(), different behavior per class.

super() — call the parent's method

class Vehicle:
    def __init__(self, brand, wheels):
        self.brand = brand
        self.wheels = wheels

    def info(self):
        print(f"{self.brand} ({self.wheels} wheels)")

class Car(Vehicle):
    def __init__(self, brand, model):
        super().__init__(brand, wheels=4)    # call parent's __init__
        self.model = model

    def info(self):
        super().info()                        # call parent's info
        print(f"  Model: {self.model}")

c = Car("Toyota", "Corolla")
c.info()

Encapsulation — public, "protected", private

Python doesn't enforce true privacy, but uses naming conventions:

Name Convention Meaning
name public Use freely.
_name protected "Don't touch from outside" — just a convention.
__name private Name-mangled by Python. Hard (but not impossible) to access outside.
class BankAccount:
    def __init__(self, owner, balance):
        self.owner = owner
        self._balance = balance    # convention: "protected"

    def deposit(self, amount):
        self._balance += amount

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

    def get_balance(self):
        return self._balance

acc = BankAccount("Alice", 1000)
acc.deposit(500)
acc.withdraw(200)
print(acc.get_balance())     # 1300
# Still accessible, just labeled "don't touch":
print(acc._balance)

@property — controlled access

A "property" looks like an attribute but is actually a method. Lets you add validation without changing how callers use it.

class Person:
    def __init__(self, name, age):
        self.name = name
        self._age = age

    @property
    def age(self):
        """Read-only access."""
        return self._age

    @age.setter
    def age(self, value):
        if value < 0:
            raise ValueError("age cannot be negative")
        self._age = value

p = Person("Alice", 25)
print(p.age)         # 25 — uses the getter
p.age = 30           # uses the setter
print(p.age)         # 30
# p.age = -5         # ValueError

Class methods and static methods

class Calculator:

    @staticmethod
    def add(a, b):
        # Doesn't need self or cls. Just a function inside the class for organization.
        return a + b

    @classmethod
    def from_strings(cls, a_str, b_str):
        # `cls` is the class itself — useful for alternative constructors
        return cls(), int(a_str) + int(b_str)

print(Calculator.add(3, 4))
inst, total = Calculator.from_strings("10", "20")
print(total)

Special (dunder) methods

Methods with __name__ enable Python's syntax to work with your class. Example — add two Points with +:

class Point:
    def __init__(self, x, y):
        self.x, self.y = x, y

    def __add__(self, other):                # makes p1 + p2 work
        return Point(self.x + other.x, self.y + other.y)

    def __eq__(self, other):                 # makes p1 == p2 work
        return self.x == other.x and self.y == other.y

    def __repr__(self):                       # used in REPL / lists
        return f"Point({self.x}, {self.y})"

p1 = Point(1, 2)
p2 = Point(3, 4)
print(p1 + p2)                                # Point(4, 6)
print(p1 == Point(1, 2))                       # True
print([p1, p2])                                # uses __repr__

Common dunders:

Method Triggered by
__init__ Constructor (MyClass(...))
__str__ print(obj), str(obj)
__repr__ REPL display, debugging
__len__ len(obj)
__add__ / __sub__ / __mul__ +, -, *
__eq__ / __lt__ ==, <
__getitem__ obj[i]
__iter__ / __next__ for loop
__call__ obj()

dataclass — the modern shortcut

Auto-generates __init__, __repr__, __eq__ for you.

from dataclasses import dataclass

@dataclass
class Product:
    name: str
    price: float
    in_stock: bool = True

p = Product("Pen", 10.5)
print(p)                # Product(name='Pen', price=10.5, in_stock=True)
print(p.name)

Mini-project — bank system

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

    def deposit(self, amount):
        self.balance += amount

    def withdraw(self, amount):
        if amount > self.balance:
            return False
        self.balance -= amount
        return True

    def __str__(self):
        return f"{self.owner}'s account: ₹{self.balance}"

class SavingsAccount(Account):
    INTEREST_RATE = 0.04

    def apply_interest(self):
        self.balance += self.balance * SavingsAccount.INTEREST_RATE

# Use it
a = SavingsAccount("Alice", 10000)
a.deposit(5000)
a.apply_interest()
print(a)

Practice

What does this print?

Expected: Buddy says: Woof!

class Dog:
    def __init__(self, name):
        self.name = name
    def bark(self):
        return f"{self.name} says: Woof!"

print(Dog("Buddy").bark())

Make this print the dog's name without crashing

Expected: Rex

class Dog:
    def __init__(name):       # bug: forgot self as first param
        self.name = name

d = Dog("Rex")
print(d.name)

Quiz — Quick check

What you remember

Q1. What does __init__ do?

  • Imports the class
  • Runs when a new object is created — it's the constructor
  • Deletes the object
  • Defines the class's name

Why: __init__ runs once per object, right after it's created. You set up instance attributes (self.x = ...) here.

Q2. What does super().__init__(...) do inside a child class?

  • Calls the parent class's __init__
  • Creates a new copy of the parent class
  • Makes the method "super fast"
  • Tells Python this is the highest class

Why: super() is how you call methods from the parent class — most commonly to reuse its __init__ so you don't duplicate setup logic.

Q3. What's the difference between a class attribute and an instance attribute?

  • No difference
  • Class attributes are shared by every instance; instance attributes are unique per object
  • Class attributes are private; instance attributes are public
  • Instance attributes can't be changed

Why: class Circle: pi = 3.14pi is shared. def __init__(self, r): self.radius = rradius is per-object. Modifying Circle.pi affects all circles; modifying c.radius only affects c.

Common doubts

Why is self always the first parameter?

self refers to the specific object the method is being called on. When you write dog1.bark(), Python translates it to Dog.bark(dog1) — passing the object as the first argument. Calling it self is a convention; you could call it anything, but every Python codebase uses self.

What's the difference between __str__ and __repr__?

__str__ is for humans — print(obj) uses it. __repr__ is for developers — the REPL and repr(obj) use it, and it should ideally show a valid Python expression that recreates the object. When in doubt, define __repr__; __str__ falls back to it automatically.

Should I use @dataclass or write __init__ by hand?

Default to @dataclass for simple value-holding classes — it auto-generates __init__, __repr__, __eq__, and saves a lot of boilerplate. Write a manual class when you need complex init logic, validation, or methods that go far beyond holding data.

What's next

File Handling