0% found this document useful (0 votes)
5 views141 pages

Core Python - Module 12 and 13

Uploaded by

Raja Meenakshi
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
5 views141 pages

Core Python - Module 12 and 13

Uploaded by

Raja Meenakshi
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as PDF, TXT or read online on Scribd
You are on page 1/ 141

Module 12 - Introduction to Oops

1. Oops concepts and features

Object-Oriented Programming (OOP) in Python is a paradigm based on the concept of objects,


which can contain data (attributes) and code (methods). Python's OOP implementation supports
several key concepts and features, as outlined below:

Key OOP Concepts in Python:

1. Class:
○ A blueprint for creating objects.

Example:
class Animal:
def __init__(self, name):
self.name = name

2. Object:
○ An instance of a class.

Example:
dog = Animal("Dog")

3. Encapsulation:
○ Restricting direct access to certain components of an object.
○ Achieved using private or protected attributes (prefix _ or __).

Example:
class BankAccount:
def __init__(self, balance):
self.__balance = balance
def get_balance(self):
return self.__balance

4. Inheritance:
○ Mechanism to create a new class from an existing class.
○ Supports code reusability.

Example:
class Animal:
def __init__(self, name):
self.name = name
class Dog(Animal):
def speak(self):
return f"{self.name} says Woof!"

5. Polymorphism:
○ Allows different classes to be treated as instances of the same class through a
common interface.

Example:
class Animal:
def speak(self):
pass
class Dog(Animal):
def speak(self):
return "Woof!"
class Cat(Animal):
def speak(self):
return "Meow!"
for animal in [Dog(), Cat()]:
print(animal.speak())

6. Abstraction:
○ Hiding implementation details and showing only the necessary features.
○ Achieved using abstract classes and methods (abc module).

Example:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius * self.radius

7. Method Overriding:
○ Redefining a method in a subclass.

Example:
class Parent:
def greet(self):
return "Hello from Parent!"
class Child(Parent):
def greet(self):
return "Hello from Child!"
8. Method Overloading:
○ Simulated using default arguments or variable-length arguments since Python
does not natively support method overloading.

Example:
class Calculator:
def add(self, a, b=0):
return a + b

Features of OOP in Python:

1. Dynamic Typing:
○ Objects can hold attributes dynamically, allowing flexibility.

Example:
class DynamicClass:
pass
obj = DynamicClass()
obj.new_attr = "Dynamic Attribute"

2. Multiple Inheritance:
○ A class can inherit from multiple parent classes.

Example:
class A:
pass

class B:
pass

class C(A, B):


pass

3. Dunder Methods (Magic Methods):


○ Special methods with __ prefix/suffix for operator overloading and custom
behavior.

Example:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)

4. Composition:
○ Creating complex objects by including other objects as attributes.

Example:
class Engine:
def start(self):
return "Engine starts!"
class Car:
def __init__(self):
self.engine = Engine()
def start(self):
return self.engine.start()

5. Static Methods and Class Methods:


○ Methods that do not depend on instance variables.
Example:
class Utility:
@staticmethod
def greet():
return "Hello!"
@classmethod
def info(cls):
return f"This is {cls.__name__} class."

6. Property Decorators:
○ Used to manage getter, setter, and deleter methods.

Example:
class Circle:
def __init__(self, radius):
self._radius = radius
@property
def radius(self):
return self._radius
@radius.setter
def radius(self, value):
self._radius = value

Python's OOP model combines simplicity and power, making it suitable for both beginners and
advanced developers.
2. Class and Object

Class

A class is a blueprint or template for creating objects. It defines the structure and behavior
(attributes and methods) that the objects created from it will have.

Syntax for Defining a Class

class ClassName:
# Class attributes (optional)
class_attribute = "I am a class attribute"

# Constructor to initialize objects


def __init__(self, instance_attribute):
self.instance_attribute = instance_attribute # Instance
attribute

# Methods (functions inside the class)


def display(self):
print(f"Instance Attribute: {self.instance_attribute}")

Example of a Class

class Animal:
# Constructor to initialize the name of the animal
def __init__(self, name):
self.name = name

# Method to describe the animal


def speak(self):
return f"{self.name} makes a sound."
Object

An object is an instance of a class. When a class is defined, no memory is allocated until it is


instantiated (creating an object).

Syntax for Creating an Object

object_name = ClassName(parameters)

Example of an Object

# Creating an object of the Animal class


dog = Animal("Dog")

# Accessing the object's method


print(dog.speak()) # Output: Dog makes a sound.

Key Differences Between Class and Object

Feature Class Object

Definition A blueprint or template for objects. An instance of a class.

Purpose Defines attributes and methods. Stores actual data and interacts using
methods.

Memory No memory allocated when defined. Memory is allocated upon creation.

Example class Animal: dog = Animal("Dog")


Working Example
class Car:
# Constructor to initialize attributes
def __init__(self, brand, model):
self.brand = brand
self.model = model

# Method to display car details


def details(self):
return f"Car: {self.brand} {self.model}"

# Creating objects of the Car class


car1 = Car("Toyota", "Corolla")
car2 = Car("Honda", "Civic")

# Accessing methods and attributes


print(car1.details()) # Output: Car: Toyota Corolla
print(car2.details()) # Output: Car: Honda Civic

Special Methods for Classes and Objects

● __init__(): Constructor method called when an object is created.


● __str__(): Returns a string representation of the object.
● __del__(): Destructor method called when an object is deleted.
Example:

class Person:
def __init__(self, name):
self.name = name
print(f"{self.name} is created.")

def __del__(self):
print(f"{self.name} is deleted.")

# Creating an object
p1 = Person("Alice")
del p1 # Explicitly deleting the object

This code demonstrates how classes and objects work together to create reusable and modular
programs.

3. Encapsulation ( Get & Set)

Encapsulation is one of the key principles of object-oriented programming (OOP). It restricts


direct access to an object's internal data and allows controlled access through methods,
commonly referred to as getters and setters. This ensures data integrity and hides
implementation details from the outside world.

Features of Encapsulation:

1. Data Hiding: Restricting access to some attributes of an object by making them private.
2. Controlled Access: Using getters and setters to read or modify private attributes.
3. Improved Code Maintenance: Internal implementation details can be changed without
affecting external code that interacts with the object.
How to Implement Encapsulation in Python?

1. Private Attributes:
○ Prefix attributes with __ to make them private.

Example:
class Example:
def __init__(self):
self.__private_attribute = "Private Data"

2. Getters and Setters:


○ Provide methods to access and update private attributes.

Example:
class BankAccount:
def __init__(self, balance):
self.__balance = balance # Private attribute

# Getter method
def get_balance(self):
return self.__balance

# Setter method
def set_balance(self, new_balance):
if new_balance >= 0:
self.__balance = new_balance
else:
print("Invalid balance amount!")
Working Example

class Person:
def __init__(self, name, age):
self.__name = name # Private attribute
self.__age = age # Private attribute

# 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 with validation


def set_age(self, age):
if age > 0:
self.__age = age
else:
print("Invalid age!")

# Using the class


person = Person("Alice", 30)
# Accessing data using getters
print(person.get_name()) # Output: Alice
print(person.get_age()) # Output: 30

# Modifying data using setters


person.set_name("Bob")
person.set_age(25)

# Accessing updated data


print(person.get_name()) # Output: Bob
print(person.get_age()) # Output: 25

Using the @property Decorator

Python provides a more elegant way to implement getters and setters using the @property
decorator.

Example:

class Circle:
def __init__(self, radius):
self.__radius = radius # Private attribute

# Getter
@property
def radius(self):
return self.__radius
# Setter
@radius.setter
def radius(self, radius):
if radius > 0:
self.__radius = radius
else:
print("Radius must be positive!")

# Using the class


circle = Circle(5)
print(circle.radius) # Accessing radius using the getter

circle.radius = 10 # Updating radius using the setter


print(circle.radius)

circle.radius = -5 # Invalid update

Output:

5
10
Radius must be positive!

Advantages of Getters and Setters

1. Validation: Ensures that only valid data is assigned.


2. Encapsulation: Protects the internal representation of the object.
3. Flexibility: Allows future changes to internal logic without breaking external code.
Using encapsulation with getters and setters or properties allows for controlled and secure access
to object attributes while adhering to OOP principles.

4. Abstraction

Abstraction is one of the key principles of Object-Oriented Programming (OOP). It focuses on


hiding the implementation details and showing only the essential features of an object or system
to the user. In Python, abstraction is achieved using abstract classes and interfaces.

Key Features of Abstraction:

1. Hiding Complexity:
○ The user interacts with high-level functionalities without needing to understand
the internal workings.
2. Promotes Reusability:
○ Abstract methods provide a contract that subclasses must follow, ensuring
consistent behavior across different implementations.
3. Improves Code Modularity:
○ Allows separation of concerns by defining what a class should do rather than how
it should do it.

Abstract Classes and Methods

What is an Abstract Class?

● An abstract class is a class that contains one or more abstract methods.


● It cannot be instantiated directly.
● It serves as a blueprint for other classes.

What is an Abstract Method?

● An abstract method is a method declared in a class but does not have an implementation.
● Subclasses must implement the abstract methods.
How to Implement Abstraction in Python?

Python uses the abc (Abstract Base Class) module to implement abstraction. This includes:

1. Using the ABC class as a base class.


2. Defining abstract methods using the @abstractmethod decorator.

Example: Abstract Class and Methods

from abc import ABC, abstractmethod

# Abstract class
class Animal(ABC):
# Abstract method
@abstractmethod
def sound(self):
pass

# Concrete method
def sleep(self):
return "Sleeping..."

# Concrete subclass
class Dog(Animal):
def sound(self):
return "Woof!"

class Cat(Animal):
def sound(self):
return "Meow!"

# Using the classes


dog = Dog()
print(dog.sound()) # Output: Woof!
print(dog.sleep()) # Output: Sleeping...

cat = Cat()
print(cat.sound()) # Output: Meow!

Key Points in the Example:

1. Animal is an abstract class:


○ It contains an abstract method sound().
2. Dog and Cat are concrete subclasses:
○ They provide specific implementations for the sound() method.
3. Abstract classes cannot be instantiated:
○ Attempting animal = Animal() will result in an error.

Advantages of Abstraction:

1. Improved Code Organization:


○ Developers can define common functionalities in the abstract class and enforce
consistency.
2. Enhances Flexibility:
○ Different implementations of abstract methods can be provided by subclasses.
3. Reduces Redundancy:
○ Avoids duplication by consolidating shared behavior in the abstract class.
Practical Example: Payment System

from abc import ABC, abstractmethod


class Payment(ABC):
@abstractmethod
def pay(self, amount):
pass

class CreditCardPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using Credit Card."

class PayPalPayment(Payment):
def pay(self, amount):
return f"Paid {amount} using PayPal."
# Using the classes
payment1 = CreditCardPayment()
print(payment1.pay(100)) # Output: Paid 100 using Credit Card.
payment2 = PayPalPayment()
print(payment2.pay(200)) # Output: Paid 200 using PayPal.

Summary:

● Abstraction focuses on defining what an object can do rather than how it does it.
● Abstract classes and methods in Python provide a clear structure for subclassing.
● abc module is used for defining abstract base classes and methods.
● It promotes a clear separation of responsibilities, making the code more maintainable and
scalable.
5. Data Hiding

Data Hiding is a concept in object-oriented programming (OOP) that restricts access to certain
parts of an object’s data. It ensures that the internal state of an object is shielded from direct
modification by external code, promoting encapsulation and data security.

How to Achieve Data Hiding in Python?

Python doesn’t have strict access modifiers like private, protected, and public in
languages like Java or C++. Instead, it uses naming conventions to control access:

Public Attributes: Accessible from anywhere.


self.attribute
Protected Attributes: Indicated by a single underscore (_) and are intended to be used only
within the class and its subclasses.
self._attribute
Private Attributes: Indicated by a double underscore (__) and are strongly discouraged from
being accessed outside the class.
self.__attribute

Example of Data Hiding

class Employee:
def __init__(self, name, salary):
self.name = name # Public attribute
self._bonus = 0 # Protected attribute
self.__salary = salary # Private attribute

# Public method to get the private salary


def get_salary(self):
return self.__salary
# Public method to update the private salary
def set_salary(self, salary):
if salary > 0:
self.__salary = salary
else:
print("Invalid salary!")

# Creating an instance of Employee


emp = Employee("Alice", 50000)

# Accessing public attribute


print(emp.name) # Output: Alice

# Accessing protected attribute (not recommended directly)


print(emp._bonus) # Output: 0

# Accessing private attribute (will cause an error)


# print(emp.__salary) # AttributeError

# Using getter and setter to access private attribute


print(emp.get_salary()) # Output: 50000
emp.set_salary(55000)
print(emp.get_salary()) # Output: 55000

Key Points in the Example:

1. Public Attribute (name):


○ Can be accessed and modified directly.
2. Protected Attribute (_bonus):
○ Intended for internal use or by subclasses.
○ Technically accessible but not encouraged to use directly.
3. Private Attribute (__salary):
○ Cannot be accessed directly from outside the class.
○ Accessible through getter and setter methods.

Name Mangling in Python

Python achieves data hiding for private attributes using name mangling, where it internally
renames the private attribute to include the class name.

For example:

class Employee:
def __init__(self, salary):
self.__salary = salary

emp = Employee(50000)
print(emp.__salary) # AttributeError
print(emp._Employee__salary) # Output: 50000

Though it's still technically accessible, name mangling makes it harder to accidentally access
private attributes.

Advantages of Data Hiding:

1. Protects Data Integrity:


○ Prevents external code from making arbitrary changes to sensitive data.
2. Encourages Encapsulation:
○ Keeps implementation details hidden and focuses on providing a clear interface.
3. Improves Code Maintainability:
○ Changes to internal representation don't affect external code.

Summary

● Data hiding shields critical data from being accessed or modified directly.
● It is implemented using private attributes (with __) and accessed via getter and setter
methods.
● Name mangling adds a layer of obfuscation to private attributes, making them less
accessible externally.
● Following this approach ensures robust, secure, and maintainable code.

6. Polymorphism

Polymorphism is one of the key principles of object-oriented programming (OOP). It allows


objects of different classes to be treated as objects of a common superclass. The word
"polymorphism" means "many forms," and in programming, it refers to the ability to call the
same method on different objects and get behavior specific to those objects.

Key Features of Polymorphism:

1. Method Overriding:
○ A subclass provides its own implementation of a method defined in its superclass.
2. Duck Typing:
○ In Python, polymorphism is supported through duck typing: "If it looks like a
duck and quacks like a duck, it's a duck."
○ Python focuses on an object's behavior rather than its class type.
Types of Polymorphism

1. Compile-Time Polymorphism (Method Overloading):


○ Python does not support traditional method overloading directly, but it can be
simulated using default arguments or *args/**kwargs.
2. Run-Time Polymorphism (Method Overriding):
○ Subclasses redefine a method from their parent class to provide specific behavior.

Examples of Polymorphism

1. Method Overriding

class Animal:
def sound(self):
return "Some generic sound"

class Dog(Animal):
def sound(self):
return "Woof!"

class Cat(Animal):
def sound(self):
return "Meow!"

# Polymorphic behavior
animals = [Dog(), Cat(), Animal()]

for animal in animals:


print(animal.sound())
Output:

Woof!
Meow!
Some generic sound

Here, the sound method behaves differently depending on the object calling it.

2. Duck Typing
class Bird:
def fly(self):
return "Bird is flying"

class Airplane:
def fly(self):
return "Airplane is flying"

class Rocket:
def fly(self):
return "Rocket is launching"

# Polymorphic behavior
def demonstrate_flight(obj):
print(obj.fly())

# Objects with different classes but similar behavior


objects = [Bird(), Airplane(), Rocket()]
for obj in objects:
demonstrate_flight(obj)

Output:

Bird is flying
Airplane is flying
Rocket is launching

Here, the fly method is called on objects from unrelated classes. This is duck typing in action.

3. Operator Overloading

Python allows operators like +, -, and * to be overloaded to work with custom objects.

class Point:
def __init__(self, x, y):
self.x = x
self.y = y

# Overloading the + operator


def __add__(self, other):
return Point(self.x + other.x, self.y + other.y)

def __str__(self):
return f"Point({self.x}, {self.y})"

# Using overloaded + operator


p1 = Point(2, 3)
p2 = Point(4, 5)
p3 = p1 + p2
print(p3) # Output: Point(6, 8)

Here, the + operator is redefined for the Point class to perform addition of two Point objects.

4. Method Overloading (Simulated)

Python does not support method overloading like Java or C++, but it can be simulated using
default arguments or variable-length arguments.

class Calculator:
def add(self, a, b=0, c=0):
return a + b + c

calc = Calculator()
print(calc.add(5)) # Output: 5
print(calc.add(5, 10)) # Output: 15
print(calc.add(5, 10, 15)) # Output: 30

Advantages of Polymorphism:

1. Flexibility:
○ Write code that works with objects of different types.
2. Extensibility:
○ New classes can be added with minimal changes to existing code.
3. Simplifies Code:
○ Eliminates the need for explicit type checking or conditional statements.
Summary

● Polymorphism allows the same interface or method to behave differently based on the
object calling it.
● Python supports method overriding, duck typing, and operator overloading to achieve
polymorphism.
● This principle promotes cleaner, reusable, and more maintainable code.

7. Inheritance

Inheritance is a fundamental concept in Object-Oriented Programming (OOP) that allows a


class (child class) to acquire the attributes and methods of another class (parent class). It enables
code reuse and establishes a hierarchical relationship between classes.

Key Concepts of Inheritance:

1. Parent Class (Super Class): The class whose properties and methods are inherited.
2. Child Class (Sub Class): The class that inherits properties and methods from the parent
class.
3. Code Reusability: Child classes reuse the code in the parent class, avoiding duplication.
4. Method Overriding: A child class can redefine methods from the parent class to provide
specific behavior.

Types of Inheritance

1. Single Inheritance: One child class inherits from a single parent class.
2. Multilevel Inheritance: A class inherits from a child class, forming a chain.
3. Multiple Inheritance: A class inherits from multiple parent classes.
4. Hierarchical Inheritance: Multiple child classes inherit from a single parent class.
5. Hybrid Inheritance: A combination of two or more types of inheritance.
Syntax for Inheritance
class Parent:
# Parent class definition
pass

class Child(Parent):
# Child class inherits from Parent
pass

Examples

1. Single Inheritance

class Animal:
def eat(self):
print("This animal eats food.")

class Dog(Animal):
def bark(self):
print("The dog barks.")

# Create a Dog object


dog = Dog()
dog.eat() # Inherited from Animal
dog.bark() # Defined in Dog

Output:

This animal eats food.


The dog barks.
2. Multilevel Inheritance
class Vehicle:
def start(self):
print("The vehicle starts.")

class Car(Vehicle):
def drive(self):
print("The car drives.")

class SportsCar(Car):
def race(self):
print("The sports car races.")

# Create a SportsCar object


sports_car = SportsCar()
sports_car.start() # Inherited from Vehicle
sports_car.drive() # Inherited from Car
sports_car.race() # Defined in SportsCar

Output:

The vehicle starts.


The car drives.
The sports car races.
3. Multiple Inheritance

class Flyer:
def fly(self):
print("This can fly.")

class Swimmer:
def swim(self):
print("This can swim.")

class Duck(Flyer, Swimmer):


pass

# Create a Duck object


duck = Duck()
duck.fly() # From Flyer
duck.swim() # From Swimmer

Output:

This can fly.


This can swim.

4. Hierarchical Inheritance

class Shape:
def area(self):
print("Calculating area.")

class Circle(Shape):
def radius(self):
print("Radius of the circle.")

class Rectangle(Shape):
def sides(self):
print("Sides of the rectangle.")

# Create objects of Circle and Rectangle


circle = Circle()
rectangle = Rectangle()

circle.area() # From Shape


circle.radius() # Defined in Circle

rectangle.area() # From Shape


rectangle.sides() # Defined in Rectangle

Output:

Calculating area.
Radius of the circle.
Calculating area.
Sides of the rectangle.

Overriding Methods in Inheritance

When a child class has a method with the same name as in the parent class, the child’s version
overrides the parent’s version.

class Parent:
def greet(self):
print("Hello from Parent.")

class Child(Parent):
def greet(self):
print("Hello from Child.")

# Create a Child object


child = Child()
child.greet() # Calls the overridden method in Child

Output:

Hello from Child.

Using the super() Function

The super() function allows access to the parent class's methods.

class Parent:
def greet(self):
print("Hello from Parent.")

class Child(Parent):
def greet(self):
super().greet() # Call the parent class method
print("Hello from Child.")

# Create a Child object


child = Child()
child.greet()
Output:

Hello from Parent.


Hello from Child.

Advantages of Inheritance

1. Code Reusability:
○ Avoid duplication by reusing existing functionality.
2. Extensibility:
○ Child classes can extend or customize parent functionality.
3. Maintainability:
○ Changes to the parent class propagate to child classes.

Summary

● Inheritance allows a child class to reuse, extend, or modify the behavior of a parent class.
● Python supports single, multiple, multilevel, hierarchical, and hybrid inheritance.
● Features like method overriding and the super() function provide flexibility for
customizing inherited behavior.

8. Creating a Class

In Python, a class is a blueprint for creating objects. Classes encapsulate data (attributes) and
behaviors (methods) into a single unit, making the code reusable and organized.

Syntax for Defining a Class

class ClassName:
# Class attributes and methods go here
Pass
Key Components of a Class

1. Class Name:
○ Follows the naming convention: capitalize the first letter of each word (e.g.,
MyClass).
2. Attributes:
○ Variables that hold data specific to the class or its objects.
3. Methods:
○ Functions defined inside the class to define its behaviors.
4. Constructor (__init__ method):
○ A special method used to initialize objects of the class.

Example: Creating a Simple Class

class Person:
# Constructor to initialize the object
def __init__(self, name, age):
self.name = name # Instance attribute
self.age = age # Instance attribute

# Method to display details


def display_info(self):
print(f"Name: {self.name}, Age: {self.age}")

# Creating objects of the class


person1 = Person("Alice", 30)
person2 = Person("Bob", 25)

# Accessing methods and attributes


person1.display_info() # Output: Name: Alice, Age: 30
person2.display_info() # Output: Name: Bob, Age: 25

Attributes in Classes

1. Instance Attributes

● Specific to each object.


● Defined inside the constructor using self.

2. Class Attributes

● Shared among all instances of the class.


● Defined outside the constructor.

class Employee:
company = "ABC Corp" # Class attribute

def __init__(self, name, position):


self.name = name # Instance attribute
self.position = position # Instance attribute

# Creating objects
emp1 = Employee("John", "Manager")
emp2 = Employee("Jane", "Developer")

print(emp1.company) # Output: ABC Corp (Class attribute)


print(emp2.company) # Output: ABC Corp (Class attribute)
Methods in Classes

Instance Methods

● Operate on object attributes.


● Use self as the first parameter.

Class Methods

● Operate on class attributes.


● Use @classmethod decorator and cls as the first parameter.

Static Methods

● Do not operate on instance or class attributes.


● Use @staticmethod decorator.

class Example:
class_attribute = "Class Attribute"

def __init__(self, value):


self.instance_attribute = value

@classmethod
def class_method(cls):
print(f"This is a {cls.class_attribute}")

@staticmethod
def static_method():
print("This is a static method")

# Using methods
example = Example("Instance Attribute")
example.class_method() # Output: This is a Class Attribute
Example.static_method() # Output: This is a static method

Best Practices

1. Use meaningful names for classes, attributes, and methods.


2. Follow the PEP 8 guidelines for naming conventions:
○ Classes: CamelCase
○ Attributes/Methods: snake_case
3. Keep the constructor simple; avoid adding too much logic in __init__.

Summary

● A class is a blueprint for creating objects in Python.


● It can contain attributes (data) and methods (behavior).
● Use constructors (__init__) to initialize attributes and methods to define behaviors.
● Classes support encapsulation, inheritance, and polymorphism, making Python a
powerful OOP language.

9. The Self Variable

In Python, the self variable is used in object-oriented programming (OOP) to represent the
instance of a class. It allows you to access the attributes and methods of the class in Python.

Key Features of self:

1. Represents the Current Object:


○ The self variable refers to the instance of the class on which the method is
being called.
2. Must Be Explicitly Declared:
○ It is the first parameter of instance methods in a class.
○ The name self is a convention, not a keyword. You could name it differently
(e.g., this), but it’s strongly recommended to stick to self for readability.
3. Distinguishes Instance Attributes from Local Variables:
○ When you want to access or modify attributes of an instance, you use
self.attribute_name.

Why Use self?

● It binds the instance attributes and methods to the particular object being created or used.
● Without self, Python would not know whether a variable refers to an instance attribute
or a local variable.

Example of self

Basic Example:

class Person:
def __init__(self, name, age):
self.name = name # Instance attribute
self.age = age # Instance attribute

def greet(self):
print(f"Hello, my name is {self.name} and I am
{self.age} years old.")

# Create an object
person = Person("Alice", 30)

# Access the method


person.greet() # Output: Hello, my name is Alice and I am 30
years old.

Explanation:

1. self.name and self.age are instance attributes bound to the current object.
2. When greet() is called on the person object, self refers to person.

Accessing Methods via self

class Calculator:
def add(self, a, b):
return a + b

def multiply(self, a, b):


return a * b

def calculate(self, a, b):


addition = self.add(a, b) # Call add() using self
multiplication = self.multiply(a, b) # Call multiply()
using self
return addition, multiplication

# Create an object
calc = Calculator()

# Use the calculate method


result = calc.calculate(3, 4)
print(result) # Output: (7, 12)
Explanation:

● The self.add() and self.multiply() calls allow the methods to interact within
the same object.

Changing self Name (Not Recommended)

Although it’s possible to rename self, it’s discouraged for the sake of convention and
readability.

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

def greet(this):
print(f"Hello, my name is {this.name}.")

# Create an object
person = Person("Bob")
person.greet() # Output: Hello, my name is Bob.

Common Mistakes with self

1. Forgetting self in Method Definitions

class Person:
def greet(): # Missing self
print("Hello!")
# Will raise an error
person = Person()
person.greet()
Error:

TypeError: Person.greet() takes 0 positional arguments but 1 was


given

Solution: Always include self in instance methods.

2. Forgetting to Use self for Attributes

class Person:
def __init__(self, name):
name = name # Wrong: no self

def greet(self):
print(f"Hello, my name is {name}") # Error: name is
undefined
person = Person("Alice")
person.greet()

Solution: Use self.name to bind the variable to the object:

self.name = name

Summary

● self is a reference to the current instance of the class.


● It’s used to access instance attributes and methods.
● Always include self as the first parameter in instance methods.
● Following conventions like using self improves code readability and consistency.

10. Constructor
A constructor is a special method in Python used to initialize an object’s attributes when the
object is created. It is defined using the __init__ method. The constructor is automatically
invoked when an object is instantiated.

Key Features of a Constructor:

1. Special Method:
○ The __init__ method is reserved for object initialization in Python.
2. Automatic Invocation:
○ The constructor is called immediately after an object of the class is created.
3. self Parameter:
○ The first parameter of the constructor is self, which refers to the instance being
created.
4. Custom Initialization:
○ Allows setting initial values for attributes when the object is instantiated.

Syntax

class ClassName:
def __init__(self, parameters):
# Initialize attributes here
pass

Examples

1. Basic Constructor Example

class Person:
def __init__(self, name, age):
self.name = name # Initialize instance attribute
self.age = age # Initialize instance attribute
def display_info(self):
print(f"Name: {self.name}, Age: {self.age}")

# Create an object
person1 = Person("Alice", 30)
person1.display_info() # Output: Name: Alice, Age: 30

2. Constructor with Default Values

You can provide default values for attributes in the constructor.

class Person:
def __init__(self, name="Unknown", age=0):
self.name = name
self.age = age

# Create objects with and without arguments


person1 = Person("Bob", 25)
person2 = Person() # Default values used

print(f"{person1.name}, {person1.age}") # Output: Bob, 25


print(f"{person2.name}, {person2.age}") # Output: Unknown, 0

3. Constructor Without Parameters

If a class does not require attributes during initialization, the constructor can be defined without
additional parameters.

class Greeting:
def __init__(self):
print("Hello! Welcome to Python.")
# Creating an object
greet = Greeting() # Output: Hello! Welcome to Python.

4. Multiple Objects with Different Data

Constructors allow the creation of multiple objects with unique attributes.

class Car:
def __init__(self, make, model):
self.make = make
self.model = model

# Create multiple car objects


car1 = Car("Toyota", "Corolla")
car2 = Car("Honda", "Civic")

print(f"{car1.make}, {car1.model}") # Output: Toyota, Corolla


print(f"{car2.make}, {car2.model}") # Output: Honda, Civic

Types of Constructors in Python

1. Default Constructor:
○ A constructor that does not take any parameters except self.
class Example:

def __init__(self):
print("Default constructor called.")
obj = Example() # Output: Default constructor called.

2. Parameterized Constructor:
○ A constructor that accepts arguments to initialize object attributes.
class Example:

def __init__(self, value):


self.value = value
obj = Example(42)
print(obj.value) # Output: 42

Destructors

While constructors initialize objects, destructors clean up resources when objects are deleted.
Python uses the __del__ method for destructors.

class Example:
def __init__(self):
print("Constructor is called.")

def __del__(self):
print("Destructor is called.")
obj = Example()
del obj # Output: Destructor is called.

Best Practices

1. Keep It Simple:
○ Use the constructor only for initializing attributes.
2. Avoid Heavy Logic:
○ Complex logic in the constructor can make the class harder to use.
3. Provide Defaults:
○ Use default values to make the class flexible for different scenarios.
Summary

● The __init__ method serves as the constructor in Python.


● It is automatically called when an object is created and is used to initialize attributes.
● Constructors can be parameterized, have default values, or even be empty based on the
requirements.
● They enhance the flexibility and usability of classes in Python.

11. Namespaces

A namespace in Python is a container that holds a collection of identifiers (e.g., variable names,
function names, class names) and maps them to their corresponding objects. It ensures that
objects in a program are uniquely identified and avoids naming conflicts.

Types of Namespaces

Python provides three main types of namespaces:

1. Built-in Namespace:
○ Contains the names of built-in functions and objects provided by Python, such as
print, len, int, list, etc.
○ Available as soon as Python is started.
2. Global Namespace:
○ Contains names defined at the top level of a script or module.
○ Global variables and functions belong to this namespace.
3. Local Namespace:
○ Created when a function is called and holds names defined within the function.
○ It is temporary and is destroyed once the function call ends.

Scope of a Namespace

The scope refers to the area of the code where a namespace is directly accessible. Python follows
the LEGB rule to determine the scope:
1. Local: Names defined inside a function or a method.
2. Enclosing: Names in the enclosing functions (if nested).
3. Global: Names defined at the top level of a script or module.
4. Built-in: Names built into Python, such as print, int.

Examples

1. Built-in Namespace

# Using a built-in function


x = max(10, 20)
print(x) # Output: 20

Names like max and print come from the built-in namespace.

2. Global Namespace

x = 10 # Global variable

def display():
print(x) # Accessing global variable

display() # Output: 10

Here, x belongs to the global namespace.

3. Local Namespace

def my_function():
y = 20 # Local variable
print(y)

my_function() # Output: 20
# print(y) # Error: NameError: name 'y' is not defined
y is only accessible within my_function and does not exist outside it.

4. LEGB Rule

x = "global"

def outer_function():
x = "enclosing"

def inner_function():
x = "local"
print(x) # Local scope is accessed first

inner_function()

outer_function() # Output: local

Python resolves names using the LEGB order.

Accessing Namespaces

1. globals():
○ Returns a dictionary representing the current global namespace.

Example:
x = 10
print(globals()) # Displays all global variables and their
values
2. locals():
○ Returns a dictionary representing the current local namespace.

Example:
def my_function():
y = 20
print(locals()) # Displays all local variables and their
values
my_function()

3. dir():
○ Lists all the names in a namespace.

Example:
print(dir()) # Lists all the names in the current global
namespace

Modifying Global Variables

To modify a global variable inside a function, use the global keyword.

x = 5

def modify_global():
global x
x = 10

modify_global()
print(x) # Output: 10
Nested Functions and Enclosing Scope

To modify an enclosing variable, use the nonlocal keyword.

def outer_function():
x = "enclosing"

def inner_function():
nonlocal x
x = "modified enclosing"
print(x)

inner_function()
print(x)

outer_function()
# Output:
# modified enclosing
# modified enclosing

Namespace Collision

Using the same name in different namespaces avoids conflicts. For example:

x = 10 # Global

def my_function():
x = 20 # Local
print(x)
my_function() # Output: 20
print(x) # Output: 10

Summary

● Namespace: A mapping between names and objects.


● Python provides built-in, global, and local namespaces.
● Use the LEGB rule to resolve the scope of a variable.
● Use globals(), locals(), and dir() to inspect namespaces.
● The global and nonlocal keywords allow modification of variables outside the local
scope.

12. Passing Members of One Class to Another Class

Passing members of one class to another in Python is a common practice when creating modular
and reusable code. This allows one class to access the attributes and methods of another class
through composition, aggregation, or inheritance. Below are various ways to achieve this:

1. Using Composition

Composition involves creating objects of one class inside another class. This allows the second
class to use the attributes and methods of the first class.

class Address:
def __init__(self, city, state):
self.city = city
self.state = state

def get_address(self):
return f"{self.city}, {self.state}"
class Person:
def __init__(self, name, address):
self.name = name
self.address = address # Passing an Address object

def display_info(self):
print(f"Name: {self.name}")
print(f"Address: {self.address.get_address()}") #
Accessing Address methods

# Create an Address object


address = Address("Hyderabad", "Telangana")

# Pass the Address object to the Person class


person = Person("Manjunath", address)
person.display_info()
# Output:
# Name: Manjunath
# Address: Hyderabad, Telangana

2. Using Aggregation

Aggregation is a more loosely coupled relationship where one class holds a reference to another
class. The referenced class can exist independently.

class Department:
def __init__(self, name):
self.name = name
class Employee:
def __init__(self, emp_name, department):
self.emp_name = emp_name
self.department = department # Aggregated object

def display_info(self):
print(f"Employee Name: {self.emp_name}")
print(f"Department: {self.department.name}")

# Create a Department object


dept = Department("Software Development")

# Pass the Department object to the Employee class


employee = Employee("Alice", dept)
employee.display_info()
# Output:
# Employee Name: Alice
# Department: Software Development

3. Using Inheritance

Inheritance allows a class (child) to inherit the members of another class (parent). In this case, no
explicit passing is required since the child class has direct access to the parent class's attributes
and methods.

class Vehicle:
def __init__(self, make, model):
self.make = make
self.model = model

def vehicle_info(self):
print(f"Make: {self.make}, Model: {self.model}")

class Car(Vehicle):
def __init__(self, make, model, doors):
super().__init__(make, model) # Call the parent
constructor
self.doors = doors

def car_info(self):
self.vehicle_info() # Access parent method
print(f"Doors: {self.doors}")

# Create a Car object


car = Car("Toyota", "Corolla", 4)
car.car_info()
# Output:
# Make: Toyota, Model: Corolla
# Doors: 4

4. Passing Individual Members Explicitly

Instead of passing entire objects, you can pass specific attributes or methods from one class to
another.

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

class Payroll:
def __init__(self, employee_name, employee_id, salary):
self.employee_name = employee_name
self.employee_id = employee_id
self.salary = salary

def display_payroll(self):
print(f"Employee: {self.employee_name}, ID:
{self.employee_id}, Salary: {self.salary}")

# Create an Employee object


emp = Employee("John", 101)

# Pass specific attributes of Employee to Payroll


payroll = Payroll(emp.emp_name, emp.emp_id, 50000)
payroll.display_payroll()
# Output:
# Employee: John, ID: 101, Salary: 50000

5. Combining Multiple Approaches

You can combine composition, aggregation, and direct member passing depending on the
complexity of the relationship.

class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower

class Car:
def __init__(self, make, model, engine):
self.make = make
self.model = model
self.engine = engine # Composition

def car_details(self):
print(f"Make: {self.make}, Model: {self.model},
Horsepower: {self.engine.horsepower}")

# Create an Engine object


engine = Engine(150)

# Pass Engine to Car


car = Car("Honda", "Civic", engine)
car.car_details()
# Output:
# Make: Honda, Model: Civic, Horsepower: 150

Advantages

1. Code Reusability: Modular design allows the reuse of classes in different contexts.
2. Flexibility: Classes can interact dynamically depending on how members are passed.
3. Encapsulation: Composition and aggregation help encapsulate behavior within distinct
classes.
Choosing the Right Approach

● Use Inheritance when the relationship is "is-a" (e.g., Car is a Vehicle).


● Use Composition when the relationship is "has-a" (e.g., Car has an Engine).
● Use Aggregation for loosely coupled relationships (e.g., Employee belongs to a
Department).

13. Inner Classes

An inner class (also known as a nested class) is a class defined inside another class. Inner
classes are often used when a class logically depends on the enclosing class, providing an
additional layer of encapsulation.

Why Use Inner Classes?

1. Logical Grouping: When a class should exist only within the context of another class.
2. Encapsulation: Helps hide the inner class from external access.
3. Tighter Relationship: Demonstrates a strong association between the two classes.

Defining and Using an Inner Class

Example 1: Basic Inner Class

class OuterClass:
def __init__(self, outer_name):
self.outer_name = outer_name

class InnerClass:
def __init__(self, inner_name):
self.inner_name = inner_name

def display_inner(self):
print(f"Inner Name: {self.inner_name}")

# Creating an instance of the inner class


outer = OuterClass("Outer Example")
inner = outer.InnerClass("Inner Example")
inner.display_inner() # Output: Inner Name: Inner Example

Types of Inner Classes

There are two main types of inner classes:

1. Regular Inner Class: Can access only static or explicitly passed members of the outer
class.
2. Inner Class with Access to Outer Class Members: Uses the outer class instance to
access its members.

Example 2: Accessing Outer Class Members

class OuterClass:
def __init__(self, outer_name):
self.outer_name = outer_name

def display_outer(self):
print(f"Outer Name: {self.outer_name}")

class InnerClass:
def __init__(self, outer_instance, inner_name):
self.outer_instance = outer_instance
self.inner_name = inner_name

def display_names(self):
# Access outer class's members through the outer
instance
self.outer_instance.display_outer()
print(f"Inner Name: {self.inner_name}")

# Create an instance of the outer class


outer = OuterClass("Outer Example")

# Create an instance of the inner class and link it to the outer


instance
inner = outer.InnerClass(outer, "Inner Example")
inner.display_names()
# Output:
# Outer Name: Outer Example
# Inner Name: Inner Example

Static Inner Classes

Static inner classes do not depend on the enclosing class instance and can be used independently.

Example 3: Static Inner Class

class OuterClass:
outer_name = "Outer Static Example"

class InnerClass:
@staticmethod
def display():
print("Inner Static Method Called")
# Call the inner class's method without creating an outer class
instance
OuterClass.InnerClass.display()
# Output: Inner Static Method Called

Use Case Examples

Example 4: A Car with an Engine (Inner Class)

class Car:
def __init__(self, make, model):
self.make = make
self.model = model
self.engine = self.Engine("V8") # Creating an Engine
instance

def display_car(self):
print(f"Car: {self.make} {self.model}")
self.engine.display_engine()

class Engine:
def __init__(self, engine_type):
self.engine_type = engine_type

def display_engine(self):
print(f"Engine Type: {self.engine_type}")

# Create a Car object


car = Car("Ford", "Mustang")
car.display_car()
# Output:
# Car: Ford Mustang
# Engine Type: V8

Example 5: Bank and Account (Inner Class)

class Bank:
def __init__(self, name):
self.name = name
self.accounts = [] # List to store accounts

def add_account(self, account_holder, balance):


account = self.Account(account_holder, balance)
self.accounts.append(account)

def display_accounts(self):
print(f"Bank: {self.name}")
for account in self.accounts:
account.display_account()

class Account:
def __init__(self, account_holder, balance):
self.account_holder = account_holder
self.balance = balance

def display_account(self):
print(f"Account Holder: {self.account_holder},
Balance: ${self.balance}")

# Create a Bank object


bank = Bank("State Bank")

# Add accounts
bank.add_account("Alice", 5000)
bank.add_account("Bob", 3000)

# Display accounts
bank.display_accounts()
# Output:
# Bank: State Bank
# Account Holder: Alice, Balance: $5000
# Account Holder: Bob, Balance: $3000

Key Points

1. Instantiation:
○ To create an instance of the inner class, you usually need an instance of the outer
class.
○ Use outer_instance.InnerClass().
2. Encapsulation:
○ Inner classes can help encapsulate logic that is specific to the outer class.
3. Static vs Regular Inner Class:
○ Static inner classes do not need an instance of the outer class.
○ Regular inner classes may require an instance of the outer class for member
access.
4. Strong Association:
○ Inner classes demonstrate a strong relationship with the outer class, often
signifying a "part-of" relationship.

When to Use Inner Classes

● When a class is tightly coupled to another class.


● To logically group code and make it easier to maintain.
● When the inner class should not be accessed independently outside the context of the
outer class.

14. Instance variable & Methods, Class Variable & Methods

In Python, instance variables, class variables, instance methods, and class methods are
foundational concepts that relate to object-oriented programming. Here’s a detailed explanation:

1. Instance Variables and Methods

Instance Variables

● Variables that belong to an instance of a class.


● Each object (instance) has its own copy of these variables.
● Defined using self.variable_name in the constructor or other instance methods.
● Scope: Accessible only by the object they are tied to.

Instance Methods

● Methods that operate on instance variables.


● The first parameter is always self, which refers to the instance calling the method.
● Can access and modify instance variables.

Example: Instance Variables and Methods

class Person:
def __init__(self, name, age):
self.name = name # Instance variable
self.age = age # Instance variable

def display_info(self): # Instance method


print(f"Name: {self.name}, Age: {self.age}")

# Creating two instances of the Person class


person1 = Person("Alice", 30)
person2 = Person("Bob", 25)

# Accessing instance variables


print(person1.name) # Output: Alice
print(person2.age) # Output: 25

# Calling an instance method


person1.display_info() # Output: Name: Alice, Age: 30
person2.display_info() # Output: Name: Bob, Age: 25

2. Class Variables and Methods

Class Variables

● Variables that belong to the class itself, shared across all instances.
● Defined directly within the class, outside any methods.
● Accessible using the class name or an instance of the class.
● Shared by all instances of the class, meaning changes affect all instances.
Class Methods

● Methods that operate on class variables.


● The first parameter is cls, which refers to the class itself.
● Defined using the @classmethod decorator.
● Can modify class variables but not instance variables directly.

Example: Class Variables and Methods


class Company:
company_name = "TechCorp" # Class variable

def __init__(self, employee_name):


self.employee_name = employee_name # Instance variable

@classmethod
def change_company_name(cls, new_name): # Class method
cls.company_name = new_name

def display_employee_info(self): # Instance method


print(f"Employee: {self.employee_name}, Company:
{Company.company_name}")

# Creating instances
emp1 = Company("Alice")
emp2 = Company("Bob")

# Accessing class variables


print(emp1.company_name) # Output: TechCorp
print(emp2.company_name) # Output: TechCorp
# Changing the class variable using a class method
Company.change_company_name("NextGenTech")
print(emp1.company_name) # Output: NextGenTech
print(emp2.company_name) # Output: NextGenTech

# Displaying employee information


emp1.display_employee_info() # Output: Employee: Alice,
Company: NextGenTech
emp2.display_employee_info() # Output: Employee: Bob, Company:
NextGenTech

3. Static Methods

● A static method is a utility function that belongs to the class but doesn’t operate on
instance or class variables.
● Doesn’t take self or cls as the first parameter.
● Defined using the @staticmethod decorator.
● Typically used for performing operations that don’t depend on instance or class data.

Example: Static Methods

class MathOperations:
@staticmethod
def add(x, y): # Static method
return x + y

@staticmethod
def subtract(x, y): # Static method
return x - y
# Calling static methods
print(MathOperations.add(10, 5)) # Output: 15
print(MathOperations.subtract(10, 5)) # Output: 5

Key Differences

Feature Instance Class Variables Instance Class Static


Variables Methods Methods Methods

Belongs Instance Class Instance Class Class


To (Object) (Object)

Accesse self.varia ClassName.v self.me ClassName. ClassName.


d Using ble_name ariable_nam thod() method() or method()
e or cls.method
self.variab ()
le_name

Shared No Yes No Yes Yes


Across
Instance
s

Defined Inside Outside any Regular @classmeth @staticmet


Using __init__ or method method od and cls as hod
other instance with self the first
methods as the first parameter
parameter

Scope Unique to each Shared across all Operates Operates on Operates


instance instances on class variables independently
instance
variables
4. Combining All Concepts

class Account:
bank_name = "Global Bank" # Class variable

def __init__(self, account_holder, balance):


self.account_holder = account_holder # Instance
variable
self.balance = balance # Instance variable

def deposit(self, amount): # Instance method


self.balance += amount
print(f"Deposited {amount}. New balance:
{self.balance}")

@classmethod
def change_bank_name(cls, new_name): # Class method
cls.bank_name = new_name

@staticmethod
def validate_amount(amount): # Static method
if amount > 0:
return True
else:
print("Invalid amount!")
return False

# Create accounts
acc1 = Account("Alice", 5000)
acc2 = Account("Bob", 3000)

# Accessing and modifying class variable


print(Account.bank_name) # Output: Global Bank
Account.change_bank_name("NextGen Bank")
print(Account.bank_name) # Output: NextGen Bank

# Using instance methods


acc1.deposit(1000) # Output: Deposited 1000. New balance: 6000
acc2.deposit(500) # Output: Deposited 500. New balance: 3500

# Using static method


print(Account.validate_amount(200)) # Output: True
print(Account.validate_amount(-50)) # Output: Invalid amount!
False

Summary

● Instance Variables are tied to individual objects, while Class Variables are shared
across the class.
● Instance Methods manipulate instance data.
● Class Methods manipulate class-level data.
● Static Methods provide utility functions that neither manipulate instance nor class data.
Module 13 - Inheritance and Polymorphism
1. Constructors in Inheritance

In Python, when a class inherits from a parent class, the constructors (__init__ methods) of
both the parent and child classes play specific roles in initializing the objects. Below is a detailed
explanation of how constructors work in inheritance:

Key Points

1. Calling the Parent Class Constructor:


○ The child class does not automatically call the constructor of the parent class.
○ To initialize the parent class, the super() function or an explicit call to the
parent class’s __init__ method is required.
2. Order of Initialization:
○ The parent class constructor should be called before performing initialization in
the child class constructor to ensure proper setup.
3. Use of super():
○ super() is a built-in function used to access the parent class methods (including
the constructor) from the child class.
○ Ensures compatibility in multiple inheritance scenarios.

Example 1: Single Inheritance with Parent Constructor Call

class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

class Child(Parent):
def __init__(self, name, age):
super().__init__(name) # Call the parent constructor
self.age = age
print(f"Child Constructor: Age = {self.age}")

# Creating an object of Child


child = Child("Alice", 10)
# Output:
# Parent Constructor: Name = Alice
# Child Constructor: Age = 10

Example 2: Explicit Call to Parent Constructor

Instead of super(), you can directly call the parent class constructor.

class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

class Child(Parent):
def __init__(self, name, age):
Parent.__init__(self, name) # Explicit call to parent
constructor
self.age = age
print(f"Child Constructor: Age = {self.age}")

# Creating an object of Child


child = Child("Bob", 12)
# Output:
# Parent Constructor: Name = Bob
# Child Constructor: Age = 12

Example 3: Multiple Inheritance with super()

In multiple inheritance, super() ensures the proper order of initialization based on the Method
Resolution Order (MRO).

class Parent1:
def __init__(self):
print("Parent1 Constructor")

class Parent2:
def __init__(self):
print("Parent2 Constructor")

class Child(Parent1, Parent2):


def __init__(self):
super().__init__() # Resolves using MRO
print("Child Constructor")

# Creating an object of Child


child = Child()
# Output:
# Parent1 Constructor
# Child Constructor
Note: Only Parent1’s constructor is called because of the MRO in this case. To
explicitly call both constructors, you can use direct calls.

Example 4: Overriding Parent Constructor in Child Class

If the child class defines its own constructor, it overrides the parent class constructor. The parent
constructor is not called unless explicitly invoked.

class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

class Child(Parent):
def __init__(self, name, age):
# Parent's constructor is not called automatically
self.age = age
print(f"Child Constructor: Name = {name}, Age =
{self.age}")

# Creating an object of Child


child = Child("Charlie", 15)
# Output:
# Child Constructor: Name = Charlie, Age = 15

If you want both constructors to run, use super() or direct calls to the parent class constructor.

Example 5: Parent and Child with Default Constructors

If a class does not define its own constructor, the parent class constructor is called automatically.
class Parent:
def __init__(self):
print("Parent Constructor")

class Child(Parent):
pass # No constructor defined

# Creating an object of Child


child = Child()
# Output:
# Parent Constructor

Example 6: Constructor with Default and Non-Default Parameters

class Parent:
def __init__(self, name="Default"):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

class Child(Parent):
def __init__(self, name, age):
super().__init__(name) # Pass name to Parent's
constructor
self.age = age
print(f"Child Constructor: Age = {self.age}")

# Creating an object of Child


child = Child("David", 20)
# Output:
# Parent Constructor: Name = David
# Child Constructor: Age = 20

Example 7: Constructor with Multiple Inheritance and super()

class Grandparent:
def __init__(self):
print("Grandparent Constructor")

class Parent(Grandparent):
def __init__(self):
super().__init__()
print("Parent Constructor")

class Child(Parent):
def __init__(self):
super().__init__()
print("Child Constructor")

# Creating an object of Child


child = Child()
# Output:
# Grandparent Constructor
# Parent Constructor
# Child Constructor
Best Practices

1. Use super() instead of directly calling the parent class constructor to ensure
compatibility with multiple inheritance.
2. Always call the parent class constructor before initializing the child class’s specific
attributes.
3. Keep the parent constructor parameterized or default, depending on the expected
flexibility in inheritance.
4. Avoid overriding parent constructors unnecessarily unless custom logic is required.

Summary

Feature Behavior

Parent Constructor Call Must be explicitly called using super() or


Parent.__init__() in the child class.

Child Overriding Parent The child constructor overrides the parent constructor.

Default Constructor If no child constructor is defined, the parent constructor is


used.

Multiple Inheritance super() ensures proper initialization according to the MRO.

By understanding constructors in inheritance, you can design your classes to be more robust,
flexible, and compatible in Python programs.

2. Overriding Super Class Constructors and Methods

When a subclass inherits from a superclass, it can override the superclass’s constructors
(__init__) and methods. This allows the subclass to provide its own implementation while
still optionally using the parent class’s behavior.
1. Overriding the Superclass Constructor

● In a subclass, the __init__ method of the superclass is not automatically called.


● If you want to include the initialization of the superclass, you must explicitly call it using
super().__init__() or ParentClass.__init__().

Example: Overriding Constructor with super()

class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

class Child(Parent):
def __init__(self, name, age):
# Call the parent constructor
super().__init__(name)
self.age = age
print(f"Child Constructor: Age = {self.age}")

# Creating an object of Child


child = Child("Alice", 25)
# Output:
# Parent Constructor: Name = Alice
# Child Constructor: Age = 25

Example: Overriding Constructor Without super()

If the child class does not call the parent class constructor, the parent class’s initialization logic
will not execute.
class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

class Child(Parent):
def __init__(self, name, age):
self.age = age # No call to Parent's constructor
print(f"Child Constructor: Name = {name}, Age =
{self.age}")

# Creating an object of Child


child = Child("Bob", 30)
# Output:
# Child Constructor: Name = Bob, Age = 30

Example: Overriding Constructor with Custom Logic

The child class can add additional initialization logic.

class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

class Child(Parent):
def __init__(self, name, age):
super().__init__(name) # Call Parent constructor
self.age = age
self.role = "Student" # Additional logic
print(f"Child Constructor: Age = {self.age}, Role =
{self.role}")

child = Child("Charlie", 20)


# Output:
# Parent Constructor: Name = Charlie
# Child Constructor: Age = 20, Role = Student

2. Overriding Superclass Methods

When a method in a subclass has the same name as a method in the superclass, the subclass’s
method overrides the superclass method. However, the subclass can still call the overridden
method using super().

Example: Basic Method Overriding

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

class Child(Parent):
def greet(self): # Overriding the greet method
print("Hello from Child")

# Creating objects
parent = Parent()
child = Child()

parent.greet() # Output: Hello from Parent


child.greet() # Output: Hello from Child

Example: Overriding and Using super()

The subclass can call the overridden method of the superclass using super().

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

class Child(Parent):
def greet(self): # Overriding the greet method
super().greet() # Call the Parent's greet method
print("Hello from Child")

child = Child()
child.greet()
# Output:
# Hello from Parent
# Hello from Child

Example: Overriding Methods with Additional Logic

class Parent:
def calculate(self, x, y):
return x + y

class Child(Parent):
def calculate(self, x, y): # Overriding the calculate
method
result = super().calculate(x, y) # Use the parent logic
return result * 2 # Additional logic

child = Child()
print(child.calculate(3, 5)) # Output: 16

3. Combining Constructor and Method Overriding

class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

def greet(self):
print(f"Hello, I am {self.name}")

class Child(Parent):
def __init__(self, name, age):
super().__init__(name) # Call Parent constructor
self.age = age
print(f"Child Constructor: Age = {self.age}")

def greet(self): # Override greet method


super().greet() # Call Parent's greet
print(f"I am {self.age} years old")

child = Child("Daisy", 10)


child.greet()
# Output:
# Parent Constructor: Name = Daisy
# Child Constructor: Age = 10
# Hello, I am Daisy
# I am 10 years old

4. Overriding in Multiple Inheritance

When using multiple inheritance, Python resolves the method to call based on the Method
Resolution Order (MRO).

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

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

class Child(Parent1, Parent2):


def greet(self):
super().greet() # Resolves to Parent1 due to MRO
print("Hello from Child")

child = Child()
child.greet()
# Output:
# Hello from Parent1
# Hello from Child
Key Points on Overriding

1. Constructor Overriding:
○ Use super().__init__() to include the parent’s initialization.
○ You can add additional attributes or logic in the child constructor.
2. Method Overriding:
○ Methods in the child class with the same name as in the parent override the parent
method.
○ Use super().method_name() to call the parent class’s version.
3. Multiple Inheritance:
○ In cases of multiple inheritance, Python uses MRO to determine the order of calls.
○ Use super() for a clean resolution.

Summary Table

Feature Description

Constructor Child class provides its own constructor while optionally calling
Overriding parent’s constructor.

Method Overriding Child class provides its own implementation of a method with the
same name.

Use of super() Allows the child class to access the parent’s methods and
constructors.

MRO Determines the order in which methods are resolved in multiple


inheritance.

By effectively overriding constructors and methods, you can enhance the behavior of subclasses
while leveraging the functionality of their parent classes.
3. The super() Method

The super() function in Python is used to call a method from a parent class. This is
particularly useful when working with inheritance, allowing the child class to invoke methods
from the parent class (including constructors) while still being able to override or extend the
functionality. The super() function is also important for ensuring correct behavior in multiple
inheritance.

Syntax of super()
super().method_name(arguments)

● super() without arguments refers to the parent class in the method resolution order
(MRO).
● method_name is the name of the method you want to call from the parent class.
● arguments are the parameters passed to the method.

Use Cases of super()

1. Calling the Parent Class Constructor: In a child class constructor, super() is used to
call the parent class constructor to initialize attributes from the parent.
2. Calling Parent Class Methods: If the child class overrides a method, super() can be
used to call the original method in the parent class, allowing the child to extend or modify
the behavior.
3. Multiple Inheritance: In multiple inheritance, super() helps maintain the correct
order of method calls according to the Method Resolution Order (MRO).

1. Calling the Parent Class Constructor

Using super() is the recommended way to call the parent class constructor from the child
class, especially in cases where the child class needs to extend the functionality.

Example: Calling Parent Constructor


class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: Name = {self.name}")

class Child(Parent):
def __init__(self, name, age):
super().__init__(name) # Calls Parent's __init__
constructor
self.age = age
print(f"Child Constructor: Age = {self.age}")

# Creating an object of Child


child = Child("Alice", 25)
# Output:
# Parent Constructor: Name = Alice
# Child Constructor: Age = 25

Here, super().__init__(name) ensures that the Parent class constructor is called to


initialize the name attribute before the child class’s specific logic is executed.

2. Calling Parent Class Methods

When a method in the child class has the same name as a method in the parent class, the method
in the child class overrides the one in the parent. However, you can still call the overridden
method from the parent class using super().

Example: Calling Parent Method Using super()

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

class Child(Parent):
def greet(self):
super().greet() # Calls Parent's greet method
print("Hello from Child")

# Creating an object of Child


child = Child()
child.greet()
# Output:
# Hello from Parent
# Hello from Child

Here, super().greet() calls the greet method from the parent class, allowing the child
class to extend the behavior by adding its own logic after calling the parent’s method.

3. Using super() in Multiple Inheritance

In the case of multiple inheritance, super() follows the Method Resolution Order (MRO) to
ensure that methods from multiple parent classes are called in a consistent and predictable way.

Example: Multiple Inheritance with super()

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

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

class Child(Parent1, Parent2):


def greet(self):
super().greet() # Calls Parent1's greet method due to
MRO
print("Hello from Child")

child = Child()
child.greet()
# Output:
# Hello from Parent1
# Hello from Child

In this example, Python uses the MRO to resolve the method call. Since Parent1 appears
before Parent2 in the inheritance chain, super().greet() calls the greet() method
from Parent1.

4. Using super() Without Arguments

In Python 3, super() can be used without any arguments to automatically refer to the parent
class. This is particularly useful when you are inside a class method and want to call a method
from the parent class, without explicitly referring to the parent class name.

Example: super() Without Arguments

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

class Child(Parent):
def greet(self):
super().greet() # Calls Parent's greet method
print("Hello from Child")

child = Child()
child.greet()
# Output:
# Hello from Parent
# Hello from Child

Using super() without arguments makes the code more maintainable, as it doesn't require
changing the parent class name if the class hierarchy changes.

5. Multiple Inheritance and super()

When working with multiple inheritance, super() follows the MRO to ensure that methods
from all base classes are called in a predictable order. This is particularly useful when using
super() in a method that is part of a class with multiple parent classes.

Example: Multiple Inheritance with super() and MRO

class A:
def __init__(self):
print("A's __init__")

class B(A):
def __init__(self):
super().__init__() # Calls A's __init__
print("B's __init__")

class C(A):
def __init__(self):
super().__init__() # Calls A's __init__
print("C's __init__")

class D(B, C):


def __init__(self):
super().__init__() # Follows MRO to determine the
method order
print("D's __init__")

d = D()
# Output:
# A's __init__
# C's __init__
# B's __init__
# D's __init__

Here, the super().__init__() in D will call B's constructor, which calls C's constructor,
and then the A constructor, as determined by the MRO.

Method Resolution Order (MRO)


The Method Resolution Order (MRO) is the order in which classes are searched when calling
methods. This is especially important in the case of multiple inheritance. Python uses the C3
Linearization Algorithm to determine the MRO.

To view the MRO of a class, you can use the .mro() method.

Example: Checking MRO

class A:
pass

class B(A):
pass

class C(A):
pass

class D(B, C):


pass

print(D.mro())
# Output:
# [<class '__main__.D'>, <class '__main__.B'>, <class
'__main__.C'>, <class '__main__.A'>, <class 'object'>]

Summary of super()

Feature Description
Calling Parent Constructor super() is used to call the parent class’s constructor,
allowing initialization of parent attributes.

Method Overriding super() can be used to call an overridden method in


the parent class.

Multiple Inheritance super() follows MRO to resolve method calls in


multiple inheritance hierarchies.

Syntax super().method_name() or super() for


automatic parent class reference.

Use Without Arguments In Python 3, super() can be used without arguments to


automatically refer to the parent class.

By using super(), you can make your code more maintainable, especially in complex
inheritance scenarios, and ensure that methods from parent classes are correctly called and
extended.

4. Types of Inheritance

Inheritance allows a class (child class) to inherit methods and attributes from another class
(parent class). Python supports multiple types of inheritance, which enable you to create complex
class hierarchies and promote code reuse. The types of inheritance in Python are:

1. Single Inheritance

In single inheritance, a class inherits from only one parent class. The child class inherits all the
methods and attributes from the parent class.

Example: Single Inheritance

class Animal:
def speak(self):
print("Animal speaks")

class Dog(Animal):
def bark(self):
print("Dog barks")

# Creating object of Dog class


dog = Dog()
dog.speak() # Inherited method from Animal class
dog.bark() # Method in Dog class

Output:

Animal speaks
Dog barks

In this case, the Dog class inherits the speak method from the Animal class.

2. Multiple Inheritance

In multiple inheritance, a class can inherit from more than one parent class. Python supports this
feature, and it allows a class to inherit attributes and methods from multiple classes.

Example: Multiple Inheritance

class Animal:
def speak(self):
print("Animal speaks")
class Dog:
def bark(self):
print("Dog barks")

class Puppy(Animal, Dog):


def play(self):
print("Puppy plays")

# Creating object of Puppy class


puppy = Puppy()
puppy.speak() # Inherited from Animal
puppy.bark() # Inherited from Dog
puppy.play() # Method in Puppy class

Output:

Animal speaks
Dog barks
Puppy plays

In this case, the Puppy class inherits from both Animal and Dog, allowing it to use methods
from both classes.

3. Multilevel Inheritance

In multilevel inheritance, a class inherits from a child class, making it a grandchild class. This
forms a chain of inheritance.

Example: Multilevel Inheritance

class Animal:
def speak(self):
print("Animal speaks")

class Dog(Animal):
def bark(self):
print("Dog barks")

class Puppy(Dog):
def play(self):
print("Puppy plays")

# Creating object of Puppy class


puppy = Puppy()
puppy.speak() # Inherited from Animal
puppy.bark() # Inherited from Dog
puppy.play() # Method in Puppy class

Output:

Animal speaks
Dog barks
Puppy plays

Here, the Puppy class inherits from Dog, and Dog inherits from Animal, creating a multilevel
inheritance chain.

4. Hierarchical Inheritance

In hierarchical inheritance, multiple classes inherit from a single parent class. All the child
classes can access the methods and attributes of the parent class.
Example: Hierarchical Inheritance

class Animal:
def speak(self):
print("Animal speaks")

class Dog(Animal):
def bark(self):
print("Dog barks")

class Cat(Animal):
def meow(self):
print("Cat meows")

# Creating objects of Dog and Cat class


dog = Dog()
cat = Cat()

dog.speak() # Inherited from Animal


dog.bark() # Method in Dog class
cat.speak() # Inherited from Animal
cat.meow() # Method in Cat class

Output:

Animal speaks
Dog barks
Animal speaks
Cat meows
In this case, both Dog and Cat inherit from the Animal class, which allows both classes to use
the speak method from the parent class.

5. Hybrid Inheritance

Hybrid inheritance is a combination of more than one type of inheritance. For example, it may
include both multiple and multilevel inheritance together. This is generally used to combine the
features of multiple inheritance styles.

Example: Hybrid Inheritance

class Animal:
def speak(self):
print("Animal speaks")

class Dog(Animal):
def bark(self):
print("Dog barks")

class Cat(Animal):
def meow(self):
print("Cat meows")

class Puppy(Dog, Cat): # Combining multiple and multilevel


inheritance
def play(self):
print("Puppy plays")

# Creating object of Puppy class


puppy = Puppy()
puppy.speak() # Inherited from Animal
puppy.bark() # Inherited from Dog
puppy.meow() # Inherited from Cat
puppy.play() # Method in Puppy class

Output:

Animal speaks
Dog barks
Cat meows
Puppy plays

In this example, Puppy inherits from both Dog and Cat, and Dog and Cat both inherit from
Animal. This is a hybrid form of inheritance combining multiple and multilevel inheritance.

6. Circular Inheritance (Not Recommended)

Circular inheritance occurs when two or more classes inherit from each other, creating a cycle.
Python allows circular inheritance, but it can lead to unexpected behavior and should generally
be avoided.

Example: Circular Inheritance (not recommended)

class A(B):
pass

class B(A):
pass

This is not a valid design and can lead to logical errors. Circular inheritance can cause confusion,
as Python’s MRO may not handle it in the expected manner.
Summary Table of Inheritance Types

Inheritance Type Description Example

Single Inheritance A class inherits from a single Child(Parent)


parent class.

Multiple A class inherits from more than Child(Parent1, Parent2)


Inheritance one parent class.

Multilevel A class inherits from a child class, Grandchild(Child)


Inheritance which itself inherits from another
class.

Hierarchical Multiple classes inherit from a Child1(Parent),


Inheritance single parent class. Child2(Parent)

Hybrid Inheritance A combination of multiple HybridClass(Parent1,


inheritance types (e.g., multiple Parent2)
and multilevel).

Circular Classes inherit from each other, A(B), B(A)


Inheritance forming a cycle (not
recommended).

Conclusion

● Single Inheritance is simple and allows one class to inherit from another.
● Multiple Inheritance enables a class to inherit from more than one class.
● Multilevel Inheritance creates a chain of inheritance.
● Hierarchical Inheritance allows multiple classes to share a common base class.
● Hybrid Inheritance combines multiple inheritance types.
● Circular Inheritance creates problematic cycles and should be avoided.
By choosing the appropriate type of inheritance for your problem, you can improve code reuse
and maintainability.

5. Method Resolution Order (MRO)

In Python, Method Resolution Order (MRO) defines the order in which methods are inherited
when a class is derived from multiple classes. MRO is particularly important in the context of
multiple inheritance, where a class can inherit from more than one parent class. The MRO
determines the sequence in which the base classes are searched when calling a method or
accessing an attribute.

MRO in Multiple Inheritance

When a class inherits from multiple parent classes, Python needs a way to determine the order in
which methods are called or attributes are accessed from the parent classes. The MRO helps
Python figure out the search order in the inheritance hierarchy.

How MRO Works:

● In the case of single inheritance, MRO is straightforward: the method or attribute is


searched only in the parent class.
● In multiple inheritance, Python uses the C3 Linearization Algorithm to determine the
MRO. The algorithm ensures that the method search order respects the order of class
inheritance while preventing the diamond problem (a situation where a class inherits from
two classes that both inherit from the same parent).

Key Points of MRO:

1. MRO Sequence: Python defines a linear sequence for method calls based on class
hierarchy.
2. C3 Linearization Algorithm: The order in which Python resolves method calls and
attributes when there are multiple inheritance chains.
3. Super Method: The super() method uses the MRO to call the next class method in
line.

Example of MRO:

Let’s look at an example of multiple inheritance and how Python resolves the method
resolution order:

class A:
def show(self):
print("A's method")

class B(A):
def show(self):
print("B's method")
super().show()

class C(A):
def show(self):
print("C's method")
super().show()

class D(B, C):


def show(self):
print("D's method")
super().show()

# Creating an object of class D


d = D()
d.show()

Output:

D's method
B's method
C's method
A's method

Explanation of MRO:

In the case of class D, Python uses the C3 Linearization Algorithm to determine the method
resolution order. The method show() is called on the object d of class D, and the MRO works
as follows:

1. Python first calls D.show().


2. Inside D.show(), super().show() is used. This calls the next class in the MRO,
which is B.
3. B.show() is executed, which calls super().show(), moving to C.
4. C.show() is called, and super().show() calls A.show().
5. Finally, A.show() is executed.

Understanding C3 Linearization:

The C3 Linearization Algorithm defines a specific way to determine the method resolution
order when a class inherits from multiple classes. It ensures that:

● The method search is consistent and respects the inheritance hierarchy.


● The diamond problem (where a class inherits from two classes that inherit from the same
parent) is avoided.

The algorithm works by:


1. Traversing the base classes in depth-first order.
2. Considering the parent classes from left to right, without repeating classes that have
already been included.

In the example above, the MRO for class D would be as follows:

D -> B -> C -> A

This means that Python will search for methods in this order when calling a method on an
instance of D.

Viewing the MRO:

You can view the MRO of a class using the mro() method or the __mro__ attribute:

Using mro() method:

print(D.mro())

Using __mro__ attribute:

print(D.__mro__)

Both of these will give the following output:

[<class '__main__.D'>, <class '__main__.B'>, <class


'__main__.C'>, <class '__main__.A'>, <class 'object'>]

This output shows the order in which Python will look for methods in case of inheritance.

Summary of MRO:

● Method Resolution Order (MRO) determines the order in which classes are searched
for methods and attributes.
● Python uses the C3 Linearization Algorithm for multiple inheritance to avoid issues
like the diamond problem.
● The MRO is crucial for classes that inherit from multiple parents because it dictates the
search order for methods and attributes.
● You can view the MRO using D.mro() or D.__mro__.

The MRO helps manage complex inheritance scenarios and ensures that the method search path
is logical and predictable.

6. Polymorphism

Polymorphism is one of the core concepts of Object-Oriented Programming (OOP). The term
"polymorphism" comes from the Greek words "poly" (meaning "many") and "morph" (meaning
"forms"). In the context of programming, it refers to the ability of different classes to provide a
common interface to interact with objects, while each class may have its own implementation of
the methods.

Polymorphism allows one interface to be used for different data types, promoting flexibility and
extensibility in code. Python achieves polymorphism in two main ways: Method Overloading
and Method Overriding.

Types of Polymorphism in Python

1. Duck Typing (Dynamic Polymorphism)


2. Method Overriding (Runtime Polymorphism)
3. Method Overloading (Static Polymorphism)

1. Duck Typing (Dynamic Polymorphism)

Python is dynamically typed, which means that a variable's type is determined at runtime. This
flexibility allows Python to implement duck typing — if an object can behave like another
object, it can be used in place of that object, regardless of its type.
In other words, Python uses duck typing based on the principle: "If it looks like a duck and
quacks like a duck, it is a duck."

Example of Duck Typing:

class Dog:
def speak(self):
print("Woof")

class Cat:
def speak(self):
print("Meow")

def animal_speak(animal):
animal.speak()

# Creating objects of Dog and Cat


dog = Dog()
cat = Cat()

animal_speak(dog) # Outputs: Woof


animal_speak(cat) # Outputs: Meow

Here, the function animal_speak can accept any object that has a speak() method,
regardless of whether it is a Dog or a Cat. This is dynamic polymorphism, where the method is
determined at runtime.
2. Method Overriding (Runtime Polymorphism)

Method overriding is a form of polymorphism where a child class provides its own
implementation of a method that is already defined in its parent class. The method in the child
class overrides the method in the parent class.

This is also called runtime polymorphism because the method that will be called is determined
at runtime based on the object type.

Example of Method Overriding:

class Animal:
def speak(self):
print("Animal speaks")

class Dog(Animal):
def speak(self):
print("Dog barks")

class Cat(Animal):
def speak(self):
print("Cat meows")

# Creating objects of Dog and Cat


dog = Dog()
cat = Cat()

# Both classes override the speak method of Animal class


dog.speak() # Outputs: Dog barks
cat.speak() # Outputs: Cat meows
In this example, both Dog and Cat classes override the speak method from the Animal class.
When calling speak() on a Dog object, the overridden method in the Dog class is executed.
Similarly, when calling speak() on a Cat object, the overridden method in the Cat class is
executed.

3. Method Overloading (Static Polymorphism)

Method overloading refers to defining multiple methods with the same name but with different
signatures (i.e., different numbers or types of arguments). In some languages, method
overloading is supported at the compile-time, but Python doesn't support traditional method
overloading. However, we can achieve a similar effect by using default arguments or
variable-length arguments.

Example of Method Overloading (Using Default Arguments):

class MathOperations:
def add(self, a, b=0):
return a + b

# Creating an object of MathOperations


math_op = MathOperations()

# Calling the add method with two arguments


print(math_op.add(2, 3)) # Outputs: 5

# Calling the add method with one argument


print(math_op.add(2)) # Outputs: 2
In this case, the add method has a default argument (b=0), allowing it to behave like an
overloaded method. When two arguments are passed, it adds them; when one argument is passed,
it returns that argument.

Example of Method Overloading (Using Variable-Length Arguments):

class MathOperations:
def add(self, *args):
return sum(args)

# Creating an object of MathOperations


math_op = MathOperations()

# Calling the add method with multiple arguments


print(math_op.add(1, 2, 3)) # Outputs: 6
print(math_op.add(4, 5)) # Outputs: 9
print(math_op.add(10)) # Outputs: 10

In this case, the add method uses *args to accept a variable number of arguments, allowing for
overloading-like behavior.

Advantages of Polymorphism

● Flexibility: Polymorphism provides a flexible interface that can handle different data
types and objects, allowing your code to be more extensible and reusable.
● Code Reusability: You can write generic functions and methods that work with different
types of objects, reducing redundancy.
● Maintainability: With polymorphism, new classes can be added to the system without
modifying existing code, making the system easier to maintain.
Summary of Polymorphism in Python

● Duck Typing: Allows objects of different types to be used interchangeably if they have
the same method or attribute.
● Method Overriding: Enables a child class to override a method from its parent class,
providing specific behavior.
● Method Overloading: While Python does not directly support traditional method
overloading, it can be mimicked using default arguments or variable-length arguments.

Polymorphism is a powerful concept that promotes flexibility and code reuse in object-oriented
design. It allows for handling different types of objects through a common interface, simplifying
the overall design of programs.

7. Duck Typing Philosophy of Python

Duck typing is a concept used in dynamic programming languages like Python. The core idea
behind duck typing is that an object’s suitability for a particular operation is determined by the
methods and properties it has, rather than the class or type it is an instance of.

The name "duck typing" comes from the saying: "If it looks like a duck, swims like a duck, and
quacks like a duck, then it probably is a duck." In programming, this means that if an object
behaves like a certain type (by having the required methods or properties), it can be treated as
that type, regardless of its actual class.

How Duck Typing Works in Python

In statically typed languages like Java or C++, type checking happens at compile time, and
objects are explicitly checked for compatibility with specific interfaces or classes. However,
Python is dynamically typed, meaning that type checking happens at runtime. In Python, the
focus is on the behavior of an object rather than its explicit type or class.
If an object implements the necessary methods or operations expected in a given context, Python
allows you to use that object as if it were an instance of a particular class or type. Python doesn't
care about the object’s class hierarchy but about whether the object behaves as required.

Key Principle of Duck Typing:

● "If an object implements the necessary methods or attributes, it can be used as an


instance of that type, even if it is not explicitly of that type."

Example of Duck Typing in Python

Consider the following example:

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

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

class Bird:
def speak(self):
print("Chirp!")

def animal_speak(animal):
animal.speak() # Duck typing: We don't care about the
class, just the method.

# Instances of different classes


dog = Dog()
cat = Cat()
bird = Bird()

# Calling the same function with different types of animals


animal_speak(dog) # Outputs: Woof!
animal_speak(cat) # Outputs: Meow!
animal_speak(bird) # Outputs: Chirp!

In this example, the animal_speak function doesn't care whether the object passed is an
instance of Dog, Cat, or Bird. It simply calls the speak() method on the passed object. Each
object responds with its own implementation of the speak() method. This is an example of
duck typing in action.

Benefits of Duck Typing

1. Flexibility: Duck typing allows more flexibility in your code because it doesn't restrict
you to work with objects of a specific type. You can pass any object to a function as long
as it has the required methods or attributes.
2. Simpler Code: It reduces the need for excessive type checking or defining rigid
interfaces. Instead, the focus is on the behavior of the object, making code simpler and
easier to maintain.
3. Code Reusability: Code that relies on duck typing can be reused for different types of
objects without requiring changes to the code. This allows greater generalization of
functions and methods.

Limitations and Challenges of Duck Typing

While duck typing offers flexibility and simplicity, it comes with some challenges:
Lack of Type Safety: Since objects are not explicitly checked for types, it is possible to
encounter runtime errors if an object doesn't actually support the methods that are expected. For
example:
class Fish:
def swim(self):
print("Swimming in water")

def animal_speak(animal):
animal.speak() # If animal doesn't have a `speak` method,
it will cause an error.

fish = Fish()
animal_speak(fish) # This will raise an AttributeError since
Fish has no `speak` method.

1. This can be mitigated by using duck typing with explicit checks (e.g., hasattr()) or
by using type annotations in modern Python (available from Python 3.5+).
2. Hard to Debug: Duck typing can sometimes make debugging more difficult because
errors related to missing methods or properties will only surface at runtime, which can
lead to unexpected issues in large codebases.

Best Practices for Using Duck Typing

Use hasattr() for Safe Duck Typing: If you're unsure whether an object will have a method
or attribute, you can check with the hasattr() function to avoid errors:
def animal_speak(animal):
if hasattr(animal, 'speak'):
animal.speak()
else:
print("This animal can't speak.")
Use Type Hints for Documentation: Type hints (available from Python 3.5) can be used to
make your intentions clearer and provide better documentation for the expected behavior of
objects. This is particularly useful when working with complex systems.
def animal_speak(animal: 'Animal'):
animal.speak() # Animal type could be a superclass or an
interface.

1. Leverage Polymorphism: Duck typing and polymorphism go hand in hand. By ensuring


that different classes implement the same interface or methods, you can achieve cleaner
and more maintainable code.

Summary of Duck Typing in Python

● Duck Typing allows Python to focus on the behavior of an object rather than its explicit
type.
● It provides flexibility and simplifies code by enabling objects with the same methods to
be used interchangeably.
● While it allows for faster development, it can sometimes lead to runtime errors if
objects don't behave as expected.
● Safe practices like hasattr() checks, type hints, and polymorphism can help mitigate
some of the risks of duck typing.

Duck typing is a powerful feature in Python that encourages code flexibility and reusability by
prioritizing behavior over specific type checking.

8. Operator Overloading

Operator Overloading is a feature in Python that allows you to redefine or "overload" the
behavior of operators (such as +, -, *, etc.) for user-defined classes. By defining special
methods, you can customize how operators behave when applied to instances of your class. This
makes it possible to use operators in a way that is intuitive and meaningful for objects of that
class.
Why Use Operator Overloading?

● Improved Readability: Operator overloading can make your code more intuitive. For
example, you might want to add two complex numbers using the + operator, rather than
calling a method like add().
● Intuitive Use of Custom Classes: You can make your custom classes behave like
built-in types, so they can be used with operators in a natural way.
● Custom Behavior for Operators: You can define custom behavior for various operators
when applied to objects of your class, allowing for flexibility in how your classes interact
with each other.

How to Overload Operators in Python

Operator overloading is done by defining special methods, which are also known as "magic
methods" or "dunder methods" (short for "double underscore"). These methods allow you to
specify how an operator works on objects of a class.

Here is a list of commonly used operator overloading methods:

Operator Magic Method

+ __add__(self, other)

- __sub__(self, other)

* __mul__(self, other)

/ __truediv__(self,
other)
% __mod__(self, other)

// __floordiv__(self,
other)

== __eq__(self, other)

!= __ne__(self, other)

< __lt__(self, other)

> __gt__(self, other)

<= __le__(self, other)

>= __ge__(self, other)

[] __getitem__(self,
key)

[]= __setitem__(self,
key, value)

() __call__(self, ...)

len() __len__(self)

Example of Operator Overloading

Let's see an example where we overload the + operator to add two objects of a Point class,
which represents a point in a 2D space:

class Point:
def __init__(self, x, y):
self.x = x
self.y = y

# Overloading the + operator


def __add__(self, other):
# Adding the corresponding x and y values of two points
return Point(self.x + other.x, self.y + other.y)

def __repr__(self):
return f"Point({self.x}, {self.y})"

# Creating two Point objects


p1 = Point(2, 3)
p2 = Point(4, 5)

# Using the overloaded + operator


p3 = p1 + p2 # This will call p1.__add__(p2)
print(p3) # Outputs: Point(6, 8)

In this example:

● The __add__ method allows us to use the + operator with instances of the Point class.
● The + operator adds the x and y coordinates of two Point objects and returns a new
Point object.

Overloading Other Operators


You can overload other operators in a similar way. Here's an example where we overload the ==
operator to compare two Point objects:

class Point:
def __init__(self, x, y):
self.x = x
self.y = y

# Overloading the == operator


def __eq__(self, other):
return self.x == other.x and self.y == other.y

def __repr__(self):
return f"Point({self.x}, {self.y})"

# Creating two Point objects


p1 = Point(2, 3)
p2 = Point(2, 3)
p3 = Point(4, 5)

# Using the overloaded == operator


print(p1 == p2) # Outputs: True
print(p1 == p3) # Outputs: False

In this example:

● The __eq__ method is used to compare two Point objects to check if they are equal
based on their x and y values.
Overloading Other Special Methods

You can also overload methods like __len__, __getitem__, and others to customize the
behavior of objects in different contexts.

Example of __len__:

class CustomList:
def __init__(self, elements):
self.elements = elements

# Overloading the len() function


def __len__(self):
return len(self.elements)

# Creating a CustomList object


my_list = CustomList([1, 2, 3, 4, 5])

# Using the overloaded len() function


print(len(my_list)) # Outputs: 5

In this example, we overload the __len__ method to make the len() function work on
CustomList objects, returning the length of the elements list.

Example of __getitem__:

class CustomList:
def __init__(self, elements):
self.elements = elements
# Overloading the [] operator for getting an element
def __getitem__(self, index):
return self.elements[index]

# Creating a CustomList object


my_list = CustomList([1, 2, 3, 4, 5])

# Using the overloaded [] operator


print(my_list[2]) # Outputs: 3

In this example, we overload the __getitem__ method to allow index-based access to the
elements of the CustomList object.

Advantages of Operator Overloading

1. Intuitive Code: Operator overloading helps you write more intuitive and readable code.
For example, using + to add two complex numbers or * to multiply two matrices is more
readable than calling a method like add() or multiply().
2. Cleaner Code: By overloading operators, you can avoid writing verbose function calls
and use operators in the natural way.
3. Customizable Behavior: You can define how different operations should behave for
your custom objects, allowing for flexible and customized functionality.

Disadvantages of Operator Overloading


1. Potential for Confusion: Overloading operators can lead to confusion if the behavior of
the operators is not intuitive or is too different from their usual meaning. For example,
using + for non-standard operations might confuse people reading the code.
2. Harder to Debug: Overloaded operators may sometimes make it difficult to understand
what exactly is happening in the code, especially if the behavior is customized in
complex ways.
3. May Obscure Logic: While overloading can make your code more concise, it can
sometimes obscure the logic of the program if overused.

Summary of Operator Overloading

● Operator Overloading allows you to redefine the behavior of operators for custom
objects.
● You can overload operators like +, -, ==, and [] using special methods such as
__add__, __sub__, __eq__, and __getitem__.
● It enhances the readability and intuitiveness of your code but should be used carefully to
avoid confusion.
● Operator overloading is a powerful feature that allows for customization of how objects
interact with each other using standard operators.

9. Method Overriding

Method Overriding is a feature in object-oriented programming (OOP) that allows a subclass to


provide a specific implementation of a method that is already defined in its superclass. This
allows the subclass to change or extend the behavior of the inherited method without modifying
the original code in the superclass.

How Method Overriding Works

When a subclass inherits from a superclass, it can override the methods of the superclass by
defining a method with the same name and signature in the subclass. When an object of the
subclass calls that method, Python will use the overridden method in the subclass instead of the
inherited method from the superclass.

Key Points about Method Overriding:

1. Same Method Signature: The method in the subclass should have the same name and
parameters as the method in the superclass.
2. Behavior Change: The overridden method provides a new implementation that changes
or extends the behavior of the original method.
3. Use of super(): If you want to call the method from the superclass inside the
overridden method, you can use super() to refer to the superclass and invoke the
original method.
4. Dynamic Dispatch: Method overriding is a runtime concept in Python, meaning the
appropriate method (from the superclass or subclass) is chosen based on the object type.

Example of Method Overriding

Here is an example where we define a superclass Animal and a subclass Dog. The Dog class
overrides the speak() method of the Animal class.

class Animal:
def speak(self):
print("Animal makes a sound")

class Dog(Animal):
# Overriding the speak method
def speak(self):
print("Dog barks")

# Creating an object of the Dog class


dog = Dog()
dog.speak() # Outputs: Dog barks

# Creating an object of the Animal class


animal = Animal()
animal.speak() # Outputs: Animal makes a sound

In this example:

● The Animal class has a speak() method that outputs "Animal makes a sound".
● The Dog class inherits from Animal and overrides the speak() method, so when
dog.speak() is called, it outputs "Dog barks" instead of the original message from
Animal.

Using super() with Method Overriding

Sometimes, in an overridden method, you may want to call the method from the superclass as
part of the new implementation. This can be done using the super() function. super()
allows you to call methods from the superclass.

Here’s an example:

class Animal:
def speak(self):
print("Animal makes a sound")

class Dog(Animal):
def speak(self):
super().speak() # Call the speak method of the
superclass
print("Dog barks")
# Creating an object of the Dog class
dog = Dog()
dog.speak()
# Outputs:
# Animal makes a sound
# Dog barks

In this example:

● The Dog class overrides the speak() method.


● Inside the speak() method of Dog, we use super().speak() to call the method
from the Animal class first, and then we add additional behavior (printing "Dog barks").

Benefits of Method Overriding

1. Specialization of Behavior: Subclasses can specialize or modify the behavior of


inherited methods to suit their own requirements without changing the behavior of the
superclass.
2. Code Reusability: By overriding methods, you can reuse the code from the superclass
and add or modify functionality in the subclass.
3. Polymorphism: Method overriding allows for polymorphic behavior. The method that
gets executed depends on the type of the object (subclass or superclass), even if the object
is being referenced by a superclass type. This is key to achieving dynamic method
dispatch.

Example with Polymorphism

class Animal:
def speak(self):
print("Animal makes a sound")
class Dog(Animal):
def speak(self):
print("Dog barks")

class Cat(Animal):
def speak(self):
print("Cat meows")

# List of animals
animals = [Dog(), Cat(), Animal()]

# Polymorphism in action: the correct method is called for each


object
for animal in animals:
animal.speak()

# Outputs:
# Dog barks
# Cat meows
# Animal makes a sound

In this example:

● The animals list contains instances of Dog, Cat, and Animal.


● The speak() method is overridden in both the Dog and Cat classes.
● When the speak() method is called in a loop, the correct method (from Dog, Cat, or
Animal) is executed based on the object type. This is an example of polymorphism.

Rules for Method Overriding


1. Same Method Signature: The method in the subclass must have the same name and
parameters as the method in the superclass.
2. Access Modifiers: The access level of the method in the subclass can be the same or less
restrictive than in the superclass. For example, if a method in the superclass is public, you
can override it in the subclass as public or protected, but not private.
3. No Overriding for Private Methods: Private methods in Python (those with a double
underscore prefix, like __method) are not directly overridden by subclasses because of
name mangling, but they can still be accessed using a different name.

Example: Inheriting from Multiple Classes (Multiple Inheritance)

In the case of multiple inheritance, Python uses the Method Resolution Order (MRO) to
determine which method to call if the method is overridden in multiple classes.

class Animal:
def speak(self):
print("Animal makes a sound")

class Dog:
def speak(self):
print("Dog barks")

class Cat(Animal, Dog):


pass
# Creating an object of the Cat class
cat = Cat()
cat.speak() # Outputs: Animal makes a sound (MRO determines
this)

In this example:
● The Cat class inherits from both Animal and Dog, and both have a speak() method.
● Python uses the Method Resolution Order (MRO) to decide which speak() method
to call. Since Animal comes first in the inheritance list, Animal's speak() method is
called.

Summary of Method Overriding

● Method Overriding allows a subclass to provide a specific implementation of a method


that is already defined in its superclass.
● The method signature in the subclass should match the one in the superclass.
● Overridden methods in the subclass can change the behavior of inherited methods and
extend or modify them.
● Using super(), a subclass can call methods from its superclass as part of the overriding
process.
● Method overriding is a key feature for achieving polymorphism and dynamic dispatch
in Python.

Method overriding is widely used in object-oriented design to enable subclasses to modify or


extend the behavior of inherited methods, providing greater flexibility and functionality.

10. Abstract class & Abstract method

In Python, abstract classes and abstract methods are used to define a blueprint for other
classes. An abstract class can define methods that must be implemented by its subclasses. These
methods are called abstract methods, and the class that contains them is known as an abstract
class.

Key Concepts

● Abstract Class: An abstract class is a class that cannot be instantiated on its own. It
serves as a blueprint for other classes. It may contain abstract methods or fully
implemented methods. The primary purpose of an abstract class is to define a common
interface for its subclasses.
● Abstract Method: An abstract method is a method that is declared but contains no
implementation in the abstract class. Subclasses that inherit from the abstract class are
required to provide an implementation for these methods.

Why Use Abstract Classes and Methods?

● Enforce Method Implementation: Abstract methods force subclasses to implement the


methods, ensuring that certain functionality is provided by all subclasses.
● Provide a Common Interface: An abstract class can define a common interface
(methods) for its subclasses to follow, making the design more structured.

Creating Abstract Classes and Methods in Python

Python’s abc (Abstract Base Class) module provides the tools to define abstract classes and
abstract methods.

To define an abstract class and abstract method:

1. Import the ABC and abstractmethod from the abc module.


2. The abstract class should inherit from ABC.
3. Use the @abstractmethod decorator to mark methods that need to be implemented in
the subclass.

Example: Abstract Class and Method

from abc import ABC, abstractmethod

# Define the Abstract Class


class Animal(ABC):

# Abstract Method (no implementation)


@abstractmethod
def speak(self):
pass

# Subclass of Animal that implements the abstract method


class Dog(Animal):

# Implementation of the abstract method


def speak(self):
print("Dog barks")

# Subclass of Animal that implements the abstract method


class Cat(Animal):

# Implementation of the abstract method


def speak(self):
print("Cat meows")

# Creating instances of the subclasses


dog = Dog()
dog.speak() # Outputs: Dog barks

cat = Cat()
cat.speak() # Outputs: Cat meows

# Trying to instantiate Animal will raise an error


# animal = Animal() # TypeError: Can't instantiate abstract
class Animal with abstract method speak

In this example:

● The Animal class is an abstract class because it inherits from ABC and contains an
abstract method speak.
● The speak() method is marked as an abstract method using the @abstractmethod
decorator. This means that any subclass of Animal must implement the speak()
method.
● The Dog and Cat classes are subclasses of Animal that provide their own
implementations of the speak() method.
● Trying to create an instance of the Animal class directly will result in a TypeError
because Animal contains an abstract method and cannot be instantiated.

Important Points About Abstract Classes and Methods

1. Cannot Instantiate Abstract Classes: An abstract class cannot be instantiated directly. It


is meant to be inherited by other classes that implement the abstract methods.
2. Must Implement Abstract Methods in Subclasses: Any subclass of an abstract class
must implement all the abstract methods of the abstract class, otherwise, it will also be
treated as an abstract class, and you won’t be able to instantiate it.
3. Abstract Methods in Abstract Classes: The abstract methods in the abstract class define
a contract that all subclasses must follow. These methods have no body in the abstract
class and must be implemented in subclasses.
4. Concrete Methods in Abstract Classes: An abstract class can have both abstract
methods (methods without implementation) and concrete methods (methods with
implementation). Subclasses can inherit the concrete methods and override them if
needed.

Abstract Class with Concrete Methods Example


from abc import ABC, abstractmethod

class Shape(ABC):

@abstractmethod
def area(self):
pass

def display(self):
print("Displaying the shape")

class Rectangle(Shape):

def __init__(self, width, height):


self.width = width
self.height = height

def area(self):
return self.width * self.height

# Creating an object of Rectangle


rectangle = Rectangle(10, 5)
rectangle.display() # Outputs: Displaying the shape
print(f"Area of rectangle: {rectangle.area()}") # Outputs: Area
of rectangle: 50

In this example:
● The Shape class is an abstract class with an abstract method area() and a concrete
method display().
● The Rectangle class is a subclass of Shape that implements the area() method but
inherits the display() method without needing to modify it.

Abstract Classes with Multiple Abstract Methods

An abstract class can have multiple abstract methods. Subclasses must implement all of these
abstract methods to be instantiated.

from abc import ABC, abstractmethod

class Animal(ABC):

@abstractmethod
def speak(self):
pass

@abstractmethod
def move(self):
pass

class Dog(Animal):

def speak(self):
print("Dog barks")

def move(self):
print("Dog runs")
# Creating an object of Dog
dog = Dog()
dog.speak() # Outputs: Dog barks
dog.move() # Outputs: Dog runs

Here, the Animal class has two abstract methods: speak() and move(). The Dog class
implements both of these methods, and so it can be instantiated.

Benefits of Using Abstract Classes and Methods

1. Enforces Consistency: Abstract classes and methods ensure that all subclasses follow a
common interface. This leads to consistent behavior across different subclasses.
2. Code Reusability: Abstract classes allow code to be written once and reused across
multiple subclasses. Concrete methods in the abstract class can be inherited by
subclasses, reducing code duplication.
3. Design Flexibility: Abstract classes allow you to define common functionality in a base
class while leaving room for customization in the subclasses.
4. Polymorphism: Abstract classes enable polymorphism, where the same method can
behave differently depending on the type of the object (subclass) that implements it.

Summary

● Abstract Class: A class that contains one or more abstract methods and cannot be
instantiated. It serves as a blueprint for other classes.
● Abstract Method: A method that is declared but not implemented in the abstract class.
Subclasses are required to implement this method.
● Usage: Abstract classes and methods are used to enforce a structure, ensure consistency
across subclasses, and allow for a flexible and extensible design.

Abstract classes are useful for creating frameworks and defining common interfaces across
different subclasses in Python.
Examples from Naan Mudhalvan - Infosys Springboard
from abc import ABC, abstractmethod
from datetime import datetime

class Notification(ABC): #abstract class

@abstractmethod
def send(self):
pass

@abstractmethod
def schedule(self, time: datetime):
pass

class EmailNotification(Notification):

def __init__(self, recipient_email: str, subject: str):


self.recipient_email = recipient_email
self.subject = subject

def send(self):
print(f"Sending email to {self.recipient_email} with subject
'{self.subject}'")

def schedule(self, time: datetime):


print(f"Email scheduled to be sent to {self.recipient_email} at
{time}")

class SMSNotification(Notification):
def __init__(self, phone_number: str, message: str):
self.phone_number = phone_number
self.message = message

def send(self):
print(f"Sending SMS to {self.phone_number} with message
'{self.message}'")

def schedule(self, time: datetime):


print(f"SMS scheduled to be sent to {self.phone_number} at
{time}")

sms = SMSNotification("+91 1234567890", "Your appointment is confirmed.")


sms.send()
sms.schedule(datetime(2024, 8, 21, 9, 0))
email = EmailNotification("[email protected]", "Meeting Reminder")
email.send()
email.schedule(datetime(2024, 8, 21, 10, 0))

class Purchase:
#static variables
list_of_items = ["cake","soap","jam","cereal","hand
sanitizer","biscuits","bread"]
list_of_count_of_each_item_sold = [0,0,0,0,0,0,0] #static
def __init__(self):
self.__items_purchased=[] #class attribute
self.__item_on_offer=None

#getter methods
def get_items_purchased(self):
return self.__items_purchased

def get_item_on_offer(self):
return self.__item_on_offer

def sell_items(self,list_of_items_to_be_purchased):
for i in list_of_items_to_be_purchased: #formal parameter

for j in Purchase.list_of_items: #static list

if i.lower() == j.lower():
if i.lower() == "soap":
self.provide_offer()
indx = Purchase.list_of_items.index(j)
self.__items_purchased.append(j)
Purchase.list_of_count_of_each_item_sold[indx] += 1
break
else:
print(i,"not available!")

def provide_offer(self):
for x in Purchase.list_of_items:
if x.lower() == "hand sanitizer":
indx = Purchase.list_of_items.index(x)
break
Purchase.list_of_count_of_each_item_sold[indx]+=1
self.__item_on_offer='HAND SANITIZER'

@staticmethod
def find_total_items_sold():
return sum(Purchase.list_of_count_of_each_item_sold)

from abc import ABC, abstractmethod

class Employee(ABC): #Abstract Class --> L0


#static variables --> class variables --> common for all objects
#ClassName.StaticVariableName
employee_count = 0
total_salary = 0

def __init__(self, name, base_salary):


#class attributes
self.name = name
self.base_salary = base_salary
Employee.employee_count += 1
Employee.total_salary += self.base_salary

@abstractmethod
def calculate_bonus(self):
pass

@abstractmethod
def get_total_compensation(self):
pass

'''def get_total_compensation(self):
return self.base_salary + self.calculate_bonus()'''

@staticmethod #concrete method


def average_salary():
print(Employee.total_salary / Employee.employee_count)
return Employee.total_salary / Employee.employee_count

class Developer(Employee): #Developer --> L1 inheriting L0


def __init__(self, name, base_salary, programming_language):
super().__init__(name, base_salary) #invoking the base class
constructor with 2 parameters
self.programming_language = programming_language #Developer class
specific

def calculate_bonus(self):
return self.base_salary * 0.1 #10% bonus for the Developer

def get_total_compensation(self):
return self.base_salary + self.calculate_bonus()

class Manager(Employee): #Manager --> L1 inheriting L0


def __init__(self, name, base_salary, team_size):
super().__init__(name, base_salary)
self.team_size = team_size

def calculate_bonus(self):
return self.base_salary * (0.05 * self.team_size)

def get_total_compensation(self):
return self.base_salary + self.calculate_bonus()

class Intern(Employee): #Intern --> L1 inheriting L0


def __init__(self, name, base_salary, school):
super().__init__(name, base_salary)
self.school = school

def calculate_bonus(self):
return 0

def get_total_compensation(self):
return self.base_salary + self.calculate_bonus()

dev = Developer("A", 80000, "Python")


mgr = Manager("B", 100000, 5)
i = Intern("C", 30000, "MIT")

print(f"Developer Total Compensation: ₹{dev.get_total_compensation()}")


#print("Developer Total Compensation:
₹{}".format(dev.get_total_compensation()))
print(f"Manager Total Compensation: ₹{mgr.get_total_compensation()}")
print(f"Intern Total Compensation: ₹{i.get_total_compensation()}")
print(f"Average Salary: ₹{Employee.average_salary()}")

#print("Manager Total Compensation: ₹{1} and Intern Total Compensation:


₹{0}".format(intern.get_total_compensation(),
mgr.get_total_compensation()))

# General Task 1
from abc import ABC, abstractmethod

# Abstract class Person


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

#whichever class is using Person as a base class, should definitely


give implemenetation for display_details() method.
@abstractmethod
def display_details(self):
pass

# Student class inheriting from Person


class Student(Person):
def __init__(self, name, age, roll_number):
super().__init__(name, age) #using the base class constructor
using super()
self.roll_number = roll_number
self.grade = None
self.courses = []

def enroll_course(self, course): #formal parameter --> object of


Course Class --> Course class is aggregated with Student
if course not in self.courses: #self.courses --> class attribute;
course --> formal parameter
self.courses.append(course)
course.add_student(self)

def update_grade(self, course, grade):


if course in self.courses:
self.grade = grade
else:
print(f"Student {self.name} is not enrolled in
{course.course_name}")

def display_details(self):
print(f"Student: {self.name}, Age: {self.age}, Roll Number:
{self.roll_number}")
print("Enrolled in:")
for course in self.courses:
print(f"- {course.course_name} ({course.course_code}), Grade:
{self.grade}")

# Teacher class inheriting from Person


class Teacher(Person):
def __init__(self, name, age, employee_id, department):
super().__init__(name, age)
self.employee_id = employee_id
self.department = department
self.courses_taught = []

def assign_course(self, course):


if course not in self.courses_taught:
self.courses_taught.append(course)
def assign_grade(self, student, course, grade): #student and course
--> aggregated objects; grade --> formal parameter
if isinstance(student, Student) and course in self.courses_taught:
print("Grade assigned!")
student.update_grade(course, grade)
else:
print(f"Cannot assign grade: Either student not found or
course not taught by {self.name}")

def display_details(self):
print(f"Teacher: {self.name}, Age: {self.age}, Employee ID:
{self.employee_id}, Department: {self.department}")
print("Courses Taught:")
for course in self.courses_taught:
print(f"- {course.course_name} ({course.course_code})")

# Course class with aggregation


class Course:
total_courses = 0 # Static variable to track the total number of
courses

def __init__(self, course_name, course_code):


self.course_name = course_name #courseName
self.course_code = course_code
self.students = []
Course.total_courses += 1

def add_student(self, student): #student --> object of Student class


if student not in self.students:
self.students.append(student)

def display_students(self):
print(f"Course: {self.course_name} ({self.course_code})")
for student in self.students:
print(f"- {student.name} (Roll Number:
{student.roll_number})")

@staticmethod
def display_total_courses():
print(f"Total Courses: {Course.total_courses}")
# College class demonstrating dependency
class College:
def __init__(self, name):
self.name = name #college name
self.students = []
self.teachers = []
self.courses = []

def register_student(self, name, age, roll_number):


student = Student(name, age, roll_number)
self.students.append(student)
return student

def hire_teacher(self, name, age, employee_id, department):


teacher = Teacher(name, age, employee_id, department)
self.teachers.append(teacher)
return teacher

def create_course(self, course_name, course_code):


course = Course(course_name, course_code)
self.courses.append(course)
return course

def enroll_student_in_course(self, student, course):


student.enroll_course(course)

def assign_teacher_to_course(self, teacher, course):


teacher.assign_course(course)

def display_all_courses(self):
for course in self.courses:
course.display_students()

def display_college_details(self):
print(f"College: {self.name}")
print(f"Total Students: {len(self.students)}")
print(f"Total Teachers: {len(self.teachers)}")
Course.display_total_courses()
col = College("XYZ College")
col.register_student("A", 20, "1234")
col.hire_teacher("B", 40, "9876","CSE")
#col.create_course("AIDS","AI007")'''
#create S1 student
s1 = Student("C",20,"1235") #student 1 created
c1 = Course("AIDS","AI007") #course 1 created
col.enroll_student_in_course(s1,c1) #s1 enrolled in c1
t1 = Teacher("B",40,"9876","CSE")
col.assign_teacher_to_course(t1,c1) #t1 will handle c1

col.register_student("E", 20, "12345")


col.hire_teacher("F", 40, "9876","ECE")
col.display_all_courses()
print()
col.display_college_details()
s2 = Student("Q",20,"1235") #student 2 created
c1.add_student(s2)
#col.display_college_details()
c1.display_students()
t1.assign_grade(s1,c1,"A")
t1.display_details()
s1.display_details()
college = College("ABC University")

# Register students
student1 = college.register_student("qwerty", 20, "S001")
student2 = college.register_student("asdf", 21, "S002")

# Hire teachers
teacher1 = college.hire_teacher("Dr. Apple", 45, "T001", "Mathematics")
teacher2 = college.hire_teacher("Dr. Orange", 50, "T002", "CSE")

# Create courses
course1 = college.create_course("Mathematics", "MATH101")
course2 = college.create_course("CSE", "CSE101")

# Enroll students in courses


college.enroll_student_in_course(student1, course1)
college.enroll_student_in_course(student2, course1)
college.enroll_student_in_course(student1, course2)

# Assign teachers to courses


college.assign_teacher_to_course(teacher1, course1)
college.assign_teacher_to_course(teacher2, course2)

# Assign grades
teacher1.assign_grade(student1, course1, "A")
teacher1.assign_grade(student2, course1, "B+")
teacher1.assign_grade(student1, course1, "A-")

# Display details
college.display_all_courses()
college.display_college_details()

You might also like