Understanding Object-Oriented Programming (OOP) in Python

Object-Oriented Programming (OOP) is a powerful way to structure code, and Python’s clean syntax makes it a great language to learn these concepts. In this blog, we’ll explore Python OOP using simple examples just like you'd build up your understanding in a classroom or hands-on notebook.
What is Object-Oriented Programming (OOP) ?
Object-Oriented Programming (OOP) is a programming paradigm that relies on the concept of classes and objects. It is used to structure a software program into simple, reusable pieces of code blueprints (usually called classes), which are used to create individual instances of objects.
There are many object-oriented programming languages, including JavaScript, C++, Java, and Python. OOP languages are not necessarily restricted to the object-oriented programming paradigm. Some languages such as JavaScript, Python, and PHP, all allow for both procedural and object-oriented programming styles.
Core Components of OOP
Class
A class is a blueprint for creating objects. It defines attributes (data) and methods (behavior) that the created objects will have.
class Car:
def __init__(self, brand, color):
self.brand = brand # Attribute
self.color = color # Attribute
def start_engine(self): # Method
print(f"{self.brand} engine started.")
Object
An object is an instance of a class. It represents a specific implementation of the class with actual values.
car1 = Car("Toyota", "Red") # car1 is an object
car2 = Car("BMW", "Black") # car2 is another object
Attribute
An attribute is a variable that holds data associated with an object. It defines the object's state.
print(car1.brand) # Output: Toyota
print(car2.color) # Output: Black
Here, brand and color are attributes of the objects.
Method
A method is a function defined inside a class that operates on its objects. It defines the object's behavior.
car1.start_engine() # Output: Toyota engine started.
car2.start_engine() # Output: BMW engine started.
Here, start_engine() is a method of the Car class.
The __init__() Constructor
The
__init__()
method is known as the constructor. It initializes attributes cleanly and consistently whenever you create an object.
class Car:
def __init__(self, brand, color):
self.brand = brand
self.color = color
car1 = Car("Toyota", "Red")
car2 = Car("BMW", "Black")
You can also add attributes on the fly, but it's not ideal:
class Car:
pass
car1 = Car()
car1.color = "Red"
car1.brand = "Toyota"
Class Variables vs Instance Variables
When working with classes, you’ll encounter two types of variables:
Instance Variables – unique to each object
Class Variables – shared by all instances of the class
Instance Variable -
Variables that are defined inside the __init__() method using self. Each object gets its own copy, so changing it in one object won’t affect others.
class Dog:
def __init__(self, name, age):
self.name = name # Instance variable
self.age = age # Instance variable
dog1 = Dog("Buddy", 3)
dog2 = Dog("Lucy", 4)
print(dog1.name) # Buddy
print(dog2.name) # Lucy
Here, dog1 and dog2 each have their own name and age.
Class Variable -
Variables that are declared directly inside the class, outside any method. They are shared across all instances of the class.
class Dog:
species = "Canine" # Class variable (shared)
def __init__(self, name):
self.name = name # Instance variable
dog1 = Dog("Buddy")
dog2 = Dog("Lucy")
print(dog1.species) # Canine
print(dog2.species) # Canine
All dogs share the same species value, because it’s a class-level variable.
4 Pillars of Object-Oriented Programming
Object-Oriented Programming is built upon four fundamental principles that make code more modular, reusable, and easier to maintain. These are often referred to as the "4 Pillars of OOP":
Inheritance
Polymorphism
Encapsulation
Abstraction
Now let’s look into each of them.
Inheritance
Inheritance is a fundamental property of OOP that allows one class (child/derived class) to inherit attributes and methods from another class (parent/base class). It helps promote code reuse and hierarchical class design. There are mainly two types of inheritance - single inheritance and multiple inheritance.
Single Inheritance
In single inheritance, a class inherits from one parent class.
# Parent Class
class Car:
def __init__(self, windows, doors, enginetype):
self.windows = windows
self.doors = doors
self.enginetype = enginetype
def drive(self):
print(f"The person will drive the {self.enginetype} car")
car1 = Car(4, 5, 'petrol')
car1.drive()
Now let's inherit this in a new class. When a class inherits from one parent class, you can use super() to initialize the parent class.
# Child Class (Single Inheritance)
class Tesla(Car):
def __init__(self, windows, doors, enginetype, is_selfdriving):
super().__init__(windows, doors, enginetype) # Call parent constructor
self.is_selfdriving = is_selfdriving
def selfdriving(self):
print(f"Tesla supports self driving: {self.is_selfdriving}")
tesla1 = Tesla(4, 5, 'electric', True)
tesla1.selfdriving()
Here, the Tesla class inherits all attributes and methods from Car and adds its own.
Multiple Inheritance
In multiple inheritance, a class inherits from more than one parent class.
# Base Class 1
class Animal:
def __init__(self, name):
self.name = name
def speak(self):
print("Subclass must implement this method")
# Base Class 2
class Pet:
def __init__(self, owner):
self.owner = owner
Now a child class Dog inherits from both Animal and Pet. In multiple inheritance cases, super() becomes less straightforward unless used with care. So, developers often call each base class constructor explicitly.
# Derived Class
class Dog(Animal, Pet):
def __init__(self, name, owner):
Animal.__init__(self, name)
Pet.__init__(self, owner)
def speak(self):
return f"{self.name} says woof"
Here, we directly call Animal.__init__() and Pet.__init__() because each base class has a separate responsibility.
Simply put,
Use super().__init__() in case of single inheritance.
Use direct base class calls (BaseClass.__init__()) in cases of mutliple inheritance.
Polymorphism
Polymorphism is a core concept in Object-Oriented Programming that means "many forms". It allows objects of different classes to be treated as objects of a common superclass, particularly when they share the same method names.
In simple terms: Polymorphism allows the same method name to behave differently depending on the object that invokes it.
Polymorphism is typically achieved through method overriding and abstract base classes (interfaces).
Method Overriding
Method Overriding allows a child (derived) class to provide a specific implementation of a method that is already defined in its parent (base) class.
Example - Let’s say we have a general class
Animal
with a methodspeak()
. We want specific animals likeDog
andCat
to override that behavior:
# Base Class
class Animal:
def speak(self):
return "Sound of the animal"
Now, two child classes override this method:
# Derived Class 1
class Dog(Animal):
def speak(self):
return "woof!"
# Derived Class 2
class Cat(Animal):
def speak(self):
return "Meow!"
Let’s write a function that can take any animal object and call its speak()
method:
def animal_speak(animal_name):
print(animal_name.speak())
Now, create objects and test:
dog = Dog()
cat = Cat()
animal_speak(dog) # Output: woof!
animal_speak(cat) # Output: Meow!
We can see that polymorphism allows a single function to operate on objects of different classes, each providing its own implementation of a shared method.
Abstract Base Classes
Abstract base classes (ABCs) provide a way to define a common interface that other classes must follow. They are part of Python’s
abc
module and are used to enforce that derived classes implement specific methods.Let’s say we define an abstract class
Vehicle
with an abstract methodstart_engine()
:
from abc import ABC, abstractmethod
# Abstract Base Class
class Vehicle(ABC):
@abstractmethod
def start_engine(self):
pass
Now, all derived classes are required to implement the start_engine()
method. In case they don’t, Python will throw an error:
class BrokenCar(Vehicle):
pass # ❌ Error: Can't instantiate abstract class without implementing 'start_engine'
Now, let’s say two child classes inherit from Vehicle
and provide their own implementation for the start_engine()
:
# Derived Class 1
class Car(Vehicle):
def start_engine(self):
return "Car engine started"
# Derived Class 2
class Motorcycle(Vehicle):
def start_engine(self):
return "Motorcycle engine started"
We can now write a function that takes any Vehicle
and starts its engine:
def start_vehicle(vehicle):
# With abstract classes, we can be sure all vehicles will have start_engine
print(vehicle.start_engine())
With abstract classes and methods, we are guaranteed that all subclasses of Vehicle
will implement start_engine()
. If you used a regular base class like class Vehicle: pass
, there’s no guarantee that subclasses will define start_engine()
.
Create objects and test:
car = Car()
motorcycle = Motorcycle()
start_vehicle(car) # Output: Car engine started
start_vehicle(motorcycle) # Output: Motorcycle engine started
In simple words, Abstract base classes enforce a structure and ensure all subclasses follow a specific interface.
Encapsulation
Encapsulation is the concept of binding data (attributes) and methods (functions) together as a single unit and restricting direct access to some components of an object. This helps prevent accidental modification, protects data integrity, and promotes modular design.
Access Modifiers
Public - Accessible from anywhere (inside and outside the class)
Protected - Intended for internal use. Can be accessed in subclass (derived class) but not meant for external use.
Private - Cannot be accessed directly outside the class. Ensures strong encapsulation.
Public Members
Public members can be freely accessed from outside the class.
class Person:
def __init__(self, name, age):
self.name = name # public
self.age = age # public
person = Person("Suraj", 22)
print(person.name) # ✅ Accessible
print(person.age) # ✅ Accessible
Protected Members
Protected members are marked with a single underscore (_
) and should only be accessed within the class or its subclasses (derived classes).
class Person:
def __init__(self, name, age):
self._name = name # protected
self._age = age # protected
class Employee(Person):
def __init__(self, name, age):
super().__init__(name, age)
employee = Employee("Suraj", 22)
print(employee._name) # ✅ Accessible
Note - Python doesn’t enforce protected access - this is just a convention.
Private Members
Private members are marked with double underscores (__
) and are name-mangled to prevent direct outside access.
class Person:
def __init__(self, name, age):
self.__name = name # private
self.__age = age # private
person = Person("Suraj", 22)
# print(person.__name) ❌ Error: 'Person' object has no attribute '__name'
Even subclasses cannot access private members:
class Employee(Person):
def __init__(self, name, age):
super().__init__(name, age)
employee = Employee("Krish", 25)
# print(employee.__name) ❌ Error
Python transforms __name
internally into _ClassName__name
to prevent access.
Using Getter and Setter Methods
To safely access or modify private variables, we use getter and setter methods.
class Person:
def __init__(self, name, age):
self.__name = name
self.__age = age
# Getter for name
def get_name(self):
return self.__name
# Setter for name
def set_name(self, name):
self.__name = name
# Getter for age
def get_age(self):
return self.__age
# Setter for age
def set_age(self, age):
if age > 0:
self.__age = age
else:
print("Age cannot be negative")
# Usage
person = Person("Suraj", 22)
# Access name
print(person.get_name()) # Output: Suraj
# Modify name
person.set_name("Krish")
print(person.get_name()) # Output: Krish
# Access age
print(person.get_age()) # Output: 22
# Modify age
person.set_age(35)
print(person.get_age()) # Output: 35
# Try setting invalid age
person.set_age(-5) # Output: Age cannot be negative
Abstraction
It refers to the concept of hiding internal implementation details and showing only the necessary features of an object. In simple terms, abstraction lets you focus on what an object does instead of how it does it.
In Python, abstraction is typically achieved using:
Abstract Base Classes (ABCs)
Abstract Methods
These are provided by the built-in abc
module.
Let’s revisit the example used earlier (in polymorphism) to understand abstraction better.
from abc import ABC, abstractmethod
# Abstract Base Class
class Vehicle(ABC):
@abstractmethod
def start_engine(self):
pass
In the above code:
Vehicle
is an abstract base class.It defines a method
start_engine()
but doesn't implement it. This represents the interface.Any subclass must implement this method, or Python will raise an error.
Let’s now create a concrete class that inherits from Vehicle
and provides the actual implementation:
# Derived Class
class Car(Vehicle):
def start_engine(self):
print("Car engine started")
car = Car()
car.start_engine() # Output: Car engine started
This demonstrates abstraction because the Vehicle
class defines the requirement, while Car
provides its specific implementation and the user just calls the method without knowing how it works internally.
Magic Methods in Python
Magic methods in Python (also known as dunder methods, short for "double underscore") are special methods with names that start and end with __. They allow you to define how your objects behave with built-in operations like printing, addition, comparison, iteration etc.
Magic methods are predefined methods in python that you can override to change the behaviour of your objects. Some common magic methods include -
__init__ - Initializes a new instance of the class
__str__ - Returns a user-friendly string representation of the object
__repr__ - Returns an official or unambiguous string representation of the object (used for debugging)
__len__ - Returns the number of items in the container (used by len())
__getitem__ - Retrieves an item from the container using an index/key
__setitem__ - Sets the value of an item in the container using an index/key
Example for __init__, __str__, __repr__ methods
Let's first look at the default behavior:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
person = Person("Suraj", 22)
print(person) # Calls default __str__ → <__main__.Person object at 0x...>
print(repr(person)) # Calls default __repr__ → <__main__.Person object at 0x...>
Now, let's override the default functionalities of __str__ and __repr__:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"{self.name} is {self.age} years old"
def __repr__(self):
return f"Person(name={self.name}, age={self.age})"
person = Person("Suraj", 22)
# User-friendly output
print(person) # Calls __str__ → Output: Suraj is 22 years old
# Developer/debugging output
print(repr(person)) # Calls __repr__ → Output: Person(name=Suraj, age=22)
Example for __len__, __getitem__, __setitem__
class BookShelf:
def __init__(self):
self.books = []
def add_book(self, book):
self.books.append(book)
def __len__(self):
return len(self.books)
def __getitem__(self, index):
return self.books[index]
def __setitem__(self, index, value):
self.books[index] = value
shelf = BookShelf()
shelf.add_book('The Alchemist')
shelf.add_book('A Song of Ice and Fire')
print(len(shelf)) # Calls __len__ → Output: 2
print(shelf[1]) # Calls __getitem__ → Output: A Song of Ice and Fire
shelf[1] = 'Pride and Prejudice' # Calls __setitem__
print(shelf[1]) # Output: Pride and Prejudice
Operator Overloading
Operator overloading means redefining the behavior of a built-in operator (like +
, -
, *
, etc.) for user-defined classes. Common operator overloading magic methods -
add(self, other) - Adds two objects using the + operator
sub(self, other) - Subtracts one object from another using the - operator
mul(self, other) - Multiplies two objects using the * operator
truediv(self, other) - Divides one object by another using the / operator
eq(self, other) - Compares two objects for equality using the == operator
lt(self, other) - Compares if one object is less than another using the < operator
Vector operations example using operator overloading -
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
# Overloading the + operator
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
# Overloading the - operator
def __sub__(self, other):
return Vector(self.x - other.x, self.y - other.y)
# Overloading the * operator
def __mul__(self, other):
return Vector(self.x * other, self.y * other)
# Overloading the == operator
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# String representation for user-friendly output
def __str__(self):
return f"Vector({self.x}, {self.y})"
# Creating vector objects
vec1 = Vector(2, 3)
vec2 = Vector(4, 6)
# Vector addition
print(vec1 + vec2) # Output: Vector(6, 9)
# Vector subtraction
print(vec1 - vec2) # Output: Vector(-2, -3)
# Scalar multiplication
print(vec1 * 3) # Output: Vector(6, 9)
# Equality comparison
vec3 = Vector(2, 3)
print(vec1 == vec3) # Output: True
print(vec1 == vec2) # Output: False
In summary, Object-Oriented Programming (OOP) is all about organizing code into reusable blueprints (classes) and real-world instances (objects). By following its four core principles - Encapsulation, Abstraction, Inheritance, and Polymorphism, we can write code that is modular, maintainable, and scalable.
Subscribe to my newsletter
Read articles from Suraj Rao directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Suraj Rao
Suraj Rao
Hi, I'm Suraj I'm an aspiring AI/ML engineer passionate about building intelligent systems that learn, see, and adapt to the real world.