Generics and Type Variables#

Twitter Handle LinkedIn Profile GitHub Profile Tag Tag

This article is closely aligned with and draws inspiration from the materials provided in Unit 20: Generics of the CS2030S course at the National University of Singapore. Some sections have been adapted and rephrased for clarity and context, in particular translating the Java code to Python.

The Motivation#

In some scenarios, crafting a simple class to aggregate a duo of variables proves beneficial, especially when a method needs to output two distinct values. Consider, for instance, the IntPair class, which groups two integer variables. This class serves merely as a utility, lacking any complex semantics or methods, and thus, its internal workings are left exposed for simplicity.

 1from dataclasses import dataclass
 2
 3@dataclass
 4class IntPair:
 5    first: int
 6    second: int
 7
 8    def get_first(self) -> int:
 9        return self.first
10
11    def get_second(self) -> int:
12        return self.second

Such a class is ideal for use cases like a function that needs to return a pair of integers, exemplified by a hypothetical find_min_max function that determines the minimum and maximum values in an array of integers. However, this raises a question: what if the need arises to identify the minimum and maximum in an array of floats?

 1def find_min_max(array: List[int]) -> IntPair:
 2    min_val = sys.maxsize  # Largest possible int
 3    max_val = -sys.maxsize - 1  # Smallest possible int
 4
 5    for num in array:
 6        if num < min_val:
 7            min_val = num
 8        if num > max_val:
 9            max_val = num
10
11    return IntPair(min_val, max_val)

To address a broader range of types, one could theorize the creation of additional pair classes, such as DoublePair for floats or BooleanPair for booleans. Alternatively, designing a pair class that accommodates a combination of different types, like pairing a Customer object with a ServiceCounter, or a string with an integer, could offer more versatility.

However, it’s impractical and inefficient to design a separate class for each potential type pairing. A more elegant solution is to design a generic pair class capable of encapsulating any two types.

 1@dataclass
 2class Pair:
 3    first: Any
 4    second: Any
 5
 6    def get_first(self) -> Any:
 7        return self.first
 8
 9    def get_second(self) -> Any:
10        return self.second

This generic Pair class can encompass any two types designated upon its instantiation, providing a versatile structure that can hold any value types, mirroring the flexibility previously applied in methods to accommodate various object types.

Nevertheless, this approach is not without its drawbacks, notably the issue of type conversion narrowing and the potential for runtime errors. For example, if a function mistakenly returns a Pair with a string and an integer but treats it inversely, static type checking at compile-time won’t catch this discrepancy, possibly leading to errors or crashes during execution. This highlights the importance of careful type management and the limitations of relying solely on runtime type identification.

 1def create_misleading_pair() -> Pair:
 2    """This function creates a Pair with a string and an integer."""
 3    return Pair("hello", 4)
 4
 5def process_misleading_pair(pair: Pair) -> None:
 6    """
 7    Process elements of the pair, with incorrect assumptions about their types.
 8    """
 9
10    # The programmer incorrectly assumes the first element is an integer
11    # and the second element is a string.
12    try:
13        first_element = int(pair.get_first())  # Mistakenly assuming it's an int
14        second_element = str(pair.get_second())  # Mistakenly assuming it's a str
15        if TYPE_CHECKING:
16            reveal_type(pair.get_first())
17            reveal_type(pair.get_second())
18            reveal_type(first_element)
19            reveal_type(second_element)
20            reveal_locals()
21
22    except ValueError as err:
23        print(f"Error: {err}")
24
25# Example Usage
26pair = create_misleading_pair()
27process_misleading_pair(pair)
Error: invalid literal for int() with base 10: 'hello'

Why does the compiler not be able to detect the type mismatch and stop the program from crashing during run-time? In fact, if you run the type checker mypy on the above code, it will not raise any error. You can also add reveal_type and reveal_locals to the code to see the type of first_element and second_element and the local variables.

16: note: Revealed type is "builtins.int"
17: note: Revealed type is "builtins.str"
18: note: Revealed local types are:
18: note:     first_element: builtins.int
18: note:     pair: Pair
18: note:     second_element: builtins.str

Only when you run the code, then the error occur. What’s worse, is that the error is silent in both compile and run time.

 1def create_silent_error_pair() -> Pair:
 2    """This function creates a Pair with a string and an integer."""
 3    return Pair('16', '4')
 4
 5def process_silent_error_pair(pair: Pair) -> str:
 6    """
 7    Process elements of the pair, with incorrect assumptions about their types.
 8    """
 9
10    try:
11        first_element = str(pair.get_first())
12        second_element = str(pair.get_second())
13        if TYPE_CHECKING:
14            reveal_type(first_element)
15            reveal_type(second_element)
16            reveal_locals()
17
18    except ValueError as err:
19        print(f"Error: {err}")
20
21    return first_element + second_element

And what is wrong in this code? The programmer actually wanted to create a pair of int but mistakenly created a pair of str and want to add them together. Instead of getting 20, the result is 164.

Running mypy on the above code will not raise any error as well, as they inferred “correctly” here.

The problem here is that Any is too “generic” literally. When the programmer write the return type of create_misleading_pair and create_silent_error_pair as Pair, the there is no type binding to the Pair class for the type of first and second attribute.

If there is a way to parameterize the Pair class, then the type checker can detect the type mismatch and stop the program from crashing during run-time. If we can create two generic type variables, S and T, both not necessarily the same, then we can have a contract that the first attribute of Pair is of type S and the second attribute of Pair is of type T, then necessarily, the return type of get_first and get_second will be S and T. If we can do that, our problems may be solved.

Containers are Generics#

Let’s defer the solution to the motivation above and first understand some practical examples of generics in Python. This is adapted from Type Hinting: Generics & Inheritance

 1@dataclass
 2class Employee:
 3    name: str
 4    id: int
 5
 6    def __str__(self) -> str:
 7        return f"{self.name} ({self.id})"
 8
 9    def unique_id(self) -> str:
10        return self.__str__()

Python’s list is a dynamic array that can hold any type of object. This has its advantages, but it also means that the type of the elements in the list is not enforced. This can lead to bugs and errors that are difficult to track down.

For example, consider the following list of dynamic types:

1list_of_dynamic_types: List = [Employee("Alice", 1), 1, "hello", 3.14]
2
3try:
4    for item in list_of_dynamic_types:
5        print(item.__str__())
6        print(item.unique_id())
7except AttributeError as err:
8    print(f"Error: {err}")
Alice (1)
Alice (1)
1
Error: 'int' object has no attribute 'unique_id'

In the above code, the list_of_dynamic_types contains a mix of Employee, int, str, and float objects. When we try to call the __str__ and unique_id methods on each item in the list, we get an AttributeError because not all the items in the list have these methods.

To address this issue, we can use Python’s type hinting system to specify the type of the elements in the list. This will allow the type checker to catch errors at compile time, rather than at run time.

 1list_of_employees: List[Employee] = [Employee("Alice", 1), Employee("Bob", 2)]
 2list_of_ints: List[int] = [1, 2, 3, 4]
 3list_of_strings: List[str] = ["hello", "world"]
 4
 5if TYPE_CHECKING:
 6    reveal_type(list_of_employees)  # Revealed type is 'List[Employee]'
 7    reveal_type(list_of_ints)  # Revealed type is 'List[int]'
 8    reveal_type(list_of_strings)  # Revealed type is 'List[str]'
 9
10for employee in list_of_employees:
11    print(employee.unique_id()) # this will not raise any error because mypy knows each employee is of type Employee and has unique_id method
Alice (1)
Bob (2)

And if you append Employee to list_of_ints:

1list_of_ints.append(Employee("Charlie", 3))  # Error: Argument 1 to "append" of "list" has incompatible type "Employee"; expected "int"

Running mypy will yield:

150: error: Argument 1 to "append" of "list" has incompatible type "Employee"; expected "int"  [arg-type]
    list_of_ints.append(Employee("Charlie", 3))  # Error: Argument 1 to "append" of "list" has incompatible type "Employee"; expected "int"

So why do we say containers are generics? If you notice, when we type hint list_of_employees, list_of_ints, and list_of_strings, we are actually using a generic type List with a type parameter Employee, int, and str respectively via List[Employee], List[int], and List[str]. This is the same as the Pair class we defined in the motivation above. The List class is a generic class that can hold any type of object, and the type parameter specifies the type of the elements in the list.

How do we use generics and typevar in this context?

Consider a very simple function that just want to append an int element to a list and return the list:

1def append_int_and_return_list(list_: List[int], element: int) -> List[int]:
2    list_.append(element)
3    return list_
4
5list_of_ints = [1, 2, 3]
6new_list_of_ints = append_int_and_return_list(list_of_ints, 4)
7print(new_list_of_ints)
[1, 2, 3, 4]

and another function that just want to append a str element to a list and return the list:

1def append_str_and_return_list(list_: List[str], element: str) -> List[str]:
2    list_.append(element)
3    return list_
4
5list_of_strings = ["hello", "world"]
6new_list_of_strings = append_str_and_return_list(list_of_strings, "!")
7print(new_list_of_strings)
['hello', 'world', '!']

The above two functions are very similar, and the only difference is the type of the list and the type of the element. We really do not want two functions doing the same thing as the append method is well defined for the List class. We can use Any as the type of the list and the element, but this will not catch type errors at compile time if we silently slip in a str to a list of int or vice versa.

1def append_and_return_list(list_: List[Any], element: Any) -> List[Any]:
2    list_.append(element)
3    return list_
4
5list_of_ints: List[int] = [1, 2, 3]
6new_list_of_ints: List[Any] = append_and_return_list(list_of_ints, "hello")
7print(new_list_of_ints)
[1, 2, 3, 'hello']

In comes the type variable.

 1T = TypeVar('T')
 2
 3def append_and_return_list(list_: List[T], element: T) -> List[T]:
 4    list_.append(element)
 5    return list_
 6
 7list_of_ints: List[int] = [1, 2, 3, 4, 5]
 8
 9# This will cause a mypy error
10new_list_of_ints: List[int] = append_and_return_list(list_=list_of_ints, element="abcedf")
11print(new_list_of_ints)
[1, 2, 3, 4, 5, 'abcedf']

Running mypy on the above code will yield:

10: error: Argument "element" to "append_and_return_list" has incompatible type "str"; expected "int"  [arg-type]
    new_list_of_ints: List[int] = append_and_return_list(list_=list_of_ints, element="abcedf")

By now, we can spot the pattern from the examples above. Our list is a container, it is called a generic container because it can hold any type of object parameterized by the type parameter/variable T. The T is a type variable, and it is a placeholder for the actual type that will be used when the function is called.

T generalizes the type in the list: List[int], List[str], List[Employee], etc and T is called a variable because it is literally a variable of types. List[str] is a specific type, and List[T] is a generic type that can represent any specific type. Consequently, List[T] is a generic type and T is a type variable[1].

Remark 9 (Why not just use Any?)

You might wonder why we use a type variable T to denote generality in types, rather than simply using Any, given that both seem to imply a kind of universality. However, there’s a crucial distinction between them in terms of type safety.

The type variable T is generic, meaning it represents a specific but unspecified type across the context in which it’s used. This specificity allows for type consistency within a given scope. For example, if a function is defined to accept a list of type List[T] and an element of type T, T will be the same type for both the list and the element. This generic but consistent typing ensures that the function can operate on a list and an element of the same, though unspecified, type, thereby maintaining type safety by preventing type mismatches.

On the other hand, Any is indeed “more generic” but in a way that bypasses type safety checks. Declaring a list as List[Any] tells the type checker that the list can contain elements of any type, effectively disabling type safety for elements of that list. This means that both integers and strings (and any other type) can coexist in such a list without raising type errors at compile-time or during static analysis. While this provides flexibility, it sacrifices the guarantees that come with stricter type checking.

Using Any indiscriminately can lead to silent type errors where, for instance, a string is accidentally added to a list of integers. The type system won’t catch this mix-up at compile-time or during static analysis with mypy, leading to potential runtime errors or bugs that are harder to trace and fix.

You can find another custom example on implementing a stack using generics in the Stack - Omniverse page.

Generics and Type Variables#

Generics#

Generics enable the construction of new, more specific types from abstract type definitions by using what are known as generic type constructors. These constructors operate at the type level, similar to how functions operate at the value level.

  • Generic Type: A generic type is a type definition that includes one or more type variables. It’s a template from which concrete types can be constructed. For example, Tuple[T] or more broadly Tuple[T, ...] (using Python’s typing syntax) is the generic type. It’s not Tuple itself that’s the generic type, but rather Tuple parameterized with type variables like T or with specific types (we usually call the realization of the generic type as a parameterized type[2] (concrete type)).

  • Generic Type Constructor: This term refers to the mechanism by which generic types like Tuple[T, ...] are used to create new, parameterized types. The constructor takes a type (or types) as an argument and “returns” a new parameterized type based on the generic definition. For instance, when you use Tuple[float, ...], you’re invoking the generic type constructor of Tuple with float as the argument, resulting in a new parameterized type that can be thought of as a vector of floats.

Here are two examples illustrating this concept:

  1. Vector Example: When Tuple is parameterized with float and an ellipsis (...) indicating a variable length, Tuple[float, ...] acts as a parameterized type that could represent a mathematical vector of floats. In this scenario, Tuple[float, ...] is a parameterized type derived from the generic type Tuple[T, ...] by specifying float as the type argument.

  2. Registry Example: Similarly, by parameterizing Tuple with another type, such as UserID (assuming UserID is a type you’ve defined elsewhere), Tuple[UserID, ...] becomes a parameterized type that could represent a registry of user IDs. Again, Tuple[UserID, ...] is a parameterized type constructed from the generic type Tuple[T, ...] using UserID as the type argument.

Such semantics is known as generic type constructor, which is similar to the semantics of functions, where a function takes a value and returns a value. In this case, a generic type constructor takes a type and returns a type[3].

Type Variable and Type Parameter#

A type variable is a placeholder used in generic programming to denote a type that is not specified at the point of declaration but will be determined later, at the time of use. Type variables allow the definition of generic types or functions that can operate on any data type. They are essentially the variables of type expressions.

A type parameter is similar to a type variable, but the term is often used in the context of defining generic classes, interfaces, or methods. The type parameter is declared as a part of a generic type or method definition and specifies a placeholder that will be replaced with an actual type when the generic type is instantiated or the generic method is invoked. It allows for the creation of parameterized types or methods where the specific type(s) to be used are specified when a class is instantiated or a method is called.

The T defined via TypeVar is a type variable, and it is a placeholder for the actual type that will be used when the function is called. For instance, looking at the function below:

T = TypeVar('T')

def append_and_return_list(list_: List[T], element: T) -> List[T]:
    list_.append(element)
    return list_

We do not know what T is at the time of defining the function, but we know that T will be a type of the list and the element when the function is called.

Type Argument#

A type argument refers to the concrete type that is supplied in place of a type variable or parameter when a generic class, interface, or method is actually used. It effectively “fills in” the generic placeholder with a specific type, thereby instantiating or invoking the generic entity with that specific type. The type argument provides the actual type information that allows the generic mechanism to operate on specific data types, ensuring type safety and consistency.

For example, in a generic class List[T], if you create a new instance with new List[int](), the int is the type argument replacing the type parameter T for that specific instance.

Consider the earlier example of the append_and_return_list function:

T = TypeVar('T')

def append_and_return_list(list_: List[T], element: T) -> List[T]:
    list_.append(element)
    return list_

When we invoke this function, we supply type arguments through the types of the arguments passed to the function. If we call append_and_return_list with a list of integers and an integer element:

list_of_ints: List[int] = [1, 2, 3, 4, 5]
new_list_of_ints: List[int] = append_and_return_list(list_=list_of_ints, element=6)

In this case, int serves as the type argument for T. The generic T in the function’s definition is replaced by int, making the function operate specifically on a list of integers. The type argument ensures that the generic function can be applied to a specific data type, in this context, integers, thereby tailoring the generic function to a particular use case while preserving type safety.

Pair Problem Revisited#

To resolve the issues with the Pair class in the motivation, we can use type variables to parameterize the Pair class. This will allow us to specify the types of the first and second attributes, as well as the return types of the get_first and get_second methods.

 1S = TypeVar("S")
 2T = TypeVar("T")
 3
 4@dataclass
 5class Pair(Generic[S, T]):
 6    first: S
 7    second: T
 8
 9    def get_first(self) -> S:
10        return self.first
11
12    def get_second(self) -> T:
13        return self.second
14
15def create_misleading_pair() -> Pair[str, int]: # 101: error: Missing type parameters for generic type "Pair"  [type-arg]
16    """This function creates a Pair with a string and an integer."""
17    return Pair("hello", 4)
18
19def process_misleading_pair(pair: Pair[str, int]) -> None:
20    """
21    Process elements of the pair, with incorrect assumptions about their types.
22    """
23
24    # The programmer incorrectly assumes the first element is an integer
25    # and the second element is a string.
26    try:
27        first_element = int(pair.get_first())  # Mistakenly assuming it's an int
28        second_element = str(pair.get_second())  # Mistakenly assuming it's a str
29
30        if TYPE_CHECKING:
31            reveal_type(pair.get_first())
32            reveal_type(pair.get_second())
33            reveal_type(first_element)
34            reveal_type(second_element)
35            reveal_locals()
36
37    except ValueError as err:
38        print(f"Error: {err}")
39
40# Example Usage
41pair = create_misleading_pair()
42process_misleading_pair(pair)
Error: invalid literal for int() with base 10: 'hello'

To recap:

  • S and T are type variables/parameters that represent the types of the first and second attributes, respectively.

  • Pair[S, T] is a generic class that is parameterized with S and T, allowing the first and second attributes to be of different types.

  • Pair[str, int] is a parameterized type that represents a pair of a string and an integer. This just means “subsitution” of S and T with str and int respectively, just like how we assign normal variables.

However, mypy actually does not raise any issue on line 27 because Python allows a string type to be dynamically converted to an integer type if and only if the string is a valid “integer” string.

But the example, if set in Java, will allow the type checker to catch the type mismatch and stop the program from crashing during run-time. Why? Because Java does not allow a string type to be coerced to an integer type. Below I show a small snippet of Java code that will raise a ClassCastException at run-time.

public class Main {

    public static void main(String[] args) {
        // Create a String object
        String stringValue = "hello";

        // Attempt to cast the String to an Integer
        Integer intValue = (Integer) stringValue;
        System.out.println(intValue);
    }
}

Example 7 (I came all the way just for a Moot Example)

I converted the notes of CS2030S from Java to Python, just to reach a moot point where the type checker does not raise any issue on line 27. But the point should be clear. If we do not parameterize the Pair class and use Any instead, then even in a strongly typed language like Java, the type mismatch may not be caught at compile time.

Let’s just construct another simple example where mypy will actually raise an error.

K = TypeVar("K")
V = TypeVar("V")

@dataclass
class Pair(Generic[K, V]):
    key: K
    value: V

    def get_key(self) -> K:
        return self.key

    def get_value(self) -> V:
        return self.value

def log_user_info(user_info: Pair[str, int]) -> None:
    """
    Logs user information, expecting a username (str) and user ID (int).
    """
    username: str = user_info.get_key()
    user_id: int = user_info.get_value()
    print(f"User: {username}, ID: {user_id}")

def create_incorrect_pair() -> Pair[int, str]:
    """
    Incorrectly creates a pair intended to represent user info,
    but swaps the types of the key and value.
    """
    # Mistakenly swapped the order of types, creating a Pair[int, str] instead of Pair[str, int]
    return Pair(12345, "john_doe")

# Example Usage
incorrect_pair = create_incorrect_pair()
log_user_info(user_info=incorrect_pair)

Indeed, running mypy on the above code will yield:

33: error: Argument 1 to "log_user_info" has incompatible type "Pair[int, str]"; expected "Pair[str, int]"  [arg-type]
    log_user_info(incorrect_pair)

Scope of Generic Methods and Functions#

In the course Unit 20: Generics - CS2030S, it is written in Java, so there is only mention of generic methods. But in Python, we can also define generic functions where the function signature and return type (need not be both) are parameterized by type variables.

Generic Functions#

It is possible to use TypeVar (type variables) to parameterize the types of the arguments and return type of a function without the need to define a Generic (generic) class. This is because functions are naturally and automatically scoped to the type variables defined in the function. What does that mean?

Let’s walk through an example[4] to find out.

Consider a function that adds two things (not necessarily numbers) together. In this particular case, if we want to find out the answer of 3 + 4, we can use the add function to add 3 and 4 together.

def add():
    return 3 + 4

Adding two literal things together is valid but restricted and obviously is a bad pattern. Polymorphism taught us to parameterize the the two things to add:

def add(x, y):
    return x + y

And injecting x and y is the act of parameterizing the function with what we call the regular variables.

Now, we want to type hint the add function. You may likely do the following:

def add(x: int, y: int) -> int:
    return x + y

This is again valid but restricted. The add function is now restricted to adding two integers together. What if we want to add two strings together? What if we want to add two floats together? What if we want to add two Item objects with __add__ method defined together?

This is where you would consider using type variables to parameterize the types of the arguments and return type of the function.

T = TypeVar('T')

def add(x: T, y: T) -> T:
    return x + y

This means that x and y can be of any type, and the return type of the function will be the same as the type of x and y. A contract is born because now if x is defined as an int type, then y must also be an int type, and the return type of the function will be an int type.

Note however, running mypy above will yield two particular errors:

4: error: Returning Any from function declared to return "T"  [no-any-return]
        return x + y
        ^~~~~~~~~~~~
4: error: Unsupported left operand type for + ("T")  [operator]
        return x + y
               ^~~~~

This is because there is no guarantee that the + operator is defined for the type T. What if T is a Dict[int, int] type, then adding two dictionaries together is not defined (likely no __add__ method defined for the dict type). But let’s ignore this and appreciate the bigger picture.

We will talk about bound and constraints later, which will easily solve this problem:

T = TypeVar('T', int, float, complex)

def add(x: T, y: T) -> T:
    return x + y # yields no error from mypy

In conclusion, the add function is now a generic function because it can operate on any type of object, and the type of the arguments and return type of the function are parameterized by type variables. But note that the type variables are only scoped to the function, and they are not available outside the function.

Generic Methods#

In the context of classes, it is also possible to define generic methods where the method signature and return type (need not both) are parameterized by type variables.

We first look at another violation of type safety via the Stack class.

 1from typing import Any, Generic, List, TypeVar
 2
 3from rich.pretty import pprint
 4
 5
 6class Stack:
 7    def __init__(self) -> None:
 8        self._container: List[Any] = []
 9
10    def push(self, item: Any) -> None:
11        self._container.append(item)
12
13    def contains(self, item: Any) -> bool:  # __contains__ method
14        for curr in self._container:
15            if curr == item:
16                return True
17        return False
18
19
20stack = Stack()
21stack.push("hello")
22stack.push("world")
23pprint(stack._container)
['hello', 'world']

As you can see, the contains method is not type safe. The item parameter is of type Any, and the method will return True if the item is found in the stack. The problem with Any, as repeated many times, is that it is too generic and does not provide a contract to abide by. For example, our stack above as we see it, contains strings, and only strings. Now if we want to search for an integer to_find = 123 in the stack, the contains method will return False because the item is not found in the stack. The problem is that the integer type will never be found in the stack because the stack only contains strings. However, because both the self._container and item are of type Any, the type checker will not raise any error.

1to_find: int = 123
2result: bool = stack.contains(to_find)
3pprint(result)
False

We want to bind a contract such that if the self._container is of type List[str], then the item must also be of type str. If the self._container is of type List[int], then the item must also be of type int. This is where we can use type variables to parameterize the types of the arguments and return type of the method.

 1from typing import Any, Generic, List, TypeVar
 2
 3from rich.pretty import pprint
 4
 5T = TypeVar("T")
 6
 7
 8class Stack(Generic[T]):
 9    def __init__(self) -> None:
10        self._container: List[T] = []
11
12    def push(self, item: T) -> None:
13        self._container.append(item)
14
15    def contains(self, item: T) -> bool:  # __contains__ method
16        for curr in self._container:
17            if curr == item:
18                return True
19        return False
20
21
22stack_of_strings = Stack[str]()
23stack_of_strings.push("hello")
24stack_of_strings.push("world")
25pprint(stack_of_strings._container)
26
27to_find: int = 123
28result: bool = stack_of_strings.contains(to_find) # error from mypy
29pprint(result)
30
31to_find: str = "hello"
32result: bool = stack_of_strings.contains(to_find) # no error from mypy
['hello', 'world']
False

Notably, if we define the Stack class as Stack[str], then the T is now bound to str. This means that any type hints in the Stack class that uses T will now be bound to str. This would include the item parameter in the contains method. Consequently, the above code will actually yield an error from mypy:

28: error: Argument 1 to "contains" of "Stack" has incompatible type "int"; expected "str"  [arg-type]
    result: bool = stack.contains(to_find)

Similarly, you can define the Stack class as Stack[int], and the T is now bound to int.

The above example scopes the type variable T to the Stack class in its entirety. Unlike functions, creating a generic class does not automatically scope the type variables across attributes and methods. You have to explicitly define the type hints yourself. Consequently, it is not uncommon to only scope the type variable to some attributes and methods to the generic class. How you scope them depends on the use cases.

References and Further Readings#