Day-3 OOPs & Python...
Tags: beginners, programming, python, ai
Published: 2024-02-28 09:48:19

## Hey, fellow code adventurers! Get ready to hop on the OOPs in Python, I am very excited to move to the next step,
Today's agenda -

1. **Introduction to OOP in Python:**
   - Brief overview of OOP principles.
   - How Python supports OOP.

2. **Classes and Objects in Python:**
   - Understanding classes and objects in Python.
   - Creating and using classes.
   - Instance variables and methods.

3. **Inheritance in Python:**
   - Explaining inheritance and its benefits.
   - Creating and using subclasses.
   - Method overriding.

4. **Encapsulation in Python:**
   - Encapsulation principles in Python.
   - Access modifiers: public, private, and protected.
   - Getter and setter methods.

5. **Polymorphism in Python:**
   - Understanding polymorphism.
   - Function overloading and overriding.
   - Operator overloading.

6. **Abstraction in Python:**
   - Abstraction concepts in Python.
   - Abstract classes and abstract methods.

7. **Interfaces in Python:**
   - Overview of interfaces in Python.
   - Implementing interfaces.
   - Multiple inheritance and interfaces.

8. **Design Patterns in Python:**
   - Common design patterns in Python.
   - Examples of design patterns implementation.

9. **Error Handling in OOP:**
   - Handling errors using OOP concepts.
   - Exception classes and custom exceptions.

10. **Duck Typing in Python:**
    - Explanation of duck typing.
    - How Python uses duck typing.

11. **Composition vs Inheritance:**
    - Pros and cons of composition and inheritance.
    - When to use each approach.

12. **Magic Methods in Python:**
    - Introduction to magic methods (dunder methods).
    - Common magic methods and their usage.

13. **Mixin Classes in Python:**
    - Understanding mixin classes.
    - Implementing mixins in Python.

14. **Best Practices for OOP in Python:**
    - Coding conventions for OOP.
    - PEP 8 guidelines for OOP.

15. **Real-life Examples of OOP in Python:**
    - Practical examples and use cases.
    - Case studies of OOP in popular Python libraries or frameworks.

<hr>

**Introduction to OOP in Python:**

*Object-Oriented Programming (OOP) Principles:*
OOP is a programming paradigm centered around objects, which encapsulate data and behavior. Key principles include encapsulation, inheritance, polymorphism, and abstraction.

*Python's Support for OOP:*
Python is an object-oriented language that seamlessly integrates OOP principles. It allows the creation of classes and objects, supports inheritance for code reuse, enables polymorphism for flexibility, and facilitates abstraction through class interfaces. Python's syntax and dynamic typing enhance the implementation of OOP concepts, making it a versatile language for building modular and scalable applications.
**Classes and Objects in Python:**

*Understanding Classes and Objects:*
Classes are blueprints for creating objects. Objects are instances of these classes, encapsulating both data and functionality. Think of a class as a template and an object as a specific instance created from that template.

*Creating and Using Classes:*
```python
class Car:
    def __init__(self, make, model): #In Python, self is a convention used to represent the instance of the class itself
        self.make = make
        self.model = model

    def display_info(self):
        print(f"{self.make} {self.model}")

# Creating an object of the Car class
my_car = Car("Toyota", "Camry")

# Accessing object's attributes and calling a method
print(my_car.make)          # Output: Toyota
my_car.display_info()       # Output: Toyota Camry
```

*Instance Variables and Methods:*
Instance variables store data unique to each object, while methods are functions associated with objects.

![Instance](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/1kxc0fjxz7j5hcmszxqq.png)


```python
class Student:
    def __init__(self, name, age):
        self.name = name
        self.age = age

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

# Creating an object of the Student class
student1 = Student("John", 20)

# Accessing object's instance variables and calling a method
print(student1.name)        # Output: John
student1.display_info()     # Output: Name: John, Age: 20
```
**Inheritance in Python:**

*Explaining Inheritance and Its Benefits:*
Inheritance is a feature in OOP that allows a new class (subclass) to inherit attributes and methods from an existing class (superclass). It promotes code reuse, as the subclass can reuse and extend the functionality of the superclass. It establishes an "is-a" relationship between classes.

*Creating and Using Subclasses:*
```python
class Animal:
    def __init__(self, name):
        self.name = name

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

# Subclass Dog inherits from Animal
class Dog(Animal):
    def bark(self):
        print("Woof!")

# Creating an instance of the Dog class
dog_instance = Dog("Buddy")

# Accessing attributes and methods from both superclass and subclass
print(dog_instance.name)  # Output: Buddy
dog_instance.speak()      # Output: Animal speaks
dog_instance.bark()       # Output: Woof!
```

*Method Overriding:*
```python
class Cat(Animal):
  def speak(self):
    print(f"{self.name}-Meow !!") # Overriding the speak method from the superclass

cat_instance=Cat('Tom')

# Calling the overridden speak method
cat_instance.speak()  # Output: Meow!
```

In this example, `Dog` and `Cat` are subclasses of the `Animal` superclass. They inherit the `__init__` method and the `speak` method. The `Cat` class demonstrates method overriding by implementing the `speak` method, replacing the one from the superclass.

### There are different types of Inheritance

![Types of Inheritance](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/bpgsok0n1gy0uyl2vdlx.png)
```python
# Single Inheritance
class Animal:
    def speak(self):
        print("Animal speaks")

class Dog(Animal):
    def bark(self):
        print("Woof!")

dog_instance = Dog()
dog_instance.speak()  # Output: Animal speaks
dog_instance.bark()   # Output: Woof!

# Multiple Inheritance
class Bird:
    def chirp(self):
        print("Bird chirps")

class Hybrid(Dog, Bird):
    pass

hybrid_instance = Hybrid()
hybrid_instance.speak()  # Output: Animal speaks
hybrid_instance.bark()   # Output: Woof!
hybrid_instance.chirp()  # Output: Bird chirps

# Multilevel Inheritance
class Mammal(Animal):
    def feed_milk(self):
        print("Mammal feeds milk")

class Cat(Mammal):
    def meow(self):
        print("Meow!")

cat_instance = Cat()
cat_instance.speak()       # Output: Animal speaks
cat_instance.feed_milk()   # Output: Mammal feeds milk
cat_instance.meow()        # Output: Meow!

# Hierarchical Inheritance
class Shape:
    def draw(self):
        print("Drawing shape")

class Circle(Shape):
    def draw_circle(self):
        print("Drawing circle")

class Square(Shape):
    def draw_square(self):
        print("Drawing square")

circle_instance = Circle()
square_instance = Square()

circle_instance.draw()        # Output: Drawing shape
circle_instance.draw_circle() # Output: Drawing circle

square_instance.draw()        # Output: Drawing shape
square_instance.draw_square() # Output: Drawing square
```
**Encapsulation in Python:**

*Encapsulation Principles:*
Encapsulation is the bundling of data (attributes) and methods that operate on the data within a single unit (a class). It hides the internal details of the object and protects its state.

*Access Modifiers:*
   - **Public:** Accessible from outside the class. 
   - **Private:** Accessible only within the class.
   - **Protected:** Accessible within the class and its subclasses.
** Important Point- Members declared as protected in the base class are accessible in the derived class. They are not accessible outside the class, but they can be accessed by subclasses. **

```python
class BankAccount:
    def __init__(self, account_holder, balance):
        self._account_holder = account_holder  # Protected attribute
        self.__balance = balance  # Private attribute

    # Public method to get balance
    def get_balance(self):
        return self.__balance

    # Public method to deposit money
    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount

    # Public method to withdraw money
    def withdraw(self, amount):
        if amount > 0 and amount <= self.__balance:
            self.__balance -= amount

# Creating an instance of the BankAccount class
account = BankAccount("John Doe", 1000)

# Accessing protected and private attributes
print(account._account_holder)  # Output: John Doe
print(account.get_balance())    # Output: 1000

# Modifying private attributes using public methods
account.deposit(500)
print(account.get_balance())    # Output: 1500

account.withdraw(200)
print(account.get_balance())    # Output: 1300

```

*Getter and Setter Methods:*
Getter and setter methods are used to control access to the attributes of a class.
```python
class Student:
    def __init__(self, name):
        self.__name = name  # Private attribute

    # Getter method
    def get_name(self):
        return self.__name

    # Setter method
    def set_name(self, new_name):
        if len(new_name) > 0:
            self.__name = new_name

# Creating an instance of the Student class
student = Student("Alice")

# Accessing private attribute using getter method
print(student.get_name())  # Output: Alice

# Modifying private attribute using setter method
student.set_name("Bob")
print(student.get_name())  # Output: Bob
```

In this example, `__name` is a private attribute, and `get_name` and `set_name` are getter and setter methods, respectively. Getter methods allow access to the private attribute, while setter methods provide controlled modification. This ensures data integrity and follows the principles of encapsulation in Python.
<hr>
**Polymorphism in Python:**

*Understanding Polymorphism:*
Polymorphism allows objects of different types to be treated as objects of a common type. It enables flexibility and code reuse by using a single interface to represent different types of entities.


![ Types of polymorphism](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/eian531mcobnhu2tth19.png)



*Function Overloading and Overriding:*
   - **Function Overloading:** Defining multiple functions with the same name but different parameter types or a different number of parameters.
   ```python
   def add(a, b):
       return a + b

   def add(a, b, c):
       return a + b + c
   ```
   - **Function Overriding:** Inheritance allows a subclass to provide a specific implementation for a method that is already defined in its superclass.
   ```python
   class Animal:
       def speak(self):
           print("Animal speaks")

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

*Operator Overloading:*
   - Allows defining how operators behave for user-defined objects.
   ```python
   class Vector:
       def __init__(self, x, y):
           self.x = x
           self.y = y

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

   v1 = Vector(1, 2)
   v2 = Vector(3, 4)
   result = v1 + v2  # Operator '+' is overloaded
   ```

Polymorphism simplifies code and promotes code reuse by allowing functions and operators to work on objects of various types, making Python a versatile and expressive language.
<hr>
**Abstraction in Python:**

*Abstraction Concepts:*
Abstraction is the process of simplifying complex systems by modeling classes based on essential features, while hiding unnecessary details. It focuses on what an object does rather than how it achieves its functionality.

*Abstract Classes and Abstract Methods:*
   - **Abstract Class:** A class that cannot be instantiated and serves as a blueprint for other classes. It may contain abstract methods.
   ```python
   from abc import ABC, abstractmethod

   class Shape(ABC):
       @abstractmethod
       def area(self):
           pass

   class Circle(Shape):
       def __init__(self, radius):
           self.radius = radius

       def area(self):
           return 3.14 * self.radius * self.radius
   ```
   - **Abstract Method:** A method declared in an abstract class without providing an implementation. Subclasses must implement abstract methods.
   - In the example, `Shape` is an abstract class with an abstract method `area`. The `Circle` class, a subclass of `Shape`, provides a concrete implementation of the `area` method.

Abstraction in Python allows for a high-level view of systems, promoting code organization, and forcing developers to provide specific implementations for abstract concepts.
<hr>
*Multiple Inheritance and Interfaces:*
   - Classes can inherit from multiple interfaces (abstract classes) to achieve multiple inheritance of behaviors.
   ```python
   class Drawable(ABC):
       @abstractmethod
       def draw(self):
           pass

   class Circle(Shape, Drawable):
       def __init__(self, radius):
           self.radius = radius

       def area(self):
           return 3.14 * self.radius * self.radius

       def draw(self):
           print("Drawing a circle")
   ```
   - `Circle` inherits from both `Shape` and `Drawable`, implementing their respective methods.

In Python, interfaces are achieved through abstract classes and methods, providing a way to enforce a contract for classes that implement them. Multiple inheritance can be used to combine behaviors from different interfaces.
<hr>
**Design Patterns in Python:**

*Common Design Patterns:*
Design patterns are reusable solutions to common problems. In Python, some common design patterns include:
   - **Singleton Pattern:** Ensures a class has only one instance and provides a global point to access it.
   - **Factory Pattern:** Defines an interface for creating an object but leaves the choice of its type to the subclasses.
   - **Observer Pattern:** Defines a one-to-many dependency between objects, where one object changes state, and its dependents are notified.

*Examples of Design Pattern Implementation:*
   - **Singleton Pattern:**
     ```python
     class Singleton:
         _instance = None

         def __new__(cls):
             if not cls._instance:
                 cls._instance = super().__new__(cls)
             return cls._instance
     ```

   - **Factory Pattern:**
     ```python
     class Animal:
         def speak(self):
             pass

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

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

   - **Observer Pattern:**
     ```python
     class Observer:
         def update(self, message):
             pass

     class ConcreteObserver(Observer):
         def update(self, message):
             print(f"Received message: {message}")

     class Subject:
         _observers = []

         def add_observer(self, observer):
             self._observers.append(observer)

         def notify_observers(self, message):
             for observer in self._observers:
                 observer.update(message)
     ```

These examples provide a glimpse into how design patterns can be implemented in Python to solve common programming challenges. Each pattern serves a specific purpose and can improve the structure and flexibility of your code.
<hr>
For Error Handling, we already cover it in detail in the previous blog [Pls read it ](https://dev.to/pranjal_ml/day-2-advanced-python--3eo8).
<hr>

**Duck Typing in Python:**

*Explanation of Duck Typing:*
Duck typing is a programming concept that focuses on an object's behavior rather than its type. If an object walks like a duck and quacks like a duck, then it's treated as a duck, regardless of its actual type.

*How Python Uses Duck Typing:*
Python follows the duck typing philosophy, emphasizing the importance of an object's capabilities over its specific class or type. Functions and methods work with objects based on their behavior rather than their explicit type, promoting flexibility and code reuse.
```python
class Duck:
    def quack(self):
        print("Quack!")

class RobotDuck:
    def quack(self):
        print("Beep beep!")

def make_duck_quack(duck):
    duck.quack()

# Both Duck and RobotDuck can be passed to make_duck_quack
duck_instance = Duck()
robot_duck_instance = RobotDuck()

make_duck_quack(duck_instance)        # Output: Quack!
make_duck_quack(robot_duck_instance)  # Output: Beep beep!
```
In this example, both `Duck` and `RobotDuck` classes are treated interchangeably in the `make_duck_quack` function because they share the common behavior of having a `quack` method. This exemplifies how Python embraces duck typing, emphasizing the importance of what an object can do rather than what it is.
<hr>

## Composition- 
Composition in Python refers to a design principle where a class includes one or more instances of other classes (or types) as attributes. This allows for the creation of complex objects by combining simpler objects, promoting code reuse and modularity. Composition is an alternative to inheritance for achieving code reuse.
```python 
class Engine:
    def start(self):
        print("Engine started")

class Car:
    def __init__(self):
        self.engine = Engine()

    def start(self):
        print("Car starting...")
        self.engine.start()

# Creating an instance of the Car class
my_car = Car()

# Using composition to start the car, which internally starts the engine
my_car.start()
```
**Composition vs Inheritance:**

*Pros and Cons:*

**Composition:**
- **Pros:**
  - Flexibility: Allows for dynamic behavior by composing objects at runtime.
  - Encapsulation: Components can be encapsulated and modified independently.
  - Avoids the issues of the "fragile base class" problem.

- **Cons:**
  - Can lead to more boilerplate code for forwarding methods from the containing class to the contained class.
  - Learning curve for developers unfamiliar with composition-based design.

**Inheritance:**
- **Pros:**
  - Code Reuse: Inherited methods and attributes provide a way to reuse code.
  - Hierarchical Structure: Inheritance can model an "is-a" relationship.

- **Cons:**
  - Tight Coupling: Can lead to a tightly coupled system, making it harder to change or extend.
  - Inflexibility: Changes in the base class may affect subclasses.

*When to Use Each Approach:*
- **Composition:**
  - When a "has-a" relationship is more appropriate than an "is-a" relationship.
  - To achieve greater flexibility and avoid the limitations of inheritance.

- **Inheritance:**
  - When there is a clear "is-a" relationship between the base class and the subclass.
  - When code reuse through a shared interface or behavior is a primary goal.

Choosing between composition and inheritance often depends on the specific requirements of the problem at hand. A combination of both can also be used to leverage the strengths of each approach.
<hr>
**Magic Methods in Python:**

*Introduction to Magic Methods (Dunder Methods):*
Magic methods, also known as dunder (double underscore) methods, are special methods in Python enclosed by double underscores. They provide a way to define how objects behave in certain situations, allowing customization of class behavior.

*Common Magic Methods and Their Usage:*
- **`__init__(self, ...)`:**
  - Initializes an object when it is created.
  - Commonly used for setting initial attributes.

- **`__str__(self)`:**
  - Returns a human-readable string representation of the object.
  - Invoked by `str(obj)` and `print(obj)`.

- **`__len__(self)`:**
  - Returns the length of the object.
  - Invoked by `len(obj)`.

- **`__add__(self, other)`:**
  - Defines the behavior of the `+` operator for objects.
  - Invoked by `obj1 + obj2`.

- **`__iter__(self)`:**
  - Returns an iterator object.
  - Enables iteration over the object using a loop.

- **`__next__(self)`:**
  - Defines the behavior for obtaining the next element in iteration.
  - Invoked by `next(obj)`.

- **`__getitem__(self, key)`:**
  - Allows objects to be accessed using square bracket notation.
  - Invoked by `obj[key]`.

- **`__setitem__(self, key, value)`:**
  - Defines the behavior for setting values using square bracket notation.
  - Invoked by `obj[key] = value`.

Magic methods provide a powerful way to customize and enhance the behavior of classes in Python, making them a fundamental aspect of object-oriented programming in the language.
<hr>
**Mixin Classes in Python:**

*Understanding Mixin Classes:*
Mixin classes are a design pattern in Python where a class provides a specific set of functionalities that can be easily added to other classes. They promote code reuse by allowing multiple classes to inherit behavior from a mixin without relying on a strict hierarchical structure.

*Implementing Mixins in Python:*
```python
# Mixin class providing logging functionality
class LoggingMixin:
    def log_info(self, message):
        print(f"INFO: {message}")

# Example class using the LoggingMixin
class User:
    def __init__(self, username):
        self.username = username

# Incorporating the mixin into the example class
class LoggedUser(LoggingMixin, User):
    def __init__(self, username):
        super().__init__(username)

# Creating an instance and using the added functionality
user_instance = LoggedUser("JohnDoe")
user_instance.log_info("User logged in")  # Output: INFO: User logged in
```

In this example, `LoggingMixin` provides logging functionality, and `LoggedUser` incorporates this functionality by inheriting from both `LoggingMixin` and `User`. Mixins enhance flexibility and modularity in code by allowing classes to acquire specific features without being limited to a strict class hierarchy.
<hr>
**Best Practices for OOP in Python:**

*Coding Conventions for OOP:*
- **Class Naming:** Use CamelCase for class names, starting with an uppercase letter (e.g., `MyClass`).
- **Method Naming:** Use lowercase with words separated by underscores for method names (e.g., `my_method`).
- **Attribute Naming:** Follow the same convention as method names (e.g., `my_attribute`).
- **Encapsulation:** Use access modifiers (`public`, `protected`, `private`) appropriately to control visibility.

*PEP 8 Guidelines for OOP:*
- **Indentation:** Use 4 spaces for indentation.
- **Whitespace in Expressions and Statements:** Avoid extraneous whitespace.
- **Imports:** Import modules and packages separately, and keep them organized.
- **Class Documentation:** Include class-level docstrings for documentation.
- **Method Documentation:** Include method-level docstrings for documentation.
- **Blank Lines:** Use blank lines to separate functions, classes, and blocks of code inside functions.
- **Limit Line Length:** Keep lines under 79 characters for code and 72 characters for docstrings.

Following these conventions and PEP 8 guidelines enhances code readability, maintainability, and consistency in object-oriented programming with Python.
<hr>
**Real-life Examples of OOP in Python:**

*Practical Examples and Use Cases:*
- **Web Development Frameworks:**
  - **Django:** Utilizes OOP to model web applications with classes representing models, views, and controllers.
  - **Flask:** Follows a micro-framework approach and uses OOP to create routes, views, and request handlers.

- **GUI Applications:** Libraries like PyQt or Tkinter utilize OOP to create graphical user interfaces with classes representing different UI components.

- **Game Development:** Pygame, a game development library, employs OOP to model game entities, scenes, and interactions through classes.

*Case Studies in Popular Python Libraries or Frameworks:*
- **Django (Web Framework):** Django uses OOP to define models, views, and forms. Models represent database tables, views handle request processing, and forms manage user input.

- **Flask (Micro-framework):** Flask follows a minimalistic design, using OOP for creating routes, views, and middleware. The application and request context are modeled using classes.

- **Pandas (Data Analysis Library):** Pandas relies on OOP to create DataFrame objects, allowing for efficient data manipulation and analysis.

- **Scikit-learn (Machine Learning Library):** Scikit-learn utilizes OOP to define machine learning models, transformers, and datasets through well-designed classes.

- **PyQt (GUI Library):** PyQt uses OOP to create interactive graphical user interfaces with classes representing windows, buttons, and other UI elements.

These examples showcase how OOP is applied in various domains, making code modular, maintainable, and scalable across different Python libraries and frameworks.

We will continue this for Python Package & git in the next blog. Stay connected. Pls visit the [github](https://github.com/Pranjal-sharma-SDE/AI_Mind_Hub) for code - 
[Colab](https://colab.research.google.com/drive/1FVubVV_iO16Ud1pum-OFy4n-JUPuVfSh?usp=sharing)

Drop by our [Telegram Channel](https://t.me/+J2qk3bDFR-piZmU1) and let the adventure begin! See you there, Data Explorer! 🌐🚀