Index
Introduction to OOP in Python
Procedural vs Object-Oriented Programming
Classes and Objects in Python
Constructors (
__init__
Method)Instance Variables and Methods
Class Variables and Methods (
@classmethod
)Static Methods (
@staticmethod
)Inheritance in Python
Single Inheritance
Multiple Inheritance
Multilevel Inheritance
Hierarchical Inheritance
Hybrid Inheritance
Method Overriding
Multiple Inheritance and MRO (Method Resolution Order)
Polymorphism
Encapsulation
Data Hiding (
__private
variables)Abstraction (
ABC
module,@abstractmethod
)Magic (Dunder) Methods (
__str__
,__repr__
,__len__
, etc.)Operator Overloading
MRO(Method Resolution Order )
Introduction: Mastering Object-Oriented Programming in Python
Object-Oriented Programming (OOP) is a fundamental paradigm in modern software development, allowing us to structure code in a modular, reusable, and scalable way. Python, being a versatile and beginner-friendly language, provides powerful support for OOP through classes, objects, inheritance, polymorphism, and more.
In this deep dive, we’ll explore OOP concepts in Python from the ground up. Whether you're a beginner looking to solidify your understanding or an experienced developer wanting to refine your skills, this series will provide practical insights, examples, and best practices. By the end, you’ll be able to write clean, efficient, and well-structured object-oriented code in Python.
Let’s get started! 🚀
What is an Object-Oriented Programming (OOP) Language?
An Object-Oriented Programming (OOP) language is a programming language that is based on the concept of objects. Objects are instances of classes that encapsulate data (attributes) and behaviors (methods) in a structured way. OOP is designed to make code more modular, reusable, and scalable by organizing it into self-contained objects that interact with each other.
Key Features of OOP Languages
Classes and Objects – A class is a blueprint, and an object is an instance of a class.
Encapsulation – Data and methods are bundled together, restricting direct access to some details.
Inheritance – A class can inherit properties and behaviors from another class.
Polymorphism – Objects can take multiple forms, allowing flexibility in coding.
Abstraction – Hides complex implementation details and exposes only the necessary parts.
Examples of OOP Languages
Python 🐍
Java ☕
C++
C#
Ruby
Swift
What is a Procedural Programming Language?
A Procedural Programming Language is a type of programming language that follows a step-by-step approach, where the code is written as a sequence of instructions (or procedures) that the computer executes in order. It is based on the concept of procedures (functions), which group statements into reusable blocks.
Characteristics of Procedural Programming:
Top-Down Approach – Programs are structured in a linear flow from start to finish.
Uses Functions – Code is divided into reusable functions to avoid repetition.
Global and Local Variables – Data is stored in variables, which can be accessed by different parts of the program.
Less Emphasis on Data Security – Since data is often stored in global variables, it can be accessed and modified from anywhere in the program.
Examples of Procedural Programming Languages:
C
Pascal
Fortran
BASIC
Problems with Procedural Programming:
Code Complexity Increases for Large Projects
- As projects grow, procedural programming can lead to spaghetti code, where functions become highly interdependent and difficult to maintain.
Lack of Data Security (Encapsulation)
- Data is often stored in global variables, which can be accessed and modified from anywhere, making the program prone to errors and security vulnerabilities.
Code Reusability is Limited
- Functions can be reused, but without objects and inheritance, it is harder to create reusable components compared to OOP.
Difficult to Model Real-World Scenarios
- Since procedural programming does not use objects, representing real-world entities (e.g., a Car, Person, or Bank Account) is not as intuitive as in OOP.
Harder to Scale and Modify
- Adding new features or modifying existing ones can be challenging because changes in one part of the program may require changes in multiple other places.
Why OOP is Preferred Over Procedural Programming?
While procedural programming works well for small and simple programs, OOP is preferred for large, complex, and scalable applications. OOP allows for modularity, code reuse, better data security, and easier maintenance, making it ideal for modern software development.
Procedural Approach
In a procedural style, we use functions and global variables to manage the account.
# Procedural Approach
# Global variables
account_balance = 0
# Function to deposit money
def deposit(amount):
global account_balance
account_balance += amount
print(f"Deposited ${amount}. New Balance: ${account_balance}")
# Function to withdraw money
def withdraw(amount):
global account_balance
if amount > account_balance:
print("Insufficient funds!")
else:
account_balance -= amount
print(f"Withdrew ${amount}. New Balance: ${account_balance}")
# Function to check balance
def check_balance():
print(f"Current Balance: ${account_balance}")
# Using the functions
deposit(1000)
withdraw(500)
check_balance()
Problems with Procedural Approach:
Uses global variables, making the data less secure.
Not scalable—if we need multiple accounts, we must create separate variables for each.
Code repetition—functions have to repeatedly reference global variables.
Object-Oriented Approach
In OOP, we use a class to create multiple bank accounts with encapsulated data.
# Object-Oriented Approach
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner # Account holder name
self.balance = balance # Initial balance
def deposit(self, amount):
self.balance += amount
print(f"{self.owner} deposited ${amount}. New Balance: ${self.balance}")
def withdraw(self, amount):
if amount > self.balance:
print(f"{self.owner}, insufficient funds!")
else:
self.balance -= amount
print(f"{self.owner} withdrew ${amount}. New Balance: ${self.balance}")
def check_balance(self):
print(f"{self.owner}'s Balance: ${self.balance}")
# Creating multiple account objects
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
# Performing transactions
account1.deposit(500)
account1.withdraw(200)
account1.check_balance()
account2.deposit(300)
account2.withdraw(1000) # Insufficient funds
account2.check_balance()
Benefits of the OOP Approach:
✅ Encapsulation – Each object (account) stores its own data securely.
✅ Scalability – We can create multiple accounts without modifying the core code.
✅ Code Reusability – The BankAccount
class can be reused for any number of accounts.
✅ Better Organization – Code is more structured, making it easier to maintain.
Classes and Objects in Python
Python is an object-oriented programming (OOP) language, meaning it revolves around the concept of classes and objects.
What is a Class?
A class is a blueprint for creating objects. It defines the attributes (data/variables) and methods (functions) that its objects will have.
Think of a class as a template for making objects. For example, a Car
class might define attributes like color, brand, speed and methods like accelerate() and brake().
Example of a Class in Python
# Defining a class
class Car:
def __init__(self, brand, color): # Constructor method
self.brand = brand # Attribute
self.color = color # Attribute
def drive(self): # Method
print(f"The {self.color} {self.brand} is driving.")
# Creating objects (instances) of the Car class
car1 = Car("Tesla", "Red")
car2 = Car("BMW", "Blue")
# Calling methods
car1.drive() # Output: The Red Tesla is driving.
car2.drive() # Output: The Blue BMW is driving.
What is an Object?
An object is an instance of a class. It is a real-world entity that has the properties and behaviors defined in the class.
For example:
car1 = Car("Tesla", "Red")
→ Creates an object of theCar
class with brand=Tesla and color=Redcar2 = Car("BMW", "Blue")
→ Another object with different attributes
Understanding the __init__
Method
The __init__
method is a constructor that is called automatically when an object is created. It is used to initialize attributes.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def greet(self):
print(f"Hello, my name is {self.name} and I am {self.age} years old.")
# Creating objects
person1 = Person("Alice", 25)
person2 = Person("Bob", 30)
# Calling method
person1.greet() # Output: Hello, my name is Alice and I am 25 years old.
person2.greet() # Output: Hello, my name is Bob and I am 30 years old.
Key Differences Between a Class and an Object
Feature | Class | Object |
Definition | A blueprint for creating objects | An instance of a class |
Usage | Defines attributes and methods | Stores actual data and behaviors |
Example | Car class defines brand, color, and drive method | car1 = Car("Tesla", "Red") is an object |
Why Use Classes and Objects?
✅ Code Reusability – Define once, create multiple objects
✅ Encapsulation – Protects data using private attributes
✅ Scalability – Makes large programs more manageable
✅ Real-World Modeling – Helps represent real-world entities
Constructors in Python (__init__
Method)
A constructor is a special method in a class that is automatically called when an object is created. In Python, the constructor is defined using the __init__
method.
The main purpose of a constructor is to initialize an object's attributes when it is created.
Syntax of __init__
Constructor
class ClassName:
def __init__(self, parameter1, parameter2):
self.attribute1 = parameter1
self.attribute2 = parameter2
Example: Understanding __init__
Method
class Person:
def __init__(self, name, age): # Constructor
self.name = name # Initializing attributes
self.age = age
def greet(self): # Method
print(f"Hello, my name is {self.name} and I am {self.age} years old.")
# Creating objects
person1 = Person("Alice", 25)
person2 = Person("Bob", 30)
# Calling method
person1.greet() # Output: Hello, my name is Alice and I am 25 years old.
person2.greet() # Output: Hello, my name is Bob and I am 30 years old.
How __init__
Works Here?
When
person1 = Person("Alice", 25)
is executed:The
__init__
method runs automatically.self.name
is set to"Alice"
, andself.age
is set to25
.A new object
person1
is created with these values.
Similarly,
person2
is created with different values.
Types of Constructors in Python
Python supports three types of constructors:
1️⃣ Default Constructor (No Parameters)
A constructor without parameters, except self
.
class Example:
def __init__(self): # Default constructor
print("Default Constructor Called!")
# Creating an object
obj = Example() # Output: Default Constructor Called!
2️⃣ Parameterized Constructor (With Arguments)
A constructor that takes parameters to initialize object attributes.
class Car:
def __init__(self, brand, color):
self.brand = brand
self.color = color
def display(self):
print(f"Car Brand: {self.brand}, Color: {self.color}")
# Creating objects with parameters
car1 = Car("Tesla", "Red")
car2 = Car("BMW", "Blue")
car1.display() # Output: Car Brand: Tesla, Color: Red
car2.display() # Output: Car Brand: BMW, Color: Blue
3️⃣ Constructor with Default Values
A constructor where some parameters have default values.
class Student:
def __init__(self, name, grade="Not Assigned"): # Default value
self.name = name
self.grade = grade
def display(self):
print(f"Student: {self.name}, Grade: {self.grade}")
# Creating objects with and without the second parameter
student1 = Student("Alice", "A")
student2 = Student("Bob") # Uses default grade
student1.display() # Output: Student: Alice, Grade: A
student2.display() # Output: Student: Bob, Grade: Not Assigned
Key Points About Constructors (__init__
)
✅ Automatically called when an object is created.
✅ Used to initialize attributes of the class.
✅ Can have default values for parameters.
✅ If not defined, Python provides a default constructor (empty constructor).
Instance Variables and Methods in Python
In Python's Object-Oriented Programming (OOP), instance variables and instance methods define the behavior and characteristics of individual objects.
Instance Variables
What are Instance Variables?
✅ Instance variables are unique to each object and hold data that belongs to that specific instance.
✅ They are defined inside the __init__
method using self.variable_name
.
✅ Each object gets its own copy of instance variables.
Example of Instance Variables:
class Person:
def __init__(self, name, age):
self.name = name # Instance variable
self.age = age # Instance variable
# Creating objects
person1 = Person("Alice", 25)
person2 = Person("Bob", 30)
# Accessing instance variables
print(person1.name, person1.age) # Output: Alice 25
print(person2.name, person2.age) # Output: Bob 30
🔹 Each object has its own name
and age
variables.
Instance Methods
What are Instance Methods?
✅ Instance methods operate on instance variables and can access or modify object-specific data.
✅ They must include self
as the first parameter to refer to the current object.
✅ They are called using an object of the class.
Example of Instance Methods:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def greet(self): # Instance method
print(f"Hello, my name is {self.name} and I am {self.age} years old.")
# Creating an object
person1 = Person("Alice", 25)
person1.greet() # Output: Hello, my name is Alice and I am 25 years old.
🔹 The greet()
method uses self.name
and self.age
, so it works for each object separately.
Modifying Instance Variables Using Methods
Instance methods can update or modify instance variables.
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner
self.balance = balance
def deposit(self, amount): # Instance method
self.balance += amount
print(f"{self.owner} deposited ${amount}. New Balance: ${self.balance}")
def withdraw(self, amount): # Instance method
if amount > self.balance:
print(f"Insufficient funds for {self.owner}!")
else:
self.balance -= amount
print(f"{self.owner} withdrew ${amount}. New Balance: ${self.balance}")
# Creating objects
account1 = BankAccount("Alice", 1000)
account2 = BankAccount("Bob", 500)
# Calling instance methods
account1.deposit(500) # Output: Alice deposited $500. New Balance: $1500
account1.withdraw(200) # Output: Alice withdrew $200. New Balance: $1300
account2.withdraw(600) # Output: Insufficient funds for Bob!
Key Differences Between Instance Variables & Methods
Feature | Instance Variable | Instance Method |
Definition | Stores object-specific data | Performs actions on instance variables |
Where Defined? | Inside __init__ method | Inside the class, has self parameter |
Scope | Belongs to a specific object | Can access and modify instance variables |
Accessed By | object.variable_name | object.method_name() |
Example | self.name = "Alice" | def greet(self): print( self.name ) |
Class Variables and Class Methods (@classmethod
) in Python
In Python's Object-Oriented Programming (OOP), class variables and class methods are used when we want to define data and behavior that should be shared across all instances of a class.
Class Variables
What are Class Variables?
✅ Class variables are shared across all instances of a class.
✅ They are defined outside the __init__
method, inside the class.
✅ Changing a class variable affects all instances of the class.
Example of Class Variables
class Employee:
company = "TechCorp" # Class variable (shared across all objects)
def __init__(self, name, salary):
self.name = name # Instance variable (unique to each object)
self.salary = salary # Instance variable
def display(self):
print(f"Employee: {self.name}, Salary: {self.salary}, Company: {Employee.company}")
# Creating objects
emp1 = Employee("Alice", 50000)
emp2 = Employee("Bob", 60000)
# Accessing class and instance variables
emp1.display() # Output: Employee: Alice, Salary: 50000, Company: TechCorp
emp2.display() # Output: Employee: Bob, Salary: 60000, Company: TechCorp
# Modifying the class variable
Employee.company = "CodeLabs"
emp1.display() # Output: Employee: Alice, Salary: 50000, Company: CodeLabs
emp2.display() # Output: Employee: Bob, Salary: 60000, Company: CodeLabs
🔹 Key Takeaways on Class Variables:
Shared by all instances of the class.
Can be accessed using
ClassName.variable_name
.Changing the class variable affects all instances.
Class Methods (@classmethod
)
What are Class Methods?
✅ Class methods operate on class variables and work at the class level, not at the instance level.
✅ They do not modify instance variables, but can modify class variables.
✅ They take cls
as the first parameter instead of self
.
✅ They are defined using the @classmethod
decorator.
Example of Class Methods
class Employee:
company = "TechCorp" # Class variable
def __init__(self, name, salary):
self.name = name # Instance variable
self.salary = salary # Instance variable
@classmethod
def change_company(cls, new_company):
cls.company = new_company # Modifying class variable
def display(self):
print(f"Employee: {self.name}, Salary: {self.salary}, Company: {Employee.company}")
# Creating objects
emp1 = Employee("Alice", 50000)
emp2 = Employee("Bob", 60000)
emp1.display() # Output: Employee: Alice, Salary: 50000, Company: TechCorp
emp2.display() # Output: Employee: Bob, Salary: 60000, Company: TechCorp
# Using class method to change the class variable
Employee.change_company("CodeLabs")
emp1.display() # Output: Employee: Alice, Salary: 50000, Company: CodeLabs
emp2.display() # Output: Employee: Bob, Salary: 60000, Company: CodeLabs
How @classmethod
Works Here?
change_company(cls, new_company)
modifies thecompany
class variable.Calling
Employee.change_company("CodeLabs")
changescompany
for all objects.Unlike instance methods, class methods don't use
self
, because they don't operate on instance variables.
Difference Between Instance & Class Variables & Methods
Feature | Instance Variable | Class Variable |
Definition | Defined inside __init__ | Defined inside class but outside __init__ |
Belongs To | Specific object (instance) | Class (shared across all objects) |
Modified By | Instance methods | Class methods |
Accessed By | self.variable_name | ClassName.variable_name |
Feature | Instance Method | Class Method |
Definition | Defined with self parameter | Defined with @classmethod and cls parameter |
Works On | Instance variables | Class variables |
Called By | Object (obj.method() ) | Class (ClassName.method() ) |
Example | def display(self): | @classmethod def update(cls): |
Static Methods (@staticmethod
) in Python
A static method is a method inside a class that does not depend on instance (self
) or class (cls
) variables. It behaves like a regular function but is logically grouped inside a class for better organization.
Key Features of Static Methods
✅ Defined using the @staticmethod
decorator.
✅ Does not use self
(instance reference) or cls
(class reference).
✅ Can be called using either the class name or an instance.
✅ Used when a function logically belongs to the class but does not need access to class or instance attributes.
🔹 Syntax of @staticmethod
class ClassName:
@staticmethod
def method_name(parameters):
# Code here
Example of a Static Method
class MathOperations:
@staticmethod
def add(x, y):
return x + y
@staticmethod
def multiply(x, y):
return x * y
# Calling static methods using the class name
print(MathOperations.add(5, 3)) # Output: 8
print(MathOperations.multiply(4, 2)) # Output: 8
# Calling static methods using an instance
math_obj = MathOperations()
print(math_obj.add(10, 5)) # Output: 15
✅ add()
and multiply()
are static methods because they don’t modify or access instance/class attributes.
When to Use Static Methods?
1️⃣ Utility Functions Inside a Class
Static methods are useful when you need a function that relates to the class but doesn’t use class or instance data.
import datetime
class DateUtils:
@staticmethod
def is_weekend(day):
return day.weekday() >= 5 # 5 = Saturday, 6 = Sunday
# Usage
today = datetime.date.today()
print(DateUtils.is_weekend(today)) # Output: True or False based on the day
✅ is_weekend()
checks if a given date is a weekend but does not modify class or instance attributes.
2️⃣ Avoiding Unnecessary Object Creation
Sometimes, you don’t need an instance but still need to call a method.
class Validator:
@staticmethod
def is_positive(number):
return number > 0
# Calling without creating an object
print(Validator.is_positive(10)) # Output: True
print(Validator.is_positive(-5)) # Output: False
✅ is_positive()
is a self-contained function that logically belongs to Validator
, so it’s defined as a static method.
Inheritance in Python (OOP)
Inheritance is one of the fundamental principles of Object-Oriented Programming (OOP). It allows a new class (child class) to derive properties and behaviors from an existing class (parent class), enabling code reuse and hierarchical relationships.
🔹 Why Use Inheritance?
✅ Promotes code reuse (avoid redundant code).
✅ Establishes a parent-child relationship between classes.
✅ Allows method overriding to customize behavior in child classes.
✅ Supports polymorphism (same method, different implementations).
🔹 Basic Syntax of Inheritance
class ParentClass:
# Parent class attributes and methods
pass
class ChildClass(ParentClass):
# Inherits from ParentClass
pass
👆 The child class (ChildClass
) inherits all attributes and methods from the parent class (ParentClass
).
🔹 Example of Inheritance
# Parent class
class Animal:
def __init__(self, name):
self.name = name
def make_sound(self):
return "Some generic animal sound"
# Child class inheriting from Animal
class Dog(Animal):
def make_sound(self): # Method Overriding
return "Bark!"
# Creating objects
animal = Animal("Generic Animal")
dog = Dog("Buddy")
print(animal.name, "says:", animal.make_sound()) # Output: Generic Animal says: Some generic animal sound
print(dog.name, "says:", dog.make_sound()) # Output: Buddy says: Bark!
✅ The Dog
class inherits the name
attribute from Animal
but overrides make_sound()
.
🔹 Types of Inheritance in Python
1️⃣ Single Inheritance
A child class inherits from one parent class.
class Parent:
def display(self):
print("This is Parent class")
class Child(Parent):
pass # Inherits everything from Parent
obj = Child()
obj.display() # Output: This is Parent class
2️⃣ Multiple Inheritance
A child class inherits from multiple parent classes.
class Father:
def skill(self):
print("Father: Knows carpentry")
class Mother:
def skill(self):
print("Mother: Knows painting")
class Child(Father, Mother):
pass
obj = Child()
obj.skill() # Output: Father: Knows carpentry (Follows Method Resolution Order - MRO)
✅ Python follows Method Resolution Order (MRO), so Father.skill()
is called first.
3️⃣ Multilevel Inheritance
A class inherits from another class, which in turn inherits from another class, forming a chain.
class Grandparent:
def family_name(self):
print("Family name: Smith")
class Parent(Grandparent):
def parent_info(self):
print("Parent: Engineer")
class Child(Parent):
def child_info(self):
print("Child: Student")
obj = Child()
obj.family_name() # ✅ Output: Family name: Smith
obj.parent_info() # ✅ Output: Parent: Engineer
obj.child_info() # ✅ Output: Child: Student
✅ Child inherits from Parent, and Parent inherits from Grandparent.
4️⃣ Hierarchical Inheritance
Multiple child classes inherit from the same parent class.
class Vehicle:
def vehicle_info(self):
print("This is a vehicle")
class Car(Vehicle):
def car_info(self):
print("This is a car")
class Bike(Vehicle):
def bike_info(self):
print("This is a bike")
car = Car()
bike = Bike()
car.vehicle_info() # ✅ Output: This is a vehicle
car.car_info() # ✅ Output: This is a car
bike.vehicle_info() # ✅ Output: This is a vehicle
bike.bike_info() # ✅ Output: This is a bike
✅ Both Car
and Bike
inherit vehicle_info()
from Vehicle
.
5️⃣ Hybrid Inheritance
A combination of multiple inheritance types.
class A:
def method_A(self):
print("Class A")
class B(A):
def method_B(self):
print("Class B")
class C(A):
def method_C(self):
print("Class C")
class D(B, C):
def method_D(self):
print("Class D")
obj = D()
obj.method_A() # ✅ Inherited from A
obj.method_B() # ✅ Inherited from B
obj.method_C() # ✅ Inherited from C
obj.method_D() # ✅ Defined in D
✅ This structure is a mix of multiple and hierarchical inheritance.
Method Resolution Order (MRO) in Inheritance
Python uses the C3 Linearization Algorithm (MRO) to determine the method execution order in multiple inheritance.
You can check the MRO of a class using:
print(D.__mro__) # OR
print(D.mro())
Using super()
in Inheritance
The super()
function allows calling methods from the parent class inside the child class.
class Parent:
def show(self):
print("This is the parent class")
class Child(Parent):
def show(self):
super().show() # Call Parent's show()
print("This is the child class")
obj = Child()
obj.show()
# ✅ Output:
# This is the parent class
# This is the child class
Method Overriding in Python (OOP)
Method Overriding allows a child class to redefine a method from its parent class with a new implementation. It is a key feature of polymorphism in Object-Oriented Programming (OOP).
Why Use Method Overriding?
✅ Allows customization of inherited methods.
✅ Implements polymorphism (same method name, different behavior).
✅ Enhances code reuse and modularity.
Method Overriding Syntax
class Parent:
def show(self):
print("This is the parent class method.")
class Child(Parent):
def show(self): # Overriding the parent method
print("This is the child class method.")
# Creating objects
obj = Child()
obj.show() # Output: This is the child class method.
✅ The child class overrides the show()
method from the parent class.
Example: Overriding a Parent Class Method
class Animal:
def make_sound(self):
return "Some generic animal sound"
class Dog(Animal):
def make_sound(self): # Overriding method
return "Bark!"
class Cat(Animal):
def make_sound(self): # Overriding method
return "Meow!"
# Creating objects
dog = Dog()
cat = Cat()
print(dog.make_sound()) # Output: Bark!
print(cat.make_sound()) # Output: Meow!
✅ The make_sound()
method is overridden in both Dog
and Cat
classes, each providing its own behavior.
Using super()
to Call Parent Class Method
The super()
function is used to call the parent class method inside the overridden method.
class Parent:
def show(self):
print("This is the parent class method.")
class Child(Parent):
def show(self):
super().show() # Calls Parent's show() method
print("This is the child class method.")
# Creating object
obj = Child()
obj.show()
🔹 Output:
kotlinCopyEditThis is the parent class method.
This is the child class method.
✅ super().show()
ensures the parent method is executed before the overridden method in the child class.
Overriding __init__()
(Constructor Overriding)
A child class can override the constructor (__init__()
) of a parent class.
class Parent:
def __init__(self, name):
self.name = name
print(f"Parent Constructor: {self.name}")
class Child(Parent):
def __init__(self, name, age):
super().__init__(name) # Calling Parent's constructor
self.age = age
print(f"Child Constructor: {self.name}, Age: {self.age}")
# Creating object
obj = Child("Alice", 25)
🔹 Output:
Parent Constructor: Alice
Child Constructor: Alice, Age: 25
✅ The child class overrides __init__()
but still calls the parent constructor using super()
.
Multiple Inheritance in Python (OOP)
Multiple Inheritance allows a class to inherit from more than one parent class. This means the child class can access attributes and methods from multiple base classes, combining functionalities.
Why Use Multiple Inheritance?
✅ Allows a class to reuse code from multiple parent classes.
✅ Helps in building complex relationships between classes.
✅ A child class can combine behaviors from multiple parents.
Syntax of Multiple Inheritance
class Parent1:
def method1(self):
print("Method from Parent1")
class Parent2:
def method2(self):
print("Method from Parent2")
class Child(Parent1, Parent2): # Inheriting from both Parent1 and Parent2
pass
# Creating an object
obj = Child()
obj.method1() # Output: Method from Parent1
obj.method2() # Output: Method from Parent2
✅ The Child
class inherits methods from both parent classes.
Example of Multiple Inheritance
class Father:
def skill(self):
print("Father: Knows Carpentry")
class Mother:
def skill(self):
print("Mother: Knows Painting")
class Child(Father, Mother):
pass
# Creating an object of Child
obj = Child()
obj.skill() # Output: Father: Knows Carpentry
✅ Why does it call Father.skill()
first?
Python follows the Method Resolution Order (MRO), which searches methods from left to right in the class definition (Father
→ Mother
).
Handling Method Conflicts with super()
If both parent classes have a method with the same name, the method from the first parent in the inheritance list is called.
class Parent1:
def show(self):
print("This is Parent1")
class Parent2:
def show(self):
print("This is Parent2")
class Child(Parent1, Parent2):
def show(self):
super().show() # Calls Parent1's method
obj = Child()
obj.show() # Output: This is Parent1
✅ Using super()
ensures proper method resolution.
The Diamond Problem in Multiple Inheritance
The diamond problem occurs when a class inherits from two classes that have a common parent.
class A:
def show(self):
print("This is A")
class B(A):
def show(self):
print("This is B")
class C(A):
def show(self):
print("This is C")
class D(B, C): # Inheriting from both B and C, which inherit from A
pass
obj = D()
obj.show() # Output: This is B (because of MRO)
✅ Python resolves this using Method Resolution Order (MRO): D → B → C → A
.
To check the MRO of a class:
print(D.mro())
🔹 MRO Output: [D, B, C, A, object]
Polymorphism in Python (OOP)
Polymorphism means "many forms." In Object-Oriented Programming (OOP), polymorphism allows different classes to have methods with the same name but different behaviors.
Why Use Polymorphism?
✅ Increases code flexibility by allowing different objects to be treated the same way.
✅ Simplifies code by using the same method name for different data types or objects.
✅ Supports method overriding and method overloading (limited in Python).
Types of Polymorphism in Python
1️⃣ Method Overriding (Runtime Polymorphism)
A child class overrides a method from the parent class to provide a new behavior.
The method name remains the same, but the behavior changes in the child class.
class Animal:
def make_sound(self):
return "Some generic animal sound"
class Dog(Animal):
def make_sound(self): # Overriding method
return "Bark!"
class Cat(Animal):
def make_sound(self): # Overriding method
return "Meow!"
# Using polymorphism
animals = [Dog(), Cat(), Animal()]
for animal in animals:
print(animal.make_sound())
🔹 Output:
rustCopyEditBark!
Meow!
Some generic animal sound
✅ The make_sound()
method behaves differently based on the object type.
2️⃣ Method Overloading (Compile-Time Polymorphism) – Not Natively Supported in Python
Unlike other languages, Python does not support method overloading in the traditional sense. However, we can achieve similar functionality using default arguments or *args
/**kwargs
.
class MathOperations:
def add(self, x, y, z=0): # Default argument for method overloading
return x + y + z
obj = MathOperations()
print(obj.add(2, 3)) # Output: 5
print(obj.add(2, 3, 4)) # Output: 9
✅ Here, add()
acts like an overloaded function by using a default parameter.
3️⃣ Operator Overloading (Magic Methods)
Python allows operators like +
, -
, *
to work differently for different data types by using special methods (dunder methods).
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other): # Overloading the `+` operator
return Point(self.x + other.x, self.y + other.y)
# Creating objects
p1 = Point(2, 3)
p2 = Point(4, 5)
result = p1 + p2 # Uses __add__()
print(f"Result: ({result.x}, {result.y})") # Output: (6, 8)
✅ The +
operator is overloaded to work with custom objects.
4️⃣ Polymorphism with Functions and Classes
Polymorphism works even if the objects belong to different classes, as long as they have the same method names.
class Car:
def move(self):
return "The car is driving"
class Boat:
def move(self):
return "The boat is sailing"
class Plane:
def move(self):
return "The plane is flying"
# Function using polymorphism
def transport_mode(vehicle):
print(vehicle.move())
# Calling function with different objects
vehicles = [Car(), Boat(), Plane()]
for v in vehicles:
transport_mode(v)
🔹 Output:
csharpCopyEditThe car is driving
The boat is sailing
The plane is flying
✅ Even though each class is different, they all have a move()
method, enabling polymorphism.
Encapsulation in Python (OOP)
Encapsulation is one of the core principles of Object-Oriented Programming (OOP). It is the practice of restricting direct access to certain data within a class and only allowing controlled interaction through methods.
Why Use Encapsulation?
✅ Protects data from accidental modification.
✅ Hides implementation details and exposes only necessary functionality.
✅ Increases security by restricting direct access to variables.
✅ Improves code maintainability by defining controlled ways to interact with data.
Encapsulation in Python
Python implements encapsulation using private and protected members:
Public Members → Accessible anywhere.
Protected Members (
_variable
) → Should be accessed only within the class or subclasses (convention, not enforced).Private Members (
__variable
) → Cannot be accessed directly outside the class (name-mangling applies).
Example: Public, Protected, and Private Members
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # Public attribute
self._account_type = "Savings" # Protected attribute
self.__balance = balance # Private attribute
def deposit(self, amount):
self.__balance += amount
print(f"Deposited {amount}. New balance: {self.__balance}")
def withdraw(self, amount):
if amount <= self.__balance:
self.__balance -= amount
print(f"Withdrew {amount}. New balance: {self.__balance}")
else:
print("Insufficient funds!")
def get_balance(self): # Public method to access private data
return self.__balance
# Creating an object
account = BankAccount("Alice", 5000)
# Accessing public attribute
print(account.owner) # ✅ Output: Alice
# Accessing protected attribute (allowed, but not recommended)
print(account._account_type) # ✅ Output: Savings
# Trying to access private attribute directly (will fail)
# print(account.__balance) ❌ AttributeError: 'BankAccount' object has no attribute '__balance'
# Accessing private attribute using a method
print(account.get_balance()) # ✅ Output: 5000
# Depositing and withdrawing money
account.deposit(2000) # ✅ Output: Deposited 2000. New balance: 7000
account.withdraw(1000) # ✅ Output: Withdrew 1000. New balance: 6000
Name Mangling: Accessing Private Members
Python performs name mangling to protect private members. You can access them using _ClassName__attribute
, but this is not recommended.
print(account._BankAccount__balance) # ✅ Output: 6000 (Avoid doing this!)
✅ This works, but breaks encapsulation. Always use getter methods instead!
Using Getters and Setters (Encapsulation Best Practice)
To properly access and modify private attributes, use getter and setter methods.
class Car:
def __init__(self, model, price):
self.model = model
self.__price = price # Private variable
def get_price(self): # Getter method
return self.__price
def set_price(self, new_price): # Setter method
if new_price > 0:
self.__price = new_price
else:
print("Invalid price!")
# Creating object
car = Car("Tesla", 50000)
# Accessing private variable using getter
print(car.get_price()) # ✅ Output: 50000
# Modifying private variable using setter
car.set_price(55000)
print(car.get_price()) # ✅ Output: 55000
# Trying to set an invalid price
car.set_price(-10000) # ❌ Output: Invalid price!
✅ Getters and Setters ensure controlled access to private data.
Using @property
for Encapsulation (Pythonic Way)
Instead of manually defining getter and setter methods, Python provides the @property
decorator.
class Employee:
def __init__(self, name, salary):
self.name = name
self.__salary = salary # Private variable
@property
def salary(self): # Getter
return self.__salary
@salary.setter
def salary(self, new_salary): # Setter
if new_salary > 0:
self.__salary = new_salary
else:
print("Salary must be positive!")
# Creating an object
emp = Employee("John", 5000)
# Accessing salary using @property
print(emp.salary) # ✅ Output: 5000
# Modifying salary using @salary.setter
emp.salary = 6000
print(emp.salary) # ✅ Output: 6000
# Trying to set a negative salary
emp.salary = -2000 # ❌ Output: Salary must be positive!
✅ This is the most Pythonic way to use getters and setters!
Data Hiding in Python (__Private Variables)
Data Hiding is a crucial part of Encapsulation, which ensures that sensitive data is not directly accessible outside the class. This is achieved using private variables in Python.
What Are Private Variables (__variable
)?
In Python, private variables are prefixed with double underscores (__
). They cannot be accessed directly from outside the class.
Example: Private Variables in Python
class BankAccount:
def __init__(self, owner, balance):
self.owner = owner # Public variable
self.__balance = balance # Private variable
def get_balance(self): # Getter method to access private data
return self.__balance
def deposit(self, amount):
if amount > 0:
self.__balance += amount
print(f"Deposited {amount}. New balance: {self.__balance}")
else:
print("Invalid deposit amount!")
def withdraw(self, amount):
if 0 < amount <= self.__balance:
self.__balance -= amount
print(f"Withdrew {amount}. New balance: {self.__balance}")
else:
print("Insufficient funds!")
# Creating an object
account = BankAccount("Alice", 5000)
# Accessing public attribute
print(account.owner) # ✅ Output: Alice
# Trying to access private attribute directly (This will fail)
# print(account.__balance) ❌ AttributeError: 'BankAccount' object has no attribute '__balance'
# Accessing private variable using a getter method
print(account.get_balance()) # ✅ Output: 5000
✅ Private variables prevent direct access and enforce controlled access through methods.
Name Mangling: Accessing Private Variables (Not Recommended)
Python performs name mangling by renaming private variables internally as _ClassName__variable
.
This can be used to force access to private variables, but it breaks encapsulation and should be avoided.
print(account._BankAccount__balance) # ✅ Output: 5000 (Not recommended)
⚠️ Even though this works, you should never access private variables like this! Always use getter methods instead.
Best Practice: Use Getters and Setters to Access Private Data
To properly access and modify private attributes, use getter and setter methods.
class Car:
def __init__(self, model, price):
self.model = model
self.__price = price # Private variable
def get_price(self): # Getter method
return self.__price
def set_price(self, new_price): # Setter method
if new_price > 0:
self.__price = new_price
else:
print("Invalid price!")
# Creating object
car = Car("Tesla", 50000)
# Accessing private variable using getter
print(car.get_price()) # ✅ Output: 50000
# Modifying private variable using setter
car.set_price(55000)
print(car.get_price()) # ✅ Output: 55000
# Trying to set an invalid price
car.set_price(-10000) # ❌ Output: Invalid price!
✅ This ensures controlled access and data integrity.
Using @property
for Private Variables (Pythonic Way)
Instead of manually defining getter and setter methods, Python provides the @property
decorator.
class Employee:
def __init__(self, name, salary):
self.name = name
self.__salary = salary # Private variable
@property
def salary(self): # Getter
return self.__salary
@salary.setter
def salary(self, new_salary): # Setter
if new_salary > 0:
self.__salary = new_salary
else:
print("Salary must be positive!")
# Creating an object
emp = Employee("John", 5000)
# Accessing private variable using @property
print(emp.salary) # ✅ Output: 5000
# Modifying private variable using @salary.setter
emp.salary = 6000
print(emp.salary) # ✅ Output: 6000
# Trying to set a negative salary
emp.salary = -2000 # ❌ Output: Salary must be positive!
✅ This is the recommended way to handle private attributes in Python!
Abstraction in Python (OOP)
What is Abstraction?
Abstraction is an OOP principle that hides the implementation details and only exposes essential features of an object. It allows us to define a blueprint for a class without specifying its exact implementation.
In Python, abstraction is implemented using abstract classes and the ABC
module.
Why Use Abstraction?
✅ Hides unnecessary details and exposes only essential features.
✅ Provides a blueprint for derived classes without enforcing a specific implementation.
✅ Ensures method implementation in child classes.
✅ Supports maintainability and scalability by enforcing a structure.
Abstract Classes and Methods
In Python, an abstract class is a class that cannot be instantiated and contains one or more abstract methods.
Abstract classes are created using
ABC
(Abstract Base Class) from theabc
module.Abstract methods are declared using
@abstractmethod
.Child classes must override all abstract methods to be instantiated.
Example: Abstract Class with ABC
Module
from abc import ABC, abstractmethod
# Abstract class
class Vehicle(ABC):
@abstractmethod
def start_engine(self): # Abstract method (no implementation)
pass
@abstractmethod
def stop_engine(self): # Abstract method (no implementation)
pass
# Concrete class (must implement all abstract methods)
class Car(Vehicle):
def start_engine(self):
print("Car engine started.")
def stop_engine(self):
print("Car engine stopped.")
# Creating an object
car = Car()
car.start_engine() # ✅ Output: Car engine started.
car.stop_engine() # ✅ Output: Car engine stopped.
# Trying to instantiate the abstract class (will fail)
# vehicle = Vehicle() ❌ TypeError: Can't instantiate abstract class Vehicle
✅ The Vehicle
class acts as a blueprint, enforcing start_engine()
and stop_engine()
methods in child classes.
Abstract Class with Concrete Methods
Abstract classes can also have normal (concrete) methods that subclasses inherit.
from abc import ABC, abstractmethod
class Animal(ABC):
def eat(self): # Concrete method (common behavior)
print("This animal eats food.")
@abstractmethod
def make_sound(self): # Abstract method
pass
class Dog(Animal):
def make_sound(self):
print("Bark!")
class Cat(Animal):
def make_sound(self):
print("Meow!")
# Creating objects
dog = Dog()
dog.eat() # ✅ Output: This animal eats food.
dog.make_sound() # ✅ Output: Bark!
cat = Cat()
cat.make_sound() # ✅ Output: Meow!
✅ Subclasses inherit eat()
from the abstract class and must implement make_sound()
.
Abstract Properties
You can also define abstract properties that must be implemented in subclasses.
from abc import ABC, abstractmethod
class Shape(ABC):
@property
@abstractmethod
def area(self):
pass
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
@property
def area(self):
return 3.14 * self.radius * self.radius
# Creating object
circle = Circle(5)
print(circle.area) # ✅ Output: 78.5
✅ The area
property must be implemented in the child class.
Abstract Class with @staticmethod
and @classmethod
Abstract classes can also have static methods and class methods.
from abc import ABC, abstractmethod
class Database(ABC):
@abstractmethod
def connect(self):
pass
@staticmethod
@abstractmethod
def info():
pass
class MySQLDatabase(Database):
def connect(self):
print("Connecting to MySQL database...")
@staticmethod
def info():
print("MySQL is an open-source relational database.")
# Creating object
db = MySQLDatabase()
db.connect() # ✅ Output: Connecting to MySQL database.
db.info() # ✅ Output: MySQL is an open-source relational database.
✅ Static methods and class methods can also be abstract!
Magic (Dunder) Methods in Python
Magic methods, also known as dunder (double underscore) methods, are special methods in Python that start and end with double underscores (__
). These methods allow objects to define their behavior for built-in Python operations, such as string representation, length, addition, iteration, comparison, and more.
Why Use Magic Methods?
✅ Improve readability by customizing object behavior.
✅ Enable built-in operations like printing (__str__
), adding (__add__
), and comparing (__eq__
).
✅ Support operator overloading, making objects behave like built-in types.
Commonly Used Magic Methods
Here are some frequently used magic methods and their purposes:
Magic Method | Purpose |
__init__ | Constructor (initializes an object) |
__str__ | Returns a user-friendly string representation |
__repr__ | Returns an unambiguous string representation |
__len__ | Returns the length of an object |
__add__ | Enables addition (+ ) operator overloading |
__sub__ | Enables subtraction (- ) operator overloading |
__mul__ | Enables multiplication (* ) operator overloading |
__truediv__ | Enables division (/ ) operator overloading |
__eq__ | Enables equality (== ) comparison |
__lt__ | Enables less than (< ) comparison |
__gt__ | Enables greater than (> ) comparison |
__getitem__ | Enables indexing (obj[index] ) |
__setitem__ | Enables item assignment (obj[index] = value ) |
__iter__ | Makes an object iterable |
__next__ | Retrieves the next item in an iterator |
1. __init__
(Constructor)
The __init__
method is called when an object is created.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
# Creating an object
p = Person("Alice", 25)
print(p.name) # ✅ Output: Alice
print(p.age) # ✅ Output: 25
✅ __init__
initializes object attributes.
2. __str__
vs. __repr__
(String Representation)
🔹 __str__
→ User-friendly representation
🔹 __repr__
→ Developer-friendly representation
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"Person(name={self.name}, age={self.age})" # Readable output
def __repr__(self):
return f"Person('{self.name}', {self.age})" # Debugging output
p = Person("Alice", 25)
print(str(p)) # ✅ Output: Person(name=Alice, age=25)
print(repr(p)) # ✅ Output: Person('Alice', 25)
✅ Use __str__
for users and __repr__
for debugging/logging.
3. __len__
(Length of an Object)
The __len__
method defines the behavior of the len()
function.
class Book:
def __init__(self, title, pages):
self.title = title
self.pages = pages
def __len__(self):
return self.pages
book = Book("Python Basics", 300)
print(len(book)) # ✅ Output: 300
✅ Use __len__
to define object size.
4. Operator Overloading (__add__
, __sub__
, etc.)
Python allows objects to overload operators by defining magic methods.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other): # Overloading `+`
return Vector(self.x + other.x, self.y + other.y)
def __sub__(self, other): # Overloading `-`
return Vector(self.x - other.x, self.y - other.y)
def __str__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(2, 3)
v2 = Vector(4, 5)
print(v1 + v2) # ✅ Output: Vector(6, 8)
print(v1 - v2) # ✅ Output: Vector(-2, -2)
✅ Operator overloading makes custom objects behave like built-in types.
5. Comparison Operators (__eq__
, __lt__
, __gt__
)
Define custom comparison logic for objects.
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def __eq__(self, other): # Overloading `==`
return self.grade == other.grade
def __lt__(self, other): # Overloading `<`
return self.grade < other.grade
def __gt__(self, other): # Overloading `>`
return self.grade > other.grade
s1 = Student("Alice", 90)
s2 = Student("Bob", 85)
print(s1 == s2) # ✅ Output: False
print(s1 > s2) # ✅ Output: True
print(s1 < s2) # ✅ Output: False
✅ Custom comparison logic allows object-based sorting and comparisons.
6. __getitem__
, __setitem__
(Indexing & Item Assignment)
Define how objects handle indexing and assignment.
class ShoppingCart:
def __init__(self):
self.items = {}
def __getitem__(self, item): # Getting an item
return self.items.get(item, 0)
def __setitem__(self, item, quantity): # Setting an item
self.items[item] = quantity
cart = ShoppingCart()
cart["apple"] = 5 # ✅ Calls `__setitem__`
print(cart["apple"]) # ✅ Calls `__getitem__`, Output: 5
✅ This enables dictionary-like behavior for custom objects.
7. __iter__
, __next__
(Iteration)
Define objects that can be looped over.
class Counter:
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self # Returning the iterator object itself
def __next__(self):
if self.current >= self.end:
raise StopIteration # Stop when end is reached
self.current += 1
return self.current - 1
counter = Counter(1, 5)
for num in counter:
print(num) # ✅ Output: 1 2 3 4
✅ __iter__
and __next__
enable iteration over custom objects.
Operator Overloading in Python
What is Operator Overloading?
Operator overloading allows us to define how operators (+
, -
, *
, /
, etc.) behave for custom objects. By default, these operators work with built-in types like integers and strings, but with magic methods (dunder methods), we can make them work with user-defined classes as well.
For example, +
is used for both numeric addition (2 + 3
) and string concatenation ("Hello" + "World"
). With operator overloading, we can define custom behavior for +
and other operators.
Why Use Operator Overloading?
✅ Makes objects behave like built-in types.
✅ Improves code readability.
✅ Allows mathematical operations on custom objects.
✅ Enables comparison between objects.
Magic Methods for Operator Overloading
Python provides magic methods (dunder methods) to define behavior for operators:
Operator | Magic Method | Example |
+ | __add__(self, other) | obj1 + obj2 |
- | __sub__(self, other) | obj1 - obj2 |
* | __mul__(self, other) | obj1 * obj2 |
/ | __truediv__(self, other) | obj1 / obj2 |
// | __floordiv__(self, other) | obj1 // obj2 |
% | __mod__(self, other) | obj1 % obj2 |
** | __pow__(self, other) | obj1 ** obj2 |
== | __eq__(self, other) | obj1 == obj2 |
!= | __ne__(self, other) | obj1 != obj2 |
< | __lt__(self, other) | obj1 < obj2 |
> | __gt__(self, other) | obj1 > obj2 |
<= | __le__(self, other) | obj1 <= obj2 |
>= | __ge__(self, other) | obj1 >= obj2 |
1. Overloading +
(Addition)
We can overload +
by defining the __add__
method.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __add__(self, other): # Overloading `+`
return Vector(self.x + other.x, self.y + other.y)
def __str__(self): # For readable output
return f"Vector({self.x}, {self.y})"
v1 = Vector(2, 3)
v2 = Vector(4, 5)
print(v1 + v2) # ✅ Output: Vector(6, 8)
✅ The +
operator now works with Vector
objects.
2. Overloading -
(Subtraction)
Similarly, we can overload -
by defining __sub__
.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def __sub__(self, other): # Overloading `-`
return Vector(self.x - other.x, self.y - other.y)
def __str__(self):
return f"Vector({self.x}, {self.y})"
v1 = Vector(5, 7)
v2 = Vector(2, 3)
print(v1 - v2) # ✅ Output: Vector(3, 4)
✅ The -
operator now works for our custom class.
3. Overloading *
(Multiplication)
We can define how objects multiply each other.
class Product:
def __init__(self, price):
self.price = price
def __mul__(self, quantity): # Overloading `*`
return self.price * quantity
p1 = Product(50)
print(p1 * 3) # ✅ Output: 150
✅ Now, we can multiply a Product
object with a number.
4. Overloading /
(Division)
We can define how objects divide each other.
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
def __truediv__(self, other): # Overloading `/`
return Temperature(self.celsius / other)
def __str__(self):
return f"{self.celsius}°C"
t1 = Temperature(100)
print(t1 / 2) # ✅ Output: 50.0°C
✅ Now, division works with custom objects.
5. Overloading Comparison Operators (==
, <
, >
)
Define custom comparison behavior for objects.
class Student:
def __init__(self, name, grade):
self.name = name
self.grade = grade
def __eq__(self, other): # Overloading `==`
return self.grade == other.grade
def __lt__(self, other): # Overloading `<`
return self.grade < other.grade
def __gt__(self, other): # Overloading `>`
return self.grade > other.grade
s1 = Student("Alice", 85)
s2 = Student("Bob", 90)
print(s1 == s2) # ✅ Output: False
print(s1 < s2) # ✅ Output: True
print(s1 > s2) # ✅ Output: False
✅ Objects can now be compared like numbers.
6. Overloading []
(Indexing with __getitem__
)
Allows objects to behave like lists or dictionaries.
class ShoppingCart:
def __init__(self):
self.items = {}
def __getitem__(self, item): # Overloading `[]`
return self.items.get(item, 0)
def __setitem__(self, item, quantity): # Setting an item
self.items[item] = quantity
cart = ShoppingCart()
cart["apple"] = 5 # ✅ Calls `__setitem__`
print(cart["apple"]) # ✅ Calls `__getitem__`, Output: 5
✅ Now, ShoppingCart
behaves like a dictionary!
Method Resolution Order (MRO) in Python
What is MRO?
Method Resolution Order (MRO) is the order in which Python looks for a method in a class hierarchy. It determines which method is called when a method is invoked on an instance, especially in the case of inheritance and multiple inheritance.
Python uses the C3 Linearization (also called the "C3 Algorithm") to determine MRO, ensuring that:
✅ A child class is checked before parent classes.
✅ A method is called only once in cases of multiple inheritance.
✅ The order remains consistent and predictable.
You can check the MRO of a class using:
ClassName.__mro__ # Returns a tuple showing MRO
help(ClassName) # Shows MRO details
Example: Simple MRO in Single Inheritance
In a single inheritance scenario, the MRO follows a top-down approach from child to parent.
class A:
def show(self):
print("A")
class B(A):
def show(self):
print("B")
obj = B()
obj.show() # ✅ Output: B
print(B.__mro__) # ✅ Output: (<class '__main__.B'>, <class '__main__.A'>, <class 'object'>)
✅ Since show()
is found in B
, Python doesn’t look in A
.
Example: MRO in Multiple Inheritance
In multiple inheritance, MRO ensures a consistent and conflict-free method resolution.
class A:
def show(self):
print("A")
class B(A):
def show(self):
print("B")
class C(A):
def show(self):
print("C")
class D(B, C): # Multiple Inheritance
pass
obj = D()
obj.show() # ✅ Output: B
print(D.__mro__)
# ✅ Output: (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
✅ MRO follows D → B → C → A → object
order, meaning Python looks in B
first, then C
, then A
.
MRO Using super()
The super()
function follows MRO to call methods in a parent class.
class A:
def show(self):
print("A")
class B(A):
def show(self):
super().show() # Calls A's show()
print("B")
obj = B()
obj.show()
# ✅ Output:
# A
# B
✅ super().show()
follows MRO and calls A.show
()
first.
Diamond Problem & MRO
The Diamond Problem occurs when a class inherits from two classes that share a common ancestor.
class A:
def show(self):
print("A")
class B(A):
def show(self):
print("B")
class C(A):
def show(self):
print("C")
class D(B, C): # Multiple Inheritance
pass
obj = D()
obj.show() # ✅ Output: B
print(D.__mro__)
# ✅ Output: (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
✅ MRO ensures A.show
()
is called only once, avoiding redundancy.
mro()
Method
Instead of using __mro__
, you can call mro()
method:
print(D.mro())
# ✅ Output: [<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]