Generika in Kotlin

1. Übersicht

In diesem Artikel werden wir uns die generischen Typen in der Kotlin-Sprache ansehen .

Sie sind denen aus der Java-Sprache sehr ähnlich, aber die Entwickler der Kotlin-Sprache haben versucht, sie durch die Einführung spezieller Schlüsselwörter wie out und in ein wenig intuitiver und verständlicher zu machen .

2. Parametrisierte Klassen erstellen

Angenommen, wir möchten eine parametrisierte Klasse erstellen. Wir können dies leicht in Kotlin-Sprache tun, indem wir generische Typen verwenden:

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

Wir können eine Instanz einer solchen Klasse erstellen, indem wir bei Verwendung des Konstruktors explizit einen parametrisierten Typ festlegen:

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

Glücklicherweise kann Kotlin den generischen Typ aus dem Parametertyp ableiten, sodass wir dies bei Verwendung des Konstruktors weglassen können:

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

3. Kotlin out und in Keywords

3.1. Das Out- Schlüsselwort

Nehmen wir an, wir möchten eine Produzentenklasse erstellen, die ein Ergebnis vom Typ T erzeugt. Manchmal; Wir wollen diesen erzeugten Wert einer Referenz zuweisen, die von einem Supertyp vom Typ T ist.

Um dies mit Kotlin zu erreichen, müssen wir das Schlüsselwort out für den generischen Typ verwenden. Dies bedeutet, dass wir diesen Verweis jedem seiner Supertypen zuordnen können. Der out-Wert kann nur von der angegebenen Klasse erzeugt, aber nicht verbraucht werden :

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

Wir haben eine ParameterizedProducer- Klasse definiert , die einen Wert vom Typ T erzeugen kann.

Nächster; Wir können der Referenz, die ein Supertyp davon ist, eine Instanz der ParameterizedProducer- Klasse zuweisen :

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

Wenn der Typ T in der ParamaterizedProducer- Klasse nicht der out- Typ ist, erzeugt die angegebene Anweisung einen Compilerfehler.

3.2. Das in Schlüsselwort

Manchmal haben wir eine entgegengesetzte Situation, was bedeutet, dass wir eine Referenz vom Typ T haben und diese dem Subtyp von T zuordnen möchten .

Wir können die Verwendung in Schlüsselwort auf der generischen Art , wenn wir es mit der Referenz seiner Subtyp zuordnen wollen. Das Schlüsselwort in kann nur für den Parametertyp verwendet werden, der verbraucht und nicht erzeugt wird :

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

Wir erklären, dass eine toString () -Methode nur einen Wert vom Typ T verbraucht .

Als nächstes können wir der Referenz seines Subtyps eine Referenz vom Typ Number zuweisen - Double:

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

Wenn der Typ T in dem ParameterizedCounsumer wird nicht das sein , in Typ, wird die gegebene Anweisung eines Compiler - Fehler erzeugen.

4. Geben Sie Projektionen ein

4.1. Kopieren Sie ein Array von Subtypen in ein Array von Supertypen

Angenommen, wir haben ein Array eines Typs und möchten das gesamte Array in ein Array eines beliebigen Typs kopieren . Es ist eine gültige Operation, aber damit der Compiler unseren Code kompilieren kann, müssen wir den Eingabeparameter mit dem Schlüsselwort out versehen .

Dadurch wird dem Compiler mitgeteilt, dass das Eingabeargument von einem beliebigen Typ sein kann, der ein Subtyp von Any ist :

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

Wenn der Parameter from nicht vom Typ out Any ist , können wir kein Array vom Typ Int als Argument übergeben:

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. Hinzufügen von Elementen eines Subtyps zu einem Array seines Supertyps

Angenommen, wir haben die folgende Situation: Wir haben ein Array vom Typ Any , das ein Supertyp von Int ist, und wir möchten diesem Array ein Int- Element hinzufügen . Wir müssen das verwenden in Schlüsselwort als eine Art des Ziel - Arrays der Compiler wissen zu lassen , dass wir das kopieren Int Wert auf dieses Array :

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

Dann können wir einen Wert vom Typ Int in das Array von Any kopieren :

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

4.3. Sternprojektionen

Es gibt Situationen, in denen wir uns nicht um die bestimmte Art von Wert kümmern. Angenommen, wir möchten nur alle Elemente eines Arrays drucken, und es spielt keine Rolle, um welchen Typ der Elemente in diesem Array es sich handelt.

Um dies zu erreichen, können wir eine Sternprojektion verwenden:

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

Dann können wir ein Array eines beliebigen Typs an die printArray () -Methode übergeben:

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

Wenn Sie den Referenztyp für die Sternprojektion verwenden, können wir Werte daraus lesen, aber nicht schreiben, da dies zu einem Kompilierungsfehler führt.

5. Allgemeine Einschränkungen

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.

Die Implementierung all dieser Beispiele und Codefragmente finden Sie im GitHub-Projekt - dies ist ein Maven-Projekt, daher sollte es einfach zu importieren und auszuführen sein, wie es ist.