Object-Oriented Programming in Python

Master Classes, Objects, Inheritance, Polymorphism & More

A comprehensive guide to OOP principles with detailed examples, practical code demonstrations, and references to official documentation.

What is Object-Oriented Programming?

OOP is a programming paradigm based on the concept of objects, which can contain data and code: data in the form of attributes, and code in the form of methods.

According to the official Python documentation, "Classes provide a means of bundling data and functionality together. Creating a new class creates a new type of object, allowing new instances of that type to be made."

Key Insight: In Python, almost everything is an object with properties and methods. Even basic data types like integers and strings are objects.

Core OOP Concepts in Python

Class

A blueprint or template for creating objects. Defines attributes and methods.

Learn more on W3Schools

Object

An instance of a class. Created from the class blueprint with actual data.

Learn more on W3Schools

Inheritance

Allows a class to inherit attributes and methods from another class.

Official Python Docs

Polymorphism

Ability to use a common interface for different data types or classes.

W3Schools Tutorial

Additional OOP Principles

Encapsulation

Bundling of data with the methods that operate on that data, often restricting direct access to some components.

Abstraction

Hiding complex implementation details and showing only essential features of an object.

Detailed Code Examples with Explanations

Example 1: Basic Class and Object

Creating a simple class and instantiating objects

class Student:
    """A class to represent a student."""
    
    def __init__(self, name, course):
        # Constructor method: Initializes object attributes
        self.name = name    # Instance attribute
        self.course = course  # Instance attribute
    
    def introduce(self):
        # Instance method: Defines behavior for Student objects
        return f"Hi, I'm {self.name} and I'm studying {self.course}."

# Creating objects (instances) of the Student class
s1 = Student("Ravi", "Python Programming")
s2 = Student("Anjali", "Data Science")

# Accessing object methods
print(s1.introduce())
print(s2.introduce())

Explanation:

  • __init__() is the constructor method that initializes new objects
  • self refers to the instance being created (automatically passed)
  • s1 and s2 are independent objects with their own attribute values
  • Methods defined in a class can access instance attributes using self

Example 2: Inheritance

Creating a base class and derived class with method overriding

class Teacher:
    """Base class representing a teacher."""
    
    def __init__(self, name, subject):
        self.name = name
        self.subject = subject
    
    def teach(self):
        return f"{self.name} is teaching {self.subject}."

class MathTeacher(Teacher):  # Inherits from Teacher class
    """Derived class representing a math teacher."""
    
    def __init__(self, name):
        # Call parent class constructor using super()
        super().__init__(name, "Mathematics")
    
    def teach(self):
        # Method overriding: Provides specific implementation
        return f"{self.name} is teaching advanced Mathematics."

# Creating instances
generic_teacher = Teacher("Dr. Sharma", "General Science")
math_teacher = MathTeacher("Prof. Gupta")

# Demonstrating inheritance and polymorphism
print(generic_teacher.teach())  # Calls Teacher.teach()
print(math_teacher.teach())     # Calls MathTeacher.teach() (overridden)
print(f"Is MathTeacher a subclass of Teacher? {issubclass(MathTeacher, Teacher)}")
print(f"Is math_teacher an instance of Teacher? {isinstance(math_teacher, Teacher)}")

Key Points:

  • MathTeacher(Teacher) syntax creates inheritance relationship
  • super() calls the parent class constructor
  • Method overriding allows child classes to provide specific implementations
  • issubclass() and isinstance() check class relationships
  • This demonstrates polymorphism - same method name, different behaviors
Read more about inheritance in the Python docs

Example 3: Encapsulation and Properties

Using private attributes and property decorators for controlled access

class BankAccount:
    """Demonstrates encapsulation with private attributes."""
    
    def __init__(self, account_holder, initial_balance):
        self.account_holder = account_holder
        self._balance = initial_balance  # Protected attribute (convention)
        self.__transaction_count = 0  # Private attribute (name mangling)
    
    # Property decorator for controlled access
    @property
    def balance(self):
        # Getter method - allows read access to _balance
        return self._balance
    
    @balance.setter
    def balance(self, amount):
        # Setter method - validates before setting balance
        if amount < 0:
            raise ValueError("Balance cannot be negative")
        self._balance = amount
        self.__transaction_count += 1
    
    def get_transaction_count(self):
        # Public method to access private attribute
        return self.__transaction_count

# Using the encapsulated class
account = BankAccount("Priya Sharma", 5000)
print(f"Account holder: {account.account_holder}")
print(f"Initial balance: ₹{account.balance}")  # Uses getter

account.balance = 7500  # Uses setter - valid
print(f"Updated balance: ₹{account.balance}")
print(f"Transaction count: {account.get_transaction_count()}")

# This would raise an error:
# account.balance = -1000  # ValueError: Balance cannot be negative

Encapsulation Details:

  • Single underscore (_balance): Protected attribute (convention, not enforced)
  • Double underscore (__transaction_count): Private attribute (name mangling)
  • @property decorator creates getter method for controlled access
  • Setter method (@balance.setter) adds validation logic
  • Encapsulation protects object integrity by controlling attribute access
Learn more about encapsulation on W3Schools

Polymorphism in Python OOP

Polymorphism allows methods to do different things based on the object they are acting upon.

Method Overriding

Child class provides specific implementation of parent class method

Operator Overloading

Same operator behaves differently with different data types

Duck Typing

"If it walks like a duck and quacks like a duck, it's a duck"

Polymorphism in Action: Shape Calculator

class Shape:
    """Base class for different shapes."""
    def area(self):
        raise NotImplementedError("Subclasses must implement this method")
    
    def perimeter(self):
        raise NotImplementedError("Subclasses must implement this method")

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        # Rectangle-specific implementation
        return self.width * self.height
    
    def perimeter(self):
        return 2 * (self.width + self.height)
    
    def __str__(self):
        # String representation for printing
        return f"Rectangle({self.width}x{self.height})"

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        # Circle-specific implementation
        import math
        return math.pi * self.radius ** 2
    
    def perimeter(self):
        # Same method name, different calculation
        import math
        return 2 * math.pi * self.radius
    
    def __str__(self):
        return f"Circle(radius={self.radius})"

# Duck Typing Example: Function works with ANY object that has area() method
def print_shape_info(shape_obj):
    """This function demonstrates polymorphism - works with ANY shape."""
    print(f"Shape: {shape_obj}")
    print(f"Area: {shape_obj.area():.2f}")
    print(f"Perimeter: {shape_obj.perimeter():.2f}")
    print("-" * 30)

# Using polymorphism with different objects
shapes = [
    Rectangle(5, 10),
    Circle(7),
    Rectangle(3, 6),
    Circle(4.5)
]

# Same interface, different behaviors
for shape in shapes:
    print_shape_info(shape)  # Polymorphic call

# Operator Overloading Example
class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y
    
    def __add__(self, other):
        # Overloading + operator for Vector addition
        return Vector(self.x + other.x, self.y + other.y)
    
    def __str__(self):
        return f"Vector({self.x}, {self.y})"

v1 = Vector(2, 3)
v2 = Vector(4, 5)
v3 = v1 + v2  # Polymorphic operator usage
print(f"{v1} + {v2} = {v3}")

Polymorphism in Practice

  • Method Overriding: Both Rectangle and Circle implement area() differently
  • Duck Typing: print_shape_info() works with any object having area() and perimeter() methods
  • Common Interface: All shapes can be processed uniformly despite different implementations
  • Operator Overloading: + operator behaves differently for Vector objects vs numbers

Real-World Applications

Payment Processing: Same process_payment() method works for credit cards, PayPal, or cryptocurrencies

Database Operations: save() method works uniformly for different database backends

File Export: Same export() interface for PDF, Excel, or CSV formats

Explore Python's special methods for operator overloading

Abstraction in Python OOP

Abstraction simplifies complex reality by modeling classes appropriate to the problem domain.

What is Abstraction?

Abstraction focuses on what an object does rather than how it does it. It's like driving a car - you use the steering wheel, pedals, and gearshift without knowing the internal combustion engine details.

Key Benefits:

  • Reduces complexity by hiding implementation details
  • Provides clear, simple interfaces
  • Improves code maintainability
  • Enhances security by restricting access
Learn more about abstraction principles

Abstract Class Example

ABC Module
from abc import ABC, abstractmethod

class Vehicle(ABC):  # Abstract base class
    """Abstract class defining vehicle interface."""
    
    @abstractmethod
    def start_engine(self):
        # Abstract method - must be implemented by subclasses
        pass
    
    @abstractmethod
    def stop_engine(self):
        # Abstract method - defines interface only
        pass
    
    def get_vehicle_type(self):
        # Concrete method - available to all subclasses
        return "Generic Vehicle"

class Car(Vehicle):  # Concrete implementation
    def __init__(self, brand):
        self.brand = brand
        self.engine_status = "off"
    
    def start_engine(self):
        self.engine_status = "on"
        return f"{self.brand} engine started. Vroom vroom!"
    
    def stop_engine(self):
        self.engine_status = "off"
        return f"{self.brand} engine stopped."
    
    def get_vehicle_type(self):
        # Overriding concrete method
        return "Four-Wheeled Car"

# Using the abstraction
try:
    v = Vehicle()  # This will fail - cannot instantiate abstract class
except TypeError as e:
    print(f"Error: {e}")

# Creating concrete vehicle
my_car = Car("Toyota")
print(my_car.start_engine())      # Uses Car's implementation
print(my_car.get_vehicle_type())  # Overridden method
print(my_car.stop_engine())

How Abstraction Works in This Example:

  • ABC Module: Python's abc module provides tools for creating abstract base classes
  • @abstractmethod: Decorator marks methods that must be implemented by child classes
  • Interface Definition: Vehicle class defines what operations are available without specifying how
  • Implementation Hiding: Users interact with Car objects without knowing internal details
  • Forced Implementation: Python prevents instantiation of incomplete abstract classes

Advanced OOP Topics

Multiple Inheritance

Python supports inheriting from multiple parent classes:

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

class Employee:
    def __init__(self, emp_id):
        self.emp_id = emp_id

class Manager(Person, Employee):
    def __init__(self, name, emp_id, department):
        Person.__init__(self, name)
        Employee.__init__(self, emp_id)
        self.department = department

mgr = Manager("Rajesh Kumar", "MGR101", "IT")
print(f"{mgr.name} (ID: {mgr.emp_id}) manages {mgr.department}")

Method Resolution Order (MRO) determines which parent class method is called.

Class & Static Methods

Methods that belong to the class rather than instances:

class Calculator:
    operation_count = 0  # Class attribute
    
    @classmethod
    def increment_operation(cls):
        cls.operation_count += 1
    
    @staticmethod
    def add(x, y):
        Calculator.increment_operation()
        return x + y
    
    @staticmethod
    def multiply(x, y):
        Calculator.increment_operation()
        return x * y

# Using class and static methods
result1 = Calculator.add(5, 3)
result2 = Calculator.multiply(5, 3)
print(f"Add result: {result1}, Multiply result: {result2}")
print(f"Total operations: {Calculator.operation_count}")

Class methods receive cls parameter, static methods don't receive implicit parameters.

Why Use Object-Oriented Programming?

Modularity & Organization

Code is organized into logical units (classes), making it easier to understand, maintain, and debug.

Code Reusability

Inheritance allows creating new classes based on existing ones, reducing code duplication.

Scalability & Maintainability

OOP makes it easier to scale applications and add new features without breaking existing code.