Polymorphism in Python Object-Oriented Programming

Python for AI, data science and machine learning Day 6

Gianpiero Andrenacci
Data Bistrot
14 min readApr 18, 2024

--

Python for AI, data science and machine learning series

Polymorphism, a core concept in object-oriented programming (OOP), refers to the ability of a single interface to support entities of multiple types or the ability of different objects to respond in a unique way to the same method call.

In Python, polymorphism is inherent in its design, allowing for flexible and dynamic use of objects. Let’s break down the major features of polymorphism as outlined, and how they apply in Python.

Types of Polymorphism

Polymorphism in Python can manifest in two primary ways

1. Duck Typing

Python is known for its “duck typing” philosophy, which is a form of polymorphism where the type or class of an object is less important than the methods it defines. When you use an object’s method without knowing its type, as long as the object supports the method invocation, Python will run it. This is often summarized as “If it looks like a duck and quacks like a duck, it must be a duck.”

Duck Typing Example: Polymorphism with the + Operator

A classic example of polymorphism in Python is the + operator, which can perform addition between two integers or concatenate two strings, depending on the operand types. This is a built-in feature of Python that showcases its flexibility and dynamic typing.

# Integer addition
result = 1 + 2
print(result) # Output: 3

# String concatenation
result = "Data" + "Science"
print(result) # Output: DataScience

2. Polymorphism With Method Overloading in python

Polymorphism with class methods is a potent tool in Python, particularly valuable in the realm of data science. It allows for the creation of flexible, scalable, and maintainable code that can handle various data types and processing strategies with a uniform interface. By leveraging polymorphism, data scientists can design their codebase to be more abstract and versatile, facilitating easier experimentation and iteration across diverse data science tasks.

Understanding Method Overloading in Python

In many programming languages, method overloading refers to the ability to have multiple methods with the same name but different parameters. This is often used to provide different implementations for a method, depending on the number and type of arguments passed. However, Python handles this concept differently due to its nature and how it handles function definitions.

How Python Handles Method Definitions

In Python, methods are defined in a class, and their behavior does not change based on the number or types of arguments passed. If you define multiple methods with the same name but different parameters in the same scope, the last definition will overwrite the previous ones. This is because Python does not support traditional method overloading found in statically typed languages like Java or C++. Instead, Python relies on its dynamic typing and other features to achieve similar functionality. Here’s a simple example to demonstrate this:

class Example:
def greet(self, name):
print(f"Hello, {name}")
def greet(self): # This will override the previous 'greet'
print("Hello")
# Create an instance
example = Example()
example.greet() # Outputs: Hello
# example.greet("Python") # This would raise an error because the greet method with one argument has been overridden

In languages like Java or C++, you can create overloaded methods by varying the number or types of parameters they accept. For example, you could have a method called calculate_area that works differently depending on whether you pass in the dimensions of a rectangle or a circle.

However, as said before, Python takes a different approach. It does not directly support traditional method overloading as seen in statically typed languages. Instead, Python allows you to define a single method with a flexible number of arguments using default values, variable-length argument lists, or keyword arguments. This dynamic behavior is more in line with Python’s philosophy of simplicity and flexibility.

Here’s how Python achieves similar functionality without explicit method overloading:

a. Overloading with Default Arguments

You can define default values for function parameters. If an argument is not provided, the default value is used. For example:

def greet(name="Guest"):
print(f"Hello, {name}!")

greet() # Output: Hello, Guest!
greet("Alice") # Output: Hello, Alice!

b. Overloading with Variable-Length Arguments

Keyword Arguments in Python

Keyword arguments in Python enhance the readability and clarity of function calls by explicitly specifying which parameter each argument corresponds to. Unlike positional arguments, where the order matters, keyword arguments allow you to pass values to a function in any order, as long as you use the parameter names. This feature is particularly useful for functions that take multiple parameters, making your code easier to understand and less prone to errors from incorrect argument order.

Basic Usage of Keyword Arguments

Here’s a simple example demonstrating how keyword arguments work:

def create_user(name, age, email):
return {
'name': name,
'age': age,
'email': email
}

# Using keyword arguments to call the function
user_info = create_user(name="John Doe", email="john@example.com", age=30)

print(user_info)

In this example, the create_user function is called with keyword arguments specifying name, age, and email. The order of the arguments does not matter because each is explicitly named. This not only improves readability but also prevents bugs that can arise from passing arguments in the wrong order.

Advantages of Using Keyword Arguments

  1. Clarity: Keyword arguments make function calls clearer to readers who aren’t familiar with the function’s signature. It’s immediately apparent what each argument represents.
  2. Flexibility: You can omit optional parameters without worrying about their position in the function definition, as long as the function is designed to handle default values.
  3. Order Independence: When using keyword arguments, the order of arguments does not matter, reducing the risk of errors from misplaced arguments.

Keyword arguments are a powerful feature in Python that contribute to clearer, more maintainable code. They allow function callers to specify arguments by name, which can make complex function calls easier to read and write. By using keyword arguments, you can make your Python programs more intuitive and less prone to errors related to the order of arguments.

Combining Positional and Keyword Arguments

In Python, you can mix positional and keyword arguments in a single function call. However, positional arguments must come before any keyword arguments. Here’s an example:

In the set_profile call, "Alice" and 28 are positional arguments, while country="Canada" is a keyword argument. This mix allows for flexible function calls while still ensuring clarity about what each argument represents.

def set_profile(name, age, country="Unknown"):
return f"{name}, {age}, from {country}"

# Mixing positional and keyword arguments
profile = set_profile("Alice", 28, country="Canada")

print(profile)

Variable-Length Arguments in Python: *args and **kwargs

In Python, *args and **kwargs are mechanisms that allow a function to accept a variable number of arguments. *args is used to scoop up a variable number of remaining positional arguments into a tuple, while **kwargs collects the remaining keyword arguments into a dictionary. This feature is incredibly useful for creating flexible and dynamic functions that can handle different amounts of inputs without needing to define the exact number of parameters beforehand.

Understanding *args

The *args allows a function to take any number of positional arguments. Here's how you can use it:

def sum_numbers(*args):
return sum(args) # `args` is a tuple of all positional arguments passed

# Example usage
print(sum_numbers(1, 2, 3)) # Output: 6
print(sum_numbers(1, 2, 3, 4, 5)) # Output: 15

In this example, sum_numbers can accept any number of arguments, summing them up regardless of how many are passed.

Understanding **kwargs

The **kwargs allows a function to accept any number of keyword arguments. Here's an example:

def greet(**kwargs):
greeting = kwargs.get('greeting', 'Hello')
name = kwargs.get('name', 'there')
return f"{greeting}, {name}!"

# Example usage
print(greet(name="John", greeting="Hi")) # Output: Hi, John!
print(greet()) # Output: Hello, there!

In this example, greet function can accept any number of keyword arguments. It looks for specific keys (greeting and name) to customize its response, providing defaults if they aren't provided.

kwargs.get(‘greeting’, ‘Hello’) takes “greeting” keyword argument or default value “Hello”.

Combining *args and **kwargs

You can combine *args and **kwargs in the same function to handle both positional and keyword variable arguments:

def create_profile(name, email, *args, **kwargs):
profile = {
'name': name,
'email': email,
'skills': args,
'details': kwargs
}
return profile

# Example usage
profile = create_profile(
"Jane Doe", "jane@example.com",
"Python", "Data Science",
location="New York", status="Active"
)

print(profile)

In this example, create_profile accepts mandatory name and email parameters, any number of skills as positional arguments, and additional details as keyword arguments. This allows for a highly flexible function that can adapt to various inputs while maintaining a clear and concise interface.

*args and **kwargs are powerful features in Python that provide flexibility in function definitions, allowing them to handle a variable number of positional and keyword arguments. This is particularly useful in scenarios where the exact number of arguments a function will receive is unknown or can vary, making your code more dynamic and easier to maintain.

c. Efficient Approach for Method Overloading: Multiple Dispatch Decorator

Method overloading in Python can be achieved through various techniques, one of which includes using the multiple dispatch decorator. This approach allows different methods to be executed based on the type of arguments they receive, making it a dynamic and efficient way to handle method overloading.

Multiple Dispatch in Python

Multiple dispatch is a technique where the function or method to be invoked is determined by the runtime types (more specifically, the number of arguments and their types) of the arguments passed to it. This is a powerful concept in programming languages that support it natively, such as Julia.

In Python, the multipledispatch package can be used to implement multiple dispatch. This package allows you to define a generic function with multiple implementations, choosing the appropriate one based on the types of the arguments passed at runtime.

Using the multipledispatch Package

First, you need to install the multipledispatch package:

pip install multipledispatch

Then, you can use it to implement method overloading based on argument types as follows:

from multipledispatch import dispatch

# Define overloaded methods using the @dispatch decorator
@dispatch(int, int)
def product(first, second):
return first * second

@dispatch(str, str)
def product(first, second):
return f"{first} {second}"

@dispatch(int, str)
def product(first, second):
return f"{' '.join([second] * first)}"

# Example usage
print(product(5, 6)) # Output: 30
print(product("Hello", "World")) # Output: "Hello World"
print(product(3, "Python")) # Output: "Python Python Python"

Benefits of Using Multiple Dispatch for Method Overloading

  • Type Safety: Ensures that the method executed matches the argument types, enhancing code reliability and reducing runtime errors.
  • Readability and Maintenance: Clearly distinguishes methods that handle different types of data, making the code more readable and easier to maintain.
  • Flexibility: Allows adding new overloaded methods without modifying existing implementations, adhering to the open/closed principle.

While Python does not support method overloading directly, the multipledispatch package offers a powerful and efficient way to achieve similar functionality through multiple dispatch. This approach not only enhances code readability and maintainability but also allows for greater flexibility in handling different types of arguments, making it a valuable tool for Python developers looking to implement method overloading in their applications.

d. Method overloading in a class

Consider a scenario where we define a function that takes two objects and calls their speak method. Due to polymorphism, objects of different types can be passed to this function, as long as they have a speak method.

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

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

In this example, the animal_sound function can call the speak method on any object passed to it, demonstrating polymorphism through duck typing. The function does not need to know the type of the object in advance, only that it can perform the action (method call) expected of it.🐶🐱

3. Class Methods Polymorphism in Python Inheritance

Polymorphism with class methods inheritance in Python allows different classes to have methods with the same name but with different implementations. This aspect of polymorphism is particularly useful in designing flexible and extensible code for data science projects, where operations like data processing, analysis, or visualization might vary significantly across different types of data but can be invoked through a common interface.

Understanding Polymorphism in Class Methods Inheritance

In a data science context, polymorphism enables the creation of a consistent interface for different data processing classes. Each class can have its own implementation of a method, such as process_data, analyze, or visualize, tailored to the specific type of data it handles. This approach simplifies the code and enhances its readability and maintainability, allowing data scientists to use a unified calling convention across diverse data types and processing techniques.

Example: Data Processing in Data Science with Polymorphism

Let's consider a scenario in a data science project where we need to process different types of data: numerical data and text data. We'll define a base class named DataProcessor with a method process_data. Then, we'll create two subclasses, NumericDataProcessor and TextDataProcessor, each overriding the process_data method to handle their respective data types appropriately.

Step 1: Define the Base Class

from abc import ABC, abstractmethod

class DataProcessor(ABC):
@abstractmethod
def process_data(self, data):
"""
Abstract method to process data.
Subclasses must override this method.
"""
pass

This base class sets up a contract with subclasses, enforcing an implementation of the process_data method.

In this example:

  • DataProcessor is an abstract base class with an abstract method process_data().
  • NumericDataProcessor and TextDataProcessor are concrete subclasses that override the process_data() method with specific implementations.

Remember to import ABC and abstractmethod from the abc module to define an abstract base class. The @abstractmethod decorator ensures that any subclass of DataProcessor must provide an implementation for process_data()

See below for a complate explanation of @abstractmethod.

Step 2: Implement Subclasses with Polymorphism

class NumericDataProcessor(DataProcessor):
def process_data(self, data):
# Assuming 'data' is a list of numbers
return [x * 2 for x in data] # Simple processing: double each number

class TextDataProcessor(DataProcessor):
def process_data(self, data):
# Assuming 'data' is a list of strings
return [s.upper() for s in data] # Simple processing: convert to uppercase

Each subclass provides its own implementation of process_data, tailored to the specific data type it handles.

Step 3: Utilize Polymorphism in a Data Science Workflow

def process_all(data_processor, data):
return data_processor.process_data(data)

# Example usage
numeric_processor = NumericDataProcessor()
text_processor = TextDataProcessor()

numeric_data = [1, 2, 3, 4]
text_data = ["python", "data", "science"]

print(process_all(numeric_processor, numeric_data)) # Output: [2, 4, 6, 8]
print(process_all(text_processor, text_data)) # Output: ['PYTHON', 'DATA', 'SCIENCE']

In this example, the process_all function does not need to know the type of data it is processing. It relies on polymorphism to call the appropriate process_data method based on the object passed to it. This approach demonstrates the power of polymorphism in creating flexible and extensible data processing pipelines in data science projects.

Liskov Substitution Principle with Class Methods Polymorphism

Understanding the Liskov Substitution Principle (LSP) is crucial for designing robust, maintainable object-oriented software. It’s one of the five SOLID principles of object-oriented design, which aim to make software more understandable, flexible, and maintainable. In this article, we’ll explore how the LSP applies to Python, specifically focusing on class methods polymorphism in inheritance. We’ll begin by defining the LSP, then delve into its application in Python through examples.

Liskov Substitution Principle Defined

The Liskov Substitution Principle ( that was initially introduced by Barbara Liskov in a 1987) states that

objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program.

In simpler terms, subclasses should extend the base classes without changing their behavior. This principle ensures that a class and its derived classes are interchangeable without modifying the program’s expected outcomes.

Significance of LSP in Python

Python, being a dynamically typed language, doesn’t enforce type checking. This makes adhering to LSP more of a design choice than a requirement enforced by the language. However, following LSP in Python is essential for building scalable and maintainable object-oriented systems, especially when dealing with class inheritance and polymorphism.

Polymorphism and Inheritance in Python with LSP

As seen before, Polymorphism allows methods to do different things based on the object that is calling them. This is closely tied to inheritance, where a subclass inherits methods and properties from a base class but can also override or extend them.

To adhere to the Liskov Substitution Principle, subclasses should not only inherit from base classes but also ensure they can be used interchangeably without breaking the functionality. Let’s illustrate this with an example involving class methods polymorphism.

Example of LSP violation

Imagine we have a base class Bird with a method fly(). We then create a subclass Penguin that inherits from Bird. Since penguins can't fly, we might be tempted to override the fly() method in Penguin to throw an exception or do nothing.

class Bird:
def fly(self):
print("Flying")

class Penguin(Bird):
def fly(self):
raise NotImplementedError("Penguins can't fly")

This design violates the Liskov Substitution Principle because we cannot substitute a Bird object with a Penguin object and expect the fly() method to behave correctly. In the base method we have a print, in the subclass method we have an error. The output of the two methods is not coherent.

Example with LSP

A better approach is to refactor our design so that it adheres to LSP. We could introduce a more general class Animal and have Bird and Penguin inherit from Animal. Then, only add a fly() method to those classes that can actually fly.

class Animal:
pass

class FlyingAnimal(Animal):
def fly(self):
print("Flying")

class Bird(FlyingAnimal):
pass

class Penguin(Animal):
pass

In this design, FlyingAnimal is a subclass of Animal that introduces the fly() method. Both Bird and Penguin are subclasses of Animal, but only Bird inherits from FlyingAnimal. This adheres to LSP because we don't expect all animals (or subclasses of Animal) to fly.

Other Violation of LSP: Method Parameters in Class Hierarchies

The Liskov Substitution Principle (LSP) not only governs the behavior and output of subclass methods but also extends to the method signatures themselves, including the number and type of parameters. A common misconception is that overriding methods in subclasses can freely change the number of parameters. However, doing so can violate LSP, leading to subtle bugs and code fragility. Let’s explore this concept with examples and conclude with best practices.

Violating LSP with Method Signatures

Consider a class hierarchy where the base class defines a method that requires one parameter, but the subclass method requires two. This modification can lead to LSP violations because the subclass no longer seamlessly substitutes for the base class.

Example: Base Class with Two Parameter

class Calculator:
def add(self, a, b):
return a + b

Subclass with Additional Parameters

class AdvancedCalculator(Calculator):
def add(self, a, b, c=0): # Attempting to extend functionality
return a + b + c

At first glance, this might seem like a clever way to extend functionality by providing a default value for the third parameter. However, it subtly violates LSP in scenarios where a user of the Calculator class expects the add method to only work with two parameters. This design can lead to unexpected behavior when AdvancedCalculator instances are used in place of Calculator instances, especially in polymorphic scenarios.

Adhering to LSP in Method Signatures

To adhere to LSP, subclass methods should accept the same parameters as their base class counterparts. If extension is necessary, consider using alternative approaches that don’t alter the method’s external signature.

Corrected Subclass with LSP Adherence

class AdvancedCalculator(Calculator):
def add(self, a, b):
return super().add(a, b)

def add_three(self, a, b, c): # New method for extended functionality
return a + b + c

In this corrected version, AdvancedCalculator still overrides the add method but maintains the original parameter list, ensuring it can substitute for Calculator without issues. The extended functionality is provided through a new method, add_three, which does not interfere with the LSP compliance of the existing add method.

Conclusions on Liskov Substitution Principle

The ultimate rule derived from the Liskov Substitution Principle is clear:

if your class hierarchy violates LSP, then inheritance is likely not the right tool for your design.

This principle extends beyond method behavior to include method signatures, ensuring that subclasses can truly substitute for their base classes in all respects.

Violating LSP can lead to unpredictable code behavior and maintenance headaches. As a best practice, always design your class hierarchies to ensure that any instance of a subclass can stand in for an instance of its superclass, not only in terms of what it does but also in how it is used. When in doubt, favor composition over inheritance (we’ll see in the last article of the series what this means) or refactor your hierarchy to better reflect the actual relationships and behaviors of your objects.

By adhering to LSP and the other SOLID principles, you ensure your codebase remains robust, flexible, and maintainable, significantly reducing the likelihood of bugs and making future expansions easier to implement.

--

--

Gianpiero Andrenacci
Data Bistrot

AI & Data Science Solution Manager. Avid reader. Passionate about ML, philosophy, and writing. Ex-BJJ master competitor, national & international titleholder.