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
, andis_available
are instance attributes. EachBook
object will have its own unique values for these attributes. They are defined within the__init__
method usingself.attribute_name
.material_type
is a class attribute. It's defined directly within the class body and is shared by all instances of theBook
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 theis_available
attribute.apply_discount()
modifies theprice
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 thename
method turnsname
into a read-only attribute (since no setter is defined for it). - The
@property
decorator on theprice
method creates a getter forprice
. - The
@price.setter
decorator defines a setter method forprice
, allowing us to add validation logic wheneverbook_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
andMotorcycle
inheritbrand
,model
,year
,start_engine
, andstop_engine
fromVehicle
.- 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:
- Fowler, M. (1999). Refactoring: Improving the Design of Existing Code. Addison-Wesley.
- Gamma, E., Helm, R., Johnson, R., & Vlissides, J. (1994). Design Patterns: Elements of Reusable Object-Oriented Software. Addison-Wesley.
- GeeksforGeeks. (2025). Encapsulation in Python. Retrieved from https://www.geeksforgeeks.org/encapsulation-in-python/
- IBM. (2025). What is abstraction?. Retrieved from https://www.ibm.com/topics/object-oriented-programming
- Lott, S. (2018). Mastering Object-Oriented Python. Packt Publishing.
- Oracle. (2025). What Is Inheritance?. Retrieved from https://www.oracle.com/java/technologies/javase/inheritance.html
- Python Documentation. (2025). asyncio — Asynchronous I/O. Retrieved from https://docs.python.org/3/library/asyncio.html
- Python Documentation. (2025). concurrent.futures — Launching parallel tasks. Retrieved from https://docs.python.org/3/library/concurrent.futures.html
- Python Documentation. (2025). Global Interpreter Lock. Retrieved from https://docs.python.org/3/glossary.html#term-global-interpreter-lock
- Python Documentation. (2025). Glossary (duck typing). Retrieved from https://docs.python.org/3/glossary.html#term-duck-typing
- Python Documentation. (2025). super(). Retrieved from https://docs.python.org/3/library/functions.html#super
- Python Documentation. (2025). The Python Language Reference (Method Resolution Order). Retrieved from https://docs.python.org/3/reference/datamodel.html#mro
- Python Documentation. (2025). typing. Protocol. Retrieved from https://docs.python.org/3/library/typing.html#typing.Protocol
- Python Enhancement Proposal 8 (PEP 8). (2001). Style Guide for Python Code. Retrieved from https://www.python.org/dev/peps/pep-0008/#design-of-core-language-features
- TutorialsPoint. (2025). Polymorphism in OOP. Retrieved from https://www.tutorialspoint.com/object_oriented_programming/oop_polymorphism.html