# Deep Dive into Object-Oriented Programming (OOP) with Python

## **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**

1. **Classes and Objects** – A **class** is a blueprint, and an **object** is an instance of a class.
    
2. **Encapsulation** – Data and methods are bundled together, restricting direct access to some details.
    
3. **Inheritance** – A class can inherit properties and behaviors from another class.
    
4. **Polymorphism** – Objects can take multiple forms, allowing flexibility in coding.
    
5. **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:**

1. **Top-Down Approach** – Programs are structured in a linear flow from start to finish.
    
2. **Uses Functions** – Code is divided into reusable functions to avoid repetition.
    
3. **Global and Local Variables** – Data is stored in variables, which can be accessed by different parts of the program.
    
4. **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:**

1. **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.
        
2. **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.
        
3. **Code Reusability is Limited**
    
    * Functions can be reused, but without objects and inheritance, it is harder to create reusable components compared to OOP.
        
4. **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.
        
5. **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.

```python
# 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:**

1. Uses **global variables**, making the data **less secure**.
    
2. **Not scalable**—if we need multiple accounts, we must create separate variables for each.
    
3. **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.

```python
# 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**

```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 the `Car` class with **brand=Tesla** and **color=Red**
    
* `car2 = 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.

```python
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

```python
class ClassName:
    def __init__(self, parameter1, parameter2):
        self.attribute1 = parameter1
        self.attribute2 = parameter2
```

---

### **Example: Understanding** `__init__` Method

```python
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?

1. When `person1 = Person("Alice", 25)` is executed:
    
    * The `__init__` method runs automatically.
        
    * [`self.name`](http://self.name) is set to `"Alice"`, and `self.age` is set to `25`.
        
    * A new object `person1` is created with these values.
        
2. 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`.

```python
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.

```python
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.

```python
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:**

```python
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:**

```python
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`](http://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.

```python
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`](http://self.name) `= "Alice"` | `def greet(self): print(`[`self.name`](http://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**

```python
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**

```python
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 the `company` class variable.
    
* Calling `Employee.change_company("CodeLabs")` changes `company` 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`

```python
class ClassName:
    @staticmethod
    def method_name(parameters):
        # Code here
```

---

### **Example of a Static Method**

```python
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**.

```python
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.

```python
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**

```python
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**

```python
# 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.

```python
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.

```python
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.

```python
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.

```python
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.

```python
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:

```python
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.

```python
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**

```python
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**

```python
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.

```python
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:**

```python
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.

```python
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:**

```python
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**

```python
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**

```python
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.

```python
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.

```python
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:

```python
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.
    

```python
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:**

```python
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`.

```python
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)**.

```python
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**.

```python
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:**

```python
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**

```python
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.

```python
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**.

```python
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.

```python
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**

```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**.

```python
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**.

```python
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.

```python
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 the `abc` module.
    
* Abstract methods are declared using `@abstractmethod`.
    
* Child classes must override all abstract methods to be instantiated.
    

---

### **Example: Abstract Class with** `ABC` Module

```python
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.

```python
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**.

```python
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**.

```python
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**.

```python
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

```python
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.

```python
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.

```python
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.

```python
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**.

```python
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**.

```python
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.

```python
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__`.

```python
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**.

```python
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**.

```python
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.

```python
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**.

```python
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:

```python
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**.

```python
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**.

```python
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**.

```python
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`](http://A.show)`()` first.

---

### **Diamond Problem & MRO**

The **Diamond Problem** occurs when a class **inherits from two classes that share a common ancestor**.

```python
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`](http://A.show)`()` is called only once, avoiding redundancy.

### `mro()` Method

Instead of using `__mro__`, you can call `mro()` method:

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

---
