10 minutes read

It is well known that Python has dynamic typing, but it also supports type hints that help prevent code errors. Although type hints don't affect the actual behavior of a program, using them consistently can improve the readability of your code. In this chapter, we will explore the use of type hints in more complex scenarios.

Base class as a function argument

One of the main concepts in Object-Oriented Programming is inheritance. Type hints can also be used to annotate classes and their successors. Let's take a look at a toy class hierarchy:

class Base:
    def __init__(self):
        self.name = 'Base'
        # some functionality
        pass

class Derived1(Base):
    def __init__(self):
        super().__init__()
        self.name = 'Derived1'
        self.is_derived1 = True
        # some functionality
        pass

class Derived2(Base):
    def __init__(self):
        super().__init__()
        self.name = 'Derived2'
        # some functionality
        pass

class Derived11(Derived1):
    def __init__(self):
        super().__init__()
        # some functionality
        self.name = 'Derived11'
        self.is_derived1 = True
        pass

The code above represents a Base class, which has two descendants: Derived1 and Derived2; Derived1 also has one descendant — Derived11, as follows:

Let's look at how we can pass such objects as function arguments. To pass an object of the Base class, use the following statement:

def printer_for_base(arg: Base):
    print(f"Printer for Base classes: {arg.name}")

Such function can accept any class from our toy hierarchy:

b: Base = Base()
d1: Base = Derived1()
d2: Base = Derived2()
d11: Base = Derived11()

printer_for_base(b) # OK: Printer for Base classes: Base
printer_for_base(d1) # OK: Printer for Base classes: Derived1
printer_for_base(d2) # OK: Printer for Base classes: Derived2
printer_for_base(d11) # OK: Printer for Base classes: Derived11

Note how we declared all objects with the Base type; since all of them are either Base or its successors, type hints allow such annotations.

Now consider a function with a slightly different signature:

def printer_for_derived1(arg: Derived1) -> None:
    print(f"Printer for Derived1 classes: {arg.is_derived1}")

Let's try to pass the same objects to this function:

b: Base = Base()
d1: Base = Derived1()
d2: Base = Derived2()
d11: Base = Derived11()

printer_for_derived1(b) # INVALID
printer_for_derived1(d1) # OK: Printer for Derived1 classes: True
printer_for_derived1(d2) # INVALID
printer_for_derived1(d11) # OK: Printer for Derived1 classes: True

Note that passing objects of classes Base and Derived2 to the function is now incorrect and will fail to type check at runtime since it only accepts successors of the Derived1 class, and Type hints would inform you about those inconsistencies.

Generic classes and functions

Type hints can also be used when creating generic classes and functions. Recall that a generic class encapsulates operations that are not specific to a particular data type, meaning that fields and methods of such class can be generalized with a specific parameter. One of the most common usages of generic classes is user-defined collections: arrays, hash maps, trees, queues, etc.

First, let's discuss type variables to parametrize a generic class or function. To declare one in Python, use the construction T = TypeVar('T'). This also makes T valid as a type within the class or function body. Here is a simple example of a generic function in Python:

from typing import TypeVar, List, Union

T = TypeVar('T')

def get_first_or_default(lst: List[T]) -> Union[T, None]:
    return None if len(lst) == 0 else lst[0]


print(get_first_or_default([1, 2, 3, 4]))

This function accepts a list of values of the same type and returns its first element or None.

Now that you know how to declare a type variable, let's delve deeper into generic classes. To create one, we'll need the already familiar TypeVar and abstract class called Generic. The syntax for such a case would look like this:

from typing import TypeVar, Generic

T = TypeVar('T')
class MyGenericClass(Generic[T]):
    def __init__(self, value: T) -> None:
        pass

    # other methods...

Now, let's delve into the code snippet and examine it line by line. The type variable parametrizes our class T; we already know how to declare one. The following line contains the definition of MyGenericClass, where we can notice that our class inherits from Generic class, an abstract base class for generics. To put it simply, type hints syntax implies that a class is marked generic once it inherits from the Generic subclass.

Variadic generics

Type hints also support variadic generics: this feature allows you to declare an arbitrary number of type parameters. To incorporate this feature into your Python code, you'll need to import TypeVarTuple and Unpack the typing_extensions module. TypeVarTuple enables variadic generics by declaring a parameter pack like so:

Ts = TypeVarTuple('Ts')

Unpack is an operator used to conceptually mark an object as having been unpacked. Let's take a closer look at the syntax:

from typing_extensions import TypeVarTuple, Unpack

Ts = TypeVarTuple('Ts')

def f(*tup: Unpack[Ts]):
    for i in tup:
        print(i, end=' ')

# From Python 3.11 and higher
def f(*tup: *Ts):
    for i in tup:
        print(i, end=' ')

Note that from Python 3.11 and higher, the Unpack operator can be replaced with the asterisk (*) operator to expand collections.

Such functions can be called as follows:

f(3, 'a', 'hello', True)  # Prints "3 a hello True"
f(3, 'a', 'hello')  # Prints "3 a hello"

Now, we can create functions with more complex behavior by combining TypeVar and TypeVarTuple, for example, here is a function that moves the first element of the tuple to the last position:

T = TypeVar('T')
Ts = TypeVarTuple('Ts')

def move_first_element_to_last(tup: tuple[T, Unpack[Ts]]) -> tuple[Unpack[Ts], T]:
    return (*tup[1:], tup[0])

Here is how this function would work in different cases:

move_first_element_to_last(tup=(1,))

# T=int, Ts=(str,)
move_first_element_to_last(tup=(1, 'spam'))  # Return value = ('spam', 1),
                                             # which has type tuple[str, int]

# T=int, Ts=(str, float)
move_first_element_to_last(tup=(1, 'spam', 3.0))  # Return value = ('spam', 3.0, 1),
                                                  # which has type tuple[str, float, int]

# This fails to type check (and fails at runtime)
# because at least one element is required and tuple[()] is not compatible with tuple[T, *Ts]
move_first_element_to_last(tup=())

Variadic generics can also be used for classes:

T = TypeVar('T')
Ts = TypeVarTuple('Ts')

class Array(Generic[T, Unpack[Ts]]):

    def __abs__(self) -> Array[T, Unpack[Ts]]:
        pass

    def __add__(self, other: Array[T, Unpack[Ts]]) -> Array[T, Unpack[Ts]]:
        pass

As you can see, Generic base class allows working with an arbitrary number of types (at least one). Note that in this case the Unpack operator can also be replaced with the asterisk (*) operator if your version of Python is 3.11 or higher.

Conclusion

Incorporating type hints into your Python classes and generics can enhance code clarity and promote better maintainability. This can take your experience from coding in Python to the next level since it benefits you as the developer and makes your code more accessible and understandable for fellow programmers who may read your code. And now you're one step closer to it, as you've learned:

  • how to use Type hints when it comes to inheritance;
  • how to annotate generic functions and classes using type variables.
  • How to work with variadic generics and declare parameter packs.
18 learners liked this piece of theory. 5 didn't like it. What about you?
Report a typo