Understanding Object-Oriented Programming (OOP) in Python

Suraj RaoSuraj Rao
13 min read

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.

Code Memes on X: "Do you like OOP? #object #objectorientedprogramming # programming #java #poo #poobear #programmers #programmershumor  #programminghumor #developers #programmersmemes #programmingmemes #code  #coding #codememes #codingmemes #memes ...

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

  1. 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.")
  1. 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
  1. 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.

  1. 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.

  1. 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

  1. 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.

  1. 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":

  1. Inheritance

  2. Polymorphism

  3. Encapsulation

  4. 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.

  1. 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.

  1. 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,

  1. Use super().__init__() in case of single inheritance.

  2. 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).

  1. 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 method speak(). We want specific animals like Dog and Cat 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.

  1. 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 method start_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

  1. Public - Accessible from anywhere (inside and outside the class)

  2. Protected - Intended for internal use. Can be accessed in subclass (derived class) but not meant for external use.

  3. 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.


Four Pillars Of OOP: Visual Edition · ProgrammerHumor.io


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

Python: Underscore usage in naming | by James Tran | Medium


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

it is all overloading. · ProgrammerHumor.io


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.

10
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.