Python’s Type Variables: A Numeric Type Odyssey

Discover Python's synergy of type variables and pattern matching for creating versatile and type-safe code. This article delves into enhancing Python's numeric flexibility, offering insights into crafting adaptable, robust functions.

Python’s Type Variables: A Numeric Type Odyssey
Numeric Odyssey: Voyage Through the Algorithmic Sea

Overview

In the previous article "Type Hints: Python’s Weapon Against Code Anarchy, ". We improved a Python function to make it type-safe but limited it to integers. However, there's no compelling reason to restrict ourselves to int when Python can handle various numeric types, and the multiplication operation is defined for those types.

In this piece, we'll expand our function's capabilities to embrace integers and other numeric types like floats. We'll use Type Variables in Python to achieve this. Type Variables allow us to write more generic and flexible code, enabling functions to operate on a range of types instead of being confined to just one.


Introducing Type Variables to Our Code

Python supports multiple numeric types. In this short article, we will enhance the code from the previous article by adding support for float and complex numbers in a type-safe manner:

Python 3.12

from typing import Callable, Optional, TypeVar, Union

# Define a type variable that can be an int, float, or complex
Numeric = int | float | complex


def multiply(a: Numeric, b: Numeric) -> Numeric:
    return a * b

def wrapper(func: Callable[[Numeric, Numeric], Numeric]) -> Callable[[Numeric, Numeric], Optional[Numeric]]:
    def inner(a: Numeric, b: Numeric) -> Optional[Numeric]:
        if a == 0 or b == 0:
            print("cannot multiply by zero")
            return None  # Returning 0 instead of None
        else:
            return func(a, b)

    return inner


def process_numbers(a: Numeric, b: Numeric) -> Optional[Numeric]:
    multiplier = wrapper(multiply)
    match multiplier(a, b):
        case result if result is not None:
            print(f"Result of operation: {result}")
            return result
        case _: # This match all is required to convince the type checker the match is exhaustive
            print("Multiplication by zero isn't allowed")
            return None

# Example calls
result_int = process_numbers(5, 3)        # Result of operation: 15
result_float = process_numbers(2.5, 3.0)  # Result of operation: 7.5
result_complex = process_numbers(1 + 2j, 2 - 3j)  # Result of operation: (8+1j)
        

This iteration of our Python code demonstrates significant advancements in handling different numeric types by defining Numeric as a union of int, float, and complex. The multiply function becomes inherently more versatile and capable of processing different numeric data types. This enhancement extends the function's utility and showcases the adaptability of Python's type system.

To facilitate the testing with different values, we added the process_numbers function, where we call the wrapped multiply function and pattern match on it. We employed a wildcard catch-all case (case _:) in the pattern-matching construct. This choice stems from a limitation in Python's handling of Optional types. In Python, an Optional type is essentially a union of a given type and None (e.g., Optional[Numeric] is equivalent to Union[Numeric, None]). This means we do not have a constructor like Some or Just for explicitly handling cases with data.

The lack of a constructor for differentiating a non-None value within an Optional is not a big issue because the type we use with it will usually have its constructor. But in this example, the Numeric type is a Type Variable, which means we do not have a constructor for it.

We could match each type (int, float, complex) individually, use an OR pattern, or, as we did, use a guard to enter the inhabited case and a wildcard for the None branch. Using None instead will give us an error with mypy because the type checker is not clever enough to understand all the covered cases.

An atmospheric illustration featuring serpentine sea creatures rising from stormy ocean waves amidst mathematical and programming symbols, with an old ship navigating through the chaos, symbolizing the adventure of mastering Python's type variables in the vast sea of coding.
Numeric Odyssey: Voyage Through the Algorithmic Sea

Conclusion


Our exploration of Python's type hints led us to type variables. By integrating these with structural pattern matching, we've unlocked code flexibility and robustness. Type variables allow us to write versatile functions handling different types and be clear and precise in their operation.

Combined with pattern matching, this approach enables us to craft exhaustive and type-safe programs. Our code now accommodates various scenarios and maintains strict adherence to type expectations, ensuring reliability and reducing the potential for runtime errors.

As usual, you'll be able to find the code for this article in our GitHub presence.

If you've found value in our content and wish to delve deeper into Python, we invite you to subscribe. Your subscription supports our work and connects you with a community passionate about coding excellence.

Stay tuned for more insights into Python's powerful features.


Addendum: A Special Note for Our Readers

Before we sign off, we’d like to share something special with our readers who’ve appreciated the visual journey accompanying our articles.

We invite you to visit the TuringTacoTales Store on Redbubble. Here, you can explore a range of products featuring these unique and inspiring designs.

Each item showcases the beauty and creativity inherent in coding. It’s a perfect way to express your love for programming while enjoying high-quality, artistically crafted merchandise:

So, please take a moment to browse our collection and perhaps find something that resonates with your passion for Python and programming!