Core Python - Module 12 and 13
Core Python - Module 12 and 13
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
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
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()
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.
class ClassName:
# Class attributes (optional)
class_attribute = "I am a class attribute"
Example of a Class
class Animal:
# Constructor to initialize the name of the animal
def __init__(self, name):
self.name = name
object_name = ClassName(parameters)
Example of an Object
Purpose Defines attributes and methods. Stores actual data and interacts using
methods.
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.
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"
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
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!")
Output:
5
10
Radius must be positive!
4. 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.
● 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:
# 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!"
cat = Cat()
print(cat.sound()) # Output: Meow!
Advantages of Abstraction:
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.
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:
class Employee:
def __init__(self, name, salary):
self.name = name # Public attribute
self._bonus = 0 # Protected attribute
self.__salary = salary # Private attribute
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.
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
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
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()]
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())
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
def __str__(self):
return f"Point({self.x}, {self.y})"
Here, the + operator is redefined for the Point class to perform addition of two Point objects.
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
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.")
Output:
class Car(Vehicle):
def drive(self):
print("The car drives.")
class SportsCar(Car):
def race(self):
print("The sports car races.")
Output:
class Flyer:
def fly(self):
print("This can fly.")
class Swimmer:
def swim(self):
print("This can swim.")
Output:
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.")
Output:
Calculating area.
Radius of the circle.
Calculating area.
Sides of the rectangle.
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.")
Output:
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.")
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.
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.
class Person:
# Constructor to initialize the object
def __init__(self, name, age):
self.name = name # Instance attribute
self.age = age # Instance attribute
Attributes in Classes
1. Instance Attributes
2. Class Attributes
class Employee:
company = "ABC Corp" # Class attribute
# Creating objects
emp1 = Employee("John", "Manager")
emp2 = Employee("Jane", "Developer")
Instance Methods
Class Methods
Static Methods
class Example:
class_attribute = "Class Attribute"
@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
Summary
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.
● 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)
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.
class Calculator:
def add(self, a, b):
return a + b
# Create an object
calc = Calculator()
● The self.add() and self.multiply() calls allow the methods to interact within
the same object.
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.
class Person:
def greet(): # Missing self
print("Hello!")
# Will raise an error
person = Person()
person.greet()
Error:
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()
self.name = name
Summary
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.
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
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
class Person:
def __init__(self, name="Unknown", age=0):
self.name = name
self.age = age
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.
class Car:
def __init__(self, make, model):
self.make = make
self.model = model
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:
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
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
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
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
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()
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
x = 5
def modify_global():
global x
x = 10
modify_global()
print(x) # Output: 10
Nested Functions and Enclosing Scope
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
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
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}")
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}")
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}")
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}")
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
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.
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.
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}")
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.
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}")
Static inner classes do not depend on the enclosing class instance and can be used independently.
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
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}")
class Bank:
def __init__(self, name):
self.name = name
self.accounts = [] # List to store accounts
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}")
# 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.
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:
Instance Variables
Instance Methods
class Person:
def __init__(self, name, age):
self.name = name # Instance variable
self.age = age # Instance variable
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
@classmethod
def change_company_name(cls, new_name): # Class method
cls.company_name = new_name
# Creating instances
emp1 = Company("Alice")
emp2 = Company("Bob")
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.
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
class Account:
bank_name = "Global Bank" # Class variable
@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)
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
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}")
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}")
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")
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}")
If you want both constructors to run, use super() or direct calls to the parent class constructor.
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
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}")
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")
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
Child Overriding Parent The child constructor overrides the parent constructor.
By understanding constructors in inheritance, you can design your classes to be more robust,
flexible, and compatible in Python programs.
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
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}")
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}")
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}")
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().
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()
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
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
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}")
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")
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.
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.
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).
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.
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}")
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().
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")
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.
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.
class Parent1:
def greet(self):
print("Hello from Parent1")
class Parent2:
def greet(self):
print("Hello from Parent2")
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.
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.
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.
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.
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__")
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.
To view the MRO of a class, you can use the .mro() method.
class A:
pass
class B(A):
pass
class C(A):
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.
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.
class Animal:
def speak(self):
print("Animal speaks")
class Dog(Animal):
def bark(self):
print("Dog barks")
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.
class Animal:
def speak(self):
print("Animal speaks")
class Dog:
def bark(self):
print("Dog barks")
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.
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")
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")
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.
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")
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.
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.
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
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.
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.
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.
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()
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:
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:
This means that Python will search for methods in this order when calling a method on an
instance of D.
You can view the MRO of a class using the mro() method or the __mro__ attribute:
print(D.mro())
print(D.__mro__)
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.
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."
class Dog:
def speak(self):
print("Woof")
class Cat:
def speak(self):
print("Meow")
def animal_speak(animal):
animal.speak()
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.
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")
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.
class MathOperations:
def add(self, a, b=0):
return a + b
class MathOperations:
def add(self, *args):
return sum(args)
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.
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.
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.
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.
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.
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.
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.
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.
● 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.
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.
+ __add__(self, other)
- __sub__(self, other)
* __mul__(self, other)
/ __truediv__(self,
other)
% __mod__(self, other)
// __floordiv__(self,
other)
== __eq__(self, other)
!= __ne__(self, other)
[] __getitem__(self,
key)
[]= __setitem__(self,
key, value)
() __call__(self, ...)
len() __len__(self)
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
def __repr__(self):
return f"Point({self.x}, {self.y})"
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.
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point({self.x}, {self.y})"
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
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]
In this example, we overload the __getitem__ method to allow index-based access to the
elements of the CustomList object.
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.
● 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
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.
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.
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")
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.
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:
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()]
# Outputs:
# Dog barks
# Cat meows
# Animal makes a sound
In this example:
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")
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.
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.
Python’s abc (Abstract Base Class) module provides the tools to define abstract classes and
abstract methods.
cat = Cat()
cat.speak() # Outputs: Cat meows
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.
class Shape(ABC):
@abstractmethod
def area(self):
pass
def display(self):
print("Displaying the shape")
class Rectangle(Shape):
def area(self):
return self.width * self.height
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.
An abstract class can have multiple abstract methods. Subclasses must implement all of these
abstract methods to be instantiated.
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.
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
@abstractmethod
def send(self):
pass
@abstractmethod
def schedule(self, time: datetime):
pass
class EmailNotification(Notification):
def send(self):
print(f"Sending email to {self.recipient_email} with subject
'{self.subject}'")
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}'")
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
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)
@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()'''
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()
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()
def calculate_bonus(self):
return 0
def get_total_compensation(self):
return self.base_salary + self.calculate_bonus()
# General Task 1
from abc import ABC, abstractmethod
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}")
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})")
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 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
# 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")
# 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()