Дженерици в Котлин

1. Общ преглед

В тази статия ще разгледаме родовите типове в езика Kotlin .

Те много приличат на тези от езика Java, но създателите на езика Kotlin се опитаха да ги направят малко по-интуитивни и разбираеми чрез въвеждане на специални ключови думи като out и in.

2. Създаване на параметризирани класове

Да кажем, че искаме да създадем параметризиран клас. Лесно можем да направим това на език Kotlin, като използваме общи типове:

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

Можем да създадем екземпляр на такъв клас, като зададем параметризиран тип изрично, когато използваме конструктора:

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

За щастие, Kotlin може да изведе родовия тип от типа на параметъра, за да можем да пропуснем това, когато използваме конструктора:

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

3. Котлин навън и в Ключови думи

3.1. Най- Out Ключова дума

Да кажем, че искаме да създадем клас производител, който ще произвежда резултат от някакъв тип Т. Понякога; искаме да присвоим тази произведена стойност на препратка, която е от супертип от тип T.

За да постигнем това с помощта на Kotlin, трябва да използваме ключовата дума out за родовия тип. Това означава, че можем да присвоим тази препратка към всеки от нейните супертипове. Стойността out може да бъде произведена само от дадения клас, но не консумирана :

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

Дефинирахме клас ParameterizedProducer, който може да генерира стойност от тип T.

Следващия; можем да присвоим екземпляр на класа ParameterizedProducer към референцията, която е супертип за него:

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

Ако тип Т в ParamaterizedProducer класа няма да е от вида, даден отчет ще се получи грешка при компилирането.

3.2. В по ключова дума

Понякога имаме обратната ситуация смисъл, че ние имаме справка от тип T и ние искаме да сме в състояние да го възложи на подтип на T .

Можем да използваме ключовата дума in за родовия тип, ако искаме да я присвоим на препратката към нейния подтип. В по ключова дума може да се използва само от вида на параметър, който се консумира, които не са произведени :

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

Ние заявяваме, че ToString () метод ще се отнема само една стойност от тип T .

След това можем да присвоим референция от тип Number към референцията на неговия подтип - Double:

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

Ако типът T в ParameterizedCounsumer няма да бъде типът in , даденият израз ще създаде грешка в компилатора.

4. Тип проекции

4.1. Копирайте масив от подтипове в масив от супертипове

Да кажем, че имаме масив от някакъв тип и искаме да копираме целия масив в масива от всякакъв тип. Това е валидна операция, но за да позволим на компилатора да компилира нашия код, трябва да анотираме входния параметър с ключовата дума out .

Това дава възможност на компилатора да знае, че входният аргумент може да бъде от всякакъв тип, който е подтип на Any :

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

Ако параметърът from не е от типа Any , няма да можем да предадем масив от тип Int като аргумент:

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. Добавяне на елементи от подтип към масив от неговия супертип

Да кажем, че имаме следната ситуация - имаме масив от Any тип, който е супертип на Int и искаме да добавим елемент Int към този масив. Трябва да използваме ключовата дума in като тип на целевия масив, за да уведомим компилатора, че можем да копираме стойността Int в този масив :

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

След това можем да копираме стойност от типа Int в масива на Any:

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

4.3. Прожекции на звезди

Има ситуации, когато не ни интересува конкретният тип ценност. Да кажем, че просто искаме да отпечатаме всички елементи на масив и няма значение какъв е видът на елементите в този масив.

За да постигнем това, можем да използваме проекция на звезда:

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

След това можем да предадем масив от всякакъв тип на метода printArray () :

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

Когато използваме референтен тип проекция на звезда, можем да четем стойности от него, но не можем да ги запишем, защото това ще доведе до грешка при компилацията.

5. Общи ограничения

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.

Изпълнението на всички тези примери и кодови фрагменти може да се намери в проекта GitHub - това е проект на Maven, така че трябва да е лесно да се импортира и да се изпълнява както е.