When you first start coding in Python, you write functions and variables. That works great for small scripts. But as your programs get bigger, functions and variables alone become hard to organise. You end up with hundreds of loose pieces that do not clearly belong together.
Object Oriented Programming (OOP) is a way of organising code around things rather than just actions. Instead of having separate variables for a user's name, age and email, you group them into one User object. The object holds both the data and the actions that can be performed on that data.
This guide explains every OOP concept from scratch using clear analogies and working Python code. No confusing jargon — just simple explanations you can follow right away.
What Is Object Oriented Programming
Think about a car. A car has properties: colour, brand, number of seats, speed. A car also has actions it can perform: start, stop, accelerate, honk.
In OOP, a class is the blueprint for a car. It describes what properties and actions every car will have. An object (also called an instance) is an actual car built from that blueprint. You can create many different car objects from the same blueprint — a red one, a blue one, a fast one — and each is independent.
The four main ideas in OOP are:
- Encapsulation — keep data and the code that works with it bundled together, and control what is accessible from outside
- Inheritance — a child class can get all the properties and methods of a parent class and then add or change things
- Polymorphism — different classes can have methods with the same name that each behave differently
- Abstraction — hide the complex inner workings and only show a simple interface to the outside world
Classes and Objects
Your First Class
You define a class using the class keyword followed by the class name. Class names use CamelCase by convention — each word starts with a capital letter.
# Define the class — this is the blueprint
class Dog:
# A class attribute — shared by ALL dogs
species = "Canis lupus familiaris"
# The constructor — runs automatically when you create a new Dog
def __init__(self, name, breed, age):
# Instance attributes — unique to EACH dog
self.name = name
self.breed = breed
self.age = age
def bark(self):
print(f"{self.name} says: Woof!")
# Create objects from the class
dog1 = Dog("Bruno", "Labrador", 3)
dog2 = Dog("Milo", "Poodle", 5)
# Access attributes
print(dog1.name) # Bruno
print(dog2.breed) # Poodle
print(dog1.species) # Canis lupus familiaris (class attribute)
# Call methods
dog1.bark() # Bruno says: Woof!
dog2.bark() # Milo says: Woof!
The Constructor — __init__
The __init__ method is the constructor. It runs automatically every time you create a new object. You use it to set up the initial state of the object — the starting values for all its attributes.
Think of it like filling in a form when you register for something. The moment you sign up, certain fields get filled in right away: your name, email, account creation date. The constructor does the same thing for your objects.
What Is self
self is a reference to the specific object being created or used at that moment. It is how an object refers to its own attributes and methods.
When you call dog1.bark(), Python automatically passes dog1 as the self argument. So inside the bark method, self.name gives you "Bruno" specifically — not Milo's name. Every instance gets its own copy of the data.
ℹ️
self is just a convention. You could technically name it anything, but the entire Python community uses self. Always use it — any other name will confuse other developers and code editors.
Methods — Functions That Belong to a Class
A method is a function defined inside a class. There are three kinds and each one serves a different purpose.
Instance Methods — the Most Common Type
An instance method works with a specific object's data. It always takes self as its first parameter and can read and modify the object's attributes.
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def deposit(self, amount):
if amount > 0:
self.balance += amount
print(f"Deposited ₹{amount}. New balance: ₹{self.balance}")
else:
print("Deposit amount must be positive.")
def withdraw(self, amount):
if amount > self.balance:
print("Insufficient funds.")
else:
self.balance -= amount
print(f"Withdrew ₹{amount}. New balance: ₹{self.balance}")
def get_balance(self):
return self.balance
account = BankAccount("Shashank", 5000)
account.deposit(2000) # Deposited ₹2000. New balance: ₹7000
account.withdraw(3000) # Withdrew ₹3000. New balance: ₹4000
Class Methods — Work on the Class Itself
A class method does not work with a specific object. Instead it receives the class itself as its first argument, conventionally called cls. It is used most often as an alternative constructor — a second way to create objects.
class Student:
def __init__(self, name, grade, score):
self.name = name
self.grade = grade
self.score = score
@classmethod
def from_string(cls, data_string):
# Create a student from a comma-separated string like "Shashank,10,92"
name, grade, score = data_string.split(',')
return cls(name, int(grade), int(score))
# Normal creation
s1 = Student("Shashank", 10, 92)
# Creation from a string using the class method
s2 = Student.from_string("Priya,11,88")
print(s2.name, s2.score) # Priya 88
Static Methods — No Object, No Class
A static method does not receive self or cls. It is just a regular function that lives inside a class because it logically belongs there. Use it for utility functions related to the class.
class MathHelper:
@staticmethod
def is_even(n):
return n % 2 == 0
@staticmethod
def celsius_to_fahrenheit(c):
return (c * 9 / 5) + 32
# Call without creating an object
print(MathHelper.is_even(8)) # True
print(MathHelper.celsius_to_fahrenheit(100)) # 212.0
Encapsulation — Controlling What Is Accessible
Encapsulation means bundling data and methods together and controlling which parts are accessible from outside the class. The idea is that the internal workings of a class should be hidden. Outside code should only interact through the methods you have deliberately exposed.
Think of a vending machine. You can press a button and get a snack. But you cannot reach inside and take whatever you want or change the pricing directly. The machine controls what you can and cannot do. That is encapsulation.
Private Attributes
In Python, prefixing an attribute name with a double underscore __ makes it "private" — it signals that this should not be accessed directly from outside the class.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # public — anyone can read this
self.__balance = balance # private — double underscore hides it
def deposit(self, amount):
if amount > 0:
self.__balance += amount
def get_balance(self):
return self.__balance
acc = BankAccount("Shashank", 5000)
print(acc.owner) # Shashank — works fine
print(acc.get_balance()) # 5000 — access through method
# acc.__balance would raise AttributeError — cannot access directly
# This forces code to use deposit() and withdraw() which have validation
Properties — Getters and Setters the Python Way
Python's @property decorator lets you access a method like an attribute. You get the clean syntax of attribute access with the control of a method underneath.
class Temperature:
def __init__(self, celsius):
self.__celsius = celsius
@property
def celsius(self):
return self.__celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("Temperature below absolute zero is impossible.")
self.__celsius = value
@property
def fahrenheit(self):
return (self.__celsius * 9 / 5) + 32
t = Temperature(100)
print(t.celsius) # 100 — looks like attribute, works through getter
print(t.fahrenheit) # 212.0 — computed on the fly
t.celsius = 25 # uses the setter with validation
Inheritance — Building on What Already Exists
Inheritance lets you create a new class that automatically gets everything from an existing class, and then you can add new things or change existing ones. The original class is called the parent (or base class). The new class is the child (or subclass).
Think of it like how a child inherits traits from a parent. A child naturally has some of the same traits (brown eyes, curly hair) but might also develop new ones or express existing traits differently.
Basic Inheritance
# Parent class
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
def breathe(self):
print(f"{self.name} is breathing.")
def speak(self):
print("...")
# Child class — put the parent name in parentheses
class Dog(Animal):
def __init__(self, name, age, breed):
super().__init__(name, age) # call the parent constructor
self.breed = breed # add a new attribute
# Override the parent's speak method
def speak(self):
print(f"{self.name} says: Woof!")
# Add a new method that Animal does not have
def fetch(self):
print(f"{self.name} fetches the ball!")
class Cat(Animal):
def speak(self):
print(f"{self.name} says: Meow!")
dog = Dog("Bruno", 3, "Labrador")
cat = Cat("Whiskers", 4)
dog.breathe() # inherited from Animal: Bruno is breathing.
dog.speak() # overridden: Bruno says: Woof!
dog.fetch() # only on Dog: Bruno fetches the ball!
cat.speak() # Whiskers says: Meow!
# Check relationships
print(isinstance(dog, Dog)) # True
print(isinstance(dog, Animal)) # True — Dog IS an Animal
print(issubclass(Dog, Animal)) # True
The super Function
super() gives you access to the parent class. It is most often used inside the child's __init__ to call the parent's constructor first, so you set up everything the parent would set up, and then add your own extra attributes on top.
✅
Always call super().__init__(). If your child class has its own constructor, always call the parent constructor first using super().__init__(). This ensures the parent's attributes are properly created before you add new ones.
Multi Level Inheritance
A child class can itself be the parent of another class, creating a chain. Each class in the chain inherits from all the classes above it.
class Vehicle:
def __init__(self, brand, speed):
self.brand = brand
self.speed = speed
def move(self):
print(f"{self.brand} is moving at {self.speed} km/h.")
class Car(Vehicle):
def __init__(self, brand, speed, doors):
super().__init__(brand, speed)
self.doors = doors
class ElectricCar(Car): # inherits from Car which inherits from Vehicle
def __init__(self, brand, speed, doors, battery_kw):
super().__init__(brand, speed, doors)
self.battery_kw = battery_kw
def charge(self):
print(f"{self.brand} is charging its {self.battery_kw}kW battery.")
tesla = ElectricCar("Tesla", 200, 4, 75)
tesla.move() # from Vehicle: Tesla is moving at 200 km/h.
tesla.charge() # from ElectricCar: Tesla is charging its 75kW battery.
Polymorphism — Same Name, Different Behaviour
Polymorphism means "many forms". In Python it means you can have multiple classes that each have a method with the same name, and when you call that method the right version runs automatically based on which class the object belongs to.
This lets you write code that works with many different types of objects without needing to know which specific type it is dealing with.
class Shape:
def area(self):
raise NotImplementedError("Every shape must implement area()")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
class Rectangle(Shape):
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Triangle(Shape):
def __init__(self, base, height):
self.base = base
self.height = height
def area(self):
return 0.5 * self.base * self.height
# This function works with ANY shape — it does not care which one
def print_area(shape):
print(f"Area: {shape.area():.2f}")
shapes = [Circle(5), Rectangle(4, 6), Triangle(3, 8)]
for s in shapes:
print_area(s)
# Area: 78.54
# Area: 24.00
# Area: 12.00
ℹ️
Duck typing: Python's polymorphism does not require formal inheritance. Any object that has an area() method will work with print_area(). Python just looks for the method by name. If it has it, great. If not, it throws an error. This philosophy is called "duck typing" — if it walks like a duck and quacks like a duck, treat it as a duck.
Dunder Methods — Making Objects Feel Like Built-ins
Dunder methods (short for "double underscore") are special methods with names like __str__, __len__ and __add__. They let your objects work with Python's built-in operations like printing, len() and the + operator.
class Book:
def __init__(self, title, author, pages):
self.title = title
self.author = author
self.pages = pages
# Controls what print(book) shows
def __str__(self):
return f'"{self.title}" by {self.author}'
# Controls what the developer sees in the console (e.g. repr(book))
def __repr__(self):
return f'Book("{self.title}", "{self.author}", {self.pages})'
# Controls len(book)
def __len__(self):
return self.pages
# Controls == comparison
def __eq__(self, other):
return self.title == other.title and self.author == other.author
# Controls less than, enables sorting
def __lt__(self, other):
return self.pages < other.pages
b1 = Book("Clean Code", "Robert C. Martin", 464)
b2 = Book("Python Tricks", "Dan Bader", 302)
print(b1) # "Clean Code" by Robert C. Martin
print(len(b1)) # 464
print(b1 == b2) # False
print(b2 < b1) # True — b2 has fewer pages
print(sorted([b1, b2])) # sorted by pages automatically
Dataclasses — Less Boilerplate for Simple Classes
If you just need a class to hold some data, writing __init__, __str__ and __eq__ every time gets repetitive. Python's @dataclass decorator generates these automatically from simple field declarations.
from dataclasses import dataclass, field
# Without @dataclass — you write __init__, __repr__, __eq__ manually
class PointOld:
def __init__(self, x, y):
self.x = x
self.y = y
# With @dataclass — Python generates __init__, __repr__ and __eq__ for you
@dataclass
class Point:
x: float
y: float
p1 = Point(3.0, 4.0)
print(p1) # Point(x=3.0, y=4.0) — __repr__ auto-generated
print(p1.x) # 3.0
# More complex dataclass with defaults
@dataclass
class User:
name: str
email: str
age: int = 0
active: bool = True
tags: list = field(default_factory=list)
user = User("Shashank", "shashank@example.com", 25)
print(user)
# User(name='Shashank', email='shashank@example.com', age=25, active=True, tags=[])
✅
Use dataclasses for data holder classes. If a class is mainly just storing data with no complex logic, @dataclass is the cleaner modern way to write it. You get __init__, __repr__ and __eq__ for free with far less code.
⚡ Key Takeaways
- A class is a blueprint. An object is a real thing built from that blueprint. You can create many objects from one class and each has its own independent data.
- __init__ is the constructor. It runs automatically when you create an object and sets up the initial attributes.
- self is a reference to the current object. Every instance method takes it as its first parameter so Python knows which object's data to use.
- Instance methods work with a specific object's data. Class methods work with the class itself and are used as alternative constructors. Static methods are utility functions that live in the class but do not need the object or class.
- Encapsulation means bundling data and methods together and controlling access. Use double underscores (__attr) to make attributes private, and @property for clean getters and setters.
- Inheritance lets a child class get all the attributes and methods of a parent class. Put the parent name in parentheses: class Dog(Animal).
- Always call super().__init__() in a child constructor to ensure the parent's setup runs first.
- Polymorphism lets different classes have methods with the same name that each behave differently. This lets you write one function that works with many different types of objects.
- Dunder methods like __str__, __len__ and __eq__ let your objects work naturally with Python's built-in functions and operators.
- Use @dataclass for simple classes that mainly store data. Python generates the boilerplate for you automatically.
Tags:
Python
OOP
Classes
Inheritance
Intermediate
Shashank Shekhar
Founder & Creator — Hoopsiper.com
Full stack developer and educator. Building Hoopsiper to help developers learn faster through practical, no-fluff coding guides on JavaScript, AI/ML, Python and modern web development.