Unit 4: Object-Oriented Programming (OOP) in Python

4.1 Introduction to OOP

What is Object-Oriented Programming?

Object-Oriented Programming (OOP) is a programming paradigm that organizes code around objects rather than functions and logic. Objects are instances of classes, which serve as blueprints defining the structure and behavior of objects.

Key Benefits of OOP:

  • Modularity: Code is organized into self-contained objects
  • Reusability: Classes can be reused across different programs
  • Maintainability: Easier to modify and update code
  • Data Security: Data hiding through encapsulation
  • Scalability: Easy to add new features

Classes and Objects

Class

A class is a blueprint or template for creating objects. It defines the attributes (data) and methods (functions) that objects of that class will have.

# Defining a class
class Car:
    # Class attribute (shared by all instances)
    wheels = 4
    
    # Method
    def start(self):
        print("Car is starting...")
    
    def stop(self):
        print("Car has stopped.")

Object

An object is an instance of a class. It is a concrete entity created from the class blueprint with its own set of attribute values.

# Creating objects (instances) of the Car class
car1 = Car()
car2 = Car()

# Calling methods on objects
car1.start()    # Output: Car is starting...
car2.stop()     # Output: Car has stopped.

# Accessing class attribute
print(car1.wheels)  # Output: 4

Real-World Analogy

Concept Real-World Example
Class Blueprint of a house
Object Actual house built from the blueprint
Attributes Number of rooms, color, size
Methods Open door, turn on lights

Four Pillars of OOP

1. Encapsulation

Encapsulation is the bundling of data (attributes) and methods that operate on the data into a single unit (class). It also involves restricting direct access to some of the object's components, known as data hiding.

Access Modifiers in Python:

Modifier Syntax Description
Public name Accessible from anywhere
Protected _name Accessible within class and subclasses (convention)
Private __name Accessible only within the class (name mangling)
class BankAccount:
    def __init__(self, account_holder, balance):
        self.account_holder = account_holder  # Public
        self._account_type = "Savings"        # Protected
        self.__balance = balance              # Private
    
    # Getter method for private attribute
    def get_balance(self):
        return self.__balance
    
    # Setter method for private attribute
    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            print(f"Deposited: {amount}")
    
    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            print(f"Withdrawn: {amount}")
        else:
            print("Insufficient balance!")

# Using the class
account = BankAccount("John", 1000)
print(account.account_holder)    # John (public - accessible)
print(account._account_type)     # Savings (protected - accessible but discouraged)
# print(account.__balance)       # Error! Private attribute
print(account.get_balance())     # 1000 (accessed via getter)

account.deposit(500)
print(account.get_balance())     # 1500

2. Inheritance

Inheritance allows a class (child/derived class) to inherit attributes and methods from another class (parent/base class). This promotes code reuse and establishes a natural hierarchy.

# Base class (Parent)
class Animal:
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        print("Animal makes a sound")
    
    def eat(self):
        print(f"{self.name} is eating")

# Derived class (Child)
class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # Call parent constructor
        self.breed = breed
    
    def speak(self):  # Method overriding
        print(f"{self.name} says Woof!")
    
    def fetch(self):  # New method specific to Dog
        print(f"{self.name} is fetching the ball")

# Using inheritance
dog = Dog("Buddy", "Golden Retriever")
dog.speak()   # Buddy says Woof! (overridden method)
dog.eat()     # Buddy is eating (inherited method)
dog.fetch()   # Buddy is fetching the ball

3. Polymorphism

Polymorphism means "many forms". It allows objects of different classes to be treated uniformly through a common interface. The same method name can behave differently based on the object calling it.

Types of Polymorphism:

  • Compile-time (Method Overloading): Same method name with different parameters (Python uses default arguments)
  • Runtime (Method Overriding): Child class redefines a method from parent class
# Polymorphism through method overriding
class Shape:
    def area(self):
        pass

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width = width
        self.height = height
    
    def area(self):
        return self.width * self.height

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    
    def area(self):
        return 3.14159 * self.radius ** 2

class Triangle(Shape):
    def __init__(self, base, height):
        self.base = base
        self.height = height
    
    def area(self):
        return 0.5 * self.base * self.height

# Polymorphism in action
shapes = [Rectangle(4, 5), Circle(3), Triangle(6, 4)]

for shape in shapes:
    print(f"Area: {shape.area()}")
# Output:
# Area: 20
# Area: 28.27431
# Area: 12.0

Duck Typing in Python:

"If it walks like a duck and quacks like a duck, then it must be a duck."

class Duck:
    def sound(self):
        print("Quack!")

class Cat:
    def sound(self):
        print("Meow!")

class Dog:
    def sound(self):
        print("Woof!")

# Polymorphism - same function works with different types
def make_sound(animal):
    animal.sound()

make_sound(Duck())  # Quack!
make_sound(Cat())   # Meow!
make_sound(Dog())   # Woof!

4. Abstraction

Abstraction hides complex implementation details and shows only the essential features. In Python, abstraction is achieved using abstract classes and methods.

from abc import ABC, abstractmethod

# Abstract class
class Vehicle(ABC):
    @abstractmethod
    def start(self):
        pass
    
    @abstractmethod
    def stop(self):
        pass
    
    def fuel_type(self):  # Concrete method
        return "Petrol"

# Concrete class implementing abstract class
class Car(Vehicle):
    def start(self):
        print("Car engine started")
    
    def stop(self):
        print("Car engine stopped")

class Bike(Vehicle):
    def start(self):
        print("Bike engine started")
    
    def stop(self):
        print("Bike engine stopped")

# Cannot create instance of abstract class
# vehicle = Vehicle()  # Error!

# Can create instances of concrete classes
car = Car()
car.start()           # Car engine started
print(car.fuel_type()) # Petrol

4.2 Creating Classes and Objects

Class Syntax

class ClassName:
    # Class attributes (shared by all instances)
    class_attribute = value
    
    # Constructor
    def __init__(self, parameters):
        # Instance attributes
        self.instance_attribute = value
    
    # Instance methods
    def method_name(self, parameters):
        # Method body
        pass

Class Attributes vs Instance Attributes

Class Attributes

Class attributes are shared by all instances of the class. They are defined directly in the class body.

class Student:
    # Class attribute
    school_name = "Python High School"
    total_students = 0
    
    def __init__(self, name):
        self.name = name  # Instance attribute
        Student.total_students += 1

# Creating students
s1 = Student("Alice")
s2 = Student("Bob")

# Class attribute is shared
print(s1.school_name)  # Python High School
print(s2.school_name)  # Python High School
print(Student.total_students)  # 2

# Modifying class attribute
Student.school_name = "Advanced Python School"
print(s1.school_name)  # Advanced Python School
print(s2.school_name)  # Advanced Python School

Instance Attributes

Instance attributes are unique to each object. They are typically defined in the __init__ method using self.

class Employee:
    def __init__(self, name, salary):
        self.name = name      # Instance attribute
        self.salary = salary  # Instance attribute

e1 = Employee("John", 50000)
e2 = Employee("Jane", 60000)

print(e1.name, e1.salary)  # John 50000
print(e2.name, e2.salary)  # Jane 60000

# Each instance has its own values
e1.salary = 55000
print(e1.salary)  # 55000
print(e2.salary)  # 60000 (unchanged)

Types of Methods

1. Instance Methods

Instance methods operate on instance data and require self as the first parameter.

class Circle:
    def __init__(self, radius):
        self.radius = radius
    
    # Instance method
    def area(self):
        return 3.14159 * self.radius ** 2
    
    # Instance method
    def circumference(self):
        return 2 * 3.14159 * self.radius

c = Circle(5)
print(c.area())          # 78.53975
print(c.circumference()) # 31.4159

2. Class Methods

Class methods operate on class-level data and use @classmethod decorator. They receive the class as the first parameter (cls).

class Employee:
    employee_count = 0
    
    def __init__(self, name):
        self.name = name
        Employee.employee_count += 1
    
    @classmethod
    def get_employee_count(cls):
        return cls.employee_count
    
    @classmethod
    def from_string(cls, emp_string):
        # Alternative constructor
        name, salary = emp_string.split("-")
        return cls(name)

# Using class method
e1 = Employee("John")
e2 = Employee("Jane")
print(Employee.get_employee_count())  # 2

# Alternative constructor
e3 = Employee.from_string("Alice-50000")
print(e3.name)  # Alice

3. Static Methods

Static methods don't operate on instance or class data. They use @staticmethod decorator and don't receive self or cls.

class MathUtils:
    @staticmethod
    def add(a, b):
        return a + b
    
    @staticmethod
    def multiply(a, b):
        return a * b
    
    @staticmethod
    def is_even(number):
        return number % 2 == 0

# Using static methods
print(MathUtils.add(5, 3))       # 8
print(MathUtils.multiply(4, 7))  # 28
print(MathUtils.is_even(10))     # True

Constructor (__init__)

The constructor is a special method that is automatically called when an object is created. It initializes the object's attributes.

class Person:
    def __init__(self, name, age, city="Unknown"):
        # Initialize instance attributes
        self.name = name
        self.age = age
        self.city = city
        print(f"Person {self.name} created!")
    
    def introduce(self):
        print(f"Hi, I'm {self.name}, {self.age} years old from {self.city}")

# Creating objects - constructor is called automatically
p1 = Person("Alice", 25, "New York")  # Person Alice created!
p2 = Person("Bob", 30)                # Person Bob created!

p1.introduce()  # Hi, I'm Alice, 25 years old from New York
p2.introduce()  # Hi, I'm Bob, 30 years old from Unknown

Destructor (__del__)

The destructor is called when an object is about to be destroyed (garbage collected). It's used for cleanup operations.

class FileHandler:
    def __init__(self, filename):
        self.filename = filename
        self.file = open(filename, 'w')
        print(f"File {filename} opened")
    
    def write(self, data):
        self.file.write(data)
    
    def __del__(self):
        # Cleanup: Close the file
        self.file.close()
        print(f"File {self.filename} closed")

# Example usage
handler = FileHandler("test.txt")
handler.write("Hello, World!")
del handler  # Explicitly delete - destructor called
# Output: File test.txt closed

Important Notes about Destructor:

  • Called automatically when object is garbage collected
  • Called explicitly using del object
  • Not guaranteed to be called immediately
  • Avoid heavy operations in destructor
  • Use context managers (with statement) for resource management instead

Special (Magic/Dunder) Methods

Python has special methods with double underscores that provide special functionality:

Method Description Usage
__init__(self) Constructor Called when object is created
__del__(self) Destructor Called when object is destroyed
__str__(self) String representation print(obj), str(obj)
__repr__(self) Official representation repr(obj)
__len__(self) Length len(obj)
__add__(self, other) Addition obj1 + obj2
__eq__(self, other) Equality obj1 == obj2
class Book:
    def __init__(self, title, pages):
        self.title = title
        self.pages = pages
    
    def __str__(self):
        return f"'{self.title}' ({self.pages} pages)"
    
    def __repr__(self):
        return f"Book('{self.title}', {self.pages})"
    
    def __len__(self):
        return self.pages
    
    def __add__(self, other):
        # Combine two books
        return Book(f"{self.title} & {other.title}", self.pages + other.pages)
    
    def __eq__(self, other):
        return self.title == other.title and self.pages == other.pages

book1 = Book("Python Basics", 200)
book2 = Book("Advanced Python", 300)

print(book1)           # 'Python Basics' (200 pages)
print(repr(book1))     # Book('Python Basics', 200)
print(len(book1))      # 200

book3 = book1 + book2
print(book3)           # 'Python Basics & Advanced Python' (500 pages)

4.3 Types of Inheritance

Inheritance allows a class to acquire properties and methods from another class. Python supports multiple types of inheritance.

1. Single Inheritance

A child class inherits from only one parent class. This is the simplest form of inheritance.

# Single Inheritance
# Parent Class
class Animal:
    def __init__(self, name):
        self.name = name
    
    def eat(self):
        print(f"{self.name} is eating")
    
    def sleep(self):
        print(f"{self.name} is sleeping")

# Child Class
class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # Call parent constructor
        self.breed = breed
    
    def bark(self):
        print(f"{self.name} is barking: Woof!")
    
    def display_info(self):
        print(f"Name: {self.name}, Breed: {self.breed}")

# Using single inheritance
dog = Dog("Buddy", "Golden Retriever")
dog.eat()          # Buddy is eating (inherited)
dog.sleep()        # Buddy is sleeping (inherited)
dog.bark()         # Buddy is barking: Woof!
dog.display_info() # Name: Buddy, Breed: Golden Retriever

Diagram:

    Animal (Parent)
        ↓
    Dog (Child)

2. Multiple Inheritance

A child class inherits from more than one parent class. The child class can access attributes and methods from all parent classes.

# Multiple Inheritance
class Father:
    def __init__(self):
        self.father_name = "John"
    
    def gardening(self):
        print("Father loves gardening")
    
    def skills(self):
        print("Father's skill: Programming")

class Mother:
    def __init__(self):
        self.mother_name = "Jane"
    
    def cooking(self):
        print("Mother loves cooking")
    
    def skills(self):
        print("Mother's skill: Painting")

# Child inheriting from both Father and Mother
class Child(Father, Mother):
    def __init__(self):
        Father.__init__(self)
        Mother.__init__(self)
        self.child_name = "Alice"
    
    def play(self):
        print(f"{self.child_name} loves to play")
    
    def show_family(self):
        print(f"Father: {self.father_name}")
        print(f"Mother: {self.mother_name}")
        print(f"Child: {self.child_name}")

# Using multiple inheritance
child = Child()
child.gardening()    # Father loves gardening
child.cooking()      # Mother loves cooking
child.play()         # Alice loves to play
child.show_family()
child.skills()       # Father's skill: Programming (MRO - Father comes first)

Diagram:

Father      Mother
    ↘      ↙
     Child

Method Resolution Order (MRO)

When multiple parent classes have the same method, Python uses MRO to determine which method to call. It follows the order in which parent classes are listed.

# Check Method Resolution Order
print(Child.__mro__)
# Output: (<class 'Child'>, <class 'Father'>, <class 'Mother'>, <class 'object'>)

# Or use mro() method
print(Child.mro())

The Diamond Problem

Occurs when a class inherits from two classes that have a common ancestor:

class Grandparent:
    def greet(self):
        print("Hello from Grandparent")

class Father(Grandparent):
    def greet(self):
        print("Hello from Father")

class Mother(Grandparent):
    def greet(self):
        print("Hello from Mother")

class Child(Father, Mother):
    pass

child = Child()
child.greet()  # Hello from Father (MRO: Child -> Father -> Mother -> Grandparent)
print(Child.__mro__)
# (<class 'Child'>, <class 'Father'>, <class 'Mother'>, <class 'Grandparent'>, <class 'object'>)

3. Multilevel Inheritance

A chain of inheritance where a child class becomes the parent for another class. It forms a hierarchy of classes.

# Multilevel Inheritance
class Grandparent:
    def __init__(self):
        self.grandparent_name = "George"
    
    def grandparent_info(self):
        print(f"Grandparent: {self.grandparent_name}")
    
    def family_tradition(self):
        print("Family tradition: Sunday dinner")

class Parent(Grandparent):
    def __init__(self):
        super().__init__()  # Call Grandparent constructor
        self.parent_name = "John"
    
    def parent_info(self):
        print(f"Parent: {self.parent_name}")
    
    def work(self):
        print("Parent is working")

class Child(Parent):
    def __init__(self):
        super().__init__()  # Call Parent constructor (which calls Grandparent)
        self.child_name = "Alice"
    
    def child_info(self):
        print(f"Child: {self.child_name}")
    
    def study(self):
        print("Child is studying")
    
    def show_family_tree(self):
        self.grandparent_info()
        self.parent_info()
        self.child_info()

# Using multilevel inheritance
child = Child()
child.show_family_tree()
# Output:
# Grandparent: George
# Parent: John
# Child: Alice

child.family_tradition()  # Family tradition: Sunday dinner (from Grandparent)
child.work()              # Parent is working (from Parent)
child.study()             # Child is studying (own method)

Diagram:

Grandparent
     ↓
   Parent
     ↓
   Child

4. Hierarchical Inheritance

Multiple child classes inherit from a single parent class.

# Hierarchical Inheritance
class Vehicle:
    def __init__(self, brand):
        self.brand = brand
    
    def start(self):
        print(f"{self.brand} vehicle is starting")
    
    def stop(self):
        print(f"{self.brand} vehicle is stopping")

class Car(Vehicle):
    def __init__(self, brand, model):
        super().__init__(brand)
        self.model = model
    
    def drive(self):
        print(f"Driving {self.brand} {self.model}")

class Bike(Vehicle):
    def __init__(self, brand, type):
        super().__init__(brand)
        self.type = type
    
    def ride(self):
        print(f"Riding {self.brand} {self.type}")

class Truck(Vehicle):
    def __init__(self, brand, capacity):
        super().__init__(brand)
        self.capacity = capacity
    
    def load(self):
        print(f"Loading {self.capacity} tons into {self.brand} truck")

# Using hierarchical inheritance
car = Car("Toyota", "Camry")
bike = Bike("Honda", "Sports")
truck = Truck("Ford", 10)

car.start()   # Toyota vehicle is starting
car.drive()   # Driving Toyota Camry

bike.start()  # Honda vehicle is starting
bike.ride()   # Riding Honda Sports

truck.start() # Ford vehicle is starting
truck.load()  # Loading 10 tons into Ford truck

Diagram:

       Vehicle
      /   |   \
   Car  Bike  Truck

5. Hybrid Inheritance

A combination of two or more types of inheritance. It can include any mix of single, multiple, multilevel, and hierarchical inheritance.

# Hybrid Inheritance (combination of Multiple and Multilevel)
class School:
    def __init__(self):
        self.school_name = "Python Academy"
    
    def school_info(self):
        print(f"School: {self.school_name}")

class Student(School):
    def __init__(self):
        super().__init__()
        self.student_id = "S001"
    
    def student_info(self):
        print(f"Student ID: {self.student_id}")

class Sports:
    def __init__(self):
        self.sport = "Basketball"
    
    def sports_info(self):
        print(f"Sport: {self.sport}")

class Athlete(Student, Sports):
    def __init__(self):
        Student.__init__(self)
        Sports.__init__(self)
        self.name = "Alice"
    
    def display_all(self):
        print(f"Name: {self.name}")
        self.school_info()
        self.student_info()
        self.sports_info()

# Using hybrid inheritance
athlete = Athlete()
athlete.display_all()
# Output:
# Name: Alice
# School: Python Academy
# Student ID: S001
# Sport: Basketball

The super() Function

The super() function is used to call methods from a parent class. It's especially useful in inheritance to avoid hardcoding parent class names.

class Parent:
    def __init__(self, name):
        self.name = name
        print("Parent constructor called")
    
    def greet(self):
        print(f"Hello, I'm {self.name}")

class Child(Parent):
    def __init__(self, name, age):
        super().__init__(name)  # Call parent constructor
        self.age = age
        print("Child constructor called")
    
    def greet(self):
        super().greet()  # Call parent method
        print(f"I'm {self.age} years old")

child = Child("Alice", 10)
# Output:
# Parent constructor called
# Child constructor called

child.greet()
# Output:
# Hello, I'm Alice
# I'm 10 years old

Summary: Types of Inheritance

Type Description Example
Single One child inherits from one parent Dog → Animal
Multiple One child inherits from multiple parents Child → (Father, Mother)
Multilevel Chain of inheritance Child → Parent → Grandparent
Hierarchical Multiple children from one parent (Cat, Dog, Bird) → Animal
Hybrid Combination of multiple types Mix of above types