Unit 4: Object-Oriented Programming (OOP) in Python
Topics Covered:
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 (
withstatement) 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 |