🔄 Unit-V: Advanced OOP in Python

Lecture 4: Polymorphism - Function Overriding and Operator Overloading

1. Understanding Polymorphism

Polymorphism allows objects of different classes to be treated as objects of a common superclass. It enables a single interface to represent different underlying forms (data types).

1.1 What is Polymorphism?

Polymorphism means "many forms". In OOP, it refers to the ability of different classes to be used interchangeably through a common interface.

class Bird: def fly(self): return "I can fly" class Penguin(Bird): def fly(self): return "I can't fly, but I can swim!" def let_it_fly(bird): print(bird.fly()) # Different objects, same method, different behaviors sparrow = Bird() penguin = Penguin() let_it_fly(sparrow) # I can fly let_it_fly(penguin) # I can't fly, but I can swim!
Key Points:
  • Polymorphism allows the same method name to be used for different types
  • It promotes code reusability and flexibility
  • Makes the code more intuitive and easier to understand
2. Function Overriding

Function overriding occurs when a child class provides a specific implementation of a method that is already defined in its parent class.

2.1 Basic Method Overriding

class Animal: def make_sound(self): return "Some generic animal sound" class Dog(Animal): def make_sound(self): return "Woof!" class Cat(Animal): def make_sound(self): return "Meow!" # Create instances animal = Animal() dog = Dog() cat = Cat() # Same method, different behaviors print(animal.make_sound()) # Some generic animal sound print(dog.make_sound()) # Woof! print(cat.make_sound()) # Meow!

2.2 Using super() in Overridden Methods

class Vehicle: def __init__(self, brand, model): self.brand = brand self.model = model def info(self): return f"{self.brand} {self.model}" class Car(Vehicle): def __init__(self, brand, model, doors): super().__init__(brand, model) self.doors = doors def info(self): # Extend the parent class method return f"{super().info()} with {self.doors} doors" # Create instances vehicle = Vehicle("Generic", "Vehicle") car = Car("Toyota", "Camry", 4) print(vehicle.info()) # Generic Vehicle print(car.info()) # Toyota Camry with 4 doors
Important: When overriding methods, it's often a good practice to call the parent class's method using super() to ensure that the parent's initialization and functionality are preserved.
3. Operator Overloading

Operator overloading allows the same operator to have different meanings according to the context. In Python, this is achieved by defining special methods in a class.

3.1 Common Operator Overloading Methods

Arithmetic
Comparison
Subscript
Context Managers
Arithmetic Operators
class Vector: def __init__(self, x, y): self.x = x self.y = y # Addition def __add__(self, other): return Vector(self.x + other.x, self.y + other.y) # Subtraction def __sub__(self, other): return Vector(self.x - other.x, self.y - other.y) # Multiplication (scalar) def __mul__(self, scalar): if isinstance(scalar, (int, float)): return Vector(self.x * scalar, self.y * scalar) return NotImplemented # Reverse multiplication (for scalar * vector) __rmul__ = __mul__ # String representation def __str__(self): return f"Vector({self.x}, {self.y})" # Create vectors v1 = Vector(2, 4) v2 = Vector(1, 3) # Test operations print(v1 + v2) # Vector(3, 7) print(v1 - v2) # Vector(1, 1) print(v1 * 3) # Vector(6, 12) print(2 * v2) # Vector(2, 6) - uses __rmul__
Comparison Operators
class Fraction: def __init__(self, numerator, denominator=1): self.numer = numerator self.denom = denominator # Equality def __eq__(self, other): if not isinstance(other, Fraction): return False return self.numer * other.denom == other.numer * self.denom # Less than def __lt__(self, other): return self.numer * other.denom < other.numer * self.denom # Less than or equal def __le__(self, other): return self.numer * other.denom <= other.numer * self.denom # String representation def __str__(self): return f"{self.numer}/{self.denom}" # Create fractions f1 = Fraction(1, 2) f2 = Fraction(2, 4) f3 = Fraction(3, 4) # Test comparisons print(f1 == f2) # True (1/2 == 2/4) print(f1 < f3) # True (1/2 < 3/4) print(f3 > f2) # True (3/4 > 1/2) print(f1 <= f2) # True (1/2 <= 1/2)
Subscript and Slicing
class ShoppingCart: def __init__(self): self.items = {} def add_item(self, item, quantity=1): self.items[item] = self.items.get(item, 0) + quantity # Subscript access def __getitem__(self, item): return self.items.get(item, 0) def __setitem__(self, item, quantity): if quantity <= 0: if item in self.items: del self.items[item] else: self.items[item] = quantity def __delitem__(self, item): if item in self.items: del self.items[item] def __contains__(self, item): return item in self.items def __len__(self): return len(self.items) def __str__(self): return ", ".join(f"{qty}x {item}" for item, qty in self.items.items()) or "Empty" # Create a shopping cart cart = ShoppingCart() # Add items using method cart.add_item("Apple", 3) cart.add_item("Banana", 2) # Subscript access print(cart["Apple"]) # 3 # Update quantity cart["Apple"] = 5 print(cart["Apple"]) # 5 # Check if item exists print("Banana" in cart) # True # Delete item del cart["Banana"] print("Banana" in cart) # False
Context Managers
class Timer: def __init__(self, name): self.name = name def __enter__(self): import time self.start_time = time.time() print(f"Starting {self.name}...") return self def __exit__(self, exc_type, exc_val, exc_tb): import time elapsed = time.time() - self.start_time print(f"{self.name} took {elapsed:.2f} seconds") # Return False to propagate exceptions, True to suppress them return False # Using the context manager with Timer("long_operation"): # Simulate a long operation import time time.sleep(1.5) # Output: # Starting long_operation... # long_operation took 1.50 seconds

3.2 Common Special Methods for Operator Overloading

Operation Method Description
Addition __add__(self, other) Implements + operator
Subtraction __sub__(self, other) Implements - operator
Multiplication __mul__(self, other) Implements * operator
Division __truediv__(self, other) Implements / operator
Equality __eq__(self, other) Implements == operator
Less than __lt__(self, other) Implements < operator
String representation __str__(self) Implements str(obj)
Length __len__(self) Implements len(obj)
Context manager __enter__(self), __exit__(self, ...) Implements with statement
4. Duck Typing and Polymorphism

Python uses "duck typing" - if an object walks like a duck and quacks like a duck, then it must be a duck. This is a form of dynamic typing where the type or class of an object is less important than the methods it defines.

class Duck: def quack(self): return "Quack!" def fly(self): return "I'm flying!" class Person: def quack(self): return "The person imitates a duck" def fly(self): return "The person takes an airplane" def duck_duck_goose(duck_like_object): print(duck_like_object.quack()) print(duck_like_object.fly()) # Both objects can be used interchangeably duck = Duck() person = Person() duck_duck_goose(duck) # Output: # Quack! # I'm flying! duck_duck_goose(person) # Output: # The person imitates a duck # The person takes an airplane
Duck Typing vs. Inheritance:
  • Duck typing focuses on what methods/properties an object has, not its actual type
  • Inheritance creates an "is-a" relationship between classes
  • Duck typing creates a "behaves-like" relationship
  • Python's dynamic nature makes duck typing very powerful
5. Abstract Base Classes and Polymorphism

Abstract Base Classes (ABCs) can be used to define a common interface that multiple classes must implement, enabling polymorphism through inheritance.

from abc import ABC, abstractmethod class Shape(ABC): @abstractmethod def area(self): pass @abstractmethod def perimeter(self): pass def __str__(self): return f"{self.__class__.__name__} - Area: {self.area()}, Perimeter: {self.perimeter()}" class Rectangle(Shape): def __init__(self, width, height): self.width = width self.height = height def area(self): return self.width * self.height def perimeter(self): return 2 * (self.width + self.height) class Circle(Shape): def __init__(self, radius): self.radius = radius def area(self): import math return math.pi * self.radius ** 2 def perimeter(self): import math return 2 * math.pi * self.radius # Create a list of shapes shapes = [Rectangle(4, 5), Circle(3)] # Process shapes polymorphically for shape in shapes: print(shape) # Calls the appropriate area() and perimeter() # Output: # Rectangle - Area: 20, Perimeter: 18 # Circle - Area: 28.274333882308138, Perimeter: 18.84955592153876
Best Practice: Use ABCs when you want to define a common interface that multiple classes should implement. This makes your code more maintainable and self-documenting, and it helps catch errors at definition time rather than at runtime.
6. Practice Exercise

Task: Create a Simple Banking System

Implement a simple banking system with different account types that demonstrate polymorphism.

from abc import ABC, abstractmethod class Account(ABC): def __init__(self, account_number, balance=0): self.account_number = account_number self.balance = balance @abstractmethod def deposit(self, amount): pass @abstractmethod def withdraw(self, amount): pass def __str__(self): return f"Account {self.account_number}: Balance = ${self.balance:.2f}" # Implement the following classes: # 1. SavingsAccount: Can't withdraw more than balance # 2. CheckingAccount: Allows overdraft up to a limit # 3. CreditAccount: Has a credit limit and charges interest on negative balance # Test your implementation if __name__ == "__main__": # accounts = [ # SavingsAccount("SAV001", 1000), # CheckingAccount("CHK001", 500, overdraft_limit=200), # CreditAccount("CRD001", 0, credit_limit=1000, interest_rate=0.02) # ] # # for account in accounts: # print("-" * 40) # print(f"Initial: {account}") # # # Test deposit # account.deposit(200) # print(f"After deposit: {account}") # # # Test withdraw # result = account.withdraw(300) # print(f"Withdraw result: {result}") # print(f"After withdraw: {account}") pass
Requirements:
  1. Create an abstract Account class with the specified methods
  2. Implement three account types with different withdrawal behaviors
  3. Each account should have a unique account number and balance
  4. Implement the __str__ method for each class
  5. Test your implementation with different scenarios
7. Course Summary

Key Concepts Covered in Unit-V:

  1. Object-Oriented Programming: Classes, objects, and the four pillars of OOP
  2. Constructors & Special Methods: __init__, __new__, and other magic methods
  3. Inheritance: Single, multiple, multi-level, and hierarchical inheritance
  4. Abstract Base Classes: Creating interfaces with @abstractmethod
  5. Polymorphism: Function overriding and operator overloading
  6. Encapsulation: Public, protected, and private members
  7. Garbage Collection: Reference counting and circular references
Congratulations! You've completed the Python OOP course. You now have a solid understanding of object-oriented programming in Python and are ready to build complex, maintainable applications.