While hanging out in a Discord server a few days ago, someone brought up the topic of generics in Python. This sparked curiosity among several members, leading to questions about what generics are and how or when to use them. Generics aren’t unique to Python, many programming languages use them as fundamental tools for creating robust and efficient software.
This conversation got me thinking it would be a great topic to explore together on this blog. So, let’s dive into the basics of using generics in Python, discuss some variants, and discover how they can streamline our code and enhance its reliability.
What are generics?#
Generics are designed to enhance the flexibility and safety of your code. Here’s what they aim to do:
- Enable Flexibility: They allow functions, methods, and classes to work with different data types while preserving information about the relationships between these data types, such as the consistency between input arguments and return values.
- Improve Type Safety: Generics help specify how different types can interact more explicitly, reducing errors by clarifying the data types used in operations.
We achieve these benefits by using generic types—placeholders for data types—instead of specific or parent types when defining components. This allows your code to be both flexible and safe, accommodating various data types while ensuring operations are performed correctly.
If that sounds a bit abstract, don’t worry! The best way to understand generics is to see them in action. Let’s dive into some simple examples to see how you can use generics in your Python projects to make your code more robust and flexible.
Using generics in Python#
I’ll begin by using the traditional approach familiar to most Python developers. This involves utilising TypeVar
from the typing
module. As we progress through the post, I’ll also cover the Type Parameter Syntax introduced by PEP-695 ↗ in Python 3.12. This update brings several enhancements that simplify the declaration and usage of generics, and I’ll highlight some of its key nuances. It is also important to note that Python will not do anything special with the generics, it is dependent on the person running a type checker e.g. Pyright / Pylance, MyPy or Pyre.
This function accepts a list containing elements of any type and returns the first element. It uses the “ type to indicate that it can handle elements of any type without restriction.
So why choose a generic over using Any
? The key advantage of using a generic lies in its ability to ensure consistency and type safety. With a generic, you can guarantee that the type returned by the function matches the type of the elements in the provided list. This consistency is not assured with Any
.
Since the specific type of elements list contains is not of concern, rather only that the function returns the same type as provided in the list, we define a generic type named T
with T = TypeVar("T")
. This approach replaces the use of Any
, which lacks this level of type specificity.
By using T
, we introduce type safety to the get_first_item()
function, safeguarding against issues like returning a transformed type or an incorrect literal. This setup ensures that our function’s behaviour remains predictable and consistent, protecting against unintended coding errors.
Let’s look at one more simple example before we move on.
The below will warn that get_first_key()
is expecting to return a key and not a value from the dictionary. But, as mentioned near the start of this post, this will not prevent the code from running and it will still print the first value, which is 1.
Limiting generics to specific types#
In the previous examples, we utilised generic types that could represent any possible type. But what if we want to narrow down and specifically limit the types that a generic can represent?
Here we constrain T
to only allow str
and int
types. When we try passing a list of floats we see the type checker warn that combine_elements()
is not expecting this type.
We can also set an upper bound constraint, limiting the generic type to that specific type and any of its subclasses.
Generics with classes#
By using generics in classes, developers can design components that are not tied to specific data types, thus increasing the utility and scalability of their code. A prime example is the implementation of data structures, such as lists, queues, and trees, which can hold any type of data but enforce type consistency within an instance. This approach eliminates common bugs that arise from type mismatches and enhances code safety.
We have created a Box
class—a versatile container that can hold various specialised items. In our scenario, we have two types of boxes: a toy box and a bolt box. The toy box is designated for toys, represented as strings, like “Teddy Bear” or “Toy Car”, and must be put away before bedtime. The bolt box is used for storing different sizes of bolts, represented as integers.
However, we’ve made a mistake by trying to add a “Toy Hammer” to our bolt box. Our type checker, as usual, will alert us to this mistake.
Argument of type "Literal['Toy Hammer']" cannot be assigned to parameter "item" of type "int" in function "add" "Literal['Toy Hammer']" is incompatible with "int"
But remember it will not prevent us from adding “Toy Hammer” to the list in Box.
Variants of generics#
There are three variants of generics, which simply refer to different behaviours or specifications that can influence how type checking behaves.
Invariance#
By default, all type variables in Python are invariant. Invariance means that if you have a generic type G[T]
, it cannot accept G[U]
, even if U
is a subclass or superclass of T
. Most mutable containers are invariant. e.g. list
and dict
.
For example, list[Animal]
can not be used where list[Cat]
is expected, where Animal
is a superclass of Cat
.
Covariance#
A type variable is covariant if it allows a generic class to be substituted with a subclass. This is typically used for return types, where a method of a generic class promises to return a type T
, and it’s safe to use a type U
such that U
is a subclass of T
. You declare this in the TypeVar
.
It’s also important to note that when a generic class is covariant, it is recommended to be immutable / read-only. Some examples of this are typing.Sequence
, typing.FrozenSet
and typing.Union
. Remember this for later in the post.
- We define a TypeVar
T_co
that is marked as a covariant by settingcovariant=True
and bounding it to theAnimal
class. This meansT_co
can accept any subclass ofAnimal
. - Using
T_co
, we create anAnimalShelter
generic class that is initialised with an instance of Animal or its subclasses. - We define a
function adopt_animal()
that takes anAnimalShelter
housing anyAnimal
and returns the animal. - We create shelters for different animals, cats, dogs, and hypothetically, snakes. Since snakes are not a subclass of
Animal
they will trigger a type error:
Argument of type "Snake" cannot be assigned to parameter "animal" of type "T_co@AnimalShelter" in function "__init__" Type "Snake" is incompatible with type "Animal"
Contravariance#
Allows substitution of a type with its supertype. For example, if a function expects a type of Cat
, then it will also accept its supertype, Animal
.
We define a type variable T_contra
that is contravariant and bounded by the class Animal
. This setup implies that wherever a specific type like Cat
is expected, any of its supertypes (up to Animal
) can be used instead.
Generics with protocols#
In practical applications, it’s often beneficial to use a Protocol
to define an interface. This approach ensures that any subclass implementing the interface will include the necessary methods, as enforced by type checking warnings.
In the example above, the Fish
class does not implement the speak()
method, therefore it does not meet the requirements of the Animal
protocol. Because of that, you can’t use Fish
in situations where you need something that fits the Animal
protocol, whether it’s covariant or contravariant.
Python 3.12 Generics (PEP 695)#
With PEP 695, we can streamline how generics are defined. Let’s take our very first example.
Note how we did not need to import and define the TypeVar
for each generic. How about with a Protocol
?
The only part that changes is the CurrencyExchangeMapper
class.
Setting bounds and limiting types in PEP 695 are similar, but there is an important difference. With the enhancements introduced in PEP 695, type variance does not always need to be explicitly defined because it can often be inferred from the context.
Recall when I said to remember that covariants are recommended to be immutable / read-only? Well here is an example showing how PEP 695 is enforcing this with class generics. I personally find this stricter approach to be more correct, but it is a differing behaviour to class Thing(Generic[T])
.
Note how this does not warn of any errors.
Now if we convert this to the PEP 695 way
Because of inference, it deems AnimalShelter[Animal]
to be invariant as self.animal
is mutable. There are two ways to resolve this.
The first is by using typing.Final
. This informs the type checker that the name cannot be re-assigned or overridden in a subclass.
The second, and the way I believe is more correct, is by making self._animal
private and using a @property
, essentially making it “read-only”.
Final thoughts#
Both approaches have their place and choosing which way you want to handle your generics in Python will essentially come down some key points.
- Project and team scale: Larger, more complex projects might still benefit from the explicit nature of traditional generics, particularly for maintaining strict type control across large teams.
- Codebase Maturity: Introducing new syntax in a mature codebase requires careful consideration, training, and potential refactoring. New projects have the freedom to adopt the latest features from the start.
- Development Philosophy: Some teams prioritise using the latest Python features to keep their codebase modern and concise, while others value the stability and clarity brought by the established patterns.
- Personal project: Use whatever you want.
Thanks for reading and if you wish to dig further into generics in Python check out the following resources:
https://peps.python.org/pep-0484/#generics ↗