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!
Make this print the dog's name without crashing
Expected: Rex
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.14—piis shared.def __init__(self, r): self.radius = r—radiusis per-object. ModifyingCircle.piaffects all circles; modifyingc.radiusonly affectsc.
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.