Python & OOP: How Object-Oriented Programming Makes Your Scrapers and Applications Cleaner and More Scalable

Python & OOP: How Object-Oriented Programming Makes Your Scrapers and Applications Cleaner and More Scalable

1. Introduction: Why OOP? From Scripts to Architecture

Every Python developer starts somewhere. Often, our first encounters with programming involve writing simple, sequential scripts: a script to fetch data from an API, another to process a CSV file, or perhaps a basic web scraper. These scripts are incredibly effective for their immediate purpose, providing quick solutions to specific problems. They are direct, easy to write, and perfect for automating small tasks.

However, as projects grow in complexity and scope, this procedural "scripting" approach often starts to show its limitations. Imagine a web scraper that initially fetches data from just one page. Over time, it needs to:

  • Crawl multiple pages or websites.
  • Handle various data formats (HTML, JSON, XML).
  • Incorporate error handling, retries, and rate limiting (as discussed in our previous articles on asynchronous scraping).
  • Manage proxies and user-agent rotation.
  • Store data in different formats (database, file system).
  • Be easily extended to new websites or data points.

What began as a few dozen lines of code can quickly balloon into hundreds or thousands of lines, becoming a tangled mess of functions, global variables, and conditional logic – often referred to as "spaghetti code" (Fowler, 1999). This unstructured growth leads to several painful problems:

  • Difficulty in Maintenance: Fixing a bug in one part of the code might unintentionally break another.
  • Reduced Readability: Understanding what a piece of code does becomes a cognitive burden.
  • Lack of Reusability: Code is highly coupled to specific contexts, making it hard to reuse components in different parts of the project or in new projects.
  • Poor Extensibility: Adding new features or modifying existing behavior becomes risky and time-consuming.
  • Challenging Collaboration: Multiple developers working on the same codebase often step on each other's toes without a clear structure.

This is precisely where Object-Oriented Programming (OOP) steps in. OOP is a programming paradigm that organizes software design around "objects" rather than "actions" or logic. These objects combine data (attributes) and behavior (methods) into self-contained units. By modeling real-world entities or abstract concepts as objects, OOP provides a powerful framework for managing complexity and fostering robust software development (Gamma et al., 1994).

In this article, we will embark on a comprehensive journey through the core principles of OOP in Python. We'll explore:

  • The fundamental concepts of classes and objects.
  • The four pillars of OOP: Encapsulation, Abstraction, Inheritance, and Polymorphism, illustrating each with clear, Pythonic code examples.
  • How to apply these principles to refactor and design cleaner, more maintainable, and highly scalable Python applications, with practical insights relevant to complex projects like advanced web scrapers.
  • When and why OOP is the right choice, and situations where other paradigms might be more suitable.

By understanding and applying OOP principles, you'll be able to transform your growing scripts into well-structured, robust, and easily extensible applications, ready to tackle even the most demanding programming challenges.

2. The Fundamentals: Classes, Objects, Attributes, and Methods

Before diving into the core principles of OOP, it's essential to grasp the fundamental building blocks: classes and objects. Think of OOP as a way to create self-contained "boxes" that store both data and the functions that operate on that data.

2.1. Classes: The Blueprint

A class is a blueprint, a template, or a prototype from which objects are created (Lott, 2018). It defines the structure (what data an object will hold) and behavior (what actions an object can perform) that all objects of that class will share. In Python, you define a class using the class keyword.

class Book:
    """
    A blueprint for creating book objects.
    Defines the structure and behavior of a book.
    """
    # Class attributes (shared by all instances, optional)
    material_type = "Paper or Digital"

    def __init__(self, title: str, author: str, price: float):
        """
        The constructor method for the Book class.
        It initializes a new Book object with specific attributes.

        Args:
            title (str): The title of the book.
            author (str): The author of the book.
            price (float): The price of the book.
        """
        # Instance attributes (unique to each object)
        self.title = title
        self.author = author
        self.price = price
        self.is_available = True # Default state for new books

    def display_info(self) -> None:
        """
        A method to display information about the book.
        """
        availability_status = "Available" if self.is_available else "Not Available"
        print(f"Title: {self.title}")
        print(f"Author: {self.author}")
        print(f"Price: ${self.price:.2f}")
        print(f"Status: {availability_status}")
        print(f"Material Type (Class Attribute): {self.material_type}\n")

    def mark_unavailable(self) -> None:
        """
        Marks the book as unavailable.
        """
        self.is_available = False
        print(f"'{self.title}' is now marked as unavailable.")

    def apply_discount(self, percentage: float) -> None:
        """
        Applies a discount to the book's price.

        Args:
            percentage (float): The discount percentage (e.g., 0.10 for 10%).
        """
        if not (0 <= percentage <= 1):
            raise ValueError("Discount percentage must be between 0 and 1.")
        self.price *= (1 - percentage)
        print(f"Applied {percentage*100:.0f}% discount. New price for '{self.title}': ${self.price:.2f}")

2.2. Objects (Instances): The Real Things

An object (or instance) is a concrete realization of a class. When you create an object, you're essentially building something based on the blueprint defined by the class. Each object has its own unique set of data, although it shares the behavior defined by the class.

# Creating objects (instances) from the Book class
book1 = Book("The Hitchhiker's Guide to the Galaxy", "Douglas Adams", 12.99)
book2 = Book("Pride and Prejudice", "Jane Austen", 9.50)

print("--- Book 1 Information ---")
book1.display_info()

print("--- Book 2 Information ---")
book2.display_info()

2.3. Attributes: The Data an Object Holds

Attributes are variables associated with an object. They store the data that defines the state of a particular object. In the Book class example:

  • title, author, price, and is_available are instance attributes. Each Book object will have its own unique values for these attributes. They are defined within the __init__ method using self.attribute_name.
  • material_type is a class attribute. It's defined directly within the class body and is shared by all instances of the Book class. Changes to a class attribute affect all objects, unless an instance attribute of the same name shadows it.

You access attributes using dot notation (object.attribute_name).

print(f"Book 1's Title: {book1.title}")
print(f"Book 2's Author: {book2.author}")

# Accessing a class attribute through an instance or the class itself
print(f"Book 1's Material Type: {book1.material_type}")
print(f"Class Book's Material Type: {Book.material_type}")

2.4. Methods: The Actions an Object Can Perform

Methods are functions defined inside a class that operate on the data (attributes) of an object. They define the behavior of the objects created from the class. Methods are called using dot notation ( object.method_name()).

In our Book class:

  • display_info() is a method that prints the book's details.
  • mark_unavailable() changes the is_available attribute.
  • apply_discount() modifies the price attribute.
print("--- After actions on Book 1 ---")
book1.mark_unavailable()
book1.apply_discount(0.15) # 15% discount
book1.display_info()

print("--- Check Book 2's status (unaffected) ---")
book2.display_info() # Book 2 remains available and its original price

2.5. The __init__ Method and self

The __init__ method (pronounced "dunder init") is a special method in Python classes. It's known as the constructor. When you create a new object (e.g., book1 = Book(...)), Python automatically calls the __init__ method of the Book class for that new object. Its primary purpose is to initialize the object's attributes.

The self parameter in method definitions (like __init__, display_info, etc.) is a convention in Python. It's a reference to the instance of the class on which the method is being called. Python automatically passes the instance as the first argument when you call a method on an object. Through self, methods can access and modify the object's own attributes.

# Illustrating `self`
# When you call `book1.display_info()`, it's implicitly like `Book.display_info(book1)`
# Inside display_info, `self` refers to `book1`.

Understanding these fundamentals is the bedrock for building robust object-oriented Python applications. With classes and objects, you begin to structure your code in a modular and intuitive way, preparing for the more advanced OOP principles that define clean and scalable software.

3. The Four Pillars of OOP: Encapsulation

Having understood classes and objects, we can now delve into the core principles that define Object-Oriented Programming. The first and arguably most fundamental of these is Encapsulation.

3.1. What is Encapsulation?

Encapsulation is the bundling of data (attributes) and the methods (functions) that operate on that data into a single unit, called a class (GeeksforGeeks, 2025). It also involves data hiding, meaning that the internal state of an object should be protected from direct, uncontrolled access from outside the object. Instead, interactions with the object's data happen through its methods.

Think of a car: you interact with it via the steering wheel, pedals, and gear stick (methods). You don't directly manipulate the engine's internal components (data) while driving. This makes the car easier to use and maintain, as you don't need to know the intricate details of how the engine works to drive it, and you can't accidentally break it by fiddling with internal parts.

Benefits of Encapsulation:

  • Data Integrity: Prevents unauthorized or invalid changes to an object's internal state.
  • Reduced Complexity: Hides the internal implementation details, presenting a simpler interface to users of the class.
  • Easier Maintenance and Debugging: Changes to internal implementation don't affect external code as long as the public interface remains consistent.
  • Modularity: Classes become self-contained, reusable components.

3.2. Encapsulation in Python: Conventions and Properties

Unlike some other OOP languages (like Java or C++), Python does not have strict keywords for enforcing "private" or "protected" access to attributes. Instead, it relies on conventions and language features to achieve encapsulation.

3.2.1. Name Mangling (Strongly Private Convention)

If you prefix an instance attribute with two underscores (__), Python's interpreter performs a process called name mangling. It renames the attribute internally to _ClassName__attributeName. This makes it harder (though not impossible) to access the attribute directly from outside the class, indicating it's intended for internal use only.

class SensitiveData:
    def __init__(self, password: str):
        self.__secret_password = password # This will be name-mangled to _SensitiveData__secret_password

    def check_password(self, input_password: str) -> bool:
        return self.__secret_password == input_password

# Using the class
data_store = SensitiveData("my_secure_pass")

# Attempting to access directly (will likely cause AttributeError or show mangled name)
# print(data_store.__secret_password) # Raises AttributeError
print(data_store._SensitiveData__secret_password) # Can be accessed this way, demonstrating mangling

print(f"Password correct: {data_store.check_password('my_secure_pass')}")

While name mangling makes direct access cumbersome, it's primarily a convention to signal strong internal use, rather than a strict enforcement of privacy. 3.2.2. Single Underscore Convention (Protected Convention)

Prefixing an attribute with a single underscore (_) is a widely accepted Python convention to indicate that the attribute is "protected" or "internal" (Python Enhancement Proposal 8, 2001). It means "don't access this directly from outside the class, unless you know what you're doing." Python's interpreter doesn't perform any special handling; it's purely a signal to other developers.

class UserProfile:
    def __init__(self, username: str, email: str):
        self._username = username # Convention for internal use
        self._email = email

    def get_info(self) -> str:
        return f"Username: {self._username}, Email: {self._email}"

# Using the class
user = UserProfile("dev_guy", "dev@example.com")
print(user.get_info())

# You *can* still access it directly, but it's discouraged
print(f"Direct access to internal email: {user._email}")

3.2.3. Getters and Setters with @property (Controlled Access)

The most Pythonic and powerful way to achieve controlled encapsulation is by using the @property decorator . It allows you to define methods that can be accessed like attributes, giving you control over how attributes are read (getter), written (setter), or deleted (deleter). This is crucial for validation, computed attributes, or lazy loading.

Let's refactor our Book class to use @property for the price attribute, ensuring it's always a positive number.

class Product:
    """
    A base class for products, demonstrating encapsulation with @property.
    """
    def __init__(self, name: str, initial_price: float):
        self._name = name # Internal attribute
        self._price = 0.0  # Initialize with a default value
        self.price = initial_price # Use the setter to validate the initial price

    @property
    def name(self) -> str:
        """Getter for product name (read-only in this example)."""
        return self._name

    @property
    def price(self) -> float:
        """
        Getter for the product price.
        """
        return self._price

    @price.setter
    def price(self, new_price: float) -> None:
        """
        Setter for the product price with validation.
        Ensures the price is a non-negative number.
        """
        if not isinstance(new_price, (int, float)):
            raise TypeError("Price must be a number.")
        if new_price < 0:
            raise ValueError("Price cannot be negative.")
        self._price = new_price
        print(f"Price for '{self._name}' set to: ${self._price:.2f}")

    def display_product_info(self) -> None:
        """Displays product information."""
        print(f"Product: {self.name}, Price: ${self.price:.2f}")

# Using the Product class with @property
book_product = Product("Advanced Python", 45.00)
book_product.display_product_info()

# Accessing price like an attribute (calls the @property getter)
print(f"Current price via getter: ${book_product.price:.2f}")

# Setting price like an attribute (calls the @price.setter)
book_product.price = 49.99
book_product.display_product_info()

try:
    book_product.price = -10.0 # This will raise a ValueError
except ValueError as e:
    print(f"Error setting price: {e}")

try:
    book_product.price = "twenty" # This will raise a TypeError
except TypeError as e:
    print(f"Error setting price: {e}")

# Attempting to change name (no setter defined, so it's effectively read-only via @property)
# book_product.name = "New Name" # This would raise an AttributeError if uncommented

In this Product example:

  • _name and _price are internal attributes, conventionally indicating they shouldn't be accessed directly.
  • The @property decorator on the name method turns name into a read-only attribute (since no setter is defined for it).
  • The @property decorator on the price method creates a getter for price.
  • The @price.setter decorator defines a setter method for price, allowing us to add validation logic whenever book_product.price is assigned a new value. This is powerful for maintaining data integrity.

Encapsulation, particularly through the clever use of @property, empowers you to create classes with clear interfaces and robust internal logic, making your Python code more reliable and easier to evolve.

4. The Four Pillars of OOP: Abstraction

Following Encapsulation, Abstraction is another cornerstone of Object-Oriented Programming, working hand-in-hand to manage complexity. While encapsulation focuses on how an object's internal state is protected, abstraction concerns what essential information is presented to the user of the object, hiding the irrelevant details.

4.1. What is Abstraction?

Abstraction is the process of simplifying complex reality by modeling classes based on the essential properties and behaviors required for a specific context, while hiding unnecessary background details (IBM, 2025). It's about providing a clear, high-level interface to a complex system, allowing users to interact with it without needing to understand its intricate inner workings.

Think of driving a car again: When you press the accelerator pedal, you expect the car to speed up. You don't need to know the complex mechanics of how fuel injection, engine combustion, and transmission gears work together. The pedal is an abstraction that allows you to control speed.

Benefits of Abstraction:

  • Simplified Usage: Users of the class interact with a simplified interface, making the code easier to understand and use.
  • Reduced Impact of Change: Changes to the internal implementation of an abstracted component don't affect other parts of the system, as long as the abstract interface remains consistent.
  • Enhanced Maintainability: Developers can focus on high-level design without getting bogged down in low-level details.
  • Foundation for Polymorphism: Abstraction often sets the stage for polymorphism, where different objects can be treated through a common interface.

4.2. Abstraction in Python: Abstract Base Classes (ABCs) and Protocols

Python, being a dynamically typed language, inherently supports a form of abstraction through duck typing (Python Documentation: Glossary, 2025). If an object "walks like a duck and quacks like a duck," it's treated as a duck, regardless of its explicit type. This means you can define an implicit interface by simply expecting certain methods to exist.

However, for more formal abstraction where you want to enforce that certain methods must be implemented by subclasses, Python provides Abstract Base Classes (ABCs) via the abc module.

4.2.1. Abstract Base Classes (abc Module)

An Abstract Base Class (ABC) is a class that cannot be instantiated on its own and requires its subclasses to implement certain methods. It acts as a template for other classes, ensuring they adhere to a specific structure.

To create an ABC, you typically inherit from ABC and use the @abstractmethod decorator for methods that must be implemented by concrete (non-abstract) subclasses.

Let's imagine we're building a scraping framework where we need different types of "data exporters" (e.g., to JSON, CSV, Database). We can define an abstract DataExporter class.

from abc import ABC, abstractmethod
from typing import List, Dict, Any

class AbstractDataExporter(ABC):
    """
    Abstract Base Class for data exporters.
    Defines a common interface for saving scraped data.
    """

    @abstractmethod
    def export_data(self, data: List[Dict[str, Any]], filename: str) -> None:
        """
        Abstract method to export a list of dictionaries to a specified file.
        Concrete subclasses must implement this method.

        Args:
            data (List[Dict[str, Any]]): A list of dictionaries representing the scraped data.
            filename (str): The name of the file to export to.
        """
        pass # Abstract methods typically have no implementation in the ABC

    @abstractmethod
    def get_file_extension(self) -> str:
        """
        Abstract method to return the file extension specific to the exporter.
        """
        pass

    def _validate_data(self, data: List[Dict[str, Any]]) -> None:
        """
        A non-abstract (concrete) helper method that can be used by subclasses.
        """
        if not isinstance(data, list):
            raise TypeError("Data to export must be a list of dictionaries.")
        if not all(isinstance(item, dict) for item in data):
            raise TypeError("All items in data list must be dictionaries.")
        print("Data validation passed.")


class JsonExporter(AbstractDataExporter):
    """
    Concrete implementation of AbstractDataExporter for JSON format.
    """
    def export_data(self, data: List[Dict[str, Any]], filename: str) -> None:
        import json
        full_filename = f"{filename}.{self.get_file_extension()}"
        self._validate_data(data) # Use the inherited helper method
        with open(full_filename, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=4)
        print(f"Data exported successfully to {full_filename} in JSON format.")

    def get_file_extension(self) -> str:
        return "json"

class CsvExporter(AbstractDataExporter):
    """
    Concrete implementation of AbstractDataExporter for CSV format.
    """
    def export_data(self, data: List[Dict[str, Any]], filename: str) -> None:
        import csv
        full_filename = f"{filename}.{self.get_file_extension()}"
        self._validate_data(data) # Use the inherited helper method

        if not data:
            print("No data to export to CSV.")
            return

        # Infer fieldnames from the first dictionary's keys
        fieldnames = list(data[0].keys())

        with open(full_filename, 'w', newline='', encoding='utf-8') as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            writer.writerows(data)
        print(f"Data exported successfully to {full_filename} in CSV format.")

    def get_file_extension(self) -> str:
        return "csv"


# --- Usage Example ---
if __name__ == "__main__":
    sample_scraped_data = [
        {"title": "The Async Book", "author": "A. Code", "price": 29.99},
        {"title": "OOP Fundamentals", "author": "B. Dev", "price": 35.50},
        {"title": "Scaling Scraping", "author": "C. Guru", "price": 49.99},
    ]

    # Demonstrate JSON Export
    json_exporter = JsonExporter()
    json_exporter.export_data(sample_scraped_data, "scraped_books")

    # Demonstrate CSV Export
    csv_exporter = CsvExporter()
    csv_exporter.export_data(sample_scraped_data, "scraped_books")

    # Trying to instantiate the abstract class directly will raise an error
    try:
        abstract_exporter = AbstractDataExporter()
    except TypeError as e:
        print(f"\nError: {e}")

    # Trying to create an incomplete subclass (missing a required method)
    class IncompleteExporter(AbstractDataExporter):
        # Missing get_file_extension implementation
        def export_data(self, data: List[Dict[str, Any]], filename: str) -> None:
            print("This is incomplete.")

    try:
        incomplete = IncompleteExporter()
    except TypeError as e:
        print(f"Error creating incomplete exporter: {e}")

In this example, AbstractDataExporter forces any class that inherits from it to provide implementations for export_data and get_file_extension. This ensures that any "exporter" object we create will always have these core behaviors, simplifying the way we interact with different data saving mechanisms.

4.2.2. Protocols (typing.Protocol - Python 3.8+)

For even more explicit and type-hint-friendly abstraction without requiring inheritance, Python 3.8 introduced typing.Protocol (Python Documentation: typing. Protocol, 2025). Protocols define a set of methods and attributes that a class must have to be considered compatible with that protocol. It's formal duck typing.

from typing import Protocol, List, Dict, Any

class Exportable(Protocol):
    """
    A Protocol defining what an object must have to be considered 'exportable'.
    """
    def export_data(self, data: List[Dict[str, Any]], filename: str) -> None:
        ... # Ellipsis indicates an abstract method in a Protocol

    def get_file_extension(self) -> str:
        ...

# JsonExporter and CsvExporter from above would automatically satisfy this Protocol
# because they implement the required methods. No explicit inheritance from Exportable is needed.

def save_any_data(exporter: Exportable, data: List[Dict[str, Any]], base_name: str) -> None:
    """
    A function that accepts any object satisfying the Exportable protocol.
    """
    print(f"\nAttempting to save using an {exporter.get_file_extension().upper()} exporter...")
    exporter.export_data(data, base_name)

if __name__ == "__main__":
    sample_scraped_data = [
        {"title": "The Protocol Book", "author": "D. Coder", "price": 19.99},
    ]

    json_exporter_protocol = JsonExporter()
    csv_exporter_protocol = CsvExporter()

    save_any_data(json_exporter_protocol, sample_scraped_data, "protocol_data")
    save_any_data(csv_exporter_protocol, sample_scraped_data, "protocol_data")

    # A class that explicitly implements the protocol (not strictly necessary but good for clarity)
    class SimpleTextExporter:
        def export_data(self, data: List[Dict[str, Any]], filename: str) -> None:
            full_filename = f"{filename}.txt"
            with open(full_filename, 'w', encoding='utf-8') as f:
                for item in data:
                    f.write(str(item) + "\n")
            print(f"Data exported successfully to {full_filename} in TXT format.")

        def get_file_extension(self) -> str:
            return "txt"

    text_exporter = SimpleTextExporter()
    save_any_data(text_exporter, sample_scraped_data, "protocol_data")

Abstraction, whether implicit through duck typing or explicit through ABCs and Protocols, is vital for designing flexible and maintainable systems. It allows you to define contracts for how components should behave, without dictating their exact internal implementation, thereby reducing dependencies and fostering modular design.


5. The Four Pillars of OOP: Inheritance

The third pillar of Object-Oriented Programming, Inheritance, is a powerful mechanism for code reuse and establishing clear relationships between classes. It allows you to define a new class based on an existing class, inheriting its attributes and methods.

5.1. What is Inheritance?

Inheritance is the mechanism where a new class (the child or derived class) inherits properties (attributes and methods) from an existing class (the parent or base class) (Oracle, 2025). This creates an "is-a" relationship; for example, a "Car is a Vehicle," or an "HTML Scraper is a Scraper."

Benefits of Inheritance:

  • Code Reusability: Avoids duplicating code. Common functionality can be defined once in the parent class and reused by multiple child classes.
  • Reduced Redundancy: Less redundant code means fewer places to make changes and fewer chances for inconsistencies.
  • Easier Maintenance: Changes to shared logic in the parent class automatically propagate to all child classes.
  • Hierarchical Organization: Models real-world hierarchies, making the code structure more intuitive and understandable.
  • Extensibility: New functionalities can be added by creating new child classes without modifying existing code.

5.2. Inheritance in Python

In Python, inheritance is straightforward. You simply define the child class and include the parent class's name in parentheses after the child class name.

5.2.1. Parent and Child Classes

Let's imagine we have a base Vehicle class, and then more specific types of vehicles like Car and Motorcycle.

class Vehicle:
    """
    Base class for all vehicles.
    """
    def __init__(self, brand: str, model: str, year: int):
        self.brand = brand
        self.model = model
        self.year = year
        self._is_engine_on = False # Encapsulated internal state

    def start_engine(self) -> None:
        """Starts the vehicle's engine."""
        if not self._is_engine_on:
            self._is_engine_on = True
            print(f"{self.brand} {self.model}'s engine started.")
        else:
            print(f"{self.brand} {self.model}'s engine is already running.")

    def stop_engine(self) -> None:
        """Stops the vehicle's engine."""
        if self._is_engine_on:
            self._is_engine_on = False
            print(f"{self.brand} {self.model}'s engine stopped.")
        else:
            print(f"{self.brand} {self.model}'s engine is already off.")

    def display_info(self) -> None:
        """Displays basic vehicle information."""
        print(f"Vehicle: {self.brand} {self.model} ({self.year})")
        print(f"Engine Status: {'On' if self._is_engine_on else 'Off'}")


class Car(Vehicle):
    """
    Child class representing a Car, inheriting from Vehicle.
    """
    def __init__(self, brand: str, model: str, year: int, num_doors: int):
        # Call the parent class's __init__ method to initialize common attributes
        super().__init__(brand, model, year)
        self.num_doors = num_doors
        self.current_gear = 0

    def drive(self) -> None:
        """Simulates driving the car."""
        if self._is_engine_on:
            print(f"Driving the {self.brand} {self.model}...")
            self.current_gear = 1 # Start in first gear
        else:
            print(f"Cannot drive {self.brand} {self.model}. Engine is off.")

    def display_info(self) -> None:
        """
        Overrides the display_info method from the parent class.
        """
        super().display_info() # Call the parent's display_info for common info
        print(f"Number of Doors: {self.num_doors}")
        print(f"Current Gear: {self.current_gear}")


class Motorcycle(Vehicle):
    """
    Child class representing a Motorcycle, inheriting from Vehicle.
    """
    def __init__(self, brand: str, model: str, year: int, has_sidecar: bool):
        super().__init__(brand, model, year)
        self.has_sidecar = has_sidecar

    def wheelie(self) -> None:
        """Performs a wheelie (motorcycle specific action)."""
        if self._is_engine_on:
            print(f"The {self.brand} {self.model} is popping a wheelie!")
        else:
            print(f"The {self.brand} {self.model} needs to be on for a wheelie!")

    def display_info(self) -> None:
        """
        Overrides the display_info method from the parent class.
        """
        super().display_info() # Call the parent's display_info for common info
        print(f"Has Sidecar: {'Yes' if self.has_sidecar else 'No'}")


# --- Usage Example ---
if __name__ == "__main__":
    my_car = Car("Toyota", "Camry", 2020, 4)
    my_motorcycle = Motorcycle("Harley-Davidson", "Iron 883", 2022, False)

    print("--- Car Information ---")
    my_car.display_info()
    my_car.start_engine()
    my_car.drive()
    my_car.display_info()
    my_car.stop_engine()
    my_car.display_info()

    print("\n--- Motorcycle Information ---")
    my_motorcycle.display_info()
    my_motorcycle.start_engine()
    my_motorcycle.wheelie()
    my_motorcycle.display_info()
    my_motorcycle.stop_engine()

In this example:

  • Car and Motorcycle inherit brand, model, year, start_engine, and stop_engine from Vehicle.
  • They add their own specific attributes (num_doors, has_sidecar) and methods (drive, wheelie).

5.2.2. Method Overriding and super()

Method overriding occurs when a child class provides its own implementation for a method that is already defined in its parent class (Python Documentation: super(), 2025). This allows child classes to specialize behavior. In the example above, both Car and Motorcycle override the display_info method to add their specific details.

The super() function is used to call a method from the parent class. In the __init__ methods of Car and Motorcycle, super().__init__(brand, model, year) ensures that the Vehicle's constructor is properly called, initializing the common attributes. Similarly, super().display_info() in the overridden display_info methods reuses the parent's display logic before adding child-specific information. This is a best practice for clean and maintainable inheritance.

5.2.3. Multiple Inheritance (Briefly)

Python supports multiple inheritance, meaning a class can inherit from more than one parent class. While powerful, it can lead to complex class hierarchies and ambiguity (known as the "diamond problem"). Python handles this through the Method Resolution Order (MRO), which defines the order in which base classes are searched for a method or attribute (Python Documentation: The Python Language Reference, 2025). You can inspect the MRO of any class using ClassName.mro() or help(ClassName).

class FlyingCreature:
    def fly(self):
        print("I can fly!")

class SwimmingCreature:
    def swim(self):
        print("I can swim!")

class Duck(FlyingCreature, SwimmingCreature):
    def quack(self):
        print("Quack!")

if __name__ == "__main__":
    donald = Duck()
    donald.fly()
    donald.swim()
    donald.quack()
    print("\nDuck MRO:", Duck.mro())

While direct multiple inheritance can be tricky, it's often more cleanly achieved in Python through Mixins – classes that provide a specific set of behaviors to be "mixed in" to other classes, typically not designed to be standalone. This is a more advanced topic but highlights Python's flexibility.

Inheritance is a cornerstone of OOP, enabling powerful code organization and reuse. When used thoughtfully, it creates clear, logical relationships between parts of your system, making it easier to build and evolve complex applications.


6. The Four Pillars of OOP: Polymorphism

The fourth and final pillar of Object-Oriented Programming is Polymorphism. This principle, often considered the most powerful, enables unparalleled flexibility and extensibility in your code.

6.1. What is Polymorphism?

Polymorphism (from Greek, meaning "many forms") is the ability of an object to take on many forms or, more precisely, the ability to present the same interface for different underlying data types (TutorialsPoint, 2025). It allows you to write code that can work with objects of various classes in a uniform way, as long as those classes share a common interface (i.e., they implement the same methods or attributes).

Think of a "Play" button on a media player. Whether you're playing a song, a video, or a podcast, you press the same "Play" button. The internal mechanism for playing each media type is different, but the interface (the button) is the same. This is polymorphism in action.

Benefits of Polymorphism:

  • Flexibility and Extensibility: New classes that adhere to a common interface can be added to the system without requiring changes to the existing code that uses that interface.
  • Simpler Client Code: Code that interacts with polymorphic objects doesn't need to know the specific type of each object; it just needs to know that the object supports a particular action.
  • Decoupling: Reduces coupling between different parts of the system, making it more modular and easier to maintain.

6.2. Polymorphism in Python: Duck Typing and Method Overriding

Python achieves polymorphism primarily through duck typing, a concept we briefly touched upon in the Abstraction chapter. If an object behaves in a certain way (i.e., has the required methods and attributes), it can be used interchangeably with any other object that behaves in the same way, regardless of its explicit class or inheritance hierarchy.

6.2.1. Polymorphism through Duck Typing

In Python, if two different classes have methods with the same name and signature, instances of those classes can be used polymorphically. The code that calls these methods doesn't care about the object's type, only that it "quacks" in the expected way.

Let's use our Vehicle examples from the Inheritance chapter to demonstrate this. Both Car and Motorcycle have a start_engine() method and an overridden display_info() method.

# Re-using classes from Chapter 5 (Vehicle, Car, Motorcycle)
# Assume these class definitions are available in the scope

class Vehicle:
    """
    Base class for all vehicles.
    """
    def __init__(self, brand: str, model: str, year: int):
        self.brand = brand
        self.model = model
        self.year = year
        self._is_engine_on = False

    def start_engine(self) -> None:
        """Starts the vehicle's engine."""
        if not self._is_engine_on:
            self._is_engine_on = True
            print(f"{self.brand} {self.model}'s engine started.")
        else:
            print(f"{self.brand} {self.model}'s engine is already running.")

    def stop_engine(self) -> None:
        """Stops the vehicle's engine."""
        if self._is_engine_on:
            self._is_engine_on = False
            print(f"{self.brand} {self.model}'s engine stopped.")
        else:
            print(f"{self.brand} {self.model}'s engine is already off.")

    def display_info(self) -> None:
        """Displays basic vehicle information."""
        print(f"Vehicle: {self.brand} {self.model} ({self.year})")
        print(f"Engine Status: {'On' if self._is_engine_on else 'Off'}")


class Car(Vehicle):
    """
    Child class representing a Car, inheriting from Vehicle.
    """
    def __init__(self, brand: str, model: str, year: int, num_doors: int):
        super().__init__(brand, model, year)
        self.num_doors = num_doors
        self.current_gear = 0

    def drive(self) -> None:
        """Simulates driving the car."""
        if self._is_engine_on:
            print(f"Driving the {self.brand} {self.model}...")
            self.current_gear = 1
        else:
            print(f"Cannot drive {self.brand} {self.model}. Engine is off.")

    def display_info(self) -> None:
        """
        Overrides the display_info method from the parent class.
        """
        super().display_info()
        print(f"Number of Doors: {self.num_doors}")
        print(f"Current Gear: {self.current_gear}")


class Motorcycle(Vehicle):
    """
    Child class representing a Motorcycle, inheriting from Vehicle.
    """
    def __init__(self, brand: str, model: str, year: int, has_sidecar: bool):
        super().__init__(brand, model, year)
        self.has_sidecar = has_sidecar

    def wheelie(self) -> None:
        """Performs a wheelie (motorcycle specific action)."""
        if self._is_engine_on:
            print(f"The {self.brand} {self.model} is popping a wheelie!")
        else:
            print(f"The {self.brand} {self.model} needs to be on for a wheelie!")

    def display_info(self) -> None:
        """
        Overrides the display_info method from the parent class.
        """
        super().display_info()
        print(f"Has Sidecar: {'Yes' if self.has_sidecar else 'No'}")


def describe_vehicles(vehicles: list[Vehicle]) -> None:
    """
    Polymorphic function that describes a list of vehicles.
    It works with any object that has a 'display_info' method.
    """
    print("\n--- Describing Vehicles ---")
    for vehicle in vehicles:
        vehicle.display_info()
        print("-" * 20)

if __name__ == "__main__":
    my_car = Car("Honda", "CR-V", 2021, 5)
    my_motorcycle = Motorcycle("Kawasaki", "Ninja 400", 2023, False)
    my_car.start_engine() # Start car for full info

    # Create a list containing different types of Vehicle objects
    all_my_vehicles = [my_car, my_motorcycle]

    # Call the polymorphic function
    describe_vehicles(all_my_vehicles)

In describe_vehicles, the function doesn't care if it's a Car object or a Motorcycle object. It just knows that each vehicle object in the list has a display_info() method, and it calls it. This is duck typing in action, enabling polymorphic behavior.

6.2.2. Polymorphism in Scrapers: A Unified Interface

Let's consider our scraping context. Different types of scrapers might interact with different sources (HTML pages, APIs), but they all might share a common goal: to Workspace_data() or parse_content().

from abc import ABC, abstractmethod
from typing import Dict, Any

class AbstractScraper(ABC):
    """
    Abstract base class for different types of scrapers.
    Defines a common interface for scraping operations.
    """
    def __init__(self, target_url: str):
        self.target_url = target_url

    @abstractmethod
    def fetch_data(self) -> str:
        """Abstract method to fetch raw data from the target."""
        pass

    @abstractmethod
    def parse_content(self, raw_content: str) -> Dict[str, Any]:
        """Abstract method to parse raw content into structured data."""
        pass

    def scrape_and_process(self) -> Dict[str, Any]:
        """
        A concrete method orchestrating the scraping process.
        Uses abstract methods, demonstrating polymorphism.
        """
        print(f"[{self.__class__.__name__}] Starting scrape for: {self.target_url}")
        raw_data = self.fetch_data()
        if raw_data:
            structured_data = self.parse_content(raw_data)
            print(f"[{self.__class__.__name__}] Finished scraping: {self.target_url}")
            return structured_data
        print(f"[{self.__class__.__name__}] No raw data fetched for: {self.target_url}")
        return {}


class HTMLScraper(AbstractScraper):
    """
    Concrete scraper for HTML pages.
    """
    def fetch_data(self) -> str:
        # Simulate fetching HTML content
        print(f"[{self.__class__.__name__}] Fetching HTML from {self.target_url}...")
        # In a real scenario, this would use requests/aiohttp
        if "example.com" in self.target_url:
            return "<html><body><h1>Example HTML Title</h1><p>Some content.</p></body></html>"
        return ""

    def parse_content(self, raw_content: str) -> Dict[str, Any]:
        from bs4 import BeautifulSoup
        print(f"[{self.__class__.__name__}] Parsing HTML content...")
        soup = BeautifulSoup(raw_content, 'html.parser')
        title = soup.find('h1').get_text() if soup.find('h1') else "No Title"
        return {"source": "HTML", "url": self.target_url, "extracted_title": title}


class APIScraper(AbstractScraper):
    """
    Concrete scraper for APIs (JSON responses).
    """
    def fetch_data(self) -> str:
        # Simulate fetching JSON content from an API
        print(f"[{self.__class__.__name__}] Fetching JSON from {self.target_url}...")
        # In a real scenario, this would use requests/aiohttp
        if "api.example.com" in self.target_url:
            return '{"id": "abc-123", "name": "API Item", "value": 123.45}'
        return ""

    def parse_content(self, raw_content: str) -> Dict[str, Any]:
        import json
        print(f"[{self.__class__.__name__}] Parsing JSON content...")
        data = json.loads(raw_content)
        return {"source": "API", "url": self.target_url, "api_data": data}


def run_all_scrapers(scrapers: list[AbstractScraper]) -> List[Dict[str, Any]]:
    """
    Polymorphic function that runs various scrapers.
    It expects any object that adheres to the AbstractScraper interface.
    """
    all_scraped_data = []
    for scraper in scrapers:
        print(f"\n--- Running a {scraper.__class__.__name__} ---")
        result = scraper.scrape_and_process()
        if result:
            all_scraped_data.append(result)
    return all_scraped_data


if __name__ == "__main__":
    html_scraper = HTMLScraper("[http://www.example.com/page1](http://www.example.com/page1)")
    api_scraper = APIScraper("[http://api.example.com/data](http://api.example.com/data)")
    html_scraper_2 = HTMLScraper("[http://www.anothersite.com/page2](http://www.anothersite.com/page2)")

    # A list containing different concrete scraper types
    my_scrapers = [html_scraper, api_scraper, html_scraper_2]

    collected_data = run_all_scrapers(my_scrapers)

    print("\n--- All Collected Data ---")
    for item in collected_data:
        print(item)

In the run_all_scrapers function, we treat each scraper object uniformly, calling its scrape_and_process() method. The exact implementation of Workspace_data() and parse_content() varies depending on whether it's an HTMLScraper or an APIScraper, but the client code doesn't need to know these specifics. This is the essence of polymorphism: interacting with diverse objects through a single, consistent interface.

6.2.3. Operator Overloading (Brief Mention)

Another form of polymorphism in Python is operator overloading. This is when operators like +, -, *, ==, or functions like len() behave differently depending on the types of operands they are applied to. This is achieved by implementing special "dunder" methods (e.g., __add__ for +, __len__ for len()) in your classes.

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

    def __add__(self, other: 'Vector') -> 'Vector':
        """Allows adding two Vector objects using the '+' operator."""
        return Vector(self.x + other.x, self.y + other.y)

    def __str__(self) -> str:
        """Provides a string representation for the Vector object."""
        return f"Vector({self.x}, {self.y})"

if __name__ == "__main__":
    v1 = Vector(2, 3)
    v2 = Vector(1, 5)
    v3 = v1 + v2 # Uses the __add__ method
    print(f"v1: {v1}")
    print(f"v2: {v2}")
    print(f"v1 + v2 = {v3}") # Prints Vector(3, 8)

    # len() is polymorphic: it works differently for lists, strings, etc.
    print(f"Length of 'hello': {len('hello')}")
    print(f"Length of [1, 2, 3]: {len([1, 2, 3])}")

Blogger My Gem Bot Markdown


6. The Four Pillars of OOP: Polymorphism

The fourth and final pillar of Object-Oriented Programming is Polymorphism. This principle, often considered the most powerful, enables unparalleled flexibility and extensibility in your code.

6.1. What is Polymorphism?

Polymorphism (from Greek, meaning "many forms") is the ability of an object to take on many forms or, more precisely, the ability to present the same interface for different underlying data types (TutorialsPoint, 2025). It allows you to write code that can work with objects of various classes in a uniform way, as long as those classes share a common interface (i.e., they implement the same methods or attributes).

Think of a "Play" button on a media player. Whether you're playing a song, a video, or a podcast, you press the same "Play" button. The internal mechanism for playing each media type is different, but the interface (the button) is the same. This is polymorphism in action.

Benefits of Polymorphism:

  • Flexibility and Extensibility: New classes that adhere to a common interface can be added to the system without requiring changes to the existing code that uses that interface.
  • Simpler Client Code: Code that interacts with polymorphic objects doesn't need to know the specific type of each object; it just needs to know that the object supports a particular action.
  • Decoupling: Reduces coupling between different parts of the system, making it more modular and easier to maintain.

6.2. Polymorphism in Python: Duck Typing and Method Overriding

Python achieves polymorphism primarily through duck typing, a concept we briefly touched upon in the Abstraction chapter. If an object behaves in a certain way (i.e., has the required methods and attributes), it can be used interchangeably with any other object that behaves in the same way, regardless of its explicit class or inheritance hierarchy.

6.2.1. Polymorphism through Duck Typing

In Python, if two different classes have methods with the same name and signature, instances of those classes can be used polymorphically. The code that calls these methods doesn't care about the object's type, only that it "quacks" in the expected way.

Let's use our Vehicle examples from the Inheritance chapter to demonstrate this. Both Car and Motorcycle have a start_engine() method and an overridden display_info() method.

# Re-using classes from Chapter 5 (Vehicle, Car, Motorcycle)
# Assume these class definitions are available in the scope

class Vehicle:
    """
    Base class for all vehicles.
    """
    def __init__(self, brand: str, model: str, year: int):
        self.brand = brand
        self.model = model
        self.year = year
        self._is_engine_on = False

    def start_engine(self) -> None:
        """Starts the vehicle's engine."""
        if not self._is_engine_on:
            self._is_engine_on = True
            print(f"{self.brand} {self.model}'s engine started.")
        else:
            print(f"{self.brand} {self.model}'s engine is already running.")

    def stop_engine(self) -> None:
        """Stops the vehicle's engine."""
        if self._is_engine_on:
            self._is_engine_on = False
            print(f"{self.brand} {self.model}'s engine stopped.")
        else:
            print(f"{self.brand} {self.model}'s engine is already off.")

    def display_info(self) -> None:
        """Displays basic vehicle information."""
        print(f"Vehicle: {self.brand} {self.model} ({self.year})")
        print(f"Engine Status: {'On' if self._is_engine_on else 'Off'}")


class Car(Vehicle):
    """
    Child class representing a Car, inheriting from Vehicle.
    """
    def __init__(self, brand: str, model: str, year: int, num_doors: int):
        super().__init__(brand, model, year)
        self.num_doors = num_doors
        self.current_gear = 0

    def drive(self) -> None:
        """Simulates driving the car."""
        if self._is_engine_on:
            print(f"Driving the {self.brand} {self.model}...")
            self.current_gear = 1
        else:
            print(f"Cannot drive {self.brand} {self.model}. Engine is off.")

    def display_info(self) -> None:
        """
        Overrides the display_info method from the parent class.
        """
        super().display_info()
        print(f"Number of Doors: {self.num_doors}")
        print(f"Current Gear: {self.current_gear}")


class Motorcycle(Vehicle):
    """
    Child class representing a Motorcycle, inheriting from Vehicle.
    """
    def __init__(self, brand: str, model: str, year: int, has_sidecar: bool):
        super().__init__(brand, model, year)
        self.has_sidecar = has_sidecar

    def wheelie(self) -> None:
        """Performs a wheelie (motorcycle specific action)."""
        if self._is_engine_on:
            print(f"The {self.brand} {self.model} is popping a wheelie!")
        else:
            print(f"The {self.brand} {self.model} needs to be on for a wheelie!")

    def display_info(self) -> None:
        """
        Overrides the display_info method from the parent class.
        """
        super().display_info()
        print(f"Has Sidecar: {'Yes' if self.has_sidecar else 'No'}")


def describe_vehicles(vehicles: list[Vehicle]) -> None:
    """
    Polymorphic function that describes a list of vehicles.
    It works with any object that has a 'display_info' method.
    """
    print("\n--- Describing Vehicles ---")
    for vehicle in vehicles:
        vehicle.display_info()
        print("-" * 20)

if __name__ == "__main__":
    my_car = Car("Honda", "CR-V", 2021, 5)
    my_motorcycle = Motorcycle("Kawasaki", "Ninja 400", 2023, False)
    my_car.start_engine() # Start car for full info

    # Create a list containing different types of Vehicle objects
    all_my_vehicles = [my_car, my_motorcycle]

    # Call the polymorphic function
    describe_vehicles(all_my_vehicles)

In  describe_vehicles, the function doesn't care if it's a  Car object or a  Motorcycle object. It just knows that each  vehicle object in the list has a  display_info() method, and it calls it. This is duck typing in action, enabling polymorphic behavior. 
6.2.2. Polymorphism in Scrapers: A Unified Interface

Let's consider our scraping context. Different types of scrapers might interact with different sources (HTML pages, APIs), but they all might share a common goal: to  Workspace_data() or  parse_content(). 
Python

from abc import ABC, abstractmethod
from typing import Dict, Any

class AbstractScraper(ABC):
    """
    Abstract base class for different types of scrapers.
    Defines a common interface for scraping operations.
    """
    def __init__(self, target_url: str):
        self.target_url = target_url

    @abstractmethod
    def fetch_data(self) -> str:
        """Abstract method to fetch raw data from the target."""
        pass

    @abstractmethod
    def parse_content(self, raw_content: str) -> Dict[str, Any]:
        """Abstract method to parse raw content into structured data."""
        pass

    def scrape_and_process(self) -> Dict[str, Any]:
        """
        A concrete method orchestrating the scraping process.
        Uses abstract methods, demonstrating polymorphism.
        """
        print(f"[{self.__class__.__name__}] Starting scrape for: {self.target_url}")
        raw_data = self.fetch_data()
        if raw_data:
            structured_data = self.parse_content(raw_data)
            print(f"[{self.__class__.__name__}] Finished scraping: {self.target_url}")
            return structured_data
        print(f"[{self.__class__.__name__}] No raw data fetched for: {self.target_url}")
        return {}


class HTMLScraper(AbstractScraper):
    """
    Concrete scraper for HTML pages.
    """
    def fetch_data(self) -> str:
        # Simulate fetching HTML content
        print(f"[{self.__class__.__name__}] Fetching HTML from {self.target_url}...")
        # In a real scenario, this would use requests/aiohttp
        if "example.com" in self.target_url:
            return "<html><body><h1>Example HTML Title</h1><p>Some content.</p></body></html>"
        return ""

    def parse_content(self, raw_content: str) -> Dict[str, Any]:
        from bs4 import BeautifulSoup
        print(f"[{self.__class__.__name__}] Parsing HTML content...")
        soup = BeautifulSoup(raw_content, 'html.parser')
        title = soup.find('h1').get_text() if soup.find('h1') else "No Title"
        return {"source": "HTML", "url": self.target_url, "extracted_title": title}


class APIScraper(AbstractScraper):
    """
    Concrete scraper for APIs (JSON responses).
    """
    def fetch_data(self) -> str:
        # Simulate fetching JSON content from an API
        print(f"[{self.__class__.__name__}] Fetching JSON from {self.target_url}...")
        # In a real scenario, this would use requests/aiohttp
        if "api.example.com" in self.target_url:
            return '{"id": "abc-123", "name": "API Item", "value": 123.45}'
        return ""

    def parse_content(self, raw_content: str) -> Dict[str, Any]:
        import json
        print(f"[{self.__class__.__name__}] Parsing JSON content...")
        data = json.loads(raw_content)
        return {"source": "API", "url": self.target_url, "api_data": data}


def run_all_scrapers(scrapers: list[AbstractScraper]) -> List[Dict[str, Any]]:
    """
    Polymorphic function that runs various scrapers.
    It expects any object that adheres to the AbstractScraper interface.
    """
    all_scraped_data = []
    for scraper in scrapers:
        print(f"\n--- Running a {scraper.__class__.__name__} ---")
        result = scraper.scrape_and_process()
        if result:
            all_scraped_data.append(result)
    return all_scraped_data


if __name__ == "__main__":
    html_scraper = HTMLScraper("[http://www.example.com/page1](http://www.example.com/page1)")
    api_scraper = APIScraper("[http://api.example.com/data](http://api.example.com/data)")
    html_scraper_2 = HTMLScraper("[http://www.anothersite.com/page2](http://www.anothersite.com/page2)")

    # A list containing different concrete scraper types
    my_scrapers = [html_scraper, api_scraper, html_scraper_2]

    collected_data = run_all_scrapers(my_scrapers)

    print("\n--- All Collected Data ---")
    for item in collected_data:
        print(item)

In the  run_all_scrapers function, we treat each  scraper object uniformly, calling its  scrape_and_process() method. The exact implementation of  Workspace_data() and  parse_content() varies depending on whether it's an  HTMLScraper or an  APIScraper, but the client code doesn't need to know these specifics. This is the essence of polymorphism: interacting with diverse objects through a single, consistent interface. 
6.2.3. Operator Overloading (Brief Mention)

Another form of polymorphism in Python is  operator overloading . This is when operators like  +,  -,  *,  ==, or functions like  len() behave differently depending on the types of operands they are applied to. This is achieved by implementing special "dunder" methods (e.g.,  __add__ for  +,  __len__ for  len()) in your classes. 
Python

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

    def __add__(self, other: 'Vector') -> 'Vector':
        """Allows adding two Vector objects using the '+' operator."""
        return Vector(self.x + other.x, self.y + other.y)

    def __str__(self) -> str:
        """Provides a string representation for the Vector object."""
        return f"Vector({self.x}, {self.y})"

if __name__ == "__main__":
    v1 = Vector(2, 3)
    v2 = Vector(1, 5)
    v3 = v1 + v2 # Uses the __add__ method
    print(f"v1: {v1}")
    print(f"v2: {v2}")
    print(f"v1 + v2 = {v3}") # Prints Vector(3, 8)

    # len() is polymorphic: it works differently for lists, strings, etc.
    print(f"Length of 'hello': {len('hello')}")
    print(f"Length of [1, 2, 3]: {len([1, 2, 3])}")

Here, the + operator is polymorphic because it can perform arithmetic addition for numbers, concatenation for strings, and vector addition for our Vector objects due to __add__ implementation.

Polymorphism makes your code more adaptable to change, allowing you to build systems where new components can be seamlessly integrated without disrupting existing functionalities. It's a cornerstone for creating truly extensible and maintainable object-oriented architectures.

Used Sources: