Generici a Kotlin

1. Panoramica

In questo articolo, esamineremo i tipi generici nel linguaggio Kotlin .

Sono molto simili a quelli del linguaggio Java, ma i creatori del linguaggio Kotlin hanno cercato di renderli un po 'più intuitivi e comprensibili introducendo parole chiave speciali come out and in.

2. Creazione di classi parametrizzate

Diciamo che vogliamo creare una classe parametrizzata. Possiamo farlo facilmente in linguaggio Kotlin utilizzando tipi generici:

class ParameterizedClass(private val value: A) { fun getValue(): A { return value } }

Possiamo creare un'istanza di tale classe impostando un tipo parametrizzato in modo esplicito quando si utilizza il costruttore:

val parameterizedClass = ParameterizedClass("string-value") val res = parameterizedClass.getValue() assertTrue(res is String)

Fortunatamente, Kotlin può dedurre il tipo generico dal tipo di parametro, quindi possiamo ometterlo quando si utilizza il costruttore:

val parameterizedClass = ParameterizedClass("string-value") val res = parameterizedClass.getValue() assertTrue(res is String)

3. Kotlin fuori e in parole chiave

3.1. La parola chiave Out

Diciamo che vogliamo creare una classe producer che produrrà un risultato di qualche tipo T. A volte; vogliamo assegnare quel valore prodotto a un riferimento che sia di un supertipo di tipo T.

Per ottenere ciò utilizzando Kotlin, dobbiamo utilizzare la parola chiave out sul tipo generico. Significa che possiamo assegnare questo riferimento a uno qualsiasi dei suoi supertipi. Il valore out può essere prodotto solo dalla classe data ma non consumato :

class ParameterizedProducer(private val value: T) { fun get(): T { return value } }

Abbiamo definito una classe ParameterizedProducer che può produrre un valore di tipo T.

Il prossimo; possiamo assegnare un'istanza della classe ParameterizedProducer al riferimento che ne è un supertipo:

val parameterizedProducer = ParameterizedProducer("string") val ref: ParameterizedProducer = parameterizedProducer assertTrue(ref is ParameterizedProducer)

Se il tipo T nella classe ParamaterizedProducer non sarà il tipo out , l'istruzione fornita produrrà un errore del compilatore.

3.2. L' in Chiave

A volte, abbiamo una situazione di significato opposto che abbiamo un riferimento di tipo T e noi vogliamo essere in grado di assegnare al sottotipo di T .

Possiamo usare la in parola chiave sul tipo generico se vogliamo assegnare al riferimento del suo sottotipo. La parola chiave in può essere utilizzata solo sul tipo di parametro che viene consumato, non prodotto :

class ParameterizedConsumer { fun toString(value: T): String { return value.toString() } }

Si dichiara che un toString () metodo sarà consumando solo un valore di tipo T .

Successivamente, possiamo assegnare un riferimento di tipo Numero al riferimento del suo sottotipo - Doppio:

val parameterizedConsumer = ParameterizedConsumer() val ref: ParameterizedConsumer = parameterizedConsumer assertTrue(ref is ParameterizedConsumer)

Se il tipo di T nel ParameterizedCounsumer non sarà l' nel tipo, la data dichiarazione produrrà un errore di compilazione.

4. Digitare Projections

4.1. Copia un array di sottotipi in un array di supertipi

Supponiamo di avere un array di qualche tipo e di voler copiare l'intero array nell'array di Any type. È un'operazione valida, ma per consentire al compilatore di compilare il nostro codice dobbiamo annotare il parametro di input con la parola chiave out .

Ciò consente al compilatore di sapere che l'argomento di input può essere di qualsiasi tipo che sia un sottotipo di Any :

fun copy(from: Array, to: Array) { assert(from.size == to.size) for (i in from.indices) to[i] = from[i] }

Se il parametro from non è del tipo out Any , non saremo in grado di passare un array di un tipo Int come argomento:

val ints: Array = arrayOf(1, 2, 3) val any: Array = arrayOfNulls(3) copy(ints, any) assertEquals(any[0], 1) assertEquals(any[1], 2) assertEquals(any[2], 3)

4.2. Aggiunta di elementi di un sottotipo a un array del suo supertipo

Supponiamo di avere la seguente situazione: abbiamo un array di Any type che è un supertipo di Int e vogliamo aggiungere un elemento Int a questo array. Dobbiamo usare la in parola chiave come una sorta di matrice di destinazione per far conoscere al compilatore che siamo in grado di copiare l'Int valore a questo array :

fun fill(dest: Array, value: Int) { dest[0] = value }

Quindi, possiamo copiare un valore del tipo Int nell'array di Any:

val objects: Array = arrayOfNulls(1) fill(objects, 1) assertEquals(objects[0], 1)

4.3. Proiezioni di stelle

Ci sono situazioni in cui non ci interessa il tipo specifico di valore. Diciamo che vogliamo solo stampare tutti gli elementi di un array e non importa quale sia il tipo degli elementi in questo array.

Per ottenere ciò, possiamo utilizzare una proiezione di stelle:

fun printArray(array: Array) { array.forEach { println(it) } }

Quindi, possiamo passare un array di qualsiasi tipo al metodo printArray () :

val array = arrayOf(1,2,3) printArray(array)

Quando si utilizza il tipo di riferimento della proiezione della stella, possiamo leggere i valori da esso, ma non possiamo scriverli perché causerà un errore di compilazione.

5. Vincoli generici

Let's say that we want to sort an array of elements, and each element type should implement a Comparable interface. We can use the generic constraints to specify that requirement:

fun 
    
      sort(list: List): List { return list.sorted() }
    

In the given example, we defined that all elements T needed to implement the Comparable interface. Otherwise, if we will try to pass a list of elements that do not implement this interface, it will cause a compiler error.

We defined a sort function that takes as an argument a list of elements that implement Comparable, so we can call the sorted() method on it. Let's look at the test case for that method:

val listOfInts = listOf(5,2,3,4,1) val sorted = sort(listOfInts) assertEquals(sorted, listOf(1,2,3,4,5))

We can easily pass a list of Ints because the Int type implements the Comparable interface.

5.1. Multiple Upper Bounds

With the angle bracket notation, we can declare at most one generic upper bound. If a type parameter needs multiple generic upper bounds, then we should use separate where clauses for that particular type parameter. For instance:

fun  sort(xs: List) where T : CharSequence, T : Comparable { // sort the collection in place }

As shown above, the parameter T must implement the CharSequence and Comparable interfaces at the same time. Similarly, we can declare classes with multiple generic upper bounds:

class StringCollection(xs: List) where T : CharSequence, T : Comparable { // omitted }

6. Generics at Runtime

6.1. Type Erasure

As with Java, Kotlin's generics are erased at runtime. That is, an instance of a generic class doesn't preserve its type parameters at runtime.

For example, if we create a Set and put a few strings into it, at runtime we're only able to see it as a Set.

Let's create two Sets with two different type parameters:

val books: Set = setOf("1984", "Brave new world") val primes: Set = setOf(2, 3, 11)

At runtime, the type information for Set and Set will be erased and we see both of them as plain Sets. So, even though it’s perfectly possible to find out at runtime that value is a Set, we can’t tell whether it’s a Set of strings, integers, or something else: that information has been erased.

So, how does Kotlin's compiler prevent us from adding a Non-String into a Set? Or, when we get an element from a Set, how does it know the element is a String?

The answer is simple. The compiler is the one responsible for erasing the type information but before that, it actually knows the books variable contains String elements.

So, every time we get an element from it, the compiler would cast it to a String or when we're gonna add an element into it, the compiler would type check the input.

6.2. Reified Type Parameters

Let's have more fun with generics and create an extension function to filter Collection elements based on their type:

fun  Iterable.filterIsInstance() = filter { it is T } Error: Cannot check for instance of erased type: T

The “it is T” part, for each collection element, checks if the element is an instance of type T, but since the type information has been erased at runtime, we can't reflect on type parameters this way.

Or can we?

The type erasure rule is true in general, but there is one case where we can avoid this limitation: Inline functions. Type parameters of inline functions can be reified, so we can refer to those type parameters at runtime.

The body of inline functions is inlined. That is, the compiler substitutes the body directly into places where the function is called instead of the normal function invocation.

If we declare the previous function as inline and mark the type parameter as reified, then we can access generic type information at runtime:

inline fun  Iterable.filterIsInstance() = filter { it is T }

The inline reification works like a charm:

>> val set = setOf("1984", 2, 3, "Brave new world", 11) >> println(set.filterIsInstance()) [2, 3, 11]

Let's write another example. We all are familiar with those typical SLF4j Logger definitions:

class User { private val log = LoggerFactory.getLogger(User::class.java) // ... }

Using reified inline functions, we can write more elegant and less syntax-horrifying Logger definitions:

inline fun  logger(): Logger = LoggerFactory.getLogger(T::class.java)

Then we can write:

class User { private val log = logger() // ... }

This gives us a cleaner option to implement logging, the Kotlin way.

6.3. Deep Dive into Inline Reification

So, what's so special about inline functions so that type reification only works with them? As we know, Kotlin's compiler copies the bytecode of inline functions into places where the function is called.

Since in each call site, the compiler knows the exact parameter type, it can replace the generic type parameter with the actual type references.

For example, when we write:

class User { private val log = logger() // ... }

When the compiler inlines the logger() function call, it knows the actual generic type parameter –User. So instead of erasing the type information, the compiler seizes the reification opportunity and reifies the actual type parameter.

7. Conclusion

In this article, we were looking at the Kotlin Generic types. We saw how to use the out and in keywords properly. We used type projections and defined a generic method that uses generic constraints.

L'implementazione di tutti questi esempi e frammenti di codice può essere trovata nel progetto GitHub: questo è un progetto Maven, quindi dovrebbe essere facile da importare ed eseguire così com'è.