Generics in Kotlin
In the early days of Java, it was designed such that all lists had the same type List
, instead of specific lists with specific parameter types, like List<String>
or List<Int>
. The List
type in Java accepts all kinds of values; when you ask for a value at a certain position, the result type is Object
(which, in Java, is the supertype of all the types).
// Java
List names = new ArrayList();
names.add("Alex");
names.add("Ben");
names.add(123); // this is incorrect, but compiles
for(int i = 0; i < names.size(); i++){
String name= (String) names.get(i); // exception at i==2
System.out.println(name.toUpperCase());
}
Such lists are hard to use. We much prefer to have a list with specified types of elements. Only then can we be sure that our list contains elements of the correct type, and only then do we not need to explicitly cast these elements when we get them from a list. This was one of the main reasons Java introduced generics in version 5. In Kotlin, we do not have this problem because it was designed with generics support from the beginning, and all lists are generic, so they must specify what kinds of elements they accept. Generics are an important feature of most modern programming languages; so, in this chapter, we will discuss what they are and how we use them in Kotlin.
In Kotlin, we have three kinds of generic elements:
- generic functions,
- generic classes,
- generic interfaces.
Let's discuss them one by one.
Generic functions
Just as we can pass an argument value to a parameter, we can pass a type as a type argument. For this, a function needs to define one or more type parameters inside angle brackets immediately after the fun
keyword. By convention, type parameter names are capitalized. When a function defines a type parameter, we have to specify the type arguments when calling this function. The type parameter is a placeholder for a concrete type; the type argument is the actual type that is used when a function is called. To specify type arguments explicitly, we also use angle brackets.
There is a popular practice that a single type argument is called T
(from "type"); if there are multiple type arguments, they are called T
with consecutive numbers. However, this practice is not a fixed rule, and there are many other conventions for naming type parameters.
When we call a generic function, all its type arguments must be clear for the Kotlin compiler. We can either specify them explicitly, or their values can be inferred from the compiler.
So, how are these type parameters useful? We use them primarily to specify the relationship between the arguments and the result type. For instance, we can express that the result type is the same as an argument type or that we expect two arguments of the same type.
Type parameters for functions are useful for the compiler since they allow it to check and correctly infer types; this makes our programs safer and makes programming more pleasurable for developers. Better parameter types and type suggestions protect us from using illegal operations and let our IDE give us better suggestions.
In the next book, Functional Kotlin, you will see plenty of generic function examples, especially for collection processing. Such functions are really important and useful. But, for now, let's get back to the initial motivation for introducing generics: let's talk about generic classes.
Generic classes
We can make classes generic by adding a type parameter after the class name. Such a type parameter can be used all over the class body, especially to specify properties, parameters, and result types. A type parameter is specified when we define an instance, after which it remains unchanged. Thanks to that, when you declare ValueWithHistory<String>
and then call setValue
in the example below, you must use an object of type String
; when you call currentValue
, the result object will be typed as String
; and when you call history
, its result is of type List<String>
. It’s the same for all other possible type arguments.
The constructor type argument can be inferred. In the above example, we specified it explicitly, but we did not need to. This type can be inferred from the argument type.
Type arguments can also be inferred from variable types. Let's say that we want to use Any
as a type argument. We can specify this by specifying the type of variable letter
as ValueWithHistory<Any>
.
As I mentioned in the introduction to this chapter, the most important motivation for introducing generics was to make collections with certain types of elements. Consider the ArrayList
class from the Standard Library (stdlib). It is generic, so when we create an instance from this class we need to specify the types of elements. Thanks to that, Kotlin protects us by expecting only values with accepted types to be added to the list, and Kotlin uses this type when we operate on the elements in the list.
Generic classes and nullability
Notice that type arguments can be nullable, so we could create ValueWithHistory<String?>
. In such a case, the null
value is a perfectly valid option.
Another thing is that when you use generic parameters inside classes or functions, you can make them nullable by adding a question mark. See the example below. The type T
might or might not be nullable, depending on the type argument, but the type T?
is always nullable. We can assign null
to variables of the type T?
. Nullable generic type parameter T?
must be unpacked before using it as T
.
The opposite can also be expressed. Since a generic type parameter might represent a nullable type (you can have List<Int?>
), we might specify a definitely non-nullable variant of this type by adding & Any
after the type parameter. In the example below, the method orThrow
can be invoked on any value, but it unpacks nullable types into non-nullable ones.
Generic interfaces
Interfaces can also be generic, which has similar consequences as for classes: the specified type parameters can be used inside the interface body as types for properties, parameters, and result types. A good example is the List
interface.
The
out
modifier and theUnsafeVariance
annotation will be explained in the book Advanced Kotlin.
List<String>
type, methods like contains
expect an argument of type String
, and methods like get
declare String
as the result type.List<String>
, methods like filter
can infer String
as a lambda parameter.Generic interfaces are inherited by classes. Let's say that we have a class Dog
that inherits from Consumer<DogFood>
, as shown in the snippet below. The interface Consumer
expects a method consume
with the type parameter T
. This means our Dog
must override the consume
method with an argument of type DogFood
. It must be DogFood
because we implement the Consumer<DogFood>
type, and the parameter type in the interface Consumer
must match the type argument DogFood
. Now, when you have an instance of Dog
, you can up-cast it to Consumer<DogFood>
.
Type parameters and inheritance
Classes can inherit from open generic classes or implement generic interfaces; however, in both cases they must explicitly specify the type argument. Consider the snippet below. Class A
inherits from C<Int>
and implements I<String>
.
It is actually quite common that a non-generic class inherits from a generic one. Consider MessageListAdapter
presented below, which inherits from ArrayAdapter<String>
.
An even more common case is when one generic class or interface inherits from another generic class or interface and uses its type parameter as a type argument of the class it inherits from. In the snippet below, the class A
is generic and uses its type parameter T
as an argument for both C
and I
. This means that if you create A<Int>
, you will be able to up-cast it to C<Int>
or I<Int>
. However, if you create A<String>
, you will be able to up-cast it to C<String>
or to I<String>
.
A good example is the collection hierarchy. An object of type MutableList<Int>
implements List<Int>
, which implements Collection<Int>
, which implements Iterable<Int>
.
However, a class does not need to use its type parameter when inheriting from a generic class or implementing a generic interface. Type parameters of parent and child classes are independent of one another and should not be confused, even if they have the same name.
Type erasure
Generic types were added to Java for developers' convenience, but they were never built into the JVM platform. All type arguments are lost when we compile Kotlin to JVM bytecode1. Under the hood, this means that List<String>
becomes List
, and emptyList<Double>
becomes emptyList
. The process of losing type arguments is known as type erasure. Due to this process, type parameters have some limitations compared to regular types. You cannot use them for is
checks; you cannot reference them2; and you cannot use them as reified type arguments3.
However, Kotlin can overcome these limitations thanks to the use of inline functions with reified type arguments. This topic is covered in depth in the chapter Inline functions in the book Functional Kotlin.
Generic constraints
An important feature of type parameters is that they can be constrained to be a subtype of a concrete type. We set a constraint by placing a supertype after a colon. For instance, let's say that you implement the maxOf
function, which returns the biggest of its arguments. To find the biggest one, the arguments need to be comparable. So, next to the type parameter, we can specify that we accept only types that are a subtype of Comparable<T>
.
Type parameter constraints are also used for generic classes. Consider the ListAdapter
class below, which expects a type argument that is a subtype of ItemAdapter
.
An important result of having a constraint is that instances of this type can use all the methods offered by this type. In this way, when T
is constrained as a subtype of Iterable<Int>
, we know that we can iterate over an instance of type T
, and that elements returned by the iterator will be of type Int
. When we are constrained to Comparable<T>
, we know that this type can be compared with another instance of the same type. Another popular choice for a constraint is Any
, which means that a type can be any non-nullable type.
In rare cases in which we might need to set more than one upper bound, we can use where
to set more constraints. We add it after the class or function name, and we use it to specify more than one generic constraint for a single type.
Star projection
In some cases, we don’t want to specify a concrete type argument for a type. In these scenarios, we can use a star projection *
, which accepts any type. There are two situations where this is useful. The first is when you check if a variable is a list. In this case, you should use the is List<*>
check. Star projection should be used in such a case because of type erasure. If you used List<Int>
, it would be compiled to List
under the hood anyway. This means a list of strings would pass the is List<Int>
check. Such a check would be confusing and is illegal in Kotlin. You must use is List<*>
instead.
Star projection can also be used for properties or parameters. You can use List<*>
when you want to express that you want a list, no matter what the type of its elements. When you get elements from such a list, they are of type Any?
, which is the supertype of all the types.
Star projection should not be confused with the Any?
type argument. To see this, let's compare MutableList<Any?>
and MutableList<*>
. Both of these types declare Any?
as generic result types. However, when elements are added, MutableList<Any?>
accepts anything (Any?
), but MutableList<*>
accepts Nothing
, so it does not accept any values.
When a star projection is used as an argument, it will be treated as Any?
in all the out-positions (result types), and it will be treated as Nothing
in all the in-positions (parameter types).
Underscore operator for type arguments
Type arguments can be either specified explicitly or inferred from the context. However, sometimes we want to specify one type argument and let the compiler infer the other. In such a case, we can use the underscore operator _
as a type argument. This operator specifies that we want to infer a type argument.
Summary
For many developers, generics seem so hard and scary, but they are actually quite simple and intuitive. We can make an element generic by specifying its type parameter (or parameters). Such a type parameter can be used inside this element. This mechanism lets us generalize algorithms and classes so that they can be used with different types. It is good to understand how generics work, which is why this chapter has presented nearly all aspects of this mechanism. However, there are a few more, and we will get back to this topic in the book Advanced Kotlin, where we still need to discuss variance modifiers (out
and in
).
I use JVM as a reference because it is the most popular target for Kotlin, but also because it was the first one, so many Kotlin mechanisms were designed for it. However, regarding a lack of support for type arguments, other platforms are not better. For example, JavaScript does not support types at all.
Class and type references are explained in the book Advanced Kotlin.
Reified type arguments are explained in the book Functional Kotlin.