Unit 20: Generics
After taking this unit, students should:
- know how to define and instantiate a generic type and a generic method
- be familiar with the term parameterized types, type arguments, type parameters
- appreciate how generics can reduce duplication of code and improve type safety
The Pair
class
Sometimes it is useful to have a lightweight class to bundle a pair of variables together. One could, for instance, write a method that returns two values. The example defines a class IntPair
that bundles two int
variables together. This is a utility class with no semantics nor methods associated with it and so, we did not attempt to hide the implementation details.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
This class can be used, for instance, in a function that returns two int
values.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
We could similarly define a pair class for two doubles (DoublePair
), two booleans (BooleanPair
), etc. In other situations, it is useful to define a pair class that bundles two variables of two different types, say, a Customer
and a ServiceCounter
; a String
and an int
; etc.
We should not, however, create one class for each possible combination of types. A better idea is to define a class that stores two Object
references:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
At the cost of using a wrapper class in place of primitive types, we get a single class that can be used to store any type of values.
You might recall that we used a similar approach for our contains
method to implement a general method that works for any type of object. Here, we are using this approach for a general class that encapsulates any type of object.
Unfortunately, the issues we faced with narrowing type conversion and potential run-time errors apply to the Pair
class as well. Suppose that a function returns a Pair
containing a String
and an Integer
, and we accidentally treat this as an Integer
and a String
instead, the compiler will not be able to detect the type mismatch and stop the program from crashing during run-time.
1 2 3 4 5 6 |
|
To reduce the risk of human error, what we need is a way to specify the following: suppose the type of first
is \(S\) and type of second
is \(T\), then we want the return type of getFirst
to be \(S\) and of getSecond
to be \(T\).
Generic Types
In Java and many other programming languages, the mechanism to do this is called generics or templates. Java allows us to define a generic type that takes other types as type parameters, just like how we can write methods that take in variables as parameters.
Declaring a Generic Type
Let's see how we can do this for Pair
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
We declare a generic type by specifying its type parameters between <
and >
when we declare the type. By convention, we use a single capital letter to name each type parameter. These type parameters are scoped within the definition of the type. In the example above, we have a generic class Pair<S,T>
(read "pair of S and T") with S
and T
as type parameters. We use S
and T
as the type of the fields first
and second
. We ensure that getFirst()
returns type S
and getSecond()
returns type T
, so that the compiler will give an error if we mix up the types.
Note that the constructor is still declared as Pair
(without the type parameters).
Using/Instanting a Generic Type
To use a generic type, we have to pass in type arguments, which itself can be a non-generic type, a generic type, or another type parameter that has been declared. Once a generic type is instantiated, it is called a parameterized type.
To avoid potential human errors leading to ClassCastException
in the example above, we can use the generic version of Pair
as follows, taking in two non-generic types:
1 2 3 4 5 6 |
|
With the parameterized type Pair<String,Integer>
, the return type of getFirst
is bound to String
, and the compiler now have enough type information to check and give us an error since we try to cast a String
to an Integer
.
Note that we use Integer
instead of int
, since only reference types can be used as type arguments.
Just like you can pass a parameter of a method to another method, we can pass the type parameter of a generic type to another:
1 2 3 |
|
We define a generic class called DictEntry<T>
with a single type parameter T
that extends from Pair<String,T>
, where String
is the first type argument (in place of S
), while the type parameter T
from DictEntry<T>
is passed as the type argument for T
of Pair<String,T>
.
Generic Methods
Methods can be parameterized with a type parameter as well. Consider the contains
method, which we now put within a class for clarity.
1 2 3 4 5 6 7 8 9 10 11 |
|
While using this method does not involve narrowing type conversion and type casting, it is a little to general -- it allows us to call contains
in a nonsensical way, like this:
1 2 |
|
Searching for an integer within an array of strings is a futile attempt! Let's constrain the type of the object to search for to be the same as the type of the array. We can make this type the parameter to this method:
1 2 3 4 5 6 7 8 9 10 11 |
|
The above shows an example of a generic method. The type parameter T
is declared within <
and >
and is added before the return type of the method. This parameter T
is then scoped within the whole method.
To call a generic method, we need to pass in the type argument placed before the name of the method1. For instance,
1 2 |
|
The code above won't compile since the compiler expects the second argument to also be a String
.
Bounded Type Parameters
Let's now try to apply our newly acquired trick to fix the issue with findLargest
. Recall that we have the following findLargest
method (which we now put into an ad hoc class just for clarity), which requires us to perform a narrowing type conversion to cast from GetAreable
and possibly leading to a run-time error.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
Let's try to make this method generic, by forcing the return type to be the same as the type of the elements in the input array,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
The code above won't compile, since the compiler cannot be sure that it can find the method getArea()
in type T
. In contrast, when we run contains
, we had no issue since we are invoking the method equals
, which exists in any reference type in Java.
Since we intend to use findLargest
only in classes that implement the GetAreable
interface and supports the getArea()
method, we can put a constraint on T
. We can say that T
must be a subtype of GetAreable
when we specify the type parameter:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
We use the keyword extends
here to indicate that T
must be a subtype of GetAreable
. It is unfortunate that Java decides to use the term extends
for any type of subtyping when declaring a bounded type parameter, even if the supertype (such as GetAreable
) is an interface.
We can use bounded type parameters for declaring generic classes as well. For instance, Java has a generic interface Comparable<T>
, which dictates the implementation of the following int compareTo(T t)
for any concrete class that implements the interface. Any class that implements the Comparable<T>
interface can be compared with an instance of type T
to establish an ordering. Such ordering can be useful for sorting objects, for instance.
Suppose we want to compare two Pair
instances, by comparing the first element in the pair, we could do the following:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
Let's look at what it means:
- We declared
Pair
to be a generic type of two type parameters: the first oneS
is bounded and must be a subtype ofComparable<S>
. This bound is self-referential, but it is intuitive -- we say thatS
must be comparable to itself, which is common in many use cases. - Since we want to compare two
Pair
instances, we makePair
implements theComparable
interface too, passing inPair<S,T>
as the type argument toComparable
.
Let's see this in action with Arrays::sort
method, which sorts an array based on the ordering defined by compareTo
.
1 2 3 4 5 6 7 8 9 10 11 12 |
|
You will see the pairs are sorted by the first element.
-
Java actually can infer the type using the type inference mechanism and allows us to skip the type argument, but for clarity, we insist on specifying the type explicitly until students get used to the generic types and reasoning about types. ↩